diff --git a/.ci/azure-pipelines-abi.yml b/.ci/azure-pipelines-abi.yml index 4d38a906e6..e58a2bdc7e 100644 --- a/.ci/azure-pipelines-abi.yml +++ b/.ci/azure-pipelines-abi.yml @@ -7,7 +7,7 @@ parameters: default: "ubuntu-latest" - name: DotNetSdkVersion type: string - default: 3.1.100 + default: 5.0.302 jobs: - job: CompatibilityCheck @@ -62,6 +62,7 @@ jobs: - task: DownloadPipelineArtifact@2 displayName: 'Download Reference Assembly Build Artifact' + enabled: false inputs: source: "specific" artifact: "$(NugetPackageName)" @@ -73,6 +74,7 @@ jobs: - task: CopyFiles@2 displayName: 'Copy Reference Assembly Build Artifact' + enabled: false inputs: sourceFolder: $(System.ArtifactsDirectory)/current-artifacts contents: '**/*.dll' @@ -83,6 +85,7 @@ jobs: - task: DotNetCoreCLI@2 displayName: 'Execute ABI Compatibility Check Tool' + enabled: false inputs: command: custom custom: compat diff --git a/.ci/azure-pipelines-api-client.yml b/.ci/azure-pipelines-api-client.yml deleted file mode 100644 index de6bbf04ce..0000000000 --- a/.ci/azure-pipelines-api-client.yml +++ /dev/null @@ -1,78 +0,0 @@ -parameters: - - name: LinuxImage - type: string - default: "ubuntu-latest" - - name: GeneratorVersion - type: string - default: "5.0.0-beta2" - -jobs: -- job: GenerateApiClients - displayName: 'Generate Api Clients' - dependsOn: Test - - pool: - vmImage: "${{ parameters.LinuxImage }}" - - steps: - - task: DownloadPipelineArtifact@2 - displayName: 'Download OpenAPI Spec Artifact' - inputs: - source: 'current' - artifact: "OpenAPI Spec" - path: "$(System.ArtifactsDirectory)/openapispec" - runVersion: "latest" - - - task: CmdLine@2 - displayName: 'Download OpenApi Generator' - inputs: - script: "wget https://repo1.maven.org/maven2/org/openapitools/openapi-generator-cli/${{ parameters.GeneratorVersion }}/openapi-generator-cli-${{ parameters.GeneratorVersion }}.jar -O openapi-generator-cli.jar" - -## Authenticate with npm registry - - task: npmAuthenticate@0 - inputs: - workingFile: ./.npmrc - customEndpoint: 'jellyfin-bot for NPM' - -## Generate npm api client -# Unstable - - task: CmdLine@2 - displayName: 'Build unstable typescript axios client' - condition: startsWith(variables['Build.SourceBranch'], 'refs/heads/master') - inputs: - script: "bash ./apiclient/templates/typescript/axios/generate.sh $(System.ArtifactsDirectory) $(Build.BuildNumber)" - -# Stable - - task: CmdLine@2 - displayName: 'Build stable typescript axios client' - condition: startsWith(variables['Build.SourceBranch'], 'refs/tags/v') - inputs: - script: "bash ./apiclient/templates/typescript/axios/generate.sh $(System.ArtifactsDirectory)" - -## Run npm install - - task: Npm@1 - displayName: 'Install npm dependencies' - inputs: - command: install - workingDir: ./apiclient/generated/typescript/axios - -## Publish npm packages -# Unstable - - task: Npm@1 - displayName: 'Publish unstable typescript axios client' - condition: startsWith(variables['Build.SourceBranch'], 'refs/heads/master') - inputs: - command: publish - publishRegistry: useFeed - publishFeed: 'jellyfin/unstable' - workingDir: ./apiclient/generated/typescript/axios - -# Stable - - task: Npm@1 - displayName: 'Publish stable typescript axios client' - condition: startsWith(variables['Build.SourceBranch'], 'refs/tags/v') - inputs: - command: publish - publishRegistry: useExternalRegistry - publishEndpoint: 'jellyfin-bot for NPM' - workingDir: ./apiclient/generated/typescript/axios diff --git a/.ci/azure-pipelines-main.yml b/.ci/azure-pipelines-main.yml index 7617f0f5a2..d2c087c14d 100644 --- a/.ci/azure-pipelines-main.yml +++ b/.ci/azure-pipelines-main.yml @@ -1,7 +1,7 @@ parameters: LinuxImage: 'ubuntu-latest' RestoreBuildProjects: 'Jellyfin.Server/Jellyfin.Server.csproj' - DotNetSdkVersion: 3.1.100 + DotNetSdkVersion: 5.0.302 jobs: - job: Build diff --git a/.ci/azure-pipelines-package.yml b/.ci/azure-pipelines-package.yml index 67aac45c9d..543fd7fc6d 100644 --- a/.ci/azure-pipelines-package.yml +++ b/.ci/azure-pipelines-package.yml @@ -22,6 +22,12 @@ jobs: BuildConfiguration: ubuntu.armhf Linux.amd64: BuildConfiguration: linux.amd64 + Linux.amd64-musl: + BuildConfiguration: linux.amd64-musl + Linux.arm64: + BuildConfiguration: linux.arm64 + Linux.armhf: + BuildConfiguration: linux.armhf Windows.amd64: BuildConfiguration: windows.amd64 MacOS: @@ -63,6 +69,7 @@ jobs: sshEndpoint: repository sourceFolder: '$(Build.SourcesDirectory)/deployment/dist' contents: '**' + targetFolder: '/srv/repository/incoming/azure/$(Build.BuildNumber)/$(BuildConfiguration)' - job: OpenAPISpec dependsOn: Test @@ -153,7 +160,6 @@ jobs: dependsOn: - BuildPackage - BuildDocker - condition: and(succeeded('BuildPackage'), succeeded('BuildDocker')) pool: vmImage: 'ubuntu-latest' @@ -166,7 +172,7 @@ jobs: inputs: sshEndpoint: repository runOptions: 'commands' - commands: sudo nohup -n /srv/repository/collect-server.azure.sh /srv/repository/incoming/azure $(Build.BuildNumber) unstable & + commands: nohup sudo /srv/repository/collect-server.azure.sh /srv/repository/incoming/azure $(Build.BuildNumber) unstable & - task: SSH@0 displayName: 'Update Stable Repository' @@ -175,28 +181,42 @@ jobs: inputs: sshEndpoint: repository runOptions: 'commands' - commands: sudo nohup -n /srv/repository/collect-server.azure.sh /srv/repository/incoming/azure $(Build.BuildNumber) & + commands: nohup sudo /srv/repository/collect-server.azure.sh /srv/repository/incoming/azure $(Build.BuildNumber) & - job: PublishNuget displayName: 'Publish NuGet packages' - dependsOn: - - BuildPackage - condition: succeeded('BuildPackage') pool: vmImage: 'ubuntu-latest' + variables: + - name: JellyfinVersion + value: $[replace(variables['Build.SourceBranch'],'refs/tags/v','')] + steps: + - task: UseDotNet@2 + displayName: 'Use .NET 5.0 sdk' + inputs: + packageType: 'sdk' + version: '5.0.x' + - task: DotNetCoreCLI@2 displayName: 'Build Stable Nuget packages' condition: startsWith(variables['Build.SourceBranch'], 'refs/tags/v') inputs: - command: 'pack' - packagesToPack: 'Jellyfin.Data/Jellyfin.Data.csproj;MediaBrowser.Common/MediaBrowser.Common.csproj;MediaBrowser.Controller/MediaBrowser.Controller.csproj;MediaBrowser.Model/MediaBrowser.Model.csproj;Emby.Naming/Emby.Naming.csproj' - versioningScheme: 'off' + command: 'custom' + projects: | + Jellyfin.Data/Jellyfin.Data.csproj + MediaBrowser.Common/MediaBrowser.Common.csproj + MediaBrowser.Controller/MediaBrowser.Controller.csproj + MediaBrowser.Model/MediaBrowser.Model.csproj + Emby.Naming/Emby.Naming.csproj + custom: 'pack' + arguments: -o $(Build.ArtifactStagingDirectory) -p:Version=$(JellyfinVersion) - task: DotNetCoreCLI@2 displayName: 'Build Unstable Nuget packages' + condition: startsWith(variables['Build.SourceBranch'], 'refs/heads/master') inputs: command: 'custom' projects: | @@ -214,18 +234,12 @@ jobs: pathToPublish: $(Build.ArtifactStagingDirectory) artifactName: Jellyfin Nuget Packages - - task: NuGetAuthenticate@0 - displayName: 'Authenticate to stable Nuget feed' - condition: startsWith(variables['Build.SourceBranch'], 'refs/tags/v') - inputs: - nuGetServiceConnections: 'NugetOrg' - - task: NuGetCommand@2 displayName: 'Push Nuget packages to stable feed' condition: startsWith(variables['Build.SourceBranch'], 'refs/tags/v') inputs: command: 'push' - packagesToPush: '$(Build.ArtifactStagingDirectory)/**/*.nupkg;$(Build.ArtifactStagingDirectory)/**/*.snupkg' + packagesToPush: '$(Build.ArtifactStagingDirectory)/**/*.nupkg' nuGetFeedType: 'external' publishFeedCredentials: 'NugetOrg' allowPackageConflicts: true # This ignores an error if the version already exists diff --git a/.ci/azure-pipelines-test.yml b/.ci/azure-pipelines-test.yml index 6a36698b56..7ec4cdad1d 100644 --- a/.ci/azure-pipelines-test.yml +++ b/.ci/azure-pipelines-test.yml @@ -10,7 +10,7 @@ parameters: default: "tests/**/*Tests.csproj" - name: DotNetSdkVersion type: string - default: 3.1.100 + default: 5.0.302 jobs: - job: Test @@ -30,11 +30,11 @@ jobs: # This is required for the SonarCloud analyzer - task: UseDotNet@2 - displayName: "Install .NET Core SDK 2.1" + displayName: "Install .NET SDK 5.x" condition: eq(variables['ImageName'], 'ubuntu-latest') inputs: packageType: sdk - version: '2.1.805' + version: '5.x' - task: UseDotNet@2 displayName: "Update DotNet" @@ -94,5 +94,5 @@ jobs: displayName: 'Publish OpenAPI Artifact' condition: and(succeeded(), eq(variables['Agent.OS'], 'Linux')) inputs: - targetPath: "tests/Jellyfin.Api.Tests/bin/Release/netcoreapp3.1/openapi.json" + targetPath: "tests/Jellyfin.Server.Integration.Tests/bin/Release/net5.0/openapi.json" artifactName: 'OpenAPI Spec' diff --git a/.ci/azure-pipelines.yml b/.ci/azure-pipelines.yml index 5b5a17dea2..4e8b6557b9 100644 --- a/.ci/azure-pipelines.yml +++ b/.ci/azure-pipelines.yml @@ -6,7 +6,7 @@ variables: - name: RestoreBuildProjects value: 'Jellyfin.Server/Jellyfin.Server.csproj' - name: DotNetSdkVersion - value: 3.1.100 + value: 5.0.302 pr: autoCancel: true @@ -61,6 +61,3 @@ jobs: - ${{ if or(startsWith(variables['Build.SourceBranch'], 'refs/tags/v'), startsWith(variables['Build.SourceBranch'], 'refs/heads/master')) }}: - template: azure-pipelines-package.yml - -- ${{ if or(startsWith(variables['Build.SourceBranch'], 'refs/tags/v'), startsWith(variables['Build.SourceBranch'], 'refs/heads/master')) }}: - - template: azure-pipelines-api-client.yml diff --git a/.drone.yml b/.drone.yml deleted file mode 100644 index 87c8e414e9..0000000000 --- a/.drone.yml +++ /dev/null @@ -1,30 +0,0 @@ ---- -kind: pipeline -name: build-debug - -steps: -- name: submodules - image: docker:git - commands: - - git submodule update --init --recursive - -- name: build - image: microsoft/dotnet:2-sdk - commands: - - dotnet publish "Jellyfin.Server" --configuration Debug --output "../ci/ci-debug" - ---- -kind: pipeline -name: build-release - -steps: -- name: submodules - image: docker:git - commands: - - git submodule update --init --recursive - -- name: build - image: microsoft/dotnet:2-sdk - commands: - - dotnet publish "Jellyfin.Server" --configuration Release --output "../ci/ci-release" - diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index e902dc7124..e1900d5833 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,3 +1,4 @@ # Joshua must review all changes to deployment and build.sh +.ci/* @joshuaboniface deployment/* @joshuaboniface build.sh @joshuaboniface diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index d67e1c98bf..5a525267a9 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -17,6 +17,7 @@ assignees: '' - Browser: [e.g. Firefox 72, Chrome 80, Safari 13] - Jellyfin Version: [e.g. 10.4.3, nightly 20191231] - Playback: [Direct Play, Remux, Direct Stream, Transcode] + - Hardware Acceleration: [e.g. none, VAAPI, NVENC, etc.] - Installed Plugins: [e.g. none, Fanart, Anime, etc.] - Reverse Proxy: [e.g. none, nginx, apache, etc.] - Base URL: [e.g. none, yes: /example] @@ -33,7 +34,13 @@ assignees: '' **Expected behavior** -**Logs** +**Server Logs** + + +**FFmpeg Logs** + + +**Browser Console Logs** **Screenshots** diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 0874cae2e3..70bcd49737 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -6,4 +6,10 @@ updates: interval: weekly time: '12:00' open-pull-requests-limit: 10 - + +- package-ecosystem: github-actions + directory: '/' + schedule: + interval: weekly + time: '12:00' + open-pull-requests-limit: 10 diff --git a/.github/stale.yml b/.github/stale.yml index 05892c44dc..cba9c33b2a 100644 --- a/.github/stale.yml +++ b/.github/stale.yml @@ -17,9 +17,13 @@ staleLabel: stale # Comment to post when marking an issue as stale. Set to `false` to disable markComment: > This issue has gone 120 days without comment. To avoid abandoned issues, it will be closed in 21 days if there are no new comments. - + If you're the original submitter of this issue, please comment confirming if this issue still affects you in the latest release or nightlies, 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). # Comment to post when closing a stale issue. Set to `false` to disable closeComment: false + +# Disable automatic closing of pull requests +pulls: + daysUntilClose: false diff --git a/.github/workflows/automation.yml b/.github/workflows/automation.yml new file mode 100644 index 0000000000..20294843d5 --- /dev/null +++ b/.github/workflows/automation.yml @@ -0,0 +1,76 @@ +name: Automation + +on: + push: + branches: + - master + pull_request_target: + issue_comment: + +jobs: + label: + name: Labeling + runs-on: ubuntu-latest + if: ${{ github.repository == 'jellyfin/jellyfin' }} + steps: + - name: Apply label + uses: eps1lon/actions-label-merge-conflict@v2.0.1 + if: ${{ github.event_name == 'push' || github.event_name == 'pull_request_target'}} + with: + dirtyLabel: 'merge conflict' + repoToken: ${{ secrets.JF_BOT_TOKEN }} + + project: + name: Project board + runs-on: ubuntu-latest + if: ${{ github.repository == 'jellyfin/jellyfin' }} + steps: + - name: Remove from 'Current Release' project + uses: alex-page/github-project-automation-plus@v0.8.1 + if: (github.event.pull_request || github.event.issue.pull_request) && !contains(github.event.*.labels.*.name, 'stable backport') + continue-on-error: true + with: + project: Current Release + action: delete + repo-token: ${{ secrets.JF_BOT_TOKEN }} + + - name: Add to 'Release Next' project + uses: alex-page/github-project-automation-plus@v0.8.1 + if: (github.event.pull_request || github.event.issue.pull_request) && github.event.action == 'opened' + continue-on-error: true + with: + project: Release Next + column: In progress + repo-token: ${{ secrets.JF_BOT_TOKEN }} + + - name: Add to 'Current Release' project + uses: alex-page/github-project-automation-plus@v0.8.1 + if: (github.event.pull_request || github.event.issue.pull_request) && !contains(github.event.*.labels.*.name, 'stable backport') + continue-on-error: true + with: + project: Current Release + column: In progress + repo-token: ${{ secrets.JF_BOT_TOKEN }} + + - name: Check number of comments from the team member + if: github.event.issue.pull_request == '' && github.event.comment.author_association == 'MEMBER' + id: member_comments + run: echo "::set-output name=number::$(curl -s ${{ github.event.issue.comments_url }} | jq '.[] | select(.author_association == "MEMBER") | .author_association' | wc -l)" + + - name: Move issue to needs triage + uses: alex-page/github-project-automation-plus@v0.8.1 + if: github.event.issue.pull_request == '' && github.event.comment.author_association == 'MEMBER' && steps.member_comments.outputs.number <= 1 + continue-on-error: true + with: + project: Issue Triage for Main Repo + column: Needs triage + repo-token: ${{ secrets.JF_BOT_TOKEN }} + + - name: Add issue to triage project + uses: alex-page/github-project-automation-plus@v0.8.1 + if: github.event.issue.pull_request == '' && github.event.action == 'opened' + continue-on-error: true + with: + project: Issue Triage for Main Repo + column: Pending response + repo-token: ${{ secrets.JF_BOT_TOKEN }} diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml new file mode 100644 index 0000000000..3e456f9093 --- /dev/null +++ b/.github/workflows/codeql-analysis.yml @@ -0,0 +1,36 @@ +name: "CodeQL" + +on: + push: + branches: [ master ] + pull_request: + branches: [ master ] + schedule: + - cron: '24 2 * * 4' + +jobs: + analyze: + name: Analyze + runs-on: ubuntu-latest + + strategy: + fail-fast: false + matrix: + language: [ 'csharp' ] + + steps: + - name: Checkout repository + uses: actions/checkout@v2 + - name: Setup .NET Core + uses: actions/setup-dotnet@v1 + with: + dotnet-version: '5.0.x' + - name: Initialize CodeQL + uses: github/codeql-action/init@v1 + with: + languages: ${{ matrix.language }} + queries: +security-extended + - name: Autobuild + uses: github/codeql-action/autobuild@v1 + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v1 diff --git a/.github/workflows/commands.yml b/.github/workflows/commands.yml new file mode 100644 index 0000000000..e0b91ecee6 --- /dev/null +++ b/.github/workflows/commands.yml @@ -0,0 +1,119 @@ +name: Commands +on: + issue_comment: + types: + - created + - edited + pull_request_target: + types: + - labeled + - synchronize + +jobs: + rebase: + name: Rebase + if: github.event.issue.pull_request != '' && contains(github.event.comment.body, '@jellyfin-bot rebase') && github.event.comment.author_association == 'MEMBER' + runs-on: ubuntu-latest + steps: + - name: Notify as seen + uses: peter-evans/create-or-update-comment@v1.4.5 + with: + token: ${{ secrets.JF_BOT_TOKEN }} + comment-id: ${{ github.event.comment.id }} + reactions: '+1' + + - name: Checkout the latest code + uses: actions/checkout@v2 + with: + token: ${{ secrets.JF_BOT_TOKEN }} + fetch-depth: 0 + + - name: Automatic Rebase + uses: cirrus-actions/rebase@1.4 + env: + GITHUB_TOKEN: ${{ secrets.JF_BOT_TOKEN }} + + check-backport: + name: Check Backport + if: ${{ ( github.event.issue.pull_request && contains(github.event.comment.body, '@jellyfin-bot check backport') ) || github.event.label.name == 'stable backport' || contains(github.event.pull_request.labels.*.name, 'stable backport' ) }} + runs-on: ubuntu-latest + steps: + - name: Notify as seen + uses: peter-evans/create-or-update-comment@v1.4.5 + if: ${{ github.event.comment != null }} + with: + token: ${{ secrets.JF_BOT_TOKEN }} + comment-id: ${{ github.event.comment.id }} + reactions: eyes + + - name: Checkout the latest code + uses: actions/checkout@v2 + with: + token: ${{ secrets.JF_BOT_TOKEN }} + fetch-depth: 0 + + - name: Notify as running + id: comment_running + uses: peter-evans/create-or-update-comment@v1.4.5 + if: ${{ github.event.comment != null }} + with: + token: ${{ secrets.JF_BOT_TOKEN }} + issue-number: ${{ github.event.issue.number }} + body: | + Running backport tests... + + - name: Perform test backport + id: run_tests + run: | + set +o errexit + git config --global user.name "Jellyfin Bot" + git config --global user.email "team@jellyfin.org" + CURRENT_BRANCH="origin/${GITHUB_HEAD_REF}" + git checkout master + git merge --no-ff ${CURRENT_BRANCH} + MERGE_COMMIT_HASH=$( git log -q -1 | head -1 | awk '{ print $2 }' ) + git fetch --all + CURRENT_STABLE=$( git branch -r | grep 'origin/release' | sort -rV | head -1 | awk -F '/' '{ print $NF }' ) + stable_branch="Current stable release branch: ${CURRENT_STABLE}" + echo ${stable_branch} + echo ::set-output name=branch::${stable_branch} + git checkout -t origin/${CURRENT_STABLE} -b ${CURRENT_STABLE} + git cherry-pick -sx -m1 ${MERGE_COMMIT_HASH} &>output.txt + retcode=$? + cat output.txt | grep -v 'hint:' + output="$( grep -v 'hint:' output.txt )" + output="${output//'%'/'%25'}" + output="${output//$'\n'/'%0A'}" + output="${output//$'\r'/'%0D'}" + echo ::set-output name=output::$output + exit ${retcode} + + - name: Notify with result success + uses: peter-evans/create-or-update-comment@v1.4.5 + if: ${{ github.event.comment != null && success() }} + with: + token: ${{ secrets.JF_BOT_TOKEN }} + comment-id: ${{ steps.comment_running.outputs.comment-id }} + body: | + ${{ steps.run_tests.outputs.branch }} + Output from `git cherry-pick`: + + --- + + ${{ steps.run_tests.outputs.output }} + reactions: hooray + + - name: Notify with result failure + uses: peter-evans/create-or-update-comment@v1.4.5 + if: ${{ github.event.comment != null && failure() }} + with: + token: ${{ secrets.JF_BOT_TOKEN }} + comment-id: ${{ steps.comment_running.outputs.comment-id }} + body: | + ${{ steps.run_tests.outputs.branch }} + Output from `git cherry-pick`: + + --- + + ${{ steps.run_tests.outputs.output }} + reactions: confused diff --git a/.gitignore b/.gitignore index 7cd3d0068a..252210e57c 100644 --- a/.gitignore +++ b/.gitignore @@ -268,6 +268,7 @@ doc/ # Deployment artifacts dist *.exe +*.dll # BenchmarkDotNet artifacts BenchmarkDotNet.Artifacts diff --git a/.vscode/launch.json b/.vscode/launch.json index 05f60cfa69..e55ea22485 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -6,7 +6,7 @@ "type": "coreclr", "request": "launch", "preLaunchTask": "build", - "program": "${workspaceFolder}/Jellyfin.Server/bin/Debug/netcoreapp3.1/jellyfin.dll", + "program": "${workspaceFolder}/Jellyfin.Server/bin/Debug/net5.0/jellyfin.dll", "args": [], "cwd": "${workspaceFolder}/Jellyfin.Server", "console": "internalConsole", @@ -22,7 +22,7 @@ "type": "coreclr", "request": "launch", "preLaunchTask": "build", - "program": "${workspaceFolder}/Jellyfin.Server/bin/Debug/netcoreapp3.1/jellyfin.dll", + "program": "${workspaceFolder}/Jellyfin.Server/bin/Debug/net5.0/jellyfin.dll", "args": ["--nowebclient"], "cwd": "${workspaceFolder}/Jellyfin.Server", "console": "internalConsole", diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index 7b4772730a..c2d2aff341 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -7,6 +7,7 @@ - [anthonylavado](https://github.com/anthonylavado) - [Artiume](https://github.com/Artiume) - [AThomsen](https://github.com/AThomsen) + - [barongreenback](https://github.com/BaronGreenback) - [barronpm](https://github.com/barronpm) - [bilde2910](https://github.com/bilde2910) - [bfayers](https://github.com/bfayers) @@ -16,6 +17,7 @@ - [bugfixin](https://github.com/bugfixin) - [chaosinnovator](https://github.com/chaosinnovator) - [ckcr4lyf](https://github.com/ckcr4lyf) + - [cocool97](https://github.com/cocool97) - [ConfusedPolarBear](https://github.com/ConfusedPolarBear) - [crankdoofus](https://github.com/crankdoofus) - [crobibero](https://github.com/crobibero) @@ -48,6 +50,7 @@ - [h1nk](https://github.com/h1nk) - [hawken93](https://github.com/hawken93) - [HelloWorld017](https://github.com/HelloWorld017) + - [ikomhoog](https://github.com/ikomhoog) - [jftuga](https://github.com/jftuga) - [joern-h](https://github.com/joern-h) - [joshuaboniface](https://github.com/joshuaboniface) @@ -67,6 +70,7 @@ - [marius-luca-87](https://github.com/marius-luca-87) - [mark-monteiro](https://github.com/mark-monteiro) - [Matt07211](https://github.com/Matt07211) + - [Maxr1998](https://github.com/Maxr1998) - [mcarlton00](https://github.com/mcarlton00) - [mitchfizz05](https://github.com/mitchfizz05) - [MrTimscampi](https://github.com/MrTimscampi) @@ -78,6 +82,8 @@ - [Nickbert7](https://github.com/Nickbert7) - [nvllsvm](https://github.com/nvllsvm) - [nyanmisaka](https://github.com/nyanmisaka) + - [OancaAndrei](https://github.com/OancaAndrei) + - [obradovichv](https://github.com/obradovichv) - [oddstr13](https://github.com/oddstr13) - [orryverducci](https://github.com/orryverducci) - [petermcneil](https://github.com/petermcneil) @@ -101,9 +107,11 @@ - [shemanaev](https://github.com/shemanaev) - [skaro13](https://github.com/skaro13) - [sl1288](https://github.com/sl1288) + - [Smith00101010](https://github.com/Smith00101010) - [sorinyo2004](https://github.com/sorinyo2004) - [sparky8251](https://github.com/sparky8251) - [spookbits](https://github.com/spookbits) + - [ssenart](https://github.com/ssenart) - [stanionascu](https://github.com/stanionascu) - [stevehayles](https://github.com/stevehayles) - [SuperSandro2000](https://github.com/SuperSandro2000) @@ -138,6 +146,8 @@ - [Pusta](https://github.com/pusta) - [nielsvanvelzen](https://github.com/nielsvanvelzen) - [skyfrk](https://github.com/skyfrk) + - [ianjazz246](https://github.com/ianjazz246) + - [peterspenler](https://github.com/peterspenler) # Emby Contributors @@ -202,3 +212,4 @@ - [Tim Hobbs](https://github.com/timhobbs) - [SvenVandenbrande](https://github.com/SvenVandenbrande) - [olsh](https://github.com/olsh) + - [gnuyent](https://github.com/gnuyent) diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000000..b899999efb --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,14 @@ + + + + + enable + true + $(MSBuildThisFileDirectory)/jellyfin.ruleset + + + + AllEnabledByDefault + + + diff --git a/Dockerfile b/Dockerfile index 69af9b77b5..0859fdc4c8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,22 +1,14 @@ -ARG DOTNET_VERSION=3.1 +ARG DOTNET_VERSION=5.0 -FROM node:alpine as web-builder +FROM node:lts-alpine as web-builder ARG JELLYFIN_WEB_VERSION=master -RUN apk add curl git zlib zlib-dev autoconf g++ make libpng-dev gifsicle alpine-sdk automake libtool make gcc musl-dev nasm python \ +RUN apk add curl git zlib zlib-dev autoconf g++ make libpng-dev gifsicle alpine-sdk automake libtool make gcc musl-dev nasm python3 \ && curl -L https://github.com/jellyfin/jellyfin-web/archive/${JELLYFIN_WEB_VERSION}.tar.gz | tar zxf - \ && cd jellyfin-web-* \ - && yarn install \ + && npm ci --no-audit --unsafe-perm \ && mv dist /dist -FROM mcr.microsoft.com/dotnet/core/sdk:${DOTNET_VERSION}-buster as builder -WORKDIR /repo -COPY . . -ENV DOTNET_CLI_TELEMETRY_OPTOUT=1 -# because of changes in docker and systemd we need to not build in parallel at the moment -# see https://success.docker.com/article/how-to-reserve-resource-temporarily-unavailable-errors-due-to-tasksmax-setting -RUN dotnet publish Jellyfin.Server --disable-parallel --configuration Release --output="/jellyfin" --self-contained --runtime linux-x64 "-p:DebugSymbols=false;DebugType=none" - -FROM debian:buster-slim +FROM debian:buster-slim as app # https://askubuntu.com/questions/972516/debian-frontend-environment-variable ARG DEBIAN_FRONTEND="noninteractive" @@ -25,10 +17,14 @@ ARG APT_KEY_DONT_WARN_ON_DANGEROUS_USAGE=DontWarn # https://github.com/NVIDIA/nvidia-docker/wiki/Installation-(Native-GPU-Support) ENV NVIDIA_DRIVER_CAPABILITIES="compute,video,utility" -COPY --from=builder /jellyfin /jellyfin -COPY --from=web-builder /dist /jellyfin/jellyfin-web +# https://github.com/intel/compute-runtime/releases +ARG GMMLIB_VERSION=20.3.2 +ARG IGC_VERSION=1.0.5435 +ARG NEO_VERSION=20.46.18421 +ARG LEVEL_ZERO_VERSION=1.0.18421 + # Install dependencies: -# mesa-va-drivers: needed for AMD VAAPI +# mesa-va-drivers: needed for AMD VAAPI. Mesa >= 20.1 is required for HEVC transcoding. RUN apt-get update \ && apt-get install --no-install-recommends --no-install-suggests -y ca-certificates gnupg wget apt-transport-https \ && wget -O - https://repo.jellyfin.org/jellyfin_team.gpg.key | apt-key add - \ @@ -39,6 +35,20 @@ RUN apt-get update \ jellyfin-ffmpeg \ openssl \ locales \ +# Intel VAAPI Tone mapping dependencies: +# Prefer NEO to Beignet since the latter one doesn't support Comet Lake or newer for now. +# Do not use the intel-opencl-icd package from repo since they will not build with RELEASE_WITH_REGKEYS enabled. + && mkdir intel-compute-runtime \ + && cd intel-compute-runtime \ + && wget https://github.com/intel/compute-runtime/releases/download/${NEO_VERSION}/intel-gmmlib_${GMMLIB_VERSION}_amd64.deb \ + && wget https://github.com/intel/intel-graphics-compiler/releases/download/igc-${IGC_VERSION}/intel-igc-core_${IGC_VERSION}_amd64.deb \ + && wget https://github.com/intel/intel-graphics-compiler/releases/download/igc-${IGC_VERSION}/intel-igc-opencl_${IGC_VERSION}_amd64.deb \ + && wget https://github.com/intel/compute-runtime/releases/download/${NEO_VERSION}/intel-opencl_${NEO_VERSION}_amd64.deb \ + && wget https://github.com/intel/compute-runtime/releases/download/${NEO_VERSION}/intel-ocloc_${NEO_VERSION}_amd64.deb \ + && wget https://github.com/intel/compute-runtime/releases/download/${NEO_VERSION}/intel-level-zero-gpu_${LEVEL_ZERO_VERSION}_amd64.deb \ + && dpkg -i *.deb \ + && cd .. \ + && rm -rf intel-compute-runtime \ && apt-get remove gnupg wget apt-transport-https -y \ && apt-get clean autoclean -y \ && apt-get autoremove -y \ @@ -52,6 +62,19 @@ ENV LC_ALL en_US.UTF-8 ENV LANG en_US.UTF-8 ENV LANGUAGE en_US:en +FROM mcr.microsoft.com/dotnet/sdk:${DOTNET_VERSION} as builder +WORKDIR /repo +COPY . . +ENV DOTNET_CLI_TELEMETRY_OPTOUT=1 +# because of changes in docker and systemd we need to not build in parallel at the moment +# see https://success.docker.com/article/how-to-reserve-resource-temporarily-unavailable-errors-due-to-tasksmax-setting +RUN dotnet publish Jellyfin.Server --disable-parallel --configuration Release --output="/jellyfin" --self-contained --runtime linux-x64 "-p:DebugSymbols=false;DebugType=none" + +FROM app + +COPY --from=builder /jellyfin /jellyfin +COPY --from=web-builder /dist /jellyfin/jellyfin-web + EXPOSE 8096 VOLUME /cache /config /media ENTRYPOINT ["./jellyfin/jellyfin", \ diff --git a/Dockerfile.arm b/Dockerfile.arm index efeed25dff..cc0c79c94f 100644 --- a/Dockerfile.arm +++ b/Dockerfile.arm @@ -2,30 +2,19 @@ ##################################### # Requires binfm_misc registration # https://github.com/multiarch/qemu-user-static#binfmt_misc-register -ARG DOTNET_VERSION=3.1 +ARG DOTNET_VERSION=5.0 -FROM node:alpine as web-builder +FROM node:lts-alpine as web-builder ARG JELLYFIN_WEB_VERSION=master -RUN apk add curl git zlib zlib-dev autoconf g++ make libpng-dev gifsicle alpine-sdk automake libtool make gcc musl-dev nasm python \ +RUN apk add curl git zlib zlib-dev autoconf g++ make libpng-dev gifsicle alpine-sdk automake libtool make gcc musl-dev nasm python3 \ && curl -L https://github.com/jellyfin/jellyfin-web/archive/${JELLYFIN_WEB_VERSION}.tar.gz | tar zxf - \ && cd jellyfin-web-* \ - && yarn install \ + && npm ci --no-audit --unsafe-perm \ && mv dist /dist - -FROM mcr.microsoft.com/dotnet/core/sdk:${DOTNET_VERSION} as builder -WORKDIR /repo -COPY . . -ENV DOTNET_CLI_TELEMETRY_OPTOUT=1 -# Discard objs - may cause failures if exists -RUN find . -type d -name obj | xargs -r rm -r -# Build -RUN dotnet publish Jellyfin.Server --configuration Release --output="/jellyfin" --self-contained --runtime linux-arm "-p:DebugSymbols=false;DebugType=none" - - FROM multiarch/qemu-user-static:x86_64-arm as qemu -FROM arm32v7/debian:buster-slim +FROM arm32v7/debian:buster-slim as app # https://askubuntu.com/questions/972516/debian-frontend-environment-variable ARG DEBIAN_FRONTEND="noninteractive" @@ -61,14 +50,25 @@ RUN apt-get update \ && chmod 777 /cache /config /media \ && sed -i -e 's/# en_US.UTF-8 UTF-8/en_US.UTF-8 UTF-8/' /etc/locale.gen && locale-gen -COPY --from=builder /jellyfin /jellyfin -COPY --from=web-builder /dist /jellyfin/jellyfin-web - ENV DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1 ENV LC_ALL en_US.UTF-8 ENV LANG en_US.UTF-8 ENV LANGUAGE en_US:en +FROM mcr.microsoft.com/dotnet/sdk:${DOTNET_VERSION} as builder +WORKDIR /repo +COPY . . +ENV DOTNET_CLI_TELEMETRY_OPTOUT=1 +# Discard objs - may cause failures if exists +RUN find . -type d -name obj | xargs -r rm -r +# Build +RUN dotnet publish Jellyfin.Server --configuration Release --output="/jellyfin" --self-contained --runtime linux-arm "-p:DebugSymbols=false;DebugType=none" + +FROM app + +COPY --from=builder /jellyfin /jellyfin +COPY --from=web-builder /dist /jellyfin/jellyfin-web + EXPOSE 8096 VOLUME /cache /config /media ENTRYPOINT ["./jellyfin/jellyfin", \ diff --git a/Dockerfile.arm64 b/Dockerfile.arm64 index 1f2c2ec36d..64367a32da 100644 --- a/Dockerfile.arm64 +++ b/Dockerfile.arm64 @@ -2,29 +2,19 @@ ##################################### # Requires binfm_misc registration # https://github.com/multiarch/qemu-user-static#binfmt_misc-register -ARG DOTNET_VERSION=3.1 +ARG DOTNET_VERSION=5.0 -FROM node:alpine as web-builder +FROM node:lts-alpine as web-builder ARG JELLYFIN_WEB_VERSION=master -RUN apk add curl git zlib zlib-dev autoconf g++ make libpng-dev gifsicle alpine-sdk automake libtool make gcc musl-dev nasm python \ +RUN apk add curl git zlib zlib-dev autoconf g++ make libpng-dev gifsicle alpine-sdk automake libtool make gcc musl-dev nasm python3 \ && curl -L https://github.com/jellyfin/jellyfin-web/archive/${JELLYFIN_WEB_VERSION}.tar.gz | tar zxf - \ && cd jellyfin-web-* \ - && yarn install \ + && npm ci --no-audit --unsafe-perm \ && mv dist /dist - -FROM mcr.microsoft.com/dotnet/core/sdk:${DOTNET_VERSION} as builder -WORKDIR /repo -COPY . . -ENV DOTNET_CLI_TELEMETRY_OPTOUT=1 -# Discard objs - may cause failures if exists -RUN find . -type d -name obj | xargs -r rm -r -# Build -RUN dotnet publish Jellyfin.Server --configuration Release --output="/jellyfin" --self-contained --runtime linux-arm64 "-p:DebugSymbols=false;DebugType=none" - FROM multiarch/qemu-user-static:x86_64-aarch64 as qemu -FROM arm64v8/debian:buster-slim +FROM arm64v8/debian:buster-slim as app # https://askubuntu.com/questions/972516/debian-frontend-environment-variable ARG DEBIAN_FRONTEND="noninteractive" @@ -50,14 +40,25 @@ RUN apt-get update && apt-get install --no-install-recommends --no-install-sugge && chmod 777 /cache /config /media \ && sed -i -e 's/# en_US.UTF-8 UTF-8/en_US.UTF-8 UTF-8/' /etc/locale.gen && locale-gen -COPY --from=builder /jellyfin /jellyfin -COPY --from=web-builder /dist /jellyfin/jellyfin-web - ENV DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1 ENV LC_ALL en_US.UTF-8 ENV LANG en_US.UTF-8 ENV LANGUAGE en_US:en +FROM mcr.microsoft.com/dotnet/sdk:${DOTNET_VERSION} as builder +WORKDIR /repo +COPY . . +ENV DOTNET_CLI_TELEMETRY_OPTOUT=1 +# Discard objs - may cause failures if exists +RUN find . -type d -name obj | xargs -r rm -r +# Build +RUN dotnet publish Jellyfin.Server --configuration Release --output="/jellyfin" --self-contained --runtime linux-arm64 "-p:DebugSymbols=false;DebugType=none" + +FROM app + +COPY --from=builder /jellyfin /jellyfin +COPY --from=web-builder /dist /jellyfin/jellyfin-web + EXPOSE 8096 VOLUME /cache /config /media ENTRYPOINT ["./jellyfin/jellyfin", \ diff --git a/DvdLib/DvdLib.csproj b/DvdLib/DvdLib.csproj index 64d041cb05..b8301e2f27 100644 --- a/DvdLib/DvdLib.csproj +++ b/DvdLib/DvdLib.csproj @@ -10,10 +10,11 @@ - netstandard2.1 + net5.0 false true - true + AllDisabledByDefault + disable diff --git a/DvdLib/Ifo/Dvd.cs b/DvdLib/Ifo/Dvd.cs index 361319625c..b4a11ed5d6 100644 --- a/DvdLib/Ifo/Dvd.cs +++ b/DvdLib/Ifo/Dvd.cs @@ -31,7 +31,7 @@ namespace DvdLib.Ifo continue; } - var nums = ifo.Name.Split(new[] { '_' }, StringSplitOptions.RemoveEmptyEntries); + var nums = ifo.Name.Split('_', StringSplitOptions.RemoveEmptyEntries); if (nums.Length >= 2 && ushort.TryParse(nums[1], out var ifoNumber)) { ReadVTS(ifoNumber, ifo.FullName); diff --git a/Emby.Dlna/Common/Argument.cs b/Emby.Dlna/Common/Argument.cs index f375e6049c..e4e9c55e0d 100644 --- a/Emby.Dlna/Common/Argument.cs +++ b/Emby.Dlna/Common/Argument.cs @@ -1,13 +1,23 @@ -#pragma warning disable CS1591 - namespace Emby.Dlna.Common { + /// + /// DLNA Query parameter type, used when querying DLNA devices via SOAP. + /// public class Argument { - public string Name { get; set; } + /// + /// Gets or sets name of the DLNA argument. + /// + public string Name { get; set; } = string.Empty; - public string Direction { get; set; } + /// + /// Gets or sets the direction of the parameter. + /// + public string Direction { get; set; } = string.Empty; - public string RelatedStateVariable { get; set; } + /// + /// Gets or sets the related DLNA state variable for this argument. + /// + public string RelatedStateVariable { get; set; } = string.Empty; } } diff --git a/Emby.Dlna/Common/DeviceIcon.cs b/Emby.Dlna/Common/DeviceIcon.cs index c3f7fa8aaa..f9fd1dcec6 100644 --- a/Emby.Dlna/Common/DeviceIcon.cs +++ b/Emby.Dlna/Common/DeviceIcon.cs @@ -1,29 +1,41 @@ -#pragma warning disable CS1591 - using System.Globalization; namespace Emby.Dlna.Common { + /// + /// Defines the . + /// public class DeviceIcon { - public string Url { get; set; } + /// + /// Gets or sets the Url. + /// + public string Url { get; set; } = string.Empty; - public string MimeType { get; set; } + /// + /// Gets or sets the MimeType. + /// + public string MimeType { get; set; } = string.Empty; + /// + /// Gets or sets the Width. + /// public int Width { get; set; } + /// + /// Gets or sets the Height. + /// public int Height { get; set; } - public string Depth { get; set; } + /// + /// Gets or sets the Depth. + /// + public string Depth { get; set; } = string.Empty; /// public override string ToString() { - return string.Format( - CultureInfo.InvariantCulture, - "{0}x{1}", - Height, - Width); + return string.Format(CultureInfo.InvariantCulture, "{0}x{1}", Height, Width); } } } diff --git a/Emby.Dlna/Common/DeviceService.cs b/Emby.Dlna/Common/DeviceService.cs index 44c0a0412a..c1369558ec 100644 --- a/Emby.Dlna/Common/DeviceService.cs +++ b/Emby.Dlna/Common/DeviceService.cs @@ -1,21 +1,36 @@ -#pragma warning disable CS1591 - namespace Emby.Dlna.Common { + /// + /// Defines the . + /// public class DeviceService { - public string ServiceType { get; set; } + /// + /// Gets or sets the Service Type. + /// + public string ServiceType { get; set; } = string.Empty; - public string ServiceId { get; set; } + /// + /// Gets or sets the Service Id. + /// + public string ServiceId { get; set; } = string.Empty; - public string ScpdUrl { get; set; } + /// + /// Gets or sets the Scpd Url. + /// + public string ScpdUrl { get; set; } = string.Empty; - public string ControlUrl { get; set; } + /// + /// Gets or sets the Control Url. + /// + public string ControlUrl { get; set; } = string.Empty; - public string EventSubUrl { get; set; } + /// + /// Gets or sets the EventSubUrl. + /// + public string EventSubUrl { get; set; } = string.Empty; /// - public override string ToString() - => ServiceId; + public override string ToString() => ServiceId; } } diff --git a/Emby.Dlna/Common/ServiceAction.cs b/Emby.Dlna/Common/ServiceAction.cs index d458d7f3f6..02b81a0aa7 100644 --- a/Emby.Dlna/Common/ServiceAction.cs +++ b/Emby.Dlna/Common/ServiceAction.cs @@ -1,24 +1,31 @@ -#pragma warning disable CS1591 - using System.Collections.Generic; namespace Emby.Dlna.Common { + /// + /// Defines the . + /// public class ServiceAction { + /// + /// Initializes a new instance of the class. + /// public ServiceAction() { ArgumentList = new List(); } - public string Name { get; set; } + /// + /// Gets or sets the name of the action. + /// + public string Name { get; set; } = string.Empty; + /// + /// Gets the ArgumentList. + /// public List ArgumentList { get; } /// - public override string ToString() - { - return Name; - } + public override string ToString() => Name; } } diff --git a/Emby.Dlna/Common/StateVariable.cs b/Emby.Dlna/Common/StateVariable.cs index 6daf7ab6b2..fd733e0853 100644 --- a/Emby.Dlna/Common/StateVariable.cs +++ b/Emby.Dlna/Common/StateVariable.cs @@ -1,27 +1,34 @@ -#pragma warning disable CS1591 - using System; using System.Collections.Generic; namespace Emby.Dlna.Common { + /// + /// Defines the . + /// public class StateVariable { - public StateVariable() - { - AllowedValues = Array.Empty(); - } - - public string Name { get; set; } + /// + /// Gets or sets the name of the state variable. + /// + public string Name { get; set; } = string.Empty; - public string DataType { get; set; } + /// + /// Gets or sets the data type of the state variable. + /// + public string DataType { get; set; } = string.Empty; + /// + /// Gets or sets a value indicating whether it sends events. + /// public bool SendsEvents { get; set; } - public IReadOnlyList AllowedValues { get; set; } + /// + /// Gets or sets the allowed values range. + /// + public IReadOnlyList AllowedValues { get; set; } = Array.Empty(); /// - public override string ToString() - => Name; + public override string ToString() => Name; } } diff --git a/Emby.Dlna/Configuration/DlnaOptions.cs b/Emby.Dlna/Configuration/DlnaOptions.cs index 6dd9a445a8..91fac4bef5 100644 --- a/Emby.Dlna/Configuration/DlnaOptions.cs +++ b/Emby.Dlna/Configuration/DlnaOptions.cs @@ -2,8 +2,14 @@ namespace Emby.Dlna.Configuration { + /// + /// The DlnaOptions class contains the user definable parameters for the dlna subsystems. + /// public class DlnaOptions { + /// + /// Initializes a new instance of the class. + /// public DlnaOptions() { EnablePlayTo = true; @@ -11,23 +17,76 @@ namespace Emby.Dlna.Configuration BlastAliveMessages = true; SendOnlyMatchedHost = true; ClientDiscoveryIntervalSeconds = 60; - BlastAliveMessageIntervalSeconds = 1800; + AliveMessageIntervalSeconds = 1800; } + /// + /// Gets or sets a value indicating whether gets or sets a value to indicate the status of the dlna playTo subsystem. + /// public bool EnablePlayTo { get; set; } + /// + /// Gets or sets a value indicating whether gets or sets a value to indicate the status of the dlna server subsystem. + /// public bool EnableServer { get; set; } + /// + /// Gets or sets a value indicating whether detailed dlna server logs are sent to the console/log. + /// If the setting "Emby.Dlna": "Debug" msut be set in logging.default.json for this property to work. + /// public bool EnableDebugLog { get; set; } - public bool BlastAliveMessages { get; set; } - - public bool SendOnlyMatchedHost { get; set; } + /// + /// Gets or sets a value indicating whether whether detailed playTo debug logs are sent to the console/log. + /// If the setting "Emby.Dlna.PlayTo": "Debug" msut be set in logging.default.json for this property to work. + /// + public bool EnablePlayToTracing { get; set; } + /// + /// Gets or sets the ssdp client discovery interval time (in seconds). + /// This is the time after which the server will send a ssdp search request. + /// public int ClientDiscoveryIntervalSeconds { get; set; } - public int BlastAliveMessageIntervalSeconds { get; set; } + /// + /// Gets or sets the frequency at which ssdp alive notifications are transmitted. + /// + public int AliveMessageIntervalSeconds { get; set; } + + /// + /// Gets or sets the frequency at which ssdp alive notifications are transmitted. MIGRATING - TO BE REMOVED ONCE WEB HAS BEEN ALTERED. + /// + public int BlastAliveMessageIntervalSeconds + { + get + { + return AliveMessageIntervalSeconds; + } + + set + { + AliveMessageIntervalSeconds = value; + } + } + + /// + /// Gets or sets the default user account that the dlna server uses. + /// + public string? DefaultUserId { get; set; } + + /// + /// Gets or sets a value indicating whether playTo device profiles should be created. + /// + public bool AutoCreatePlayToProfiles { get; set; } + + /// + /// Gets or sets a value indicating whether to blast alive messages. + /// + public bool BlastAliveMessages { get; set; } = true; - public string DefaultUserId { get; set; } + /// + /// gets or sets a value indicating whether to send only matched host. + /// + public bool SendOnlyMatchedHost { get; set; } = true; } } diff --git a/Emby.Dlna/ConfigurationExtension.cs b/Emby.Dlna/ConfigurationExtension.cs index fc02e17515..3ca43052a4 100644 --- a/Emby.Dlna/ConfigurationExtension.cs +++ b/Emby.Dlna/ConfigurationExtension.cs @@ -1,4 +1,3 @@ -#nullable enable #pragma warning disable CS1591 using Emby.Dlna.Configuration; diff --git a/Emby.Dlna/ConnectionManager/ConnectionManagerService.cs b/Emby.Dlna/ConnectionManager/ConnectionManagerService.cs index f5a7eca720..916044a0cc 100644 --- a/Emby.Dlna/ConnectionManager/ConnectionManagerService.cs +++ b/Emby.Dlna/ConnectionManager/ConnectionManagerService.cs @@ -9,11 +9,21 @@ using Microsoft.Extensions.Logging; namespace Emby.Dlna.ConnectionManager { + /// + /// Defines the . + /// public class ConnectionManagerService : BaseService, IConnectionManager { private readonly IDlnaManager _dlna; private readonly IServerConfigurationManager _config; + /// + /// Initializes a new instance of the class. + /// + /// The for use with the instance. + /// The for use with the instance. + /// The for use with the instance.. + /// The for use with the instance.. public ConnectionManagerService( IDlnaManager dlna, IServerConfigurationManager config, @@ -28,7 +38,7 @@ namespace Emby.Dlna.ConnectionManager /// public string GetServiceXml() { - return new ConnectionManagerXmlBuilder().GetXml(); + return ConnectionManagerXmlBuilder.GetXml(); } /// diff --git a/Emby.Dlna/ConnectionManager/ConnectionManagerXmlBuilder.cs b/Emby.Dlna/ConnectionManager/ConnectionManagerXmlBuilder.cs index c8db5a3674..c484dac542 100644 --- a/Emby.Dlna/ConnectionManager/ConnectionManagerXmlBuilder.cs +++ b/Emby.Dlna/ConnectionManager/ConnectionManagerXmlBuilder.cs @@ -6,45 +6,57 @@ using Emby.Dlna.Service; namespace Emby.Dlna.ConnectionManager { - public class ConnectionManagerXmlBuilder + /// + /// Defines the . + /// + public static class ConnectionManagerXmlBuilder { - public string GetXml() + /// + /// Gets the ConnectionManager:1 service template. + /// See http://upnp.org/specs/av/UPnP-av-ConnectionManager-v1-Service.pdf. + /// + /// An XML description of this service. + public static string GetXml() { - return new ServiceXmlBuilder().GetXml(new ServiceActionListBuilder().GetActions(), GetStateVariables()); + return new ServiceXmlBuilder().GetXml(ServiceActionListBuilder.GetActions(), GetStateVariables()); } + /// + /// Get the list of state variables for this invocation. + /// + /// The . private static IEnumerable GetStateVariables() { - var list = new List(); - - list.Add(new StateVariable + var list = new List { - Name = "SourceProtocolInfo", - DataType = "string", - SendsEvents = true - }); + new StateVariable + { + Name = "SourceProtocolInfo", + DataType = "string", + SendsEvents = true + }, - list.Add(new StateVariable - { - Name = "SinkProtocolInfo", - DataType = "string", - SendsEvents = true - }); + new StateVariable + { + Name = "SinkProtocolInfo", + DataType = "string", + SendsEvents = true + }, - list.Add(new StateVariable - { - Name = "CurrentConnectionIDs", - DataType = "string", - SendsEvents = true - }); + new StateVariable + { + Name = "CurrentConnectionIDs", + DataType = "string", + SendsEvents = true + }, - list.Add(new StateVariable - { - Name = "A_ARG_TYPE_ConnectionStatus", - DataType = "string", - SendsEvents = false, + new StateVariable + { + Name = "A_ARG_TYPE_ConnectionStatus", + DataType = "string", + SendsEvents = false, - AllowedValues = new[] + AllowedValues = new[] { "OK", "ContentFormatMismatch", @@ -52,55 +64,56 @@ namespace Emby.Dlna.ConnectionManager "UnreliableChannel", "Unknown" } - }); + }, - list.Add(new StateVariable - { - Name = "A_ARG_TYPE_ConnectionManager", - DataType = "string", - SendsEvents = false - }); + new StateVariable + { + Name = "A_ARG_TYPE_ConnectionManager", + DataType = "string", + SendsEvents = false + }, - list.Add(new StateVariable - { - Name = "A_ARG_TYPE_Direction", - DataType = "string", - SendsEvents = false, + new StateVariable + { + Name = "A_ARG_TYPE_Direction", + DataType = "string", + SendsEvents = false, - AllowedValues = new[] + AllowedValues = new[] { "Output", "Input" } - }); + }, - list.Add(new StateVariable - { - Name = "A_ARG_TYPE_ProtocolInfo", - DataType = "string", - SendsEvents = false - }); + new StateVariable + { + Name = "A_ARG_TYPE_ProtocolInfo", + DataType = "string", + SendsEvents = false + }, - list.Add(new StateVariable - { - Name = "A_ARG_TYPE_ConnectionID", - DataType = "ui4", - SendsEvents = false - }); + new StateVariable + { + Name = "A_ARG_TYPE_ConnectionID", + DataType = "ui4", + SendsEvents = false + }, - list.Add(new StateVariable - { - Name = "A_ARG_TYPE_AVTransportID", - DataType = "ui4", - SendsEvents = false - }); + new StateVariable + { + Name = "A_ARG_TYPE_AVTransportID", + DataType = "ui4", + SendsEvents = false + }, - list.Add(new StateVariable - { - Name = "A_ARG_TYPE_RcsID", - DataType = "ui4", - SendsEvents = false - }); + new StateVariable + { + Name = "A_ARG_TYPE_RcsID", + DataType = "ui4", + SendsEvents = false + } + }; return list; } diff --git a/Emby.Dlna/ConnectionManager/ControlHandler.cs b/Emby.Dlna/ConnectionManager/ControlHandler.cs index d4cc653942..1a1790ee6a 100644 --- a/Emby.Dlna/ConnectionManager/ControlHandler.cs +++ b/Emby.Dlna/ConnectionManager/ControlHandler.cs @@ -11,10 +11,19 @@ using Microsoft.Extensions.Logging; namespace Emby.Dlna.ConnectionManager { + /// + /// Defines the . + /// public class ControlHandler : BaseControlHandler { private readonly DeviceProfile _profile; + /// + /// Initializes a new instance of the class. + /// + /// The for use with the instance. + /// The for use with the instance. + /// The for use with the instance. public ControlHandler(IServerConfigurationManager config, ILogger logger, DeviceProfile profile) : base(config, logger) { @@ -22,7 +31,7 @@ namespace Emby.Dlna.ConnectionManager } /// - protected override void WriteResult(string methodName, IDictionary methodParams, XmlWriter xmlWriter) + protected override void WriteResult(string methodName, IReadOnlyDictionary methodParams, XmlWriter xmlWriter) { if (string.Equals(methodName, "GetProtocolInfo", StringComparison.OrdinalIgnoreCase)) { @@ -33,6 +42,10 @@ namespace Emby.Dlna.ConnectionManager throw new ResourceNotFoundException("Unexpected control request name: " + methodName); } + /// + /// Builds the response to the GetProtocolInfo request. + /// + /// The . private void HandleGetProtocolInfo(XmlWriter xmlWriter) { xmlWriter.WriteElementString("Source", _profile.ProtocolInfo); diff --git a/Emby.Dlna/ConnectionManager/ServiceActionListBuilder.cs b/Emby.Dlna/ConnectionManager/ServiceActionListBuilder.cs index b853e7eab6..542c7bfb4b 100644 --- a/Emby.Dlna/ConnectionManager/ServiceActionListBuilder.cs +++ b/Emby.Dlna/ConnectionManager/ServiceActionListBuilder.cs @@ -5,9 +5,16 @@ using Emby.Dlna.Common; namespace Emby.Dlna.ConnectionManager { - public class ServiceActionListBuilder + /// + /// Defines the . + /// + public static class ServiceActionListBuilder { - public IEnumerable GetActions() + /// + /// Returns an enumerable of the ConnectionManagar:1 DLNA actions. + /// + /// An . + public static IEnumerable GetActions() { var list = new List { @@ -21,6 +28,10 @@ namespace Emby.Dlna.ConnectionManager return list; } + /// + /// Returns the action details for "PrepareForConnection". + /// + /// The . private static ServiceAction PrepareForConnection() { var action = new ServiceAction @@ -80,6 +91,10 @@ namespace Emby.Dlna.ConnectionManager return action; } + /// + /// Returns the action details for "GetCurrentConnectionInfo". + /// + /// The . private static ServiceAction GetCurrentConnectionInfo() { var action = new ServiceAction @@ -146,7 +161,11 @@ namespace Emby.Dlna.ConnectionManager return action; } - private ServiceAction GetProtocolInfo() + /// + /// Returns the action details for "GetProtocolInfo". + /// + /// The . + private static ServiceAction GetProtocolInfo() { var action = new ServiceAction { @@ -170,7 +189,11 @@ namespace Emby.Dlna.ConnectionManager return action; } - private ServiceAction GetCurrentConnectionIDs() + /// + /// Returns the action details for "GetCurrentConnectionIDs". + /// + /// The . + private static ServiceAction GetCurrentConnectionIDs() { var action = new ServiceAction { @@ -187,7 +210,11 @@ namespace Emby.Dlna.ConnectionManager return action; } - private ServiceAction ConnectionComplete() + /// + /// Returns the action details for "ConnectionComplete". + /// + /// The . + private static ServiceAction ConnectionComplete() { var action = new ServiceAction { diff --git a/Emby.Dlna/ContentDirectory/ContentDirectoryService.cs b/Emby.Dlna/ContentDirectory/ContentDirectoryService.cs index 5760f260cf..9020dea994 100644 --- a/Emby.Dlna/ContentDirectory/ContentDirectoryService.cs +++ b/Emby.Dlna/ContentDirectory/ContentDirectoryService.cs @@ -19,6 +19,9 @@ using Microsoft.Extensions.Logging; namespace Emby.Dlna.ContentDirectory { + /// + /// Defines the . + /// public class ContentDirectoryService : BaseService, IContentDirectory { private readonly ILibraryManager _libraryManager; @@ -33,6 +36,22 @@ namespace Emby.Dlna.ContentDirectory private readonly IMediaEncoder _mediaEncoder; private readonly ITVSeriesManager _tvSeriesManager; + /// + /// Initializes a new instance of the class. + /// + /// The to use in the instance. + /// The to use in the instance. + /// The to use in the instance. + /// The to use in the instance. + /// The to use in the instance. + /// The to use in the instance. + /// The to use in the instance. + /// The to use in the instance. + /// The to use in the instance. + /// The to use in the instance. + /// The to use in the instance. + /// The to use in the instance. + /// The to use in the instance. public ContentDirectoryService( IDlnaManager dlna, IUserDataManager userDataManager, @@ -62,7 +81,10 @@ namespace Emby.Dlna.ContentDirectory _tvSeriesManager = tvSeriesManager; } - private int SystemUpdateId + /// + /// Gets the system id. (A unique id which changes on when our definition changes.) + /// + private static int SystemUpdateId { get { @@ -75,14 +97,18 @@ namespace Emby.Dlna.ContentDirectory /// public string GetServiceXml() { - return new ContentDirectoryXmlBuilder().GetXml(); + return ContentDirectoryXmlBuilder.GetXml(); } /// public Task ProcessControlRequestAsync(ControlRequest request) { - var profile = _dlna.GetProfile(request.Headers) ?? - _dlna.GetDefaultProfile(); + if (request == null) + { + throw new ArgumentNullException(nameof(request)); + } + + var profile = _dlna.GetProfile(request.Headers) ?? _dlna.GetDefaultProfile(); var serverAddress = request.RequestedUrl.Substring(0, request.RequestedUrl.IndexOf("/dlna", StringComparison.OrdinalIgnoreCase)); @@ -107,7 +133,12 @@ namespace Emby.Dlna.ContentDirectory .ProcessControlRequestAsync(request); } - private User GetUser(DeviceProfile profile) + /// + /// Get the user stored in the device profile. + /// + /// The . + /// The . + private User? GetUser(DeviceProfile profile) { if (!string.IsNullOrEmpty(profile.UserId)) { diff --git a/Emby.Dlna/ContentDirectory/ContentDirectoryXmlBuilder.cs b/Emby.Dlna/ContentDirectory/ContentDirectoryXmlBuilder.cs index 743dcc5161..3edaabb70e 100644 --- a/Emby.Dlna/ContentDirectory/ContentDirectoryXmlBuilder.cs +++ b/Emby.Dlna/ContentDirectory/ContentDirectoryXmlBuilder.cs @@ -6,143 +6,154 @@ using Emby.Dlna.Service; namespace Emby.Dlna.ContentDirectory { - public class ContentDirectoryXmlBuilder + /// + /// Defines the . + /// + public static class ContentDirectoryXmlBuilder { - public string GetXml() + /// + /// Gets the ContentDirectory:1 service template. + /// See http://upnp.org/specs/av/UPnP-av-ContentDirectory-v1-Service.pdf. + /// + /// An XML description of this service. + public static string GetXml() { - return new ServiceXmlBuilder().GetXml( - new ServiceActionListBuilder().GetActions(), - GetStateVariables()); + return new ServiceXmlBuilder().GetXml(ServiceActionListBuilder.GetActions(), GetStateVariables()); } + /// + /// Get the list of state variables for this invocation. + /// + /// The . private static IEnumerable GetStateVariables() { - var list = new List(); - - list.Add(new StateVariable + var list = new List { - Name = "A_ARG_TYPE_Filter", - DataType = "string", - SendsEvents = false - }); + new StateVariable + { + Name = "A_ARG_TYPE_Filter", + DataType = "string", + SendsEvents = false + }, - list.Add(new StateVariable - { - Name = "A_ARG_TYPE_SortCriteria", - DataType = "string", - SendsEvents = false - }); + new StateVariable + { + Name = "A_ARG_TYPE_SortCriteria", + DataType = "string", + SendsEvents = false + }, - list.Add(new StateVariable - { - Name = "A_ARG_TYPE_Index", - DataType = "ui4", - SendsEvents = false - }); + new StateVariable + { + Name = "A_ARG_TYPE_Index", + DataType = "ui4", + SendsEvents = false + }, - list.Add(new StateVariable - { - Name = "A_ARG_TYPE_Count", - DataType = "ui4", - SendsEvents = false - }); + new StateVariable + { + Name = "A_ARG_TYPE_Count", + DataType = "ui4", + SendsEvents = false + }, - list.Add(new StateVariable - { - Name = "A_ARG_TYPE_UpdateID", - DataType = "ui4", - SendsEvents = false - }); + new StateVariable + { + Name = "A_ARG_TYPE_UpdateID", + DataType = "ui4", + SendsEvents = false + }, - list.Add(new StateVariable - { - Name = "SearchCapabilities", - DataType = "string", - SendsEvents = false - }); + new StateVariable + { + Name = "SearchCapabilities", + DataType = "string", + SendsEvents = false + }, - list.Add(new StateVariable - { - Name = "SortCapabilities", - DataType = "string", - SendsEvents = false - }); + new StateVariable + { + Name = "SortCapabilities", + DataType = "string", + SendsEvents = false + }, - list.Add(new StateVariable - { - Name = "SystemUpdateID", - DataType = "ui4", - SendsEvents = true - }); + new StateVariable + { + Name = "SystemUpdateID", + DataType = "ui4", + SendsEvents = true + }, - list.Add(new StateVariable - { - Name = "A_ARG_TYPE_SearchCriteria", - DataType = "string", - SendsEvents = false - }); + new StateVariable + { + Name = "A_ARG_TYPE_SearchCriteria", + DataType = "string", + SendsEvents = false + }, - list.Add(new StateVariable - { - Name = "A_ARG_TYPE_Result", - DataType = "string", - SendsEvents = false - }); + new StateVariable + { + Name = "A_ARG_TYPE_Result", + DataType = "string", + SendsEvents = false + }, - list.Add(new StateVariable - { - Name = "A_ARG_TYPE_ObjectID", - DataType = "string", - SendsEvents = false - }); + new StateVariable + { + Name = "A_ARG_TYPE_ObjectID", + DataType = "string", + SendsEvents = false + }, - list.Add(new StateVariable - { - Name = "A_ARG_TYPE_BrowseFlag", - DataType = "string", - SendsEvents = false, + new StateVariable + { + Name = "A_ARG_TYPE_BrowseFlag", + DataType = "string", + SendsEvents = false, - AllowedValues = new[] + AllowedValues = new[] { "BrowseMetadata", "BrowseDirectChildren" } - }); + }, - list.Add(new StateVariable - { - Name = "A_ARG_TYPE_BrowseLetter", - DataType = "string", - SendsEvents = false - }); + new StateVariable + { + Name = "A_ARG_TYPE_BrowseLetter", + DataType = "string", + SendsEvents = false + }, - list.Add(new StateVariable - { - Name = "A_ARG_TYPE_CategoryType", - DataType = "ui4", - SendsEvents = false - }); + new StateVariable + { + Name = "A_ARG_TYPE_CategoryType", + DataType = "ui4", + SendsEvents = false + }, - list.Add(new StateVariable - { - Name = "A_ARG_TYPE_RID", - DataType = "ui4", - SendsEvents = false - }); + new StateVariable + { + Name = "A_ARG_TYPE_RID", + DataType = "ui4", + SendsEvents = false + }, - list.Add(new StateVariable - { - Name = "A_ARG_TYPE_PosSec", - DataType = "ui4", - SendsEvents = false - }); + new StateVariable + { + Name = "A_ARG_TYPE_PosSec", + DataType = "ui4", + SendsEvents = false + }, - list.Add(new StateVariable - { - Name = "A_ARG_TYPE_Featurelist", - DataType = "string", - SendsEvents = false - }); + new StateVariable + { + Name = "A_ARG_TYPE_Featurelist", + DataType = "string", + SendsEvents = false + } + }; return list; } diff --git a/Emby.Dlna/ContentDirectory/ControlHandler.cs b/Emby.Dlna/ContentDirectory/ControlHandler.cs index 4b108b89ea..ac336e5dcc 100644 --- a/Emby.Dlna/ContentDirectory/ControlHandler.cs +++ b/Emby.Dlna/ContentDirectory/ControlHandler.cs @@ -1,4 +1,4 @@ -#pragma warning disable CS1591 +#nullable disable using System; using System.Collections.Generic; @@ -38,6 +38,9 @@ using Series = MediaBrowser.Controller.Entities.TV.Series; namespace Emby.Dlna.ContentDirectory { + /// + /// Defines the . + /// public class ControlHandler : BaseControlHandler { private const string NsDc = "http://purl.org/dc/elements/1.1/"; @@ -58,6 +61,24 @@ namespace Emby.Dlna.ContentDirectory private readonly DeviceProfile _profile; + /// + /// Initializes a new instance of the class. + /// + /// The for use with the instance. + /// The for use with the instance. + /// The for use with the instance. + /// The server address to use in this instance> for use with the instance. + /// The for use with the instance. + /// The for use with the instance. + /// The for use with the instance. + /// The for use with the instance. + /// The system id for use with the instance. + /// The for use with the instance. + /// The for use with the instance. + /// The for use with the instance. + /// The for use with the instance. + /// The for use with the instance. + /// The for use with the instance. public ControlHandler( ILogger logger, ILibraryManager libraryManager, @@ -100,8 +121,18 @@ namespace Emby.Dlna.ContentDirectory } /// - protected override void WriteResult(string methodName, IDictionary methodParams, XmlWriter xmlWriter) + protected override void WriteResult(string methodName, IReadOnlyDictionary methodParams, XmlWriter xmlWriter) { + if (xmlWriter == null) + { + throw new ArgumentNullException(nameof(xmlWriter)); + } + + if (methodParams == null) + { + throw new ArgumentNullException(nameof(methodParams)); + } + const string DeviceId = "test"; if (string.Equals(methodName, "GetSearchCapabilities", StringComparison.OrdinalIgnoreCase)) @@ -167,7 +198,11 @@ namespace Emby.Dlna.ContentDirectory throw new ResourceNotFoundException("Unexpected control request name: " + methodName); } - private void HandleXSetBookmark(IDictionary sparams) + /// + /// Adds a "XSetBookmark" element to the xml document. + /// + /// The method parameters. + private void HandleXSetBookmark(IReadOnlyDictionary sparams) { var id = sparams["ObjectID"]; @@ -189,75 +224,92 @@ namespace Emby.Dlna.ContentDirectory CancellationToken.None); } - private void HandleGetSearchCapabilities(XmlWriter xmlWriter) + /// + /// Adds the "SearchCaps" element to the xml document. + /// + /// The . + private static void HandleGetSearchCapabilities(XmlWriter xmlWriter) { xmlWriter.WriteElementString( "SearchCaps", "res@resolution,res@size,res@duration,dc:title,dc:creator,upnp:actor,upnp:artist,upnp:genre,upnp:album,dc:date,upnp:class,@id,@refID,@protocolInfo,upnp:author,dc:description,pv:avKeywords"); } - private void HandleGetSortCapabilities(XmlWriter xmlWriter) + /// + /// Adds the "SortCaps" element to the xml document. + /// + /// The . + private static void HandleGetSortCapabilities(XmlWriter xmlWriter) { xmlWriter.WriteElementString( "SortCaps", "res@duration,res@size,res@bitrate,dc:date,dc:title,dc:size,upnp:album,upnp:artist,upnp:albumArtist,upnp:episodeNumber,upnp:genre,upnp:originalTrackNumber,upnp:rating"); } - private void HandleGetSortExtensionCapabilities(XmlWriter xmlWriter) + /// + /// Adds the "SortExtensionCaps" element to the xml document. + /// + /// The . + private static void HandleGetSortExtensionCapabilities(XmlWriter xmlWriter) { xmlWriter.WriteElementString( "SortExtensionCaps", "res@duration,res@size,res@bitrate,dc:date,dc:title,dc:size,upnp:album,upnp:artist,upnp:albumArtist,upnp:episodeNumber,upnp:genre,upnp:originalTrackNumber,upnp:rating"); } + /// + /// Adds the "Id" element to the xml document. + /// + /// The . private void HandleGetSystemUpdateID(XmlWriter xmlWriter) { xmlWriter.WriteElementString("Id", _systemUpdateId.ToString(CultureInfo.InvariantCulture)); } - private void HandleGetFeatureList(XmlWriter xmlWriter) + /// + /// Adds the "FeatureList" element to the xml document. + /// + /// The . + private static void HandleGetFeatureList(XmlWriter xmlWriter) { xmlWriter.WriteElementString("FeatureList", WriteFeatureListXml()); } - private void HandleXGetFeatureList(XmlWriter xmlWriter) + /// + /// Adds the "FeatureList" element to the xml document. + /// + /// The . + private static void HandleXGetFeatureList(XmlWriter xmlWriter) => HandleGetFeatureList(xmlWriter); - private string WriteFeatureListXml() - { - // TODO: clean this up - var builder = new StringBuilder(); - - builder.Append(""); - builder.Append(""); - - builder.Append(""); - builder.Append(""); - builder.Append(""); - builder.Append(""); - builder.Append(""); - - builder.Append(""); - - return builder.ToString(); - } - - public string GetValueOrDefault(IDictionary sparams, string key, string defaultValue) + /// + /// Builds a static feature list. + /// + /// The xml feature list. + private static string WriteFeatureListXml() { - if (sparams.TryGetValue(key, out string val)) - { - return val; - } - - return defaultValue; + return "" + + "" + + "" + + "" + + "" + + "" + + "" + + ""; } - private void HandleBrowse(XmlWriter xmlWriter, IDictionary sparams, string deviceId) + /// + /// Builds the "Browse" xml response. + /// + /// The . + /// The method parameters. + /// The device Id to use. + private void HandleBrowse(XmlWriter xmlWriter, IReadOnlyDictionary sparams, string deviceId) { var id = sparams["ObjectID"]; var flag = sparams["BrowseFlag"]; - var filter = new Filter(GetValueOrDefault(sparams, "Filter", "*")); - var sortCriteria = new SortCriteria(GetValueOrDefault(sparams, "SortCriteria", string.Empty)); + var filter = new Filter(sparams.GetValueOrDefault("Filter", "*")); + var sortCriteria = new SortCriteria(sparams.GetValueOrDefault("SortCriteria", string.Empty)); var provided = 0; @@ -313,7 +365,6 @@ namespace Emby.Dlna.ContentDirectory } else { - var dlnaOptions = _config.GetDlnaConfiguration(); _didlBuilder.WriteItemElement(writer, item, _user, null, null, deviceId, filter); } @@ -326,7 +377,6 @@ namespace Emby.Dlna.ContentDirectory provided = childrenResult.Items.Count; - var dlnaOptions = _config.GetDlnaConfiguration(); foreach (var i in childrenResult.Items) { var childItem = i.Item; @@ -357,17 +407,29 @@ namespace Emby.Dlna.ContentDirectory xmlWriter.WriteElementString("UpdateID", _systemUpdateId.ToString(CultureInfo.InvariantCulture)); } - private void HandleXBrowseByLetter(XmlWriter xmlWriter, IDictionary sparams, string deviceId) + /// + /// Builds the response to the "X_BrowseByLetter request. + /// + /// The . + /// The method parameters. + /// The device id. + private void HandleXBrowseByLetter(XmlWriter xmlWriter, IReadOnlyDictionary sparams, string deviceId) { // TODO: Implement this method HandleSearch(xmlWriter, sparams, deviceId); } - private void HandleSearch(XmlWriter xmlWriter, IDictionary sparams, string deviceId) + /// + /// Builds a response to the "Search" request. + /// + /// The xmlWriter. + /// The method parameters. + /// The deviceId. + private void HandleSearch(XmlWriter xmlWriter, IReadOnlyDictionary sparams, string deviceId) { - var searchCriteria = new SearchCriteria(GetValueOrDefault(sparams, "SearchCriteria", string.Empty)); - var sortCriteria = new SortCriteria(GetValueOrDefault(sparams, "SortCriteria", string.Empty)); - var filter = new Filter(GetValueOrDefault(sparams, "Filter", "*")); + var searchCriteria = new SearchCriteria(sparams.GetValueOrDefault("SearchCriteria", string.Empty)); + var sortCriteria = new SortCriteria(sparams.GetValueOrDefault("SortCriteria", string.Empty)); + var filter = new Filter(sparams.GetValueOrDefault("Filter", "*")); // sort example: dc:title, dc:date @@ -442,7 +504,17 @@ namespace Emby.Dlna.ContentDirectory xmlWriter.WriteElementString("UpdateID", _systemUpdateId.ToString(CultureInfo.InvariantCulture)); } - private QueryResult GetChildrenSorted(BaseItem item, User user, SearchCriteria search, SortCriteria sort, int? startIndex, int? limit) + /// + /// Returns the child items meeting the criteria. + /// + /// The . + /// The . + /// The . + /// The . + /// The start index. + /// The maximum number to return. + /// The . + private static QueryResult GetChildrenSorted(BaseItem item, User user, SearchCriteria search, SortCriteria sort, int? startIndex, int? limit) { var folder = (Folder)item; @@ -487,18 +559,32 @@ namespace Emby.Dlna.ContentDirectory User = user, Recursive = true, IsMissing = false, - ExcludeItemTypes = new[] { typeof(Book).Name }, + ExcludeItemTypes = new[] { nameof(Book) }, IsFolder = isFolder, MediaTypes = mediaTypes, DtoOptions = GetDtoOptions() }); } - private DtoOptions GetDtoOptions() + /// + /// Returns a new DtoOptions object. + /// + /// The . + private static DtoOptions GetDtoOptions() { return new DtoOptions(true); } + /// + /// Returns the User items meeting the criteria. + /// + /// The . + /// The . + /// The . + /// The . + /// The start index. + /// The maximum number to return. + /// The . private QueryResult GetUserItems(BaseItem item, StubType? stubType, User user, SortCriteria sort, int? startIndex, int? limit) { if (item is MusicGenre) @@ -556,7 +642,7 @@ namespace Emby.Dlna.ContentDirectory Limit = limit, StartIndex = startIndex, IsVirtualItem = false, - ExcludeItemTypes = new[] { typeof(Book).Name }, + ExcludeItemTypes = new[] { nameof(Book) }, IsPlaceHolder = false, DtoOptions = GetDtoOptions() }; @@ -568,6 +654,14 @@ namespace Emby.Dlna.ContentDirectory return ToResult(queryResult); } + /// + /// Returns the Live Tv Channels meeting the criteria. + /// + /// The . + /// The . + /// The start index. + /// The maximum number to return. + /// The . private QueryResult GetLiveTvChannels(User user, SortCriteria sort, int? startIndex, int? limit) { var query = new InternalItemsQuery(user) @@ -575,7 +669,7 @@ namespace Emby.Dlna.ContentDirectory StartIndex = startIndex, Limit = limit, }; - query.IncludeItemTypes = new[] { typeof(LiveTvChannel).Name }; + query.IncludeItemTypes = new[] { nameof(LiveTvChannel) }; SetSorting(query, sort, false); @@ -584,6 +678,16 @@ namespace Emby.Dlna.ContentDirectory return ToResult(result); } + /// + /// Returns the music folders meeting the criteria. + /// + /// The . + /// The . + /// The . + /// The . + /// The start index. + /// The maximum number to return. + /// The . private QueryResult GetMusicFolders(BaseItem item, User user, StubType? stubType, SortCriteria sort, int? startIndex, int? limit) { var query = new InternalItemsQuery(user) @@ -643,57 +747,58 @@ namespace Emby.Dlna.ContentDirectory return GetMusicGenres(item, user, query); } - var list = new List(); - - list.Add(new ServerItem(item) + var list = new List { - StubType = StubType.Latest - }); + new ServerItem(item) + { + StubType = StubType.Latest + }, - list.Add(new ServerItem(item) - { - StubType = StubType.Playlists - }); + new ServerItem(item) + { + StubType = StubType.Playlists + }, - list.Add(new ServerItem(item) - { - StubType = StubType.Albums - }); + new ServerItem(item) + { + StubType = StubType.Albums + }, - list.Add(new ServerItem(item) - { - StubType = StubType.AlbumArtists - }); + new ServerItem(item) + { + StubType = StubType.AlbumArtists + }, - list.Add(new ServerItem(item) - { - StubType = StubType.Artists - }); + new ServerItem(item) + { + StubType = StubType.Artists + }, - list.Add(new ServerItem(item) - { - StubType = StubType.Songs - }); + new ServerItem(item) + { + StubType = StubType.Songs + }, - list.Add(new ServerItem(item) - { - StubType = StubType.Genres - }); + new ServerItem(item) + { + StubType = StubType.Genres + }, - list.Add(new ServerItem(item) - { - StubType = StubType.FavoriteArtists - }); + new ServerItem(item) + { + StubType = StubType.FavoriteArtists + }, - list.Add(new ServerItem(item) - { - StubType = StubType.FavoriteAlbums - }); + new ServerItem(item) + { + StubType = StubType.FavoriteAlbums + }, - list.Add(new ServerItem(item) - { - StubType = StubType.FavoriteSongs - }); + new ServerItem(item) + { + StubType = StubType.FavoriteSongs + } + }; return new QueryResult { @@ -702,6 +807,16 @@ namespace Emby.Dlna.ContentDirectory }; } + /// + /// Returns the movie folders meeting the criteria. + /// + /// The . + /// The . + /// The . + /// The . + /// The start index. + /// The maximum number to return. + /// The . private QueryResult GetMovieFolders(BaseItem item, User user, StubType? stubType, SortCriteria sort, int? startIndex, int? limit) { var query = new InternalItemsQuery(user) @@ -776,6 +891,13 @@ namespace Emby.Dlna.ContentDirectory }; } + /// + /// Returns the folders meeting the criteria. + /// + /// The . + /// The start index. + /// The maximum number to return. + /// The . private QueryResult GetFolders(User user, int? startIndex, int? limit) { var folders = _libraryManager.GetUserRootFolder().GetChildren(user, true) @@ -796,6 +918,16 @@ namespace Emby.Dlna.ContentDirectory limit); } + /// + /// Returns the TV folders meeting the criteria. + /// + /// The . + /// The . + /// The . + /// The . + /// The start index. + /// The maximum number to return. + /// The . private QueryResult GetTvFolders(BaseItem item, User user, StubType? stubType, SortCriteria sort, int? startIndex, int? limit) { var query = new InternalItemsQuery(user) @@ -840,42 +972,43 @@ namespace Emby.Dlna.ContentDirectory return GetGenres(item, user, query); } - var list = new List(); - - list.Add(new ServerItem(item) + var list = new List { - StubType = StubType.ContinueWatching - }); + new ServerItem(item) + { + StubType = StubType.ContinueWatching + }, - list.Add(new ServerItem(item) - { - StubType = StubType.NextUp - }); + new ServerItem(item) + { + StubType = StubType.NextUp + }, - list.Add(new ServerItem(item) - { - StubType = StubType.Latest - }); + new ServerItem(item) + { + StubType = StubType.Latest + }, - list.Add(new ServerItem(item) - { - StubType = StubType.Series - }); + new ServerItem(item) + { + StubType = StubType.Series + }, - list.Add(new ServerItem(item) - { - StubType = StubType.FavoriteSeries - }); + new ServerItem(item) + { + StubType = StubType.FavoriteSeries + }, - list.Add(new ServerItem(item) - { - StubType = StubType.FavoriteEpisodes - }); + new ServerItem(item) + { + StubType = StubType.FavoriteEpisodes + }, - list.Add(new ServerItem(item) - { - StubType = StubType.Genres - }); + new ServerItem(item) + { + StubType = StubType.Genres + } + }; return new QueryResult { @@ -884,6 +1017,13 @@ namespace Emby.Dlna.ContentDirectory }; } + /// + /// Returns the Movies that are part watched that meet the criteria. + /// + /// The . + /// The . + /// The . + /// The . private QueryResult GetMovieContinueWatching(BaseItem parent, User user, InternalItemsQuery query) { query.Recursive = true; @@ -904,136 +1044,213 @@ namespace Emby.Dlna.ContentDirectory return ToResult(result); } + /// + /// Returns the series meeting the criteria. + /// + /// The . + /// The . + /// The . + /// The . private QueryResult GetSeries(BaseItem parent, User user, InternalItemsQuery query) { query.Recursive = true; query.Parent = parent; query.SetUser(user); - query.IncludeItemTypes = new[] { typeof(Series).Name }; + query.IncludeItemTypes = new[] { nameof(Series) }; var result = _libraryManager.GetItemsResult(query); return ToResult(result); } + /// + /// Returns the Movie folders meeting the criteria. + /// + /// The . + /// The . + /// The . + /// The . private QueryResult GetMovieMovies(BaseItem parent, User user, InternalItemsQuery query) { query.Recursive = true; query.Parent = parent; query.SetUser(user); - query.IncludeItemTypes = new[] { typeof(Movie).Name }; + query.IncludeItemTypes = new[] { nameof(Movie) }; var result = _libraryManager.GetItemsResult(query); return ToResult(result); } + /// + /// Returns the Movie collections meeting the criteria. + /// + /// The see cref="User"/>. + /// The see cref="InternalItemsQuery"/>. + /// The . private QueryResult GetMovieCollections(User user, InternalItemsQuery query) { query.Recursive = true; // query.Parent = parent; query.SetUser(user); - query.IncludeItemTypes = new[] { typeof(BoxSet).Name }; + query.IncludeItemTypes = new[] { nameof(BoxSet) }; var result = _libraryManager.GetItemsResult(query); return ToResult(result); } + /// + /// Returns the Music albums meeting the criteria. + /// + /// The . + /// The . + /// The . + /// The . private QueryResult GetMusicAlbums(BaseItem parent, User user, InternalItemsQuery query) { query.Recursive = true; query.Parent = parent; query.SetUser(user); - query.IncludeItemTypes = new[] { typeof(MusicAlbum).Name }; + query.IncludeItemTypes = new[] { nameof(MusicAlbum) }; var result = _libraryManager.GetItemsResult(query); return ToResult(result); } + /// + /// Returns the Music songs meeting the criteria. + /// + /// The . + /// The . + /// The . + /// The . private QueryResult GetMusicSongs(BaseItem parent, User user, InternalItemsQuery query) { query.Recursive = true; query.Parent = parent; query.SetUser(user); - query.IncludeItemTypes = new[] { typeof(Audio).Name }; + query.IncludeItemTypes = new[] { nameof(Audio) }; var result = _libraryManager.GetItemsResult(query); return ToResult(result); } + /// + /// Returns the songs tagged as favourite that meet the criteria. + /// + /// The . + /// The . + /// The . + /// The . private QueryResult GetFavoriteSongs(BaseItem parent, User user, InternalItemsQuery query) { query.Recursive = true; query.Parent = parent; query.SetUser(user); query.IsFavorite = true; - query.IncludeItemTypes = new[] { typeof(Audio).Name }; + query.IncludeItemTypes = new[] { nameof(Audio) }; var result = _libraryManager.GetItemsResult(query); return ToResult(result); } + /// + /// Returns the series tagged as favourite that meet the criteria. + /// + /// The . + /// The . + /// The . + /// The . private QueryResult GetFavoriteSeries(BaseItem parent, User user, InternalItemsQuery query) { query.Recursive = true; query.Parent = parent; query.SetUser(user); query.IsFavorite = true; - query.IncludeItemTypes = new[] { typeof(Series).Name }; + query.IncludeItemTypes = new[] { nameof(Series) }; var result = _libraryManager.GetItemsResult(query); return ToResult(result); } + /// + /// Returns the episodes tagged as favourite that meet the criteria. + /// + /// The . + /// The . + /// The . + /// The . private QueryResult GetFavoriteEpisodes(BaseItem parent, User user, InternalItemsQuery query) { query.Recursive = true; query.Parent = parent; query.SetUser(user); query.IsFavorite = true; - query.IncludeItemTypes = new[] { typeof(Episode).Name }; + query.IncludeItemTypes = new[] { nameof(Episode) }; var result = _libraryManager.GetItemsResult(query); return ToResult(result); } + /// + /// Returns the movies tagged as favourite that meet the criteria. + /// + /// The . + /// The . + /// The . + /// The . private QueryResult GetMovieFavorites(BaseItem parent, User user, InternalItemsQuery query) { query.Recursive = true; query.Parent = parent; query.SetUser(user); query.IsFavorite = true; - query.IncludeItemTypes = new[] { typeof(Movie).Name }; + query.IncludeItemTypes = new[] { nameof(Movie) }; var result = _libraryManager.GetItemsResult(query); return ToResult(result); } + /// + /// /// Returns the albums tagged as favourite that meet the criteria. + /// + /// The . + /// The . + /// The . + /// The . private QueryResult GetFavoriteAlbums(BaseItem parent, User user, InternalItemsQuery query) { query.Recursive = true; query.Parent = parent; query.SetUser(user); query.IsFavorite = true; - query.IncludeItemTypes = new[] { typeof(MusicAlbum).Name }; + query.IncludeItemTypes = new[] { nameof(MusicAlbum) }; var result = _libraryManager.GetItemsResult(query); return ToResult(result); } + /// + /// Returns the genres meeting the criteria. + /// The GetGenres. + /// + /// The . + /// The . + /// The . + /// The . private QueryResult GetGenres(BaseItem parent, User user, InternalItemsQuery query) { var genresResult = _libraryManager.GetGenres(new InternalItemsQuery(user) @@ -1052,6 +1269,13 @@ namespace Emby.Dlna.ContentDirectory return ToResult(result); } + /// + /// Returns the music genres meeting the criteria. + /// + /// The . + /// The . + /// The . + /// The . private QueryResult GetMusicGenres(BaseItem parent, User user, InternalItemsQuery query) { var genresResult = _libraryManager.GetMusicGenres(new InternalItemsQuery(user) @@ -1070,6 +1294,13 @@ namespace Emby.Dlna.ContentDirectory return ToResult(result); } + /// + /// Returns the music albums by artist that meet the criteria. + /// + /// The . + /// The . + /// The . + /// The . private QueryResult GetMusicAlbumArtists(BaseItem parent, User user, InternalItemsQuery query) { var artists = _libraryManager.GetAlbumArtists(new InternalItemsQuery(user) @@ -1088,6 +1319,13 @@ namespace Emby.Dlna.ContentDirectory return ToResult(result); } + /// + /// Returns the music artists meeting the criteria. + /// + /// The . + /// The . + /// The . + /// The . private QueryResult GetMusicArtists(BaseItem parent, User user, InternalItemsQuery query) { var artists = _libraryManager.GetArtists(new InternalItemsQuery(user) @@ -1106,6 +1344,13 @@ namespace Emby.Dlna.ContentDirectory return ToResult(result); } + /// + /// Returns the artists tagged as favourite that meet the criteria. + /// + /// The . + /// The . + /// The . + /// The . private QueryResult GetFavoriteArtists(BaseItem parent, User user, InternalItemsQuery query) { var artists = _libraryManager.GetArtists(new InternalItemsQuery(user) @@ -1125,6 +1370,12 @@ namespace Emby.Dlna.ContentDirectory return ToResult(result); } + /// + /// Returns the music playlists meeting the criteria. + /// + /// The user. + /// The query. + /// The . private QueryResult GetMusicPlaylists(User user, InternalItemsQuery query) { query.Parent = null; @@ -1137,6 +1388,13 @@ namespace Emby.Dlna.ContentDirectory return ToResult(result); } + /// + /// Returns the latest music meeting the criteria. + /// + /// The . + /// The . + /// The . + /// The . private QueryResult GetMusicLatest(BaseItem parent, User user, InternalItemsQuery query) { query.OrderBy = Array.Empty<(string, SortOrder)>(); @@ -1155,6 +1413,12 @@ namespace Emby.Dlna.ContentDirectory return ToResult(items); } + /// + /// Returns the next up item meeting the criteria. + /// + /// The . + /// The . + /// The . private QueryResult GetNextUp(BaseItem parent, InternalItemsQuery query) { query.OrderBy = Array.Empty<(string, SortOrder)>(); @@ -1172,6 +1436,13 @@ namespace Emby.Dlna.ContentDirectory return ToResult(result); } + /// + /// Returns the latest tv meeting the criteria. + /// + /// The . + /// The . + /// The . + /// The . private QueryResult GetTvLatest(BaseItem parent, User user, InternalItemsQuery query) { query.OrderBy = Array.Empty<(string, SortOrder)>(); @@ -1181,7 +1452,7 @@ namespace Emby.Dlna.ContentDirectory { UserId = user.Id, Limit = 50, - IncludeItemTypes = new[] { typeof(Episode).Name }, + IncludeItemTypes = new[] { nameof(Episode) }, ParentId = parent == null ? Guid.Empty : parent.Id, GroupItems = false }, @@ -1190,6 +1461,13 @@ namespace Emby.Dlna.ContentDirectory return ToResult(items); } + /// + /// Returns the latest movies meeting the criteria. + /// + /// The . + /// The . + /// The . + /// The . private QueryResult GetMovieLatest(BaseItem parent, User user, InternalItemsQuery query) { query.OrderBy = Array.Empty<(string, SortOrder)>(); @@ -1208,6 +1486,16 @@ namespace Emby.Dlna.ContentDirectory return ToResult(items); } + /// + /// Returns music artist items that meet the criteria. + /// + /// The . + /// The . + /// The . + /// The . + /// The start index. + /// The maximum number to return. + /// The . private QueryResult GetMusicArtistItems(BaseItem item, Guid parentId, User user, SortCriteria sort, int? startIndex, int? limit) { var query = new InternalItemsQuery(user) @@ -1215,7 +1503,7 @@ namespace Emby.Dlna.ContentDirectory Recursive = true, ParentId = parentId, ArtistIds = new[] { item.Id }, - IncludeItemTypes = new[] { typeof(MusicAlbum).Name }, + IncludeItemTypes = new[] { nameof(MusicAlbum) }, Limit = limit, StartIndex = startIndex, DtoOptions = GetDtoOptions() @@ -1228,6 +1516,16 @@ namespace Emby.Dlna.ContentDirectory return ToResult(result); } + /// + /// Returns the genre items meeting the criteria. + /// + /// The . + /// The . + /// The . + /// The . + /// The start index. + /// The maximum number to return. + /// The . private QueryResult GetGenreItems(BaseItem item, Guid parentId, User user, SortCriteria sort, int? startIndex, int? limit) { var query = new InternalItemsQuery(user) @@ -1252,6 +1550,16 @@ namespace Emby.Dlna.ContentDirectory return ToResult(result); } + /// + /// Returns the music genre items meeting the criteria. + /// + /// The . + /// The . + /// The . + /// The . + /// The start index. + /// The maximum number to return. + /// The . private QueryResult GetMusicGenreItems(BaseItem item, Guid parentId, User user, SortCriteria sort, int? startIndex, int? limit) { var query = new InternalItemsQuery(user) @@ -1259,7 +1567,7 @@ namespace Emby.Dlna.ContentDirectory Recursive = true, ParentId = parentId, GenreIds = new[] { item.Id }, - IncludeItemTypes = new[] { typeof(MusicAlbum).Name }, + IncludeItemTypes = new[] { nameof(MusicAlbum) }, Limit = limit, StartIndex = startIndex, DtoOptions = GetDtoOptions() @@ -1272,7 +1580,12 @@ namespace Emby.Dlna.ContentDirectory return ToResult(result); } - private QueryResult ToResult(BaseItem[] result) + /// + /// Converts a array into a . + /// + /// An array of . + /// A . + private static QueryResult ToResult(BaseItem[] result) { var serverItems = result .Select(i => new ServerItem(i)) @@ -1285,7 +1598,12 @@ namespace Emby.Dlna.ContentDirectory }; } - private QueryResult ToResult(QueryResult result) + /// + /// Converts a to a . + /// + /// A . + /// The . + private static QueryResult ToResult(QueryResult result) { var serverItems = result .Items @@ -1299,7 +1617,13 @@ namespace Emby.Dlna.ContentDirectory }; } - private void SetSorting(InternalItemsQuery query, SortCriteria sort, bool isPreSorted) + /// + /// Sets the sorting method on a query. + /// + /// The . + /// The . + /// True if pre-sorted. + private static void SetSorting(InternalItemsQuery query, SortCriteria sort, bool isPreSorted) { if (isPreSorted) { @@ -1311,21 +1635,37 @@ namespace Emby.Dlna.ContentDirectory } } - private QueryResult ApplyPaging(QueryResult result, int? startIndex, int? limit) + /// + /// Apply paging to a query. + /// + /// The . + /// The start index. + /// The maximum number to return. + /// A . + private static QueryResult ApplyPaging(QueryResult result, int? startIndex, int? limit) { result.Items = result.Items.Skip(startIndex ?? 0).Take(limit ?? int.MaxValue).ToArray(); return result; } + /// + /// Retrieves the ServerItem id. + /// + /// The id. + /// The . private ServerItem GetItemFromObjectId(string id) { return DidlBuilder.IsIdRoot(id) - ? new ServerItem(_libraryManager.GetUserRootFolder()) : ParseItemId(id); } + /// + /// Parses the item id into a . + /// + /// The . + /// The corresponding . private ServerItem ParseItemId(string id) { StubType? stubType = null; @@ -1346,8 +1686,8 @@ namespace Emby.Dlna.ContentDirectory { if (id.StartsWith(name + "_", StringComparison.OrdinalIgnoreCase)) { - stubType = (StubType)Enum.Parse(typeof(StubType), name, true); - id = id.Split(new[] { '_' }, 2)[1]; + stubType = Enum.Parse(name, true); + id = id.Split('_', 2)[1]; break; } diff --git a/Emby.Dlna/ContentDirectory/ServerItem.cs b/Emby.Dlna/ContentDirectory/ServerItem.cs index e406054149..34244000c1 100644 --- a/Emby.Dlna/ContentDirectory/ServerItem.cs +++ b/Emby.Dlna/ContentDirectory/ServerItem.cs @@ -4,8 +4,15 @@ using MediaBrowser.Controller.Entities; namespace Emby.Dlna.ContentDirectory { + /// + /// Defines the . + /// internal class ServerItem { + /// + /// Initializes a new instance of the class. + /// + /// The . public ServerItem(BaseItem item) { Item = item; @@ -16,8 +23,14 @@ namespace Emby.Dlna.ContentDirectory } } + /// + /// Gets or sets the underlying base item. + /// public BaseItem Item { get; set; } + /// + /// Gets or sets the DLNA item type. + /// public StubType? StubType { get; set; } } } diff --git a/Emby.Dlna/ContentDirectory/ServiceActionListBuilder.cs b/Emby.Dlna/ContentDirectory/ServiceActionListBuilder.cs index 921b14e394..7e3db46519 100644 --- a/Emby.Dlna/ContentDirectory/ServiceActionListBuilder.cs +++ b/Emby.Dlna/ContentDirectory/ServiceActionListBuilder.cs @@ -1,13 +1,18 @@ -#pragma warning disable CS1591 - using System.Collections.Generic; using Emby.Dlna.Common; namespace Emby.Dlna.ContentDirectory { - public class ServiceActionListBuilder + /// + /// Defines the . + /// + public static class ServiceActionListBuilder { - public IEnumerable GetActions() + /// + /// Returns a list of services that this instance provides. + /// + /// An . + public static IEnumerable GetActions() { return new[] { @@ -22,6 +27,10 @@ namespace Emby.Dlna.ContentDirectory }; } + /// + /// Returns the action details for "GetSystemUpdateID". + /// + /// The . private static ServiceAction GetGetSystemUpdateIDAction() { var action = new ServiceAction @@ -39,6 +48,10 @@ namespace Emby.Dlna.ContentDirectory return action; } + /// + /// Returns the action details for "GetSearchCapabilities". + /// + /// The . private static ServiceAction GetSearchCapabilitiesAction() { var action = new ServiceAction @@ -56,6 +69,10 @@ namespace Emby.Dlna.ContentDirectory return action; } + /// + /// Returns the action details for "GetSortCapabilities". + /// + /// The . private static ServiceAction GetSortCapabilitiesAction() { var action = new ServiceAction @@ -73,6 +90,10 @@ namespace Emby.Dlna.ContentDirectory return action; } + /// + /// Returns the action details for "X_GetFeatureList". + /// + /// The . private static ServiceAction GetX_GetFeatureListAction() { var action = new ServiceAction @@ -90,6 +111,10 @@ namespace Emby.Dlna.ContentDirectory return action; } + /// + /// Returns the action details for "Search". + /// + /// The . private static ServiceAction GetSearchAction() { var action = new ServiceAction @@ -170,7 +195,11 @@ namespace Emby.Dlna.ContentDirectory return action; } - private ServiceAction GetBrowseAction() + /// + /// Returns the action details for "Browse". + /// + /// The . + private static ServiceAction GetBrowseAction() { var action = new ServiceAction { @@ -250,7 +279,11 @@ namespace Emby.Dlna.ContentDirectory return action; } - private ServiceAction GetBrowseByLetterAction() + /// + /// Returns the action details for "X_BrowseByLetter". + /// + /// The . + private static ServiceAction GetBrowseByLetterAction() { var action = new ServiceAction { @@ -337,7 +370,11 @@ namespace Emby.Dlna.ContentDirectory return action; } - private ServiceAction GetXSetBookmarkAction() + /// + /// Returns the action details for "X_SetBookmark". + /// + /// The . + private static ServiceAction GetXSetBookmarkAction() { var action = new ServiceAction { diff --git a/Emby.Dlna/ContentDirectory/StubType.cs b/Emby.Dlna/ContentDirectory/StubType.cs index eee405d3e7..187dc1d75a 100644 --- a/Emby.Dlna/ContentDirectory/StubType.cs +++ b/Emby.Dlna/ContentDirectory/StubType.cs @@ -1,8 +1,10 @@ #pragma warning disable CS1591 -#pragma warning disable SA1602 namespace Emby.Dlna.ContentDirectory { + /// + /// Defines the DLNA item types. + /// public enum StubType { Folder = 0, diff --git a/Emby.Dlna/ControlRequest.cs b/Emby.Dlna/ControlRequest.cs index 4ea4e4e48c..8ee6325e9e 100644 --- a/Emby.Dlna/ControlRequest.cs +++ b/Emby.Dlna/ControlRequest.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System.IO; diff --git a/Emby.Dlna/ControlResponse.cs b/Emby.Dlna/ControlResponse.cs index d827eef26c..8b09588424 100644 --- a/Emby.Dlna/ControlResponse.cs +++ b/Emby.Dlna/ControlResponse.cs @@ -6,9 +6,11 @@ namespace Emby.Dlna { public class ControlResponse { - public ControlResponse() + public ControlResponse(string xml, bool isSuccessful) { Headers = new Dictionary(); + Xml = xml; + IsSuccessful = isSuccessful; } public IDictionary Headers { get; } diff --git a/Emby.Dlna/Didl/DidlBuilder.cs b/Emby.Dlna/Didl/DidlBuilder.cs index 5b8a89d8f3..2982ce97e1 100644 --- a/Emby.Dlna/Didl/DidlBuilder.cs +++ b/Emby.Dlna/Didl/DidlBuilder.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System; @@ -96,6 +98,7 @@ namespace Emby.Dlna.Didl using (StringWriter builder = new StringWriterWithEncoding(Encoding.UTF8)) { + // If this using are changed to single lines, then write.Flush needs to be appended before the return. using (var writer = XmlWriter.Create(builder, settings)) { // writer.WriteStartDocument(); @@ -123,7 +126,7 @@ namespace Emby.Dlna.Didl { foreach (var att in profile.XmlRootAttributes) { - var parts = att.Name.Split(new[] { ':' }, StringSplitOptions.RemoveEmptyEntries); + var parts = att.Name.Split(':', StringSplitOptions.RemoveEmptyEntries); if (parts.Length == 2) { writer.WriteAttributeString(parts[0], parts[1], null, att.Value); @@ -207,7 +210,8 @@ namespace Emby.Dlna.Didl var targetWidth = streamInfo.TargetWidth; var targetHeight = streamInfo.TargetHeight; - var contentFeatureList = new ContentFeatureBuilder(_profile).BuildVideoHeader( + var contentFeatureList = ContentFeatureBuilder.BuildVideoHeader( + _profile, streamInfo.Container, streamInfo.TargetVideoCodec.FirstOrDefault(), streamInfo.TargetAudioCodec.FirstOrDefault(), @@ -598,7 +602,8 @@ namespace Emby.Dlna.Didl ? MimeTypes.GetMimeType(filename) : mediaProfile.MimeType; - var contentFeatures = new ContentFeatureBuilder(_profile).BuildAudioHeader( + var contentFeatures = ContentFeatureBuilder.BuildAudioHeader( + _profile, streamInfo.Container, streamInfo.TargetAudioCodec.FirstOrDefault(), targetAudioBitrate, @@ -973,15 +978,28 @@ namespace Emby.Dlna.Didl return; } - var albumartUrlInfo = GetImageUrl(imageInfo, _profile.MaxAlbumArtWidth, _profile.MaxAlbumArtHeight, "jpg"); + // TODO: Remove these default values + var albumArtUrlInfo = GetImageUrl( + imageInfo, + _profile.MaxAlbumArtWidth ?? 10000, + _profile.MaxAlbumArtHeight ?? 10000, + "jpg"); writer.WriteStartElement("upnp", "albumArtURI", NsUpnp); - writer.WriteAttributeString("dlna", "profileID", NsDlna, _profile.AlbumArtPn); - writer.WriteString(albumartUrlInfo.url); + if (!string.IsNullOrEmpty(_profile.AlbumArtPn)) + { + writer.WriteAttributeString("dlna", "profileID", NsDlna, _profile.AlbumArtPn); + } + + writer.WriteString(albumArtUrlInfo.url); writer.WriteFullEndElement(); - // TOOD: Remove these default values - var iconUrlInfo = GetImageUrl(imageInfo, _profile.MaxIconWidth ?? 48, _profile.MaxIconHeight ?? 48, "jpg"); + // TODO: Remove these default values + var iconUrlInfo = GetImageUrl( + imageInfo, + _profile.MaxIconWidth ?? 48, + _profile.MaxIconHeight ?? 48, + "jpg"); writer.WriteElementString("upnp", "icon", NsUpnp, iconUrlInfo.url); if (!_profile.EnableAlbumArtInDidl) @@ -1032,8 +1050,7 @@ namespace Emby.Dlna.Didl var width = albumartUrlInfo.width ?? maxWidth; var height = albumartUrlInfo.height ?? maxHeight; - var contentFeatures = new ContentFeatureBuilder(_profile) - .BuildImageHeader(format, width, height, imageInfo.IsDirectStream, org_Pn); + var contentFeatures = ContentFeatureBuilder.BuildImageHeader(_profile, format, width, height, imageInfo.IsDirectStream, org_Pn); writer.WriteAttributeString( "protocolInfo", @@ -1205,8 +1222,7 @@ namespace Emby.Dlna.Didl if (width.HasValue && height.HasValue) { - var newSize = DrawingUtils.Resize( - new ImageDimensions(width.Value, height.Value), 0, 0, maxWidth, maxHeight); + var newSize = DrawingUtils.Resize(new ImageDimensions(width.Value, height.Value), 0, 0, maxWidth, maxHeight); width = newSize.Width; height = newSize.Height; diff --git a/Emby.Dlna/Didl/Filter.cs b/Emby.Dlna/Didl/Filter.cs index b58fdff2c9..d703f043eb 100644 --- a/Emby.Dlna/Didl/Filter.cs +++ b/Emby.Dlna/Didl/Filter.cs @@ -18,7 +18,7 @@ namespace Emby.Dlna.Didl { _all = string.Equals(filter, "*", StringComparison.OrdinalIgnoreCase); - _fields = (filter ?? string.Empty).Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries); + _fields = (filter ?? string.Empty).Split(',', StringSplitOptions.RemoveEmptyEntries); } public bool Contains(string field) diff --git a/Emby.Dlna/Didl/StringWriterWithEncoding.cs b/Emby.Dlna/Didl/StringWriterWithEncoding.cs index 2b86ea333f..b66f53ece2 100644 --- a/Emby.Dlna/Didl/StringWriterWithEncoding.cs +++ b/Emby.Dlna/Didl/StringWriterWithEncoding.cs @@ -9,7 +9,7 @@ namespace Emby.Dlna.Didl { public class StringWriterWithEncoding : StringWriter { - private readonly Encoding _encoding; + private readonly Encoding? _encoding; public StringWriterWithEncoding() { diff --git a/Emby.Dlna/DlnaConfigurationFactory.cs b/Emby.Dlna/DlnaConfigurationFactory.cs index 4c6ca869aa..6cc6b73a0c 100644 --- a/Emby.Dlna/DlnaConfigurationFactory.cs +++ b/Emby.Dlna/DlnaConfigurationFactory.cs @@ -1,4 +1,3 @@ -#nullable enable #pragma warning disable CS1591 using System.Collections.Generic; diff --git a/Emby.Dlna/DlnaManager.cs b/Emby.Dlna/DlnaManager.cs index 1807ac6a13..af70793ccf 100644 --- a/Emby.Dlna/DlnaManager.cs +++ b/Emby.Dlna/DlnaManager.cs @@ -1,5 +1,4 @@ #pragma warning disable CS1591 - using System; using System.Collections.Generic; using System.Globalization; @@ -7,10 +6,12 @@ using System.IO; using System.Linq; using System.Reflection; using System.Text; +using System.Text.Json; using System.Text.RegularExpressions; using System.Threading.Tasks; using Emby.Dlna.Profiles; using Emby.Dlna.Server; +using Jellyfin.Extensions.Json; using MediaBrowser.Common.Configuration; using MediaBrowser.Common.Extensions; using MediaBrowser.Controller; @@ -32,9 +33,9 @@ namespace Emby.Dlna private readonly IXmlSerializer _xmlSerializer; private readonly IFileSystem _fileSystem; private readonly ILogger _logger; - private readonly IJsonSerializer _jsonSerializer; private readonly IServerApplicationHost _appHost; private static readonly Assembly _assembly = typeof(DlnaManager).Assembly; + private readonly JsonSerializerOptions _jsonOptions = JsonDefaults.Options; private readonly Dictionary> _profiles = new Dictionary>(StringComparer.Ordinal); @@ -43,14 +44,12 @@ namespace Emby.Dlna IFileSystem fileSystem, IApplicationPaths appPaths, ILoggerFactory loggerFactory, - IJsonSerializer jsonSerializer, IServerApplicationHost appHost) { _xmlSerializer = xmlSerializer; _fileSystem = fileSystem; _appPaths = appPaths; _logger = loggerFactory.CreateLogger(); - _jsonSerializer = jsonSerializer; _appHost = appHost; } @@ -94,12 +93,14 @@ namespace Emby.Dlna } } + /// public DeviceProfile GetDefaultProfile() { return new DefaultProfile(); } - public DeviceProfile GetProfile(DeviceIdentification deviceInfo) + /// + public DeviceProfile? GetProfile(DeviceIdentification deviceInfo) { if (deviceInfo == null) { @@ -109,13 +110,13 @@ namespace Emby.Dlna var profile = GetProfiles() .FirstOrDefault(i => i.Identification != null && IsMatch(deviceInfo, i.Identification)); - if (profile != null) + if (profile == null) { - _logger.LogDebug("Found matching device profile: {0}", profile.Name); + LogUnmatchedProfile(deviceInfo); } else { - LogUnmatchedProfile(deviceInfo); + _logger.LogDebug("Found matching device profile: {ProfileName}", profile.Name); } return profile; @@ -126,92 +127,57 @@ namespace Emby.Dlna var builder = new StringBuilder(); builder.AppendLine("No matching device profile found. The default will need to be used."); - builder.Append("FriendlyName:").AppendLine(profile.FriendlyName); - builder.Append("Manufacturer:").AppendLine(profile.Manufacturer); - builder.Append("ManufacturerUrl:").AppendLine(profile.ManufacturerUrl); - builder.Append("ModelDescription:").AppendLine(profile.ModelDescription); - builder.Append("ModelName:").AppendLine(profile.ModelName); - builder.Append("ModelNumber:").AppendLine(profile.ModelNumber); - builder.Append("ModelUrl:").AppendLine(profile.ModelUrl); - builder.Append("SerialNumber:").AppendLine(profile.SerialNumber); + builder.Append("FriendlyName: ").AppendLine(profile.FriendlyName); + builder.Append("Manufacturer: ").AppendLine(profile.Manufacturer); + builder.Append("ManufacturerUrl: ").AppendLine(profile.ManufacturerUrl); + builder.Append("ModelDescription: ").AppendLine(profile.ModelDescription); + builder.Append("ModelName: ").AppendLine(profile.ModelName); + builder.Append("ModelNumber: ").AppendLine(profile.ModelNumber); + builder.Append("ModelUrl: ").AppendLine(profile.ModelUrl); + builder.Append("SerialNumber: ").AppendLine(profile.SerialNumber); _logger.LogInformation(builder.ToString()); } - private bool IsMatch(DeviceIdentification deviceInfo, DeviceIdentification profileInfo) + /// + /// Attempts to match a device with a profile. + /// Rules: + /// - If the profile field has no value, the field matches irregardless of its contents. + /// - the profile field can be an exact match, or a reg exp. + /// + /// The of the device. + /// The of the profile. + /// True if they match. + public bool IsMatch(DeviceIdentification deviceInfo, DeviceIdentification profileInfo) { - if (!string.IsNullOrEmpty(profileInfo.FriendlyName)) - { - if (deviceInfo.FriendlyName == null || !IsRegexOrSubstringMatch(deviceInfo.FriendlyName, profileInfo.FriendlyName)) - { - return false; - } - } - - if (!string.IsNullOrEmpty(profileInfo.Manufacturer)) - { - if (deviceInfo.Manufacturer == null || !IsRegexOrSubstringMatch(deviceInfo.Manufacturer, profileInfo.Manufacturer)) - { - return false; - } - } - - if (!string.IsNullOrEmpty(profileInfo.ManufacturerUrl)) - { - if (deviceInfo.ManufacturerUrl == null || !IsRegexOrSubstringMatch(deviceInfo.ManufacturerUrl, profileInfo.ManufacturerUrl)) - { - return false; - } - } - - if (!string.IsNullOrEmpty(profileInfo.ModelDescription)) - { - if (deviceInfo.ModelDescription == null || !IsRegexOrSubstringMatch(deviceInfo.ModelDescription, profileInfo.ModelDescription)) - { - return false; - } - } - - if (!string.IsNullOrEmpty(profileInfo.ModelName)) - { - if (deviceInfo.ModelName == null || !IsRegexOrSubstringMatch(deviceInfo.ModelName, profileInfo.ModelName)) - { - return false; - } - } - - if (!string.IsNullOrEmpty(profileInfo.ModelNumber)) - { - if (deviceInfo.ModelNumber == null || !IsRegexOrSubstringMatch(deviceInfo.ModelNumber, profileInfo.ModelNumber)) - { - return false; - } - } + return IsRegexOrSubstringMatch(deviceInfo.FriendlyName, profileInfo.FriendlyName) + && IsRegexOrSubstringMatch(deviceInfo.Manufacturer, profileInfo.Manufacturer) + && IsRegexOrSubstringMatch(deviceInfo.ManufacturerUrl, profileInfo.ManufacturerUrl) + && IsRegexOrSubstringMatch(deviceInfo.ModelDescription, profileInfo.ModelDescription) + && IsRegexOrSubstringMatch(deviceInfo.ModelName, profileInfo.ModelName) + && IsRegexOrSubstringMatch(deviceInfo.ModelNumber, profileInfo.ModelNumber) + && IsRegexOrSubstringMatch(deviceInfo.ModelUrl, profileInfo.ModelUrl) + && IsRegexOrSubstringMatch(deviceInfo.SerialNumber, profileInfo.SerialNumber); + } - if (!string.IsNullOrEmpty(profileInfo.ModelUrl)) + private bool IsRegexOrSubstringMatch(string input, string pattern) + { + if (string.IsNullOrEmpty(pattern)) { - if (deviceInfo.ModelUrl == null || !IsRegexOrSubstringMatch(deviceInfo.ModelUrl, profileInfo.ModelUrl)) - { - return false; - } + // In profile identification: An empty pattern matches anything. + return true; } - if (!string.IsNullOrEmpty(profileInfo.SerialNumber)) + if (string.IsNullOrEmpty(input)) { - if (deviceInfo.SerialNumber == null || !IsRegexOrSubstringMatch(deviceInfo.SerialNumber, profileInfo.SerialNumber)) - { - return false; - } + // The profile contains a value, and the device doesn't. + return false; } - return true; - } - - private bool IsRegexOrSubstringMatch(string input, string pattern) - { try { - return input.Contains(pattern, StringComparison.OrdinalIgnoreCase) || Regex.IsMatch(input, pattern, RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + return input.Equals(pattern, StringComparison.OrdinalIgnoreCase) + || Regex.IsMatch(input, pattern, RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); } catch (ArgumentException ex) { @@ -220,7 +186,8 @@ namespace Emby.Dlna } } - public DeviceProfile GetProfile(IHeaderDictionary headers) + /// + public DeviceProfile? GetProfile(IHeaderDictionary headers) { if (headers == null) { @@ -228,15 +195,13 @@ namespace Emby.Dlna } var profile = GetProfiles().FirstOrDefault(i => i.Identification != null && IsMatch(headers, i.Identification)); - - if (profile != null) + if (profile == null) { - _logger.LogDebug("Found matching device profile: {0}", profile.Name); + _logger.LogDebug("No matching device profile found. {@Headers}", headers); } else { - var headerString = string.Join(", ", headers.Select(i => string.Format(CultureInfo.InvariantCulture, "{0}={1}", i.Key, i.Value))); - _logger.LogDebug("No matching device profile found. {0}", headerString); + _logger.LogDebug("Found matching device profile: {0}", profile.Name); } return profile; @@ -286,19 +251,19 @@ namespace Emby.Dlna return xmlFies .Select(i => ParseProfileFile(i, type)) .Where(i => i != null) - .ToList(); + .ToList()!; // We just filtered out all the nulls } catch (IOException) { - return new List(); + return Array.Empty(); } } - private DeviceProfile ParseProfileFile(string path, DeviceProfileType type) + private DeviceProfile? ParseProfileFile(string path, DeviceProfileType type) { lock (_profiles) { - if (_profiles.TryGetValue(path, out Tuple profileTuple)) + if (_profiles.TryGetValue(path, out Tuple? profileTuple)) { return profileTuple.Item2; } @@ -326,14 +291,20 @@ namespace Emby.Dlna } } - public DeviceProfile GetProfile(string id) + /// + public DeviceProfile? GetProfile(string id) { if (string.IsNullOrEmpty(id)) { throw new ArgumentNullException(nameof(id)); } - var info = GetProfileInfosInternal().First(i => string.Equals(i.Info.Id, id, StringComparison.OrdinalIgnoreCase)); + var info = GetProfileInfosInternal().FirstOrDefault(i => string.Equals(i.Info.Id, id, StringComparison.OrdinalIgnoreCase)); + + if (info == null) + { + return null; + } return ParseProfileFile(info.Path, info.Info.Type); } @@ -350,6 +321,7 @@ namespace Emby.Dlna } } + /// public IEnumerable GetProfileInfos() { return GetProfileInfosInternal().Select(i => i.Info); @@ -357,17 +329,14 @@ namespace Emby.Dlna private InternalProfileInfo GetInternalProfileInfo(FileSystemMetadata file, DeviceProfileType type) { - return new InternalProfileInfo - { - Path = file.FullName, - - Info = new DeviceProfileInfo + return new InternalProfileInfo( + new DeviceProfileInfo { Id = file.FullName.ToLowerInvariant().GetMD5().ToString("N", CultureInfo.InvariantCulture), Name = _fileSystem.GetFileNameWithoutExtension(file), Type = type - } - }; + }, + file.FullName); } private async Task ExtractSystemProfilesAsync() @@ -383,11 +352,12 @@ namespace Emby.Dlna continue; } - var filename = Path.GetFileName(name).Substring(namespaceName.Length); - - var path = Path.Combine(systemProfilesPath, filename); + var path = Path.Join( + systemProfilesPath, + Path.GetFileName(name.AsSpan()).Slice(namespaceName.Length)); - using (var stream = _assembly.GetManifestResourceStream(name)) + // The stream should exist as we just got its name from GetManifestResourceNames + using (var stream = _assembly.GetManifestResourceStream(name)!) { var fileInfo = _fileSystem.GetFileInfo(path); @@ -395,7 +365,8 @@ namespace Emby.Dlna { Directory.CreateDirectory(systemProfilesPath); - using (var fileStream = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.Read)) + // use FileShare.None as this bypasses dotnet bug dotnet/runtime#42790 . + using (var fileStream = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.None)) { await stream.CopyToAsync(fileStream).ConfigureAwait(false); } @@ -407,6 +378,7 @@ namespace Emby.Dlna Directory.CreateDirectory(UserProfilesPath); } + /// public void DeleteProfile(string id) { var info = GetProfileInfosInternal().First(i => string.Equals(id, i.Info.Id, StringComparison.OrdinalIgnoreCase)); @@ -424,6 +396,7 @@ namespace Emby.Dlna } } + /// public void CreateProfile(DeviceProfile profile) { profile = ReserializeProfile(profile); @@ -439,6 +412,7 @@ namespace Emby.Dlna SaveProfile(profile, path, DeviceProfileType.User); } + /// public void UpdateProfile(DeviceProfile profile) { profile = ReserializeProfile(profile); @@ -484,10 +458,10 @@ namespace Emby.Dlna /// /// Recreates the object using serialization, to ensure it's not a subclass. - /// If it's a subclass it may not serlialize properly to xml (different root element tag name). + /// If it's a subclass it may not serialize properly to xml (different root element tag name). /// /// The device profile. - /// The reserialized device profile. + /// The re-serialized device profile. private DeviceProfile ReserializeProfile(DeviceProfile profile) { if (profile.GetType() == typeof(DeviceProfile)) @@ -495,11 +469,13 @@ namespace Emby.Dlna return profile; } - var json = _jsonSerializer.SerializeToString(profile); + var json = JsonSerializer.Serialize(profile, _jsonOptions); - return _jsonSerializer.DeserializeFromString(json); + // Output can't be null if the input isn't null + return JsonSerializer.Deserialize(json, _jsonOptions)!; } + /// public string GetServerDescriptionXml(IHeaderDictionary headers, string serverUuId, string serverAddress) { var profile = GetDefaultProfile(); @@ -509,6 +485,7 @@ namespace Emby.Dlna return new DescriptionXmlBuilder(profile, serverUuId, serverAddress, _appHost.FriendlyName, serverId).GetXml(); } + /// public ImageStream GetIcon(string filename) { var format = filename.EndsWith(".png", StringComparison.OrdinalIgnoreCase) @@ -526,9 +503,15 @@ namespace Emby.Dlna private class InternalProfileInfo { - internal DeviceProfileInfo Info { get; set; } + internal InternalProfileInfo(DeviceProfileInfo info, string path) + { + Info = info; + Path = path; + } + + internal DeviceProfileInfo Info { get; } - internal string Path { get; set; } + internal string Path { get; } } } @@ -553,7 +536,7 @@ namespace Emby.Dlna private void DumpProfiles() { - DeviceProfile[] list = new [] + DeviceProfile[] list = new[] { new SamsungSmartTvProfile(), new XboxOneProfile(), diff --git a/Emby.Dlna/Emby.Dlna.csproj b/Emby.Dlna/Emby.Dlna.csproj index 6ed49944c0..970c16d2e6 100644 --- a/Emby.Dlna/Emby.Dlna.csproj +++ b/Emby.Dlna/Emby.Dlna.csproj @@ -17,24 +17,19 @@ - netstandard2.1 + net5.0 false true - true + AllDisabledByDefault - - - ../jellyfin.ruleset - - @@ -78,9 +73,7 @@ - - - + diff --git a/Emby.Dlna/EventSubscriptionResponse.cs b/Emby.Dlna/EventSubscriptionResponse.cs index 1b1bd426c5..635d2c47a1 100644 --- a/Emby.Dlna/EventSubscriptionResponse.cs +++ b/Emby.Dlna/EventSubscriptionResponse.cs @@ -6,8 +6,10 @@ namespace Emby.Dlna { public class EventSubscriptionResponse { - public EventSubscriptionResponse() + public EventSubscriptionResponse(string content, string contentType) { + Content = content; + ContentType = contentType; Headers = new Dictionary(); } diff --git a/Emby.Dlna/Eventing/DlnaEventManager.cs b/Emby.Dlna/Eventing/DlnaEventManager.cs index 7d8da86ef9..3c91360904 100644 --- a/Emby.Dlna/Eventing/DlnaEventManager.cs +++ b/Emby.Dlna/Eventing/DlnaEventManager.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System; @@ -49,11 +51,7 @@ namespace Emby.Dlna.Eventing return GetEventSubscriptionResponse(subscriptionId, requestedTimeoutString, timeoutSeconds); } - return new EventSubscriptionResponse - { - Content = string.Empty, - ContentType = "text/plain" - }; + return new EventSubscriptionResponse(string.Empty, "text/plain"); } public EventSubscriptionResponse CreateEventSubscription(string notificationType, string requestedTimeoutString, string callbackUrl) @@ -72,7 +70,8 @@ namespace Emby.Dlna.Eventing Id = id, CallbackUrl = callbackUrl, SubscriptionTime = DateTime.UtcNow, - TimeoutSeconds = timeout + TimeoutSeconds = timeout, + NotificationType = notificationType }); return GetEventSubscriptionResponse(id, requestedTimeoutString, timeout); @@ -83,7 +82,7 @@ namespace Emby.Dlna.Eventing if (!string.IsNullOrEmpty(header)) { // Starts with SECOND- - header = header.Split('-').Last(); + header = header.Split('-')[^1]; if (int.TryParse(header, NumberStyles.Integer, _usCulture, out var val)) { @@ -100,20 +99,12 @@ namespace Emby.Dlna.Eventing _subscriptions.TryRemove(subscriptionId, out _); - return new EventSubscriptionResponse - { - Content = string.Empty, - ContentType = "text/plain" - }; + return new EventSubscriptionResponse(string.Empty, "text/plain"); } private EventSubscriptionResponse GetEventSubscriptionResponse(string subscriptionId, string requestedTimeoutString, int timeoutSeconds) { - var response = new EventSubscriptionResponse - { - Content = string.Empty, - ContentType = "text/plain" - }; + var response = new EventSubscriptionResponse(string.Empty, "text/plain"); response.Headers["SID"] = subscriptionId; response.Headers["TIMEOUT"] = string.IsNullOrEmpty(requestedTimeoutString) ? ("SECOND-" + timeoutSeconds.ToString(_usCulture)) : requestedTimeoutString; @@ -168,7 +159,7 @@ namespace Emby.Dlna.Eventing builder.Append(""); - using var options = new HttpRequestMessage(new HttpMethod("NOTIFY"), subscription.CallbackUrl); + using var options = new HttpRequestMessage(new HttpMethod("NOTIFY"), subscription.CallbackUrl); options.Content = new StringContent(builder.ToString(), Encoding.UTF8, MediaTypeNames.Text.Xml); options.Headers.TryAddWithoutValidation("NT", subscription.NotificationType); options.Headers.TryAddWithoutValidation("NTS", "upnp:propchange"); diff --git a/Emby.Dlna/Eventing/EventSubscription.cs b/Emby.Dlna/Eventing/EventSubscription.cs index 40d73ee0e5..4fd7f81695 100644 --- a/Emby.Dlna/Eventing/EventSubscription.cs +++ b/Emby.Dlna/Eventing/EventSubscription.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System; diff --git a/Emby.Dlna/Main/DlnaEntryPoint.cs b/Emby.Dlna/Main/DlnaEntryPoint.cs index 40c2cc0e0a..5d252d8dc4 100644 --- a/Emby.Dlna/Main/DlnaEntryPoint.cs +++ b/Emby.Dlna/Main/DlnaEntryPoint.cs @@ -1,13 +1,17 @@ +#nullable disable + #pragma warning disable CS1591 using System; using System.Globalization; +using System.Linq; using System.Net.Http; using System.Net.Sockets; -using System.Threading; using System.Threading.Tasks; using Emby.Dlna.PlayTo; using Emby.Dlna.Ssdp; +using Jellyfin.Networking.Configuration; +using Jellyfin.Networking.Manager; using MediaBrowser.Common.Configuration; using MediaBrowser.Common.Extensions; using MediaBrowser.Common.Net; @@ -23,11 +27,9 @@ using MediaBrowser.Controller.TV; using MediaBrowser.Model.Dlna; using MediaBrowser.Model.Globalization; using MediaBrowser.Model.Net; -using MediaBrowser.Model.System; using Microsoft.Extensions.Logging; using Rssdp; using Rssdp.Infrastructure; -using OperatingSystem = MediaBrowser.Common.System.OperatingSystem; namespace Emby.Dlna.Main { @@ -50,6 +52,8 @@ namespace Emby.Dlna.Main private readonly ISocketFactory _socketFactory; private readonly INetworkManager _networkManager; private readonly object _syncLock = new object(); + private readonly NetworkConfiguration _netConfig; + private readonly bool _disabled; private PlayToManager _manager; private SsdpDevicePublisher _publisher; @@ -120,10 +124,23 @@ namespace Emby.Dlna.Main httpClientFactory, config); Current = this; + + _netConfig = config.GetConfiguration("network"); + _disabled = appHost.ListenWithHttps && _netConfig.RequireHttps; + + if (_disabled && _config.GetDlnaConfiguration().EnableServer) + { + _logger.LogError("The DLNA specification does not support HTTPS."); + } } public static DlnaEntryPoint Current { get; private set; } + /// + /// Gets a value indicating whether the dlna server is enabled. + /// + public static bool Enabled { get; private set; } + public IContentDirectory ContentDirectory { get; private set; } public IConnectionManager ConnectionManager { get; private set; } @@ -134,28 +151,35 @@ namespace Emby.Dlna.Main { await ((DlnaManager)_dlnaManager).InitProfilesAsync().ConfigureAwait(false); - await ReloadComponents().ConfigureAwait(false); + if (_disabled) + { + // No use starting as dlna won't work, as we're running purely on HTTPS. + return; + } + + ReloadComponents(); _config.NamedConfigurationUpdated += OnNamedConfigurationUpdated; } - private async void OnNamedConfigurationUpdated(object sender, ConfigurationUpdateEventArgs e) + private void OnNamedConfigurationUpdated(object sender, ConfigurationUpdateEventArgs e) { if (string.Equals(e.Key, "dlna", StringComparison.OrdinalIgnoreCase)) { - await ReloadComponents().ConfigureAwait(false); + ReloadComponents(); } } - private async Task ReloadComponents() + private void ReloadComponents() { var options = _config.GetDlnaConfiguration(); + Enabled = options.EnableServer; StartSsdpHandler(); if (options.EnableServer) { - await StartDevicePublisher(options).ConfigureAwait(false); + StartDevicePublisher(options); } else { @@ -178,8 +202,8 @@ namespace Emby.Dlna.Main { if (_communicationsServer == null) { - var enableMultiSocketBinding = OperatingSystem.Id == OperatingSystemId.Windows || - OperatingSystem.Id == OperatingSystemId.Linux; + var enableMultiSocketBinding = OperatingSystem.IsWindows() || + OperatingSystem.IsLinux(); _communicationsServer = new SsdpCommunicationsServer(_socketFactory, _networkManager, _logger, enableMultiSocketBinding) { @@ -204,7 +228,10 @@ namespace Emby.Dlna.Main { try { - ((DeviceDiscovery)_deviceDiscovery).Start(communicationsServer); + if (communicationsServer != null) + { + ((DeviceDiscovery)_deviceDiscovery).Start(communicationsServer); + } } catch (Exception ex) { @@ -225,7 +252,7 @@ namespace Emby.Dlna.Main } } - public async Task StartDevicePublisher(Configuration.DlnaOptions options) + public void StartDevicePublisher(Configuration.DlnaOptions options) { if (!options.BlastAliveMessages) { @@ -239,13 +266,18 @@ namespace Emby.Dlna.Main try { - _publisher = new SsdpDevicePublisher(_communicationsServer, _networkManager, OperatingSystem.Name, Environment.OSVersion.VersionString, _config.GetDlnaConfiguration().SendOnlyMatchedHost) + _publisher = new SsdpDevicePublisher( + _communicationsServer, + _networkManager, + MediaBrowser.Common.System.OperatingSystem.Name, + Environment.OSVersion.VersionString, + _config.GetDlnaConfiguration().SendOnlyMatchedHost) { LogFunction = LogMessage, SupportPnpRootDevice = false }; - await RegisterServerEndpoints().ConfigureAwait(false); + RegisterServerEndpoints(); _publisher.StartBroadcastingAliveMessages(TimeSpan.FromSeconds(options.BlastAliveMessageIntervalSeconds)); } @@ -255,13 +287,22 @@ namespace Emby.Dlna.Main } } - private async Task RegisterServerEndpoints() + private void RegisterServerEndpoints() { - var addresses = await _appHost.GetLocalIpAddresses(CancellationToken.None).ConfigureAwait(false); - var udn = CreateUuid(_appHost.SystemId); + var descriptorUri = "/dlna/" + udn + "/description.xml"; - foreach (var address in addresses) + var bindAddresses = NetworkManager.CreateCollection( + _networkManager.GetInternalBindAddresses() + .Where(i => i.AddressFamily == AddressFamily.InterNetwork || (i.AddressFamily == AddressFamily.InterNetworkV6 && i.Address.ScopeId != 0))); + + if (bindAddresses.Count == 0) + { + // No interfaces returned, so use loopback. + bindAddresses = _networkManager.GetLoopbacks(); + } + + foreach (IPNetAddress address in bindAddresses) { if (address.AddressFamily == AddressFamily.InterNetworkV6) { @@ -270,7 +311,7 @@ namespace Emby.Dlna.Main } // Limit to LAN addresses only - if (!_networkManager.IsAddressInSubnets(address, true, true)) + if (!_networkManager.IsInLocalNetwork(address)) { continue; } @@ -279,15 +320,20 @@ namespace Emby.Dlna.Main _logger.LogInformation("Registering publisher for {0} on {1}", fullService, address); - var descriptorUri = "/dlna/" + udn + "/description.xml"; - var uri = new Uri(_appHost.GetLocalApiUrl(address) + descriptorUri); + var uri = new UriBuilder(_appHost.GetSmartApiUrl(address.Address) + descriptorUri); + if (!string.IsNullOrEmpty(_appHost.PublishedServerUrl)) + { + // DLNA will only work over http, so we must reset to http:// : {port}. + uri.Scheme = "http"; + uri.Port = _netConfig.HttpServerPortNumber; + } var device = new SsdpRootDevice { CacheLifetime = TimeSpan.FromSeconds(1800), // How long SSDP clients can cache this info. - Location = uri, // Must point to the URL that serves your devices UPnP description document. - Address = address, - SubnetMask = _networkManager.GetLocalIpSubnetMask(address), + Location = uri.Uri, // Must point to the URL that serves your devices UPnP description document. + Address = address.Address, + PrefixLength = address.PrefixLength, FriendlyName = "Jellyfin", Manufacturer = "Jellyfin", ModelName = "Jellyfin Server", diff --git a/Emby.Dlna/MediaReceiverRegistrar/ControlHandler.cs b/Emby.Dlna/MediaReceiverRegistrar/ControlHandler.cs index 464f71a6f1..d8fb127420 100644 --- a/Emby.Dlna/MediaReceiverRegistrar/ControlHandler.cs +++ b/Emby.Dlna/MediaReceiverRegistrar/ControlHandler.cs @@ -24,7 +24,7 @@ namespace Emby.Dlna.MediaReceiverRegistrar } /// - protected override void WriteResult(string methodName, IDictionary methodParams, XmlWriter xmlWriter) + protected override void WriteResult(string methodName, IReadOnlyDictionary methodParams, XmlWriter xmlWriter) { if (string.Equals(methodName, "IsAuthorized", StringComparison.OrdinalIgnoreCase)) { diff --git a/Emby.Dlna/MediaReceiverRegistrar/MediaReceiverRegistrarXmlBuilder.cs b/Emby.Dlna/MediaReceiverRegistrar/MediaReceiverRegistrarXmlBuilder.cs index 37840cd096..f3789a791c 100644 --- a/Emby.Dlna/MediaReceiverRegistrar/MediaReceiverRegistrarXmlBuilder.cs +++ b/Emby.Dlna/MediaReceiverRegistrar/MediaReceiverRegistrarXmlBuilder.cs @@ -1,7 +1,6 @@ using System.Collections.Generic; using Emby.Dlna.Common; using Emby.Dlna.Service; -using MediaBrowser.Model.Dlna; namespace Emby.Dlna.MediaReceiverRegistrar { diff --git a/Emby.Dlna/MediaReceiverRegistrar/ServiceActionListBuilder.cs b/Emby.Dlna/MediaReceiverRegistrar/ServiceActionListBuilder.cs index 1dc9c79c14..56788ae223 100644 --- a/Emby.Dlna/MediaReceiverRegistrar/ServiceActionListBuilder.cs +++ b/Emby.Dlna/MediaReceiverRegistrar/ServiceActionListBuilder.cs @@ -1,6 +1,5 @@ using System.Collections.Generic; using Emby.Dlna.Common; -using MediaBrowser.Model.Dlna; namespace Emby.Dlna.MediaReceiverRegistrar { diff --git a/Emby.Dlna/PlayTo/Device.cs b/Emby.Dlna/PlayTo/Device.cs index c97acdb026..11fcd81cff 100644 --- a/Emby.Dlna/PlayTo/Device.cs +++ b/Emby.Dlna/PlayTo/Device.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System; @@ -12,8 +14,6 @@ using System.Xml; using System.Xml.Linq; using Emby.Dlna.Common; using Emby.Dlna.Ssdp; -using MediaBrowser.Common.Net; -using MediaBrowser.Controller.Configuration; using Microsoft.Extensions.Logging; namespace Emby.Dlna.PlayTo @@ -221,7 +221,7 @@ namespace Emby.Dlna.PlayTo { var rendererCommands = await GetRenderingProtocolAsync(cancellationToken).ConfigureAwait(false); - var command = rendererCommands.ServiceActions.FirstOrDefault(c => c.Name == "SetMute"); + var command = rendererCommands?.ServiceActions.FirstOrDefault(c => c.Name == "SetMute"); if (command == null) { return false; @@ -237,7 +237,13 @@ namespace Emby.Dlna.PlayTo _logger.LogDebug("Setting mute"); var value = mute ? 1 : 0; - await new SsdpHttpClient(_httpClientFactory).SendCommandAsync(Properties.BaseUrl, service, command.Name, rendererCommands.BuildPost(command, service.ServiceType, value)) + await new SsdpHttpClient(_httpClientFactory) + .SendCommandAsync( + Properties.BaseUrl, + service, + command.Name, + rendererCommands.BuildPost(command, service.ServiceType, value), + cancellationToken: cancellationToken) .ConfigureAwait(false); IsMuted = mute; @@ -255,7 +261,7 @@ namespace Emby.Dlna.PlayTo { var rendererCommands = await GetRenderingProtocolAsync(cancellationToken).ConfigureAwait(false); - var command = rendererCommands.ServiceActions.FirstOrDefault(c => c.Name == "SetVolume"); + var command = rendererCommands?.ServiceActions.FirstOrDefault(c => c.Name == "SetVolume"); if (command == null) { return; @@ -272,7 +278,13 @@ namespace Emby.Dlna.PlayTo // Remote control will perform better Volume = value; - await new SsdpHttpClient(_httpClientFactory).SendCommandAsync(Properties.BaseUrl, service, command.Name, rendererCommands.BuildPost(command, service.ServiceType, value)) + await new SsdpHttpClient(_httpClientFactory) + .SendCommandAsync( + Properties.BaseUrl, + service, + command.Name, + rendererCommands.BuildPost(command, service.ServiceType, value), + cancellationToken: cancellationToken) .ConfigureAwait(false); } @@ -280,7 +292,7 @@ namespace Emby.Dlna.PlayTo { var avCommands = await GetAVProtocolAsync(cancellationToken).ConfigureAwait(false); - var command = avCommands.ServiceActions.FirstOrDefault(c => c.Name == "Seek"); + var command = avCommands?.ServiceActions.FirstOrDefault(c => c.Name == "Seek"); if (command == null) { return; @@ -293,7 +305,13 @@ namespace Emby.Dlna.PlayTo throw new InvalidOperationException("Unable to find service"); } - await new SsdpHttpClient(_httpClientFactory).SendCommandAsync(Properties.BaseUrl, service, command.Name, avCommands.BuildPost(command, service.ServiceType, string.Format(CultureInfo.InvariantCulture, "{0:hh}:{0:mm}:{0:ss}", value), "REL_TIME")) + await new SsdpHttpClient(_httpClientFactory) + .SendCommandAsync( + Properties.BaseUrl, + service, + command.Name, + avCommands.BuildPost(command, service.ServiceType, string.Format(CultureInfo.InvariantCulture, "{0:hh}:{0:mm}:{0:ss}", value), "REL_TIME"), + cancellationToken: cancellationToken) .ConfigureAwait(false); RestartTimer(true); @@ -307,7 +325,7 @@ namespace Emby.Dlna.PlayTo _logger.LogDebug("{0} - SetAvTransport Uri: {1} DlnaHeaders: {2}", Properties.Name, url, header); - var command = avCommands.ServiceActions.FirstOrDefault(c => c.Name == "SetAVTransportURI"); + var command = avCommands?.ServiceActions.FirstOrDefault(c => c.Name == "SetAVTransportURI"); if (command == null) { return; @@ -327,14 +345,21 @@ namespace Emby.Dlna.PlayTo } var post = avCommands.BuildPost(command, service.ServiceType, url, dictionary); - await new SsdpHttpClient(_httpClientFactory).SendCommandAsync(Properties.BaseUrl, service, command.Name, post, header: header) + await new SsdpHttpClient(_httpClientFactory) + .SendCommandAsync( + Properties.BaseUrl, + service, + command.Name, + post, + header: header, + cancellationToken: cancellationToken) .ConfigureAwait(false); - await Task.Delay(50).ConfigureAwait(false); + await Task.Delay(50, cancellationToken).ConfigureAwait(false); try { - await SetPlay(avCommands, CancellationToken.None).ConfigureAwait(false); + await SetPlay(avCommands, cancellationToken).ConfigureAwait(false); } catch { @@ -345,7 +370,43 @@ namespace Emby.Dlna.PlayTo RestartTimer(true); } - private string CreateDidlMeta(string value) + /* + * SetNextAvTransport is used to specify to the DLNA device what is the next track to play. + * Without that information, the next track command on the device does not work. + */ + public async Task SetNextAvTransport(string url, string header, string metaData, CancellationToken cancellationToken = default) + { + var avCommands = await GetAVProtocolAsync(cancellationToken).ConfigureAwait(false); + + url = url.Replace("&", "&", StringComparison.Ordinal); + + _logger.LogDebug("{PropertyName} - SetNextAvTransport Uri: {Url} DlnaHeaders: {Header}", Properties.Name, url, header); + + var command = avCommands.ServiceActions.FirstOrDefault(c => string.Equals(c.Name, "SetNextAVTransportURI", StringComparison.OrdinalIgnoreCase)); + if (command == null) + { + return; + } + + var dictionary = new Dictionary + { + { "NextURI", url }, + { "NextURIMetaData", CreateDidlMeta(metaData) } + }; + + var service = GetAvTransportService(); + + if (service == null) + { + throw new InvalidOperationException("Unable to find service"); + } + + var post = avCommands.BuildPost(command, service.ServiceType, url, dictionary); + await new SsdpHttpClient(_httpClientFactory).SendCommandAsync(Properties.BaseUrl, service, command.Name, post, header: header, cancellationToken) + .ConfigureAwait(false); + } + + private static string CreateDidlMeta(string value) { if (string.IsNullOrEmpty(value)) { @@ -380,6 +441,10 @@ namespace Emby.Dlna.PlayTo public async Task SetPlay(CancellationToken cancellationToken) { var avCommands = await GetAVProtocolAsync(cancellationToken).ConfigureAwait(false); + if (avCommands == null) + { + return; + } await SetPlay(avCommands, cancellationToken).ConfigureAwait(false); @@ -390,7 +455,7 @@ namespace Emby.Dlna.PlayTo { var avCommands = await GetAVProtocolAsync(cancellationToken).ConfigureAwait(false); - var command = avCommands.ServiceActions.FirstOrDefault(c => c.Name == "Stop"); + var command = avCommands?.ServiceActions.FirstOrDefault(c => c.Name == "Stop"); if (command == null) { return; @@ -398,7 +463,13 @@ namespace Emby.Dlna.PlayTo var service = GetAvTransportService(); - await new SsdpHttpClient(_httpClientFactory).SendCommandAsync(Properties.BaseUrl, service, command.Name, avCommands.BuildPost(command, service.ServiceType, 1)) + await new SsdpHttpClient(_httpClientFactory) + .SendCommandAsync( + Properties.BaseUrl, + service, + command.Name, + avCommands.BuildPost(command, service.ServiceType, 1), + cancellationToken: cancellationToken) .ConfigureAwait(false); RestartTimer(true); @@ -408,7 +479,7 @@ namespace Emby.Dlna.PlayTo { var avCommands = await GetAVProtocolAsync(cancellationToken).ConfigureAwait(false); - var command = avCommands.ServiceActions.FirstOrDefault(c => c.Name == "Pause"); + var command = avCommands?.ServiceActions.FirstOrDefault(c => c.Name == "Pause"); if (command == null) { return; @@ -416,7 +487,13 @@ namespace Emby.Dlna.PlayTo var service = GetAvTransportService(); - await new SsdpHttpClient(_httpClientFactory).SendCommandAsync(Properties.BaseUrl, service, command.Name, avCommands.BuildPost(command, service.ServiceType, 1)) + await new SsdpHttpClient(_httpClientFactory) + .SendCommandAsync( + Properties.BaseUrl, + service, + command.Name, + avCommands.BuildPost(command, service.ServiceType, 1), + cancellationToken: cancellationToken) .ConfigureAwait(false); TransportState = TransportState.Paused; @@ -480,7 +557,7 @@ namespace Emby.Dlna.PlayTo return; } - // If we're not playing anything make sure we don't get data more often than neccessry to keep the Session alive + // If we're not playing anything make sure we don't get data more often than necessary to keep the Session alive if (transportState.Value == TransportState.Stopped) { RestartTimerInactive(); @@ -530,7 +607,7 @@ namespace Emby.Dlna.PlayTo var rendererCommands = await GetRenderingProtocolAsync(cancellationToken).ConfigureAwait(false); - var command = rendererCommands.ServiceActions.FirstOrDefault(c => c.Name == "GetVolume"); + var command = rendererCommands?.ServiceActions.FirstOrDefault(c => c.Name == "GetVolume"); if (command == null) { return; @@ -580,7 +657,7 @@ namespace Emby.Dlna.PlayTo var rendererCommands = await GetRenderingProtocolAsync(cancellationToken).ConfigureAwait(false); - var command = rendererCommands.ServiceActions.FirstOrDefault(c => c.Name == "GetMute"); + var command = rendererCommands?.ServiceActions.FirstOrDefault(c => c.Name == "GetMute"); if (command == null) { return; @@ -667,6 +744,10 @@ namespace Emby.Dlna.PlayTo } var rendererCommands = await GetRenderingProtocolAsync(cancellationToken).ConfigureAwait(false); + if (rendererCommands == null) + { + return null; + } var result = await new SsdpHttpClient(_httpClientFactory).SendCommandAsync( Properties.BaseUrl, @@ -735,6 +816,11 @@ namespace Emby.Dlna.PlayTo var rendererCommands = await GetRenderingProtocolAsync(cancellationToken).ConfigureAwait(false); + if (rendererCommands == null) + { + return (false, null); + } + var result = await new SsdpHttpClient(_httpClientFactory).SendCommandAsync( Properties.BaseUrl, service, @@ -775,7 +861,7 @@ namespace Emby.Dlna.PlayTo if (track == null) { - // If track is null, some vendors do this, use GetMediaInfo instead + // If track is null, some vendors do this, use GetMediaInfo instead. return (true, null); } @@ -812,7 +898,7 @@ namespace Emby.Dlna.PlayTo private XElement ParseResponse(string xml) { - // Handle different variations sent back by devices + // Handle different variations sent back by devices. try { return XElement.Parse(xml); @@ -821,7 +907,7 @@ namespace Emby.Dlna.PlayTo { } - // first try to add a root node with a dlna namesapce + // first try to add a root node with a dlna namespace. try { return XElement.Parse("" + xml + "") @@ -916,6 +1002,10 @@ namespace Emby.Dlna.PlayTo var httpClient = new SsdpHttpClient(_httpClientFactory); var document = await httpClient.GetDataAsync(url, cancellationToken).ConfigureAwait(false); + if (document == null) + { + return null; + } AvCommands = TransportCommands.Create(document); return AvCommands; @@ -944,6 +1034,10 @@ namespace Emby.Dlna.PlayTo var httpClient = new SsdpHttpClient(_httpClientFactory); _logger.LogDebug("Dlna Device.GetRenderingProtocolAsync"); var document = await httpClient.GetDataAsync(url, cancellationToken).ConfigureAwait(false); + if (document == null) + { + return null; + } RendererCommands = TransportCommands.Create(document); return RendererCommands; @@ -962,7 +1056,7 @@ namespace Emby.Dlna.PlayTo url = "/dmr/" + url; } - if (!url.StartsWith("/", StringComparison.Ordinal)) + if (!url.StartsWith('/')) { url = "/" + url; } @@ -975,6 +1069,10 @@ namespace Emby.Dlna.PlayTo var ssdpHttpClient = new SsdpHttpClient(httpClientFactory); var document = await ssdpHttpClient.GetDataAsync(url.ToString(), cancellationToken).ConfigureAwait(false); + if (document == null) + { + return null; + } var friendlyNames = new List(); @@ -992,7 +1090,7 @@ namespace Emby.Dlna.PlayTo var deviceProperties = new DeviceInfo() { - Name = string.Join(" ", friendlyNames), + Name = string.Join(' ', friendlyNames), BaseUrl = string.Format(CultureInfo.InvariantCulture, "http://{0}:{1}", url.Host, url.Port) }; @@ -1162,10 +1260,7 @@ namespace Emby.Dlna.PlayTo return; } - PlaybackStart?.Invoke(this, new PlaybackStartEventArgs - { - MediaInfo = mediaInfo - }); + PlaybackStart?.Invoke(this, new PlaybackStartEventArgs(mediaInfo)); } private void OnPlaybackProgress(UBaseObject mediaInfo) @@ -1175,27 +1270,17 @@ namespace Emby.Dlna.PlayTo return; } - PlaybackProgress?.Invoke(this, new PlaybackProgressEventArgs - { - MediaInfo = mediaInfo - }); + PlaybackProgress?.Invoke(this, new PlaybackProgressEventArgs(mediaInfo)); } private void OnPlaybackStop(UBaseObject mediaInfo) { - PlaybackStopped?.Invoke(this, new PlaybackStoppedEventArgs - { - MediaInfo = mediaInfo - }); + PlaybackStopped?.Invoke(this, new PlaybackStoppedEventArgs(mediaInfo)); } private void OnMediaChanged(UBaseObject old, UBaseObject newMedia) { - MediaChanged?.Invoke(this, new MediaChangedEventArgs - { - OldMediaInfo = old, - NewMediaInfo = newMedia - }); + MediaChanged?.Invoke(this, new MediaChangedEventArgs(old, newMedia)); } /// diff --git a/Emby.Dlna/PlayTo/DeviceInfo.cs b/Emby.Dlna/PlayTo/DeviceInfo.cs index d3daab9e0a..2acfff4eb6 100644 --- a/Emby.Dlna/PlayTo/DeviceInfo.cs +++ b/Emby.Dlna/PlayTo/DeviceInfo.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System.Collections.Generic; diff --git a/Emby.Dlna/PlayTo/MediaChangedEventArgs.cs b/Emby.Dlna/PlayTo/MediaChangedEventArgs.cs index dabd079afd..0f7a524d62 100644 --- a/Emby.Dlna/PlayTo/MediaChangedEventArgs.cs +++ b/Emby.Dlna/PlayTo/MediaChangedEventArgs.cs @@ -1,4 +1,4 @@ -#pragma warning disable CS1591 +#pragma warning disable CS1591 using System; @@ -6,6 +6,12 @@ namespace Emby.Dlna.PlayTo { public class MediaChangedEventArgs : EventArgs { + public MediaChangedEventArgs(UBaseObject oldMediaInfo, UBaseObject newMediaInfo) + { + OldMediaInfo = oldMediaInfo; + NewMediaInfo = newMediaInfo; + } + public UBaseObject OldMediaInfo { get; set; } public UBaseObject NewMediaInfo { get; set; } diff --git a/Emby.Dlna/PlayTo/PlayToController.cs b/Emby.Dlna/PlayTo/PlayToController.cs index a5b8e2b3ce..0e49fd2c02 100644 --- a/Emby.Dlna/PlayTo/PlayToController.cs +++ b/Emby.Dlna/PlayTo/PlayToController.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System; @@ -9,7 +11,6 @@ using System.Threading.Tasks; using Emby.Dlna.Didl; using Jellyfin.Data.Entities; using Jellyfin.Data.Events; -using MediaBrowser.Common.Configuration; using MediaBrowser.Controller.Dlna; using MediaBrowser.Controller.Drawing; using MediaBrowser.Controller.Entities; @@ -41,7 +42,6 @@ namespace Emby.Dlna.PlayTo private readonly IUserDataManager _userDataManager; private readonly ILocalizationManager _localization; private readonly IMediaSourceManager _mediaSourceManager; - private readonly IConfigurationManager _config; private readonly IMediaEncoder _mediaEncoder; private readonly IDeviceDiscovery _deviceDiscovery; @@ -68,7 +68,6 @@ namespace Emby.Dlna.PlayTo IUserDataManager userDataManager, ILocalizationManager localization, IMediaSourceManager mediaSourceManager, - IConfigurationManager config, IMediaEncoder mediaEncoder) { _session = session; @@ -84,7 +83,6 @@ namespace Emby.Dlna.PlayTo _userDataManager = userDataManager; _localization = localization; _mediaSourceManager = mediaSourceManager; - _config = config; _mediaEncoder = mediaEncoder; } @@ -106,6 +104,22 @@ namespace Emby.Dlna.PlayTo _deviceDiscovery.DeviceLeft += OnDeviceDiscoveryDeviceLeft; } + /* + * Send a message to the DLNA device to notify what is the next track in the playlist. + */ + private async Task SendNextTrackMessage(int currentPlayListItemIndex, CancellationToken cancellationToken) + { + if (currentPlayListItemIndex >= 0 && currentPlayListItemIndex < _playlist.Count - 1) + { + // The current playing item is indeed in the play list and we are not yet at the end of the playlist. + var nextItemIndex = currentPlayListItemIndex + 1; + var nextItem = _playlist[nextItemIndex]; + + // Send the SetNextAvTransport message. + await _device.SetNextAvTransport(nextItem.StreamUrl, GetDlnaHeaders(nextItem), nextItem.Didl, cancellationToken).ConfigureAwait(false); + } + } + private void OnDeviceUnavailable() { try @@ -136,7 +150,7 @@ namespace Emby.Dlna.PlayTo private async void OnDeviceMediaChanged(object sender, MediaChangedEventArgs e) { - if (_disposed) + if (_disposed || string.IsNullOrEmpty(e.OldMediaInfo.Url)) { return; } @@ -160,6 +174,15 @@ namespace Emby.Dlna.PlayTo var newItemProgress = GetProgressInfo(streamInfo); await _sessionManager.OnPlaybackStart(newItemProgress).ConfigureAwait(false); + + // Send a message to the DLNA device to notify what is the next track in the playlist. + var currentItemIndex = _playlist.FindIndex(item => item.StreamInfo.ItemId == streamInfo.ItemId); + if (currentItemIndex >= 0) + { + _currentPlaylistIndex = currentItemIndex; + } + + await SendNextTrackMessage(currentItemIndex, CancellationToken.None); } catch (Exception ex) { @@ -326,7 +349,7 @@ namespace Emby.Dlna.PlayTo public Task SendPlayCommand(PlayRequest command, CancellationToken cancellationToken) { - _logger.LogDebug("{0} - Received PlayRequest: {1}", this._session.DeviceName, command.PlayCommand); + _logger.LogDebug("{0} - Received PlayRequest: {1}", _session.DeviceName, command.PlayCommand); var user = command.ControllingUserId.Equals(Guid.Empty) ? null : _userManager.GetUserById(command.ControllingUserId); @@ -337,25 +360,26 @@ namespace Emby.Dlna.PlayTo } var startIndex = command.StartIndex ?? 0; + int len = items.Count - startIndex; if (startIndex > 0) { - items = items.Skip(startIndex).ToList(); + items = items.GetRange(startIndex, len); } - var playlist = new List(); - var isFirst = true; + var playlist = new PlaylistItem[len]; + + // Not nullable enabled - so this is required. + playlist[0] = CreatePlaylistItem( + items[0], + user, + command.StartPositionTicks ?? 0, + command.MediaSourceId ?? string.Empty, + command.AudioStreamIndex, + command.SubtitleStreamIndex); - foreach (var item in items) + for (int i = 1; i < len; i++) { - if (isFirst && command.StartPositionTicks.HasValue) - { - playlist.Add(CreatePlaylistItem(item, user, command.StartPositionTicks.Value, command.MediaSourceId, command.AudioStreamIndex, command.SubtitleStreamIndex)); - isFirst = false; - } - else - { - playlist.Add(CreatePlaylistItem(item, user, 0, null, null, null)); - } + playlist[i] = CreatePlaylistItem(items[i], user, 0, string.Empty, null, null); } _logger.LogDebug("{0} - Playlist created", _session.DeviceName); @@ -428,6 +452,11 @@ namespace Emby.Dlna.PlayTo var newItem = CreatePlaylistItem(info.Item, user, newPosition, info.MediaSourceId, info.AudioStreamIndex, info.SubtitleStreamIndex); await _device.SetAvTransport(newItem.StreamUrl, GetDlnaHeaders(newItem), newItem.Didl, CancellationToken.None).ConfigureAwait(false); + + // Send a message to the DLNA device to notify what is the next track in the play list. + var newItemIndex = _playlist.FindIndex(item => item.StreamUrl == newItem.StreamUrl); + await SendNextTrackMessage(newItemIndex, CancellationToken.None); + return; } @@ -468,8 +497,8 @@ namespace Emby.Dlna.PlayTo _dlnaManager.GetDefaultProfile(); var mediaSources = item is IHasMediaSources - ? _mediaSourceManager.GetStaticMediaSources(item, true, user) - : new List(); + ? _mediaSourceManager.GetStaticMediaSources(item, true, user).ToArray() + : Array.Empty(); var playlistItem = GetPlaylistItem(item, mediaSources, profile, _session.DeviceId, mediaSourceId, audioStreamIndex, subtitleStreamIndex); playlistItem.StreamInfo.StartPositionTicks = startPostionTicks; @@ -502,8 +531,8 @@ namespace Emby.Dlna.PlayTo if (streamInfo.MediaType == DlnaProfileType.Audio) { - return new ContentFeatureBuilder(profile) - .BuildAudioHeader( + return ContentFeatureBuilder.BuildAudioHeader( + profile, streamInfo.Container, streamInfo.TargetAudioCodec.FirstOrDefault(), streamInfo.TargetAudioBitrate, @@ -517,8 +546,8 @@ namespace Emby.Dlna.PlayTo if (streamInfo.MediaType == DlnaProfileType.Video) { - var list = new ContentFeatureBuilder(profile) - .BuildVideoHeader( + var list = ContentFeatureBuilder.BuildVideoHeader( + profile, streamInfo.Container, streamInfo.TargetVideoCodec.FirstOrDefault(), streamInfo.TargetAudioCodec.FirstOrDefault(), @@ -548,7 +577,7 @@ namespace Emby.Dlna.PlayTo return null; } - private PlaylistItem GetPlaylistItem(BaseItem item, List 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)) { @@ -557,7 +586,7 @@ namespace Emby.Dlna.PlayTo StreamInfo = new StreamBuilder(_mediaEncoder, _logger).BuildVideoItem(new VideoOptions { ItemId = item.Id, - MediaSources = mediaSources.ToArray(), + MediaSources = mediaSources, Profile = profile, DeviceId = deviceId, MaxBitrate = profile.MaxStreamingBitrate, @@ -577,7 +606,7 @@ namespace Emby.Dlna.PlayTo StreamInfo = new StreamBuilder(_mediaEncoder, _logger).BuildAudioItem(new AudioOptions { ItemId = item.Id, - MediaSources = mediaSources.ToArray(), + MediaSources = mediaSources, Profile = profile, DeviceId = deviceId, MaxBitrate = profile.MaxStreamingBitrate, @@ -590,7 +619,7 @@ namespace Emby.Dlna.PlayTo if (string.Equals(item.MediaType, MediaType.Photo, StringComparison.OrdinalIgnoreCase)) { - return new PlaylistItemFactory().Create((Photo)item, profile); + return PlaylistItemFactory.Create((Photo)item, profile); } throw new ArgumentException("Unrecognized item type."); @@ -626,6 +655,9 @@ namespace Emby.Dlna.PlayTo await _device.SetAvTransport(currentitem.StreamUrl, GetDlnaHeaders(currentitem), currentitem.Didl, cancellationToken).ConfigureAwait(false); + // Send a message to the DLNA device to notify what is the next track in the play list. + await SendNextTrackMessage(index, cancellationToken); + var streamInfo = currentitem.StreamInfo; if (streamInfo.StartPositionTicks > 0 && EnableClientSideSeek(streamInfo)) { @@ -739,6 +771,10 @@ namespace Emby.Dlna.PlayTo await _device.SetAvTransport(newItem.StreamUrl, GetDlnaHeaders(newItem), newItem.Didl, CancellationToken.None).ConfigureAwait(false); + // Send a message to the DLNA device to notify what is the next track in the play list. + var newItemIndex = _playlist.FindIndex(item => item.StreamUrl == newItem.StreamUrl); + await SendNextTrackMessage(newItemIndex, CancellationToken.None); + if (EnableClientSideSeek(newItem.StreamInfo)) { await SeekAfterTransportChange(newPosition, CancellationToken.None).ConfigureAwait(false); @@ -764,6 +800,10 @@ namespace Emby.Dlna.PlayTo await _device.SetAvTransport(newItem.StreamUrl, GetDlnaHeaders(newItem), newItem.Didl, CancellationToken.None).ConfigureAwait(false); + // Send a message to the DLNA device to notify what is the next track in the play list. + var newItemIndex = _playlist.FindIndex(item => item.StreamUrl == newItem.StreamUrl); + await SendNextTrackMessage(newItemIndex, CancellationToken.None); + if (EnableClientSideSeek(newItem.StreamInfo) && newPosition > 0) { await SeekAfterTransportChange(newPosition, CancellationToken.None).ConfigureAwait(false); @@ -774,13 +814,14 @@ namespace Emby.Dlna.PlayTo private async Task SeekAfterTransportChange(long positionTicks, CancellationToken cancellationToken) { - const int maxWait = 15000000; - const int interval = 500; + const int MaxWait = 15000000; + const int Interval = 500; + var currentWait = 0; - while (_device.TransportState != TransportState.Playing && currentWait < maxWait) + while (_device.TransportState != TransportState.Playing && currentWait < MaxWait) { - await Task.Delay(interval).ConfigureAwait(false); - currentWait += interval; + await Task.Delay(Interval, cancellationToken).ConfigureAwait(false); + currentWait += Interval; } await _device.Seek(TimeSpan.FromTicks(positionTicks), cancellationToken).ConfigureAwait(false); @@ -828,7 +869,7 @@ namespace Emby.Dlna.PlayTo return SendPlayCommand(data as PlayRequest, cancellationToken); } - if (name == SessionMessageType.PlayState) + if (name == SessionMessageType.Playstate) { return SendPlaystateCommand(data as PlaystateRequest, cancellationToken); } @@ -898,16 +939,16 @@ namespace Emby.Dlna.PlayTo var parts = url.Split('/'); - for (var i = 0; i < parts.Length; i++) + for (var i = 0; i < parts.Length - 1; i++) { var part = parts[i]; if (string.Equals(part, "audio", StringComparison.OrdinalIgnoreCase) || string.Equals(part, "videos", StringComparison.OrdinalIgnoreCase)) { - if (parts.Length > i + 1) + if (Guid.TryParse(parts[i + 1], out var result)) { - return Guid.Parse(parts[i + 1]); + return result; } } } @@ -946,7 +987,6 @@ namespace Emby.Dlna.PlayTo request.MediaSourceId = values.GetValueOrDefault("MediaSourceId"); request.LiveStreamId = values.GetValueOrDefault("LiveStreamId"); request.IsDirectStream = string.Equals("true", values.GetValueOrDefault("Static"), StringComparison.OrdinalIgnoreCase); - request.AudioStreamIndex = GetIntValue(values, "AudioStreamIndex"); request.SubtitleStreamIndex = GetIntValue(values, "SubtitleStreamIndex"); request.StartPositionTicks = GetLongValue(values, "StartPositionTicks"); diff --git a/Emby.Dlna/PlayTo/PlayToManager.cs b/Emby.Dlna/PlayTo/PlayToManager.cs index e93aef3043..35bf5927c4 100644 --- a/Emby.Dlna/PlayTo/PlayToManager.cs +++ b/Emby.Dlna/PlayTo/PlayToManager.cs @@ -1,15 +1,15 @@ +#nullable disable + #pragma warning disable CS1591 using System; using System.Globalization; using System.Linq; -using System.Net; using System.Net.Http; using System.Threading; using System.Threading.Tasks; using Jellyfin.Data.Events; using MediaBrowser.Common.Extensions; -using MediaBrowser.Common.Net; using MediaBrowser.Controller; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Dlna; @@ -89,13 +89,10 @@ namespace Emby.Dlna.PlayTo nt = string.Empty; } - string location = info.Location.ToString(); - // It has to report that it's a media renderer - if (usn.IndexOf("MediaRenderer:", StringComparison.OrdinalIgnoreCase) == -1 && - nt.IndexOf("MediaRenderer:", StringComparison.OrdinalIgnoreCase) == -1) + if (!usn.Contains("MediaRenderer:", StringComparison.OrdinalIgnoreCase) + && !nt.Contains("MediaRenderer:", StringComparison.OrdinalIgnoreCase)) { - // _logger.LogDebug("Upnp device {0} does not contain a MediaRenderer device (0).", location); return; } @@ -115,7 +112,7 @@ namespace Emby.Dlna.PlayTo return; } - await AddDevice(info, location, cancellationToken).ConfigureAwait(false); + await AddDevice(info, cancellationToken).ConfigureAwait(false); } catch (OperationCanceledException) { @@ -130,39 +127,50 @@ namespace Emby.Dlna.PlayTo } } - private static string GetUuid(string usn) + internal static string GetUuid(string usn) { const string UuidStr = "uuid:"; const string UuidColonStr = "::"; var index = usn.IndexOf(UuidStr, StringComparison.OrdinalIgnoreCase); + if (index == -1) + { + return usn.GetMD5().ToString("N", CultureInfo.InvariantCulture); + } + + ReadOnlySpan tmp = usn.AsSpan()[(index + UuidStr.Length)..]; + + index = tmp.IndexOf(UuidColonStr, StringComparison.OrdinalIgnoreCase); if (index != -1) { - return usn.Substring(index + UuidStr.Length); + tmp = tmp[..index]; } - index = usn.IndexOf(UuidColonStr, StringComparison.OrdinalIgnoreCase); + index = tmp.IndexOf('{'); if (index != -1) { - usn = usn.Substring(0, index + UuidColonStr.Length); + int endIndex = tmp.IndexOf('}'); + if (endIndex != -1) + { + tmp = tmp[(index + 1)..endIndex]; + } } - return usn.GetMD5().ToString("N", CultureInfo.InvariantCulture); + return tmp.ToString(); } - private async Task AddDevice(UpnpDeviceInfo info, string location, CancellationToken cancellationToken) + private async Task AddDevice(UpnpDeviceInfo info, CancellationToken cancellationToken) { var uri = info.Location; - _logger.LogDebug("Attempting to create PlayToController from location {0}", location); + _logger.LogDebug("Attempting to create PlayToController from location {0}", uri); - _logger.LogDebug("Logging session activity from location {0}", location); if (info.Headers.TryGetValue("USN", out string uuid)) { uuid = GetUuid(uuid); } else { - uuid = location.GetMD5().ToString("N", CultureInfo.InvariantCulture); + uuid = uri.ToString().GetMD5().ToString("N", CultureInfo.InvariantCulture); } var sessionInfo = _sessionManager.LogSessionActivity("DLNA", _appHost.ApplicationVersionString, uuid, null, uri.OriginalString, null); @@ -172,20 +180,17 @@ namespace Emby.Dlna.PlayTo if (controller == null) { var device = await Device.CreateuPnpDeviceAsync(uri, _httpClientFactory, _logger, cancellationToken).ConfigureAwait(false); + if (device == null) + { + _logger.LogError("Ignoring device as xml response is invalid."); + return; + } string deviceName = device.Properties.Name; _sessionManager.UpdateDeviceName(sessionInfo.Id, deviceName); - string serverAddress; - if (info.LocalIpAddress == null || info.LocalIpAddress.Equals(IPAddress.Any) || info.LocalIpAddress.Equals(IPAddress.IPv6Any)) - { - serverAddress = await _appHost.GetLocalApiUrl(cancellationToken).ConfigureAwait(false); - } - else - { - serverAddress = _appHost.GetLocalApiUrl(info.LocalIpAddress); - } + string serverAddress = _appHost.GetSmartApiUrl(info.RemoteIpAddress); controller = new PlayToController( sessionInfo, @@ -201,7 +206,6 @@ namespace Emby.Dlna.PlayTo _userDataManager, _localization, _mediaSourceManager, - _config, _mediaEncoder); sessionInfo.AddController(controller); diff --git a/Emby.Dlna/PlayTo/PlaybackProgressEventArgs.cs b/Emby.Dlna/PlayTo/PlaybackProgressEventArgs.cs index d14617c8a0..c95d8b1e84 100644 --- a/Emby.Dlna/PlayTo/PlaybackProgressEventArgs.cs +++ b/Emby.Dlna/PlayTo/PlaybackProgressEventArgs.cs @@ -6,6 +6,11 @@ namespace Emby.Dlna.PlayTo { public class PlaybackProgressEventArgs : EventArgs { + public PlaybackProgressEventArgs(UBaseObject mediaInfo) + { + MediaInfo = mediaInfo; + } + public UBaseObject MediaInfo { get; set; } } } diff --git a/Emby.Dlna/PlayTo/PlaybackStartEventArgs.cs b/Emby.Dlna/PlayTo/PlaybackStartEventArgs.cs index 3f8d552636..619c861ed9 100644 --- a/Emby.Dlna/PlayTo/PlaybackStartEventArgs.cs +++ b/Emby.Dlna/PlayTo/PlaybackStartEventArgs.cs @@ -6,6 +6,11 @@ namespace Emby.Dlna.PlayTo { public class PlaybackStartEventArgs : EventArgs { + public PlaybackStartEventArgs(UBaseObject mediaInfo) + { + MediaInfo = mediaInfo; + } + public UBaseObject MediaInfo { get; set; } } } diff --git a/Emby.Dlna/PlayTo/PlaybackStoppedEventArgs.cs b/Emby.Dlna/PlayTo/PlaybackStoppedEventArgs.cs index deeb47918d..d0ec250591 100644 --- a/Emby.Dlna/PlayTo/PlaybackStoppedEventArgs.cs +++ b/Emby.Dlna/PlayTo/PlaybackStoppedEventArgs.cs @@ -6,6 +6,11 @@ namespace Emby.Dlna.PlayTo { public class PlaybackStoppedEventArgs : EventArgs { + public PlaybackStoppedEventArgs(UBaseObject mediaInfo) + { + MediaInfo = mediaInfo; + } + public UBaseObject MediaInfo { get; set; } } } diff --git a/Emby.Dlna/PlayTo/PlaylistItem.cs b/Emby.Dlna/PlayTo/PlaylistItem.cs index 85846166cf..5056e69ae7 100644 --- a/Emby.Dlna/PlayTo/PlaylistItem.cs +++ b/Emby.Dlna/PlayTo/PlaylistItem.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using MediaBrowser.Model.Dlna; diff --git a/Emby.Dlna/PlayTo/PlaylistItemFactory.cs b/Emby.Dlna/PlayTo/PlaylistItemFactory.cs index bedc8b9ad3..6574913032 100644 --- a/Emby.Dlna/PlayTo/PlaylistItemFactory.cs +++ b/Emby.Dlna/PlayTo/PlaylistItemFactory.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System.IO; @@ -8,9 +10,9 @@ using MediaBrowser.Model.Session; namespace Emby.Dlna.PlayTo { - public class PlaylistItemFactory + public static class PlaylistItemFactory { - public PlaylistItem Create(Photo item, DeviceProfile profile) + public static PlaylistItem Create(Photo item, DeviceProfile profile) { var playlistItem = new PlaylistItem { diff --git a/Emby.Dlna/PlayTo/SsdpHttpClient.cs b/Emby.Dlna/PlayTo/SsdpHttpClient.cs index c8c36fc972..f14f73bb6f 100644 --- a/Emby.Dlna/PlayTo/SsdpHttpClient.cs +++ b/Emby.Dlna/PlayTo/SsdpHttpClient.cs @@ -1,10 +1,10 @@ +#nullable disable + #pragma warning disable CS1591 using System; using System.Globalization; -using System.IO; using System.Net.Http; -using System.Net.Http.Headers; using System.Net.Mime; using System.Text; using System.Threading; @@ -45,11 +45,11 @@ namespace Emby.Dlna.PlayTo header, cancellationToken) .ConfigureAwait(false); - await using var stream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false); - using var reader = new StreamReader(stream, Encoding.UTF8); - return XDocument.Parse( - await reader.ReadToEndAsync().ConfigureAwait(false), - LoadOptions.PreserveWhitespace); + await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); + return await XDocument.LoadAsync( + stream, + LoadOptions.PreserveWhitespace, + cancellationToken).ConfigureAwait(false); } private static string NormalizeServiceUrl(string baseUrl, string serviceUrl) @@ -60,7 +60,7 @@ namespace Emby.Dlna.PlayTo return serviceUrl; } - if (!serviceUrl.StartsWith("/", StringComparison.Ordinal)) + if (!serviceUrl.StartsWith('/')) { serviceUrl = "/" + serviceUrl; } @@ -94,11 +94,18 @@ namespace Emby.Dlna.PlayTo options.Headers.UserAgent.ParseAdd(USERAGENT); options.Headers.TryAddWithoutValidation("FriendlyName.DLNA.ORG", FriendlyName); using var response = await _httpClientFactory.CreateClient(NamedClient.Default).SendAsync(options, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); - await using var stream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false); - using var reader = new StreamReader(stream, Encoding.UTF8); - return XDocument.Parse( - await reader.ReadToEndAsync().ConfigureAwait(false), - LoadOptions.PreserveWhitespace); + await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); + try + { + return await XDocument.LoadAsync( + stream, + LoadOptions.PreserveWhitespace, + cancellationToken).ConfigureAwait(false); + } + catch + { + return null; + } } private async Task PostSoapDataAsync( diff --git a/Emby.Dlna/PlayTo/TransportCommands.cs b/Emby.Dlna/PlayTo/TransportCommands.cs index fda17a8b41..b58669355d 100644 --- a/Emby.Dlna/PlayTo/TransportCommands.cs +++ b/Emby.Dlna/PlayTo/TransportCommands.cs @@ -13,12 +13,10 @@ namespace Emby.Dlna.PlayTo public class TransportCommands { private const string CommandBase = "\r\n" + "" + "" + "" + "{2}" + "" + ""; - private List _stateVariables = new List(); - private List _serviceActions = new List(); - public List StateVariables => _stateVariables; + public List StateVariables { get; } = new List(); - public List ServiceActions => _serviceActions; + public List ServiceActions { get; } = new List(); public static TransportCommands Create(XDocument document) { @@ -48,7 +46,7 @@ namespace Emby.Dlna.PlayTo { var serviceAction = new ServiceAction { - Name = container.GetValue(UPnpNamespaces.Svc + "name"), + Name = container.GetValue(UPnpNamespaces.Svc + "name") ?? string.Empty, }; var argumentList = serviceAction.ArgumentList; @@ -70,15 +68,15 @@ namespace Emby.Dlna.PlayTo return new Argument { - Name = container.GetValue(UPnpNamespaces.Svc + "name"), - Direction = container.GetValue(UPnpNamespaces.Svc + "direction"), - RelatedStateVariable = container.GetValue(UPnpNamespaces.Svc + "relatedStateVariable") + Name = container.GetValue(UPnpNamespaces.Svc + "name") ?? string.Empty, + Direction = container.GetValue(UPnpNamespaces.Svc + "direction") ?? string.Empty, + RelatedStateVariable = container.GetValue(UPnpNamespaces.Svc + "relatedStateVariable") ?? string.Empty }; } private static StateVariable FromXml(XElement container) { - var allowedValues = new List(); + var allowedValues = Array.Empty(); var element = container.Descendants(UPnpNamespaces.Svc + "allowedValueList") .FirstOrDefault(); @@ -86,14 +84,14 @@ namespace Emby.Dlna.PlayTo { var values = element.Descendants(UPnpNamespaces.Svc + "allowedValue"); - allowedValues.AddRange(values.Select(child => child.Value)); + allowedValues = values.Select(child => child.Value).ToArray(); } return new StateVariable { - Name = container.GetValue(UPnpNamespaces.Svc + "name"), - DataType = container.GetValue(UPnpNamespaces.Svc + "dataType"), - AllowedValues = allowedValues.ToArray() + Name = container.GetValue(UPnpNamespaces.Svc + "name") ?? string.Empty, + DataType = container.GetValue(UPnpNamespaces.Svc + "dataType") ?? string.Empty, + AllowedValues = allowedValues }; } @@ -103,12 +101,12 @@ namespace Emby.Dlna.PlayTo foreach (var arg in action.ArgumentList) { - if (arg.Direction == "out") + if (string.Equals(arg.Direction, "out", StringComparison.Ordinal)) { continue; } - if (arg.Name == "InstanceID") + if (string.Equals(arg.Name, "InstanceID", StringComparison.Ordinal)) { stateString += BuildArgumentXml(arg, "0"); } @@ -127,12 +125,12 @@ namespace Emby.Dlna.PlayTo foreach (var arg in action.ArgumentList) { - if (arg.Direction == "out") + if (string.Equals(arg.Direction, "out", StringComparison.Ordinal)) { continue; } - if (arg.Name == "InstanceID") + if (string.Equals(arg.Name, "InstanceID", StringComparison.Ordinal)) { stateString += BuildArgumentXml(arg, "0"); } @@ -151,7 +149,7 @@ namespace Emby.Dlna.PlayTo foreach (var arg in action.ArgumentList) { - if (arg.Name == "InstanceID") + if (string.Equals(arg.Name, "InstanceID", StringComparison.Ordinal)) { stateString += BuildArgumentXml(arg, "0"); } @@ -168,7 +166,7 @@ namespace Emby.Dlna.PlayTo return string.Format(CultureInfo.InvariantCulture, CommandBase, action.Name, xmlNamesapce, stateString); } - private string BuildArgumentXml(Argument argument, string value, string commandParameter = "") + private string BuildArgumentXml(Argument argument, string? value, string commandParameter = "") { var state = StateVariables.FirstOrDefault(a => string.Equals(a.Name, argument.RelatedStateVariable, StringComparison.OrdinalIgnoreCase)); diff --git a/Emby.Dlna/PlayTo/TransportState.cs b/Emby.Dlna/PlayTo/TransportState.cs index 7068a5d24d..2058e9dc79 100644 --- a/Emby.Dlna/PlayTo/TransportState.cs +++ b/Emby.Dlna/PlayTo/TransportState.cs @@ -1,5 +1,4 @@ #pragma warning disable CS1591 -#pragma warning disable SA1602 namespace Emby.Dlna.PlayTo { diff --git a/Emby.Dlna/PlayTo/uBaseObject.cs b/Emby.Dlna/PlayTo/uBaseObject.cs index 0d9478e42e..02d2da58d6 100644 --- a/Emby.Dlna/PlayTo/uBaseObject.cs +++ b/Emby.Dlna/PlayTo/uBaseObject.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System; diff --git a/Emby.Dlna/Profiles/DefaultProfile.cs b/Emby.Dlna/Profiles/DefaultProfile.cs index d4af72b626..8eaf12ba9a 100644 --- a/Emby.Dlna/Profiles/DefaultProfile.cs +++ b/Emby.Dlna/Profiles/DefaultProfile.cs @@ -1,5 +1,7 @@ #pragma warning disable CS1591 +using System; +using System.Globalization; using System.Linq; using MediaBrowser.Model.Dlna; @@ -10,6 +12,7 @@ namespace Emby.Dlna.Profiles { public DefaultProfile() { + Id = Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture); Name = "Generic Device"; ProtocolInfo = "http-get:*:video/mpeg:*,http-get:*:video/mp4:*,http-get:*:video/vnd.dlna.mpeg-tts:*,http-get:*:video/avi:*,http-get:*:video/x-matroska:*,http-get:*:video/x-ms-wmv:*,http-get:*:video/wtv:*,http-get:*:audio/mpeg:*,http-get:*:audio/mp3:*,http-get:*:audio/mp4:*,http-get:*:audio/x-ms-wma:*,http-get:*:audio/wav:*,http-get:*:audio/L16:*,http-get:*:image/jpeg:*,http-get:*:image/png:*,http-get:*:image/gif:*,http-get:*:image/tiff:*"; diff --git a/Emby.Dlna/Profiles/SonyBravia2010Profile.cs b/Emby.Dlna/Profiles/SonyBravia2010Profile.cs index 8ab4acd1be..9f0d82b8f8 100644 --- a/Emby.Dlna/Profiles/SonyBravia2010Profile.cs +++ b/Emby.Dlna/Profiles/SonyBravia2010Profile.cs @@ -13,7 +13,7 @@ namespace Emby.Dlna.Profiles Identification = new DeviceIdentification { - FriendlyName = @"KDL-\d{2}[EHLNPB]X\d[01]\d.*", + FriendlyName = @"KDL-[0-9]{2}[EHLNPB]X[0-9][01][0-9].*", Manufacturer = "Sony", Headers = new[] @@ -21,7 +21,7 @@ namespace Emby.Dlna.Profiles new HttpHeaderInfo { Name = "X-AV-Client-Info", - Value = @".*KDL-\d{2}[EHLNPB]X\d[01]\d.*", + Value = @".*KDL-[0-9]{2}[EHLNPB]X[0-9][01][0-9].*", Match = HeaderMatchType.Regex } } diff --git a/Emby.Dlna/Profiles/SonyBravia2011Profile.cs b/Emby.Dlna/Profiles/SonyBravia2011Profile.cs index 42d253394c..dfb91817ac 100644 --- a/Emby.Dlna/Profiles/SonyBravia2011Profile.cs +++ b/Emby.Dlna/Profiles/SonyBravia2011Profile.cs @@ -13,7 +13,7 @@ namespace Emby.Dlna.Profiles Identification = new DeviceIdentification { - FriendlyName = @"KDL-\d{2}([A-Z]X\d2\d|CX400).*", + FriendlyName = @"KDL-[0-9]{2}([A-Z]X[0-9]2[0-9]|CX400).*", Manufacturer = "Sony", Headers = new[] @@ -21,7 +21,7 @@ namespace Emby.Dlna.Profiles new HttpHeaderInfo { Name = "X-AV-Client-Info", - Value = @".*KDL-\d{2}([A-Z]X\d2\d|CX400).*", + Value = @".*KDL-[0-9]{2}([A-Z]X[0-9]2[0-9]|CX400).*", Match = HeaderMatchType.Regex } } diff --git a/Emby.Dlna/Profiles/SonyBravia2012Profile.cs b/Emby.Dlna/Profiles/SonyBravia2012Profile.cs index 0598e83422..d59ee38d74 100644 --- a/Emby.Dlna/Profiles/SonyBravia2012Profile.cs +++ b/Emby.Dlna/Profiles/SonyBravia2012Profile.cs @@ -13,7 +13,7 @@ namespace Emby.Dlna.Profiles Identification = new DeviceIdentification { - FriendlyName = @"KDL-\d{2}[A-Z]X\d5(\d|G).*", + FriendlyName = @"KDL-[0-9]{2}[A-Z]X[0-9]5([0-9]|G).*", Manufacturer = "Sony", Headers = new[] @@ -21,7 +21,7 @@ namespace Emby.Dlna.Profiles new HttpHeaderInfo { Name = "X-AV-Client-Info", - Value = @".*KDL-\d{2}[A-Z]X\d5(\d|G).*", + Value = @".*KDL-[0-9]{2}[A-Z]X[0-9]5([0-9]|G).*", Match = HeaderMatchType.Regex } } diff --git a/Emby.Dlna/Profiles/SonyBravia2013Profile.cs b/Emby.Dlna/Profiles/SonyBravia2013Profile.cs index 3d90a1e72d..73b0fd67eb 100644 --- a/Emby.Dlna/Profiles/SonyBravia2013Profile.cs +++ b/Emby.Dlna/Profiles/SonyBravia2013Profile.cs @@ -13,7 +13,7 @@ namespace Emby.Dlna.Profiles Identification = new DeviceIdentification { - FriendlyName = @"KDL-\d{2}[WR][5689]\d{2}A.*", + FriendlyName = @"KDL-[0-9]{2}[WR][5689][0-9]{2}A.*", Manufacturer = "Sony", Headers = new[] @@ -21,7 +21,7 @@ namespace Emby.Dlna.Profiles new HttpHeaderInfo { Name = "X-AV-Client-Info", - Value = @".*KDL-\d{2}[WR][5689]\d{2}A.*", + Value = @".*KDL-[0-9]{2}[WR][5689][0-9]{2}A.*", Match = HeaderMatchType.Regex } } diff --git a/Emby.Dlna/Profiles/SonyBravia2014Profile.cs b/Emby.Dlna/Profiles/SonyBravia2014Profile.cs index 9188f73ef1..db8ee5750f 100644 --- a/Emby.Dlna/Profiles/SonyBravia2014Profile.cs +++ b/Emby.Dlna/Profiles/SonyBravia2014Profile.cs @@ -13,7 +13,7 @@ namespace Emby.Dlna.Profiles Identification = new DeviceIdentification { - FriendlyName = @"(KDL-\d{2}W[5-9]\d{2}B|KDL-\d{2}R480|XBR-\d{2}X[89]\d{2}B|KD-\d{2}[SX][89]\d{3}B).*", + FriendlyName = @"(KDL-[0-9]{2}W[5-9][0-9]{2}B|KDL-[0-9]{2}R480|XBR-[0-9]{2}X[89][0-9]{2}B|KD-[0-9]{2}[SX][89][0-9]{3}B).*", Manufacturer = "Sony", Headers = new[] @@ -21,7 +21,7 @@ namespace Emby.Dlna.Profiles new HttpHeaderInfo { Name = "X-AV-Client-Info", - Value = @".*(KDL-\d{2}W[5-9]\d{2}B|KDL-\d{2}R480|XBR-\d{2}X[89]\d{2}B|KD-\d{2}[SX][89]\d{3}B).*", + Value = @".*(KDL-[0-9]{2}W[5-9][0-9]{2}B|KDL-[0-9]{2}R480|XBR-[0-9]{2}X[89][0-9]{2}B|KD-[0-9]{2}[SX][89][0-9]{3}B).*", Match = HeaderMatchType.Regex } } diff --git a/Emby.Dlna/Profiles/SonyPs3Profile.cs b/Emby.Dlna/Profiles/SonyPs3Profile.cs index d56b1df507..e4a7a3a596 100644 --- a/Emby.Dlna/Profiles/SonyPs3Profile.cs +++ b/Emby.Dlna/Profiles/SonyPs3Profile.cs @@ -52,7 +52,7 @@ namespace Emby.Dlna.Profiles Container = "ts,mpegts", Type = DlnaProfileType.Video, VideoCodec = "mpeg1video,mpeg2video,h264", - AudioCodec = "ac3,mp2,mp3,aac" + AudioCodec = "aac,ac3,mp2" }, new DirectPlayProfile { @@ -92,7 +92,7 @@ namespace Emby.Dlna.Profiles { Container = "ts", VideoCodec = "h264", - AudioCodec = "ac3,aac,mp3", + AudioCodec = "aac,ac3,mp2", Type = DlnaProfileType.Video }, new TranscodingProfile diff --git a/Emby.Dlna/Profiles/SonyPs4Profile.cs b/Emby.Dlna/Profiles/SonyPs4Profile.cs index db56094e2b..985df0c9a5 100644 --- a/Emby.Dlna/Profiles/SonyPs4Profile.cs +++ b/Emby.Dlna/Profiles/SonyPs4Profile.cs @@ -52,7 +52,7 @@ namespace Emby.Dlna.Profiles Container = "ts,mpegts", Type = DlnaProfileType.Video, VideoCodec = "mpeg1video,mpeg2video,h264", - AudioCodec = "ac3,mp2,mp3,aac" + AudioCodec = "aac,ac3,mp2" }, new DirectPlayProfile { @@ -94,7 +94,7 @@ namespace Emby.Dlna.Profiles { Container = "ts", VideoCodec = "h264", - AudioCodec = "mp3", + AudioCodec = "aac,ac3,mp2", Type = DlnaProfileType.Video }, new TranscodingProfile diff --git a/Emby.Dlna/Profiles/Xml/Sony Bravia (2010).xml b/Emby.Dlna/Profiles/Xml/Sony Bravia (2010).xml index f20e9fcb6f..1461db3117 100644 --- a/Emby.Dlna/Profiles/Xml/Sony Bravia (2010).xml +++ b/Emby.Dlna/Profiles/Xml/Sony Bravia (2010).xml @@ -3,10 +3,10 @@ xmlns:xsd="http://www.w3.org/2001/XMLSchema"> Sony Bravia (2010) - KDL-\d{2}[EHLNPB]X\d[01]\d.* + KDL-[0-9]{2}[EHLNPB]X[0-9][01][0-9].* Sony - + Microsoft Corporation diff --git a/Emby.Dlna/Profiles/Xml/Sony Bravia (2011).xml b/Emby.Dlna/Profiles/Xml/Sony Bravia (2011).xml index e516ff512d..7c5f2b1817 100644 --- a/Emby.Dlna/Profiles/Xml/Sony Bravia (2011).xml +++ b/Emby.Dlna/Profiles/Xml/Sony Bravia (2011).xml @@ -3,10 +3,10 @@ xmlns:xsd="http://www.w3.org/2001/XMLSchema"> Sony Bravia (2011) - KDL-\d{2}([A-Z]X\d2\d|CX400).* + KDL-[0-9]{2}([A-Z]X[0-9]2[0-9]|CX400).* Sony - + Microsoft Corporation diff --git a/Emby.Dlna/Profiles/Xml/Sony Bravia (2012).xml b/Emby.Dlna/Profiles/Xml/Sony Bravia (2012).xml index 88bd1c2f53..842a8fba33 100644 --- a/Emby.Dlna/Profiles/Xml/Sony Bravia (2012).xml +++ b/Emby.Dlna/Profiles/Xml/Sony Bravia (2012).xml @@ -3,10 +3,10 @@ xmlns:xsd="http://www.w3.org/2001/XMLSchema"> Sony Bravia (2012) - KDL-\d{2}[A-Z]X\d5(\d|G).* + KDL-[0-9]{2}[A-Z]X[0-9]5([0-9]|G).* Sony - + Microsoft Corporation diff --git a/Emby.Dlna/Profiles/Xml/Sony Bravia (2013).xml b/Emby.Dlna/Profiles/Xml/Sony Bravia (2013).xml index 3ca9893cdc..f1135c3fe3 100644 --- a/Emby.Dlna/Profiles/Xml/Sony Bravia (2013).xml +++ b/Emby.Dlna/Profiles/Xml/Sony Bravia (2013).xml @@ -3,10 +3,10 @@ xmlns:xsd="http://www.w3.org/2001/XMLSchema"> Sony Bravia (2013) - KDL-\d{2}[WR][5689]\d{2}A.* + KDL-[0-9]{2}[WR][5689][0-9]{2}A.* Sony - + Microsoft Corporation diff --git a/Emby.Dlna/Profiles/Xml/Sony Bravia (2014).xml b/Emby.Dlna/Profiles/Xml/Sony Bravia (2014).xml index 8804a75dfa..85c7868c66 100644 --- a/Emby.Dlna/Profiles/Xml/Sony Bravia (2014).xml +++ b/Emby.Dlna/Profiles/Xml/Sony Bravia (2014).xml @@ -3,10 +3,10 @@ xmlns:xsd="http://www.w3.org/2001/XMLSchema"> Sony Bravia (2014) - (KDL-\d{2}W[5-9]\d{2}B|KDL-\d{2}R480|XBR-\d{2}X[89]\d{2}B|KD-\d{2}[SX][89]\d{3}B).* + (KDL-[0-9]{2}W[5-9][0-9]{2}B|KDL-[0-9]{2}R480|XBR-[0-9]{2}X[89][0-9]{2}B|KD-[0-9]{2}[SX][89][0-9]{3}B).* Sony - + Microsoft Corporation diff --git a/Emby.Dlna/Profiles/Xml/Sony PlayStation 3.xml b/Emby.Dlna/Profiles/Xml/Sony PlayStation 3.xml index bafa44b828..129b188e2a 100644 --- a/Emby.Dlna/Profiles/Xml/Sony PlayStation 3.xml +++ b/Emby.Dlna/Profiles/Xml/Sony PlayStation 3.xml @@ -38,7 +38,7 @@ - + @@ -46,7 +46,7 @@ - + diff --git a/Emby.Dlna/Profiles/Xml/Sony PlayStation 4.xml b/Emby.Dlna/Profiles/Xml/Sony PlayStation 4.xml index eb8e645b31..592119305e 100644 --- a/Emby.Dlna/Profiles/Xml/Sony PlayStation 4.xml +++ b/Emby.Dlna/Profiles/Xml/Sony PlayStation 4.xml @@ -38,7 +38,7 @@ - + @@ -46,7 +46,7 @@ - + diff --git a/Emby.Dlna/Properties/AssemblyInfo.cs b/Emby.Dlna/Properties/AssemblyInfo.cs index a2c1e0db8b..606ffcf4fd 100644 --- a/Emby.Dlna/Properties/AssemblyInfo.cs +++ b/Emby.Dlna/Properties/AssemblyInfo.cs @@ -1,5 +1,6 @@ using System.Reflection; using System.Resources; +using System.Runtime.CompilerServices; // General Information about an assembly is controlled through the following // set of attributes. Change these attribute values to modify the information @@ -13,6 +14,7 @@ using System.Resources; [assembly: AssemblyTrademark("")] [assembly: AssemblyCulture("")] [assembly: NeutralResourcesLanguage("en")] +[assembly: InternalsVisibleTo("Jellyfin.Dlna.Tests")] // Version information for an assembly consists of the following four values: // diff --git a/Emby.Dlna/Server/DescriptionXmlBuilder.cs b/Emby.Dlna/Server/DescriptionXmlBuilder.cs index bca9e81cd0..3f3dfccd3a 100644 --- a/Emby.Dlna/Server/DescriptionXmlBuilder.cs +++ b/Emby.Dlna/Server/DescriptionXmlBuilder.cs @@ -40,8 +40,6 @@ namespace Emby.Dlna.Server _serverId = serverId; } - private static bool EnableAbsoluteUrls => false; - public string GetXml() { var builder = new StringBuilder(); @@ -75,13 +73,6 @@ namespace Emby.Dlna.Server builder.Append("0"); builder.Append(""); - if (!EnableAbsoluteUrls) - { - builder.Append("") - .Append(SecurityElement.Escape(_serverAddress)) - .Append(""); - } - AppendDeviceInfo(builder); builder.Append(""); @@ -257,16 +248,10 @@ namespace Emby.Dlna.Server return string.Empty; } - url = url.TrimStart('/'); - - url = "/dlna/" + _serverUdn + "/" + url; - - if (EnableAbsoluteUrls) - { - url = _serverAddress.TrimEnd('/') + url; - } + url = _serverAddress.TrimEnd('/') + "/dlna/" + _serverUdn + "/" + url.TrimStart('/'); - return SecurityElement.Escape(url); + // TODO: @bond remove null-coalescing operator when https://github.com/dotnet/runtime/pull/52442 is merged/released + return SecurityElement.Escape(url) ?? string.Empty; } private IEnumerable GetIcons() diff --git a/Emby.Dlna/Service/BaseControlHandler.cs b/Emby.Dlna/Service/BaseControlHandler.cs index 4b0bbba969..581e4a2861 100644 --- a/Emby.Dlna/Service/BaseControlHandler.cs +++ b/Emby.Dlna/Service/BaseControlHandler.cs @@ -6,9 +6,9 @@ using System.IO; using System.Text; using System.Threading.Tasks; using System.Xml; +using Diacritics.Extensions; using Emby.Dlna.Didl; using MediaBrowser.Controller.Configuration; -using MediaBrowser.Controller.Extensions; using Microsoft.Extensions.Logging; namespace Emby.Dlna.Service @@ -47,9 +47,9 @@ namespace Emby.Dlna.Service private async Task ProcessControlRequestInternalAsync(ControlRequest request) { - ControlRequestInfo requestInfo = null; + ControlRequestInfo? requestInfo = null; - using (var streamReader = new StreamReader(request.InputXml)) + using (var streamReader = new StreamReader(request.InputXml, Encoding.UTF8)) { var readerSettings = new XmlReaderSettings() { @@ -95,11 +95,7 @@ namespace Emby.Dlna.Service var xml = builder.ToString().Replace("xmlns:m=", "xmlns:u=", StringComparison.Ordinal); - var controlResponse = new ControlResponse - { - Xml = xml, - IsSuccessful = true - }; + var controlResponse = new ControlResponse(xml, true); controlResponse.Headers.Add("EXT", string.Empty); @@ -151,7 +147,7 @@ namespace Emby.Dlna.Service private async Task ParseBodyTagAsync(XmlReader reader) { - string namespaceURI = null, localName = null; + string? namespaceURI = null, localName = null; await reader.MoveToContentAsync().ConfigureAwait(false); await reader.ReadAsync().ConfigureAwait(false); @@ -169,6 +165,7 @@ namespace Emby.Dlna.Service var result = new ControlRequestInfo(localName, namespaceURI); using var subReader = reader.ReadSubtree(); await ParseFirstBodyChildAsync(subReader, result.Headers).ConfigureAwait(false); + return result; } else { @@ -209,7 +206,7 @@ namespace Emby.Dlna.Service } } - protected abstract void WriteResult(string methodName, IDictionary methodParams, XmlWriter xmlWriter); + protected abstract void WriteResult(string methodName, IReadOnlyDictionary methodParams, XmlWriter xmlWriter); private void LogRequest(ControlRequest request) { diff --git a/Emby.Dlna/Service/ControlErrorHandler.cs b/Emby.Dlna/Service/ControlErrorHandler.cs index f2b5dd9ca8..3e2cd6d2e4 100644 --- a/Emby.Dlna/Service/ControlErrorHandler.cs +++ b/Emby.Dlna/Service/ControlErrorHandler.cs @@ -46,11 +46,7 @@ namespace Emby.Dlna.Service writer.WriteEndDocument(); } - return new ControlResponse - { - Xml = builder.ToString(), - IsSuccessful = false - }; + return new ControlResponse(builder.ToString(), false); } } } diff --git a/Emby.Dlna/Ssdp/DeviceDiscovery.cs b/Emby.Dlna/Ssdp/DeviceDiscovery.cs index 8c7d961f3e..391dda1479 100644 --- a/Emby.Dlna/Ssdp/DeviceDiscovery.cs +++ b/Emby.Dlna/Ssdp/DeviceDiscovery.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System; @@ -69,7 +71,7 @@ namespace Emby.Dlna.Ssdp { lock (_syncLock) { - if (_listenerCount > 0 && _deviceLocator == null) + if (_listenerCount > 0 && _deviceLocator == null && _commsServer != null) { _deviceLocator = new SsdpDeviceLocator(_commsServer); @@ -104,7 +106,7 @@ namespace Emby.Dlna.Ssdp { Location = e.DiscoveredDevice.DescriptionLocation, Headers = headers, - LocalIpAddress = e.LocalIpAddress + RemoteIpAddress = e.RemoteIpAddress }); DeviceDiscoveredInternal?.Invoke(this, args); diff --git a/Emby.Dlna/Ssdp/SsdpExtensions.cs b/Emby.Dlna/Ssdp/SsdpExtensions.cs index e7a52f168f..d00eb02b46 100644 --- a/Emby.Dlna/Ssdp/SsdpExtensions.cs +++ b/Emby.Dlna/Ssdp/SsdpExtensions.cs @@ -7,21 +7,21 @@ namespace Emby.Dlna.Ssdp { public static class SsdpExtensions { - public static string GetValue(this XElement container, XName name) + public static string? GetValue(this XElement container, XName name) { var node = container.Element(name); return node?.Value; } - public static string GetAttributeValue(this XElement container, XName name) + public static string? GetAttributeValue(this XElement container, XName name) { var node = container.Attribute(name); return node?.Value; } - public static string GetDescendantValue(this XElement container, XName name) + public static string? GetDescendantValue(this XElement container, XName name) => container.Descendants(name).FirstOrDefault()?.Value; } } diff --git a/Emby.Drawing/Emby.Drawing.csproj b/Emby.Drawing/Emby.Drawing.csproj index 092f8580a6..baf350c6f1 100644 --- a/Emby.Drawing/Emby.Drawing.csproj +++ b/Emby.Drawing/Emby.Drawing.csproj @@ -6,11 +6,10 @@ - netstandard2.1 + net5.0 false true - true - enable + AllDisabledByDefault @@ -25,14 +24,9 @@ - - - ../jellyfin.ruleset - - diff --git a/Emby.Drawing/ImageProcessor.cs b/Emby.Drawing/ImageProcessor.cs index 8a2301d2d6..7d952aa23b 100644 --- a/Emby.Drawing/ImageProcessor.cs +++ b/Emby.Drawing/ImageProcessor.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Globalization; using System.IO; using System.Linq; +using System.Text; using System.Threading.Tasks; using Jellyfin.Data.Entities; using MediaBrowser.Common.Extensions; @@ -171,21 +172,31 @@ namespace Emby.Drawing return (originalImagePath, MimeTypes.GetMimeType(originalImagePath), dateModified); } - ImageDimensions newSize = ImageHelper.GetNewImageSize(options, null); int quality = options.Quality; ImageFormat outputFormat = GetOutputFormat(options.SupportedOutputFormats, requiresTransparency); - string cacheFilePath = GetCacheFilePath(originalImagePath, newSize, quality, dateModified, outputFormat, options.AddPlayedIndicator, options.PercentPlayed, options.UnplayedCount, options.Blur, options.BackgroundColor, options.ForegroundLayer); + string cacheFilePath = GetCacheFilePath( + originalImagePath, + options.Width, + options.Height, + options.MaxWidth, + options.MaxHeight, + options.FillWidth, + options.FillHeight, + quality, + dateModified, + outputFormat, + options.AddPlayedIndicator, + options.PercentPlayed, + options.UnplayedCount, + options.Blur, + options.BackgroundColor, + options.ForegroundLayer); try { if (!File.Exists(cacheFilePath)) { - if (options.CropWhiteSpace && !SupportsTransparency(originalImagePath)) - { - options.CropWhiteSpace = false; - } - string resultPath = _imageEncoder.EncodeImage(originalImagePath, dateModified, cacheFilePath, autoOrient, orientation, quality, options, outputFormat); if (string.Equals(resultPath, originalImagePath, StringComparison.OrdinalIgnoreCase)) @@ -246,48 +257,111 @@ namespace Emby.Drawing /// /// Gets the cache file path based on a set of parameters. /// - private string GetCacheFilePath(string originalPath, ImageDimensions outputSize, int quality, DateTime dateModified, ImageFormat format, bool addPlayedIndicator, double percentPlayed, int? unwatchedCount, int? blur, string backgroundColor, string foregroundLayer) + private string GetCacheFilePath( + string originalPath, + int? width, + int? height, + int? maxWidth, + int? maxHeight, + int? fillWidth, + int? fillHeight, + int quality, + DateTime dateModified, + ImageFormat format, + bool addPlayedIndicator, + double percentPlayed, + int? unwatchedCount, + int? blur, + string backgroundColor, + string foregroundLayer) { - var filename = originalPath - + "width=" + outputSize.Width - + "height=" + outputSize.Height - + "quality=" + quality - + "datemodified=" + dateModified.Ticks - + "f=" + format; + var filename = new StringBuilder(256); + filename.Append(originalPath); + + filename.Append(",quality="); + filename.Append(quality); + + filename.Append(",datemodified="); + filename.Append(dateModified.Ticks); + + filename.Append(",f="); + filename.Append(format); + + if (width.HasValue) + { + filename.Append(",width="); + filename.Append(width.Value); + } + + if (height.HasValue) + { + filename.Append(",height="); + filename.Append(height.Value); + } + + if (maxWidth.HasValue) + { + filename.Append(",maxwidth="); + filename.Append(maxWidth.Value); + } + + if (maxHeight.HasValue) + { + filename.Append(",maxheight="); + filename.Append(maxHeight.Value); + } + + if (fillWidth.HasValue) + { + filename.Append(",fillwidth="); + filename.Append(fillWidth.Value); + } + + if (fillHeight.HasValue) + { + filename.Append(",fillheight="); + filename.Append(fillHeight.Value); + } if (addPlayedIndicator) { - filename += "pl=true"; + filename.Append(",pl=true"); } if (percentPlayed > 0) { - filename += "p=" + percentPlayed; + filename.Append(",p="); + filename.Append(percentPlayed); } if (unwatchedCount.HasValue) { - filename += "p=" + unwatchedCount.Value; + filename.Append(",p="); + filename.Append(unwatchedCount.Value); } if (blur.HasValue) { - filename += "blur=" + blur.Value; + filename.Append(",blur="); + filename.Append(blur.Value); } if (!string.IsNullOrEmpty(backgroundColor)) { - filename += "b=" + backgroundColor; + filename.Append(",b="); + filename.Append(backgroundColor); } if (!string.IsNullOrEmpty(foregroundLayer)) { - filename += "fl=" + foregroundLayer; + filename.Append(",fl="); + filename.Append(foregroundLayer); } - filename += "v=" + Version; + filename.Append(",v="); + filename.Append(Version); - return GetCachePath(ResizedImageCachePath, filename, "." + format.ToString().ToLowerInvariant()); + return GetCachePath(ResizedImageCachePath, filename.ToString(), "." + format.ToString().ToLowerInvariant()); } /// @@ -352,8 +426,13 @@ namespace Emby.Drawing } /// - public string GetImageCacheTag(User user) + public string? GetImageCacheTag(User user) { + if (user.ProfileImage == null) + { + return null; + } + return (user.ProfileImage.Path + user.ProfileImage.LastModified.Ticks).GetMD5() .ToString("N", CultureInfo.InvariantCulture); } diff --git a/Emby.Drawing/NullImageEncoder.cs b/Emby.Drawing/NullImageEncoder.cs index 2a1cfd3da5..1c05aa9161 100644 --- a/Emby.Drawing/NullImageEncoder.cs +++ b/Emby.Drawing/NullImageEncoder.cs @@ -32,7 +32,7 @@ namespace Emby.Drawing => throw new NotImplementedException(); /// - public string EncodeImage(string inputPath, DateTime dateModified, string outputPath, bool autoOrient, ImageOrientation? orientation, int quality, ImageProcessingOptions options, ImageFormat selectedOutputFormat) + public string EncodeImage(string inputPath, DateTime dateModified, string outputPath, bool autoOrient, ImageOrientation? orientation, int quality, ImageProcessingOptions options, ImageFormat outputFormat) { throw new NotImplementedException(); } diff --git a/Emby.Naming/Audio/AlbumParser.cs b/Emby.Naming/Audio/AlbumParser.cs index b63be3a647..bbfdccc902 100644 --- a/Emby.Naming/Audio/AlbumParser.cs +++ b/Emby.Naming/Audio/AlbumParser.cs @@ -1,6 +1,3 @@ -#nullable enable -#pragma warning disable CS1591 - using System; using System.Globalization; using System.IO; @@ -9,15 +6,27 @@ using Emby.Naming.Common; namespace Emby.Naming.Audio { + /// + /// Helper class to determine if Album is multipart. + /// public class AlbumParser { private readonly NamingOptions _options; + /// + /// Initializes a new instance of the class. + /// + /// Naming options containing AlbumStackingPrefixes. public AlbumParser(NamingOptions options) { _options = options; } + /// + /// Function that determines if album is multipart. + /// + /// Path to file. + /// True if album is multipart. public bool IsMultiPart(string path) { var filename = Path.GetFileName(path); diff --git a/Emby.Naming/Audio/AudioFileParser.cs b/Emby.Naming/Audio/AudioFileParser.cs index 6b2f4be93e..2b610ec796 100644 --- a/Emby.Naming/Audio/AudioFileParser.cs +++ b/Emby.Naming/Audio/AudioFileParser.cs @@ -1,19 +1,25 @@ -#nullable enable -#pragma warning disable CS1591 - using System; using System.IO; -using System.Linq; using Emby.Naming.Common; +using Jellyfin.Extensions; namespace Emby.Naming.Audio { + /// + /// Static helper class to determine if file at path is audio file. + /// public static class AudioFileParser { + /// + /// Static helper method to determine if file at path is audio file. + /// + /// Path to file. + /// containing AudioFileExtensions. + /// True if file at path is audio file. public static bool IsAudioFile(string path, NamingOptions options) { - var extension = Path.GetExtension(path); - return options.AudioFileExtensions.Contains(extension, StringComparer.OrdinalIgnoreCase); + var extension = Path.GetExtension(path.AsSpan()); + return options.AudioFileExtensions.Contains(extension, StringComparison.OrdinalIgnoreCase); } } } diff --git a/Emby.Naming/AudioBook/AudioBookFileInfo.cs b/Emby.Naming/AudioBook/AudioBookFileInfo.cs index c4863b50ab..862e396677 100644 --- a/Emby.Naming/AudioBook/AudioBookFileInfo.cs +++ b/Emby.Naming/AudioBook/AudioBookFileInfo.cs @@ -7,6 +7,21 @@ namespace Emby.Naming.AudioBook /// public class AudioBookFileInfo : IComparable { + /// + /// Initializes a new instance of the class. + /// + /// Path to audiobook file. + /// File type. + /// Number of part this file represents. + /// Number of chapter this file represents. + public AudioBookFileInfo(string path, string container, int? partNumber = default, int? chapterNumber = default) + { + Path = path; + Container = container; + PartNumber = partNumber; + ChapterNumber = chapterNumber; + } + /// /// Gets or sets the path. /// @@ -31,14 +46,8 @@ namespace Emby.Naming.AudioBook /// The chapter number. public int? ChapterNumber { get; set; } - /// - /// Gets or sets a value indicating whether this instance is a directory. - /// - /// The type. - public bool IsDirectory { get; set; } - /// - public int CompareTo(AudioBookFileInfo other) + public int CompareTo(AudioBookFileInfo? other) { if (ReferenceEquals(this, other)) { diff --git a/Emby.Naming/AudioBook/AudioBookFilePathParser.cs b/Emby.Naming/AudioBook/AudioBookFilePathParser.cs index 14edd64926..7b4429ab15 100644 --- a/Emby.Naming/AudioBook/AudioBookFilePathParser.cs +++ b/Emby.Naming/AudioBook/AudioBookFilePathParser.cs @@ -1,6 +1,3 @@ -#nullable enable -#pragma warning disable CS1591 - using System.Globalization; using System.IO; using System.Text.RegularExpressions; @@ -8,15 +5,27 @@ using Emby.Naming.Common; namespace Emby.Naming.AudioBook { + /// + /// Parser class to extract part and/or chapter number from audiobook filename. + /// public class AudioBookFilePathParser { private readonly NamingOptions _options; + /// + /// Initializes a new instance of the class. + /// + /// Naming options containing AudioBookPartsExpressions. public AudioBookFilePathParser(NamingOptions options) { _options = options; } + /// + /// Based on regex determines if filename includes part/chapter number. + /// + /// Path to audiobook file. + /// Returns object. public AudioBookFilePathParserResult Parse(string path) { AudioBookFilePathParserResult result = default; @@ -52,8 +61,6 @@ namespace Emby.Naming.AudioBook } } - result.Success = result.ChapterNumber.HasValue || result.PartNumber.HasValue; - return result; } } diff --git a/Emby.Naming/AudioBook/AudioBookFilePathParserResult.cs b/Emby.Naming/AudioBook/AudioBookFilePathParserResult.cs index 7bfc4479d2..48ab8b57dc 100644 --- a/Emby.Naming/AudioBook/AudioBookFilePathParserResult.cs +++ b/Emby.Naming/AudioBook/AudioBookFilePathParserResult.cs @@ -1,14 +1,18 @@ -#nullable enable -#pragma warning disable CS1591 - namespace Emby.Naming.AudioBook { + /// + /// Data object for passing result of audiobook part/chapter extraction. + /// public struct AudioBookFilePathParserResult { + /// + /// Gets or sets optional number of path extracted from audiobook filename. + /// public int? PartNumber { get; set; } + /// + /// Gets or sets optional number of chapter extracted from audiobook filename. + /// public int? ChapterNumber { get; set; } - - public bool Success { get; set; } } } diff --git a/Emby.Naming/AudioBook/AudioBookInfo.cs b/Emby.Naming/AudioBook/AudioBookInfo.cs index b0b5bd881f..acd8905af6 100644 --- a/Emby.Naming/AudioBook/AudioBookInfo.cs +++ b/Emby.Naming/AudioBook/AudioBookInfo.cs @@ -10,11 +10,18 @@ namespace Emby.Naming.AudioBook /// /// Initializes a new instance of the class. /// - public AudioBookInfo() + /// Name of audiobook. + /// Year of audiobook release. + /// List of files composing the actual audiobook. + /// List of extra files. + /// Alternative version of files. + public AudioBookInfo(string name, int? year, IReadOnlyList files, IReadOnlyList extras, IReadOnlyList alternateVersions) { - Files = new List(); - Extras = new List(); - AlternateVersions = new List(); + Name = name; + Year = year; + Files = files; + Extras = extras; + AlternateVersions = alternateVersions; } /// @@ -32,18 +39,18 @@ namespace Emby.Naming.AudioBook /// Gets or sets the files. /// /// The files. - public List Files { get; set; } + public IReadOnlyList Files { get; set; } /// /// Gets or sets the extras. /// /// The extras. - public List Extras { get; set; } + public IReadOnlyList Extras { get; set; } /// /// Gets or sets the alternate versions. /// /// The alternate versions. - public List AlternateVersions { get; set; } + public IReadOnlyList AlternateVersions { get; set; } } } diff --git a/Emby.Naming/AudioBook/AudioBookListResolver.cs b/Emby.Naming/AudioBook/AudioBookListResolver.cs index f4ba11a0d1..1e4a8d2edc 100644 --- a/Emby.Naming/AudioBook/AudioBookListResolver.cs +++ b/Emby.Naming/AudioBook/AudioBookListResolver.cs @@ -1,6 +1,6 @@ -#pragma warning disable CS1591 - +using System; using System.Collections.Generic; +using System.IO; using System.Linq; using Emby.Naming.Common; using Emby.Naming.Video; @@ -8,40 +8,145 @@ using MediaBrowser.Model.IO; namespace Emby.Naming.AudioBook { + /// + /// Class used to resolve Name, Year, alternative files and extras from stack of files. + /// public class AudioBookListResolver { private readonly NamingOptions _options; + /// + /// Initializes a new instance of the class. + /// + /// Naming options passed along to and . public AudioBookListResolver(NamingOptions options) { _options = options; } + /// + /// Resolves Name, Year and differentiate alternative files and extras from regular audiobook files. + /// + /// List of files related to audiobook. + /// Returns IEnumerable of . public IEnumerable Resolve(IEnumerable files) { var audioBookResolver = new AudioBookResolver(_options); + // File with empty fullname will be sorted out here. var audiobookFileInfos = files - .Select(i => audioBookResolver.Resolve(i.FullName, i.IsDirectory)) - .Where(i => i != null) + .Select(i => audioBookResolver.Resolve(i.FullName)) + .OfType() .ToList(); - // Filter out all extras, otherwise they could cause stacks to not be resolved - // See the unit test TestStackedWithTrailer - var metadata = audiobookFileInfos - .Select(i => new FileSystemMetadata { FullName = i.Path, IsDirectory = i.IsDirectory }); - var stackResult = new StackResolver(_options) - .ResolveAudioBooks(metadata); + .ResolveAudioBooks(audiobookFileInfos); foreach (var stack in stackResult) { - var stackFiles = stack.Files.Select(i => audioBookResolver.Resolve(i, stack.IsDirectoryStack)).ToList(); + var stackFiles = stack.Files + .Select(i => audioBookResolver.Resolve(i)) + .OfType() + .ToList(); + stackFiles.Sort(); - var info = new AudioBookInfo { Files = stackFiles, Name = stack.Name }; + + var nameParserResult = new AudioBookNameParser(_options).Parse(stack.Name); + + FindExtraAndAlternativeFiles(ref stackFiles, out var extras, out var alternativeVersions, nameParserResult); + + var info = new AudioBookInfo( + nameParserResult.Name, + nameParserResult.Year, + stackFiles, + extras, + alternativeVersions); yield return info; } } + + private void FindExtraAndAlternativeFiles(ref List stackFiles, out List extras, out List alternativeVersions, AudioBookNameParserResult nameParserResult) + { + extras = new List(); + alternativeVersions = new List(); + + var haveChaptersOrPages = stackFiles.Any(x => x.ChapterNumber != null || x.PartNumber != null); + var groupedBy = stackFiles.GroupBy(file => new { file.ChapterNumber, file.PartNumber }); + var nameWithReplacedDots = nameParserResult.Name.Replace(' ', '.'); + + foreach (var group in groupedBy) + { + if (group.Key.ChapterNumber == null && group.Key.PartNumber == null) + { + if (group.Count() > 1 || haveChaptersOrPages) + { + var ex = new List(); + var alt = new List(); + + 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)) + { + alt.Add(audioFile); + } + else + { + ex.Add(audioFile); + } + } + + if (ex.Count > 0) + { + var extra = ex + .OrderBy(x => x.Container) + .ThenBy(x => x.Path) + .ToList(); + + stackFiles = stackFiles.Except(extra).ToList(); + extras.AddRange(extra); + } + + if (alt.Count > 0) + { + var alternatives = alt + .OrderBy(x => x.Container) + .ThenBy(x => x.Path) + .ToList(); + + var main = FindMainAudioBookFile(alternatives, nameParserResult.Name); + alternatives.Remove(main); + stackFiles = stackFiles.Except(alternatives).ToList(); + alternativeVersions.AddRange(alternatives); + } + } + } + else if (group.Count() > 1) + { + var alternatives = group + .OrderBy(x => x.Container) + .ThenBy(x => x.Path) + .Skip(1) + .ToList(); + + stackFiles = stackFiles.Except(alternatives).ToList(); + alternativeVersions.AddRange(alternatives); + } + } + } + + private AudioBookFileInfo FindMainAudioBookFile(List files, string name) + { + var main = files.Find(x => Path.GetFileNameWithoutExtension(x.Path).Equals(name, StringComparison.OrdinalIgnoreCase)); + main ??= files.FirstOrDefault(x => Path.GetFileNameWithoutExtension(x.Path).Equals("audiobook", StringComparison.OrdinalIgnoreCase)); + main ??= files.OrderBy(x => x.Container) + .ThenBy(x => x.Path) + .First(); + + return main; + } } } diff --git a/Emby.Naming/AudioBook/AudioBookNameParser.cs b/Emby.Naming/AudioBook/AudioBookNameParser.cs new file mode 100644 index 0000000000..120482bc2c --- /dev/null +++ b/Emby.Naming/AudioBook/AudioBookNameParser.cs @@ -0,0 +1,67 @@ +using System.Globalization; +using System.Text.RegularExpressions; +using Emby.Naming.Common; + +namespace Emby.Naming.AudioBook +{ + /// + /// Helper class to retrieve name and year from audiobook previously retrieved name. + /// + public class AudioBookNameParser + { + private readonly NamingOptions _options; + + /// + /// Initializes a new instance of the class. + /// + /// Naming options containing AudioBookNamesExpressions. + public AudioBookNameParser(NamingOptions options) + { + _options = options; + } + + /// + /// Parse name and year from previously determined name of audiobook. + /// + /// Name of the audiobook. + /// Returns object. + public AudioBookNameParserResult Parse(string name) + { + AudioBookNameParserResult result = default; + foreach (var expression in _options.AudioBookNamesExpressions) + { + var match = new Regex(expression, RegexOptions.IgnoreCase).Match(name); + if (match.Success) + { + if (result.Name == null) + { + var value = match.Groups["name"]; + if (value.Success) + { + result.Name = value.Value; + } + } + + if (!result.Year.HasValue) + { + var value = match.Groups["year"]; + if (value.Success) + { + if (int.TryParse(value.Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var intValue)) + { + result.Year = intValue; + } + } + } + } + } + + if (string.IsNullOrEmpty(result.Name)) + { + result.Name = name; + } + + return result; + } + } +} diff --git a/Emby.Naming/AudioBook/AudioBookNameParserResult.cs b/Emby.Naming/AudioBook/AudioBookNameParserResult.cs new file mode 100644 index 0000000000..3f2d7b2b0b --- /dev/null +++ b/Emby.Naming/AudioBook/AudioBookNameParserResult.cs @@ -0,0 +1,18 @@ +namespace Emby.Naming.AudioBook +{ + /// + /// Data object used to pass result of name and year parsing. + /// + public struct AudioBookNameParserResult + { + /// + /// Gets or sets name of audiobook. + /// + public string Name { get; set; } + + /// + /// Gets or sets optional year of release. + /// + public int? Year { get; set; } + } +} diff --git a/Emby.Naming/AudioBook/AudioBookResolver.cs b/Emby.Naming/AudioBook/AudioBookResolver.cs index 5807d4688c..f6ad3601d7 100644 --- a/Emby.Naming/AudioBook/AudioBookResolver.cs +++ b/Emby.Naming/AudioBook/AudioBookResolver.cs @@ -1,6 +1,3 @@ -#nullable enable -#pragma warning disable CS1591 - using System; using System.IO; using System.Linq; @@ -8,25 +5,32 @@ using Emby.Naming.Common; namespace Emby.Naming.AudioBook { + /// + /// Resolve specifics (path, container, partNumber, chapterNumber) about audiobook file. + /// public class AudioBookResolver { private readonly NamingOptions _options; + /// + /// Initializes a new instance of the class. + /// + /// containing AudioFileExtensions and also used to pass to AudioBookFilePathParser. public AudioBookResolver(NamingOptions options) { _options = options; } - public AudioBookFileInfo? Resolve(string path, bool isDirectory = false) + /// + /// Resolve specifics (path, container, partNumber, chapterNumber) about audiobook file. + /// + /// Path to audiobook file. + /// Returns object. + public AudioBookFileInfo? Resolve(string path) { - if (path.Length == 0) - { - throw new ArgumentException("String can't be empty.", nameof(path)); - } - - // TODO - if (isDirectory) + if (path.Length == 0 || Path.GetFileNameWithoutExtension(path).Length == 0) { + // Return null to indicate this path will not be used, instead of stopping whole process with exception return null; } @@ -42,14 +46,11 @@ namespace Emby.Naming.AudioBook var parsingResult = new AudioBookFilePathParser(_options).Parse(path); - return new AudioBookFileInfo - { - Path = path, - Container = container, - ChapterNumber = parsingResult.ChapterNumber, - PartNumber = parsingResult.PartNumber, - IsDirectory = isDirectory - }; + return new AudioBookFileInfo( + path, + container, + chapterNumber: parsingResult.ChapterNumber, + partNumber: parsingResult.PartNumber); } } } diff --git a/Emby.Naming/Common/EpisodeExpression.cs b/Emby.Naming/Common/EpisodeExpression.cs index ed6ba8881c..19d3c7aab0 100644 --- a/Emby.Naming/Common/EpisodeExpression.cs +++ b/Emby.Naming/Common/EpisodeExpression.cs @@ -1,28 +1,32 @@ -#pragma warning disable CS1591 - using System; using System.Text.RegularExpressions; namespace Emby.Naming.Common { + /// + /// Regular expressions for parsing TV Episodes. + /// public class EpisodeExpression { private string _expression; - private Regex _regex; + private Regex? _regex; - public EpisodeExpression(string expression, bool byDate) + /// + /// Initializes a new instance of the class. + /// + /// Regular expressions. + /// True if date is expected. + public EpisodeExpression(string expression, bool byDate = false) { - Expression = expression; + _expression = expression; IsByDate = byDate; DateTimeFormats = Array.Empty(); SupportsAbsoluteEpisodeNumbers = true; } - public EpisodeExpression(string expression) - : this(expression, false) - { - } - + /// + /// Gets or sets raw expressions string. + /// public string Expression { get => _expression; @@ -33,16 +37,34 @@ namespace Emby.Naming.Common } } + /// + /// Gets or sets a value indicating whether gets or sets property indicating if date can be find in expression. + /// public bool IsByDate { get; set; } + /// + /// Gets or sets a value indicating whether gets or sets property indicating if expression is optimistic. + /// public bool IsOptimistic { get; set; } + /// + /// Gets or sets a value indicating whether gets or sets property indicating if expression is named. + /// public bool IsNamed { get; set; } + /// + /// Gets or sets a value indicating whether gets or sets property indicating if expression supports episodes with absolute numbers. + /// public bool SupportsAbsoluteEpisodeNumbers { get; set; } + /// + /// Gets or sets optional list of date formats used for date parsing. + /// public string[] DateTimeFormats { get; set; } + /// + /// Gets a expressions objects (creates it if null). + /// public Regex Regex => _regex ??= new Regex(Expression, RegexOptions.IgnoreCase | RegexOptions.Compiled); } } diff --git a/Emby.Naming/Common/MediaType.cs b/Emby.Naming/Common/MediaType.cs index 148833765f..dc9784c6da 100644 --- a/Emby.Naming/Common/MediaType.cs +++ b/Emby.Naming/Common/MediaType.cs @@ -1,7 +1,8 @@ -#pragma warning disable CS1591 - namespace Emby.Naming.Common { + /// + /// Type of audiovisual media. + /// public enum MediaType { /// diff --git a/Emby.Naming/Common/NamingOptions.cs b/Emby.Naming/Common/NamingOptions.cs index fd4244f64d..915ce42cc9 100644 --- a/Emby.Naming/Common/NamingOptions.cs +++ b/Emby.Naming/Common/NamingOptions.cs @@ -1,15 +1,21 @@ -#pragma warning disable CS1591 - using System; using System.Linq; using System.Text.RegularExpressions; using Emby.Naming.Video; using MediaBrowser.Model.Entities; +// ReSharper disable StringLiteralTypo + namespace Emby.Naming.Common { + /// + /// Big ugly class containing lot of different naming options that should be split and injected instead of passes everywhere. + /// public class NamingOptions { + /// + /// Initializes a new instance of the class. + /// public NamingOptions() { VideoFileExtensions = new[] @@ -75,63 +81,52 @@ namespace Emby.Naming.Common StubTypes = new[] { - new StubTypeRule - { - StubType = "dvd", - Token = "dvd" - }, - new StubTypeRule - { - StubType = "hddvd", - Token = "hddvd" - }, - new StubTypeRule - { - StubType = "bluray", - Token = "bluray" - }, - new StubTypeRule - { - StubType = "bluray", - Token = "brrip" - }, - new StubTypeRule - { - StubType = "bluray", - Token = "bd25" - }, - new StubTypeRule - { - StubType = "bluray", - Token = "bd50" - }, - new StubTypeRule - { - StubType = "vhs", - Token = "vhs" - }, - new StubTypeRule - { - StubType = "tv", - Token = "HDTV" - }, - new StubTypeRule - { - StubType = "tv", - Token = "PDTV" - }, - new StubTypeRule - { - StubType = "tv", - Token = "DSR" - } + new StubTypeRule( + stubType: "dvd", + token: "dvd"), + + new StubTypeRule( + stubType: "hddvd", + token: "hddvd"), + + new StubTypeRule( + stubType: "bluray", + token: "bluray"), + + new StubTypeRule( + stubType: "bluray", + token: "brrip"), + + new StubTypeRule( + stubType: "bluray", + token: "bd25"), + + new StubTypeRule( + stubType: "bluray", + token: "bd50"), + + new StubTypeRule( + stubType: "vhs", + token: "vhs"), + + new StubTypeRule( + stubType: "tv", + token: "HDTV"), + + new StubTypeRule( + stubType: "tv", + token: "PDTV"), + + new StubTypeRule( + stubType: "tv", + token: "DSR") }; VideoFileStackingExpressions = new[] { - "(.*?)([ _.-]*(?:cd|dvd|p(?:ar)?t|dis[ck])[ _.-]*[0-9]+)(.*?)(\\.[^.]+)$", - "(.*?)([ _.-]*(?:cd|dvd|p(?:ar)?t|dis[ck])[ _.-]*[a-d])(.*?)(\\.[^.]+)$", - "(.*?)([ ._-]*[a-d])(.*?)(\\.[^.]+)$" + "(?.*?)(?<volume>[ _.-]*(?:cd|dvd|p(?:ar)?t|dis[ck])[ _.-]*[0-9]+)(?<ignore>.*?)(?<extension>\\.[^.]+)$", + "(?<title>.*?)(?<volume>[ _.-]*(?:cd|dvd|p(?:ar)?t|dis[ck])[ _.-]*[a-d])(?<ignore>.*?)(?<extension>\\.[^.]+)$", + "(?<title>.*?)(?<volume>[ ._-]*[a-d])(?<ignore>.*?)(?<extension>\\.[^.]+)$" }; CleanDateTimes = new[] @@ -142,7 +137,7 @@ namespace Emby.Naming.Common CleanStrings = new[] { - @"[ _\,\.\(\)\[\]\-](3d|sbs|tab|hsbs|htab|mvc|HDR|HDC|UHD|UltraHD|4k|ac3|dts|custom|dc|divx|divx5|dsr|dsrip|dutch|dvd|dvdrip|dvdscr|dvdscreener|screener|dvdivx|cam|fragment|fs|hdtv|hdrip|hdtvrip|internal|limited|multisubs|ntsc|ogg|ogm|pal|pdtv|proper|repack|rerip|retail|cd[1-9]|r3|r5|bd5|bd|se|svcd|swedish|german|read.nfo|nfofix|unrated|ws|telesync|ts|telecine|tc|brrip|bdrip|480p|480i|576p|576i|720p|720i|1080p|1080i|2160p|hrhd|hrhdtv|hddvd|bluray|x264|h264|xvid|xvidvd|xxx|www.www|\[.*\])([ _\,\.\(\)\[\]\-]|$)", + @"[ _\,\.\(\)\[\]\-](3d|sbs|tab|hsbs|htab|mvc|HDR|HDC|UHD|UltraHD|4k|ac3|dts|custom|dc|divx|divx5|dsr|dsrip|dutch|dvd|dvdrip|dvdscr|dvdscreener|screener|dvdivx|cam|fragment|fs|hdtv|hdrip|hdtvrip|internal|limited|multisubs|ntsc|ogg|ogm|pal|pdtv|proper|repack|rerip|retail|cd[1-9]|r3|r5|bd5|bd|se|svcd|swedish|german|read.nfo|nfofix|unrated|ws|telesync|ts|telecine|tc|brrip|bdrip|480p|480i|576p|576i|720p|720i|1080p|1080i|2160p|hrhd|hrhdtv|hddvd|bluray|blu-ray|x264|x265|h264|h265|xvid|xvidvd|xxx|www.www|AAC|DTS|\[.*\])([ _\,\.\(\)\[\]\-]|$)", @"(\[.*\])" }; @@ -255,7 +250,7 @@ namespace Emby.Naming.Common }, // <!-- foo.ep01, foo.EP_01 --> new EpisodeExpression(@"[\._ -]()[Ee][Pp]_?([0-9]+)([^\\/]*)$"), - new EpisodeExpression("([0-9]{4})[\\.-]([0-9]{2})[\\.-]([0-9]{2})", true) + new EpisodeExpression("(?<year>[0-9]{4})[\\.-](?<month>[0-9]{2})[\\.-](?<day>[0-9]{2})", true) { DateTimeFormats = new[] { @@ -264,7 +259,7 @@ namespace Emby.Naming.Common "yyyy_MM_dd" } }, - new EpisodeExpression("([0-9]{2})[\\.-]([0-9]{2})[\\.-]([0-9]{4})", true) + new EpisodeExpression(@"(?<day>[0-9]{2})[.-](?<month>[0-9]{2})[.-](?<year>[0-9]{4})", true) { DateTimeFormats = new[] { @@ -282,11 +277,22 @@ namespace Emby.Naming.Common IsNamed = true }, - new EpisodeExpression("[\\\\/\\._ \\[\\(-]([0-9]+)x([0-9]+(?:(?:[a-i]|\\.[1-9])(?![0-9]))?)([^\\\\/]*)$") + new EpisodeExpression(@"[\\\/\._ \[\(-]([0-9]+)x([0-9]+(?:(?:[a-i]|\.[1-9])(?![0-9]))?)([^\\\/]*)$") { SupportsAbsoluteEpisodeNumbers = true }, - new EpisodeExpression(@"[\\\\/\\._ -](?<seriesname>(?![0-9]+[0-9][0-9])([^\\\/])*)[\\\\/\\._ -](?<seasonnumber>[0-9]+)(?<epnumber>[0-9][0-9](?:(?:[a-i]|\\.[1-9])(?![0-9]))?)([\\._ -][^\\\\/]*)$") + + // Not a Kodi rule as well, but below rule also causes false positives for triple-digit episode names + // [bar] Foo - 1 [baz] special case of below expression to prevent false positives with digits in the series name + new EpisodeExpression(@".*[\\\/]?.*?(\[.*?\])+.*?(?<seriesname>[-\w\s]+?)[\s_]*-[\s_]*(?<epnumber>[0-9]+).*$") + { + IsNamed = true + }, + + // /server/anything_102.mp4 + // /server/james.corden.2017.04.20.anne.hathaway.720p.hdtv.x264-crooks.mkv + // /server/anything_1996.11.14.mp4 + new EpisodeExpression(@"[\\/._ -](?<seriesname>(?![0-9]+[0-9][0-9])([^\\\/_])*)[\\\/._ -](?<seasonnumber>[0-9]+)(?<epnumber>[0-9][0-9](?:(?:[a-i]|\.[1-9])(?![0-9]))?)([._ -][^\\\/]*)$") { IsOptimistic = true, IsNamed = true, @@ -299,11 +305,12 @@ namespace Emby.Naming.Common // *** End Kodi Standard Naming - // [bar] Foo - 1 [baz] - new EpisodeExpression(@".*?(\[.*?\])+.*?(?<seriesname>[\w\s]+?)[-\s_]+(?<epnumber>[0-9]+).*$") + // "Episode 16", "Episode 16 - Title" + new EpisodeExpression(@"[Ee]pisode (?<epnumber>[0-9]+)(-(?<endingepnumber>[0-9]+))?[^\\\/]*$") { IsNamed = true }, + new EpisodeExpression(@".*(\\|\/)[sS]?(?<seasonnumber>[0-9]+)[xX](?<epnumber>[0-9]+)[^\\\/]*$") { IsNamed = true @@ -361,12 +368,6 @@ namespace Emby.Naming.Common IsOptimistic = true, IsNamed = true }, - // "Episode 16", "Episode 16 - Title" - new EpisodeExpression(@".*[\\\/][^\\\/]* (?<epnumber>[0-9]{1,3})(-(?<endingepnumber>[0-9]{2,3}))*[^\\\/]*$") - { - IsOptimistic = true, - IsNamed = true - } }; EpisodeWithoutSeasonExpressions = new[] @@ -381,247 +382,193 @@ namespace Emby.Naming.Common VideoExtraRules = new[] { - new ExtraRule - { - ExtraType = ExtraType.Trailer, - RuleType = ExtraRuleType.Filename, - Token = "trailer", - MediaType = MediaType.Video - }, - new ExtraRule - { - ExtraType = ExtraType.Trailer, - RuleType = ExtraRuleType.Suffix, - Token = "-trailer", - MediaType = MediaType.Video - }, - new ExtraRule - { - ExtraType = ExtraType.Trailer, - RuleType = ExtraRuleType.Suffix, - Token = ".trailer", - MediaType = MediaType.Video - }, - new ExtraRule - { - ExtraType = ExtraType.Trailer, - RuleType = ExtraRuleType.Suffix, - Token = "_trailer", - MediaType = MediaType.Video - }, - new ExtraRule - { - ExtraType = ExtraType.Trailer, - RuleType = ExtraRuleType.Suffix, - Token = " trailer", - MediaType = MediaType.Video - }, - new ExtraRule - { - ExtraType = ExtraType.Sample, - RuleType = ExtraRuleType.Filename, - Token = "sample", - MediaType = MediaType.Video - }, - new ExtraRule - { - ExtraType = ExtraType.Sample, - RuleType = ExtraRuleType.Suffix, - Token = "-sample", - MediaType = MediaType.Video - }, - new ExtraRule - { - ExtraType = ExtraType.Sample, - RuleType = ExtraRuleType.Suffix, - Token = ".sample", - MediaType = MediaType.Video - }, - new ExtraRule - { - ExtraType = ExtraType.Sample, - RuleType = ExtraRuleType.Suffix, - Token = "_sample", - MediaType = MediaType.Video - }, - new ExtraRule - { - ExtraType = ExtraType.Sample, - RuleType = ExtraRuleType.Suffix, - Token = " sample", - MediaType = MediaType.Video - }, - new ExtraRule - { - ExtraType = ExtraType.ThemeSong, - RuleType = ExtraRuleType.Filename, - Token = "theme", - MediaType = MediaType.Audio - }, - new ExtraRule - { - ExtraType = ExtraType.Scene, - RuleType = ExtraRuleType.Suffix, - Token = "-scene", - MediaType = MediaType.Video - }, - new ExtraRule - { - ExtraType = ExtraType.Clip, - RuleType = ExtraRuleType.Suffix, - Token = "-clip", - MediaType = MediaType.Video - }, - new ExtraRule - { - ExtraType = ExtraType.Interview, - RuleType = ExtraRuleType.Suffix, - Token = "-interview", - MediaType = MediaType.Video - }, - new ExtraRule - { - ExtraType = ExtraType.BehindTheScenes, - RuleType = ExtraRuleType.Suffix, - Token = "-behindthescenes", - MediaType = MediaType.Video - }, - new ExtraRule - { - ExtraType = ExtraType.DeletedScene, - RuleType = ExtraRuleType.Suffix, - Token = "-deleted", - MediaType = MediaType.Video - }, - new ExtraRule - { - ExtraType = ExtraType.Clip, - RuleType = ExtraRuleType.Suffix, - Token = "-featurette", - MediaType = MediaType.Video - }, - new ExtraRule - { - ExtraType = ExtraType.Clip, - RuleType = ExtraRuleType.Suffix, - Token = "-short", - MediaType = MediaType.Video - }, - new ExtraRule - { - ExtraType = ExtraType.BehindTheScenes, - RuleType = ExtraRuleType.DirectoryName, - Token = "behind the scenes", - MediaType = MediaType.Video, - }, - new ExtraRule - { - ExtraType = ExtraType.DeletedScene, - RuleType = ExtraRuleType.DirectoryName, - Token = "deleted scenes", - MediaType = MediaType.Video, - }, - new ExtraRule - { - ExtraType = ExtraType.Interview, - RuleType = ExtraRuleType.DirectoryName, - Token = "interviews", - MediaType = MediaType.Video, - }, - new ExtraRule - { - ExtraType = ExtraType.Scene, - RuleType = ExtraRuleType.DirectoryName, - Token = "scenes", - MediaType = MediaType.Video, - }, - new ExtraRule - { - ExtraType = ExtraType.Sample, - RuleType = ExtraRuleType.DirectoryName, - Token = "samples", - MediaType = MediaType.Video, - }, - new ExtraRule - { - ExtraType = ExtraType.Clip, - RuleType = ExtraRuleType.DirectoryName, - Token = "shorts", - MediaType = MediaType.Video, - }, - new ExtraRule - { - ExtraType = ExtraType.Clip, - RuleType = ExtraRuleType.DirectoryName, - Token = "featurettes", - MediaType = MediaType.Video, - }, - new ExtraRule - { - ExtraType = ExtraType.Unknown, - RuleType = ExtraRuleType.DirectoryName, - Token = "extras", - MediaType = MediaType.Video, - }, + new ExtraRule( + ExtraType.Trailer, + ExtraRuleType.Filename, + "trailer", + MediaType.Video), + + new ExtraRule( + ExtraType.Trailer, + ExtraRuleType.Suffix, + "-trailer", + MediaType.Video), + + new ExtraRule( + ExtraType.Trailer, + ExtraRuleType.Suffix, + ".trailer", + MediaType.Video), + + new ExtraRule( + ExtraType.Trailer, + ExtraRuleType.Suffix, + "_trailer", + MediaType.Video), + + new ExtraRule( + ExtraType.Trailer, + ExtraRuleType.Suffix, + " trailer", + MediaType.Video), + + new ExtraRule( + ExtraType.Sample, + ExtraRuleType.Filename, + "sample", + MediaType.Video), + + new ExtraRule( + ExtraType.Sample, + ExtraRuleType.Suffix, + "-sample", + MediaType.Video), + + new ExtraRule( + ExtraType.Sample, + ExtraRuleType.Suffix, + ".sample", + MediaType.Video), + + new ExtraRule( + ExtraType.Sample, + ExtraRuleType.Suffix, + "_sample", + MediaType.Video), + + new ExtraRule( + ExtraType.Sample, + ExtraRuleType.Suffix, + " sample", + MediaType.Video), + + new ExtraRule( + ExtraType.ThemeSong, + ExtraRuleType.Filename, + "theme", + MediaType.Audio), + + new ExtraRule( + ExtraType.Scene, + ExtraRuleType.Suffix, + "-scene", + MediaType.Video), + + new ExtraRule( + ExtraType.Clip, + ExtraRuleType.Suffix, + "-clip", + MediaType.Video), + + new ExtraRule( + ExtraType.Interview, + ExtraRuleType.Suffix, + "-interview", + MediaType.Video), + + new ExtraRule( + ExtraType.BehindTheScenes, + ExtraRuleType.Suffix, + "-behindthescenes", + MediaType.Video), + + new ExtraRule( + ExtraType.DeletedScene, + ExtraRuleType.Suffix, + "-deleted", + MediaType.Video), + + new ExtraRule( + ExtraType.Clip, + ExtraRuleType.Suffix, + "-featurette", + MediaType.Video), + + new ExtraRule( + ExtraType.Clip, + ExtraRuleType.Suffix, + "-short", + MediaType.Video), + + new ExtraRule( + ExtraType.BehindTheScenes, + ExtraRuleType.DirectoryName, + "behind the scenes", + MediaType.Video), + + new ExtraRule( + ExtraType.DeletedScene, + ExtraRuleType.DirectoryName, + "deleted scenes", + MediaType.Video), + + new ExtraRule( + ExtraType.Interview, + ExtraRuleType.DirectoryName, + "interviews", + MediaType.Video), + + new ExtraRule( + ExtraType.Scene, + ExtraRuleType.DirectoryName, + "scenes", + MediaType.Video), + + new ExtraRule( + ExtraType.Sample, + ExtraRuleType.DirectoryName, + "samples", + MediaType.Video), + + new ExtraRule( + ExtraType.Clip, + ExtraRuleType.DirectoryName, + "shorts", + MediaType.Video), + + new ExtraRule( + ExtraType.Clip, + ExtraRuleType.DirectoryName, + "featurettes", + MediaType.Video), + + new ExtraRule( + ExtraType.Unknown, + ExtraRuleType.DirectoryName, + "extras", + MediaType.Video), }; Format3DRules = new[] { // Kodi rules: - new Format3DRule - { - PreceedingToken = "3d", - Token = "hsbs" - }, - new Format3DRule - { - PreceedingToken = "3d", - Token = "sbs" - }, - new Format3DRule - { - PreceedingToken = "3d", - Token = "htab" - }, - new Format3DRule - { - PreceedingToken = "3d", - Token = "tab" - }, - // Media Browser rules: - new Format3DRule - { - Token = "fsbs" - }, - new Format3DRule - { - Token = "hsbs" - }, - new Format3DRule - { - Token = "sbs" - }, - new Format3DRule - { - Token = "ftab" - }, - new Format3DRule - { - Token = "htab" - }, - new Format3DRule - { - Token = "tab" - }, - new Format3DRule - { - Token = "sbs3d" - }, - new Format3DRule - { - Token = "mvc" - } + new Format3DRule( + precedingToken: "3d", + token: "hsbs"), + + new Format3DRule( + precedingToken: "3d", + token: "sbs"), + + new Format3DRule( + precedingToken: "3d", + token: "htab"), + + new Format3DRule( + precedingToken: "3d", + token: "tab"), + + // Media Browser rules: + new Format3DRule("fsbs"), + new Format3DRule("hsbs"), + new Format3DRule("sbs"), + new Format3DRule("ftab"), + new Format3DRule("htab"), + new Format3DRule("tab"), + new Format3DRule("sbs3d"), + new Format3DRule("mvc") }; + AudioBookPartsExpressions = new[] { // Detect specified chapters, like CH 01 @@ -631,13 +578,20 @@ namespace Emby.Naming.Common // Chapter is often beginning of filename "^(?<chapter>[0-9]+)", // Part if often ending of filename - "(?<part>[0-9]+)$", + @"(?<!ch(?:apter) )(?<part>[0-9]+)$", // Sometimes named as 0001_005 (chapter_part) "(?<chapter>[0-9]+)_(?<part>[0-9]+)", // Some audiobooks are ripped from cd's, and will be named by disk number. @"dis(?:c|k)[\s_-]?(?<chapter>[0-9]+)" }; + AudioBookNamesExpressions = new[] + { + // Detect year usually in brackets after name Batman (2020) + @"^(?<name>.+?)\s*\(\s*(?<year>[0-9]{4})\s*\)\s*$", + @"^\s*(?<name>[^ ].*?)\s*$" + }; + var extensions = VideoFileExtensions.ToList(); extensions.AddRange(new[] @@ -673,7 +627,7 @@ namespace Emby.Naming.Common ".mxf" }); - MultipleEpisodeExpressions = new string[] + MultipleEpisodeExpressions = new[] { @".*(\\|\/)[sS]?(?<seasonnumber>[0-9]{1,4})[xX](?<epnumber>[0-9]{1,3})((-| - )[0-9]{1,4}[eExX](?<endingepnumber>[0-9]{1,3}))+[^\\\/]*$", @".*(\\|\/)[sS]?(?<seasonnumber>[0-9]{1,4})[xX](?<epnumber>[0-9]{1,3})((-| - )[0-9]{1,4}[xX][eE](?<endingepnumber>[0-9]{1,3}))+[^\\\/]*$", @@ -697,56 +651,139 @@ namespace Emby.Naming.Common Compile(); } + /// <summary> + /// Gets or sets list of audio file extensions. + /// </summary> public string[] AudioFileExtensions { get; set; } + /// <summary> + /// Gets or sets list of album stacking prefixes. + /// </summary> public string[] AlbumStackingPrefixes { get; set; } + /// <summary> + /// Gets or sets list of subtitle file extensions. + /// </summary> public string[] SubtitleFileExtensions { get; set; } + /// <summary> + /// Gets or sets list of subtitles flag delimiters. + /// </summary> public char[] SubtitleFlagDelimiters { get; set; } + /// <summary> + /// Gets or sets list of subtitle forced flags. + /// </summary> public string[] SubtitleForcedFlags { get; set; } + /// <summary> + /// Gets or sets list of subtitle default flags. + /// </summary> public string[] SubtitleDefaultFlags { get; set; } + /// <summary> + /// Gets or sets list of episode regular expressions. + /// </summary> public EpisodeExpression[] EpisodeExpressions { get; set; } + /// <summary> + /// Gets or sets list of raw episode without season regular expressions strings. + /// </summary> public string[] EpisodeWithoutSeasonExpressions { get; set; } + /// <summary> + /// Gets or sets list of raw multi-part episodes regular expressions strings. + /// </summary> public string[] EpisodeMultiPartExpressions { get; set; } + /// <summary> + /// Gets or sets list of video file extensions. + /// </summary> public string[] VideoFileExtensions { get; set; } + /// <summary> + /// Gets or sets list of video stub file extensions. + /// </summary> public string[] StubFileExtensions { get; set; } + /// <summary> + /// Gets or sets list of raw audiobook parts regular expressions strings. + /// </summary> public string[] AudioBookPartsExpressions { get; set; } + /// <summary> + /// Gets or sets list of raw audiobook names regular expressions strings. + /// </summary> + public string[] AudioBookNamesExpressions { get; set; } + + /// <summary> + /// Gets or sets list of stub type rules. + /// </summary> public StubTypeRule[] StubTypes { get; set; } + /// <summary> + /// Gets or sets list of video flag delimiters. + /// </summary> public char[] VideoFlagDelimiters { get; set; } + /// <summary> + /// Gets or sets list of 3D Format rules. + /// </summary> public Format3DRule[] Format3DRules { get; set; } + /// <summary> + /// Gets or sets list of raw video file-stacking expressions strings. + /// </summary> public string[] VideoFileStackingExpressions { get; set; } + /// <summary> + /// Gets or sets list of raw clean DateTimes regular expressions strings. + /// </summary> public string[] CleanDateTimes { get; set; } + /// <summary> + /// Gets or sets list of raw clean strings regular expressions strings. + /// </summary> public string[] CleanStrings { get; set; } + /// <summary> + /// Gets or sets list of multi-episode regular expressions. + /// </summary> public EpisodeExpression[] MultipleEpisodeExpressions { get; set; } + /// <summary> + /// Gets or sets list of extra rules for videos. + /// </summary> public ExtraRule[] VideoExtraRules { get; set; } - public Regex[] VideoFileStackingRegexes { get; private set; } - - public Regex[] CleanDateTimeRegexes { get; private set; } - - public Regex[] CleanStringRegexes { get; private set; } - - public Regex[] EpisodeWithoutSeasonRegexes { get; private set; } - - public Regex[] EpisodeMultiPartRegexes { get; private set; } - + /// <summary> + /// Gets list of video file-stack regular expressions. + /// </summary> + public Regex[] VideoFileStackingRegexes { get; private set; } = Array.Empty<Regex>(); + + /// <summary> + /// Gets list of clean datetime regular expressions. + /// </summary> + public Regex[] CleanDateTimeRegexes { get; private set; } = Array.Empty<Regex>(); + + /// <summary> + /// Gets list of clean string regular expressions. + /// </summary> + public Regex[] CleanStringRegexes { get; private set; } = Array.Empty<Regex>(); + + /// <summary> + /// Gets list of episode without season regular expressions. + /// </summary> + public Regex[] EpisodeWithoutSeasonRegexes { get; private set; } = Array.Empty<Regex>(); + + /// <summary> + /// Gets list of multi-part episode regular expressions. + /// </summary> + public Regex[] EpisodeMultiPartRegexes { get; private set; } = Array.Empty<Regex>(); + + /// <summary> + /// Compiles raw regex strings into regexes. + /// </summary> public void Compile() { VideoFileStackingRegexes = VideoFileStackingExpressions.Select(Compile).ToArray(); diff --git a/Emby.Naming/Emby.Naming.csproj b/Emby.Naming/Emby.Naming.csproj index 6857f9952c..07d879e96a 100644 --- a/Emby.Naming/Emby.Naming.csproj +++ b/Emby.Naming/Emby.Naming.csproj @@ -1,4 +1,4 @@ -<Project Sdk="Microsoft.NET.Sdk"> +<Project Sdk="Microsoft.NET.Sdk"> <!-- ProjectGuid is only included as a requirement for SonarQube analysis --> <PropertyGroup> @@ -6,14 +6,14 @@ </PropertyGroup> <PropertyGroup> - <TargetFramework>netstandard2.1</TargetFramework> + <TargetFramework>net5.0</TargetFramework> <GenerateAssemblyInfo>false</GenerateAssemblyInfo> <GenerateDocumentationFile>true</GenerateDocumentationFile> - <TreatWarningsAsErrors>true</TreatWarningsAsErrors> <PublishRepositoryUrl>true</PublishRepositoryUrl> <EmbedUntrackedSources>true</EmbedUntrackedSources> <IncludeSymbols>true</IncludeSymbols> <SymbolPackageFormat>snupkg</SymbolPackageFormat> + <AnalysisMode>AllDisabledByDefault</AnalysisMode> </PropertyGroup> <PropertyGroup Condition=" '$(Stability)'=='Unstable'"> @@ -22,35 +22,31 @@ </PropertyGroup> <ItemGroup> - <Compile Include="..\SharedVersion.cs" /> + <Compile Include="../SharedVersion.cs" /> </ItemGroup> <ItemGroup> - <ProjectReference Include="..\MediaBrowser.Model\MediaBrowser.Model.csproj" /> + <ProjectReference Include="../MediaBrowser.Common/MediaBrowser.Common.csproj" /> + <ProjectReference Include="../MediaBrowser.Model/MediaBrowser.Model.csproj" /> </ItemGroup> <PropertyGroup> <Authors>Jellyfin Contributors</Authors> <PackageId>Jellyfin.Naming</PackageId> - <VersionPrefix>10.7.0</VersionPrefix> + <VersionPrefix>10.8.0</VersionPrefix> <RepositoryUrl>https://github.com/jellyfin/jellyfin</RepositoryUrl> <PackageLicenseExpression>GPL-3.0-only</PackageLicenseExpression> </PropertyGroup> <ItemGroup> - <PackageReference Include="Microsoft.SourceLink.GitHub" Version="1.0.0" PrivateAssets="All"/> + <PackageReference Include="Microsoft.SourceLink.GitHub" Version="1.0.0" PrivateAssets="All" /> </ItemGroup> <!-- Code Analyzers--> <ItemGroup Condition=" '$(Configuration)' == 'Debug' "> - <!-- TODO: <PackageReference Include="Microsoft.CodeAnalysis.FxCopAnalyzers" Version="2.9.8" PrivateAssets="All" /> --> <PackageReference Include="SerilogAnalyzer" Version="0.15.0" PrivateAssets="All" /> <PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="All" /> <PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" PrivateAssets="All" /> </ItemGroup> - <PropertyGroup Condition=" '$(Configuration)' == 'Debug' "> - <CodeAnalysisRuleSet>../jellyfin.ruleset</CodeAnalysisRuleSet> - </PropertyGroup> - </Project> diff --git a/Emby.Naming/Subtitles/SubtitleInfo.cs b/Emby.Naming/Subtitles/SubtitleInfo.cs index f39c496b7a..1fb2e0dc89 100644 --- a/Emby.Naming/Subtitles/SubtitleInfo.cs +++ b/Emby.Naming/Subtitles/SubtitleInfo.cs @@ -1,9 +1,23 @@ -#pragma warning disable CS1591 - namespace Emby.Naming.Subtitles { + /// <summary> + /// Class holding information about subtitle. + /// </summary> public class SubtitleInfo { + /// <summary> + /// Initializes a new instance of the <see cref="SubtitleInfo"/> class. + /// </summary> + /// <param name="path">Path to file.</param> + /// <param name="isDefault">Is subtitle default.</param> + /// <param name="isForced">Is subtitle forced.</param> + public SubtitleInfo(string path, bool isDefault, bool isForced) + { + Path = path; + IsDefault = isDefault; + IsForced = isForced; + } + /// <summary> /// Gets or sets the path. /// </summary> @@ -14,7 +28,7 @@ namespace Emby.Naming.Subtitles /// Gets or sets the language. /// </summary> /// <value>The language.</value> - public string Language { get; set; } + public string? Language { get; set; } /// <summary> /// Gets or sets a value indicating whether this instance is default. diff --git a/Emby.Naming/Subtitles/SubtitleParser.cs b/Emby.Naming/Subtitles/SubtitleParser.cs index 24e59f90a3..a19340ef69 100644 --- a/Emby.Naming/Subtitles/SubtitleParser.cs +++ b/Emby.Naming/Subtitles/SubtitleParser.cs @@ -1,6 +1,3 @@ -#nullable enable -#pragma warning disable CS1591 - using System; using System.IO; using System.Linq; @@ -8,20 +5,32 @@ using Emby.Naming.Common; namespace Emby.Naming.Subtitles { + /// <summary> + /// Subtitle Parser class. + /// </summary> public class SubtitleParser { private readonly NamingOptions _options; + /// <summary> + /// Initializes a new instance of the <see cref="SubtitleParser"/> class. + /// </summary> + /// <param name="options"><see cref="NamingOptions"/> object containing SubtitleFileExtensions, SubtitleDefaultFlags, SubtitleForcedFlags and SubtitleFlagDelimiters.</param> public SubtitleParser(NamingOptions options) { _options = options; } + /// <summary> + /// Parse file to determine if is subtitle and <see cref="SubtitleInfo"/>. + /// </summary> + /// <param name="path">Path to file.</param> + /// <returns>Returns null or <see cref="SubtitleInfo"/> object if parsing is successful.</returns> public SubtitleInfo? ParseFile(string path) { if (path.Length == 0) { - throw new ArgumentException("File path can't be empty.", nameof(path)); + return null; } var extension = Path.GetExtension(path); @@ -31,12 +40,10 @@ namespace Emby.Naming.Subtitles } var flags = GetFlags(path); - var info = new SubtitleInfo - { - Path = path, - IsDefault = _options.SubtitleDefaultFlags.Any(i => flags.Contains(i, StringComparer.OrdinalIgnoreCase)), - IsForced = _options.SubtitleForcedFlags.Any(i => flags.Contains(i, StringComparer.OrdinalIgnoreCase)) - }; + var info = new SubtitleInfo( + path, + _options.SubtitleDefaultFlags.Any(i => flags.Contains(i, StringComparer.OrdinalIgnoreCase)), + _options.SubtitleForcedFlags.Any(i => flags.Contains(i, StringComparer.OrdinalIgnoreCase))); var parts = flags.Where(i => !_options.SubtitleDefaultFlags.Contains(i, StringComparer.OrdinalIgnoreCase) && !_options.SubtitleForcedFlags.Contains(i, StringComparer.OrdinalIgnoreCase)) @@ -53,7 +60,7 @@ namespace Emby.Naming.Subtitles private string[] GetFlags(string path) { - // Note: the tags need be be surrounded be either a space ( ), hyphen -, dot . or underscore _. + // Note: the tags need be surrounded be either a space ( ), hyphen -, dot . or underscore _. var file = Path.GetFileName(path); diff --git a/Emby.Naming/TV/EpisodeInfo.cs b/Emby.Naming/TV/EpisodeInfo.cs index 250df4e2d3..a8920b36ae 100644 --- a/Emby.Naming/TV/EpisodeInfo.cs +++ b/Emby.Naming/TV/EpisodeInfo.cs @@ -1,9 +1,19 @@ -#pragma warning disable CS1591 - namespace Emby.Naming.TV { + /// <summary> + /// Holder object for Episode information. + /// </summary> public class EpisodeInfo { + /// <summary> + /// Initializes a new instance of the <see cref="EpisodeInfo"/> class. + /// </summary> + /// <param name="path">Path to the file.</param> + public EpisodeInfo(string path) + { + Path = path; + } + /// <summary> /// Gets or sets the path. /// </summary> @@ -14,19 +24,19 @@ namespace Emby.Naming.TV /// Gets or sets the container. /// </summary> /// <value>The container.</value> - public string Container { get; set; } + public string? Container { get; set; } /// <summary> /// Gets or sets the name of the series. /// </summary> /// <value>The name of the series.</value> - public string SeriesName { get; set; } + public string? SeriesName { get; set; } /// <summary> /// Gets or sets the format3 d. /// </summary> /// <value>The format3 d.</value> - public string Format3D { get; set; } + public string? Format3D { get; set; } /// <summary> /// Gets or sets a value indicating whether [is3 d]. @@ -44,20 +54,41 @@ namespace Emby.Naming.TV /// Gets or sets the type of the stub. /// </summary> /// <value>The type of the stub.</value> - public string StubType { get; set; } + public string? StubType { get; set; } + /// <summary> + /// Gets or sets optional season number. + /// </summary> public int? SeasonNumber { get; set; } + /// <summary> + /// Gets or sets optional episode number. + /// </summary> public int? EpisodeNumber { get; set; } - public int? EndingEpsiodeNumber { get; set; } + /// <summary> + /// Gets or sets optional ending episode number. For multi-episode files 1-13. + /// </summary> + public int? EndingEpisodeNumber { get; set; } + /// <summary> + /// Gets or sets optional year of release. + /// </summary> public int? Year { get; set; } + /// <summary> + /// Gets or sets optional year of release. + /// </summary> public int? Month { get; set; } + /// <summary> + /// Gets or sets optional day of release. + /// </summary> public int? Day { get; set; } + /// <summary> + /// Gets or sets a value indicating whether by date expression was used. + /// </summary> public bool IsByDate { get; set; } } } diff --git a/Emby.Naming/TV/EpisodePathParser.cs b/Emby.Naming/TV/EpisodePathParser.cs index a6af689c72..6d0597356b 100644 --- a/Emby.Naming/TV/EpisodePathParser.cs +++ b/Emby.Naming/TV/EpisodePathParser.cs @@ -1,6 +1,3 @@ -#pragma warning disable CS1591 -#nullable enable - using System; using System.Collections.Generic; using System.Globalization; @@ -9,15 +6,32 @@ using Emby.Naming.Common; namespace Emby.Naming.TV { + /// <summary> + /// Used to parse information about episode from path. + /// </summary> public class EpisodePathParser { private readonly NamingOptions _options; + /// <summary> + /// Initializes a new instance of the <see cref="EpisodePathParser"/> class. + /// </summary> + /// <param name="options"><see cref="NamingOptions"/> object containing EpisodeExpressions and MultipleEpisodeExpressions.</param> public EpisodePathParser(NamingOptions options) { _options = options; } + /// <summary> + /// Parses information about episode from path. + /// </summary> + /// <param name="path">Path.</param> + /// <param name="isDirectory">Is path for a directory or file.</param> + /// <param name="isNamed">Do we want to use IsNamed expressions.</param> + /// <param name="isOptimistic">Do we want to use Optimistic expressions.</param> + /// <param name="supportsAbsoluteNumbers">Do we want to use expressions supporting absolute episode numbers.</param> + /// <param name="fillExtendedInfo">Should we attempt to retrieve extended information.</param> + /// <returns>Returns <see cref="EpisodePathParserResult"/> object.</returns> public EpisodePathParserResult Parse( string path, bool isDirectory, @@ -146,7 +160,7 @@ namespace Emby.Naming.TV { if (int.TryParse(endingNumberGroup.Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out num)) { - result.EndingEpsiodeNumber = num; + result.EndingEpisodeNumber = num; } } } @@ -186,7 +200,7 @@ namespace Emby.Naming.TV private void FillAdditional(string path, EpisodePathParserResult info) { - var expressions = _options.MultipleEpisodeExpressions.ToList(); + var expressions = _options.MultipleEpisodeExpressions.Where(i => i.IsNamed).ToList(); if (string.IsNullOrEmpty(info.SeriesName)) { @@ -200,11 +214,6 @@ namespace Emby.Naming.TV { foreach (var i in expressions) { - if (!i.IsNamed) - { - continue; - } - var result = Parse(path, i); if (!result.Success) @@ -217,13 +226,13 @@ namespace Emby.Naming.TV info.SeriesName = result.SeriesName; } - if (!info.EndingEpsiodeNumber.HasValue && info.EpisodeNumber.HasValue) + if (!info.EndingEpisodeNumber.HasValue && info.EpisodeNumber.HasValue) { - info.EndingEpsiodeNumber = result.EndingEpsiodeNumber; + info.EndingEpisodeNumber = result.EndingEpisodeNumber; } if (!string.IsNullOrEmpty(info.SeriesName) - && (!info.EpisodeNumber.HasValue || info.EndingEpsiodeNumber.HasValue)) + && (!info.EpisodeNumber.HasValue || info.EndingEpisodeNumber.HasValue)) { break; } diff --git a/Emby.Naming/TV/EpisodePathParserResult.cs b/Emby.Naming/TV/EpisodePathParserResult.cs index 05f921edc9..233d5a4f6c 100644 --- a/Emby.Naming/TV/EpisodePathParserResult.cs +++ b/Emby.Naming/TV/EpisodePathParserResult.cs @@ -1,25 +1,54 @@ -#pragma warning disable CS1591 - namespace Emby.Naming.TV { + /// <summary> + /// Holder object for <see cref="EpisodePathParser"/> result. + /// </summary> public class EpisodePathParserResult { + /// <summary> + /// Gets or sets optional season number. + /// </summary> public int? SeasonNumber { get; set; } + /// <summary> + /// Gets or sets optional episode number. + /// </summary> public int? EpisodeNumber { get; set; } - public int? EndingEpsiodeNumber { get; set; } + /// <summary> + /// Gets or sets optional ending episode number. For multi-episode files 1-13. + /// </summary> + public int? EndingEpisodeNumber { get; set; } - public string SeriesName { get; set; } + /// <summary> + /// Gets or sets the name of the series. + /// </summary> + /// <value>The name of the series.</value> + public string? SeriesName { get; set; } + /// <summary> + /// Gets or sets a value indicating whether parsing was successful. + /// </summary> public bool Success { get; set; } + /// <summary> + /// Gets or sets a value indicating whether by date expression was used. + /// </summary> public bool IsByDate { get; set; } + /// <summary> + /// Gets or sets optional year of release. + /// </summary> public int? Year { get; set; } + /// <summary> + /// Gets or sets optional year of release. + /// </summary> public int? Month { get; set; } + /// <summary> + /// Gets or sets optional day of release. + /// </summary> public int? Day { get; set; } } } diff --git a/Emby.Naming/TV/EpisodeResolver.cs b/Emby.Naming/TV/EpisodeResolver.cs index 6994f69fc4..5e952e47b7 100644 --- a/Emby.Naming/TV/EpisodeResolver.cs +++ b/Emby.Naming/TV/EpisodeResolver.cs @@ -1,6 +1,3 @@ -#pragma warning disable CS1591 -#nullable enable - using System; using System.IO; using System.Linq; @@ -9,15 +6,32 @@ using Emby.Naming.Video; namespace Emby.Naming.TV { + /// <summary> + /// Used to resolve information about episode from path. + /// </summary> public class EpisodeResolver { private readonly NamingOptions _options; + /// <summary> + /// Initializes a new instance of the <see cref="EpisodeResolver"/> class. + /// </summary> + /// <param name="options"><see cref="NamingOptions"/> object containing VideoFileExtensions and passed to <see cref="StubResolver"/>, <see cref="Format3DParser"/> and <see cref="EpisodePathParser"/>.</param> public EpisodeResolver(NamingOptions options) { _options = options; } + /// <summary> + /// Resolve information about episode from path. + /// </summary> + /// <param name="path">Path.</param> + /// <param name="isDirectory">Is path for a directory or file.</param> + /// <param name="isNamed">Do we want to use IsNamed expressions.</param> + /// <param name="isOptimistic">Do we want to use Optimistic expressions.</param> + /// <param name="supportsAbsoluteNumbers">Do we want to use expressions supporting absolute episode numbers.</param> + /// <param name="fillExtendedInfo">Should we attempt to retrieve extended information.</param> + /// <returns>Returns null or <see cref="EpisodeInfo"/> object if successful.</returns> public EpisodeInfo? Resolve( string path, bool isDirectory, @@ -48,18 +62,21 @@ namespace Emby.Naming.TV container = extension.TrimStart('.'); } - var flags = new FlagParser(_options).GetFlags(path); - var format3DResult = new Format3DParser(_options).Parse(flags); + var format3DResult = Format3DParser.Parse(path, _options); var parsingResult = new EpisodePathParser(_options) .Parse(path, isDirectory, isNamed, isOptimistic, supportsAbsoluteNumbers, fillExtendedInfo); - return new EpisodeInfo + if (!parsingResult.Success && !isStub) + { + return null; + } + + return new EpisodeInfo(path) { - Path = path, Container = container, IsStub = isStub, - EndingEpsiodeNumber = parsingResult.EndingEpsiodeNumber, + EndingEpisodeNumber = parsingResult.EndingEpisodeNumber, EpisodeNumber = parsingResult.EpisodeNumber, SeasonNumber = parsingResult.SeasonNumber, SeriesName = parsingResult.SeriesName, diff --git a/Emby.Naming/TV/SeasonPathParser.cs b/Emby.Naming/TV/SeasonPathParser.cs index d2e324dda5..6236f86c43 100644 --- a/Emby.Naming/TV/SeasonPathParser.cs +++ b/Emby.Naming/TV/SeasonPathParser.cs @@ -1,11 +1,12 @@ -#pragma warning disable CS1591 - using System; using System.Globalization; using System.IO; namespace Emby.Naming.TV { + /// <summary> + /// Class to parse season paths. + /// </summary> public static class SeasonPathParser { /// <summary> @@ -23,6 +24,13 @@ namespace Emby.Naming.TV "stagione" }; + /// <summary> + /// Attempts to parse season number from path. + /// </summary> + /// <param name="path">Path to season.</param> + /// <param name="supportSpecialAliases">Support special aliases when parsing.</param> + /// <param name="supportNumericSeasonFolders">Support numeric season folders when parsing.</param> + /// <returns>Returns <see cref="SeasonPathParserResult"/> object.</returns> public static SeasonPathParserResult Parse(string path, bool supportSpecialAliases, bool supportNumericSeasonFolders) { var result = new SeasonPathParserResult(); @@ -52,7 +60,7 @@ namespace Emby.Naming.TV bool supportSpecialAliases, bool supportNumericSeasonFolders) { - var filename = Path.GetFileName(path) ?? string.Empty; + string filename = Path.GetFileName(path); if (supportSpecialAliases) { @@ -101,9 +109,9 @@ namespace Emby.Naming.TV } var parts = filename.Split(new[] { '.', '_', ' ', '-' }, StringSplitOptions.RemoveEmptyEntries); - for (int i = 0; i < parts.Length; i++) + foreach (var part in parts) { - if (TryGetSeasonNumberFromPart(parts[i], out int seasonNumber)) + if (TryGetSeasonNumberFromPart(part, out int seasonNumber)) { return (seasonNumber, true); } @@ -139,7 +147,7 @@ namespace Emby.Naming.TV var numericStart = -1; var length = 0; - var hasOpenParenth = false; + var hasOpenParenthesis = false; var isSeasonFolder = true; // Find out where the numbers start, and then keep going until they end @@ -147,7 +155,7 @@ namespace Emby.Naming.TV { if (char.IsNumber(path[i])) { - if (!hasOpenParenth) + if (!hasOpenParenthesis) { if (numericStart == -1) { @@ -167,11 +175,11 @@ namespace Emby.Naming.TV var currentChar = path[i]; if (currentChar == '(') { - hasOpenParenth = true; + hasOpenParenthesis = true; } else if (currentChar == ')') { - hasOpenParenth = false; + hasOpenParenthesis = false; } } diff --git a/Emby.Naming/TV/SeasonPathParserResult.cs b/Emby.Naming/TV/SeasonPathParserResult.cs index a142fafea0..b4b6f236a7 100644 --- a/Emby.Naming/TV/SeasonPathParserResult.cs +++ b/Emby.Naming/TV/SeasonPathParserResult.cs @@ -1,7 +1,8 @@ -#pragma warning disable CS1591 - namespace Emby.Naming.TV { + /// <summary> + /// Data object to pass result of <see cref="SeasonPathParser"/>. + /// </summary> public class SeasonPathParserResult { /// <summary> @@ -16,6 +17,10 @@ namespace Emby.Naming.TV /// <value><c>true</c> if success; otherwise, <c>false</c>.</value> public bool Success { get; set; } + /// <summary> + /// Gets or sets a value indicating whether "Is season folder". + /// Seems redundant and barely used. + /// </summary> public bool IsSeasonFolder { get; set; } } } diff --git a/Emby.Naming/Video/CleanDateTimeParser.cs b/Emby.Naming/Video/CleanDateTimeParser.cs index 579c9e91e1..0ee633dcc6 100644 --- a/Emby.Naming/Video/CleanDateTimeParser.cs +++ b/Emby.Naming/Video/CleanDateTimeParser.cs @@ -1,6 +1,3 @@ -#pragma warning disable CS1591 -#nullable enable - using System.Collections.Generic; using System.Globalization; using System.Text.RegularExpressions; @@ -12,9 +9,20 @@ namespace Emby.Naming.Video /// </summary> public static class CleanDateTimeParser { + /// <summary> + /// Attempts to clean the name. + /// </summary> + /// <param name="name">Name of video.</param> + /// <param name="cleanDateTimeRegexes">Optional list of regexes to clean the name.</param> + /// <returns>Returns <see cref="CleanDateTimeResult"/> object.</returns> public static CleanDateTimeResult Clean(string name, IReadOnlyList<Regex> cleanDateTimeRegexes) { CleanDateTimeResult result = new CleanDateTimeResult(name); + if (string.IsNullOrEmpty(name)) + { + return result; + } + var len = cleanDateTimeRegexes.Count; for (int i = 0; i < len; i++) { diff --git a/Emby.Naming/Video/CleanDateTimeResult.cs b/Emby.Naming/Video/CleanDateTimeResult.cs index 57eeaa7e32..c675a19d0f 100644 --- a/Emby.Naming/Video/CleanDateTimeResult.cs +++ b/Emby.Naming/Video/CleanDateTimeResult.cs @@ -1,22 +1,21 @@ -#pragma warning disable CS1591 -#nullable enable - namespace Emby.Naming.Video { + /// <summary> + /// Holder structure for name and year. + /// </summary> public readonly struct CleanDateTimeResult { - public CleanDateTimeResult(string name, int? year) + /// <summary> + /// Initializes a new instance of the <see cref="CleanDateTimeResult"/> struct. + /// </summary> + /// <param name="name">Name of video.</param> + /// <param name="year">Year of release.</param> + public CleanDateTimeResult(string name, int? year = null) { Name = name; Year = year; } - public CleanDateTimeResult(string name) - { - Name = name; - Year = null; - } - /// <summary> /// Gets the name. /// </summary> diff --git a/Emby.Naming/Video/CleanStringParser.cs b/Emby.Naming/Video/CleanStringParser.cs index 3f584d5847..4eef3ebc5e 100644 --- a/Emby.Naming/Video/CleanStringParser.cs +++ b/Emby.Naming/Video/CleanStringParser.cs @@ -1,8 +1,6 @@ -#pragma warning disable CS1591 -#nullable enable - using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Text.RegularExpressions; namespace Emby.Naming.Video @@ -12,8 +10,21 @@ namespace Emby.Naming.Video /// </summary> public static class CleanStringParser { - public static bool TryClean(string name, IReadOnlyList<Regex> expressions, out ReadOnlySpan<char> newName) + /// <summary> + /// Attempts to extract clean name with regular expressions. + /// </summary> + /// <param name="name">Name of file.</param> + /// <param name="expressions">List of regex to parse name and year from.</param> + /// <param name="newName">Parsing result string.</param> + /// <returns>True if parsing was successful.</returns> + public static bool TryClean([NotNullWhen(true)] string? name, IReadOnlyList<Regex> expressions, out ReadOnlySpan<char> newName) { + if (string.IsNullOrEmpty(name)) + { + newName = ReadOnlySpan<char>.Empty; + return false; + } + var len = expressions.Count; for (int i = 0; i < len; i++) { @@ -37,7 +48,7 @@ namespace Emby.Naming.Video return true; } - newName = string.Empty; + newName = ReadOnlySpan<char>.Empty; return false; } } diff --git a/Emby.Naming/Video/ExtraResolver.cs b/Emby.Naming/Video/ExtraResolver.cs index fc0424faab..a32af002cc 100644 --- a/Emby.Naming/Video/ExtraResolver.cs +++ b/Emby.Naming/Video/ExtraResolver.cs @@ -1,92 +1,100 @@ -#pragma warning disable CS1591 - using System; using System.IO; -using System.Linq; using System.Text.RegularExpressions; using Emby.Naming.Audio; using Emby.Naming.Common; namespace Emby.Naming.Video { + /// <summary> + /// Resolve if file is extra for video. + /// </summary> public class ExtraResolver { private readonly NamingOptions _options; + /// <summary> + /// Initializes a new instance of the <see cref="ExtraResolver"/> class. + /// </summary> + /// <param name="options"><see cref="NamingOptions"/> object containing VideoExtraRules and passed to <see cref="AudioFileParser"/> and <see cref="VideoResolver"/>.</param> public ExtraResolver(NamingOptions options) { _options = options; } + /// <summary> + /// Attempts to resolve if file is extra. + /// </summary> + /// <param name="path">Path to file.</param> + /// <returns>Returns <see cref="ExtraResult"/> object.</returns> public ExtraResult GetExtraInfo(string path) - { - return _options.VideoExtraRules - .Select(i => GetExtraInfo(path, i)) - .FirstOrDefault(i => i.ExtraType != null) ?? new ExtraResult(); - } - - private ExtraResult GetExtraInfo(string path, ExtraRule rule) { var result = new ExtraResult(); - if (rule.MediaType == MediaType.Audio) + for (var i = 0; i < _options.VideoExtraRules.Length; i++) { - if (!AudioFileParser.IsAudioFile(path, _options)) + var rule = _options.VideoExtraRules[i]; + if (rule.MediaType == MediaType.Audio) { - return result; + if (!AudioFileParser.IsAudioFile(path, _options)) + { + continue; + } } - } - else if (rule.MediaType == MediaType.Video) - { - if (!new VideoResolver(_options).IsVideoFile(path)) + else if (rule.MediaType == MediaType.Video) { - return result; + if (!VideoResolver.IsVideoFile(path, _options)) + { + continue; + } } - } - else - { - return result; - } - - if (rule.RuleType == ExtraRuleType.Filename) - { - var filename = Path.GetFileNameWithoutExtension(path); - if (string.Equals(filename, rule.Token, StringComparison.OrdinalIgnoreCase)) + var pathSpan = path.AsSpan(); + if (rule.RuleType == ExtraRuleType.Filename) { - result.ExtraType = rule.ExtraType; - result.Rule = rule; - } - } - else if (rule.RuleType == ExtraRuleType.Suffix) - { - var filename = Path.GetFileNameWithoutExtension(path); + var filename = Path.GetFileNameWithoutExtension(pathSpan); - if (filename.IndexOf(rule.Token, StringComparison.OrdinalIgnoreCase) > 0) + if (filename.Equals(rule.Token, StringComparison.OrdinalIgnoreCase)) + { + result.ExtraType = rule.ExtraType; + result.Rule = rule; + } + } + else if (rule.RuleType == ExtraRuleType.Suffix) { - result.ExtraType = rule.ExtraType; - result.Rule = rule; + var filename = Path.GetFileNameWithoutExtension(pathSpan); + + if (filename.Contains(rule.Token, StringComparison.OrdinalIgnoreCase)) + { + result.ExtraType = rule.ExtraType; + result.Rule = rule; + } } - } - else if (rule.RuleType == ExtraRuleType.Regex) - { - var filename = Path.GetFileName(path); + else if (rule.RuleType == ExtraRuleType.Regex) + { + var filename = Path.GetFileName(path); - var regex = new Regex(rule.Token, RegexOptions.IgnoreCase); + var regex = new Regex(rule.Token, RegexOptions.IgnoreCase); - if (regex.IsMatch(filename)) + if (regex.IsMatch(filename)) + { + result.ExtraType = rule.ExtraType; + result.Rule = rule; + } + } + else if (rule.RuleType == ExtraRuleType.DirectoryName) { - result.ExtraType = rule.ExtraType; - result.Rule = rule; + var directoryName = Path.GetFileName(Path.GetDirectoryName(pathSpan)); + if (directoryName.Equals(rule.Token, StringComparison.OrdinalIgnoreCase)) + { + result.ExtraType = rule.ExtraType; + result.Rule = rule; + } } - } - else if (rule.RuleType == ExtraRuleType.DirectoryName) - { - var directoryName = Path.GetFileName(Path.GetDirectoryName(path)); - if (string.Equals(directoryName, rule.Token, StringComparison.OrdinalIgnoreCase)) + + if (result.ExtraType != null) { - result.ExtraType = rule.ExtraType; - result.Rule = rule; + return result; } } diff --git a/Emby.Naming/Video/ExtraResult.cs b/Emby.Naming/Video/ExtraResult.cs index 15db32e876..243fc2b415 100644 --- a/Emby.Naming/Video/ExtraResult.cs +++ b/Emby.Naming/Video/ExtraResult.cs @@ -1,9 +1,10 @@ -#pragma warning disable CS1591 - using MediaBrowser.Model.Entities; namespace Emby.Naming.Video { + /// <summary> + /// Holder object for passing results from ExtraResolver. + /// </summary> public class ExtraResult { /// <summary> @@ -16,6 +17,6 @@ namespace Emby.Naming.Video /// Gets or sets the rule. /// </summary> /// <value>The rule.</value> - public ExtraRule Rule { get; set; } + public ExtraRule? Rule { get; set; } } } diff --git a/Emby.Naming/Video/ExtraRule.cs b/Emby.Naming/Video/ExtraRule.cs index 7c9702e244..e267ac55fc 100644 --- a/Emby.Naming/Video/ExtraRule.cs +++ b/Emby.Naming/Video/ExtraRule.cs @@ -1,5 +1,3 @@ -#pragma warning disable CS1591 - using MediaBrowser.Model.Entities; using MediaType = Emby.Naming.Common.MediaType; @@ -10,6 +8,21 @@ namespace Emby.Naming.Video /// </summary> public class ExtraRule { + /// <summary> + /// Initializes a new instance of the <see cref="ExtraRule"/> class. + /// </summary> + /// <param name="extraType">Type of extra.</param> + /// <param name="ruleType">Type of rule.</param> + /// <param name="token">Token.</param> + /// <param name="mediaType">Media type.</param> + public ExtraRule(ExtraType extraType, ExtraRuleType ruleType, string token, MediaType mediaType) + { + Token = token; + ExtraType = extraType; + RuleType = ruleType; + MediaType = mediaType; + } + /// <summary> /// Gets or sets the token to use for matching against the file path. /// </summary> diff --git a/Emby.Naming/Video/ExtraRuleType.cs b/Emby.Naming/Video/ExtraRuleType.cs index e89876f4ae..3243195057 100644 --- a/Emby.Naming/Video/ExtraRuleType.cs +++ b/Emby.Naming/Video/ExtraRuleType.cs @@ -1,7 +1,8 @@ -#pragma warning disable CS1591 - namespace Emby.Naming.Video { + /// <summary> + /// Extra rules type to determine against what <see cref="ExtraRule.Token"/> should be matched. + /// </summary> public enum ExtraRuleType { /// <summary> @@ -22,6 +23,6 @@ namespace Emby.Naming.Video /// <summary> /// Match <see cref="ExtraRule.Token"/> against the name of the directory containing the file. /// </summary> - DirectoryName = 3, + DirectoryName = 3 } } diff --git a/Emby.Naming/Video/FileStack.cs b/Emby.Naming/Video/FileStack.cs index 3ef190b865..6519db57c3 100644 --- a/Emby.Naming/Video/FileStack.cs +++ b/Emby.Naming/Video/FileStack.cs @@ -1,24 +1,43 @@ -#pragma warning disable CS1591 - using System; using System.Collections.Generic; using System.Linq; namespace Emby.Naming.Video { + /// <summary> + /// Object holding list of files paths with additional information. + /// </summary> public class FileStack { + /// <summary> + /// Initializes a new instance of the <see cref="FileStack"/> class. + /// </summary> public FileStack() { Files = new List<string>(); } - public string Name { get; set; } + /// <summary> + /// Gets or sets name of file stack. + /// </summary> + public string Name { get; set; } = string.Empty; + /// <summary> + /// Gets or sets list of paths in stack. + /// </summary> public List<string> Files { get; set; } + /// <summary> + /// Gets or sets a value indicating whether stack is directory stack. + /// </summary> public bool IsDirectoryStack { get; set; } + /// <summary> + /// Helper function to determine if path is in the stack. + /// </summary> + /// <param name="file">Path of desired file.</param> + /// <param name="isDirectory">Requested type of stack.</param> + /// <returns>True if file is in the stack.</returns> public bool ContainsFile(string file, bool isDirectory) { if (IsDirectoryStack == isDirectory) diff --git a/Emby.Naming/Video/FlagParser.cs b/Emby.Naming/Video/FlagParser.cs deleted file mode 100644 index a8bd9d5c5d..0000000000 --- a/Emby.Naming/Video/FlagParser.cs +++ /dev/null @@ -1,37 +0,0 @@ -#pragma warning disable CS1591 - -using System; -using System.IO; -using Emby.Naming.Common; - -namespace Emby.Naming.Video -{ - public class FlagParser - { - private readonly NamingOptions _options; - - public FlagParser(NamingOptions options) - { - _options = options; - } - - public string[] GetFlags(string path) - { - return GetFlags(path, _options.VideoFlagDelimiters); - } - - public string[] GetFlags(string path, char[] delimeters) - { - if (string.IsNullOrEmpty(path)) - { - throw new ArgumentNullException(nameof(path)); - } - - // Note: the tags need be be surrounded be either a space ( ), hyphen -, dot . or underscore _. - - var file = Path.GetFileName(path); - - return file.Split(delimeters, StringSplitOptions.RemoveEmptyEntries); - } - } -} diff --git a/Emby.Naming/Video/Format3DParser.cs b/Emby.Naming/Video/Format3DParser.cs index 51c26af863..0890899894 100644 --- a/Emby.Naming/Video/Format3DParser.cs +++ b/Emby.Naming/Video/Format3DParser.cs @@ -1,35 +1,37 @@ -#pragma warning disable CS1591 - using System; -using System.Linq; using Emby.Naming.Common; namespace Emby.Naming.Video { - public class Format3DParser + /// <summary> + /// Parse 3D format related flags. + /// </summary> + public static class Format3DParser { - private readonly NamingOptions _options; - - public Format3DParser(NamingOptions options) + // Static default result to save on allocation costs. + private static readonly Format3DResult _defaultResult = new (false, null); + + /// <summary> + /// Parse 3D format related flags. + /// </summary> + /// <param name="path">Path to file.</param> + /// <param name="namingOptions">The naming options.</param> + /// <returns>Returns <see cref="Format3DResult"/> object.</returns> + public static Format3DResult Parse(ReadOnlySpan<char> path, NamingOptions namingOptions) { - _options = options; - } + int oldLen = namingOptions.VideoFlagDelimiters.Length; + Span<char> delimiters = stackalloc char[oldLen + 1]; + namingOptions.VideoFlagDelimiters.AsSpan().CopyTo(delimiters); + delimiters[oldLen] = ' '; - public Format3DResult Parse(string path) - { - int oldLen = _options.VideoFlagDelimiters.Length; - var delimeters = new char[oldLen + 1]; - _options.VideoFlagDelimiters.CopyTo(delimeters, 0); - delimeters[oldLen] = ' '; - - return Parse(new FlagParser(_options).GetFlags(path, delimeters)); + return Parse(path, delimiters, namingOptions); } - internal Format3DResult Parse(string[] videoFlags) + private static Format3DResult Parse(ReadOnlySpan<char> path, ReadOnlySpan<char> delimiters, NamingOptions namingOptions) { - foreach (var rule in _options.Format3DRules) + foreach (var rule in namingOptions.Format3DRules) { - var result = Parse(videoFlags, rule); + var result = Parse(path, rule, delimiters); if (result.Is3D) { @@ -37,51 +39,43 @@ namespace Emby.Naming.Video } } - return new Format3DResult(); + return _defaultResult; } - private static Format3DResult Parse(string[] videoFlags, Format3DRule rule) + private static Format3DResult Parse(ReadOnlySpan<char> path, Format3DRule rule, ReadOnlySpan<char> delimiters) { - var result = new Format3DResult(); + bool is3D = false; + string? format3D = null; - if (string.IsNullOrEmpty(rule.PreceedingToken)) + // If there's no preceding token we just consider it found + var foundPrefix = string.IsNullOrEmpty(rule.PrecedingToken); + while (path.Length > 0) { - result.Format3D = new[] { rule.Token }.FirstOrDefault(i => videoFlags.Contains(i, StringComparer.OrdinalIgnoreCase)); - result.Is3D = !string.IsNullOrEmpty(result.Format3D); - - if (result.Is3D) + var index = path.IndexOfAny(delimiters); + if (index == -1) { - result.Tokens.Add(rule.Token); + index = path.Length - 1; } - } - else - { - var foundPrefix = false; - string format = null; - foreach (var flag in videoFlags) - { - if (foundPrefix) - { - result.Tokens.Add(rule.PreceedingToken); + var currentSlice = path[..index]; + path = path[(index + 1)..]; - if (string.Equals(rule.Token, flag, StringComparison.OrdinalIgnoreCase)) - { - format = flag; - result.Tokens.Add(rule.Token); - } + if (!foundPrefix) + { + foundPrefix = currentSlice.Equals(rule.PrecedingToken, StringComparison.OrdinalIgnoreCase); + continue; + } - break; - } + is3D = foundPrefix && currentSlice.Equals(rule.Token, StringComparison.OrdinalIgnoreCase); - foundPrefix = string.Equals(flag, rule.PreceedingToken, StringComparison.OrdinalIgnoreCase); + if (is3D) + { + format3D = rule.Token; + break; } - - result.Is3D = foundPrefix && !string.IsNullOrEmpty(format); - result.Format3D = format; } - return result; + return is3D ? new Format3DResult(true, format3D) : _defaultResult; } } } diff --git a/Emby.Naming/Video/Format3DResult.cs b/Emby.Naming/Video/Format3DResult.cs index fa0e9d3b80..aac959c133 100644 --- a/Emby.Naming/Video/Format3DResult.cs +++ b/Emby.Naming/Video/Format3DResult.cs @@ -1,32 +1,31 @@ -#pragma warning disable CS1591 - -using System.Collections.Generic; - namespace Emby.Naming.Video { + /// <summary> + /// Helper object to return data from <see cref="Format3DParser"/>. + /// </summary> public class Format3DResult { - public Format3DResult() + /// <summary> + /// Initializes a new instance of the <see cref="Format3DResult"/> class. + /// </summary> + /// <param name="is3D">A value indicating whether the parsed string contains 3D tokens.</param> + /// <param name="format3D">The 3D format. Value might be null if [is3D] is <c>false</c>.</param> + public Format3DResult(bool is3D, string? format3D) { - Tokens = new List<string>(); + Is3D = is3D; + Format3D = format3D; } /// <summary> - /// Gets or sets a value indicating whether [is3 d]. + /// Gets a value indicating whether [is3 d]. /// </summary> /// <value><c>true</c> if [is3 d]; otherwise, <c>false</c>.</value> - public bool Is3D { get; set; } + public bool Is3D { get; } /// <summary> - /// Gets or sets the format3 d. + /// Gets the format3 d. /// </summary> /// <value>The format3 d.</value> - public string Format3D { get; set; } - - /// <summary> - /// Gets or sets the tokens. - /// </summary> - /// <value>The tokens.</value> - public List<string> Tokens { get; set; } + public string? Format3D { get; } } } diff --git a/Emby.Naming/Video/Format3DRule.cs b/Emby.Naming/Video/Format3DRule.cs index 310ec84e8f..e562691df9 100644 --- a/Emby.Naming/Video/Format3DRule.cs +++ b/Emby.Naming/Video/Format3DRule.cs @@ -1,9 +1,21 @@ -#pragma warning disable CS1591 - namespace Emby.Naming.Video { + /// <summary> + /// Data holder class for 3D format rule. + /// </summary> public class Format3DRule { + /// <summary> + /// Initializes a new instance of the <see cref="Format3DRule"/> class. + /// </summary> + /// <param name="token">Token.</param> + /// <param name="precedingToken">Token present before current token.</param> + public Format3DRule(string token, string? precedingToken = null) + { + Token = token; + PrecedingToken = precedingToken; + } + /// <summary> /// Gets or sets the token. /// </summary> @@ -11,9 +23,9 @@ namespace Emby.Naming.Video public string Token { get; set; } /// <summary> - /// Gets or sets the preceeding token. + /// Gets or sets the preceding token. /// </summary> - /// <value>The preceeding token.</value> - public string PreceedingToken { get; set; } + /// <value>The preceding token.</value> + public string? PrecedingToken { get; set; } } } diff --git a/Emby.Naming/Video/StackResolver.cs b/Emby.Naming/Video/StackResolver.cs index f733cd2620..36f65a5624 100644 --- a/Emby.Naming/Video/StackResolver.cs +++ b/Emby.Naming/Video/StackResolver.cs @@ -1,64 +1,92 @@ -#pragma warning disable CS1591 - using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Text.RegularExpressions; +using Emby.Naming.AudioBook; using Emby.Naming.Common; using MediaBrowser.Model.IO; namespace Emby.Naming.Video { + /// <summary> + /// Resolve <see cref="FileStack"/> from list of paths. + /// </summary> public class StackResolver { private readonly NamingOptions _options; + /// <summary> + /// Initializes a new instance of the <see cref="StackResolver"/> class. + /// </summary> + /// <param name="options"><see cref="NamingOptions"/> object containing VideoFileStackingRegexes and passes options to <see cref="VideoResolver"/>.</param> public StackResolver(NamingOptions options) { _options = options; } + /// <summary> + /// Resolves only directories from paths. + /// </summary> + /// <param name="files">List of paths.</param> + /// <returns>Enumerable <see cref="FileStack"/> of directories.</returns> public IEnumerable<FileStack> ResolveDirectories(IEnumerable<string> files) { return Resolve(files.Select(i => new FileSystemMetadata { FullName = i, IsDirectory = true })); } + /// <summary> + /// Resolves only files from paths. + /// </summary> + /// <param name="files">List of paths.</param> + /// <returns>Enumerable <see cref="FileStack"/> of files.</returns> public IEnumerable<FileStack> ResolveFiles(IEnumerable<string> files) { return Resolve(files.Select(i => new FileSystemMetadata { FullName = i, IsDirectory = false })); } - public IEnumerable<FileStack> ResolveAudioBooks(IEnumerable<FileSystemMetadata> files) + /// <summary> + /// Resolves audiobooks from paths. + /// </summary> + /// <param name="files">List of paths.</param> + /// <returns>Enumerable <see cref="FileStack"/> of directories.</returns> + public IEnumerable<FileStack> ResolveAudioBooks(IEnumerable<AudioBookFileInfo> files) { - var groupedDirectoryFiles = files.GroupBy(file => - file.IsDirectory - ? file.FullName - : Path.GetDirectoryName(file.FullName)); + var groupedDirectoryFiles = files.GroupBy(file => Path.GetDirectoryName(file.Path)); foreach (var directory in groupedDirectoryFiles) { - var stack = new FileStack { Name = Path.GetFileName(directory.Key), IsDirectoryStack = false }; - foreach (var file in directory) + if (string.IsNullOrEmpty(directory.Key)) { - if (file.IsDirectory) + foreach (var file in directory) { - continue; + var stack = new FileStack { Name = Path.GetFileNameWithoutExtension(file.Path), IsDirectoryStack = false }; + stack.Files.Add(file.Path); + yield return stack; } - - stack.Files.Add(file.FullName); } + else + { + var stack = new FileStack { Name = Path.GetFileName(directory.Key), IsDirectoryStack = false }; + foreach (var file in directory) + { + stack.Files.Add(file.Path); + } - yield return stack; + yield return stack; + } } } + /// <summary> + /// Resolves videos from paths. + /// </summary> + /// <param name="files">List of paths.</param> + /// <returns>Enumerable <see cref="FileStack"/> of videos.</returns> public IEnumerable<FileStack> Resolve(IEnumerable<FileSystemMetadata> files) { - var resolver = new VideoResolver(_options); - var list = files - .Where(i => i.IsDirectory || resolver.IsVideoFile(i.FullName) || resolver.IsStubFile(i.FullName)) + .Where(i => i.IsDirectory || VideoResolver.IsVideoFile(i.FullName, _options) || VideoResolver.IsStubFile(i.FullName, _options)) .OrderBy(i => i.FullName) .ToList(); @@ -81,10 +109,10 @@ namespace Emby.Naming.Video if (match1.Success) { - var title1 = match1.Groups[1].Value; - var volume1 = match1.Groups[2].Value; - var ignore1 = match1.Groups[3].Value; - var extension1 = match1.Groups[4].Value; + var title1 = match1.Groups["title"].Value; + var volume1 = match1.Groups["volume"].Value; + var ignore1 = match1.Groups["ignore"].Value; + var extension1 = match1.Groups["extension"].Value; var j = i + 1; while (j < list.Count) diff --git a/Emby.Naming/Video/StubResolver.cs b/Emby.Naming/Video/StubResolver.cs index f1b5d7bcca..079987fe8a 100644 --- a/Emby.Naming/Video/StubResolver.cs +++ b/Emby.Naming/Video/StubResolver.cs @@ -1,6 +1,3 @@ -#pragma warning disable CS1591 -#nullable enable - using System; using System.IO; using System.Linq; @@ -8,13 +5,23 @@ using Emby.Naming.Common; namespace Emby.Naming.Video { + /// <summary> + /// Resolve if file is stub (.disc). + /// </summary> public static class StubResolver { + /// <summary> + /// Tries to resolve if file is stub (.disc). + /// </summary> + /// <param name="path">Path to file.</param> + /// <param name="options">NamingOptions containing StubFileExtensions and StubTypes.</param> + /// <param name="stubType">Stub type.</param> + /// <returns>True if file is a stub.</returns> public static bool TryResolveFile(string path, NamingOptions options, out string? stubType) { stubType = default; - if (path == null) + if (string.IsNullOrEmpty(path)) { return false; } diff --git a/Emby.Naming/Video/StubResult.cs b/Emby.Naming/Video/StubResult.cs deleted file mode 100644 index 1b8e99b0dc..0000000000 --- a/Emby.Naming/Video/StubResult.cs +++ /dev/null @@ -1,19 +0,0 @@ -#pragma warning disable CS1591 - -namespace Emby.Naming.Video -{ - public struct StubResult - { - /// <summary> - /// Gets or sets a value indicating whether this instance is stub. - /// </summary> - /// <value><c>true</c> if this instance is stub; otherwise, <c>false</c>.</value> - public bool IsStub { get; set; } - - /// <summary> - /// Gets or sets the type of the stub. - /// </summary> - /// <value>The type of the stub.</value> - public string StubType { get; set; } - } -} diff --git a/Emby.Naming/Video/StubTypeRule.cs b/Emby.Naming/Video/StubTypeRule.cs index 8285cb51a3..dfb3ac013d 100644 --- a/Emby.Naming/Video/StubTypeRule.cs +++ b/Emby.Naming/Video/StubTypeRule.cs @@ -1,9 +1,21 @@ -#pragma warning disable CS1591 - namespace Emby.Naming.Video { + /// <summary> + /// Data class holding information about Stub type rule. + /// </summary> public class StubTypeRule { + /// <summary> + /// Initializes a new instance of the <see cref="StubTypeRule"/> class. + /// </summary> + /// <param name="token">Token.</param> + /// <param name="stubType">Stub type.</param> + public StubTypeRule(string token, string stubType) + { + Token = token; + StubType = stubType; + } + /// <summary> /// Gets or sets the token. /// </summary> diff --git a/Emby.Naming/Video/VideoFileInfo.cs b/Emby.Naming/Video/VideoFileInfo.cs index 11e789b663..481773ff60 100644 --- a/Emby.Naming/Video/VideoFileInfo.cs +++ b/Emby.Naming/Video/VideoFileInfo.cs @@ -1,3 +1,4 @@ +using System; using MediaBrowser.Model.Entities; namespace Emby.Naming.Video @@ -7,6 +8,35 @@ namespace Emby.Naming.Video /// </summary> public class VideoFileInfo { + /// <summary> + /// Initializes a new instance of the <see cref="VideoFileInfo"/> class. + /// </summary> + /// <param name="name">Name of file.</param> + /// <param name="path">Path to the file.</param> + /// <param name="container">Container type.</param> + /// <param name="year">Year of release.</param> + /// <param name="extraType">Extra type.</param> + /// <param name="extraRule">Extra rule.</param> + /// <param name="format3D">Format 3D.</param> + /// <param name="is3D">Is 3D.</param> + /// <param name="isStub">Is Stub.</param> + /// <param name="stubType">Stub type.</param> + /// <param name="isDirectory">Is directory.</param> + public VideoFileInfo(string name, string path, string? container, int? year = default, ExtraType? extraType = default, ExtraRule? extraRule = default, string? format3D = default, bool is3D = default, bool isStub = default, string? stubType = default, bool isDirectory = default) + { + Path = path; + Container = container; + Name = name; + Year = year; + ExtraType = extraType; + ExtraRule = extraRule; + Format3D = format3D; + Is3D = is3D; + IsStub = isStub; + StubType = stubType; + IsDirectory = isDirectory; + } + /// <summary> /// Gets or sets the path. /// </summary> @@ -17,7 +47,7 @@ namespace Emby.Naming.Video /// Gets or sets the container. /// </summary> /// <value>The container.</value> - public string Container { get; set; } + public string? Container { get; set; } /// <summary> /// Gets or sets the name. @@ -41,13 +71,13 @@ namespace Emby.Naming.Video /// Gets or sets the extra rule. /// </summary> /// <value>The extra rule.</value> - public ExtraRule ExtraRule { get; set; } + public ExtraRule? ExtraRule { get; set; } /// <summary> /// Gets or sets the format3 d. /// </summary> /// <value>The format3 d.</value> - public string Format3D { get; set; } + public string? Format3D { get; set; } /// <summary> /// Gets or sets a value indicating whether [is3 d]. @@ -65,7 +95,7 @@ namespace Emby.Naming.Video /// Gets or sets the type of the stub. /// </summary> /// <value>The type of the stub.</value> - public string StubType { get; set; } + public string? StubType { get; set; } /// <summary> /// Gets or sets a value indicating whether this instance is a directory. @@ -77,15 +107,14 @@ namespace Emby.Naming.Video /// Gets the file name without extension. /// </summary> /// <value>The file name without extension.</value> - public string FileNameWithoutExtension => !IsDirectory - ? System.IO.Path.GetFileNameWithoutExtension(Path) - : System.IO.Path.GetFileName(Path); + public ReadOnlySpan<char> FileNameWithoutExtension => !IsDirectory + ? System.IO.Path.GetFileNameWithoutExtension(Path.AsSpan()) + : System.IO.Path.GetFileName(Path.AsSpan()); /// <inheritdoc /> public override string ToString() { - // Makes debugging easier - return Name ?? base.ToString(); + return "VideoFileInfo(Name: '" + Name + "')"; } } } diff --git a/Emby.Naming/Video/VideoInfo.cs b/Emby.Naming/Video/VideoInfo.cs index ea74c40e2a..930fdb33f8 100644 --- a/Emby.Naming/Video/VideoInfo.cs +++ b/Emby.Naming/Video/VideoInfo.cs @@ -12,7 +12,7 @@ namespace Emby.Naming.Video /// Initializes a new instance of the <see cref="VideoInfo" /> class. /// </summary> /// <param name="name">The name.</param> - public VideoInfo(string name) + public VideoInfo(string? name) { Name = name; @@ -25,7 +25,7 @@ namespace Emby.Naming.Video /// Gets or sets the name. /// </summary> /// <value>The name.</value> - public string Name { get; set; } + public string? Name { get; set; } /// <summary> /// Gets or sets the year. diff --git a/Emby.Naming/Video/VideoListResolver.cs b/Emby.Naming/Video/VideoListResolver.cs index 948fe037b5..ed7d511a39 100644 --- a/Emby.Naming/Video/VideoListResolver.cs +++ b/Emby.Naming/Video/VideoListResolver.cs @@ -1,5 +1,3 @@ -#pragma warning disable CS1591 - using System; using System.Collections.Generic; using System.IO; @@ -11,22 +9,23 @@ using MediaBrowser.Model.IO; namespace Emby.Naming.Video { - public class VideoListResolver + /// <summary> + /// Resolves alternative versions and extras from list of video files. + /// </summary> + public static class VideoListResolver { - private readonly NamingOptions _options; - - public VideoListResolver(NamingOptions options) - { - _options = options; - } - - public IEnumerable<VideoInfo> Resolve(List<FileSystemMetadata> files, bool supportMultiVersion = true) + /// <summary> + /// Resolves alternative versions and extras from list of video files. + /// </summary> + /// <param name="files">List of related video files.</param> + /// <param name="namingOptions">The naming options.</param> + /// <param name="supportMultiVersion">Indication we should consider multi-versions of content.</param> + /// <returns>Returns enumerable of <see cref="VideoInfo"/> which groups files together when related.</returns> + public static IEnumerable<VideoInfo> Resolve(IEnumerable<FileSystemMetadata> files, NamingOptions namingOptions, bool supportMultiVersion = true) { - var videoResolver = new VideoResolver(_options); - var videoInfos = files - .Select(i => videoResolver.Resolve(i.FullName, i.IsDirectory)) - .Where(i => i != null) + .Select(i => VideoResolver.Resolve(i.FullName, i.IsDirectory, namingOptions)) + .OfType<VideoFileInfo>() .ToList(); // Filter out all extras, otherwise they could cause stacks to not be resolved @@ -35,11 +34,11 @@ namespace Emby.Naming.Video .Where(i => i.ExtraType == null) .Select(i => new FileSystemMetadata { FullName = i.Path, IsDirectory = i.IsDirectory }); - var stackResult = new StackResolver(_options) + var stackResult = new StackResolver(namingOptions) .Resolve(nonExtras).ToList(); var remainingFiles = videoInfos - .Where(i => !stackResult.Any(s => s.ContainsFile(i.Path, i.IsDirectory))) + .Where(i => !stackResult.Any(s => i.Path != null && s.ContainsFile(i.Path, i.IsDirectory))) .ToList(); var list = new List<VideoInfo>(); @@ -48,21 +47,17 @@ namespace Emby.Naming.Video { var info = new VideoInfo(stack.Name) { - Files = stack.Files.Select(i => videoResolver.Resolve(i, stack.IsDirectoryStack)).ToList() + Files = stack.Files.Select(i => VideoResolver.Resolve(i, stack.IsDirectoryStack, namingOptions)) + .OfType<VideoFileInfo>() + .ToList() }; info.Year = info.Files[0].Year; - var extraBaseNames = new List<string> { stack.Name, Path.GetFileNameWithoutExtension(stack.Files[0]) }; - - var extras = GetExtras(remainingFiles, extraBaseNames); + var extras = ExtractExtras(remainingFiles, stack.Name, Path.GetFileNameWithoutExtension(stack.Files[0].AsSpan()), namingOptions.VideoFlagDelimiters); if (extras.Count > 0) { - remainingFiles = remainingFiles - .Except(extras) - .ToList(); - info.Extras = extras; } @@ -75,15 +70,12 @@ namespace Emby.Naming.Video foreach (var media in standaloneMedia) { - var info = new VideoInfo(media.Name) { Files = new List<VideoFileInfo> { media } }; + var info = new VideoInfo(media.Name) { Files = new[] { media } }; info.Year = info.Files[0].Year; - var extras = GetExtras(remainingFiles, new List<string> { media.FileNameWithoutExtension }); - - remainingFiles = remainingFiles - .Except(extras.Concat(new[] { media })) - .ToList(); + remainingFiles.Remove(media); + var extras = ExtractExtras(remainingFiles, media.FileNameWithoutExtension, namingOptions.VideoFlagDelimiters); info.Extras = extras; @@ -92,8 +84,7 @@ namespace Emby.Naming.Video if (supportMultiVersion) { - list = GetVideosGroupedByVersion(list) - .ToList(); + list = GetVideosGroupedByVersion(list, namingOptions); } // If there's only one resolved video, use the folder name as well to find extras @@ -101,19 +92,14 @@ namespace Emby.Naming.Video { var info = list[0]; var videoPath = list[0].Files[0].Path; - var parentPath = Path.GetDirectoryName(videoPath); + var parentPath = Path.GetDirectoryName(videoPath.AsSpan()); - if (!string.IsNullOrEmpty(parentPath)) + if (!parentPath.IsEmpty) { var folderName = Path.GetFileName(parentPath); - if (!string.IsNullOrEmpty(folderName)) + if (!folderName.IsEmpty) { - var extras = GetExtras(remainingFiles, new List<string> { folderName }); - - remainingFiles = remainingFiles - .Except(extras) - .ToList(); - + var extras = ExtractExtras(remainingFiles, folderName, namingOptions.VideoFlagDelimiters); extras.AddRange(info.Extras); info.Extras = extras; } @@ -133,7 +119,7 @@ namespace Emby.Naming.Video } // If there's only one video, accept all trailers - // Be lenient because people use all kinds of mish mash conventions with trailers + // Be lenient because people use all kinds of mishmash conventions with trailers. if (list.Count == 1) { var trailers = remainingFiles @@ -151,86 +137,168 @@ namespace Emby.Naming.Video // Whatever files are left, just add them list.AddRange(remainingFiles.Select(i => new VideoInfo(i.Name) { - Files = new List<VideoFileInfo> { i }, + Files = new[] { i }, Year = i.Year })); return list; } - private IEnumerable<VideoInfo> GetVideosGroupedByVersion(List<VideoInfo> videos) + private static List<VideoInfo> GetVideosGroupedByVersion(List<VideoInfo> videos, NamingOptions namingOptions) { if (videos.Count == 0) { return videos; } - var list = new List<VideoInfo>(); + var folderName = Path.GetFileName(Path.GetDirectoryName(videos[0].Files[0].Path.AsSpan())); - var folderName = Path.GetFileName(Path.GetDirectoryName(videos[0].Files[0].Path)); - - if (!string.IsNullOrEmpty(folderName) - && folderName.Length > 1 - && videos.All(i => i.Files.Count == 1 - && IsEligibleForMultiVersion(folderName, i.Files[0].Path)) - && HaveSameYear(videos)) + if (folderName.Length <= 1 || !HaveSameYear(videos)) { - var ordered = videos.OrderBy(i => i.Name).ToList(); - - list.Add(ordered[0]); + return videos; + } - var alternateVersionsLen = ordered.Count - 1; - var alternateVersions = new VideoFileInfo[alternateVersionsLen]; - for (int i = 0; i < alternateVersionsLen; i++) + // Cannot use Span inside local functions and delegates thus we cannot use LINQ here nor merge with the above [if] + for (var i = 0; i < videos.Count; i++) + { + var video = videos[i]; + if (!IsEligibleForMultiVersion(folderName, video.Files[0].Path, namingOptions)) { - alternateVersions[i] = ordered[i + 1].Files[0]; + return videos; } + } + + // 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)); - list[0].AlternateVersions = alternateVersions; - list[0].Name = folderName; - var extras = ordered.Skip(1).SelectMany(i => i.Extras).ToList(); - extras.AddRange(list[0].Extras); - list[0].Extras = extras; + var list = new List<VideoInfo> + { + videos[0] + }; - return list; + var alternateVersionsLen = videos.Count - 1; + var alternateVersions = new VideoFileInfo[alternateVersionsLen]; + var extras = new List<VideoFileInfo>(list[0].Extras); + for (int i = 0; i < alternateVersionsLen; i++) + { + var video = videos[i + 1]; + alternateVersions[i] = video.Files[0]; + extras.AddRange(video.Extras); } - return videos; + list[0].AlternateVersions = alternateVersions; + list[0].Name = folderName.ToString(); + list[0].Extras = extras; + + return list; } - private bool HaveSameYear(List<VideoInfo> videos) + private static bool HaveSameYear(IReadOnlyList<VideoInfo> videos) { - return videos.Select(i => i.Year ?? -1).Distinct().Count() < 2; + if (videos.Count == 1) + { + return true; + } + + var firstYear = videos[0].Year ?? -1; + for (var i = 1; i < videos.Count; i++) + { + if ((videos[i].Year ?? -1) != firstYear) + { + return false; + } + } + + return true; } - private bool IsEligibleForMultiVersion(string folderName, string testFilename) + private static bool IsEligibleForMultiVersion(ReadOnlySpan<char> folderName, string testFilePath, NamingOptions namingOptions) { - testFilename = Path.GetFileNameWithoutExtension(testFilename) ?? string.Empty; + var testFilename = Path.GetFileNameWithoutExtension(testFilePath.AsSpan()); + if (!testFilename.StartsWith(folderName, StringComparison.OrdinalIgnoreCase)) + { + return false; + } - if (testFilename.StartsWith(folderName, StringComparison.OrdinalIgnoreCase)) + // Remove the folder name before cleaning as we don't care about cleaning that part + if (folderName.Length <= testFilename.Length) { - testFilename = testFilename.Substring(folderName.Length).Trim(); - return string.IsNullOrEmpty(testFilename) + testFilename = testFilename[folderName.Length..].Trim(); + } + + // There are no span overloads for regex unfortunately + var tmpTestFilename = testFilename.ToString(); + if (CleanStringParser.TryClean(tmpTestFilename, namingOptions.CleanStringRegexes, out var cleanName)) + { + tmpTestFilename = cleanName.Trim().ToString(); + } + + // The CleanStringParser should have removed common keywords etc. + return string.IsNullOrEmpty(tmpTestFilename) || testFilename[0] == '-' - || string.IsNullOrWhiteSpace(Regex.Replace(testFilename, @"\[([^]]*)\]", string.Empty)); + || Regex.IsMatch(tmpTestFilename, @"^\[([^]]*)\]", RegexOptions.Compiled); + } + + private static ReadOnlySpan<char> TrimFilenameDelimiters(ReadOnlySpan<char> name, ReadOnlySpan<char> videoFlagDelimiters) + { + return name.IsEmpty ? name : name.TrimEnd().TrimEnd(videoFlagDelimiters).TrimEnd(); + } + + private static bool StartsWith(ReadOnlySpan<char> fileName, ReadOnlySpan<char> baseName, ReadOnlySpan<char> trimmedBaseName) + { + if (baseName.IsEmpty) + { + return false; } - return false; + return fileName.StartsWith(baseName, StringComparison.OrdinalIgnoreCase) + || (!trimmedBaseName.IsEmpty && fileName.StartsWith(trimmedBaseName, StringComparison.OrdinalIgnoreCase)); + } + + /// <summary> + /// Finds similar filenames to that of [baseName] and removes any matches from [remainingFiles]. + /// </summary> + /// <param name="remainingFiles">The list of remaining filenames.</param> + /// <param name="baseName">The base name to use for the comparison.</param> + /// <param name="videoFlagDelimiters">The video flag delimiters.</param> + /// <returns>A list of video extras for [baseName].</returns> + private static List<VideoFileInfo> ExtractExtras(IList<VideoFileInfo> remainingFiles, ReadOnlySpan<char> baseName, ReadOnlySpan<char> videoFlagDelimiters) + { + return ExtractExtras(remainingFiles, baseName, ReadOnlySpan<char>.Empty, videoFlagDelimiters); } - private List<VideoFileInfo> GetExtras(IEnumerable<VideoFileInfo> remainingFiles, List<string> baseNames) + /// <summary> + /// Finds similar filenames to that of [firstBaseName] and [secondBaseName] and removes any matches from [remainingFiles]. + /// </summary> + /// <param name="remainingFiles">The list of remaining filenames.</param> + /// <param name="firstBaseName">The first base name to use for the comparison.</param> + /// <param name="secondBaseName">The second base name to use for the comparison.</param> + /// <param name="videoFlagDelimiters">The video flag delimiters.</param> + /// <returns>A list of video extras for [firstBaseName] and [secondBaseName].</returns> + private static List<VideoFileInfo> ExtractExtras(IList<VideoFileInfo> remainingFiles, ReadOnlySpan<char> firstBaseName, ReadOnlySpan<char> secondBaseName, ReadOnlySpan<char> videoFlagDelimiters) { - foreach (var name in baseNames.ToList()) + var trimmedFirstBaseName = TrimFilenameDelimiters(firstBaseName, videoFlagDelimiters); + var trimmedSecondBaseName = TrimFilenameDelimiters(secondBaseName, videoFlagDelimiters); + + var result = new List<VideoFileInfo>(); + for (var pos = remainingFiles.Count - 1; pos >= 0; pos--) { - var trimmedName = name.TrimEnd().TrimEnd(_options.VideoFlagDelimiters).TrimEnd(); - baseNames.Add(trimmedName); + var file = remainingFiles[pos]; + if (file.ExtraType == null) + { + continue; + } + + var filename = file.FileNameWithoutExtension; + if (StartsWith(filename, firstBaseName, trimmedFirstBaseName) + || StartsWith(filename, secondBaseName, trimmedSecondBaseName)) + { + result.Add(file); + remainingFiles.RemoveAt(pos); + } } - return remainingFiles - .Where(i => i.ExtraType != null) - .Where(i => baseNames.Any(b => - i.FileNameWithoutExtension.StartsWith(b, StringComparison.OrdinalIgnoreCase))) - .ToList(); + return result; } } } diff --git a/Emby.Naming/Video/VideoResolver.cs b/Emby.Naming/Video/VideoResolver.cs index b4aee614b0..3b1d906c64 100644 --- a/Emby.Naming/Video/VideoResolver.cs +++ b/Emby.Naming/Video/VideoResolver.cs @@ -1,40 +1,36 @@ -#pragma warning disable CS1591 -#nullable enable - using System; +using System.Diagnostics.CodeAnalysis; using System.IO; -using System.Linq; using Emby.Naming.Common; +using Jellyfin.Extensions; namespace Emby.Naming.Video { - public class VideoResolver + /// <summary> + /// Resolves <see cref="VideoFileInfo"/> from file path. + /// </summary> + public static class VideoResolver { - private readonly NamingOptions _options; - - public VideoResolver(NamingOptions options) - { - _options = options; - } - /// <summary> /// Resolves the directory. /// </summary> /// <param name="path">The path.</param> + /// <param name="namingOptions">The naming options.</param> /// <returns>VideoFileInfo.</returns> - public VideoFileInfo? ResolveDirectory(string path) + public static VideoFileInfo? ResolveDirectory(string? path, NamingOptions namingOptions) { - return Resolve(path, true); + return Resolve(path, true, namingOptions); } /// <summary> /// Resolves the file. /// </summary> /// <param name="path">The path.</param> + /// <param name="namingOptions">The naming options.</param> /// <returns>VideoFileInfo.</returns> - public VideoFileInfo? ResolveFile(string path) + public static VideoFileInfo? ResolveFile(string? path, NamingOptions namingOptions) { - return Resolve(path, false); + return Resolve(path, false, namingOptions); } /// <summary> @@ -42,29 +38,30 @@ namespace Emby.Naming.Video /// </summary> /// <param name="path">The path.</param> /// <param name="isDirectory">if set to <c>true</c> [is folder].</param> + /// <param name="namingOptions">The naming options.</param> /// <param name="parseName">Whether or not the name should be parsed for info.</param> /// <returns>VideoFileInfo.</returns> /// <exception cref="ArgumentNullException"><c>path</c> is <c>null</c>.</exception> - public VideoFileInfo? Resolve(string path, bool isDirectory, bool parseName = true) + public static VideoFileInfo? Resolve(string? path, bool isDirectory, NamingOptions namingOptions, bool parseName = true) { if (string.IsNullOrEmpty(path)) { - throw new ArgumentNullException(nameof(path)); + return null; } bool isStub = false; - string? container = null; + ReadOnlySpan<char> container = ReadOnlySpan<char>.Empty; string? stubType = null; if (!isDirectory) { - var extension = Path.GetExtension(path); + var extension = Path.GetExtension(path.AsSpan()); // Check supported extensions - if (!_options.VideoFileExtensions.Contains(extension, StringComparer.OrdinalIgnoreCase)) + if (!namingOptions.VideoFileExtensions.Contains(extension, StringComparison.OrdinalIgnoreCase)) { // It's not supported. Check stub extensions - if (!StubResolver.TryResolveFile(path, _options, out stubType)) + if (!StubResolver.TryResolveFile(path, namingOptions, out stubType)) { return null; } @@ -75,66 +72,86 @@ namespace Emby.Naming.Video container = extension.TrimStart('.'); } - var flags = new FlagParser(_options).GetFlags(path); - var format3DResult = new Format3DParser(_options).Parse(flags); + var format3DResult = Format3DParser.Parse(path, namingOptions); - var extraResult = new ExtraResolver(_options).GetExtraInfo(path); + var extraResult = new ExtraResolver(namingOptions).GetExtraInfo(path); - var name = isDirectory - ? Path.GetFileName(path) - : Path.GetFileNameWithoutExtension(path); + var name = Path.GetFileNameWithoutExtension(path); int? year = null; if (parseName) { - var cleanDateTimeResult = CleanDateTime(name); + var cleanDateTimeResult = CleanDateTime(name, namingOptions); name = cleanDateTimeResult.Name; year = cleanDateTimeResult.Year; if (extraResult.ExtraType == null - && TryCleanString(name, out ReadOnlySpan<char> newName)) + && TryCleanString(name, namingOptions, out ReadOnlySpan<char> newName)) { name = newName.ToString(); } } - return new VideoFileInfo - { - Path = path, - Container = container, - IsStub = isStub, - Name = name, - Year = year, - StubType = stubType, - Is3D = format3DResult.Is3D, - Format3D = format3DResult.Format3D, - ExtraType = extraResult.ExtraType, - IsDirectory = isDirectory, - ExtraRule = extraResult.Rule - }; + return new VideoFileInfo( + path: path, + container: container.IsEmpty ? null : container.ToString(), + isStub: isStub, + name: name, + year: year, + stubType: stubType, + is3D: format3DResult.Is3D, + format3D: format3DResult.Format3D, + extraType: extraResult.ExtraType, + isDirectory: isDirectory, + extraRule: extraResult.Rule); } - public bool IsVideoFile(string path) + /// <summary> + /// Determines if path is video file based on extension. + /// </summary> + /// <param name="path">Path to file.</param> + /// <param name="namingOptions">The naming options.</param> + /// <returns>True if is video file.</returns> + public static bool IsVideoFile(string path, NamingOptions namingOptions) { - var extension = Path.GetExtension(path) ?? string.Empty; - return _options.VideoFileExtensions.Contains(extension, StringComparer.OrdinalIgnoreCase); + var extension = Path.GetExtension(path.AsSpan()); + return namingOptions.VideoFileExtensions.Contains(extension, StringComparison.OrdinalIgnoreCase); } - public bool IsStubFile(string path) + /// <summary> + /// Determines if path is video file stub based on extension. + /// </summary> + /// <param name="path">Path to file.</param> + /// <param name="namingOptions">The naming options.</param> + /// <returns>True if is video file stub.</returns> + public static bool IsStubFile(string path, NamingOptions namingOptions) { - var extension = Path.GetExtension(path) ?? string.Empty; - return _options.StubFileExtensions.Contains(extension, StringComparer.OrdinalIgnoreCase); + var extension = Path.GetExtension(path.AsSpan()); + return namingOptions.StubFileExtensions.Contains(extension, StringComparison.OrdinalIgnoreCase); } - public bool TryCleanString(string name, out ReadOnlySpan<char> newName) + /// <summary> + /// Tries to clean name of clutter. + /// </summary> + /// <param name="name">Raw name.</param> + /// <param name="namingOptions">The naming options.</param> + /// <param name="newName">Clean name.</param> + /// <returns>True if cleaning of name was successful.</returns> + public static bool TryCleanString([NotNullWhen(true)] string? name, NamingOptions namingOptions, out ReadOnlySpan<char> newName) { - return CleanStringParser.TryClean(name, _options.CleanStringRegexes, out newName); + return CleanStringParser.TryClean(name, namingOptions.CleanStringRegexes, out newName); } - public CleanDateTimeResult CleanDateTime(string name) + /// <summary> + /// Tries to get name and year from raw name. + /// </summary> + /// <param name="name">Raw name.</param> + /// <param name="namingOptions">The naming options.</param> + /// <returns>Returns <see cref="CleanDateTimeResult"/> with name and optional year.</returns> + public static CleanDateTimeResult CleanDateTime(string name, NamingOptions namingOptions) { - return CleanDateTimeParser.Clean(name, _options.CleanDateTimeRegexes); + return CleanDateTimeParser.Clean(name, namingOptions.CleanDateTimeRegexes); } } } diff --git a/Emby.Notifications/CoreNotificationTypes.cs b/Emby.Notifications/CoreNotificationTypes.cs index a602b72213..ec3490e23b 100644 --- a/Emby.Notifications/CoreNotificationTypes.cs +++ b/Emby.Notifications/CoreNotificationTypes.cs @@ -75,10 +75,6 @@ namespace Emby.Notifications Type = NotificationType.VideoPlaybackStopped.ToString() }, new NotificationTypeInfo - { - Type = NotificationType.CameraImageUploaded.ToString() - }, - new NotificationTypeInfo { Type = NotificationType.UserLockedOut.ToString() }, @@ -114,10 +110,6 @@ namespace Emby.Notifications { note.Category = _localization.GetLocalizedString("Plugin"); } - else if (note.Type.IndexOf("CameraImageUploaded", StringComparison.OrdinalIgnoreCase) != -1) - { - note.Category = _localization.GetLocalizedString("Sync"); - } else if (note.Type.IndexOf("UserLockedOut", StringComparison.OrdinalIgnoreCase) != -1) { note.Category = _localization.GetLocalizedString("User"); diff --git a/Emby.Notifications/Emby.Notifications.csproj b/Emby.Notifications/Emby.Notifications.csproj index 1d430a5e58..5edcf2f295 100644 --- a/Emby.Notifications/Emby.Notifications.csproj +++ b/Emby.Notifications/Emby.Notifications.csproj @@ -6,11 +6,9 @@ </PropertyGroup> <PropertyGroup> - <TargetFramework>netstandard2.1</TargetFramework> + <TargetFramework>net5.0</TargetFramework> <GenerateAssemblyInfo>false</GenerateAssemblyInfo> <GenerateDocumentationFile>true</GenerateDocumentationFile> - <TreatWarningsAsErrors>true</TreatWarningsAsErrors> - <Nullable>enable</Nullable> </PropertyGroup> <ItemGroup> @@ -25,14 +23,9 @@ <!-- Code analyzers--> <ItemGroup Condition=" '$(Configuration)' == 'Debug' "> - <PackageReference Include="Microsoft.CodeAnalysis.FxCopAnalyzers" Version="2.9.8" PrivateAssets="All" /> <PackageReference Include="SerilogAnalyzer" Version="0.15.0" PrivateAssets="All" /> <PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="All" /> <PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" PrivateAssets="All" /> </ItemGroup> - <PropertyGroup Condition=" '$(Configuration)' == 'Debug' "> - <CodeAnalysisRuleSet>../jellyfin.ruleset</CodeAnalysisRuleSet> - </PropertyGroup> - </Project> diff --git a/Emby.Notifications/NotificationEntryPoint.cs b/Emby.Notifications/NotificationEntryPoint.cs index ded22d26cc..e8ae14ff22 100644 --- a/Emby.Notifications/NotificationEntryPoint.cs +++ b/Emby.Notifications/NotificationEntryPoint.cs @@ -77,13 +77,12 @@ namespace Emby.Notifications { _libraryManager.ItemAdded += OnLibraryManagerItemAdded; _appHost.HasPendingRestartChanged += OnAppHostHasPendingRestartChanged; - _appHost.HasUpdateAvailableChanged += OnAppHostHasUpdateAvailableChanged; _activityManager.EntryCreated += OnActivityManagerEntryCreated; return Task.CompletedTask; } - private async void OnAppHostHasPendingRestartChanged(object sender, EventArgs e) + private async void OnAppHostHasPendingRestartChanged(object? sender, EventArgs e) { var type = NotificationType.ServerRestartRequired.ToString(); @@ -99,7 +98,7 @@ namespace Emby.Notifications await SendNotification(notification, null).ConfigureAwait(false); } - private async void OnActivityManagerEntryCreated(object sender, GenericEventArgs<ActivityLogEntry> e) + private async void OnActivityManagerEntryCreated(object? sender, GenericEventArgs<ActivityLogEntry> e) { var entry = e.Argument; @@ -132,26 +131,7 @@ namespace Emby.Notifications return _config.GetConfiguration<NotificationOptions>("notifications"); } - private async void OnAppHostHasUpdateAvailableChanged(object sender, EventArgs e) - { - if (!_appHost.HasUpdateAvailable) - { - return; - } - - var type = NotificationType.ApplicationUpdateAvailable.ToString(); - - var notification = new NotificationRequest - { - Description = "Please see jellyfin.org for details.", - NotificationType = type, - Name = _localization.GetLocalizedString("NewVersionIsAvailable") - }; - - await SendNotification(notification, null).ConfigureAwait(false); - } - - private void OnLibraryManagerItemAdded(object sender, ItemChangeEventArgs e) + private void OnLibraryManagerItemAdded(object? sender, ItemChangeEventArgs e) { if (!FilterItem(e.Item)) { @@ -197,7 +177,7 @@ namespace Emby.Notifications return item.SourceType == SourceType.Library; } - private async void LibraryUpdateTimerCallback(object state) + private async void LibraryUpdateTimerCallback(object? state) { List<BaseItem> items; @@ -209,7 +189,10 @@ namespace Emby.Notifications _libraryUpdateTimer = null; } - items = items.Take(10).ToList(); + if (items.Count > 10) + { + items = items.GetRange(0, 10); + } foreach (var item in items) { @@ -322,7 +305,6 @@ namespace Emby.Notifications _libraryManager.ItemAdded -= OnLibraryManagerItemAdded; _appHost.HasPendingRestartChanged -= OnAppHostHasPendingRestartChanged; - _appHost.HasUpdateAvailableChanged -= OnAppHostHasUpdateAvailableChanged; _activityManager.EntryCreated -= OnActivityManagerEntryCreated; _disposed = true; diff --git a/Emby.Photos/Emby.Photos.csproj b/Emby.Photos/Emby.Photos.csproj index dbe01257f4..00b2f0f94c 100644 --- a/Emby.Photos/Emby.Photos.csproj +++ b/Emby.Photos/Emby.Photos.csproj @@ -19,23 +19,16 @@ </ItemGroup> <PropertyGroup> - <TargetFramework>netstandard2.1</TargetFramework> + <TargetFramework>net5.0</TargetFramework> <GenerateAssemblyInfo>false</GenerateAssemblyInfo> <GenerateDocumentationFile>true</GenerateDocumentationFile> - <TreatWarningsAsErrors>true</TreatWarningsAsErrors> - <Nullable>enable</Nullable> </PropertyGroup> <!-- Code Analyzers--> <ItemGroup Condition=" '$(Configuration)' == 'Debug' "> - <PackageReference Include="Microsoft.CodeAnalysis.FxCopAnalyzers" Version="2.9.8" PrivateAssets="All" /> <PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="All" /> <PackageReference Include="SerilogAnalyzer" Version="0.15.0" PrivateAssets="All" /> <PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" PrivateAssets="All" /> </ItemGroup> - <PropertyGroup Condition=" '$(Configuration)' == 'Debug' "> - <CodeAnalysisRuleSet>../jellyfin.ruleset</CodeAnalysisRuleSet> - </PropertyGroup> - </Project> diff --git a/Emby.Server.Implementations/AppBase/BaseApplicationPaths.cs b/Emby.Server.Implementations/AppBase/BaseApplicationPaths.cs index 2adc1d6c34..6edfad575a 100644 --- a/Emby.Server.Implementations/AppBase/BaseApplicationPaths.cs +++ b/Emby.Server.Implementations/AppBase/BaseApplicationPaths.cs @@ -33,7 +33,7 @@ namespace Emby.Server.Implementations.AppBase CachePath = cacheDirectoryPath; WebPath = webDirectoryPath; - DataPath = Path.Combine(ProgramDataPath, "data"); + _dataPath = Directory.CreateDirectory(Path.Combine(ProgramDataPath, "data")).FullName; } /// <summary> @@ -55,14 +55,10 @@ namespace Emby.Server.Implementations.AppBase /// Gets the folder path to the data directory. /// </summary> /// <value>The data directory.</value> - public string DataPath - { - get => _dataPath; - private set => _dataPath = Directory.CreateDirectory(value).FullName; - } + public string DataPath => _dataPath; /// <inheritdoc /> - public string VirtualDataPath { get; } = "%AppDataPath%"; + public string VirtualDataPath => "%AppDataPath%"; /// <summary> /// Gets the image cache path. diff --git a/Emby.Server.Implementations/AppBase/BaseConfigurationManager.cs b/Emby.Server.Implementations/AppBase/BaseConfigurationManager.cs index 4ab0a2a3f2..d385356345 100644 --- a/Emby.Server.Implementations/AppBase/BaseConfigurationManager.cs +++ b/Emby.Server.Implementations/AppBase/BaseConfigurationManager.cs @@ -1,3 +1,5 @@ +#nullable disable + using System; using System.Collections.Concurrent; using System.Collections.Generic; @@ -23,6 +25,11 @@ namespace Emby.Server.Implementations.AppBase private readonly ConcurrentDictionary<string, object> _configurations = new ConcurrentDictionary<string, object>(); + /// <summary> + /// The _configuration sync lock. + /// </summary> + private readonly object _configurationSyncLock = new object(); + private ConfigurationStore[] _configurationStores = Array.Empty<ConfigurationStore>(); private IConfigurationFactory[] _configurationFactories = Array.Empty<IConfigurationFactory>(); @@ -31,11 +38,6 @@ namespace Emby.Server.Implementations.AppBase /// </summary> private bool _configurationLoaded; - /// <summary> - /// The _configuration sync lock. - /// </summary> - private readonly object _configurationSyncLock = new object(); - /// <summary> /// The _configuration. /// </summary> @@ -133,6 +135,33 @@ namespace Emby.Server.Implementations.AppBase } } + /// <summary> + /// Manually pre-loads a factory so that it is available pre system initialisation. + /// </summary> + /// <typeparam name="T">Class to register.</typeparam> + public virtual void RegisterConfiguration<T>() + where T : IConfigurationFactory + { + IConfigurationFactory factory = Activator.CreateInstance<T>(); + + if (_configurationFactories == null) + { + _configurationFactories = new[] { factory }; + } + else + { + var oldLen = _configurationFactories.Length; + var arr = new IConfigurationFactory[oldLen + 1]; + _configurationFactories.CopyTo(arr, 0); + arr[oldLen] = factory; + _configurationFactories = arr; + } + + _configurationStores = _configurationFactories + .SelectMany(i => i.GetConfigurations()) + .ToArray(); + } + /// <summary> /// Adds parts. /// </summary> @@ -270,25 +299,29 @@ namespace Emby.Server.Implementations.AppBase /// <inheritdoc /> public object GetConfiguration(string key) { - return _configurations.GetOrAdd(key, k => - { - var file = GetConfigurationFile(key); + return _configurations.GetOrAdd( + key, + (k, configurationManager) => + { + var file = configurationManager.GetConfigurationFile(k); - var configurationInfo = _configurationStores - .FirstOrDefault(i => string.Equals(i.Key, key, StringComparison.OrdinalIgnoreCase)); + var configurationInfo = Array.Find( + configurationManager._configurationStores, + i => string.Equals(i.Key, k, StringComparison.OrdinalIgnoreCase)); - if (configurationInfo == null) - { - throw new ResourceNotFoundException("Configuration with key " + key + " not found."); - } + if (configurationInfo == null) + { + throw new ResourceNotFoundException("Configuration with key " + k + " not found."); + } - var configurationType = configurationInfo.ConfigurationType; + var configurationType = configurationInfo.ConfigurationType; - lock (_configurationSyncLock) - { - return LoadConfiguration(file, configurationType); - } - }); + lock (configurationManager._configurationSyncLock) + { + return configurationManager.LoadConfiguration(file, configurationType); + } + }, + this); } private object LoadConfiguration(string path, Type configurationType) diff --git a/Emby.Server.Implementations/AppBase/ConfigurationHelper.cs b/Emby.Server.Implementations/AppBase/ConfigurationHelper.cs index 4c9ab33a7c..0308a68e42 100644 --- a/Emby.Server.Implementations/AppBase/ConfigurationHelper.cs +++ b/Emby.Server.Implementations/AppBase/ConfigurationHelper.cs @@ -1,5 +1,3 @@ -#nullable enable - using System; using System.IO; using System.Linq; @@ -35,7 +33,8 @@ namespace Emby.Server.Implementations.AppBase } catch (Exception) { - configuration = Activator.CreateInstance(type); + // Note: CreateInstance returns null for Nullable<T>, e.g. CreateInstance(typeof(int?)) returns null. + configuration = Activator.CreateInstance(type)!; } using var stream = new MemoryStream(buffer?.Length ?? 0); @@ -48,10 +47,12 @@ namespace Emby.Server.Implementations.AppBase // If the file didn't exist before, or if something has changed, re-save if (buffer == null || !newBytes.AsSpan(0, newBytesLen).SequenceEqual(buffer)) { - Directory.CreateDirectory(Path.GetDirectoryName(path)); + var directory = Path.GetDirectoryName(path) ?? throw new ArgumentException($"Provided path ({path}) is not valid.", nameof(path)); + Directory.CreateDirectory(directory); // Save it after load in case we got new items - using (var fs = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.Read)) + // use FileShare.None as this bypasses dotnet bug dotnet/runtime#42790 . + using (var fs = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.None)) { fs.Write(newBytes, 0, newBytesLen); } diff --git a/Emby.Server.Implementations/ApplicationHost.cs b/Emby.Server.Implementations/ApplicationHost.cs index 31ca738296..bf7ddace2d 100644 --- a/Emby.Server.Implementations/ApplicationHost.cs +++ b/Emby.Server.Implementations/ApplicationHost.cs @@ -1,19 +1,17 @@ +#nullable disable + #pragma warning disable CS1591 using System; -using System.Collections.Concurrent; using System.Collections.Generic; using System.Diagnostics; using System.Globalization; using System.IO; using System.Linq; using System.Net; -using System.Net.Http; -using System.Net.Sockets; using System.Reflection; using System.Runtime.InteropServices; using System.Security.Cryptography.X509Certificates; -using System.Text; using System.Threading; using System.Threading.Tasks; using Emby.Dlna; @@ -30,7 +28,6 @@ using Emby.Server.Implementations.Cryptography; using Emby.Server.Implementations.Data; using Emby.Server.Implementations.Devices; using Emby.Server.Implementations.Dto; -using Emby.Server.Implementations.HttpServer; using Emby.Server.Implementations.HttpServer.Security; using Emby.Server.Implementations.IO; using Emby.Server.Implementations.Library; @@ -46,12 +43,14 @@ using Emby.Server.Implementations.Serialization; using Emby.Server.Implementations.Session; using Emby.Server.Implementations.SyncPlay; using Emby.Server.Implementations.TV; +using Emby.Server.Implementations.Udp; using Emby.Server.Implementations.Updates; using Jellyfin.Api.Helpers; +using Jellyfin.Networking.Configuration; +using Jellyfin.Networking.Manager; using MediaBrowser.Common; using MediaBrowser.Common.Configuration; using MediaBrowser.Common.Events; -using MediaBrowser.Common.Json; using MediaBrowser.Common.Net; using MediaBrowser.Common.Plugins; using MediaBrowser.Common.Updates; @@ -84,7 +83,6 @@ using MediaBrowser.Controller.SyncPlay; using MediaBrowser.Controller.TV; using MediaBrowser.LocalMetadata.Savers; using MediaBrowser.MediaEncoding.BdInfo; -using MediaBrowser.Model.Configuration; using MediaBrowser.Model.Cryptography; using MediaBrowser.Model.Dlna; using MediaBrowser.Model.Globalization; @@ -96,15 +94,15 @@ using MediaBrowser.Model.System; using MediaBrowser.Model.Tasks; using MediaBrowser.Providers.Chapters; using MediaBrowser.Providers.Manager; -using MediaBrowser.Providers.Plugins.TheTvdb; using MediaBrowser.Providers.Plugins.Tmdb; using MediaBrowser.Providers.Subtitles; using MediaBrowser.XbmcMetadata.Providers; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Prometheus.DotNetRuntime; -using OperatingSystem = MediaBrowser.Common.System.OperatingSystem; using WebSocketManager = Emby.Server.Implementations.HttpServer.WebSocketManager; namespace Emby.Server.Implementations @@ -120,15 +118,14 @@ namespace Emby.Server.Implementations private static readonly string[] _relevantEnvVarPrefixes = { "JELLYFIN_", "DOTNET_", "ASPNETCORE_" }; private readonly IFileSystem _fileSystemManager; - private readonly INetworkManager _networkManager; + private readonly IConfiguration _startupConfig; private readonly IXmlSerializer _xmlSerializer; - private readonly IJsonSerializer _jsonSerializer; private readonly IStartupOptions _startupOptions; + private readonly IPluginManager _pluginManager; + private List<Type> _creatingInstances; private IMediaEncoder _mediaEncoder; private ISessionManager _sessionManager; - private IHttpClientFactory _httpClientFactory; - private string[] _urlPrefixes; /// <summary> @@ -152,16 +149,15 @@ namespace Emby.Server.Implementations return false; } - if (OperatingSystem.Id == OperatingSystemId.Windows - || OperatingSystem.Id == OperatingSystemId.Darwin) - { - return true; - } - - return false; + return OperatingSystem.IsWindows() || OperatingSystem.IsMacOS(); } } + /// <summary> + /// Gets the <see cref="INetworkManager"/> singleton instance. + /// </summary> + public INetworkManager NetManager { get; internal set; } + /// <summary> /// Occurs when [has pending restart changed]. /// </summary> @@ -183,14 +179,6 @@ namespace Emby.Server.Implementations protected IServiceCollection ServiceCollection { get; } - private IPlugin[] _plugins; - - /// <summary> - /// Gets the plugins. - /// </summary> - /// <value>The plugins.</value> - public IReadOnlyList<IPlugin> Plugins => _plugins; - /// <summary> /// Gets the logger factory. /// </summary> @@ -214,10 +202,10 @@ namespace Emby.Server.Implementations private readonly List<IDisposable> _disposableParts = new List<IDisposable>(); /// <summary> - /// Gets the configuration manager. + /// Gets or sets the configuration manager. /// </summary> /// <value>The configuration manager.</value> - protected IConfigurationManager ConfigurationManager { get; set; } + public ServerConfigurationManager ConfigurationManager { get; set; } /// <summary> /// Gets or sets the service provider. @@ -235,10 +223,9 @@ namespace Emby.Server.Implementations public int HttpsPort { get; private set; } /// <summary> - /// Gets the server configuration manager. + /// Gets the value of the PublishedServerUrl setting. /// </summary> - /// <value>The server configuration manager.</value> - public IServerConfigurationManager ServerConfigurationManager => (IServerConfigurationManager)ConfigurationManager; + public string PublishedServerUrl => _startupOptions.PublishedServerUrl ?? _startupConfig[UdpServer.AddressOverrideConfigKey]; /// <summary> /// Initializes a new instance of the <see cref="ApplicationHost"/> class. @@ -246,55 +233,55 @@ namespace Emby.Server.Implementations /// <param name="applicationPaths">Instance of the <see cref="IServerApplicationPaths"/> interface.</param> /// <param name="loggerFactory">Instance of the <see cref="ILoggerFactory"/> interface.</param> /// <param name="options">Instance of the <see cref="IStartupOptions"/> interface.</param> + /// <param name="startupConfig">The <see cref="IConfiguration" /> interface.</param> /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param> - /// <param name="networkManager">Instance of the <see cref="INetworkManager"/> interface.</param> /// <param name="serviceCollection">Instance of the <see cref="IServiceCollection"/> interface.</param> public ApplicationHost( IServerApplicationPaths applicationPaths, ILoggerFactory loggerFactory, IStartupOptions options, + IConfiguration startupConfig, IFileSystem fileSystem, - INetworkManager networkManager, IServiceCollection serviceCollection) { - _xmlSerializer = new MyXmlSerializer(); - _jsonSerializer = new JsonSerializer(); - - ServiceCollection = serviceCollection; - - _networkManager = networkManager; - networkManager.LocalSubnetsFn = GetConfiguredLocalSubnets; - ApplicationPaths = applicationPaths; LoggerFactory = loggerFactory; + _startupOptions = options; + _startupConfig = startupConfig; _fileSystemManager = fileSystem; - - ConfigurationManager = new ServerConfigurationManager(ApplicationPaths, LoggerFactory, _xmlSerializer, _fileSystemManager); + ServiceCollection = serviceCollection; Logger = LoggerFactory.CreateLogger<ApplicationHost>(); - - _startupOptions = options; - - // Initialize runtime stat collection - if (ServerConfigurationManager.Configuration.EnableMetrics) - { - DotNetRuntimeStatsBuilder.Default().StartCollecting(); - } - fileSystem.AddShortcutHandler(new MbLinkShortcutHandler(fileSystem)); - _networkManager.NetworkChanged += OnNetworkChanged; - - CertificateInfo = new CertificateInfo - { - Path = ServerConfigurationManager.Configuration.CertificatePath, - Password = ServerConfigurationManager.Configuration.CertificatePassword - }; - Certificate = GetCertificate(CertificateInfo); - ApplicationVersion = typeof(ApplicationHost).Assembly.GetName().Version; ApplicationVersionString = ApplicationVersion.ToString(3); ApplicationUserAgent = Name.Replace(' ', '-') + "/" + ApplicationVersionString; + + _xmlSerializer = new MyXmlSerializer(); + ConfigurationManager = new ServerConfigurationManager(ApplicationPaths, LoggerFactory, _xmlSerializer, _fileSystemManager); + _pluginManager = new PluginManager( + LoggerFactory.CreateLogger<PluginManager>(), + this, + ConfigurationManager.Configuration, + ApplicationPaths.PluginsPath, + ApplicationVersion); + } + + /// <summary> + /// Temporary function to migration network settings out of system.xml and into network.xml. + /// TODO: remove at the point when a fixed migration path has been decided upon. + /// </summary> + private void MigrateNetworkConfiguration() + { + string path = Path.Combine(ConfigurationManager.CommonApplicationPaths.ConfigurationDirectoryPath, "network.xml"); + if (!File.Exists(path)) + { + var networkSettings = new NetworkConfiguration(); + ClassMigrationHelper.CopyProperties(ConfigurationManager.Configuration, networkSettings); + _xmlSerializer.SerializeToFile(networkSettings, path); + Logger.LogDebug("Successfully migrated network settings."); + } } public string ExpandVirtualPath(string path) @@ -313,16 +300,6 @@ namespace Emby.Server.Implementations .Replace(appPaths.InternalMetadataPath, appPaths.VirtualInternalMetadataPath, StringComparison.OrdinalIgnoreCase); } - private string[] GetConfiguredLocalSubnets() - { - return ServerConfigurationManager.Configuration.LocalNetworkSubnets; - } - - private void OnNetworkChanged(object sender, EventArgs e) - { - _validAddressResults.Clear(); - } - /// <inheritdoc /> public Version ApplicationVersion { get; } @@ -339,7 +316,7 @@ namespace Emby.Server.Implementations /// Gets the email address for use within a comment section of a user agent field. /// Presently used to provide contact information to MusicBrainz service. /// </summary> - public string ApplicationUserAgentAddress { get; } = "team@jellyfin.org"; + public string ApplicationUserAgentAddress => "team@jellyfin.org"; /// <summary> /// Gets the current application name. @@ -353,10 +330,7 @@ namespace Emby.Server.Implementations { get { - if (_deviceId == null) - { - _deviceId = new DeviceId(ApplicationPaths, LoggerFactory); - } + _deviceId ??= new DeviceId(ApplicationPaths, LoggerFactory); return _deviceId.Value; } @@ -376,7 +350,7 @@ namespace Emby.Server.Implementations /// <summary> /// Creates an instance of type and resolves all constructor dependencies. /// </summary> - /// /// <typeparam name="T">The type.</typeparam> + /// <typeparam name="T">The type.</typeparam> /// <returns>T.</returns> public T CreateInstance<T>() => ActivatorUtilities.CreateInstance<T>(ServiceProvider); @@ -388,30 +362,48 @@ namespace Emby.Server.Implementations /// <returns>System.Object.</returns> protected object CreateInstanceSafe(Type type) { + _creatingInstances ??= new List<Type>(); + + if (_creatingInstances.IndexOf(type) != -1) + { + Logger.LogError("DI Loop detected in the attempted creation of {Type}", type.FullName); + foreach (var entry in _creatingInstances) + { + Logger.LogError("Called from: {TypeName}", entry.FullName); + } + + _pluginManager.FailPlugin(type.Assembly); + + throw new ExternalException("DI Loop detected."); + } + try { + _creatingInstances.Add(type); Logger.LogDebug("Creating instance of {Type}", type); return ActivatorUtilities.CreateInstance(ServiceProvider, type); } catch (Exception ex) { Logger.LogError(ex, "Error creating {Type}", type); + // If this is a plugin fail it. + _pluginManager.FailPlugin(type.Assembly); return null; } + finally + { + _creatingInstances.Remove(type); + } } /// <summary> /// Resolves this instance. /// </summary> - /// <typeparam name="T">The type</typeparam> + /// <typeparam name="T">The type.</typeparam> /// <returns>``0.</returns> public T Resolve<T>() => ServiceProvider.GetService<T>(); - /// <summary> - /// Gets the export types. - /// </summary> - /// <typeparam name="T">The type.</typeparam> - /// <returns>IEnumerable{Type}.</returns> + /// <inheritdoc/> public IEnumerable<Type> GetExportTypes<T>() { var currentType = typeof(T); @@ -440,17 +432,40 @@ namespace Emby.Server.Implementations return parts; } + /// <inheritdoc /> + public IReadOnlyCollection<T> GetExports<T>(CreationDelegateFactory defaultFunc, bool manageLifetime = true) + { + // Convert to list so this isn't executed for each iteration + var parts = GetExportTypes<T>() + .Select(i => defaultFunc(i)) + .Where(i => i != null) + .Cast<T>() + .ToList(); + + if (manageLifetime) + { + lock (_disposableParts) + { + _disposableParts.AddRange(parts.OfType<IDisposable>()); + } + } + + return parts; + } + /// <summary> /// Runs the startup tasks. /// </summary> /// <returns><see cref="Task" />.</returns> - public async Task RunStartupTasksAsync() + public async Task RunStartupTasksAsync(CancellationToken cancellationToken) { + cancellationToken.ThrowIfCancellationRequested(); Logger.LogInformation("Running startup tasks"); Resolve<ITaskManager>().AddTasks(GetExports<IScheduledTask>(false)); ConfigurationManager.ConfigurationUpdated += OnConfigurationUpdated; + ConfigurationManager.NamedConfigurationUpdated += OnConfigurationUpdated; _mediaEncoder.SetFFmpegPath(); @@ -458,14 +473,21 @@ namespace Emby.Server.Implementations var entryPoints = GetExports<IServerEntryPoint>(); + cancellationToken.ThrowIfCancellationRequested(); + var stopWatch = new Stopwatch(); stopWatch.Start(); + await Task.WhenAll(StartEntryPoints(entryPoints, true)).ConfigureAwait(false); Logger.LogInformation("Executed all pre-startup entry points in {Elapsed:g}", stopWatch.Elapsed); Logger.LogInformation("Core startup complete"); CoreStartupHasCompleted = true; + + cancellationToken.ThrowIfCancellationRequested(); + stopWatch.Restart(); + await Task.WhenAll(StartEntryPoints(entryPoints, false)).ConfigureAwait(false); Logger.LogInformation("Executed all post-startup entry points in {Elapsed:g}", stopWatch.Elapsed); stopWatch.Stop(); @@ -489,34 +511,41 @@ namespace Emby.Server.Implementations /// <inheritdoc/> public void Init() { - HttpPort = ServerConfigurationManager.Configuration.HttpServerPortNumber; - HttpsPort = ServerConfigurationManager.Configuration.HttpsPortNumber; + DiscoverTypes(); - // Safeguard against invalid configuration - if (HttpPort == HttpsPort) + ConfigurationManager.AddParts(GetExports<IConfigurationFactory>()); + + // Have to migrate settings here as migration subsystem not yet initialised. + MigrateNetworkConfiguration(); + NetManager = new NetworkManager(ConfigurationManager, LoggerFactory.CreateLogger<NetworkManager>()); + + // Initialize runtime stat collection + if (ConfigurationManager.Configuration.EnableMetrics) { - HttpPort = ServerConfiguration.DefaultHttpPort; - HttpsPort = ServerConfiguration.DefaultHttpsPort; + DotNetRuntimeStatsBuilder.Default().StartCollecting(); } - if (Plugins != null) - { - var pluginBuilder = new StringBuilder(); + var networkConfiguration = ConfigurationManager.GetNetworkConfiguration(); + HttpPort = networkConfiguration.HttpServerPortNumber; + HttpsPort = networkConfiguration.HttpsPortNumber; - foreach (var plugin in Plugins) - { - pluginBuilder.Append(plugin.Name) - .Append(' ') - .Append(plugin.Version) - .AppendLine(); - } - - Logger.LogInformation("Plugins: {Plugins}", pluginBuilder.ToString()); + // Safeguard against invalid configuration + if (HttpPort == HttpsPort) + { + HttpPort = NetworkConfiguration.DefaultHttpPort; + HttpsPort = NetworkConfiguration.DefaultHttpsPort; } - DiscoverTypes(); + CertificateInfo = new CertificateInfo + { + Path = networkConfiguration.CertificatePath, + Password = networkConfiguration.CertificatePassword + }; + Certificate = GetCertificate(CertificateInfo); RegisterServices(); + + _pluginManager.RegisterServices(ServiceCollection); } /// <summary> @@ -528,20 +557,16 @@ namespace Emby.Server.Implementations ServiceCollection.AddMemoryCache(); - ServiceCollection.AddSingleton(ConfigurationManager); + ServiceCollection.AddSingleton<IServerConfigurationManager>(ConfigurationManager); + ServiceCollection.AddSingleton<IConfigurationManager>(ConfigurationManager); ServiceCollection.AddSingleton<IApplicationHost>(this); - + ServiceCollection.AddSingleton<IPluginManager>(_pluginManager); ServiceCollection.AddSingleton<IApplicationPaths>(ApplicationPaths); - ServiceCollection.AddSingleton<IJsonSerializer, JsonSerializer>(); - ServiceCollection.AddSingleton(_fileSystemManager); - ServiceCollection.AddSingleton<TvdbClientManager>(); ServiceCollection.AddSingleton<TmdbClientManager>(); - ServiceCollection.AddSingleton(_networkManager); - - ServiceCollection.AddSingleton<IIsoManager, IsoManager>(); + ServiceCollection.AddSingleton(NetManager); ServiceCollection.AddSingleton<ITaskManager, TaskManager>(); @@ -560,8 +585,6 @@ namespace Emby.Server.Implementations ServiceCollection.AddSingleton<IServerApplicationHost>(this); ServiceCollection.AddSingleton<IServerApplicationPaths>(ApplicationPaths); - ServiceCollection.AddSingleton(ServerConfigurationManager); - ServiceCollection.AddSingleton<ILocalizationManager, LocalizationManager>(); ServiceCollection.AddSingleton<IBlurayExaminer, BdInfoExaminer>(); @@ -573,12 +596,8 @@ namespace Emby.Server.Implementations ServiceCollection.AddSingleton<IAuthenticationRepository, AuthenticationRepository>(); - // TODO: Refactor to eliminate the circular dependency here so that Lazy<T> isn't required - ServiceCollection.AddTransient(provider => new Lazy<IDtoService>(provider.GetRequiredService<IDtoService>)); - - // TODO: Refactor to eliminate the circular dependency here so that Lazy<T> isn't required - ServiceCollection.AddTransient(provider => new Lazy<EncodingHelper>(provider.GetRequiredService<EncodingHelper>)); ServiceCollection.AddSingleton<IMediaEncoder, MediaBrowser.MediaEncoding.Encoder.MediaEncoder>(); + ServiceCollection.AddSingleton<EncodingHelper>(); // TODO: Refactor to eliminate the circular dependencies here so that Lazy<T> isn't required ServiceCollection.AddTransient(provider => new Lazy<ILibraryMonitor>(provider.GetRequiredService<ILibraryMonitor>)); @@ -643,15 +662,14 @@ namespace Emby.Server.Implementations ServiceCollection.AddSingleton<ISubtitleEncoder, MediaBrowser.MediaEncoding.Subtitles.SubtitleEncoder>(); - ServiceCollection.AddSingleton<IResourceFileManager, ResourceFileManager>(); - ServiceCollection.AddSingleton<EncodingHelper>(); - ServiceCollection.AddSingleton<IAttachmentExtractor, MediaBrowser.MediaEncoding.Attachments.AttachmentExtractor>(); ServiceCollection.AddSingleton<TranscodingJobHelper>(); ServiceCollection.AddScoped<MediaInfoHelper>(); ServiceCollection.AddScoped<AudioHelper>(); ServiceCollection.AddScoped<DynamicHlsHelper>(); + + ServiceCollection.AddSingleton<IDirectoryService, DirectoryService>(); } /// <summary> @@ -665,7 +683,6 @@ namespace Emby.Server.Implementations _mediaEncoder = Resolve<IMediaEncoder>(); _sessionManager = Resolve<ISessionManager>(); - _httpClientFactory = Resolve<IHttpClientFactory>(); ((AuthenticationRepository)Resolve<IAuthenticationRepository>()).Initialize(); @@ -697,7 +714,7 @@ namespace Emby.Server.Implementations logger.LogInformation("Environment Variables: {EnvVars}", relevantEnvVars); logger.LogInformation("Arguments: {Args}", commandLineArgs); - logger.LogInformation("Operating system: {OS}", OperatingSystem.Name); + logger.LogInformation("Operating system: {OS}", MediaBrowser.Common.System.OperatingSystem.Name); logger.LogInformation("Architecture: {Architecture}", RuntimeInformation.OSArchitecture); logger.LogInformation("64-Bit Process: {Is64Bit}", Environment.Is64BitProcess); logger.LogInformation("User Interactive: {IsUserInteractive}", Environment.UserInteractive); @@ -726,7 +743,7 @@ namespace Emby.Server.Implementations // Don't use an empty string password var password = string.IsNullOrWhiteSpace(info.Password) ? null : info.Password; - var localCert = new X509Certificate2(certificateLocation, password); + var localCert = new X509Certificate2(certificateLocation, password, X509KeyStorageFlags.UserKeySet); // localCert.PrivateKey = PrivateKey.CreateFromFile(pvk_file).RSA; if (!localCert.HasPrivateKey) { @@ -750,7 +767,7 @@ namespace Emby.Server.Implementations { // For now there's no real way to inject these properly BaseItem.Logger = Resolve<ILogger<BaseItem>>(); - BaseItem.ConfigurationManager = ServerConfigurationManager; + BaseItem.ConfigurationManager = ConfigurationManager; BaseItem.LibraryManager = Resolve<ILibraryManager>(); BaseItem.ProviderManager = Resolve<IProviderManager>(); BaseItem.LocalizationManager = Resolve<ILocalizationManager>(); @@ -764,7 +781,6 @@ namespace Emby.Server.Implementations UserView.CollectionManager = Resolve<ICollectionManager>(); BaseItem.MediaSourceManager = Resolve<IMediaSourceManager>(); CollectionFolder.XmlSerializer = _xmlSerializer; - CollectionFolder.JsonSerializer = Resolve<IJsonSerializer>(); CollectionFolder.ApplicationHost = this; } @@ -773,17 +789,13 @@ namespace Emby.Server.Implementations /// </summary> private void FindParts() { - if (!ServerConfigurationManager.Configuration.IsPortAuthorized) + if (!ConfigurationManager.Configuration.IsPortAuthorized) { - ServerConfigurationManager.Configuration.IsPortAuthorized = true; + ConfigurationManager.Configuration.IsPortAuthorized = true; ConfigurationManager.SaveConfiguration(); } - ConfigurationManager.AddParts(GetExports<IConfigurationFactory>()); - _plugins = GetExports<IPlugin>() - .Select(LoadPlugin) - .Where(i => i != null) - .ToArray(); + _pluginManager.CreatePlugins(); _urlPrefixes = GetUrlPrefixes().ToArray(); @@ -810,23 +822,6 @@ namespace Emby.Server.Implementations Resolve<IMediaSourceManager>().AddParts(GetExports<IMediaSourceProvider>()); Resolve<INotificationManager>().AddParts(GetExports<INotificationService>(), GetExports<INotificationTypeFactory>()); - - Resolve<IIsoManager>().AddParts(GetExports<IIsoMounter>()); - } - - private IPlugin LoadPlugin(IPlugin plugin) - { - try - { - plugin.RegisterServices(ServiceCollection); - } - catch (Exception ex) - { - Logger.LogError(ex, "Error loading plugin {PluginName}", plugin.GetType().FullName); - return null; - } - - return plugin; } /// <summary> @@ -851,11 +846,13 @@ namespace Emby.Server.Implementations catch (FileNotFoundException ex) { Logger.LogError(ex, "Error getting exported types from {Assembly}", ass.FullName); + _pluginManager.FailPlugin(ass); continue; } catch (TypeLoadException ex) { Logger.LogError(ex, "Error loading types from {Assembly}.", ass.FullName); + _pluginManager.FailPlugin(ass); continue; } @@ -901,18 +898,19 @@ namespace Emby.Server.Implementations protected void OnConfigurationUpdated(object sender, EventArgs e) { var requiresRestart = false; + var networkConfiguration = ConfigurationManager.GetNetworkConfiguration(); // Don't do anything if these haven't been set yet if (HttpPort != 0 && HttpsPort != 0) { // Need to restart if ports have changed - if (ServerConfigurationManager.Configuration.HttpServerPortNumber != HttpPort || - ServerConfigurationManager.Configuration.HttpsPortNumber != HttpsPort) + if (networkConfiguration.HttpServerPortNumber != HttpPort || + networkConfiguration.HttpsPortNumber != HttpsPort) { - if (ServerConfigurationManager.Configuration.IsPortAuthorized) + if (ConfigurationManager.Configuration.IsPortAuthorized) { - ServerConfigurationManager.Configuration.IsPortAuthorized = false; - ServerConfigurationManager.SaveConfiguration(); + ConfigurationManager.Configuration.IsPortAuthorized = false; + ConfigurationManager.SaveConfiguration(); requiresRestart = true; } @@ -924,10 +922,7 @@ namespace Emby.Server.Implementations requiresRestart = true; } - var currentCertPath = CertificateInfo?.Path; - var newCertPath = ServerConfigurationManager.Configuration.CertificatePath; - - if (!string.Equals(currentCertPath, newCertPath, StringComparison.OrdinalIgnoreCase)) + if (ValidateSslCertificate(networkConfiguration)) { requiresRestart = true; } @@ -940,6 +935,33 @@ namespace Emby.Server.Implementations } } + /// <summary> + /// Validates the SSL certificate. + /// </summary> + /// <param name="networkConfig">The new configuration.</param> + /// <exception cref="FileNotFoundException">The certificate path doesn't exist.</exception> + private bool ValidateSslCertificate(NetworkConfiguration networkConfig) + { + var newPath = networkConfig.CertificatePath; + + if (!string.IsNullOrWhiteSpace(newPath) + && !string.Equals(CertificateInfo?.Path, newPath, StringComparison.Ordinal)) + { + if (File.Exists(newPath)) + { + return true; + } + + throw new FileNotFoundException( + string.Format( + CultureInfo.InvariantCulture, + "Certificate file '{0}' does not exist.", + newPath)); + } + + return false; + } + /// <summary> /// Notifies that the kernel that a change has been made that requires a restart. /// </summary> @@ -993,143 +1015,15 @@ namespace Emby.Server.Implementations protected abstract void RestartInternal(); - /// <summary> - /// Comparison function used in <see cref="GetPlugins" />. - /// </summary> - /// <param name="a">Item to compare.</param> - /// <param name="b">Item to compare with.</param> - /// <returns>Boolean result of the operation.</returns> - private static int VersionCompare( - (Version PluginVersion, string Name, string Path) a, - (Version PluginVersion, string Name, string Path) b) - { - int compare = string.Compare(a.Name, b.Name, true, CultureInfo.InvariantCulture); - - if (compare == 0) - { - return a.PluginVersion.CompareTo(b.PluginVersion); - } - - return compare; - } - - /// <summary> - /// Returns a list of plugins to install. - /// </summary> - /// <param name="path">Path to check.</param> - /// <param name="cleanup">True if an attempt should be made to delete old plugs.</param> - /// <returns>Enumerable list of dlls to load.</returns> - private IEnumerable<string> GetPlugins(string path, bool cleanup = true) - { - var dllList = new List<string>(); - var versions = new List<(Version PluginVersion, string Name, string Path)>(); - var directories = Directory.EnumerateDirectories(path, "*.*", SearchOption.TopDirectoryOnly); - string metafile; - - foreach (var dir in directories) - { - try - { - metafile = Path.Combine(dir, "meta.json"); - if (File.Exists(metafile)) - { - var manifest = _jsonSerializer.DeserializeFromFile<PluginManifest>(metafile); - - if (!Version.TryParse(manifest.TargetAbi, out var targetAbi)) - { - targetAbi = new Version(0, 0, 0, 1); - } - - if (!Version.TryParse(manifest.Version, out var version)) - { - version = new Version(0, 0, 0, 1); - } - - if (ApplicationVersion >= targetAbi) - { - // Only load Plugins if the plugin is built for this version or below. - versions.Add((version, manifest.Name, dir)); - } - } - else - { - // No metafile, so lets see if the folder is versioned. - metafile = dir.Split(new[] { Path.DirectorySeparatorChar }, StringSplitOptions.RemoveEmptyEntries)[^1]; - - int versionIndex = dir.LastIndexOf('_'); - if (versionIndex != -1 && Version.TryParse(dir.Substring(versionIndex + 1), out Version ver)) - { - // Versioned folder. - versions.Add((ver, metafile, dir)); - } - else - { - // Un-versioned folder - Add it under the path name and version 0.0.0.1. - versions.Add((new Version(0, 0, 0, 1), metafile, dir)); - } - } - } - catch - { - continue; - } - } - - string lastName = string.Empty; - versions.Sort(VersionCompare); - // Traverse backwards through the list. - // The first item will be the latest version. - for (int x = versions.Count - 1; x >= 0; x--) - { - if (!string.Equals(lastName, versions[x].Name, StringComparison.OrdinalIgnoreCase)) - { - dllList.AddRange(Directory.EnumerateFiles(versions[x].Path, "*.dll", SearchOption.AllDirectories)); - lastName = versions[x].Name; - continue; - } - - if (!string.IsNullOrEmpty(lastName) && cleanup) - { - // Attempt a cleanup of old folders. - try - { - Logger.LogDebug("Deleting {Path}", versions[x].Path); - Directory.Delete(versions[x].Path, true); - } - catch (Exception e) - { - Logger.LogWarning(e, "Unable to delete {Path}", versions[x].Path); - } - } - } - - return dllList; - } - /// <summary> /// Gets the composable part assemblies. /// </summary> /// <returns>IEnumerable{Assembly}.</returns> protected IEnumerable<Assembly> GetComposablePartAssemblies() { - if (Directory.Exists(ApplicationPaths.PluginsPath)) + foreach (var p in _pluginManager.LoadAssemblies()) { - foreach (var file in GetPlugins(ApplicationPaths.PluginsPath)) - { - Assembly plugAss; - try - { - plugAss = Assembly.LoadFrom(file); - } - catch (FileLoadException ex) - { - Logger.LogError(ex, "Failed to load assembly {Path}", file); - continue; - } - - Logger.LogInformation("Loaded assembly {Assembly} from {Path}", plugAss.FullName, file); - yield return plugAss; - } + yield return p; } // Include composable parts in the Model assembly @@ -1165,6 +1059,9 @@ namespace Emby.Server.Implementations // Xbmc yield return typeof(ArtistNfoProvider).Assembly; + // Network + yield return typeof(NetworkManager).Assembly; + foreach (var i in GetAssembliesWithPartsInternal()) { yield return i; @@ -1176,13 +1073,10 @@ namespace Emby.Server.Implementations /// <summary> /// Gets the system status. /// </summary> - /// <param name="cancellationToken">The cancellation token.</param> + /// <param name="source">Where this request originated.</param> /// <returns>SystemInfo.</returns> - public async Task<SystemInfo> GetSystemInfo(CancellationToken cancellationToken) + public SystemInfo GetSystemInfo(IPAddress source) { - var localAddress = await GetLocalApiUrl(cancellationToken).ConfigureAwait(false); - var transcodingTempPath = ConfigurationManager.GetTranscodePath(); - return new SystemInfo { HasPendingRestart = HasPendingRestart, @@ -1197,14 +1091,13 @@ namespace Emby.Server.Implementations ItemsByNamePath = ApplicationPaths.InternalMetadataPath, InternalMetadataPath = ApplicationPaths.InternalMetadataPath, CachePath = ApplicationPaths.CachePath, - OperatingSystem = OperatingSystem.Id.ToString(), - OperatingSystemDisplayName = OperatingSystem.Name, + OperatingSystem = MediaBrowser.Common.System.OperatingSystem.Id.ToString(), + OperatingSystemDisplayName = MediaBrowser.Common.System.OperatingSystem.Name, CanSelfRestart = CanSelfRestart, CanLaunchWebBrowser = CanLaunchWebBrowser, - HasUpdateAvailable = HasUpdateAvailable, - TranscodingTempPath = transcodingTempPath, + TranscodingTempPath = ConfigurationManager.GetTranscodePath(), ServerName = FriendlyName, - LocalAddress = localAddress, + LocalAddress = GetSmartApiUrl(source), SupportsLibraryMonitor = true, EncoderLocation = _mediaEncoder.EncoderLocation, SystemArchitecture = RuntimeInformation.OSArchitecture, @@ -1213,214 +1106,117 @@ namespace Emby.Server.Implementations } public IEnumerable<WakeOnLanInfo> GetWakeOnLanInfo() - => _networkManager.GetMacAddresses() + => NetManager.GetMacAddresses() .Select(i => new WakeOnLanInfo(i)) .ToList(); - public async Task<PublicSystemInfo> GetPublicSystemInfo(CancellationToken cancellationToken) + public PublicSystemInfo GetPublicSystemInfo(IPAddress address) { - var localAddress = await GetLocalApiUrl(cancellationToken).ConfigureAwait(false); - return new PublicSystemInfo { Version = ApplicationVersionString, ProductName = ApplicationProductName, Id = SystemId, - OperatingSystem = OperatingSystem.Id.ToString(), + OperatingSystem = MediaBrowser.Common.System.OperatingSystem.Id.ToString(), ServerName = FriendlyName, - LocalAddress = localAddress, + LocalAddress = GetSmartApiUrl(address), StartupWizardCompleted = ConfigurationManager.CommonConfiguration.IsStartupWizardCompleted }; } /// <inheritdoc/> - public bool ListenWithHttps => Certificate != null && ServerConfigurationManager.Configuration.EnableHttps; + public bool ListenWithHttps => Certificate != null && ConfigurationManager.GetNetworkConfiguration().EnableHttps; /// <inheritdoc/> - public async Task<string> GetLocalApiUrl(CancellationToken cancellationToken) + public string GetSmartApiUrl(IPAddress remoteAddr, int? port = null) { - try + // Published server ends with a / + if (!string.IsNullOrEmpty(PublishedServerUrl)) { - // Return the first matched address, if found, or the first known local address - var addresses = await GetLocalIpAddressesInternal(false, 1, cancellationToken).ConfigureAwait(false); - if (addresses.Count == 0) - { - return null; - } - - return GetLocalApiUrl(addresses[0]); + // Published server ends with a '/', so we need to remove it. + return PublishedServerUrl.Trim('/'); } - catch (Exception ex) + + string smart = NetManager.GetBindInterface(remoteAddr, out port); + // If the smartAPI doesn't start with http then treat it as a host or ip. + if (smart.StartsWith("http", StringComparison.OrdinalIgnoreCase)) { - Logger.LogError(ex, "Error getting local Ip address information"); + return smart.Trim('/'); } - return null; + return GetLocalApiUrl(smart.Trim('/'), null, port); } - /// <summary> - /// Removes the scope id from IPv6 addresses. - /// </summary> - /// <param name="address">The IPv6 address.</param> - /// <returns>The IPv6 address without the scope id.</returns> - private ReadOnlySpan<char> RemoveScopeId(ReadOnlySpan<char> address) + /// <inheritdoc/> + public string GetSmartApiUrl(HttpRequest request, int? port = null) { - var index = address.IndexOf('%'); - if (index == -1) + // Published server ends with a / + if (!string.IsNullOrEmpty(PublishedServerUrl)) { - return address; + // Published server ends with a '/', so we need to remove it. + return PublishedServerUrl.Trim('/'); } - return address.Slice(0, index); - } - - /// <inheritdoc /> - public string GetLocalApiUrl(IPAddress ipAddress) - { - if (ipAddress.AddressFamily == AddressFamily.InterNetworkV6) + string smart = NetManager.GetBindInterface(request, out port); + // If the smartAPI doesn't start with http then treat it as a host or ip. + if (smart.StartsWith("http", StringComparison.OrdinalIgnoreCase)) { - var str = RemoveScopeId(ipAddress.ToString()); - Span<char> span = new char[str.Length + 2]; - span[0] = '['; - str.CopyTo(span.Slice(1)); - span[^1] = ']'; - - return GetLocalApiUrl(span); + return smart.Trim('/'); } - return GetLocalApiUrl(ipAddress.ToString()); - } - - /// <inheritdoc/> - public string GetLoopbackHttpApiUrl() - { - return GetLocalApiUrl("127.0.0.1", Uri.UriSchemeHttp, HttpPort); + return GetLocalApiUrl(smart.Trim('/'), request.Scheme, port); } /// <inheritdoc/> - public string GetLocalApiUrl(ReadOnlySpan<char> host, string scheme = null, int? port = null) + public string GetSmartApiUrl(string hostname, int? port = null) { - // NOTE: If no BaseUrl is set then UriBuilder appends a trailing slash, but if there is no BaseUrl it does - // not. For consistency, always trim the trailing slash. - return new UriBuilder + // Published server ends with a / + if (!string.IsNullOrEmpty(PublishedServerUrl)) { - Scheme = scheme ?? (ListenWithHttps ? Uri.UriSchemeHttps : Uri.UriSchemeHttp), - Host = host.ToString(), - Port = port ?? (ListenWithHttps ? HttpsPort : HttpPort), - Path = ServerConfigurationManager.Configuration.BaseUrl - }.ToString().TrimEnd('/'); - } - - public Task<List<IPAddress>> GetLocalIpAddresses(CancellationToken cancellationToken) - { - return GetLocalIpAddressesInternal(true, 0, cancellationToken); - } - - private async Task<List<IPAddress>> GetLocalIpAddressesInternal(bool allowLoopback, int limit, CancellationToken cancellationToken) - { - var addresses = ServerConfigurationManager - .Configuration - .LocalNetworkAddresses - .Select(x => NormalizeConfiguredLocalAddress(x)) - .Where(i => i != null) - .ToList(); - - if (addresses.Count == 0) - { - addresses.AddRange(_networkManager.GetLocalIpAddresses()); + // Published server ends with a '/', so we need to remove it. + return PublishedServerUrl.Trim('/'); } - var resultList = new List<IPAddress>(); + string smart = NetManager.GetBindInterface(hostname, out port); - foreach (var address in addresses) + // If the smartAPI doesn't start with http then treat it as a host or ip. + if (smart.StartsWith("http", StringComparison.OrdinalIgnoreCase)) { - if (!allowLoopback) - { - if (address.Equals(IPAddress.Loopback) || address.Equals(IPAddress.IPv6Loopback)) - { - continue; - } - } - - if (await IsLocalIpAddressValidAsync(address, cancellationToken).ConfigureAwait(false)) - { - resultList.Add(address); - - if (limit > 0 && resultList.Count >= limit) - { - return resultList; - } - } + return smart.Trim('/'); } - return resultList; + return GetLocalApiUrl(smart.Trim('/'), null, port); } - public IPAddress NormalizeConfiguredLocalAddress(ReadOnlySpan<char> address) + /// <inheritdoc/> + public string GetLoopbackHttpApiUrl() { - var index = address.Trim('/').IndexOf('/'); - if (index != -1) + if (NetManager.IsIP6Enabled) { - address = address.Slice(index + 1); + return GetLocalApiUrl("::1", Uri.UriSchemeHttp, HttpPort); } - if (IPAddress.TryParse(address.Trim('/'), out IPAddress result)) - { - return result; - } - - return null; + return GetLocalApiUrl("127.0.0.1", Uri.UriSchemeHttp, HttpPort); } - private readonly ConcurrentDictionary<string, bool> _validAddressResults = new ConcurrentDictionary<string, bool>(StringComparer.OrdinalIgnoreCase); - - private async Task<bool> IsLocalIpAddressValidAsync(IPAddress address, CancellationToken cancellationToken) + /// <inheritdoc/> + public string GetLocalApiUrl(string hostname, string scheme = null, int? port = null) { - if (address.Equals(IPAddress.Loopback) - || address.Equals(IPAddress.IPv6Loopback)) - { - return true; - } - - var apiUrl = GetLocalApiUrl(address) + "/system/ping"; - - if (_validAddressResults.TryGetValue(apiUrl, out var cachedResult)) - { - return cachedResult; - } - - try - { - using var request = new HttpRequestMessage(HttpMethod.Post, apiUrl); - using var response = await _httpClientFactory.CreateClient(NamedClient.Default) - .SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); - - await using var stream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false); - var result = await System.Text.Json.JsonSerializer.DeserializeAsync<string>(stream, JsonDefaults.GetOptions(), cancellationToken).ConfigureAwait(false); - var valid = string.Equals(Name, result, StringComparison.OrdinalIgnoreCase); - - _validAddressResults.AddOrUpdate(apiUrl, valid, (k, v) => valid); - Logger.LogDebug("Ping test result to {0}. Success: {1}", apiUrl, valid); - return valid; - } - catch (OperationCanceledException) - { - Logger.LogDebug("Ping test result to {0}. Success: {1}", apiUrl, "Cancelled"); - throw; - } - catch (Exception ex) + // NOTE: If no BaseUrl is set then UriBuilder appends a trailing slash, but if there is no BaseUrl it does + // not. For consistency, always trim the trailing slash. + return new UriBuilder { - Logger.LogDebug(ex, "Ping test result to {0}. Success: {1}", apiUrl, false); - - _validAddressResults.AddOrUpdate(apiUrl, false, (k, v) => false); - return false; - } + Scheme = scheme ?? (ListenWithHttps ? Uri.UriSchemeHttps : Uri.UriSchemeHttp), + Host = hostname, + Port = port ?? (ListenWithHttps ? HttpsPort : HttpPort), + Path = ConfigurationManager.GetNetworkConfiguration().BaseUrl + }.ToString().TrimEnd('/'); } public string FriendlyName => - string.IsNullOrEmpty(ServerConfigurationManager.Configuration.ServerName) + string.IsNullOrEmpty(ConfigurationManager.Configuration.ServerName) ? Environment.MachineName - : ServerConfigurationManager.Configuration.ServerName; + : ConfigurationManager.Configuration.ServerName; /// <summary> /// Shuts down. @@ -1448,37 +1244,6 @@ namespace Emby.Server.Implementations protected abstract void ShutdownInternal(); - public event EventHandler HasUpdateAvailableChanged; - - private bool _hasUpdateAvailable; - - public bool HasUpdateAvailable - { - get => _hasUpdateAvailable; - set - { - var fireEvent = value && !_hasUpdateAvailable; - - _hasUpdateAvailable = value; - - if (fireEvent) - { - HasUpdateAvailableChanged?.Invoke(this, EventArgs.Empty); - } - } - } - - /// <summary> - /// Removes the plugin. - /// </summary> - /// <param name="plugin">The plugin.</param> - public void RemovePlugin(IPlugin plugin) - { - var list = _plugins.ToList(); - list.Remove(plugin); - _plugins = list.ToArray(); - } - public IEnumerable<Assembly> GetApiPluginAssemblies() { var assemblies = _allConcreteTypes diff --git a/Emby.Server.Implementations/Channels/ChannelManager.cs b/Emby.Server.Implementations/Channels/ChannelManager.cs index fb1bb65a09..aa54510a71 100644 --- a/Emby.Server.Implementations/Channels/ChannelManager.cs +++ b/Emby.Server.Implementations/Channels/ChannelManager.cs @@ -1,13 +1,17 @@ +#nullable disable + using System; using System.Collections.Generic; using System.Globalization; using System.IO; using System.Linq; +using System.Text.Json; using System.Threading; using System.Threading.Tasks; using Jellyfin.Data.Entities; using Jellyfin.Data.Enums; using MediaBrowser.Common.Extensions; +using Jellyfin.Extensions.Json; using MediaBrowser.Common.Progress; using MediaBrowser.Controller.Channels; using MediaBrowser.Controller.Configuration; @@ -21,7 +25,6 @@ using MediaBrowser.Model.Dto; using MediaBrowser.Model.Entities; using MediaBrowser.Model.IO; using MediaBrowser.Model.Querying; -using MediaBrowser.Model.Serialization; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Logging; using Episode = MediaBrowser.Controller.Entities.TV.Episode; @@ -44,10 +47,10 @@ namespace Emby.Server.Implementations.Channels private readonly ILogger<ChannelManager> _logger; private readonly IServerConfigurationManager _config; private readonly IFileSystem _fileSystem; - private readonly IJsonSerializer _jsonSerializer; private readonly IProviderManager _providerManager; private readonly IMemoryCache _memoryCache; private readonly SemaphoreSlim _resourcePool = new SemaphoreSlim(1, 1); + private readonly JsonSerializerOptions _jsonOptions = JsonDefaults.Options; /// <summary> /// Initializes a new instance of the <see cref="ChannelManager"/> class. @@ -59,7 +62,6 @@ namespace Emby.Server.Implementations.Channels /// <param name="config">The server configuration manager.</param> /// <param name="fileSystem">The filesystem.</param> /// <param name="userDataManager">The user data manager.</param> - /// <param name="jsonSerializer">The JSON serializer.</param> /// <param name="providerManager">The provider manager.</param> /// <param name="memoryCache">The memory cache.</param> public ChannelManager( @@ -70,7 +72,6 @@ namespace Emby.Server.Implementations.Channels IServerConfigurationManager config, IFileSystem fileSystem, IUserDataManager userDataManager, - IJsonSerializer jsonSerializer, IProviderManager providerManager, IMemoryCache memoryCache) { @@ -81,7 +82,6 @@ namespace Emby.Server.Implementations.Channels _config = config; _fileSystem = fileSystem; _userDataManager = userDataManager; - _jsonSerializer = jsonSerializer; _providerManager = providerManager; _memoryCache = memoryCache; } @@ -250,21 +250,16 @@ namespace Emby.Server.Implementations.Channels var all = channels; var totalCount = all.Count; - if (query.StartIndex.HasValue) - { - all = all.Skip(query.StartIndex.Value).ToList(); - } - - if (query.Limit.HasValue) + if (query.StartIndex.HasValue || query.Limit.HasValue) { - all = all.Take(query.Limit.Value).ToList(); + int startIndex = query.StartIndex ?? 0; + int count = query.Limit == null ? totalCount - startIndex : Math.Min(query.Limit.Value, totalCount - startIndex); + all = all.GetRange(startIndex, count); } - var returnItems = all.ToArray(); - if (query.RefreshLatestChannelItems) { - foreach (var item in returnItems) + foreach (var item in all) { RefreshLatestChannelItems(GetChannelProvider(item), CancellationToken.None).GetAwaiter().GetResult(); } @@ -272,7 +267,7 @@ namespace Emby.Server.Implementations.Channels return new QueryResult<Channel> { - Items = returnItems, + Items = all, TotalRecordCount = totalCount }; } @@ -342,21 +337,23 @@ namespace Emby.Server.Implementations.Channels return GetChannel(GetInternalChannelId(channel.Name)) ?? GetChannel(channel, CancellationToken.None).Result; } - private List<MediaSourceInfo> GetSavedMediaSources(BaseItem item) + private MediaSourceInfo[] GetSavedMediaSources(BaseItem item) { var path = Path.Combine(item.GetInternalMetadataPath(), "channelmediasourceinfos.json"); try { - return _jsonSerializer.DeserializeFromFile<List<MediaSourceInfo>>(path) ?? new List<MediaSourceInfo>(); + var bytes = File.ReadAllBytes(path); + return JsonSerializer.Deserialize<MediaSourceInfo[]>(bytes, _jsonOptions) + ?? Array.Empty<MediaSourceInfo>(); } catch { - return new List<MediaSourceInfo>(); + return Array.Empty<MediaSourceInfo>(); } } - private void SaveMediaSources(BaseItem item, List<MediaSourceInfo> mediaSources) + private async Task SaveMediaSources(BaseItem item, List<MediaSourceInfo> mediaSources) { var path = Path.Combine(item.GetInternalMetadataPath(), "channelmediasourceinfos.json"); @@ -375,7 +372,8 @@ namespace Emby.Server.Implementations.Channels Directory.CreateDirectory(Path.GetDirectoryName(path)); - _jsonSerializer.SerializeToFile(mediaSources, path); + await using FileStream createStream = File.Create(path); + await JsonSerializer.SerializeAsync(createStream, mediaSources, _jsonOptions).ConfigureAwait(false); } /// <inheritdoc /> @@ -543,20 +541,20 @@ namespace Emby.Server.Implementations.Channels return _libraryManager.GetItemIds( new InternalItemsQuery { - IncludeItemTypes = new[] { typeof(Channel).Name }, + IncludeItemTypes = new[] { nameof(Channel) }, OrderBy = new[] { (ItemSortBy.SortName, SortOrder.Ascending) } - }).Select(i => GetChannelFeatures(i.ToString("N", CultureInfo.InvariantCulture))).ToArray(); + }).Select(i => GetChannelFeatures(i)).ToArray(); } /// <inheritdoc /> - public ChannelFeatures GetChannelFeatures(string id) + public ChannelFeatures GetChannelFeatures(Guid? id) { - if (string.IsNullOrEmpty(id)) + if (!id.HasValue) { throw new ArgumentNullException(nameof(id)); } - var channel = GetChannel(id); + var channel = GetChannel(id.Value); var channelProvider = GetChannelProvider(channel); return GetChannelFeaturesDto(channel, channelProvider, channelProvider.GetChannelFeatures()); @@ -639,7 +637,7 @@ namespace Emby.Server.Implementations.Channels { var channels = GetAllChannels().Where(i => i is ISupportsLatestMedia).ToArray(); - if (query.ChannelIds.Length > 0) + if (query.ChannelIds.Count > 0) { // Avoid implicitly captured closure var ids = query.ChannelIds; @@ -817,7 +815,8 @@ namespace Emby.Server.Implementations.Channels { if (_fileSystem.GetLastWriteTimeUtc(cachePath).Add(cacheLength) > DateTime.UtcNow) { - var cachedResult = _jsonSerializer.DeserializeFromFile<ChannelItemResult>(cachePath); + await using FileStream jsonStream = File.OpenRead(cachePath); + var cachedResult = await JsonSerializer.DeserializeAsync<ChannelItemResult>(jsonStream, _jsonOptions, cancellationToken).ConfigureAwait(false); if (cachedResult != null) { return null; @@ -839,7 +838,8 @@ namespace Emby.Server.Implementations.Channels { if (_fileSystem.GetLastWriteTimeUtc(cachePath).Add(cacheLength) > DateTime.UtcNow) { - var cachedResult = _jsonSerializer.DeserializeFromFile<ChannelItemResult>(cachePath); + await using FileStream jsonStream = File.OpenRead(cachePath); + var cachedResult = await JsonSerializer.DeserializeAsync<ChannelItemResult>(jsonStream, _jsonOptions, cancellationToken).ConfigureAwait(false); if (cachedResult != null) { return null; @@ -870,7 +870,7 @@ namespace Emby.Server.Implementations.Channels throw new InvalidOperationException("Channel returned a null result from GetChannelItems"); } - CacheResponse(result, cachePath); + await CacheResponse(result, cachePath); return result; } @@ -880,13 +880,14 @@ namespace Emby.Server.Implementations.Channels } } - private void CacheResponse(object result, string path) + private async Task CacheResponse(ChannelItemResult result, string path) { try { Directory.CreateDirectory(Path.GetDirectoryName(path)); - _jsonSerializer.SerializeToFile(result, path); + await using FileStream createStream = File.Create(path); + await JsonSerializer.SerializeAsync(createStream, result, _jsonOptions).ConfigureAwait(false); } catch (Exception ex) { @@ -1181,11 +1182,11 @@ namespace Emby.Server.Implementations.Channels { if (enableMediaProbe && !info.IsLiveStream && item.HasPathProtocol) { - SaveMediaSources(item, new List<MediaSourceInfo>()); + await SaveMediaSources(item, new List<MediaSourceInfo>()).ConfigureAwait(false); } else { - SaveMediaSources(item, info.MediaSources); + await SaveMediaSources(item, info.MediaSources).ConfigureAwait(false); } } diff --git a/Emby.Server.Implementations/Channels/ChannelPostScanTask.cs b/Emby.Server.Implementations/Channels/ChannelPostScanTask.cs index eeb49b8fef..2391eed428 100644 --- a/Emby.Server.Implementations/Channels/ChannelPostScanTask.cs +++ b/Emby.Server.Implementations/Channels/ChannelPostScanTask.cs @@ -51,7 +51,7 @@ namespace Emby.Server.Implementations.Channels var uninstalledChannels = _libraryManager.GetItemList(new InternalItemsQuery { - IncludeItemTypes = new[] { typeof(Channel).Name }, + IncludeItemTypes = new[] { nameof(Channel) }, ExcludeItemIds = installedChannelIds.ToArray() }); diff --git a/Emby.Server.Implementations/Collections/CollectionImageProvider.cs b/Emby.Server.Implementations/Collections/CollectionImageProvider.cs index c69a07e83e..ca84094024 100644 --- a/Emby.Server.Implementations/Collections/CollectionImageProvider.cs +++ b/Emby.Server.Implementations/Collections/CollectionImageProvider.cs @@ -82,9 +82,9 @@ namespace Emby.Server.Implementations.Collections return null; }) .Where(i => i != null) - .GroupBy(x => x.Id) + .GroupBy(x => x!.Id) // We removed the null values .Select(x => x.First()) - .ToList(); + .ToList()!; // Again... the list doesn't contain any null values } /// <inheritdoc /> diff --git a/Emby.Server.Implementations/Collections/CollectionManager.cs b/Emby.Server.Implementations/Collections/CollectionManager.cs index 3011a37e31..8270c2e84c 100644 --- a/Emby.Server.Implementations/Collections/CollectionManager.cs +++ b/Emby.Server.Implementations/Collections/CollectionManager.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Generic; -using System.Globalization; using System.IO; using System.Linq; using System.Threading; @@ -8,11 +7,9 @@ using System.Threading.Tasks; using Jellyfin.Data.Entities; using MediaBrowser.Common.Configuration; using MediaBrowser.Controller.Collections; -using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.Movies; using MediaBrowser.Controller.Library; -using MediaBrowser.Controller.Plugins; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Configuration; using MediaBrowser.Model.Entities; @@ -64,13 +61,13 @@ namespace Emby.Server.Implementations.Collections } /// <inheritdoc /> - public event EventHandler<CollectionCreatedEventArgs> CollectionCreated; + public event EventHandler<CollectionCreatedEventArgs>? CollectionCreated; /// <inheritdoc /> - public event EventHandler<CollectionModifiedEventArgs> ItemsAddedToCollection; + public event EventHandler<CollectionModifiedEventArgs>? ItemsAddedToCollection; /// <inheritdoc /> - public event EventHandler<CollectionModifiedEventArgs> ItemsRemovedFromCollection; + public event EventHandler<CollectionModifiedEventArgs>? ItemsRemovedFromCollection; private IEnumerable<Folder> FindFolders(string path) { @@ -81,14 +78,12 @@ namespace Emby.Server.Implementations.Collections .Where(i => _fileSystem.AreEqual(path, i.Path) || _fileSystem.ContainsSubPath(i.Path, path)); } - internal async Task<Folder> EnsureLibraryFolder(string path, bool createIfNeeded) + internal async Task<Folder?> EnsureLibraryFolder(string path, bool createIfNeeded) { - var existingFolders = FindFolders(path) - .ToList(); - - if (existingFolders.Count > 0) + var existingFolder = FindFolders(path).FirstOrDefault(); + if (existingFolder != null) { - return existingFolders[0]; + return existingFolder; } if (!createIfNeeded) @@ -107,7 +102,7 @@ namespace Emby.Server.Implementations.Collections var name = _localizationManager.GetLocalizedString("Collections"); - await _libraryManager.AddVirtualFolder(name, CollectionType.BoxSets, libraryOptions, true).ConfigureAwait(false); + await _libraryManager.AddVirtualFolder(name, CollectionTypeOptions.BoxSets, libraryOptions, true).ConfigureAwait(false); return FindFolders(path).First(); } @@ -117,14 +112,14 @@ namespace Emby.Server.Implementations.Collections return Path.Combine(_appPaths.DataPath, "collections"); } - private Task<Folder> GetCollectionsFolder(bool createIfNeeded) + private Task<Folder?> GetCollectionsFolder(bool createIfNeeded) { return EnsureLibraryFolder(GetCollectionsFolderPath(), createIfNeeded); } private IEnumerable<BoxSet> GetCollections(User user) { - var folder = GetCollectionsFolder(false).Result; + var folder = GetCollectionsFolder(false).GetAwaiter().GetResult(); return folder == null ? Enumerable.Empty<BoxSet>() @@ -165,9 +160,9 @@ namespace Emby.Server.Implementations.Collections DateCreated = DateTime.UtcNow }; - parentFolder.AddChild(collection, CancellationToken.None); + parentFolder.AddChild(collection); - if (options.ItemIdList.Length > 0) + if (options.ItemIdList.Count > 0) { await AddToCollectionAsync( collection.Id, @@ -206,8 +201,7 @@ namespace Emby.Server.Implementations.Collections private async Task AddToCollectionAsync(Guid collectionId, IEnumerable<Guid> ids, bool fireEvent, MetadataRefreshOptions refreshOptions) { - var collection = _libraryManager.GetItemById(collectionId) as BoxSet; - if (collection == null) + if (_libraryManager.GetItemById(collectionId) is not BoxSet collection) { throw new ArgumentException("No collection exists with the supplied Id"); } @@ -251,11 +245,7 @@ namespace Emby.Server.Implementations.Collections if (fireEvent) { - ItemsAddedToCollection?.Invoke(this, new CollectionModifiedEventArgs - { - Collection = collection, - ItemsChanged = itemList - }); + ItemsAddedToCollection?.Invoke(this, new CollectionModifiedEventArgs(collection, itemList)); } } } @@ -263,9 +253,7 @@ namespace Emby.Server.Implementations.Collections /// <inheritdoc /> public async Task RemoveFromCollectionAsync(Guid collectionId, IEnumerable<Guid> itemIds) { - var collection = _libraryManager.GetItemById(collectionId) as BoxSet; - - if (collection == null) + if (_libraryManager.GetItemById(collectionId) is not BoxSet collection) { throw new ArgumentException("No collection exists with the supplied Id"); } @@ -307,11 +295,7 @@ namespace Emby.Server.Implementations.Collections }, RefreshPriority.High); - ItemsRemovedFromCollection?.Invoke(this, new CollectionModifiedEventArgs - { - Collection = collection, - ItemsChanged = itemList - }); + ItemsRemovedFromCollection?.Invoke(this, new CollectionModifiedEventArgs(collection, itemList)); } /// <inheritdoc /> @@ -319,34 +303,57 @@ namespace Emby.Server.Implementations.Collections { var results = new Dictionary<Guid, BaseItem>(); - var allBoxsets = GetCollections(user).ToList(); + var allBoxSets = GetCollections(user).ToList(); foreach (var item in items) { - if (!(item is ISupportsBoxSetGrouping)) - { - results[item.Id] = item; - } - else + if (item is ISupportsBoxSetGrouping) { var itemId = item.Id; - var currentBoxSets = allBoxsets - .Where(i => i.ContainsLinkedChildByItemId(itemId)) - .ToList(); + var itemIsInBoxSet = false; + foreach (var boxSet in allBoxSets) + { + if (!boxSet.ContainsLinkedChildByItemId(itemId)) + { + continue; + } + + itemIsInBoxSet = true; + + results.TryAdd(boxSet.Id, boxSet); + } + + // skip any item that is in a box set + if (itemIsInBoxSet) + { + continue; + } + + var alreadyInResults = false; - if (currentBoxSets.Count > 0) + // this is kind of a performance hack because only Video has alternate versions that should be in a box set? + if (item is Video video) { - foreach (var boxset in currentBoxSets) + foreach (var childId in video.GetLocalAlternateVersionIds()) { - results[boxset.Id] = boxset; + if (!results.ContainsKey(childId)) + { + continue; + } + + alreadyInResults = true; + break; } } - else + + if (alreadyInResults) { - results[item.Id] = item; + continue; } } + + results[item.Id] = item; } return results.Values; diff --git a/Emby.Server.Implementations/Configuration/ServerConfigurationManager.cs b/Emby.Server.Implementations/Configuration/ServerConfigurationManager.cs index f05a30a897..ff5602f243 100644 --- a/Emby.Server.Implementations/Configuration/ServerConfigurationManager.cs +++ b/Emby.Server.Implementations/Configuration/ServerConfigurationManager.cs @@ -1,3 +1,5 @@ +#nullable disable + using System; using System.Globalization; using System.IO; @@ -88,38 +90,12 @@ namespace Emby.Server.Implementations.Configuration var newConfig = (ServerConfiguration)newConfiguration; ValidateMetadataPath(newConfig); - ValidateSslCertificate(newConfig); ConfigurationUpdating?.Invoke(this, new GenericEventArgs<ServerConfiguration>(newConfig)); base.ReplaceConfiguration(newConfiguration); } - /// <summary> - /// Validates the SSL certificate. - /// </summary> - /// <param name="newConfig">The new configuration.</param> - /// <exception cref="FileNotFoundException">The certificate path doesn't exist.</exception> - private void ValidateSslCertificate(BaseApplicationConfiguration newConfig) - { - var serverConfig = (ServerConfiguration)newConfig; - - var newPath = serverConfig.CertificatePath; - - if (!string.IsNullOrWhiteSpace(newPath) - && !string.Equals(Configuration.CertificatePath, newPath, StringComparison.Ordinal)) - { - if (!File.Exists(newPath)) - { - throw new FileNotFoundException( - string.Format( - CultureInfo.InvariantCulture, - "Certificate file '{0}' does not exist.", - newPath)); - } - } - } - /// <summary> /// Validates the metadata path. /// </summary> diff --git a/Emby.Server.Implementations/ConfigurationOptions.cs b/Emby.Server.Implementations/ConfigurationOptions.cs index cd9dbb1bda..01dc728c1c 100644 --- a/Emby.Server.Implementations/ConfigurationOptions.cs +++ b/Emby.Server.Implementations/ConfigurationOptions.cs @@ -1,5 +1,4 @@ using System.Collections.Generic; -using Emby.Server.Implementations.HttpServer; using static MediaBrowser.Controller.Extensions.ConfigurationExtensions; namespace Emby.Server.Implementations diff --git a/Emby.Server.Implementations/Cryptography/CryptographyProvider.cs b/Emby.Server.Implementations/Cryptography/CryptographyProvider.cs index fd302d1365..4a9b280852 100644 --- a/Emby.Server.Implementations/Cryptography/CryptographyProvider.cs +++ b/Emby.Server.Implementations/Cryptography/CryptographyProvider.cs @@ -1,8 +1,7 @@ -#nullable enable - using System; using System.Collections.Generic; using System.Security.Cryptography; +using MediaBrowser.Common.Extensions; using MediaBrowser.Model.Cryptography; using static MediaBrowser.Common.Cryptography.Constants; @@ -80,7 +79,7 @@ namespace Emby.Server.Implementations.Cryptography throw new CryptographicException($"Requested hash method is not supported: {hashMethod}"); } - using var h = HashAlgorithm.Create(hashMethod); + using var h = HashAlgorithm.Create(hashMethod) ?? throw new ResourceNotFoundException($"Unknown hash method: {hashMethod}."); if (salt.Length == 0) { return h.ComputeHash(bytes); diff --git a/Emby.Server.Implementations/Data/BaseSqliteRepository.cs b/Emby.Server.Implementations/Data/BaseSqliteRepository.cs index 8c756a7f4c..6f23a0888e 100644 --- a/Emby.Server.Implementations/Data/BaseSqliteRepository.cs +++ b/Emby.Server.Implementations/Data/BaseSqliteRepository.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System; @@ -181,11 +183,9 @@ namespace Emby.Server.Implementations.Data foreach (var row in connection.Query("PRAGMA table_info(" + table + ")")) { - if (row[1].SQLiteType != SQLiteType.Null) + if (row.TryGetString(1, out var columnName)) { - var name = row[1].ToString(); - - columnNames.Add(name); + columnNames.Add(columnName); } } diff --git a/Emby.Server.Implementations/Data/ManagedConnection.cs b/Emby.Server.Implementations/Data/ManagedConnection.cs index 5c094ddd2d..afc8966f9c 100644 --- a/Emby.Server.Implementations/Data/ManagedConnection.cs +++ b/Emby.Server.Implementations/Data/ManagedConnection.cs @@ -9,7 +9,7 @@ namespace Emby.Server.Implementations.Data { public class ManagedConnection : IDisposable { - private SQLiteDatabaseConnection _db; + private SQLiteDatabaseConnection? _db; private readonly SemaphoreSlim _writeLock; private bool _disposed = false; @@ -54,12 +54,12 @@ namespace Emby.Server.Implementations.Data return _db.RunInTransaction(action, mode); } - public IEnumerable<IReadOnlyList<IResultSetValue>> Query(string sql) + public IEnumerable<IReadOnlyList<ResultSetValue>> Query(string sql) { return _db.Query(sql); } - public IEnumerable<IReadOnlyList<IResultSetValue>> Query(string sql, params object[] values) + public IEnumerable<IReadOnlyList<ResultSetValue>> Query(string sql, params object[] values) { return _db.Query(sql, values); } diff --git a/Emby.Server.Implementations/Data/SqliteExtensions.cs b/Emby.Server.Implementations/Data/SqliteExtensions.cs index 70a6df977f..3289e76098 100644 --- a/Emby.Server.Implementations/Data/SqliteExtensions.cs +++ b/Emby.Server.Implementations/Data/SqliteExtensions.cs @@ -1,7 +1,9 @@ +#nullable disable #pragma warning disable CS1591 using System; using System.Collections.Generic; +using System.Diagnostics; using System.Globalization; using SQLitePCL.pretty; @@ -59,11 +61,11 @@ namespace Emby.Server.Implementations.Data connection.RunInTransaction(conn => { - conn.ExecuteAll(string.Join(";", queries)); + conn.ExecuteAll(string.Join(';', queries)); }); } - public static Guid ReadGuidFromBlob(this IResultSetValue result) + public static Guid ReadGuidFromBlob(this ResultSetValue result) { return new Guid(result.ToBlob()); } @@ -84,7 +86,7 @@ namespace Emby.Server.Implementations.Data private static string GetDateTimeKindFormat(DateTimeKind kind) => (kind == DateTimeKind.Utc) ? DatetimeFormatUtc : DatetimeFormatLocal; - public static DateTime ReadDateTime(this IResultSetValue result) + public static DateTime ReadDateTime(this ResultSetValue result) { var dateText = result.ToString(); @@ -95,72 +97,147 @@ namespace Emby.Server.Implementations.Data DateTimeStyles.None).ToUniversalTime(); } - public static DateTime? TryReadDateTime(this IResultSetValue result) + public static bool TryReadDateTime(this IReadOnlyList<ResultSetValue> reader, int index, out DateTime result) { - var dateText = result.ToString(); + var item = reader[index]; + if (item.IsDbNull()) + { + result = default; + return false; + } + + var dateText = item.ToString(); if (DateTime.TryParseExact(dateText, _datetimeFormats, DateTimeFormatInfo.InvariantInfo, DateTimeStyles.None, out var dateTimeResult)) { - return dateTimeResult.ToUniversalTime(); + result = dateTimeResult.ToUniversalTime(); + return true; } - return null; + result = default; + return false; } - public static void Attach(SQLiteDatabaseConnection db, string path, string alias) + public static bool TryGetGuid(this IReadOnlyList<ResultSetValue> reader, int index, out Guid result) { - var commandText = string.Format( - CultureInfo.InvariantCulture, - "attach @path as {0};", - alias); - - using (var statement = db.PrepareStatement(commandText)) + var item = reader[index]; + if (item.IsDbNull()) { - statement.TryBind("@path", path); - statement.MoveNext(); + result = default; + return false; } + + result = item.ReadGuidFromBlob(); + return true; } - public static bool IsDBNull(this IReadOnlyList<IResultSetValue> result, int index) + public static bool IsDbNull(this ResultSetValue result) { - return result[index].SQLiteType == SQLiteType.Null; + return result.SQLiteType == SQLiteType.Null; } - public static string GetString(this IReadOnlyList<IResultSetValue> result, int index) + public static string GetString(this IReadOnlyList<ResultSetValue> result, int index) { return result[index].ToString(); } - public static bool GetBoolean(this IReadOnlyList<IResultSetValue> result, int index) + public static bool TryGetString(this IReadOnlyList<ResultSetValue> reader, int index, out string result) + { + result = null; + var item = reader[index]; + if (item.IsDbNull()) + { + return false; + } + + result = item.ToString(); + return true; + } + + public static bool GetBoolean(this IReadOnlyList<ResultSetValue> result, int index) { return result[index].ToBool(); } - public static int GetInt32(this IReadOnlyList<IResultSetValue> result, int index) + public static bool TryGetBoolean(this IReadOnlyList<ResultSetValue> reader, int index, out bool result) + { + var item = reader[index]; + if (item.IsDbNull()) + { + result = default; + return false; + } + + result = item.ToBool(); + return true; + } + + public static bool TryGetInt32(this IReadOnlyList<ResultSetValue> reader, int index, out int result) { - return result[index].ToInt(); + var item = reader[index]; + if (item.IsDbNull()) + { + result = default; + return false; + } + + result = item.ToInt(); + return true; } - public static long GetInt64(this IReadOnlyList<IResultSetValue> result, int index) + public static long GetInt64(this IReadOnlyList<ResultSetValue> result, int index) { return result[index].ToInt64(); } - public static float GetFloat(this IReadOnlyList<IResultSetValue> result, int index) + public static bool TryGetInt64(this IReadOnlyList<ResultSetValue> reader, int index, out long result) + { + var item = reader[index]; + if (item.IsDbNull()) + { + result = default; + return false; + } + + result = item.ToInt64(); + return true; + } + + public static bool TryGetSingle(this IReadOnlyList<ResultSetValue> reader, int index, out float result) { - return result[index].ToFloat(); + var item = reader[index]; + if (item.IsDbNull()) + { + result = default; + return false; + } + + result = item.ToFloat(); + return true; + } + + public static bool TryGetDouble(this IReadOnlyList<ResultSetValue> reader, int index, out double result) + { + var item = reader[index]; + if (item.IsDbNull()) + { + result = default; + return false; + } + + result = item.ToDouble(); + return true; } - public static Guid GetGuid(this IReadOnlyList<IResultSetValue> result, int index) + public static Guid GetGuid(this IReadOnlyList<ResultSetValue> result, int index) { return result[index].ReadGuidFromBlob(); } + [Conditional("DEBUG")] private static void CheckName(string name) { -#if DEBUG throw new ArgumentException("Invalid param name: " + name, nameof(name)); -#endif } public static void TryBind(this IStatement statement, string name, double value) @@ -364,7 +441,7 @@ namespace Emby.Server.Implementations.Data } } - public static IEnumerable<IReadOnlyList<IResultSetValue>> ExecuteQuery(this IStatement statement) + public static IEnumerable<IReadOnlyList<ResultSetValue>> ExecuteQuery(this IStatement statement) { while (statement.MoveNext()) { diff --git a/Emby.Server.Implementations/Data/SqliteItemRepository.cs b/Emby.Server.Implementations/Data/SqliteItemRepository.cs index 1c2aeda709..2cb10765ff 100644 --- a/Emby.Server.Implementations/Data/SqliteItemRepository.cs +++ b/Emby.Server.Implementations/Data/SqliteItemRepository.cs @@ -1,6 +1,9 @@ +#nullable disable + #pragma warning disable CS1591 using System; +using System.Buffers.Text; using System.Collections.Generic; using System.Globalization; using System.IO; @@ -8,10 +11,12 @@ using System.Linq; using System.Text; using System.Text.Json; using System.Threading; +using Diacritics.Extensions; using Emby.Server.Implementations.Playlists; using Jellyfin.Data.Enums; +using Jellyfin.Extensions; +using Jellyfin.Extensions.Json; using MediaBrowser.Common.Extensions; -using MediaBrowser.Common.Json; using MediaBrowser.Controller; using MediaBrowser.Controller.Channels; using MediaBrowser.Controller.Configuration; @@ -40,6 +45,7 @@ namespace Emby.Server.Implementations.Data /// </summary> public class SqliteItemRepository : BaseSqliteRepository, IItemRepository { + private const string FromText = " from TypedBaseItems A"; private const string ChaptersTableName = "Chapters2"; private readonly IServerConfigurationManager _config; @@ -88,7 +94,7 @@ namespace Emby.Server.Implementations.Data _imageProcessor = imageProcessor; _typeMapper = new TypeMapper(); - _jsonOptions = JsonDefaults.GetOptions(); + _jsonOptions = JsonDefaults.Options; DbFilePath = Path.Combine(_config.ApplicationPaths.DataPath, "library.db"); } @@ -502,7 +508,7 @@ namespace Emby.Server.Implementations.Data using (var saveImagesStatement = base.PrepareStatement(db, "Update TypedBaseItems set Images=@Images where guid=@Id")) { saveImagesStatement.TryBind("@Id", item.Id.ToByteArray()); - saveImagesStatement.TryBind("@Images", SerializeImages(item)); + saveImagesStatement.TryBind("@Images", SerializeImages(item.ImageInfos)); saveImagesStatement.MoveNext(); } @@ -687,7 +693,7 @@ namespace Emby.Server.Implementations.Data if (item.Genres.Length > 0) { - saveItemStatement.TryBind("@Genres", string.Join("|", item.Genres)); + saveItemStatement.TryBind("@Genres", string.Join('|', item.Genres)); } else { @@ -749,7 +755,7 @@ namespace Emby.Server.Implementations.Data if (item.LockedFields.Length > 0) { - saveItemStatement.TryBind("@LockedFields", string.Join("|", item.LockedFields)); + saveItemStatement.TryBind("@LockedFields", string.Join('|', item.LockedFields)); } else { @@ -758,7 +764,7 @@ namespace Emby.Server.Implementations.Data if (item.Studios.Length > 0) { - saveItemStatement.TryBind("@Studios", string.Join("|", item.Studios)); + saveItemStatement.TryBind("@Studios", string.Join('|', item.Studios)); } else { @@ -785,7 +791,7 @@ namespace Emby.Server.Implementations.Data if (item.Tags.Length > 0) { - saveItemStatement.TryBind("@Tags", string.Join("|", item.Tags)); + saveItemStatement.TryBind("@Tags", string.Join('|', item.Tags)); } else { @@ -807,7 +813,7 @@ namespace Emby.Server.Implementations.Data if (item is Trailer trailer && trailer.TrailerTypes.Length > 0) { - saveItemStatement.TryBind("@TrailerTypes", string.Join("|", trailer.TrailerTypes)); + saveItemStatement.TryBind("@TrailerTypes", string.Join('|', trailer.TrailerTypes)); } else { @@ -897,12 +903,12 @@ namespace Emby.Server.Implementations.Data saveItemStatement.TryBind("@ExternalSeriesId", item.ExternalSeriesId); saveItemStatement.TryBind("@Tagline", item.Tagline); - saveItemStatement.TryBind("@ProviderIds", SerializeProviderIds(item)); - saveItemStatement.TryBind("@Images", SerializeImages(item)); + saveItemStatement.TryBind("@ProviderIds", SerializeProviderIds(item.ProviderIds)); + saveItemStatement.TryBind("@Images", SerializeImages(item.ImageInfos)); if (item.ProductionLocations.Length > 0) { - saveItemStatement.TryBind("@ProductionLocations", string.Join("|", item.ProductionLocations)); + saveItemStatement.TryBind("@ProductionLocations", string.Join('|', item.ProductionLocations)); } else { @@ -911,7 +917,7 @@ namespace Emby.Server.Implementations.Data if (item.ExtraIds.Length > 0) { - saveItemStatement.TryBind("@ExtraIds", string.Join("|", item.ExtraIds)); + saveItemStatement.TryBind("@ExtraIds", string.Join('|', item.ExtraIds)); } else { @@ -931,7 +937,7 @@ namespace Emby.Server.Implementations.Data string artists = null; if (item is IHasArtist hasArtists && hasArtists.Artists.Count > 0) { - artists = string.Join("|", hasArtists.Artists); + artists = string.Join('|', hasArtists.Artists); } saveItemStatement.TryBind("@Artists", artists); @@ -940,7 +946,7 @@ namespace Emby.Server.Implementations.Data if (item is IHasAlbumArtist hasAlbumArtists && hasAlbumArtists.AlbumArtists.Count > 0) { - albumArtists = string.Join("|", hasAlbumArtists.AlbumArtists); + albumArtists = string.Join('|', hasAlbumArtists.AlbumArtists); } saveItemStatement.TryBind("@AlbumArtists", albumArtists); @@ -968,10 +974,10 @@ namespace Emby.Server.Implementations.Data saveItemStatement.MoveNext(); } - private static string SerializeProviderIds(BaseItem item) + internal static string SerializeProviderIds(Dictionary<string, string> providerIds) { StringBuilder str = new StringBuilder(); - foreach (var i in item.ProviderIds) + foreach (var i in providerIds) { // Ideally we shouldn't need this IsNullOrWhiteSpace check, // but we're seeing some cases of bad data slip through @@ -995,35 +1001,25 @@ namespace Emby.Server.Implementations.Data return str.ToString(); } - private static void DeserializeProviderIds(string value, BaseItem item) + internal static void DeserializeProviderIds(string value, IHasProviderIds item) { if (string.IsNullOrWhiteSpace(value)) { return; } - if (item.ProviderIds.Count > 0) + foreach (var part in value.SpanSplit('|')) { - return; - } - - var parts = value.Split(new[] { '|' }, StringSplitOptions.RemoveEmptyEntries); - - foreach (var part in parts) - { - var idParts = part.Split('='); - - if (idParts.Length == 2) + var providerDelimiterIndex = part.IndexOf('='); + if (providerDelimiterIndex != -1 && providerDelimiterIndex == part.LastIndexOf('=')) { - item.SetProviderId(idParts[0], idParts[1]); + item.SetProviderId(part.Slice(0, providerDelimiterIndex).ToString(), part.Slice(providerDelimiterIndex + 1).ToString()); } } } - private string SerializeImages(BaseItem item) + internal string SerializeImages(ItemImageInfo[] images) { - var images = item.ImageInfos; - if (images.Length == 0) { return null; @@ -1045,39 +1041,48 @@ namespace Emby.Server.Implementations.Data return str.ToString(); } - private void DeserializeImages(string value, BaseItem item) + internal ItemImageInfo[] DeserializeImages(string value) { if (string.IsNullOrWhiteSpace(value)) { - return; + return Array.Empty<ItemImageInfo>(); } - if (item.ImageInfos.Length > 0) - { - return; - } + // TODO The following is an ugly performance optimization, but it's extremely unlikely that the data in the database would be malformed + var valueSpan = value.AsSpan(); + var count = valueSpan.Count('|') + 1; - var parts = value.Split(new[] { '|' }, StringSplitOptions.RemoveEmptyEntries); - var list = new List<ItemImageInfo>(); - foreach (var part in parts) + var position = 0; + var result = new ItemImageInfo[count]; + foreach (var part in valueSpan.Split('|')) { var image = ItemImageInfoFromValueString(part); if (image != null) { - list.Add(image); + result[position++] = image; } } - item.ImageInfos = list.ToArray(); + if (position == count) + { + return result; + } + + if (position == 0) + { + return Array.Empty<ItemImageInfo>(); + } + + // Extremely unlikely, but somehow one or more of the image strings were malformed. Cut the array. + return result[..position]; } - public void AppendItemImageInfo(StringBuilder bldr, ItemImageInfo image) + private void AppendItemImageInfo(StringBuilder bldr, ItemImageInfo image) { const char Delimiter = '*'; var path = image.Path ?? string.Empty; - var hash = image.BlurHash ?? string.Empty; bldr.Append(GetPathToSave(path)) .Append(Delimiter) @@ -1087,48 +1092,105 @@ namespace Emby.Server.Implementations.Data .Append(Delimiter) .Append(image.Width) .Append(Delimiter) - .Append(image.Height) - .Append(Delimiter) - // Replace delimiters with other characters. - // This can be removed when we migrate to a proper DB. - .Append(hash.Replace('*', '/').Replace('|', '\\')); + .Append(image.Height); + + var hash = image.BlurHash; + if (!string.IsNullOrEmpty(hash)) + { + bldr.Append(Delimiter) + // Replace delimiters with other characters. + // This can be removed when we migrate to a proper DB. + .Append(hash.Replace('*', '/').Replace('|', '\\')); + } } - public ItemImageInfo ItemImageInfoFromValueString(string value) + internal ItemImageInfo ItemImageInfoFromValueString(ReadOnlySpan<char> value) { - var parts = value.Split(new[] { '*' }, StringSplitOptions.None); + var nextSegment = value.IndexOf('*'); + if (nextSegment == -1) + { + return null; + } - if (parts.Length < 3) + ReadOnlySpan<char> path = value[..nextSegment]; + value = value[(nextSegment + 1)..]; + nextSegment = value.IndexOf('*'); + if (nextSegment == -1) { return null; } - var image = new ItemImageInfo(); + ReadOnlySpan<char> dateModified = value[..nextSegment]; + value = value[(nextSegment + 1)..]; + nextSegment = value.IndexOf('*'); + if (nextSegment == -1) + { + nextSegment = value.Length; + } + + ReadOnlySpan<char> imageType = value[..nextSegment]; - image.Path = RestorePath(parts[0]); + var image = new ItemImageInfo + { + Path = RestorePath(path.ToString()) + }; - if (long.TryParse(parts[1], NumberStyles.Any, CultureInfo.InvariantCulture, out var ticks)) + if (long.TryParse(dateModified, NumberStyles.Any, CultureInfo.InvariantCulture, out var ticks)) { image.DateModified = new DateTime(ticks, DateTimeKind.Utc); } - if (Enum.TryParse(parts[2], true, out ImageType type)) + if (Enum.TryParse(imageType.ToString(), true, out ImageType type)) { image.Type = type; } - if (parts.Length >= 5) + // Optional parameters: width*height*blurhash + if (nextSegment + 1 < value.Length - 1) { - if (int.TryParse(parts[3], NumberStyles.Integer, CultureInfo.InvariantCulture, out var width) - && int.TryParse(parts[4], NumberStyles.Integer, CultureInfo.InvariantCulture, out var height)) + value = value[(nextSegment + 1)..]; + nextSegment = value.IndexOf('*'); + if (nextSegment == -1 || nextSegment == value.Length) + { + return image; + } + + ReadOnlySpan<char> widthSpan = value[..nextSegment]; + + value = value[(nextSegment + 1)..]; + nextSegment = value.IndexOf('*'); + if (nextSegment == -1) + { + nextSegment = value.Length; + } + + ReadOnlySpan<char> heightSpan = value[..nextSegment]; + + if (int.TryParse(widthSpan, NumberStyles.Integer, CultureInfo.InvariantCulture, out var width) + && int.TryParse(heightSpan, NumberStyles.Integer, CultureInfo.InvariantCulture, out var height)) { image.Width = width; image.Height = height; } - if (parts.Length >= 6) + if (nextSegment < value.Length - 1) { - image.BlurHash = parts[5].Replace('/', '*').Replace('\\', '|'); + value = value[(nextSegment + 1)..]; + var length = value.Length; + + Span<char> blurHashSpan = stackalloc char[length]; + for (int i = 0; i < length; i++) + { + var c = value[i]; + blurHashSpan[i] = c switch + { + '/' => '*', + '\\' => '|', + _ => c + }; + } + + image.BlurHash = new string(blurHashSpan); } } @@ -1241,12 +1303,12 @@ namespace Emby.Server.Implementations.Data return true; } - private BaseItem GetItem(IReadOnlyList<IResultSetValue> reader, InternalItemsQuery query) + private BaseItem GetItem(IReadOnlyList<ResultSetValue> reader, InternalItemsQuery query) { return GetItem(reader, query, HasProgramAttributes(query), HasEpisodeAttributes(query), HasServiceName(query), HasStartDate(query), HasTrailerTypes(query), HasArtistFields(query), HasSeriesFields(query)); } - private BaseItem GetItem(IReadOnlyList<IResultSetValue> reader, InternalItemsQuery query, bool enableProgramAttributes, bool hasEpisodeAttributes, bool hasServiceName, bool queryHasStartDate, bool hasTrailerTypes, bool hasArtistFields, bool hasSeriesFields) + private BaseItem GetItem(IReadOnlyList<ResultSetValue> reader, InternalItemsQuery query, bool enableProgramAttributes, bool hasEpisodeAttributes, bool hasServiceName, bool queryHasStartDate, bool hasTrailerTypes, bool hasArtistFields, bool hasSeriesFields) { var typeString = reader.GetString(0); @@ -1291,27 +1353,30 @@ namespace Emby.Server.Implementations.Data if (queryHasStartDate) { - if (!reader.IsDBNull(index)) + if (item is IHasStartDate hasStartDate && reader.TryReadDateTime(index, out var startDate)) { - if (item is IHasStartDate hasStartDate) - { - hasStartDate.StartDate = reader[index].ReadDateTime(); - } + hasStartDate.StartDate = startDate; } index++; } - if (!reader.IsDBNull(index)) + if (reader.TryReadDateTime(index++, out var endDate)) { - item.EndDate = reader[index].TryReadDateTime(); + item.EndDate = endDate; } - index++; - - if (!reader.IsDBNull(index)) + var channelId = reader[index]; + if (!channelId.IsDbNull()) { - item.ChannelId = new Guid(reader.GetString(index)); + if (!Utf8Parser.TryParse(channelId.ToBlob(), out Guid value, out _, standardFormat: 'N')) + { + var str = reader.GetString(index); + Logger.LogWarning("{ChannelId} isn't in the expected format", str); + value = new Guid(str); + } + + item.ChannelId = value; } index++; @@ -1320,33 +1385,25 @@ namespace Emby.Server.Implementations.Data { if (item is IHasProgramAttributes hasProgramAttributes) { - if (!reader.IsDBNull(index)) + if (reader.TryGetBoolean(index++, out var isMovie)) { - hasProgramAttributes.IsMovie = reader.GetBoolean(index); + hasProgramAttributes.IsMovie = isMovie; } - index++; - - if (!reader.IsDBNull(index)) + if (reader.TryGetBoolean(index++, out var isSeries)) { - hasProgramAttributes.IsSeries = reader.GetBoolean(index); + hasProgramAttributes.IsSeries = isSeries; } - index++; - - if (!reader.IsDBNull(index)) + if (reader.TryGetString(index++, out var episodeTitle)) { - hasProgramAttributes.EpisodeTitle = reader.GetString(index); + hasProgramAttributes.EpisodeTitle = episodeTitle; } - index++; - - if (!reader.IsDBNull(index)) + if (reader.TryGetBoolean(index++, out var isRepeat)) { - hasProgramAttributes.IsRepeat = reader.GetBoolean(index); + hasProgramAttributes.IsRepeat = isRepeat; } - - index++; } else { @@ -1354,246 +1411,194 @@ namespace Emby.Server.Implementations.Data } } - if (!reader.IsDBNull(index)) + if (reader.TryGetSingle(index++, out var communityRating)) { - item.CommunityRating = reader.GetFloat(index); + item.CommunityRating = communityRating; } - index++; - if (HasField(query, ItemFields.CustomRating)) { - if (!reader.IsDBNull(index)) + if (reader.TryGetString(index++, out var customRating)) { - item.CustomRating = reader.GetString(index); + item.CustomRating = customRating; } - - index++; } - if (!reader.IsDBNull(index)) + if (reader.TryGetInt32(index++, out var indexNumber)) { - item.IndexNumber = reader.GetInt32(index); + item.IndexNumber = indexNumber; } - index++; - if (HasField(query, ItemFields.Settings)) { - if (!reader.IsDBNull(index)) + if (reader.TryGetBoolean(index++, out var isLocked)) { - item.IsLocked = reader.GetBoolean(index); + item.IsLocked = isLocked; } - index++; - - if (!reader.IsDBNull(index)) + if (reader.TryGetString(index++, out var preferredMetadataLanguage)) { - item.PreferredMetadataLanguage = reader.GetString(index); + item.PreferredMetadataLanguage = preferredMetadataLanguage; } - index++; - - if (!reader.IsDBNull(index)) + if (reader.TryGetString(index++, out var preferredMetadataCountryCode)) { - item.PreferredMetadataCountryCode = reader.GetString(index); + item.PreferredMetadataCountryCode = preferredMetadataCountryCode; } - - index++; } if (HasField(query, ItemFields.Width)) { - if (!reader.IsDBNull(index)) + if (reader.TryGetInt32(index++, out var width)) { - item.Width = reader.GetInt32(index); + item.Width = width; } - - index++; } if (HasField(query, ItemFields.Height)) { - if (!reader.IsDBNull(index)) + if (reader.TryGetInt32(index++, out var height)) { - item.Height = reader.GetInt32(index); + item.Height = height; } - - index++; } if (HasField(query, ItemFields.DateLastRefreshed)) { - if (!reader.IsDBNull(index)) + if (reader.TryReadDateTime(index++, out var dateLastRefreshed)) { - item.DateLastRefreshed = reader[index].ReadDateTime(); + item.DateLastRefreshed = dateLastRefreshed; } - - index++; } - if (!reader.IsDBNull(index)) + if (reader.TryGetString(index++, out var name)) { - item.Name = reader.GetString(index); + item.Name = name; } - index++; - - if (!reader.IsDBNull(index)) + if (reader.TryGetString(index++, out var restorePath)) { - item.Path = RestorePath(reader.GetString(index)); + item.Path = RestorePath(restorePath); } - index++; - - if (!reader.IsDBNull(index)) + if (reader.TryReadDateTime(index++, out var premiereDate)) { - item.PremiereDate = reader[index].TryReadDateTime(); + item.PremiereDate = premiereDate; } - index++; - if (HasField(query, ItemFields.Overview)) { - if (!reader.IsDBNull(index)) + if (reader.TryGetString(index++, out var overview)) { - item.Overview = reader.GetString(index); + item.Overview = overview; } - - index++; } - if (!reader.IsDBNull(index)) + if (reader.TryGetInt32(index++, out var parentIndexNumber)) { - item.ParentIndexNumber = reader.GetInt32(index); + item.ParentIndexNumber = parentIndexNumber; } - index++; - - if (!reader.IsDBNull(index)) + if (reader.TryGetInt32(index++, out var productionYear)) { - item.ProductionYear = reader.GetInt32(index); + item.ProductionYear = productionYear; } - index++; - - if (!reader.IsDBNull(index)) + if (reader.TryGetString(index++, out var officialRating)) { - item.OfficialRating = reader.GetString(index); + item.OfficialRating = officialRating; } - index++; - if (HasField(query, ItemFields.SortName)) { - if (!reader.IsDBNull(index)) + if (reader.TryGetString(index++, out var forcedSortName)) { - item.ForcedSortName = reader.GetString(index); + item.ForcedSortName = forcedSortName; } - - index++; } - if (!reader.IsDBNull(index)) + if (reader.TryGetInt64(index++, out var runTimeTicks)) { - item.RunTimeTicks = reader.GetInt64(index); + item.RunTimeTicks = runTimeTicks; } - index++; - - if (!reader.IsDBNull(index)) + if (reader.TryGetInt64(index++, out var size)) { - item.Size = reader.GetInt64(index); + item.Size = size; } - index++; - if (HasField(query, ItemFields.DateCreated)) { - if (!reader.IsDBNull(index)) + if (reader.TryReadDateTime(index++, out var dateCreated)) { - item.DateCreated = reader[index].ReadDateTime(); + item.DateCreated = dateCreated; } - - index++; } - if (!reader.IsDBNull(index)) + if (reader.TryReadDateTime(index++, out var dateModified)) { - item.DateModified = reader[index].ReadDateTime(); + item.DateModified = dateModified; } - index++; - - item.Id = reader.GetGuid(index); - index++; + item.Id = reader.GetGuid(index++); if (HasField(query, ItemFields.Genres)) { - if (!reader.IsDBNull(index)) + if (reader.TryGetString(index++, out var genres)) { - item.Genres = reader.GetString(index).Split(new[] { '|' }, StringSplitOptions.RemoveEmptyEntries); + item.Genres = genres.Split('|', StringSplitOptions.RemoveEmptyEntries); } - - index++; } - if (!reader.IsDBNull(index)) + if (reader.TryGetGuid(index++, out var parentId)) { - item.ParentId = reader.GetGuid(index); + item.ParentId = parentId; } - index++; - - if (!reader.IsDBNull(index)) + if (reader.TryGetString(index++, out var audioString)) { - if (Enum.TryParse(reader.GetString(index), true, out ProgramAudio audio)) + // TODO Span overload coming in the future https://github.com/dotnet/runtime/issues/1916 + if (Enum.TryParse(audioString, true, out ProgramAudio audio)) { item.Audio = audio; } } - index++; - // TODO: Even if not needed by apps, the server needs it internally // But get this excluded from contexts where it is not needed if (hasServiceName) { if (item is LiveTvChannel liveTvChannel) { - if (!reader.IsDBNull(index)) + if (reader.TryGetString(index, out var serviceName)) { - liveTvChannel.ServiceName = reader.GetString(index); + liveTvChannel.ServiceName = serviceName; } } index++; } - if (!reader.IsDBNull(index)) + if (reader.TryGetBoolean(index++, out var isInMixedFolder)) { - item.IsInMixedFolder = reader.GetBoolean(index); + item.IsInMixedFolder = isInMixedFolder; } - index++; - if (HasField(query, ItemFields.DateLastSaved)) { - if (!reader.IsDBNull(index)) + if (reader.TryReadDateTime(index++, out var dateLastSaved)) { - item.DateLastSaved = reader[index].ReadDateTime(); + item.DateLastSaved = dateLastSaved; } - - index++; } if (HasField(query, ItemFields.Settings)) { - if (!reader.IsDBNull(index)) + if (reader.TryGetString(index++, out var lockedFields)) { IEnumerable<MetadataField> GetLockedFields(string s) { - foreach (var i in s.Split(new[] { '|' }, StringSplitOptions.RemoveEmptyEntries)) + foreach (var i in s.Split('|', StringSplitOptions.RemoveEmptyEntries)) { if (Enum.TryParse(i, true, out MetadataField parsedValue)) { @@ -1602,41 +1607,35 @@ namespace Emby.Server.Implementations.Data } } - item.LockedFields = GetLockedFields(reader.GetString(index)).ToArray(); + item.LockedFields = GetLockedFields(lockedFields).ToArray(); } - - index++; } if (HasField(query, ItemFields.Studios)) { - if (!reader.IsDBNull(index)) + if (reader.TryGetString(index++, out var studios)) { - item.Studios = reader.GetString(index).Split(new[] { '|' }, StringSplitOptions.RemoveEmptyEntries); + item.Studios = studios.Split('|', StringSplitOptions.RemoveEmptyEntries); } - - index++; } if (HasField(query, ItemFields.Tags)) { - if (!reader.IsDBNull(index)) + if (reader.TryGetString(index++, out var tags)) { - item.Tags = reader.GetString(index).Split(new[] { '|' }, StringSplitOptions.RemoveEmptyEntries); + item.Tags = tags.Split('|', StringSplitOptions.RemoveEmptyEntries); } - - index++; } if (hasTrailerTypes) { if (item is Trailer trailer) { - if (!reader.IsDBNull(index)) + if (reader.TryGetString(index, out var trailerTypes)) { IEnumerable<TrailerType> GetTrailerTypes(string s) { - foreach (var i in s.Split(new[] { '|' }, StringSplitOptions.RemoveEmptyEntries)) + foreach (var i in s.Split('|', StringSplitOptions.RemoveEmptyEntries)) { if (Enum.TryParse(i, true, out TrailerType parsedValue)) { @@ -1645,7 +1644,7 @@ namespace Emby.Server.Implementations.Data } } - trailer.TrailerTypes = GetTrailerTypes(reader.GetString(index)).ToArray(); + trailer.TrailerTypes = GetTrailerTypes(trailerTypes).ToArray(); } } @@ -1654,19 +1653,17 @@ namespace Emby.Server.Implementations.Data if (HasField(query, ItemFields.OriginalTitle)) { - if (!reader.IsDBNull(index)) + if (reader.TryGetString(index++, out var originalTitle)) { - item.OriginalTitle = reader.GetString(index); + item.OriginalTitle = originalTitle; } - - index++; } if (item is Video video) { - if (!reader.IsDBNull(index)) + if (reader.TryGetString(index, out var primaryVersionId)) { - video.PrimaryVersionId = reader.GetString(index); + video.PrimaryVersionId = primaryVersionId; } } @@ -1674,40 +1671,34 @@ namespace Emby.Server.Implementations.Data if (HasField(query, ItemFields.DateLastMediaAdded)) { - if (item is Folder folder && !reader.IsDBNull(index)) + if (item is Folder folder && reader.TryReadDateTime(index, out var dateLastMediaAdded)) { - folder.DateLastMediaAdded = reader[index].TryReadDateTime(); + folder.DateLastMediaAdded = dateLastMediaAdded; } index++; } - if (!reader.IsDBNull(index)) + if (reader.TryGetString(index++, out var album)) { - item.Album = reader.GetString(index); + item.Album = album; } - index++; - - if (!reader.IsDBNull(index)) + if (reader.TryGetSingle(index++, out var criticRating)) { - item.CriticRating = reader.GetFloat(index); + item.CriticRating = criticRating; } - index++; - - if (!reader.IsDBNull(index)) + if (reader.TryGetBoolean(index++, out var isVirtualItem)) { - item.IsVirtualItem = reader.GetBoolean(index); + item.IsVirtualItem = isVirtualItem; } - index++; - if (item is IHasSeries hasSeriesName) { - if (!reader.IsDBNull(index)) + if (reader.TryGetString(index, out var seriesName)) { - hasSeriesName.SeriesName = reader.GetString(index); + hasSeriesName.SeriesName = seriesName; } } @@ -1717,15 +1708,15 @@ namespace Emby.Server.Implementations.Data { if (item is Episode episode) { - if (!reader.IsDBNull(index)) + if (reader.TryGetString(index, out var seasonName)) { - episode.SeasonName = reader.GetString(index); + episode.SeasonName = seasonName; } index++; - if (!reader.IsDBNull(index)) + if (reader.TryGetGuid(index, out var seasonId)) { - episode.SeasonId = reader.GetGuid(index); + episode.SeasonId = seasonId; } } else @@ -1741,9 +1732,9 @@ namespace Emby.Server.Implementations.Data { if (hasSeries != null) { - if (!reader.IsDBNull(index)) + if (reader.TryGetGuid(index, out var seriesId)) { - hasSeries.SeriesId = reader.GetGuid(index); + hasSeries.SeriesId = seriesId; } } @@ -1752,56 +1743,48 @@ namespace Emby.Server.Implementations.Data if (HasField(query, ItemFields.PresentationUniqueKey)) { - if (!reader.IsDBNull(index)) + if (reader.TryGetString(index++, out var presentationUniqueKey)) { - item.PresentationUniqueKey = reader.GetString(index); + item.PresentationUniqueKey = presentationUniqueKey; } - - index++; } if (HasField(query, ItemFields.InheritedParentalRatingValue)) { - if (!reader.IsDBNull(index)) + if (reader.TryGetInt32(index++, out var parentalRating)) { - item.InheritedParentalRatingValue = reader.GetInt32(index); + item.InheritedParentalRatingValue = parentalRating; } - - index++; } if (HasField(query, ItemFields.ExternalSeriesId)) { - if (!reader.IsDBNull(index)) + if (reader.TryGetString(index++, out var externalSeriesId)) { - item.ExternalSeriesId = reader.GetString(index); + item.ExternalSeriesId = externalSeriesId; } - - index++; } if (HasField(query, ItemFields.Taglines)) { - if (!reader.IsDBNull(index)) + if (reader.TryGetString(index++, out var tagLine)) { - item.Tagline = reader.GetString(index); + item.Tagline = tagLine; } - - index++; } - if (!reader.IsDBNull(index)) + if (item.ProviderIds.Count == 0 && reader.TryGetString(index, out var providerIds)) { - DeserializeProviderIds(reader.GetString(index), item); + DeserializeProviderIds(providerIds, item); } index++; if (query.DtoOptions.EnableImages) { - if (!reader.IsDBNull(index)) + if (item.ImageInfos.Length == 0 && reader.TryGetString(index, out var imageInfos)) { - DeserializeImages(reader.GetString(index), item); + item.ImageInfos = DeserializeImages(imageInfos); } index++; @@ -1809,72 +1792,62 @@ namespace Emby.Server.Implementations.Data if (HasField(query, ItemFields.ProductionLocations)) { - if (!reader.IsDBNull(index)) + if (reader.TryGetString(index++, out var productionLocations)) { - item.ProductionLocations = reader.GetString(index).Split(new[] { '|' }, StringSplitOptions.RemoveEmptyEntries).ToArray(); + item.ProductionLocations = productionLocations.Split('|', StringSplitOptions.RemoveEmptyEntries); } - - index++; } if (HasField(query, ItemFields.ExtraIds)) { - if (!reader.IsDBNull(index)) + if (reader.TryGetString(index++, out var extraIds)) { - item.ExtraIds = SplitToGuids(reader.GetString(index)); + item.ExtraIds = SplitToGuids(extraIds); } - - index++; } - if (!reader.IsDBNull(index)) + if (reader.TryGetInt32(index++, out var totalBitrate)) { - item.TotalBitrate = reader.GetInt32(index); + item.TotalBitrate = totalBitrate; } - index++; - - if (!reader.IsDBNull(index)) + if (reader.TryGetString(index++, out var extraTypeString)) { - if (Enum.TryParse(reader.GetString(index), true, out ExtraType extraType)) + if (Enum.TryParse(extraTypeString, true, out ExtraType extraType)) { item.ExtraType = extraType; } } - index++; - if (hasArtistFields) { - if (item is IHasArtist hasArtists && !reader.IsDBNull(index)) + if (item is IHasArtist hasArtists && reader.TryGetString(index, out var artists)) { - hasArtists.Artists = reader.GetString(index).Split(new[] { '|' }, StringSplitOptions.RemoveEmptyEntries); + hasArtists.Artists = artists.Split('|', StringSplitOptions.RemoveEmptyEntries); } index++; - if (item is IHasAlbumArtist hasAlbumArtists && !reader.IsDBNull(index)) + if (item is IHasAlbumArtist hasAlbumArtists && reader.TryGetString(index, out var albumArtists)) { - hasAlbumArtists.AlbumArtists = reader.GetString(index).Split(new[] { '|' }, StringSplitOptions.RemoveEmptyEntries); + hasAlbumArtists.AlbumArtists = albumArtists.Split('|', StringSplitOptions.RemoveEmptyEntries); } index++; } - if (!reader.IsDBNull(index)) + if (reader.TryGetString(index++, out var externalId)) { - item.ExternalId = reader.GetString(index); + item.ExternalId = externalId; } - index++; - if (HasField(query, ItemFields.SeriesPresentationUniqueKey)) { if (hasSeries != null) { - if (!reader.IsDBNull(index)) + if (reader.TryGetString(index, out var seriesPresentationUniqueKey)) { - hasSeries.SeriesPresentationUniqueKey = reader.GetString(index); + hasSeries.SeriesPresentationUniqueKey = seriesPresentationUniqueKey; } } @@ -1883,21 +1856,19 @@ namespace Emby.Server.Implementations.Data if (enableProgramAttributes) { - if (item is LiveTvProgram program && !reader.IsDBNull(index)) + if (item is LiveTvProgram program && reader.TryGetString(index, out var showId)) { - program.ShowId = reader.GetString(index); + program.ShowId = showId; } index++; } - if (!reader.IsDBNull(index)) + if (reader.TryGetGuid(index, out var ownerId)) { - item.OwnerId = reader.GetGuid(index); + item.OwnerId = ownerId; } - index++; - return item; } @@ -1977,21 +1948,21 @@ namespace Emby.Server.Implementations.Data /// <param name="reader">The reader.</param> /// <param name="item">The item.</param> /// <returns>ChapterInfo.</returns> - private ChapterInfo GetChapter(IReadOnlyList<IResultSetValue> reader, BaseItem item) + private ChapterInfo GetChapter(IReadOnlyList<ResultSetValue> reader, BaseItem item) { var chapter = new ChapterInfo { StartPositionTicks = reader.GetInt64(0) }; - if (!reader.IsDBNull(1)) + if (reader.TryGetString(1, out var chapterName)) { - chapter.Name = reader.GetString(1); + chapter.Name = chapterName; } - if (!reader.IsDBNull(2)) + if (reader.TryGetString(2, out var imagePath)) { - chapter.ImagePath = reader.GetString(2); + chapter.ImagePath = imagePath; if (!string.IsNullOrEmpty(chapter.ImagePath)) { @@ -2006,9 +1977,9 @@ namespace Emby.Server.Implementations.Data } } - if (!reader.IsDBNull(3)) + if (reader.TryReadDateTime(3, out var imageDateModified)) { - chapter.ImageDateModified = reader[3].ReadDateTime(); + chapter.ImageDateModified = imageDateModified; } return chapter; @@ -2116,30 +2087,7 @@ namespace Emby.Server.Implementations.Data || query.IsLiked.HasValue; } - private readonly ItemFields[] _allFields = Enum.GetNames(typeof(ItemFields)) - .Select(i => (ItemFields)Enum.Parse(typeof(ItemFields), i, true)) - .ToArray(); - - private string[] GetColumnNamesFromField(ItemFields field) - { - switch (field) - { - case ItemFields.Settings: - return new[] { "IsLocked", "PreferredMetadataCountryCode", "PreferredMetadataLanguage", "LockedFields" }; - case ItemFields.ServiceName: - return new[] { "ExternalServiceId" }; - case ItemFields.SortName: - return new[] { "ForcedSortName" }; - case ItemFields.Taglines: - return new[] { "Tagline" }; - case ItemFields.Tags: - return new[] { "Tags" }; - case ItemFields.IsHD: - return Array.Empty<string>(); - default: - return new[] { field.ToString() }; - } - } + private readonly ItemFields[] _allFields = Enum.GetValues<ItemFields>(); private bool HasField(InternalItemsQuery query, ItemFields name) { @@ -2321,77 +2269,98 @@ namespace Emby.Server.Implementations.Data return query.IncludeItemTypes.Any(x => _seriesTypes.Contains(x)); } - private List<string> GetFinalColumnsToSelect(InternalItemsQuery query, IEnumerable<string> startColumns) + private void SetFinalColumnsToSelect(InternalItemsQuery query, List<string> columns) { - var list = startColumns.ToList(); - foreach (var field in _allFields) { if (!HasField(query, field)) { - foreach (var fieldToRemove in GetColumnNamesFromField(field)) + switch (field) { - list.Remove(fieldToRemove); + case ItemFields.Settings: + columns.Remove("IsLocked"); + columns.Remove("PreferredMetadataCountryCode"); + columns.Remove("PreferredMetadataLanguage"); + columns.Remove("LockedFields"); + break; + case ItemFields.ServiceName: + columns.Remove("ExternalServiceId"); + break; + case ItemFields.SortName: + columns.Remove("ForcedSortName"); + break; + case ItemFields.Taglines: + columns.Remove("Tagline"); + break; + case ItemFields.Tags: + columns.Remove("Tags"); + break; + case ItemFields.IsHD: + // do nothing + break; + default: + columns.Remove(field.ToString()); + break; } } } if (!HasProgramAttributes(query)) { - list.Remove("IsMovie"); - list.Remove("IsSeries"); - list.Remove("EpisodeTitle"); - list.Remove("IsRepeat"); - list.Remove("ShowId"); + columns.Remove("IsMovie"); + columns.Remove("IsSeries"); + columns.Remove("EpisodeTitle"); + columns.Remove("IsRepeat"); + columns.Remove("ShowId"); } if (!HasEpisodeAttributes(query)) { - list.Remove("SeasonName"); - list.Remove("SeasonId"); + columns.Remove("SeasonName"); + columns.Remove("SeasonId"); } if (!HasStartDate(query)) { - list.Remove("StartDate"); + columns.Remove("StartDate"); } if (!HasTrailerTypes(query)) { - list.Remove("TrailerTypes"); + columns.Remove("TrailerTypes"); } if (!HasArtistFields(query)) { - list.Remove("AlbumArtists"); - list.Remove("Artists"); + columns.Remove("AlbumArtists"); + columns.Remove("Artists"); } if (!HasSeriesFields(query)) { - list.Remove("SeriesId"); + columns.Remove("SeriesId"); } if (!HasEpisodeAttributes(query)) { - list.Remove("SeasonName"); - list.Remove("SeasonId"); + columns.Remove("SeasonName"); + columns.Remove("SeasonId"); } if (!query.DtoOptions.EnableImages) { - list.Remove("Images"); + columns.Remove("Images"); } if (EnableJoinUserData(query)) { - list.Add("UserDatas.UserId"); - list.Add("UserDatas.lastPlayedDate"); - list.Add("UserDatas.playbackPositionTicks"); - list.Add("UserDatas.playcount"); - list.Add("UserDatas.isFavorite"); - list.Add("UserDatas.played"); - list.Add("UserDatas.rating"); + columns.Add("UserDatas.UserId"); + columns.Add("UserDatas.lastPlayedDate"); + columns.Add("UserDatas.playbackPositionTicks"); + columns.Add("UserDatas.playcount"); + columns.Add("UserDatas.isFavorite"); + columns.Add("UserDatas.played"); + columns.Add("UserDatas.rating"); } if (query.SimilarTo != null) @@ -2403,11 +2372,11 @@ namespace Emby.Server.Implementations.Data if (string.IsNullOrEmpty(item.OfficialRating)) { - builder.Append("((OfficialRating is null) * 10)"); + builder.Append("(OfficialRating is null * 10)"); } else { - builder.Append("((OfficialRating=@ItemOfficialRating) * 10)"); + builder.Append("(OfficialRating=@ItemOfficialRating * 10)"); } if (item.ProductionYear.HasValue) @@ -2416,12 +2385,30 @@ namespace Emby.Server.Implementations.Data builder.Append("+(Select Case When Abs(COALESCE(ProductionYear, 0) - @ItemProductionYear) < 5 Then 5 Else 0 End )"); } - //// genres, tags - builder.Append("+ ((Select count(CleanValue) from ItemValues where ItemId=Guid and CleanValue in (select CleanValue from itemvalues where ItemId=@SimilarItemId)) * 10)"); + // 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))"); + + if (item is MusicArtist) + { + // Match albums where the artist is AlbumArtist against other albums. + // It is assumed that similar albums => similar artists. + builder.Append( + @"+ (WITH artistValues AS ( + SELECT DISTINCT albumValues.CleanValue + FROM ItemValues albumValues + INNER JOIN ItemValues artistAlbums ON albumValues.ItemId = artistAlbums.ItemId + INNER JOIN TypedBaseItems artistItem ON artistAlbums.CleanValue = artistItem.CleanName AND artistAlbums.TYPE = 1 AND artistItem.Guid = @SimilarItemId + ), similarArtist AS ( + SELECT albumValues.ItemId + FROM ItemValues albumValues + INNER JOIN ItemValues artistAlbums ON albumValues.ItemId = artistAlbums.ItemId + INNER JOIN TypedBaseItems artistItem ON artistAlbums.CleanValue = artistItem.CleanName AND artistAlbums.TYPE = 1 AND artistItem.Guid = A.Guid + ) SELECT COUNT(DISTINCT(CleanValue)) * 10 FROM ItemValues WHERE ItemId IN (SELECT ItemId FROM similarArtist) AND CleanValue IN (SELECT CleanValue FROM artistValues))"); + } builder.Append(") as SimilarityScore"); - list.Add(builder.ToString()); + columns.Add(builder.ToString()); var oldLen = query.ExcludeItemIds.Length; var newLen = oldLen + item.ExtraIds.Length + 1; @@ -2448,10 +2435,8 @@ namespace Emby.Server.Implementations.Data builder.Append(") as SearchScore"); - list.Add(builder.ToString()); + columns.Add(builder.ToString()); } - - return list; } private void BindSearchParams(InternalItemsQuery query, IStatement statement) @@ -2517,31 +2502,25 @@ namespace Emby.Server.Implementations.Data private string GetGroupBy(InternalItemsQuery query) { - var groups = new List<string>(); - - if (EnableGroupByPresentationUniqueKey(query)) + var enableGroupByPresentationUniqueKey = EnableGroupByPresentationUniqueKey(query); + if (enableGroupByPresentationUniqueKey && query.GroupBySeriesPresentationUniqueKey) { - groups.Add("PresentationUniqueKey"); + return " Group by PresentationUniqueKey, SeriesPresentationUniqueKey"; } - if (query.GroupBySeriesPresentationUniqueKey) + if (enableGroupByPresentationUniqueKey) { - groups.Add("SeriesPresentationUniqueKey"); + return " Group by PresentationUniqueKey"; } - if (groups.Count > 0) + if (query.GroupBySeriesPresentationUniqueKey) { - return " Group by " + string.Join(",", groups); + return " Group by SeriesPresentationUniqueKey"; } return string.Empty; } - private string GetFromText(string alias = "A") - { - return " from TypedBaseItems " + alias; - } - public int GetCount(InternalItemsQuery query) { if (query == null) @@ -2559,17 +2538,21 @@ namespace Emby.Server.Implementations.Data query.Limit = query.Limit.Value + 4; } - var commandText = "select " - + string.Join(",", GetFinalColumnsToSelect(query, new[] { "count(distinct PresentationUniqueKey)" })) - + GetFromText() - + GetJoinUserDataText(query); + var columns = new List<string> { "count(distinct PresentationUniqueKey)" }; + SetFinalColumnsToSelect(query, columns); + var commandTextBuilder = new StringBuilder("select ", 256) + .AppendJoin(',', columns) + .Append(FromText) + .Append(GetJoinUserDataText(query)); var whereClauses = GetWhereClauses(query, null); if (whereClauses.Count != 0) { - commandText += " where " + string.Join(" AND ", whereClauses); + commandTextBuilder.Append(" where ") + .AppendJoin(" AND ", whereClauses); } + var commandText = commandTextBuilder.ToString(); int count; using (var connection = GetConnection(true)) { @@ -2611,20 +2594,23 @@ namespace Emby.Server.Implementations.Data query.Limit = query.Limit.Value + 4; } - var commandText = "select " - + string.Join(",", GetFinalColumnsToSelect(query, _retriveItemColumns)) - + GetFromText() - + GetJoinUserDataText(query); + var columns = _retriveItemColumns.ToList(); + SetFinalColumnsToSelect(query, columns); + var commandTextBuilder = new StringBuilder("select ", 1024) + .AppendJoin(',', columns) + .Append(FromText) + .Append(GetJoinUserDataText(query)); var whereClauses = GetWhereClauses(query, null); if (whereClauses.Count != 0) { - commandText += " where " + string.Join(" AND ", whereClauses); + commandTextBuilder.Append(" where ") + .AppendJoin(" AND ", whereClauses); } - commandText += GetGroupBy(query) - + GetOrderByText(query); + commandTextBuilder.Append(GetGroupBy(query)) + .Append(GetOrderByText(query)); if (query.Limit.HasValue || query.StartIndex.HasValue) { @@ -2632,15 +2618,18 @@ namespace Emby.Server.Implementations.Data if (query.Limit.HasValue || offset > 0) { - commandText += " LIMIT " + (query.Limit ?? int.MaxValue).ToString(CultureInfo.InvariantCulture); + commandTextBuilder.Append(" LIMIT ") + .Append(query.Limit ?? int.MaxValue); } if (offset > 0) { - commandText += " OFFSET " + offset.ToString(CultureInfo.InvariantCulture); + commandTextBuilder.Append(" OFFSET ") + .Append(offset); } } + var commandText = commandTextBuilder.ToString(); var items = new List<BaseItem>(); using (var connection = GetConnection(true)) { @@ -2703,87 +2692,22 @@ namespace Emby.Server.Implementations.Data private string FixUnicodeChars(string buffer) { - if (buffer.IndexOf('\u2013', StringComparison.Ordinal) > -1) - { - buffer = buffer.Replace('\u2013', '-'); // en dash - } - - if (buffer.IndexOf('\u2014', StringComparison.Ordinal) > -1) - { - buffer = buffer.Replace('\u2014', '-'); // em dash - } - - if (buffer.IndexOf('\u2015', StringComparison.Ordinal) > -1) - { - buffer = buffer.Replace('\u2015', '-'); // horizontal bar - } - - if (buffer.IndexOf('\u2017', StringComparison.Ordinal) > -1) - { - buffer = buffer.Replace('\u2017', '_'); // double low line - } - - if (buffer.IndexOf('\u2018', StringComparison.Ordinal) > -1) - { - buffer = buffer.Replace('\u2018', '\''); // left single quotation mark - } - - if (buffer.IndexOf('\u2019', StringComparison.Ordinal) > -1) - { - buffer = buffer.Replace('\u2019', '\''); // right single quotation mark - } - - if (buffer.IndexOf('\u201a', StringComparison.Ordinal) > -1) - { - buffer = buffer.Replace('\u201a', ','); // single low-9 quotation mark - } - - if (buffer.IndexOf('\u201b', StringComparison.Ordinal) > -1) - { - buffer = buffer.Replace('\u201b', '\''); // single high-reversed-9 quotation mark - } - - if (buffer.IndexOf('\u201c', StringComparison.Ordinal) > -1) - { - buffer = buffer.Replace('\u201c', '\"'); // left double quotation mark - } - - if (buffer.IndexOf('\u201d', StringComparison.Ordinal) > -1) - { - buffer = buffer.Replace('\u201d', '\"'); // right double quotation mark - } - - if (buffer.IndexOf('\u201e', StringComparison.Ordinal) > -1) - { - buffer = buffer.Replace('\u201e', '\"'); // double low-9 quotation mark - } - - if (buffer.IndexOf('\u2026', StringComparison.Ordinal) > -1) - { - buffer = buffer.Replace("\u2026", "...", StringComparison.Ordinal); // horizontal ellipsis - } - - if (buffer.IndexOf('\u2032', StringComparison.Ordinal) > -1) - { - buffer = buffer.Replace('\u2032', '\''); // prime - } - - if (buffer.IndexOf('\u2033', StringComparison.Ordinal) > -1) - { - buffer = buffer.Replace('\u2033', '\"'); // double prime - } - - if (buffer.IndexOf('\u0060', StringComparison.Ordinal) > -1) - { - buffer = buffer.Replace('\u0060', '\''); // grave accent - } - - if (buffer.IndexOf('\u00B4', StringComparison.Ordinal) > -1) - { - buffer = buffer.Replace('\u00B4', '\''); // acute accent - } - - return buffer; + buffer = buffer.Replace('\u2013', '-'); // en dash + buffer = buffer.Replace('\u2014', '-'); // em dash + buffer = buffer.Replace('\u2015', '-'); // horizontal bar + buffer = buffer.Replace('\u2017', '_'); // double low line + buffer = buffer.Replace('\u2018', '\''); // left single quotation mark + buffer = buffer.Replace('\u2019', '\''); // right single quotation mark + buffer = buffer.Replace('\u201a', ','); // single low-9 quotation mark + buffer = buffer.Replace('\u201b', '\''); // single high-reversed-9 quotation mark + buffer = buffer.Replace('\u201c', '\"'); // left double quotation mark + buffer = buffer.Replace('\u201d', '\"'); // right double quotation mark + buffer = buffer.Replace('\u201e', '\"'); // double low-9 quotation mark + buffer = buffer.Replace("\u2026", "...", StringComparison.Ordinal); // horizontal ellipsis + buffer = buffer.Replace('\u2032', '\''); // prime + buffer = buffer.Replace('\u2033', '\"'); // double prime + buffer = buffer.Replace('\u0060', '\''); // grave accent + return buffer.Replace('\u00B4', '\''); // acute accent } private void AddItem(List<BaseItem> items, BaseItem newItem) @@ -2861,20 +2785,27 @@ namespace Emby.Server.Implementations.Data query.Limit = query.Limit.Value + 4; } - var commandText = "select " - + string.Join(",", GetFinalColumnsToSelect(query, _retriveItemColumns)) - + GetFromText() - + GetJoinUserDataText(query); + var columns = _retriveItemColumns.ToList(); + SetFinalColumnsToSelect(query, columns); + var commandTextBuilder = new StringBuilder("select ", 512) + .AppendJoin(',', columns) + .Append(FromText) + .Append(GetJoinUserDataText(query)); var whereClauses = GetWhereClauses(query, null); var whereText = whereClauses.Count == 0 ? string.Empty : - " where " + string.Join(" AND ", whereClauses); + string.Join(" AND ", whereClauses); - commandText += whereText - + GetGroupBy(query) - + GetOrderByText(query); + if (!string.IsNullOrEmpty(whereText)) + { + commandTextBuilder.Append(" where ") + .Append(whereText); + } + + commandTextBuilder.Append(GetGroupBy(query)) + .Append(GetOrderByText(query)); if (query.Limit.HasValue || query.StartIndex.HasValue) { @@ -2882,43 +2813,58 @@ namespace Emby.Server.Implementations.Data if (query.Limit.HasValue || offset > 0) { - commandText += " LIMIT " + (query.Limit ?? int.MaxValue).ToString(CultureInfo.InvariantCulture); + commandTextBuilder.Append(" LIMIT ") + .Append(query.Limit ?? int.MaxValue); } if (offset > 0) { - commandText += " OFFSET " + offset.ToString(CultureInfo.InvariantCulture); + commandTextBuilder.Append(" OFFSET ") + .Append(offset); } } var isReturningZeroItems = query.Limit.HasValue && query.Limit <= 0; - var statementTexts = new List<string>(); + var itemQuery = string.Empty; + var totalRecordCountQuery = string.Empty; if (!isReturningZeroItems) { - statementTexts.Add(commandText); + itemQuery = commandTextBuilder.ToString(); } if (query.EnableTotalRecordCount) { - commandText = string.Empty; + commandTextBuilder.Clear(); + + commandTextBuilder.Append(" select "); + List<string> columnsToSelect; if (EnableGroupByPresentationUniqueKey(query)) { - commandText += " select " + string.Join(",", GetFinalColumnsToSelect(query, new[] { "count (distinct PresentationUniqueKey)" })) + GetFromText(); + columnsToSelect = new List<string> { "count (distinct PresentationUniqueKey)" }; } else if (query.GroupBySeriesPresentationUniqueKey) { - commandText += " select " + string.Join(",", GetFinalColumnsToSelect(query, new[] { "count (distinct SeriesPresentationUniqueKey)" })) + GetFromText(); + columnsToSelect = new List<string> { "count (distinct SeriesPresentationUniqueKey)" }; } else { - commandText += " select " + string.Join(",", GetFinalColumnsToSelect(query, new[] { "count (guid)" })) + GetFromText(); + columnsToSelect = new List<string> { "count (guid)" }; } - commandText += GetJoinUserDataText(query) - + whereText; - statementTexts.Add(commandText); + SetFinalColumnsToSelect(query, columnsToSelect); + + commandTextBuilder.AppendJoin(',', columnsToSelect) + .Append(FromText) + .Append(GetJoinUserDataText(query)); + if (!string.IsNullOrEmpty(whereText)) + { + commandTextBuilder.Append(" where ") + .Append(whereText); + } + + totalRecordCountQuery = commandTextBuilder.ToString(); } var list = new List<BaseItem>(); @@ -2928,11 +2874,12 @@ namespace Emby.Server.Implementations.Data connection.RunInTransaction( db => { - var statements = PrepareAll(db, statementTexts); + var itemQueryStatement = PrepareStatement(db, itemQuery); + var totalRecordCountQueryStatement = PrepareStatement(db, totalRecordCountQuery); if (!isReturningZeroItems) { - using (var statement = statements[0]) + using (var statement = itemQueryStatement) { if (EnableJoinUserData(query)) { @@ -2962,11 +2909,14 @@ namespace Emby.Server.Implementations.Data } } } + + LogQueryTime("GetItems.ItemQuery", itemQuery, now); } + now = DateTime.UtcNow; if (query.EnableTotalRecordCount) { - using (var statement = statements[statements.Length - 1]) + using (var statement = totalRecordCountQueryStatement) { if (EnableJoinUserData(query)) { @@ -2981,11 +2931,12 @@ namespace Emby.Server.Implementations.Data result.TotalRecordCount = statement.ExecuteQuery().SelectScalarInt().First(); } + + LogQueryTime("GetItems.TotalRecordCount", totalRecordCountQuery, now); } }, ReadTransactionMode); } - LogQueryTime("GetItems", commandText, now); result.Items = list; return result; } @@ -3021,7 +2972,7 @@ namespace Emby.Server.Implementations.Data return string.Empty; } - return " ORDER BY " + string.Join(",", orderBy.Select(i => + return " ORDER BY " + string.Join(',', orderBy.Select(i => { var columnMap = MapOrderByField(i.Item1, query); @@ -3118,19 +3069,22 @@ namespace Emby.Server.Implementations.Data var now = DateTime.UtcNow; - var commandText = "select " - + string.Join(",", GetFinalColumnsToSelect(query, new[] { "guid" })) - + GetFromText() - + GetJoinUserDataText(query); + var columns = new List<string> { "guid" }; + SetFinalColumnsToSelect(query, columns); + var commandTextBuilder = new StringBuilder("select ", 256) + .AppendJoin(',', columns) + .Append(FromText) + .Append(GetJoinUserDataText(query)); var whereClauses = GetWhereClauses(query, null); if (whereClauses.Count != 0) { - commandText += " where " + string.Join(" AND ", whereClauses); + commandTextBuilder.Append(" where ") + .AppendJoin(" AND ", whereClauses); } - commandText += GetGroupBy(query) - + GetOrderByText(query); + commandTextBuilder.Append(GetGroupBy(query)) + .Append(GetOrderByText(query)); if (query.Limit.HasValue || query.StartIndex.HasValue) { @@ -3138,15 +3092,18 @@ namespace Emby.Server.Implementations.Data if (query.Limit.HasValue || offset > 0) { - commandText += " LIMIT " + (query.Limit ?? int.MaxValue).ToString(CultureInfo.InvariantCulture); + commandTextBuilder.Append(" LIMIT ") + .Append(query.Limit ?? int.MaxValue); } if (offset > 0) { - commandText += " OFFSET " + offset.ToString(CultureInfo.InvariantCulture); + commandTextBuilder.Append(" OFFSET ") + .Append(offset); } } + var commandText = commandTextBuilder.ToString(); var list = new List<Guid>(); using (var connection = GetConnection(true)) { @@ -3185,7 +3142,9 @@ namespace Emby.Server.Implementations.Data var now = DateTime.UtcNow; - var commandText = "select " + string.Join(",", GetFinalColumnsToSelect(query, new[] { "guid", "path" })) + GetFromText(); + var columns = new List<string> { "guid", "path" }; + SetFinalColumnsToSelect(query, columns); + var commandText = "select " + string.Join(',', columns) + FromText; var whereClauses = GetWhereClauses(query, null); if (whereClauses.Count != 0) @@ -3227,12 +3186,8 @@ namespace Emby.Server.Implementations.Data foreach (var row in statement.ExecuteQuery()) { var id = row.GetGuid(0); - string path = null; - if (!row.IsDBNull(1)) - { - path = row.GetString(1); - } + row.TryGetString(1, out var path); list.Add(new Tuple<Guid, string>(id, path)); } @@ -3265,9 +3220,11 @@ namespace Emby.Server.Implementations.Data var now = DateTime.UtcNow; + var columns = new List<string> { "guid" }; + SetFinalColumnsToSelect(query, columns); var commandText = "select " - + string.Join(",", GetFinalColumnsToSelect(query, new[] { "guid" })) - + GetFromText() + + string.Join(',', columns) + + FromText + GetJoinUserDataText(query); var whereClauses = GetWhereClauses(query, null); @@ -3307,19 +3264,23 @@ namespace Emby.Server.Implementations.Data { commandText = string.Empty; + List<string> columnsToSelect; if (EnableGroupByPresentationUniqueKey(query)) { - commandText += " select " + string.Join(",", GetFinalColumnsToSelect(query, new[] { "count (distinct PresentationUniqueKey)" })) + GetFromText(); + columnsToSelect = new List<string> { "count (distinct PresentationUniqueKey)" }; } else if (query.GroupBySeriesPresentationUniqueKey) { - commandText += " select " + string.Join(",", GetFinalColumnsToSelect(query, new[] { "count (distinct SeriesPresentationUniqueKey)" })) + GetFromText(); + columnsToSelect = new List<string> { "count (distinct SeriesPresentationUniqueKey)" }; } else { - commandText += " select " + string.Join(",", GetFinalColumnsToSelect(query, new[] { "count (guid)" })) + GetFromText(); + columnsToSelect = new List<string> { "count (guid)" }; } + SetFinalColumnsToSelect(query, columnsToSelect); + commandText += " select " + string.Join(',', columnsToSelect) + FromText; + commandText += GetJoinUserDataText(query) + whereText; statementTexts.Add(commandText); @@ -3566,11 +3527,11 @@ namespace Emby.Server.Implementations.Data statement?.TryBind("@IsFolder", query.IsFolder); } - var includeTypes = query.IncludeItemTypes.SelectMany(MapIncludeItemTypes).ToArray(); + var includeTypes = query.IncludeItemTypes.Select(MapIncludeItemTypes).Where(x => x != null).ToArray(); // Only specify excluded types if no included types are specified if (includeTypes.Length == 0) { - var excludeTypes = query.ExcludeItemTypes.SelectMany(MapIncludeItemTypes).ToArray(); + var excludeTypes = query.ExcludeItemTypes.Select(MapIncludeItemTypes).Where(x => x != null).ToArray(); if (excludeTypes.Length == 1) { whereClauses.Add("type<>@type"); @@ -3578,7 +3539,7 @@ namespace Emby.Server.Implementations.Data } else if (excludeTypes.Length > 1) { - var inClause = string.Join(",", excludeTypes.Select(i => "'" + i + "'")); + var inClause = string.Join(',', excludeTypes.Select(i => "'" + i + "'")); whereClauses.Add($"type not in ({inClause})"); } } @@ -3589,18 +3550,18 @@ namespace Emby.Server.Implementations.Data } else if (includeTypes.Length > 1) { - var inClause = string.Join(",", includeTypes.Select(i => "'" + i + "'")); + var inClause = string.Join(',', includeTypes.Select(i => "'" + i + "'")); whereClauses.Add($"type in ({inClause})"); } - if (query.ChannelIds.Length == 1) + if (query.ChannelIds.Count == 1) { whereClauses.Add("ChannelId=@ChannelId"); statement?.TryBind("@ChannelId", query.ChannelIds[0].ToString("N", CultureInfo.InvariantCulture)); } - else if (query.ChannelIds.Length > 1) + else if (query.ChannelIds.Count > 1) { - var inClause = string.Join(",", query.ChannelIds.Select(i => "'" + i.ToString("N", CultureInfo.InvariantCulture) + "'")); + var inClause = string.Join(',', query.ChannelIds.Select(i => "'" + i.ToString("N", CultureInfo.InvariantCulture) + "'")); whereClauses.Add($"ChannelId in ({inClause})"); } @@ -3914,7 +3875,7 @@ namespace Emby.Server.Implementations.Data if (query.IsPlayed.HasValue) { // We should probably figure this out for all folders, but for right now, this is the only place where we need it - if (query.IncludeItemTypes.Length == 1 && string.Equals(query.IncludeItemTypes[0], typeof(Series).Name, StringComparison.OrdinalIgnoreCase)) + if (query.IncludeItemTypes.Length == 1 && string.Equals(query.IncludeItemTypes[0], nameof(Series), StringComparison.OrdinalIgnoreCase)) { if (query.IsPlayed.Value) { @@ -4058,7 +4019,7 @@ namespace Emby.Server.Implementations.Data whereClauses.Add(clause); } - if (query.GenreIds.Length > 0) + if (query.GenreIds.Count > 0) { var clauses = new List<string>(); var index = 0; @@ -4079,7 +4040,7 @@ namespace Emby.Server.Implementations.Data whereClauses.Add(clause); } - if (query.Genres.Length > 0) + if (query.Genres.Count > 0) { var clauses = new List<string>(); var index = 0; @@ -4333,7 +4294,7 @@ namespace Emby.Server.Implementations.Data } else if (query.Years.Length > 1) { - var val = string.Join(",", query.Years); + var val = string.Join(',', query.Years); whereClauses.Add("ProductionYear in (" + val + ")"); } @@ -4383,7 +4344,7 @@ namespace Emby.Server.Implementations.Data } else if (queryMediaTypes.Length > 1) { - var val = string.Join(",", queryMediaTypes.Select(i => "'" + i + "'")); + var val = string.Join(',', queryMediaTypes.Select(i => "'" + i + "'")); whereClauses.Add("MediaType in (" + val + ")"); } @@ -4426,7 +4387,7 @@ namespace Emby.Server.Implementations.Data whereClauses.Add(string.Join(" AND ", excludeIds)); } - if (query.ExcludeProviderIds.Count > 0) + if (query.ExcludeProviderIds != null && query.ExcludeProviderIds.Count > 0) { var excludeIds = new List<string>(); @@ -4456,7 +4417,7 @@ namespace Emby.Server.Implementations.Data } } - if (query.HasAnyProviderId.Count > 0) + if (query.HasAnyProviderId != null && query.HasAnyProviderId.Count > 0) { var hasProviderIds = new List<string>(); @@ -4480,7 +4441,7 @@ namespace Emby.Server.Implementations.Data var paramName = "@HasAnyProviderId" + index; // this is a search for the placeholder - hasProviderIds.Add("ProviderIds like " + paramName + ""); + hasProviderIds.Add("ProviderIds like " + paramName); // this replaces the placeholder with a value, here: %key=val% if (statement != null) @@ -4501,69 +4462,63 @@ namespace Emby.Server.Implementations.Data if (query.HasImdbId.HasValue) { - whereClauses.Add("ProviderIds like '%imdb=%'"); + whereClauses.Add(GetProviderIdClause(query.HasImdbId.Value, "imdb")); } if (query.HasTmdbId.HasValue) { - whereClauses.Add("ProviderIds like '%tmdb=%'"); + whereClauses.Add(GetProviderIdClause(query.HasTmdbId.Value, "tmdb")); } if (query.HasTvdbId.HasValue) { - whereClauses.Add("ProviderIds like '%tvdb=%'"); + whereClauses.Add(GetProviderIdClause(query.HasTvdbId.Value, "tvdb")); } - var includedItemByNameTypes = GetItemByNameTypesInQuery(query).SelectMany(MapIncludeItemTypes).ToList(); - var enableItemsByName = (query.IncludeItemsByName ?? false) && includedItemByNameTypes.Count > 0; - var queryTopParentIds = query.TopParentIds; - if (queryTopParentIds.Length == 1) + if (queryTopParentIds.Length > 0) { - if (enableItemsByName && includedItemByNameTypes.Count == 1) + var includedItemByNameTypes = GetItemByNameTypesInQuery(query); + var enableItemsByName = (query.IncludeItemsByName ?? false) && includedItemByNameTypes.Count > 0; + + if (queryTopParentIds.Length == 1) { - whereClauses.Add("(TopParentId=@TopParentId or Type=@IncludedItemByNameType)"); - if (statement != null) + if (enableItemsByName && includedItemByNameTypes.Count == 1) { - statement.TryBind("@IncludedItemByNameType", includedItemByNameTypes[0]); + whereClauses.Add("(TopParentId=@TopParentId or Type=@IncludedItemByNameType)"); + statement?.TryBind("@IncludedItemByNameType", includedItemByNameTypes[0]); + } + else if (enableItemsByName && includedItemByNameTypes.Count > 1) + { + var itemByNameTypeVal = string.Join(',', includedItemByNameTypes.Select(i => "'" + i + "'")); + whereClauses.Add("(TopParentId=@TopParentId or Type in (" + itemByNameTypeVal + "))"); + } + else + { + whereClauses.Add("(TopParentId=@TopParentId)"); } - } - else if (enableItemsByName && includedItemByNameTypes.Count > 1) - { - var itemByNameTypeVal = string.Join(",", includedItemByNameTypes.Select(i => "'" + i + "'")); - whereClauses.Add("(TopParentId=@TopParentId or Type in (" + itemByNameTypeVal + "))"); - } - else - { - whereClauses.Add("(TopParentId=@TopParentId)"); - } - if (statement != null) - { - statement.TryBind("@TopParentId", queryTopParentIds[0].ToString("N", CultureInfo.InvariantCulture)); + statement?.TryBind("@TopParentId", queryTopParentIds[0].ToString("N", CultureInfo.InvariantCulture)); } - } - else if (queryTopParentIds.Length > 1) - { - var val = string.Join(",", queryTopParentIds.Select(i => "'" + i.ToString("N", CultureInfo.InvariantCulture) + "'")); - - if (enableItemsByName && includedItemByNameTypes.Count == 1) + else if (queryTopParentIds.Length > 1) { - whereClauses.Add("(Type=@IncludedItemByNameType or TopParentId in (" + val + "))"); - if (statement != null) + var val = string.Join(',', queryTopParentIds.Select(i => "'" + i.ToString("N", CultureInfo.InvariantCulture) + "'")); + + if (enableItemsByName && includedItemByNameTypes.Count == 1) { - statement.TryBind("@IncludedItemByNameType", includedItemByNameTypes[0]); + whereClauses.Add("(Type=@IncludedItemByNameType or TopParentId in (" + val + "))"); + statement?.TryBind("@IncludedItemByNameType", includedItemByNameTypes[0]); + } + else if (enableItemsByName && includedItemByNameTypes.Count > 1) + { + var itemByNameTypeVal = string.Join(',', includedItemByNameTypes.Select(i => "'" + i + "'")); + whereClauses.Add("(Type in (" + itemByNameTypeVal + ") or TopParentId in (" + val + "))"); + } + else + { + whereClauses.Add("TopParentId in (" + val + ")"); } - } - else if (enableItemsByName && includedItemByNameTypes.Count > 1) - { - var itemByNameTypeVal = string.Join(",", includedItemByNameTypes.Select(i => "'" + i + "'")); - whereClauses.Add("(Type in (" + itemByNameTypeVal + ") or TopParentId in (" + val + "))"); - } - else - { - whereClauses.Add("TopParentId in (" + val + ")"); } } @@ -4579,7 +4534,7 @@ namespace Emby.Server.Implementations.Data if (query.AncestorIds.Length > 1) { - var inClause = string.Join(",", query.AncestorIds.Select(i => "'" + i.ToString("N", CultureInfo.InvariantCulture) + "'")); + var inClause = string.Join(',', query.AncestorIds.Select(i => "'" + i.ToString("N", CultureInfo.InvariantCulture) + "'")); whereClauses.Add(string.Format(CultureInfo.InvariantCulture, "Guid in (select itemId from AncestorIds where AncestorIdText in ({0}))", inClause)); } @@ -4751,33 +4706,48 @@ namespace Emby.Server.Implementations.Data return whereClauses; } + /// <summary> + /// Formats a where clause for the specified provider. + /// </summary> + /// <param name="includeResults">Whether or not to include items with this provider's ids.</param> + /// <param name="provider">Provider name.</param> + /// <returns>Formatted SQL clause.</returns> + private string GetProviderIdClause(bool includeResults, string provider) + { + return string.Format( + CultureInfo.InvariantCulture, + "ProviderIds {0} like '%{1}=%'", + includeResults ? string.Empty : "not", + provider); + } + private List<string> GetItemByNameTypesInQuery(InternalItemsQuery query) { var list = new List<string>(); - if (IsTypeInQuery(typeof(Person).Name, query)) + if (IsTypeInQuery(nameof(Person), query)) { - list.Add(typeof(Person).Name); + list.Add(typeof(Person).FullName); } - if (IsTypeInQuery(typeof(Genre).Name, query)) + if (IsTypeInQuery(nameof(Genre), query)) { - list.Add(typeof(Genre).Name); + list.Add(typeof(Genre).FullName); } - if (IsTypeInQuery(typeof(MusicGenre).Name, query)) + if (IsTypeInQuery(nameof(MusicGenre), query)) { - list.Add(typeof(MusicGenre).Name); + list.Add(typeof(MusicGenre).FullName); } - if (IsTypeInQuery(typeof(MusicArtist).Name, query)) + if (IsTypeInQuery(nameof(MusicArtist), query)) { - list.Add(typeof(MusicArtist).Name); + list.Add(typeof(MusicArtist).FullName); } - if (IsTypeInQuery(typeof(Studio).Name, query)) + if (IsTypeInQuery(nameof(Studio), query)) { - list.Add(typeof(Studio).Name); + list.Add(typeof(Studio).FullName); } return list; @@ -4830,17 +4800,12 @@ namespace Emby.Server.Implementations.Data return true; } - var types = new[] - { - typeof(Episode).Name, - typeof(Video).Name, - typeof(Movie).Name, - typeof(MusicVideo).Name, - typeof(Series).Name, - typeof(Season).Name - }; - - if (types.Any(i => query.IncludeItemTypes.Contains(i, StringComparer.OrdinalIgnoreCase))) + if (query.IncludeItemTypes.Contains(nameof(Episode), StringComparer.OrdinalIgnoreCase) + || query.IncludeItemTypes.Contains(nameof(Video), StringComparer.OrdinalIgnoreCase) + || query.IncludeItemTypes.Contains(nameof(Movie), StringComparer.OrdinalIgnoreCase) + || query.IncludeItemTypes.Contains(nameof(MusicVideo), StringComparer.OrdinalIgnoreCase) + || query.IncludeItemTypes.Contains(nameof(Series), StringComparer.OrdinalIgnoreCase) + || query.IncludeItemTypes.Contains(nameof(Season), StringComparer.OrdinalIgnoreCase)) { return true; } @@ -4882,15 +4847,10 @@ namespace Emby.Server.Implementations.Data typeof(AggregateFolder) }; - public void UpdateInheritedValues(CancellationToken cancellationToken) - { - UpdateInheritedTags(cancellationToken); - } - - private void UpdateInheritedTags(CancellationToken cancellationToken) + public void UpdateInheritedValues() { string sql = string.Join( - ";", + ';', new string[] { "delete from itemvalues where type = 6", @@ -4913,37 +4873,38 @@ where AncestorIdText not null and ItemValues.Value not null and ItemValues.Type } } - private static Dictionary<string, string[]> GetTypeMapDictionary() + private static Dictionary<string, string> GetTypeMapDictionary() { - var dict = new Dictionary<string, string[]>(StringComparer.OrdinalIgnoreCase); + var dict = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase); foreach (var t in _knownTypes) { - dict[t.Name] = new[] { t.FullName }; + dict[t.Name] = t.FullName ; } - dict["Program"] = new[] { typeof(LiveTvProgram).FullName }; - dict["TvChannel"] = new[] { typeof(LiveTvChannel).FullName }; + dict["Program"] = typeof(LiveTvProgram).FullName; + dict["TvChannel"] = typeof(LiveTvChannel).FullName; return dict; } // Not crazy about having this all the way down here, but at least it's in one place - private readonly Dictionary<string, string[]> _types = GetTypeMapDictionary(); + private readonly Dictionary<string, string> _types = GetTypeMapDictionary(); - private string[] MapIncludeItemTypes(string value) + private string MapIncludeItemTypes(string value) { - if (_types.TryGetValue(value, out string[] result)) + if (_types.TryGetValue(value, out string result)) { return result; } if (IsValidType(value)) { - return new[] { value }; + return value; } - return Array.Empty<string>(); + Logger.LogWarning("Unknown item type: {ItemType}", value); + return null; } public void DeleteItem(Guid id) @@ -5002,26 +4963,26 @@ where AncestorIdText not null and ItemValues.Value not null and ItemValues.Type CheckDisposed(); - var commandText = "select Distinct Name from People"; + var commandText = new StringBuilder("select Distinct p.Name from People p"); var whereClauses = GetPeopleWhereClauses(query, null); if (whereClauses.Count != 0) { - commandText += " where " + string.Join(" AND ", whereClauses); + commandText.Append(" where ").Append(string.Join(" AND ", whereClauses)); } - commandText += " order by ListOrder"; + commandText.Append(" order by ListOrder"); if (query.Limit > 0) { - commandText += " LIMIT " + query.Limit; + commandText.Append(" LIMIT ").Append(query.Limit); } using (var connection = GetConnection(true)) { var list = new List<string>(); - using (var statement = PrepareStatement(connection, commandText)) + using (var statement = PrepareStatement(connection, commandText.ToString())) { // Run this again to bind the params GetPeopleWhereClauses(query, statement); @@ -5045,7 +5006,7 @@ where AncestorIdText not null and ItemValues.Value not null and ItemValues.Type CheckDisposed(); - var commandText = "select ItemId, Name, Role, PersonType, SortOrder from People"; + var commandText = "select ItemId, Name, Role, PersonType, SortOrder from People p"; var whereClauses = GetPeopleWhereClauses(query, null); @@ -5084,22 +5045,26 @@ where AncestorIdText not null and ItemValues.Value not null and ItemValues.Type { var whereClauses = new List<string>(); + if (query.User != null && query.IsFavorite.HasValue) + { + whereClauses.Add(@"p.Name IN ( +SELECT Name FROM TypedBaseItems WHERE UserDataKey IN ( +SELECT key FROM UserDatas WHERE isFavorite=@IsFavorite AND userId=@UserId) +AND Type = @InternalPersonType)"); + statement?.TryBind("@IsFavorite", query.IsFavorite.Value); + statement?.TryBind("@InternalPersonType", typeof(Person).FullName); + } + if (!query.ItemId.Equals(Guid.Empty)) { whereClauses.Add("ItemId=@ItemId"); - if (statement != null) - { - statement.TryBind("@ItemId", query.ItemId.ToByteArray()); - } + statement?.TryBind("@ItemId", query.ItemId.ToByteArray()); } if (!query.AppearsInItemId.Equals(Guid.Empty)) { - whereClauses.Add("Name in (Select Name from People where ItemId=@AppearsInItemId)"); - if (statement != null) - { - statement.TryBind("@AppearsInItemId", query.AppearsInItemId.ToByteArray()); - } + whereClauses.Add("p.Name in (Select Name from People where ItemId=@AppearsInItemId)"); + statement?.TryBind("@AppearsInItemId", query.AppearsInItemId.ToByteArray()); } var queryPersonTypes = query.PersonTypes.Where(IsValidPersonType).ToList(); @@ -5107,14 +5072,11 @@ where AncestorIdText not null and ItemValues.Value not null and ItemValues.Type if (queryPersonTypes.Count == 1) { whereClauses.Add("PersonType=@PersonType"); - if (statement != null) - { - statement.TryBind("@PersonType", queryPersonTypes[0]); - } + statement?.TryBind("@PersonType", queryPersonTypes[0]); } else if (queryPersonTypes.Count > 1) { - var val = string.Join(",", queryPersonTypes.Select(i => "'" + i + "'")); + var val = string.Join(',', queryPersonTypes.Select(i => "'" + i + "'")); whereClauses.Add("PersonType in (" + val + ")"); } @@ -5124,14 +5086,11 @@ where AncestorIdText not null and ItemValues.Value not null and ItemValues.Type if (queryExcludePersonTypes.Count == 1) { whereClauses.Add("PersonType<>@PersonType"); - if (statement != null) - { - statement.TryBind("@PersonType", queryExcludePersonTypes[0]); - } + statement?.TryBind("@PersonType", queryExcludePersonTypes[0]); } else if (queryExcludePersonTypes.Count > 1) { - var val = string.Join(",", queryExcludePersonTypes.Select(i => "'" + i + "'")); + var val = string.Join(',', queryExcludePersonTypes.Select(i => "'" + i + "'")); whereClauses.Add("PersonType not in (" + val + ")"); } @@ -5139,19 +5098,18 @@ where AncestorIdText not null and ItemValues.Value not null and ItemValues.Type if (query.MaxListOrder.HasValue) { whereClauses.Add("ListOrder<=@MaxListOrder"); - if (statement != null) - { - statement.TryBind("@MaxListOrder", query.MaxListOrder.Value); - } + statement?.TryBind("@MaxListOrder", query.MaxListOrder.Value); } if (!string.IsNullOrWhiteSpace(query.NameContains)) { - whereClauses.Add("Name like @NameContains"); - if (statement != null) - { - statement.TryBind("@NameContains", "%" + query.NameContains + "%"); - } + whereClauses.Add("p.Name like @NameContains"); + statement?.TryBind("@NameContains", "%" + query.NameContains + "%"); + } + + if (query.User != null) + { + statement?.TryBind("@UserId", query.User.InternalId); } return whereClauses; @@ -5249,64 +5207,87 @@ where AncestorIdText not null and ItemValues.Value not null and ItemValues.Type public List<string> GetStudioNames() { - return GetItemValueNames(new[] { 3 }, new List<string>(), new List<string>()); + return GetItemValueNames(new[] { 3 }, Array.Empty<string>(), Array.Empty<string>()); } public List<string> GetAllArtistNames() { - return GetItemValueNames(new[] { 0, 1 }, new List<string>(), new List<string>()); + return GetItemValueNames(new[] { 0, 1 }, Array.Empty<string>(), Array.Empty<string>()); } public List<string> GetMusicGenreNames() { - return GetItemValueNames(new[] { 2 }, new List<string> { "Audio", "MusicVideo", "MusicAlbum", "MusicArtist" }, new List<string>()); + return GetItemValueNames( + new[] { 2 }, + new string[] + { + typeof(Audio).FullName, + typeof(MusicVideo).FullName, + typeof(MusicAlbum).FullName, + typeof(MusicArtist).FullName + }, + Array.Empty<string>()); } public List<string> GetGenreNames() { - return GetItemValueNames(new[] { 2 }, new List<string>(), new List<string> { "Audio", "MusicVideo", "MusicAlbum", "MusicArtist" }); + return GetItemValueNames( + new[] { 2 }, + Array.Empty<string>(), + new string[] + { + typeof(Audio).FullName, + typeof(MusicVideo).FullName, + typeof(MusicAlbum).FullName, + typeof(MusicArtist).FullName + }); } - private List<string> GetItemValueNames(int[] itemValueTypes, List<string> withItemTypes, List<string> excludeItemTypes) + private List<string> GetItemValueNames(int[] itemValueTypes, IReadOnlyList<string> withItemTypes, IReadOnlyList<string> excludeItemTypes) { CheckDisposed(); - withItemTypes = withItemTypes.SelectMany(MapIncludeItemTypes).ToList(); - excludeItemTypes = excludeItemTypes.SelectMany(MapIncludeItemTypes).ToList(); - var now = DateTime.UtcNow; - var typeClause = itemValueTypes.Length == 1 ? - ("Type=" + itemValueTypes[0].ToString(CultureInfo.InvariantCulture)) : - ("Type in (" + string.Join(",", itemValueTypes.Select(i => i.ToString(CultureInfo.InvariantCulture))) + ")"); - - var commandText = "Select Value From ItemValues where " + typeClause; + var stringBuilder = new StringBuilder("Select Value From ItemValues where Type", 128); + if (itemValueTypes.Length == 1) + { + stringBuilder.Append('=') + .Append(itemValueTypes[0]); + } + else + { + stringBuilder.Append(" in (") + .AppendJoin(',', itemValueTypes) + .Append(')'); + } if (withItemTypes.Count > 0) { - var typeString = string.Join(",", withItemTypes.Select(i => "'" + i + "'")); - commandText += " AND ItemId In (select guid from typedbaseitems where type in (" + typeString + "))"; + stringBuilder.Append(" AND ItemId In (select guid from typedbaseitems where type in (") + .AppendJoinInSingleQuotes(',', withItemTypes) + .Append("))"); } if (excludeItemTypes.Count > 0) { - var typeString = string.Join(",", excludeItemTypes.Select(i => "'" + i + "'")); - commandText += " AND ItemId not In (select guid from typedbaseitems where type in (" + typeString + "))"; + stringBuilder.Append(" AND ItemId not In (select guid from typedbaseitems where type in (") + .AppendJoinInSingleQuotes(',', excludeItemTypes) + .Append("))"); } - commandText += " Group By CleanValue"; + stringBuilder.Append(" Group By CleanValue"); + var commandText = stringBuilder.ToString(); var list = new List<string>(); using (var connection = GetConnection(true)) + using (var statement = PrepareStatement(connection, commandText)) { - using (var statement = PrepareStatement(connection, commandText)) + foreach (var row in statement.ExecuteQuery()) { - foreach (var row in statement.ExecuteQuery()) + if (row.TryGetString(0, out var result)) { - if (!row.IsDBNull(0)) - { - list.Add(row.GetString(0)); - } + list.Add(result); } } } @@ -5332,18 +5313,19 @@ where AncestorIdText not null and ItemValues.Value not null and ItemValues.Type var now = DateTime.UtcNow; var typeClause = itemValueTypes.Length == 1 ? - ("Type=" + itemValueTypes[0].ToString(CultureInfo.InvariantCulture)) : - ("Type in (" + string.Join(",", itemValueTypes.Select(i => i.ToString(CultureInfo.InvariantCulture))) + ")"); + ("Type=" + itemValueTypes[0]) : + ("Type in (" + string.Join(',', itemValueTypes) + ")"); InternalItemsQuery typeSubQuery = null; - Dictionary<string, string> itemCountColumns = null; + string itemCountColumns = null; + var stringBuilder = new StringBuilder(1024); var typesToCount = query.IncludeItemTypes; if (typesToCount.Length > 0) { - var itemCountColumnQuery = "select group_concat(type, '|')" + GetFromText("B"); + stringBuilder.Append("(select group_concat(type, '|') from TypedBaseItems B"); typeSubQuery = new InternalItemsQuery(query.User) { @@ -5359,20 +5341,22 @@ where AncestorIdText not null and ItemValues.Value not null and ItemValues.Type }; var whereClauses = GetWhereClauses(typeSubQuery, null); - whereClauses.Add("guid in (select ItemId from ItemValues where ItemValues.CleanValue=A.CleanName AND " + typeClause + ")"); - - itemCountColumnQuery += " where " + string.Join(" AND ", whereClauses); + stringBuilder.Append(" where ") + .AppendJoin(" AND ", whereClauses) + .Append(" AND ") + .Append("guid in (select ItemId from ItemValues where ItemValues.CleanValue=A.CleanName AND ") + .Append(typeClause) + .Append(")) as itemTypes"); - itemCountColumns = new Dictionary<string, string>() - { - { "itemTypes", "(" + itemCountColumnQuery + ") as itemTypes" } - }; + itemCountColumns = stringBuilder.ToString(); + stringBuilder.Clear(); } List<string> columns = _retriveItemColumns.ToList(); - if (itemCountColumns != null) + // Unfortunately we need to add it to columns to ensure the order of the columns in the select + if (!string.IsNullOrEmpty(itemCountColumns)) { - columns.AddRange(itemCountColumns.Values); + columns.Add(itemCountColumns); } // do this first before calling GetFinalColumnsToSelect, otherwise ExcludeItemIds will be set by SimilarTo @@ -5385,7 +5369,6 @@ where AncestorIdText not null and ItemValues.Value not null and ItemValues.Type ItemIds = query.ItemIds, TopParentIds = query.TopParentIds, ParentId = query.ParentId, - IsPlayed = query.IsPlayed, IsAiring = query.IsAiring, IsMovie = query.IsMovie, IsSports = query.IsSports, @@ -5394,23 +5377,24 @@ where AncestorIdText not null and ItemValues.Value not null and ItemValues.Type IsSeries = query.IsSeries }; - columns = GetFinalColumnsToSelect(query, columns); - - var commandText = "select " - + string.Join(",", columns) - + GetFromText() - + GetJoinUserDataText(query); + SetFinalColumnsToSelect(query, columns); var innerWhereClauses = GetWhereClauses(innerQuery, null); - var innerWhereText = innerWhereClauses.Count == 0 ? - string.Empty : - " where " + string.Join(" AND ", innerWhereClauses); + stringBuilder.Append(" where Type=@SelectType And CleanName In (Select CleanValue from ItemValues where ") + .Append(typeClause) + .Append(" AND ItemId in (select guid from TypedBaseItems"); + if (innerWhereClauses.Count > 0) + { + stringBuilder.Append(" where ") + .AppendJoin(" AND ", innerWhereClauses); + } - var whereText = " where Type=@SelectType And CleanName In (Select CleanValue from ItemValues where " + typeClause + " AND ItemId in (select guid from TypedBaseItems" + innerWhereText + "))"; + stringBuilder.Append("))"); var outerQuery = new InternalItemsQuery(query.User) { + IsPlayed = query.IsPlayed, IsFavorite = query.IsFavorite, IsFavoriteOrLiked = query.IsFavoriteOrLiked, IsLiked = query.IsLiked, @@ -5420,6 +5404,7 @@ where AncestorIdText not null and ItemValues.Value not null and ItemValues.Type NameStartsWithOrGreater = query.NameStartsWithOrGreater, Tags = query.Tags, OfficialRatings = query.OfficialRatings, + StudioIds = query.StudioIds, GenreIds = query.GenreIds, Genres = query.Genres, Years = query.Years, @@ -5430,21 +5415,31 @@ where AncestorIdText not null and ItemValues.Value not null and ItemValues.Type }; var outerWhereClauses = GetWhereClauses(outerQuery, null); - if (outerWhereClauses.Count != 0) { - whereText += " AND " + string.Join(" AND ", outerWhereClauses); + stringBuilder.Append(" AND ") + .AppendJoin(" AND ", outerWhereClauses); } - commandText += whereText + " group by PresentationUniqueKey"; + var whereText = stringBuilder.ToString(); + stringBuilder.Clear(); + + stringBuilder.Append("select ") + .AppendJoin(',', columns) + .Append(FromText) + .Append(GetJoinUserDataText(query)) + .Append(whereText) + .Append(" group by PresentationUniqueKey"); - if (query.SimilarTo != null || !string.IsNullOrEmpty(query.SearchTerm)) + if (query.OrderBy.Count != 0 + || query.SimilarTo != null + || !string.IsNullOrEmpty(query.SearchTerm)) { - commandText += GetOrderByText(query); + stringBuilder.Append(GetOrderByText(query)); } else { - commandText += " order by SortName"; + stringBuilder.Append(" order by SortName"); } if (query.Limit.HasValue || query.StartIndex.HasValue) @@ -5453,32 +5448,39 @@ where AncestorIdText not null and ItemValues.Value not null and ItemValues.Type if (query.Limit.HasValue || offset > 0) { - commandText += " LIMIT " + (query.Limit ?? int.MaxValue).ToString(CultureInfo.InvariantCulture); + stringBuilder.Append(" LIMIT ") + .Append(query.Limit ?? int.MaxValue); } if (offset > 0) { - commandText += " OFFSET " + offset.ToString(CultureInfo.InvariantCulture); + stringBuilder.Append(" OFFSET ") + .Append(offset); } } var isReturningZeroItems = query.Limit.HasValue && query.Limit <= 0; - var statementTexts = new List<string>(); + string commandText = string.Empty; + if (!isReturningZeroItems) { - statementTexts.Add(commandText); + commandText = stringBuilder.ToString(); } + string countText = string.Empty; if (query.EnableTotalRecordCount) { - var countText = "select " - + string.Join(",", GetFinalColumnsToSelect(query, new[] { "count (distinct PresentationUniqueKey)" })) - + GetFromText() - + GetJoinUserDataText(query) - + whereText; + stringBuilder.Clear(); + var columnsToSelect = new List<string> { "count (distinct PresentationUniqueKey)" }; + SetFinalColumnsToSelect(query, columnsToSelect); + stringBuilder.Append("select ") + .AppendJoin(',', columnsToSelect) + .Append(FromText) + .Append(GetJoinUserDataText(query)) + .Append(whereText); - statementTexts.Add(countText); + countText = stringBuilder.ToString(); } var list = new List<(BaseItem, ItemCounts)>(); @@ -5488,11 +5490,9 @@ where AncestorIdText not null and ItemValues.Value not null and ItemValues.Type connection.RunInTransaction( db => { - var statements = PrepareAll(db, statementTexts); - if (!isReturningZeroItems) { - using (var statement = statements[0]) + using (var statement = PrepareStatement(db, commandText)) { statement.TryBind("@SelectType", returnType); if (EnableJoinUserData(query)) @@ -5533,13 +5533,7 @@ where AncestorIdText not null and ItemValues.Value not null and ItemValues.Type if (query.EnableTotalRecordCount) { - commandText = "select " - + string.Join(",", GetFinalColumnsToSelect(query, new[] { "count (distinct PresentationUniqueKey)" })) - + GetFromText() - + GetJoinUserDataText(query) - + whereText; - - using (var statement = statements[statements.Length - 1]) + using (var statement = PrepareStatement(db, countText)) { statement.TryBind("@SelectType", returnType); if (EnableJoinUserData(query)) @@ -5576,7 +5570,7 @@ where AncestorIdText not null and ItemValues.Value not null and ItemValues.Type return result; } - private ItemCounts GetItemCounts(IReadOnlyList<IResultSetValue> reader, int countStartColumn, string[] typesToCount) + private static ItemCounts GetItemCounts(IReadOnlyList<ResultSetValue> reader, int countStartColumn, string[] typesToCount) { var counts = new ItemCounts(); @@ -5585,51 +5579,43 @@ where AncestorIdText not null and ItemValues.Value not null and ItemValues.Type return counts; } - var typeString = reader.IsDBNull(countStartColumn) ? null : reader.GetString(countStartColumn); - - if (string.IsNullOrWhiteSpace(typeString)) + if (!reader.TryGetString(countStartColumn, out var typeString)) { return counts; } - var allTypes = typeString.Split(new[] { '|' }, StringSplitOptions.RemoveEmptyEntries) - .ToLookup(x => x); - - foreach (var type in allTypes) + foreach (var typeName in typeString.AsSpan().Split('|')) { - var value = type.Count(); - var typeName = type.Key; - - if (string.Equals(typeName, typeof(Series).FullName, StringComparison.OrdinalIgnoreCase)) + if (typeName.Equals(typeof(Series).FullName, StringComparison.OrdinalIgnoreCase)) { - counts.SeriesCount = value; + counts.SeriesCount++; } - else if (string.Equals(typeName, typeof(Episode).FullName, StringComparison.OrdinalIgnoreCase)) + else if (typeName.Equals(typeof(Episode).FullName, StringComparison.OrdinalIgnoreCase)) { - counts.EpisodeCount = value; + counts.EpisodeCount++; } - else if (string.Equals(typeName, typeof(Movie).FullName, StringComparison.OrdinalIgnoreCase)) + else if (typeName.Equals(typeof(Movie).FullName, StringComparison.OrdinalIgnoreCase)) { - counts.MovieCount = value; + counts.MovieCount++; } - else if (string.Equals(typeName, typeof(MusicAlbum).FullName, StringComparison.OrdinalIgnoreCase)) + else if (typeName.Equals(typeof(MusicAlbum).FullName, StringComparison.OrdinalIgnoreCase)) { - counts.AlbumCount = value; + counts.AlbumCount++; } - else if (string.Equals(typeName, typeof(MusicArtist).FullName, StringComparison.OrdinalIgnoreCase)) + else if (typeName.Equals(typeof(MusicArtist).FullName, StringComparison.OrdinalIgnoreCase)) { - counts.ArtistCount = value; + counts.ArtistCount++; } - else if (string.Equals(typeName, typeof(Audio).FullName, StringComparison.OrdinalIgnoreCase)) + else if (typeName.Equals(typeof(Audio).FullName, StringComparison.OrdinalIgnoreCase)) { - counts.SongCount = value; + counts.SongCount++; } - else if (string.Equals(typeName, typeof(Trailer).FullName, StringComparison.OrdinalIgnoreCase)) + else if (typeName.Equals(typeof(Trailer).FullName, StringComparison.OrdinalIgnoreCase)) { - counts.TrailerCount = value; + counts.TrailerCount++; } - counts.ItemCount += value; + counts.ItemCount++; } return counts; @@ -5778,7 +5764,10 @@ where AncestorIdText not null and ItemValues.Value not null and ItemValues.Type var endIndex = Math.Min(people.Count, startIndex + Limit); for (var i = startIndex; i < endIndex; i++) { - insertText.AppendFormat("(@ItemId, @Name{0}, @Role{0}, @PersonType{0}, @SortOrder{0}, @ListOrder{0}),", i.ToString(CultureInfo.InvariantCulture)); + insertText.AppendFormat( + CultureInfo.InvariantCulture, + "(@ItemId, @Name{0}, @Role{0}, @PersonType{0}, @SortOrder{0}, @ListOrder{0}),", + i.ToString(CultureInfo.InvariantCulture)); } // Remove last comma @@ -5812,7 +5801,7 @@ where AncestorIdText not null and ItemValues.Value not null and ItemValues.Type } } - private PersonInfo GetPerson(IReadOnlyList<IResultSetValue> reader) + private PersonInfo GetPerson(IReadOnlyList<ResultSetValue> reader) { var item = new PersonInfo { @@ -5820,19 +5809,19 @@ where AncestorIdText not null and ItemValues.Value not null and ItemValues.Type Name = reader.GetString(1) }; - if (!reader.IsDBNull(2)) + if (reader.TryGetString(2, out var role)) { - item.Role = reader.GetString(2); + item.Role = role; } - if (!reader.IsDBNull(3)) + if (reader.TryGetString(3, out var type)) { - item.Type = reader.GetString(3); + item.Type = type; } - if (!reader.IsDBNull(4)) + if (reader.TryGetInt32(4, out var sortOrder)) { - item.SortOrder = reader.GetInt32(4); + item.SortOrder = sortOrder; } return item; @@ -6019,7 +6008,7 @@ where AncestorIdText not null and ItemValues.Value not null and ItemValues.Type /// </summary> /// <param name="reader">The reader.</param> /// <returns>ChapterInfo.</returns> - private MediaStream GetMediaStream(IReadOnlyList<IResultSetValue> reader) + private MediaStream GetMediaStream(IReadOnlyList<ResultSetValue> reader) { var item = new MediaStream { @@ -6028,157 +6017,157 @@ where AncestorIdText not null and ItemValues.Value not null and ItemValues.Type item.Type = Enum.Parse<MediaStreamType>(reader[2].ToString(), true); - if (reader[3].SQLiteType != SQLiteType.Null) + if (reader.TryGetString(3, out var codec)) { - item.Codec = reader[3].ToString(); + item.Codec = codec; } - if (reader[4].SQLiteType != SQLiteType.Null) + if (reader.TryGetString(4, out var language)) { - item.Language = reader[4].ToString(); + item.Language = language; } - if (reader[5].SQLiteType != SQLiteType.Null) + if (reader.TryGetString(5, out var channelLayout)) { - item.ChannelLayout = reader[5].ToString(); + item.ChannelLayout = channelLayout; } - if (reader[6].SQLiteType != SQLiteType.Null) + if (reader.TryGetString(6, out var profile)) { - item.Profile = reader[6].ToString(); + item.Profile = profile; } - if (reader[7].SQLiteType != SQLiteType.Null) + if (reader.TryGetString(7, out var aspectRatio)) { - item.AspectRatio = reader[7].ToString(); + item.AspectRatio = aspectRatio; } - if (reader[8].SQLiteType != SQLiteType.Null) + if (reader.TryGetString(8, out var path)) { - item.Path = RestorePath(reader[8].ToString()); + item.Path = RestorePath(path); } item.IsInterlaced = reader.GetBoolean(9); - if (reader[10].SQLiteType != SQLiteType.Null) + if (reader.TryGetInt32(10, out var bitrate)) { - item.BitRate = reader.GetInt32(10); + item.BitRate = bitrate; } - if (reader[11].SQLiteType != SQLiteType.Null) + if (reader.TryGetInt32(11, out var channels)) { - item.Channels = reader.GetInt32(11); + item.Channels = channels; } - if (reader[12].SQLiteType != SQLiteType.Null) + if (reader.TryGetInt32(12, out var sampleRate)) { - item.SampleRate = reader.GetInt32(12); + item.SampleRate = sampleRate; } item.IsDefault = reader.GetBoolean(13); item.IsForced = reader.GetBoolean(14); item.IsExternal = reader.GetBoolean(15); - if (reader[16].SQLiteType != SQLiteType.Null) + if (reader.TryGetInt32(16, out var width)) { - item.Width = reader.GetInt32(16); + item.Width = width; } - if (reader[17].SQLiteType != SQLiteType.Null) + if (reader.TryGetInt32(17, out var height)) { - item.Height = reader.GetInt32(17); + item.Height = height; } - if (reader[18].SQLiteType != SQLiteType.Null) + if (reader.TryGetSingle(18, out var averageFrameRate)) { - item.AverageFrameRate = reader.GetFloat(18); + item.AverageFrameRate = averageFrameRate; } - if (reader[19].SQLiteType != SQLiteType.Null) + if (reader.TryGetSingle(19, out var realFrameRate)) { - item.RealFrameRate = reader.GetFloat(19); + item.RealFrameRate = realFrameRate; } - if (reader[20].SQLiteType != SQLiteType.Null) + if (reader.TryGetSingle(20, out var level)) { - item.Level = reader.GetFloat(20); + item.Level = level; } - if (reader[21].SQLiteType != SQLiteType.Null) + if (reader.TryGetString(21, out var pixelFormat)) { - item.PixelFormat = reader[21].ToString(); + item.PixelFormat = pixelFormat; } - if (reader[22].SQLiteType != SQLiteType.Null) + if (reader.TryGetInt32(22, out var bitDepth)) { - item.BitDepth = reader.GetInt32(22); + item.BitDepth = bitDepth; } - if (reader[23].SQLiteType != SQLiteType.Null) + if (reader.TryGetBoolean(23, out var isAnamorphic)) { - item.IsAnamorphic = reader.GetBoolean(23); + item.IsAnamorphic = isAnamorphic; } - if (reader[24].SQLiteType != SQLiteType.Null) + if (reader.TryGetInt32(24, out var refFrames)) { - item.RefFrames = reader.GetInt32(24); + item.RefFrames = refFrames; } - if (reader[25].SQLiteType != SQLiteType.Null) + if (reader.TryGetString(25, out var codecTag)) { - item.CodecTag = reader.GetString(25); + item.CodecTag = codecTag; } - if (reader[26].SQLiteType != SQLiteType.Null) + if (reader.TryGetString(26, out var comment)) { - item.Comment = reader.GetString(26); + item.Comment = comment; } - if (reader[27].SQLiteType != SQLiteType.Null) + if (reader.TryGetString(27, out var nalLengthSize)) { - item.NalLengthSize = reader.GetString(27); + item.NalLengthSize = nalLengthSize; } - if (reader[28].SQLiteType != SQLiteType.Null) + if (reader.TryGetBoolean(28, out var isAVC)) { - item.IsAVC = reader[28].ToBool(); + item.IsAVC = isAVC; } - if (reader[29].SQLiteType != SQLiteType.Null) + if (reader.TryGetString(29, out var title)) { - item.Title = reader[29].ToString(); + item.Title = title; } - if (reader[30].SQLiteType != SQLiteType.Null) + if (reader.TryGetString(30, out var timeBase)) { - item.TimeBase = reader[30].ToString(); + item.TimeBase = timeBase; } - if (reader[31].SQLiteType != SQLiteType.Null) + if (reader.TryGetString(31, out var codecTimeBase)) { - item.CodecTimeBase = reader[31].ToString(); + item.CodecTimeBase = codecTimeBase; } - if (reader[32].SQLiteType != SQLiteType.Null) + if (reader.TryGetString(32, out var colorPrimaries)) { - item.ColorPrimaries = reader[32].ToString(); + item.ColorPrimaries = colorPrimaries; } - if (reader[33].SQLiteType != SQLiteType.Null) + if (reader.TryGetString(33, out var colorSpace)) { - item.ColorSpace = reader[33].ToString(); + item.ColorSpace = colorSpace; } - if (reader[34].SQLiteType != SQLiteType.Null) + if (reader.TryGetString(34, out var colorTransfer)) { - item.ColorTransfer = reader[34].ToString(); + item.ColorTransfer = colorTransfer; } if (item.Type == MediaStreamType.Subtitle) { - item.localizedUndefined = _localization.GetLocalizedString("Undefined"); - item.localizedDefault = _localization.GetLocalizedString("Default"); - item.localizedForced = _localization.GetLocalizedString("Forced"); + item.LocalizedUndefined = _localization.GetLocalizedString("Undefined"); + item.LocalizedDefault = _localization.GetLocalizedString("Default"); + item.LocalizedForced = _localization.GetLocalizedString("Forced"); } return item; @@ -6230,7 +6219,7 @@ where AncestorIdText not null and ItemValues.Value not null and ItemValues.Type CheckDisposed(); if (id == Guid.Empty) { - throw new ArgumentException(nameof(id)); + throw new ArgumentException("Guid can't be empty.", nameof(id)); } if (attachments == null) @@ -6320,36 +6309,36 @@ where AncestorIdText not null and ItemValues.Value not null and ItemValues.Type /// </summary> /// <param name="reader">The reader.</param> /// <returns>MediaAttachment.</returns> - private MediaAttachment GetMediaAttachment(IReadOnlyList<IResultSetValue> reader) + private MediaAttachment GetMediaAttachment(IReadOnlyList<ResultSetValue> reader) { var item = new MediaAttachment { Index = reader[1].ToInt() }; - if (reader[2].SQLiteType != SQLiteType.Null) + if (reader.TryGetString(2, out var codec)) { - item.Codec = reader[2].ToString(); + item.Codec = codec; } - if (reader[2].SQLiteType != SQLiteType.Null) + if (reader.TryGetString(3, out var codecTag)) { - item.CodecTag = reader[3].ToString(); + item.CodecTag = codecTag; } - if (reader[4].SQLiteType != SQLiteType.Null) + if (reader.TryGetString(4, out var comment)) { - item.Comment = reader[4].ToString(); + item.Comment = comment; } - if (reader[6].SQLiteType != SQLiteType.Null) + if (reader.TryGetString(5, out var fileName)) { - item.FileName = reader[5].ToString(); + item.FileName = fileName; } - if (reader[6].SQLiteType != SQLiteType.Null) + if (reader.TryGetString(6, out var mimeType)) { - item.MimeType = reader[6].ToString(); + item.MimeType = mimeType; } return item; diff --git a/Emby.Server.Implementations/Data/SqliteUserDataRepository.cs b/Emby.Server.Implementations/Data/SqliteUserDataRepository.cs index 2c4e8e0fcc..ef9af1dcd0 100644 --- a/Emby.Server.Implementations/Data/SqliteUserDataRepository.cs +++ b/Emby.Server.Implementations/Data/SqliteUserDataRepository.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System; @@ -47,7 +49,7 @@ namespace Emby.Server.Implementations.Data connection.RunInTransaction( db => { - db.ExecuteAll(string.Join(";", new[] { + db.ExecuteAll(string.Join(';', new[] { "create table if not exists UserDatas (key nvarchar not null, userId INT not null, rating float null, played bit not null, playCount int not null, isFavorite bit not null, playbackPositionTicks bigint not null, lastPlayedDate datetime null, AudioStreamIndex INT, SubtitleStreamIndex INT)", @@ -348,16 +350,16 @@ namespace Emby.Server.Implementations.Data /// Read a row from the specified reader into the provided userData object. /// </summary> /// <param name="reader"></param> - private UserItemData ReadRow(IReadOnlyList<IResultSetValue> reader) + private UserItemData ReadRow(IReadOnlyList<ResultSetValue> reader) { var userData = new UserItemData(); userData.Key = reader[0].ToString(); // userData.UserId = reader[1].ReadGuidFromBlob(); - if (reader[2].SQLiteType != SQLiteType.Null) + if (reader.TryGetDouble(2, out var rating)) { - userData.Rating = reader[2].ToDouble(); + userData.Rating = rating; } userData.Played = reader[3].ToBool(); @@ -365,19 +367,19 @@ namespace Emby.Server.Implementations.Data userData.IsFavorite = reader[5].ToBool(); userData.PlaybackPositionTicks = reader[6].ToInt64(); - if (reader[7].SQLiteType != SQLiteType.Null) + if (reader.TryReadDateTime(7, out var lastPlayedDate)) { - userData.LastPlayedDate = reader[7].TryReadDateTime(); + userData.LastPlayedDate = lastPlayedDate; } - if (reader[8].SQLiteType != SQLiteType.Null) + if (reader.TryGetInt32(8, out var audioStreamIndex)) { - userData.AudioStreamIndex = reader[8].ToInt(); + userData.AudioStreamIndex = audioStreamIndex; } - if (reader[9].SQLiteType != SQLiteType.Null) + if (reader.TryGetInt32(9, out var subtitleStreamIndex)) { - userData.SubtitleStreamIndex = reader[9].ToInt(); + userData.SubtitleStreamIndex = subtitleStreamIndex; } return userData; diff --git a/Emby.Server.Implementations/Data/TypeMapper.cs b/Emby.Server.Implementations/Data/TypeMapper.cs index 7044b1d194..064664e1fe 100644 --- a/Emby.Server.Implementations/Data/TypeMapper.cs +++ b/Emby.Server.Implementations/Data/TypeMapper.cs @@ -13,7 +13,7 @@ namespace Emby.Server.Implementations.Data /// This holds all the types in the running assemblies /// so that we can de-serialize properly when we don't have strong types. /// </summary> - private readonly ConcurrentDictionary<string, Type> _typeMap = new ConcurrentDictionary<string, Type>(); + private readonly ConcurrentDictionary<string, Type?> _typeMap = new ConcurrentDictionary<string, Type?>(); /// <summary> /// Gets the type. @@ -21,26 +21,16 @@ namespace Emby.Server.Implementations.Data /// <param name="typeName">Name of the type.</param> /// <returns>Type.</returns> /// <exception cref="ArgumentNullException"><c>typeName</c> is null.</exception> - public Type GetType(string typeName) + public Type? GetType(string typeName) { if (string.IsNullOrEmpty(typeName)) { throw new ArgumentNullException(nameof(typeName)); } - return _typeMap.GetOrAdd(typeName, LookupType); - } - - /// <summary> - /// Lookups the type. - /// </summary> - /// <param name="typeName">Name of the type.</param> - /// <returns>Type.</returns> - private Type LookupType(string typeName) - { - return AppDomain.CurrentDomain.GetAssemblies() - .Select(a => a.GetType(typeName)) - .FirstOrDefault(t => t != null); + return _typeMap.GetOrAdd(typeName, k => AppDomain.CurrentDomain.GetAssemblies() + .Select(a => a.GetType(k)) + .FirstOrDefault(t => t != null)); } } } diff --git a/Emby.Server.Implementations/Devices/DeviceId.cs b/Emby.Server.Implementations/Devices/DeviceId.cs index fa6ac95fd3..3d15b3e768 100644 --- a/Emby.Server.Implementations/Devices/DeviceId.cs +++ b/Emby.Server.Implementations/Devices/DeviceId.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System; diff --git a/Emby.Server.Implementations/Devices/DeviceManager.cs b/Emby.Server.Implementations/Devices/DeviceManager.cs index f98c694c46..2637addce7 100644 --- a/Emby.Server.Implementations/Devices/DeviceManager.cs +++ b/Emby.Server.Implementations/Devices/DeviceManager.cs @@ -1,61 +1,40 @@ +#nullable disable + #pragma warning disable CS1591 using System; +using System.Collections.Concurrent; using System.Collections.Generic; -using System.Globalization; -using System.IO; using System.Linq; using Jellyfin.Data.Entities; using Jellyfin.Data.Enums; using Jellyfin.Data.Events; -using MediaBrowser.Common.Extensions; -using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Devices; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Security; using MediaBrowser.Model.Devices; using MediaBrowser.Model.Querying; -using MediaBrowser.Model.Serialization; using MediaBrowser.Model.Session; -using Microsoft.Extensions.Caching.Memory; namespace Emby.Server.Implementations.Devices { public class DeviceManager : IDeviceManager { - private readonly IMemoryCache _memoryCache; - private readonly IJsonSerializer _json; private readonly IUserManager _userManager; - private readonly IServerConfigurationManager _config; private readonly IAuthenticationRepository _authRepo; - private readonly object _capabilitiesSyncLock = new object(); - - public event EventHandler<GenericEventArgs<Tuple<string, DeviceOptions>>> DeviceOptionsUpdated; + private readonly ConcurrentDictionary<string, ClientCapabilities> _capabilitiesMap = new (); - public DeviceManager( - IAuthenticationRepository authRepo, - IJsonSerializer json, - IUserManager userManager, - IServerConfigurationManager config, - IMemoryCache memoryCache) + public DeviceManager(IAuthenticationRepository authRepo, IUserManager userManager) { - _json = json; _userManager = userManager; - _config = config; - _memoryCache = memoryCache; _authRepo = authRepo; } + public event EventHandler<GenericEventArgs<Tuple<string, DeviceOptions>>> DeviceOptionsUpdated; + public void SaveCapabilities(string deviceId, ClientCapabilities capabilities) { - var path = Path.Combine(GetDevicePath(deviceId), "capabilities.json"); - Directory.CreateDirectory(Path.GetDirectoryName(path)); - - lock (_capabilitiesSyncLock) - { - _memoryCache.Set(deviceId, capabilities); - _json.SerializeToFile(capabilities, path); - } + _capabilitiesMap[deviceId] = capabilities; } public void UpdateDeviceOptions(string deviceId, DeviceOptions options) @@ -72,32 +51,12 @@ namespace Emby.Server.Implementations.Devices public ClientCapabilities GetCapabilities(string id) { - if (_memoryCache.TryGetValue(id, out ClientCapabilities result)) - { - return result; - } - - lock (_capabilitiesSyncLock) - { - var path = Path.Combine(GetDevicePath(id), "capabilities.json"); - try - { - return _json.DeserializeFromFile<ClientCapabilities>(path) ?? new ClientCapabilities(); - } - catch - { - } - } - - return new ClientCapabilities(); + return _capabilitiesMap.TryGetValue(id, out ClientCapabilities result) + ? result + : new ClientCapabilities(); } public DeviceInfo GetDevice(string id) - { - return GetDevice(id, true); - } - - private DeviceInfo GetDevice(string id, bool includeCapabilities) { var session = _authRepo.Get(new AuthenticationInfoQuery { @@ -154,16 +113,6 @@ namespace Emby.Server.Implementations.Devices }; } - private string GetDevicesPath() - { - return Path.Combine(_config.ApplicationPaths.DataPath, "devices"); - } - - private string GetDevicePath(string id) - { - return Path.Combine(GetDevicesPath(), id.GetMD5().ToString("N", CultureInfo.InvariantCulture)); - } - public bool CanAccessDevice(User user, string deviceId) { if (user == null) diff --git a/Emby.Server.Implementations/Dto/DtoService.cs b/Emby.Server.Implementations/Dto/DtoService.cs index edb8753fd4..7411239a1e 100644 --- a/Emby.Server.Implementations/Dto/DtoService.cs +++ b/Emby.Server.Implementations/Dto/DtoService.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System; @@ -249,7 +251,7 @@ namespace Emby.Server.Implementations.Dto var activeRecording = liveTvManager.GetActiveRecordingInfo(item.Path); if (activeRecording != null) { - dto.Type = "Recording"; + dto.Type = BaseItemKind.Recording; dto.CanDownload = false; dto.RunTimeTicks = null; @@ -275,7 +277,7 @@ namespace Emby.Server.Implementations.Dto continue; } - var containers = container.Split(new[] { ',' }); + var containers = container.Split(','); if (containers.Length < 2) { continue; @@ -465,7 +467,7 @@ namespace Emby.Server.Implementations.Dto { var parentAlbumIds = _libraryManager.GetItemIds(new InternalItemsQuery { - IncludeItemTypes = new[] { typeof(MusicAlbum).Name }, + IncludeItemTypes = new[] { nameof(MusicAlbum) }, Name = item.Album, Limit = 1 }); @@ -582,7 +584,26 @@ namespace Emby.Server.Implementations.Dto { baseItemPerson.PrimaryImageTag = GetTagAndFillBlurhash(dto, entity, ImageType.Primary); baseItemPerson.Id = entity.Id.ToString("N", CultureInfo.InvariantCulture); - baseItemPerson.ImageBlurHashes = dto.ImageBlurHashes; + if (dto.ImageBlurHashes != null) + { + // Only add BlurHash for the person's image. + baseItemPerson.ImageBlurHashes = new Dictionary<ImageType, Dictionary<string, string>>(); + foreach (var (imageType, blurHash) in dto.ImageBlurHashes) + { + if (blurHash != null) + { + baseItemPerson.ImageBlurHashes[imageType] = new Dictionary<string, string>(); + foreach (var (imageId, blurHashValue) in blurHash) + { + if (string.Equals(baseItemPerson.PrimaryImageTag, imageId, StringComparison.OrdinalIgnoreCase)) + { + baseItemPerson.ImageBlurHashes[imageType][imageId] = blurHashValue; + } + } + } + } + } + list.Add(baseItemPerson); } } @@ -646,10 +667,7 @@ namespace Emby.Server.Implementations.Dto var tag = GetImageCacheTag(item, image); if (!string.IsNullOrEmpty(image.BlurHash)) { - if (dto.ImageBlurHashes == null) - { - dto.ImageBlurHashes = new Dictionary<ImageType, Dictionary<string, string>>(); - } + dto.ImageBlurHashes ??= new Dictionary<ImageType, Dictionary<string, string>>(); if (!dto.ImageBlurHashes.ContainsKey(image.Type)) { @@ -683,10 +701,7 @@ namespace Emby.Server.Implementations.Dto if (hashes.Count > 0) { - if (dto.ImageBlurHashes == null) - { - dto.ImageBlurHashes = new Dictionary<ImageType, Dictionary<string, string>>(); - } + dto.ImageBlurHashes ??= new Dictionary<ImageType, Dictionary<string, string>>(); dto.ImageBlurHashes[imageType] = hashes; } @@ -879,13 +894,10 @@ namespace Emby.Server.Implementations.Dto dto.Taglines = new string[] { item.Tagline }; } - if (dto.Taglines == null) - { - dto.Taglines = Array.Empty<string>(); - } + dto.Taglines ??= Array.Empty<string>(); } - dto.Type = item.GetClientTypeName(); + dto.Type = item.GetBaseItemKind(); if ((item.CommunityRating ?? 0) > 0) { dto.CommunityRating = item.CommunityRating; @@ -1138,7 +1150,10 @@ namespace Emby.Server.Implementations.Dto if (episodeSeries != null) { dto.SeriesPrimaryImageTag = GetTagAndFillBlurhash(dto, episodeSeries, ImageType.Primary); - AttachPrimaryImageAspectRatio(dto, episodeSeries); + if (dto.ImageTags == null || !dto.ImageTags.ContainsKey(ImageType.Primary)) + { + AttachPrimaryImageAspectRatio(dto, episodeSeries); + } } } @@ -1185,7 +1200,10 @@ namespace Emby.Server.Implementations.Dto if (series != null) { dto.SeriesPrimaryImageTag = GetTagAndFillBlurhash(dto, series, ImageType.Primary); - AttachPrimaryImageAspectRatio(dto, series); + if (dto.ImageTags == null || !dto.ImageTags.ContainsKey(ImageType.Primary)) + { + AttachPrimaryImageAspectRatio(dto, series); + } } } } diff --git a/Emby.Server.Implementations/Emby.Server.Implementations.csproj b/Emby.Server.Implementations/Emby.Server.Implementations.csproj index c762aa0b84..e0f841d529 100644 --- a/Emby.Server.Implementations/Emby.Server.Implementations.csproj +++ b/Emby.Server.Implementations/Emby.Server.Implementations.csproj @@ -9,6 +9,7 @@ <ProjectReference Include="..\Emby.Naming\Emby.Naming.csproj" /> <ProjectReference Include="..\Emby.Notifications\Emby.Notifications.csproj" /> <ProjectReference Include="..\Jellyfin.Api\Jellyfin.Api.csproj" /> + <ProjectReference Include="..\Jellyfin.Server.Implementations\Jellyfin.Server.Implementations.csproj" /> <ProjectReference Include="..\MediaBrowser.Model\MediaBrowser.Model.csproj" /> <ProjectReference Include="..\MediaBrowser.Common\MediaBrowser.Common.csproj" /> <ProjectReference Include="..\MediaBrowser.Controller\MediaBrowser.Controller.csproj" /> @@ -22,26 +23,17 @@ </ItemGroup> <ItemGroup> - <PackageReference Include="IPNetwork2" Version="2.5.226" /> <PackageReference Include="Jellyfin.XmlTv" Version="10.6.2" /> - <PackageReference Include="Microsoft.AspNetCore.Hosting" Version="2.2.7" /> - <PackageReference Include="Microsoft.AspNetCore.Hosting.Abstractions" Version="2.2.0" /> - <PackageReference Include="Microsoft.AspNetCore.Hosting.Server.Abstractions" Version="2.2.0" /> - <PackageReference Include="Microsoft.AspNetCore.Http" Version="2.2.2" /> - <PackageReference Include="Microsoft.AspNetCore.Http.Extensions" Version="2.2.0" /> - <PackageReference Include="Microsoft.AspNetCore.ResponseCompression" Version="2.2.0" /> - <PackageReference Include="Microsoft.AspNetCore.Server.Kestrel" Version="2.2.0" /> - <PackageReference Include="Microsoft.AspNetCore.WebSockets" Version="2.2.1" /> - <PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="3.1.9" /> - <PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="3.1.9" /> - <PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="3.1.9" /> - <PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="3.1.9" /> - <PackageReference Include="Mono.Nat" Version="3.0.0" /> - <PackageReference Include="prometheus-net.DotNetRuntime" Version="3.4.0" /> - <PackageReference Include="ServiceStack.Text.Core" Version="5.9.2" /> - <PackageReference Include="sharpcompress" Version="0.26.0" /> - <PackageReference Include="SQLitePCL.pretty.netstandard" Version="2.1.0" /> - <PackageReference Include="DotNet.Glob" Version="3.1.0" /> + <PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="5.0.2" /> + <PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="5.0.0" /> + <PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="5.0.0" /> + <PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="5.0.0" /> + <PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="5.0.9" /> + <PackageReference Include="Mono.Nat" Version="3.0.1" /> + <PackageReference Include="prometheus-net.DotNetRuntime" Version="4.1.0" /> + <PackageReference Include="sharpcompress" Version="0.28.3" /> + <PackageReference Include="SQLitePCL.pretty.netstandard" Version="3.1.0" /> + <PackageReference Include="DotNet.Glob" Version="3.1.2" /> </ItemGroup> <ItemGroup> @@ -49,29 +41,29 @@ </ItemGroup> <PropertyGroup> - <TargetFramework>netstandard2.1</TargetFramework> + <TargetFramework>net5.0</TargetFramework> <GenerateAssemblyInfo>false</GenerateAssemblyInfo> <GenerateDocumentationFile>true</GenerateDocumentationFile> - <TreatWarningsAsErrors Condition=" '$(Configuration)' == 'Release'">true</TreatWarningsAsErrors> + <!-- https://github.com/microsoft/ApplicationInsights-dotnet/issues/2047 --> + <NoWarn>AD0001</NoWarn> + <TreatWarningsAsErrors>false</TreatWarningsAsErrors> + </PropertyGroup> + + <PropertyGroup Condition=" '$(Configuration)' == 'Release'"> + <TreatWarningsAsErrors>true</TreatWarningsAsErrors> </PropertyGroup> <!-- Code Analyzers--> <ItemGroup Condition=" '$(Configuration)' == 'Debug' "> - <PackageReference Include="Microsoft.CodeAnalysis.FxCopAnalyzers" Version="2.9.8" PrivateAssets="All" /> <PackageReference Include="SerilogAnalyzer" Version="0.15.0" PrivateAssets="All" /> <PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="All" /> <PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" PrivateAssets="All" /> </ItemGroup> - <PropertyGroup Condition=" '$(Configuration)' == 'Debug' "> - <CodeAnalysisRuleSet>../jellyfin.ruleset</CodeAnalysisRuleSet> - </PropertyGroup> - <ItemGroup> <EmbeddedResource Include="Localization\iso6392.txt" /> <EmbeddedResource Include="Localization\countries.json" /> <EmbeddedResource Include="Localization\Core\*.json" /> <EmbeddedResource Include="Localization\Ratings\*.csv" /> </ItemGroup> - </Project> diff --git a/Emby.Server.Implementations/EntryPoints/ExternalPortForwarding.cs b/Emby.Server.Implementations/EntryPoints/ExternalPortForwarding.cs index 2e8cc76d23..0a4efd73c7 100644 --- a/Emby.Server.Implementations/EntryPoints/ExternalPortForwarding.cs +++ b/Emby.Server.Implementations/EntryPoints/ExternalPortForwarding.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System; @@ -8,6 +10,7 @@ using System.Text; using System.Threading; using System.Threading.Tasks; using Jellyfin.Data.Events; +using Jellyfin.Networking.Configuration; using MediaBrowser.Controller; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Plugins; @@ -56,7 +59,7 @@ namespace Emby.Server.Implementations.EntryPoints private string GetConfigIdentifier() { const char Separator = '|'; - var config = _config.Configuration; + var config = _config.GetNetworkConfiguration(); return new StringBuilder(32) .Append(config.EnableUPnP).Append(Separator) @@ -93,7 +96,8 @@ namespace Emby.Server.Implementations.EntryPoints private void Start() { - if (!_config.Configuration.EnableUPnP || !_config.Configuration.EnableRemoteAccess) + var config = _config.GetNetworkConfiguration(); + if (!config.EnableUPnP || !config.EnableRemoteAccess) { return; } @@ -104,8 +108,6 @@ namespace Emby.Server.Implementations.EntryPoints NatUtility.StartDiscovery(); _timer = new Timer((_) => _createdRules.Clear(), null, TimeSpan.FromMinutes(10), TimeSpan.FromMinutes(10)); - - _deviceDiscovery.DeviceDiscovered += OnDeviceDiscoveryDeviceDiscovered; } private void Stop() @@ -116,13 +118,6 @@ namespace Emby.Server.Implementations.EntryPoints NatUtility.DeviceFound -= OnNatUtilityDeviceFound; _timer?.Dispose(); - - _deviceDiscovery.DeviceDiscovered -= OnDeviceDiscoveryDeviceDiscovered; - } - - private void OnDeviceDiscoveryDeviceDiscovered(object sender, GenericEventArgs<UpnpDeviceInfo> e) - { - NatUtility.Search(e.Argument.LocalIpAddress, NatProtocol.Upnp); } private async void OnNatUtilityDeviceFound(object sender, DeviceEventArgs e) @@ -156,11 +151,12 @@ namespace Emby.Server.Implementations.EntryPoints private IEnumerable<Task> CreatePortMaps(INatDevice device) { - yield return CreatePortMap(device, _appHost.HttpPort, _config.Configuration.PublicPort); + var config = _config.GetNetworkConfiguration(); + yield return CreatePortMap(device, _appHost.HttpPort, config.PublicPort); if (_appHost.ListenWithHttps) { - yield return CreatePortMap(device, _appHost.HttpsPort, _config.Configuration.PublicHttpsPort); + yield return CreatePortMap(device, _appHost.HttpsPort, config.PublicHttpsPort); } } diff --git a/Emby.Server.Implementations/EntryPoints/LibraryChangedNotifier.cs b/Emby.Server.Implementations/EntryPoints/LibraryChangedNotifier.cs index ff64e217a0..5bb4100ba9 100644 --- a/Emby.Server.Implementations/EntryPoints/LibraryChangedNotifier.cs +++ b/Emby.Server.Implementations/EntryPoints/LibraryChangedNotifier.cs @@ -1,6 +1,9 @@ +#nullable disable + #pragma warning disable CS1591 using System; +using System.Collections.Concurrent; using System.Collections.Generic; using System.Globalization; using System.Linq; @@ -44,7 +47,7 @@ namespace Emby.Server.Implementations.EntryPoints private readonly List<BaseItem> _itemsAdded = new List<BaseItem>(); private readonly List<BaseItem> _itemsRemoved = new List<BaseItem>(); private readonly List<BaseItem> _itemsUpdated = new List<BaseItem>(); - private readonly Dictionary<Guid, DateTime> _lastProgressMessageTimes = new Dictionary<Guid, DateTime>(); + private readonly ConcurrentDictionary<Guid, DateTime> _lastProgressMessageTimes = new ConcurrentDictionary<Guid, DateTime>(); public LibraryChangedNotifier( ILibraryManager libraryManager, @@ -98,7 +101,7 @@ namespace Emby.Server.Implementations.EntryPoints } } - _lastProgressMessageTimes[item.Id] = DateTime.UtcNow; + _lastProgressMessageTimes.AddOrUpdate(item.Id, key => DateTime.UtcNow, (key, existing) => DateTime.UtcNow); var dict = new Dictionary<string, string>(); dict["ItemId"] = item.Id.ToString("N", CultureInfo.InvariantCulture); @@ -140,6 +143,8 @@ namespace Emby.Server.Implementations.EntryPoints private void OnProviderRefreshCompleted(object sender, GenericEventArgs<BaseItem> e) { OnProviderRefreshProgress(sender, new GenericEventArgs<Tuple<BaseItem, double>>(new Tuple<BaseItem, double>(e.Argument, 100))); + + _lastProgressMessageTimes.TryRemove(e.Argument.Id, out DateTime removed); } private static bool EnableRefreshMessage(BaseItem item) diff --git a/Emby.Server.Implementations/EntryPoints/RecordingNotifier.cs b/Emby.Server.Implementations/EntryPoints/RecordingNotifier.cs index 824bb85f44..e0ca02d986 100644 --- a/Emby.Server.Implementations/EntryPoints/RecordingNotifier.cs +++ b/Emby.Server.Implementations/EntryPoints/RecordingNotifier.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System; diff --git a/Emby.Server.Implementations/EntryPoints/UdpServerEntryPoint.cs b/Emby.Server.Implementations/EntryPoints/UdpServerEntryPoint.cs index 9486874d58..2e72b18f57 100644 --- a/Emby.Server.Implementations/EntryPoints/UdpServerEntryPoint.cs +++ b/Emby.Server.Implementations/EntryPoints/UdpServerEntryPoint.cs @@ -1,3 +1,4 @@ +using System; using System.Net.Sockets; using System.Threading; using System.Threading.Tasks; @@ -29,7 +30,7 @@ namespace Emby.Server.Implementations.EntryPoints /// <summary> /// The UDP server. /// </summary> - private UdpServer _udpServer; + private UdpServer? _udpServer; private CancellationTokenSource _cancellationTokenSource = new CancellationTokenSource(); private bool _disposed = false; @@ -49,10 +50,12 @@ namespace Emby.Server.Implementations.EntryPoints /// <inheritdoc /> public Task RunAsync() { + CheckDisposed(); + try { - _udpServer = new UdpServer(_logger, _appHost, _config); - _udpServer.Start(PortNumber, _cancellationTokenSource.Token); + _udpServer = new UdpServer(_logger, _appHost, _config, PortNumber); + _udpServer.Start(_cancellationTokenSource.Token); } catch (SocketException ex) { @@ -62,6 +65,14 @@ namespace Emby.Server.Implementations.EntryPoints return Task.CompletedTask; } + private void CheckDisposed() + { + if (_disposed) + { + throw new ObjectDisposedException(this.GetType().Name); + } + } + /// <inheritdoc /> public void Dispose() { @@ -71,9 +82,8 @@ namespace Emby.Server.Implementations.EntryPoints } _cancellationTokenSource.Cancel(); - _udpServer.Dispose(); _cancellationTokenSource.Dispose(); - _cancellationTokenSource = null; + _udpServer?.Dispose(); _udpServer = null; _disposed = true; diff --git a/Emby.Server.Implementations/EntryPoints/UserDataChangeNotifier.cs b/Emby.Server.Implementations/EntryPoints/UserDataChangeNotifier.cs index 1989e9ed25..d3bcd5e13e 100644 --- a/Emby.Server.Implementations/EntryPoints/UserDataChangeNotifier.cs +++ b/Emby.Server.Implementations/EntryPoints/UserDataChangeNotifier.cs @@ -26,7 +26,7 @@ namespace Emby.Server.Implementations.EntryPoints private readonly Dictionary<Guid, List<BaseItem>> _changedItems = new Dictionary<Guid, List<BaseItem>>(); private readonly object _syncLock = new object(); - private Timer _updateTimer; + private Timer? _updateTimer; public UserDataChangeNotifier(IUserDataManager userDataManager, ISessionManager sessionManager, IUserManager userManager) { @@ -42,7 +42,7 @@ namespace Emby.Server.Implementations.EntryPoints return Task.CompletedTask; } - void OnUserDataManagerUserDataSaved(object sender, UserDataSaveEventArgs e) + private void OnUserDataManagerUserDataSaved(object? sender, UserDataSaveEventArgs e) { if (e.SaveReason == UserDataSaveReason.PlaybackProgress) { @@ -64,7 +64,7 @@ namespace Emby.Server.Implementations.EntryPoints _updateTimer.Change(UpdateDuration, Timeout.Infinite); } - if (!_changedItems.TryGetValue(e.UserId, out List<BaseItem> keys)) + if (!_changedItems.TryGetValue(e.UserId, out List<BaseItem>? keys)) { keys = new List<BaseItem>(); _changedItems[e.UserId] = keys; @@ -87,7 +87,7 @@ namespace Emby.Server.Implementations.EntryPoints } } - private void UpdateTimerCallback(object state) + private void UpdateTimerCallback(object? state) { lock (_syncLock) { diff --git a/Emby.Server.Implementations/HttpServer/Security/AuthService.cs b/Emby.Server.Implementations/HttpServer/Security/AuthService.cs index 68d981ad1f..9afabf5272 100644 --- a/Emby.Server.Implementations/HttpServer/Security/AuthService.cs +++ b/Emby.Server.Implementations/HttpServer/Security/AuthService.cs @@ -1,6 +1,7 @@ #pragma warning disable CS1591 using Jellyfin.Data.Enums; +using MediaBrowser.Controller.Authentication; using MediaBrowser.Controller.Net; using Microsoft.AspNetCore.Http; @@ -19,12 +20,18 @@ namespace Emby.Server.Implementations.HttpServer.Security public AuthorizationInfo Authenticate(HttpRequest request) { var auth = _authorizationContext.GetAuthorizationInfo(request); - if (auth?.User == null) + + if (!auth.HasToken) + { + throw new AuthenticationException("Request does not contain a token."); + } + + if (!auth.IsAuthenticated) { - return null; + throw new SecurityException("Invalid token."); } - if (auth.User.HasPermission(PermissionKind.IsDisabled)) + if (auth.User?.HasPermission(PermissionKind.IsDisabled) ?? false) { throw new SecurityException("User account has been disabled."); } diff --git a/Emby.Server.Implementations/HttpServer/Security/AuthorizationContext.cs b/Emby.Server.Implementations/HttpServer/Security/AuthorizationContext.cs index 4b407dd9d2..b2625a68c9 100644 --- a/Emby.Server.Implementations/HttpServer/Security/AuthorizationContext.cs +++ b/Emby.Server.Implementations/HttpServer/Security/AuthorizationContext.cs @@ -2,8 +2,8 @@ using System; using System.Collections.Generic; -using System.Linq; using System.Net; +using Jellyfin.Extensions; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Net; using MediaBrowser.Controller.Security; @@ -27,7 +27,7 @@ namespace Emby.Server.Implementations.HttpServer.Security { if (requestContext.Request.HttpContext.Items.TryGetValue("AuthorizationInfo", out var cached)) { - return (AuthorizationInfo)cached; + return (AuthorizationInfo)cached!; // Cache should never contain null } return GetAuthorization(requestContext); @@ -36,8 +36,7 @@ namespace Emby.Server.Implementations.HttpServer.Security public AuthorizationInfo GetAuthorizationInfo(HttpRequest requestContext) { var auth = GetAuthorizationDictionary(requestContext); - var (authInfo, _) = - GetAuthorizationInfoFromDictionary(auth, requestContext.Headers, requestContext.Query); + var authInfo = GetAuthorizationInfoFromDictionary(auth, requestContext.Headers, requestContext.Query); return authInfo; } @@ -49,28 +48,22 @@ namespace Emby.Server.Implementations.HttpServer.Security private AuthorizationInfo GetAuthorization(HttpContext httpReq) { var auth = GetAuthorizationDictionary(httpReq); - var (authInfo, originalAuthInfo) = - GetAuthorizationInfoFromDictionary(auth, httpReq.Request.Headers, httpReq.Request.Query); - - if (originalAuthInfo != null) - { - httpReq.Request.HttpContext.Items["OriginalAuthenticationInfo"] = originalAuthInfo; - } + var authInfo = GetAuthorizationInfoFromDictionary(auth, httpReq.Request.Headers, httpReq.Request.Query); httpReq.Request.HttpContext.Items["AuthorizationInfo"] = authInfo; return authInfo; } - private (AuthorizationInfo authInfo, AuthenticationInfo originalAuthenticationInfo) GetAuthorizationInfoFromDictionary( - in Dictionary<string, string> auth, + private AuthorizationInfo GetAuthorizationInfoFromDictionary( + in Dictionary<string, string>? auth, in IHeaderDictionary headers, in IQueryCollection queryString) { - string deviceId = null; - string device = null; - string client = null; - string version = null; - string token = null; + string? deviceId = null; + string? device = null; + string? client = null; + string? version = null; + string? token = null; if (auth != null) { @@ -108,88 +101,104 @@ namespace Emby.Server.Implementations.HttpServer.Security Device = device, DeviceId = deviceId, Version = version, - Token = token + Token = token, + IsAuthenticated = false, + HasToken = false }; - AuthenticationInfo originalAuthenticationInfo = null; - if (!string.IsNullOrWhiteSpace(token)) + if (string.IsNullOrWhiteSpace(token)) { - var result = _authRepo.Get(new AuthenticationInfoQuery - { - AccessToken = token - }); + // Request doesn't contain a token. + return authInfo; + } - originalAuthenticationInfo = result.Items.Count > 0 ? result.Items[0] : null; + authInfo.HasToken = true; + var result = _authRepo.Get(new AuthenticationInfoQuery + { + AccessToken = token + }); - if (originalAuthenticationInfo != null) - { - var updateToken = false; + if (result.Items.Count > 0) + { + authInfo.IsAuthenticated = true; + } - // TODO: Remove these checks for IsNullOrWhiteSpace - if (string.IsNullOrWhiteSpace(authInfo.Client)) - { - authInfo.Client = originalAuthenticationInfo.AppName; - } + var originalAuthenticationInfo = result.Items.Count > 0 ? result.Items[0] : null; - if (string.IsNullOrWhiteSpace(authInfo.DeviceId)) - { - authInfo.DeviceId = originalAuthenticationInfo.DeviceId; - } + if (originalAuthenticationInfo != null) + { + var updateToken = false; - // Temporary. TODO - allow clients to specify that the token has been shared with a casting device - var allowTokenInfoUpdate = authInfo.Client == null || authInfo.Client.IndexOf("chromecast", StringComparison.OrdinalIgnoreCase) == -1; + // TODO: Remove these checks for IsNullOrWhiteSpace + if (string.IsNullOrWhiteSpace(authInfo.Client)) + { + authInfo.Client = originalAuthenticationInfo.AppName; + } - if (string.IsNullOrWhiteSpace(authInfo.Device)) - { - authInfo.Device = originalAuthenticationInfo.DeviceName; - } - else if (!string.Equals(authInfo.Device, originalAuthenticationInfo.DeviceName, StringComparison.OrdinalIgnoreCase)) - { - if (allowTokenInfoUpdate) - { - updateToken = true; - originalAuthenticationInfo.DeviceName = authInfo.Device; - } - } + if (string.IsNullOrWhiteSpace(authInfo.DeviceId)) + { + authInfo.DeviceId = originalAuthenticationInfo.DeviceId; + } - if (string.IsNullOrWhiteSpace(authInfo.Version)) - { - authInfo.Version = originalAuthenticationInfo.AppVersion; - } - else if (!string.Equals(authInfo.Version, originalAuthenticationInfo.AppVersion, StringComparison.OrdinalIgnoreCase)) + // Temporary. TODO - allow clients to specify that the token has been shared with a casting device + var allowTokenInfoUpdate = authInfo.Client == null || !authInfo.Client.Contains("chromecast", StringComparison.OrdinalIgnoreCase); + + if (string.IsNullOrWhiteSpace(authInfo.Device)) + { + authInfo.Device = originalAuthenticationInfo.DeviceName; + } + else if (!string.Equals(authInfo.Device, originalAuthenticationInfo.DeviceName, StringComparison.OrdinalIgnoreCase)) + { + if (allowTokenInfoUpdate) { - if (allowTokenInfoUpdate) - { - updateToken = true; - originalAuthenticationInfo.AppVersion = authInfo.Version; - } + updateToken = true; + originalAuthenticationInfo.DeviceName = authInfo.Device; } + } - if ((DateTime.UtcNow - originalAuthenticationInfo.DateLastActivity).TotalMinutes > 3) + if (string.IsNullOrWhiteSpace(authInfo.Version)) + { + authInfo.Version = originalAuthenticationInfo.AppVersion; + } + else if (!string.Equals(authInfo.Version, originalAuthenticationInfo.AppVersion, StringComparison.OrdinalIgnoreCase)) + { + if (allowTokenInfoUpdate) { - originalAuthenticationInfo.DateLastActivity = DateTime.UtcNow; updateToken = true; + originalAuthenticationInfo.AppVersion = authInfo.Version; } + } - if (!originalAuthenticationInfo.UserId.Equals(Guid.Empty)) - { - authInfo.User = _userManager.GetUserById(originalAuthenticationInfo.UserId); + if ((DateTime.UtcNow - originalAuthenticationInfo.DateLastActivity).TotalMinutes > 3) + { + originalAuthenticationInfo.DateLastActivity = DateTime.UtcNow; + updateToken = true; + } - if (authInfo.User != null && !string.Equals(authInfo.User.Username, originalAuthenticationInfo.UserName, StringComparison.OrdinalIgnoreCase)) - { - originalAuthenticationInfo.UserName = authInfo.User.Username; - updateToken = true; - } - } + if (!originalAuthenticationInfo.UserId.Equals(Guid.Empty)) + { + authInfo.User = _userManager.GetUserById(originalAuthenticationInfo.UserId); - if (updateToken) + if (authInfo.User != null && !string.Equals(authInfo.User.Username, originalAuthenticationInfo.UserName, StringComparison.OrdinalIgnoreCase)) { - _authRepo.Update(originalAuthenticationInfo); + originalAuthenticationInfo.UserName = authInfo.User.Username; + updateToken = true; } + + authInfo.IsApiKey = false; + } + else + { + authInfo.IsApiKey = true; + } + + if (updateToken) + { + _authRepo.Update(originalAuthenticationInfo); } } - return (authInfo, originalAuthenticationInfo); + return authInfo; } /// <summary> @@ -197,7 +206,7 @@ namespace Emby.Server.Implementations.HttpServer.Security /// </summary> /// <param name="httpReq">The HTTP req.</param> /// <returns>Dictionary{System.StringSystem.String}.</returns> - private Dictionary<string, string> GetAuthorizationDictionary(HttpContext httpReq) + private Dictionary<string, string>? GetAuthorizationDictionary(HttpContext httpReq) { var auth = httpReq.Request.Headers["X-Emby-Authorization"]; @@ -206,7 +215,7 @@ namespace Emby.Server.Implementations.HttpServer.Security auth = httpReq.Request.Headers[HeaderNames.Authorization]; } - return GetAuthorization(auth); + return GetAuthorization(auth.Count > 0 ? auth[0] : null); } /// <summary> @@ -214,7 +223,7 @@ namespace Emby.Server.Implementations.HttpServer.Security /// </summary> /// <param name="httpReq">The HTTP req.</param> /// <returns>Dictionary{System.StringSystem.String}.</returns> - private Dictionary<string, string> GetAuthorizationDictionary(HttpRequest httpReq) + private Dictionary<string, string>? GetAuthorizationDictionary(HttpRequest httpReq) { var auth = httpReq.Headers["X-Emby-Authorization"]; @@ -223,7 +232,7 @@ namespace Emby.Server.Implementations.HttpServer.Security auth = httpReq.Headers[HeaderNames.Authorization]; } - return GetAuthorization(auth); + return GetAuthorization(auth.Count > 0 ? auth[0] : null); } /// <summary> @@ -231,43 +240,43 @@ namespace Emby.Server.Implementations.HttpServer.Security /// </summary> /// <param name="authorizationHeader">The authorization header.</param> /// <returns>Dictionary{System.StringSystem.String}.</returns> - private Dictionary<string, string> GetAuthorization(string authorizationHeader) + private Dictionary<string, string>? GetAuthorization(ReadOnlySpan<char> authorizationHeader) { if (authorizationHeader == null) { return null; } - var parts = authorizationHeader.Split(new[] { ' ' }, 2); + var firstSpace = authorizationHeader.IndexOf(' '); - // There should be at least to parts - if (parts.Length != 2) + // There should be at least two parts + if (firstSpace == -1) { return null; } - var acceptedNames = new[] { "MediaBrowser", "Emby" }; + var name = authorizationHeader[..firstSpace]; - // It has to be a digest request - if (!acceptedNames.Contains(parts[0], StringComparer.OrdinalIgnoreCase)) + if (!name.Equals("MediaBrowser", StringComparison.OrdinalIgnoreCase) + && !name.Equals("Emby", StringComparison.OrdinalIgnoreCase)) { return null; } - // Remove uptil the first space - authorizationHeader = parts[1]; - parts = authorizationHeader.Split(','); + authorizationHeader = authorizationHeader[(firstSpace + 1)..]; var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase); - foreach (var item in parts) + foreach (var item in authorizationHeader.Split(',')) { - var param = item.Trim().Split(new[] { '=' }, 2); + var trimmedItem = item.Trim(); + var firstEqualsSign = trimmedItem.IndexOf('='); - if (param.Length == 2) + if (firstEqualsSign > 0) { - var value = NormalizeValue(param[1].Trim(new[] { '"' })); - result.Add(param[0], value); + var key = trimmedItem[..firstEqualsSign].ToString(); + var value = NormalizeValue(trimmedItem[(firstEqualsSign + 1)..].Trim('"').ToString()); + result[key] = value; } } diff --git a/Emby.Server.Implementations/HttpServer/Security/SessionContext.cs b/Emby.Server.Implementations/HttpServer/Security/SessionContext.cs index 86914dea20..c375f36ce4 100644 --- a/Emby.Server.Implementations/HttpServer/Security/SessionContext.cs +++ b/Emby.Server.Implementations/HttpServer/Security/SessionContext.cs @@ -28,7 +28,7 @@ namespace Emby.Server.Implementations.HttpServer.Security var authorization = _authContext.GetAuthorizationInfo(requestContext); var user = authorization.User; - return _sessionManager.LogSessionActivity(authorization.Client, authorization.Version, authorization.DeviceId, authorization.Device, requestContext.GetNormalizedRemoteIp(), user); + return _sessionManager.LogSessionActivity(authorization.Client, authorization.Version, authorization.DeviceId, authorization.Device, requestContext.GetNormalizedRemoteIp().ToString(), user); } public SessionInfo GetSession(object requestContext) @@ -36,16 +36,16 @@ namespace Emby.Server.Implementations.HttpServer.Security return GetSession((HttpContext)requestContext); } - public User GetUser(HttpContext requestContext) + public User? GetUser(HttpContext requestContext) { var session = GetSession(requestContext); return session == null || session.UserId.Equals(Guid.Empty) ? null : _userManager.GetUserById(session.UserId); } - public User GetUser(object requestContext) + public User? GetUser(object requestContext) { - return GetUser((HttpContext)requestContext); + return GetUser(((HttpRequest)requestContext).HttpContext); } } } diff --git a/Emby.Server.Implementations/HttpServer/WebSocketConnection.cs b/Emby.Server.Implementations/HttpServer/WebSocketConnection.cs index fed2addf80..5d38ea0ca6 100644 --- a/Emby.Server.Implementations/HttpServer/WebSocketConnection.cs +++ b/Emby.Server.Implementations/HttpServer/WebSocketConnection.cs @@ -1,14 +1,13 @@ -#nullable enable - using System; using System.Buffers; using System.IO.Pipelines; using System.Net; using System.Net.WebSockets; +using System.Text; using System.Text.Json; using System.Threading; using System.Threading.Tasks; -using MediaBrowser.Common.Json; +using Jellyfin.Extensions.Json; using MediaBrowser.Controller.Net; using MediaBrowser.Model.Net; using MediaBrowser.Model.Session; @@ -55,7 +54,7 @@ namespace Emby.Server.Implementations.HttpServer RemoteEndPoint = remoteEndPoint; QueryString = query; - _jsonOptions = JsonDefaults.GetOptions(); + _jsonOptions = JsonDefaults.Options; LastActivityDate = DateTime.Now; } @@ -138,7 +137,7 @@ namespace Emby.Server.Implementations.HttpServer writer.Advance(bytesRead); // Make the data available to the PipeReader - FlushResult flushResult = await writer.FlushAsync().ConfigureAwait(false); + FlushResult flushResult = await writer.FlushAsync(cancellationToken).ConfigureAwait(false); if (flushResult.IsCompleted) { // The PipeReader stopped reading @@ -181,32 +180,16 @@ namespace Emby.Server.Implementations.HttpServer } WebSocketMessage<object>? stub; + long bytesConsumed = 0; try { - - if (buffer.IsSingleSegment) - { - stub = JsonSerializer.Deserialize<WebSocketMessage<object>>(buffer.FirstSpan, _jsonOptions); - } - else - { - var buf = ArrayPool<byte>.Shared.Rent(Convert.ToInt32(buffer.Length)); - try - { - buffer.CopyTo(buf); - stub = JsonSerializer.Deserialize<WebSocketMessage<object>>(buf, _jsonOptions); - } - finally - { - ArrayPool<byte>.Shared.Return(buf); - } - } + stub = DeserializeWebSocketMessage(buffer, out bytesConsumed); } catch (JsonException ex) { // Tell the PipeReader how much of the buffer we have consumed reader.AdvanceTo(buffer.End); - _logger.LogError(ex, "Error processing web socket message"); + _logger.LogError(ex, "Error processing web socket message: {Data}", Encoding.UTF8.GetString(buffer)); return; } @@ -217,27 +200,34 @@ namespace Emby.Server.Implementations.HttpServer } // Tell the PipeReader how much of the buffer we have consumed - reader.AdvanceTo(buffer.End); + reader.AdvanceTo(buffer.GetPosition(bytesConsumed)); _logger.LogDebug("WS {IP} received message: {@Message}", RemoteEndPoint, stub); - var info = new WebSocketMessageInfo - { - MessageType = stub.MessageType, - Data = stub.Data?.ToString(), // Data can be null - Connection = this - }; - - if (info.MessageType == SessionMessageType.KeepAlive) + if (stub.MessageType == SessionMessageType.KeepAlive) { await SendKeepAliveResponse().ConfigureAwait(false); } else { - await OnReceive(info).ConfigureAwait(false); + await OnReceive( + new WebSocketMessageInfo + { + MessageType = stub.MessageType, + Data = stub.Data?.ToString(), // Data can be null + Connection = this + }).ConfigureAwait(false); } } + internal WebSocketMessage<object>? DeserializeWebSocketMessage(ReadOnlySequence<byte> bytes, out long bytesConsumed) + { + var jsonReader = new Utf8JsonReader(bytes); + var ret = JsonSerializer.Deserialize<WebSocketMessage<object>>(ref jsonReader, _jsonOptions); + bytesConsumed = jsonReader.BytesConsumed; + return ret; + } + private Task SendKeepAliveResponse() { LastKeepAliveDate = DateTime.UtcNow; diff --git a/Emby.Server.Implementations/HttpServer/WebSocketManager.cs b/Emby.Server.Implementations/HttpServer/WebSocketManager.cs index 71ece80a75..861c0a95e3 100644 --- a/Emby.Server.Implementations/HttpServer/WebSocketManager.cs +++ b/Emby.Server.Implementations/HttpServer/WebSocketManager.cs @@ -1,10 +1,12 @@ +#nullable disable + #pragma warning disable CS1591 using System; using System.Collections.Generic; +using System.Linq; using System.Net.WebSockets; using System.Threading.Tasks; -using Jellyfin.Data.Events; using MediaBrowser.Controller.Net; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; @@ -13,32 +15,27 @@ namespace Emby.Server.Implementations.HttpServer { public class WebSocketManager : IWebSocketManager { - private readonly Lazy<IEnumerable<IWebSocketListener>> _webSocketListeners; + private readonly IWebSocketListener[] _webSocketListeners; + private readonly IAuthService _authService; private readonly ILogger<WebSocketManager> _logger; private readonly ILoggerFactory _loggerFactory; - private bool _disposed = false; - public WebSocketManager( - Lazy<IEnumerable<IWebSocketListener>> webSocketListeners, + IAuthService authService, + IEnumerable<IWebSocketListener> webSocketListeners, ILogger<WebSocketManager> logger, ILoggerFactory loggerFactory) { - _webSocketListeners = webSocketListeners; + _webSocketListeners = webSocketListeners.ToArray(); + _authService = authService; _logger = logger; _loggerFactory = loggerFactory; } - public event EventHandler<GenericEventArgs<IWebSocketConnection>> WebSocketConnected; - /// <inheritdoc /> public async Task WebSocketRequestHandler(HttpContext context) { - if (_disposed) - { - return; - } - + _ = _authService.Authenticate(context.Request); try { _logger.LogInformation("WS {IP} request", context.Connection.RemoteIpAddress); @@ -54,7 +51,13 @@ namespace Emby.Server.Implementations.HttpServer OnReceive = ProcessWebSocketMessageReceived }; - WebSocketConnected?.Invoke(this, new GenericEventArgs<IWebSocketConnection>(connection)); + var tasks = new Task[_webSocketListeners.Length]; + for (var i = 0; i < _webSocketListeners.Length; ++i) + { + tasks[i] = _webSocketListeners[i].ProcessWebSocketConnectedAsync(connection); + } + + await Task.WhenAll(tasks).ConfigureAwait(false); await connection.ProcessAsync().ConfigureAwait(false); _logger.LogInformation("WS {IP} closed", context.Connection.RemoteIpAddress); @@ -75,21 +78,13 @@ namespace Emby.Server.Implementations.HttpServer /// <param name="result">The result.</param> private Task ProcessWebSocketMessageReceived(WebSocketMessageInfo result) { - if (_disposed) + var tasks = new Task[_webSocketListeners.Length]; + for (var i = 0; i < _webSocketListeners.Length; ++i) { - return Task.CompletedTask; - } - - IEnumerable<Task> GetTasks() - { - var listeners = _webSocketListeners.Value; - foreach (var x in listeners) - { - yield return x.ProcessMessageAsync(result); - } + tasks[i] = _webSocketListeners[i].ProcessMessageAsync(result); } - return Task.WhenAll(GetTasks()); + return Task.WhenAll(tasks); } } } diff --git a/Emby.Server.Implementations/IO/FileRefresher.cs b/Emby.Server.Implementations/IO/FileRefresher.cs index 7435e9d0bf..47a83d77ce 100644 --- a/Emby.Server.Implementations/IO/FileRefresher.cs +++ b/Emby.Server.Implementations/IO/FileRefresher.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System; diff --git a/Emby.Server.Implementations/IO/IsoManager.cs b/Emby.Server.Implementations/IO/IsoManager.cs deleted file mode 100644 index 94e92c2a6a..0000000000 --- a/Emby.Server.Implementations/IO/IsoManager.cs +++ /dev/null @@ -1,67 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Globalization; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using MediaBrowser.Model.IO; - -namespace Emby.Server.Implementations.IO -{ - /// <summary> - /// Class IsoManager. - /// </summary> - public class IsoManager : IIsoManager - { - /// <summary> - /// The _mounters. - /// </summary> - private readonly List<IIsoMounter> _mounters = new List<IIsoMounter>(); - - /// <summary> - /// Mounts the specified iso path. - /// </summary> - /// <param name="isoPath">The iso path.</param> - /// <param name="cancellationToken">The cancellation token.</param> - /// <returns><see creaf="IsoMount" />.</returns> - public Task<IIsoMount> Mount(string isoPath, CancellationToken cancellationToken) - { - if (string.IsNullOrEmpty(isoPath)) - { - throw new ArgumentNullException(nameof(isoPath)); - } - - var mounter = _mounters.FirstOrDefault(i => i.CanMount(isoPath)); - - if (mounter == null) - { - throw new ArgumentException( - string.Format( - CultureInfo.InvariantCulture, - "No mounters are able to mount {0}", - isoPath)); - } - - return mounter.Mount(isoPath, cancellationToken); - } - - /// <summary> - /// Determines whether this instance can mount the specified path. - /// </summary> - /// <param name="path">The path.</param> - /// <returns><c>true</c> if this instance can mount the specified path; otherwise, <c>false</c>.</returns> - public bool CanMount(string path) - { - return _mounters.Any(i => i.CanMount(path)); - } - - /// <summary> - /// Adds the parts. - /// </summary> - /// <param name="mounters">The mounters.</param> - public void AddParts(IEnumerable<IIsoMounter> mounters) - { - _mounters.AddRange(mounters); - } - } -} diff --git a/Emby.Server.Implementations/IO/LibraryMonitor.cs b/Emby.Server.Implementations/IO/LibraryMonitor.cs index 3353fae9d8..aa80bccd72 100644 --- a/Emby.Server.Implementations/IO/LibraryMonitor.cs +++ b/Emby.Server.Implementations/IO/LibraryMonitor.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System; diff --git a/Emby.Server.Implementations/IO/ManagedFileSystem.cs b/Emby.Server.Implementations/IO/ManagedFileSystem.cs index 3cb025111d..7c3c7da230 100644 --- a/Emby.Server.Implementations/IO/ManagedFileSystem.cs +++ b/Emby.Server.Implementations/IO/ManagedFileSystem.cs @@ -2,16 +2,15 @@ using System; using System.Collections.Generic; -using System.Diagnostics; using System.Globalization; using System.IO; using System.Linq; -using System.Text; +using System.Runtime.InteropServices; +using Jellyfin.Extensions; using MediaBrowser.Common.Configuration; using MediaBrowser.Model.IO; using MediaBrowser.Model.System; using Microsoft.Extensions.Logging; -using OperatingSystem = MediaBrowser.Common.System.OperatingSystem; namespace Emby.Server.Implementations.IO { @@ -24,7 +23,7 @@ namespace Emby.Server.Implementations.IO private readonly List<IShortcutHandler> _shortcutHandlers = new List<IShortcutHandler>(); private readonly string _tempPath; - private readonly bool _isEnvironmentCaseInsensitive; + private static readonly bool _isEnvironmentCaseInsensitive = OperatingSystem.IsWindows(); public ManagedFileSystem( ILogger<ManagedFileSystem> logger, @@ -32,8 +31,6 @@ namespace Emby.Server.Implementations.IO { Logger = logger; _tempPath = applicationPaths.TempDirectory; - - _isEnvironmentCaseInsensitive = OperatingSystem.Id == OperatingSystemId.Windows; } public virtual void AddShortcutHandler(IShortcutHandler handler) @@ -64,7 +61,7 @@ namespace Emby.Server.Implementations.IO /// <param name="filename">The filename.</param> /// <returns>System.String.</returns> /// <exception cref="ArgumentNullException">filename</exception> - public virtual string ResolveShortcut(string filename) + public virtual string? ResolveShortcut(string filename) { if (string.IsNullOrEmpty(filename)) { @@ -72,7 +69,7 @@ namespace Emby.Server.Implementations.IO } var extension = Path.GetExtension(filename); - var handler = _shortcutHandlers.FirstOrDefault(i => string.Equals(extension, i.Extension, StringComparison.OrdinalIgnoreCase)); + var handler = _shortcutHandlers.Find(i => string.Equals(extension, i.Extension, StringComparison.OrdinalIgnoreCase)); return handler?.Resolve(filename); } @@ -246,16 +243,23 @@ namespace Emby.Server.Implementations.IO { result.Length = fileInfo.Length; - // Issue #2354 get the size of files behind symbolic links - if (fileInfo.Attributes.HasFlag(FileAttributes.ReparsePoint)) + // Issue #2354 get the size of files behind symbolic links. Also Enum.HasFlag is bad as it boxes! + if ((fileInfo.Attributes & FileAttributes.ReparsePoint) == FileAttributes.ReparsePoint) { - using (Stream thisFileStream = File.OpenRead(fileInfo.FullName)) + try { - result.Length = thisFileStream.Length; + using (Stream thisFileStream = File.OpenRead(fileInfo.FullName)) + { + result.Length = thisFileStream.Length; + } + } + catch (FileNotFoundException ex) + { + // Dangling symlinks cannot be detected before opening the file unfortunately... + Logger.LogError(ex, "Reading the file size of the symlink at {Path} failed. Marking the file as not existing.", fileInfo.FullName); + result.Exists = false; } } - - result.DirectoryName = fileInfo.DirectoryName; } result.CreationTimeUtc = GetCreationTimeUtc(info); @@ -294,16 +298,37 @@ namespace Emby.Server.Implementations.IO /// <param name="filename">The filename.</param> /// <returns>System.String.</returns> /// <exception cref="ArgumentNullException">The filename is null.</exception> - public virtual string GetValidFilename(string filename) + public string GetValidFilename(string filename) { - var builder = new StringBuilder(filename); - - foreach (var c in Path.GetInvalidFileNameChars()) + var invalid = Path.GetInvalidFileNameChars(); + var first = filename.IndexOfAny(invalid); + if (first == -1) { - builder = builder.Replace(c, ' '); + // Fast path for clean strings + return filename; } - return builder.ToString(); + return string.Create( + filename.Length, + (filename, invalid, first), + (chars, state) => + { + state.filename.AsSpan().CopyTo(chars); + + chars[state.first++] = ' '; + + var len = chars.Length; + foreach (var c in state.invalid) + { + for (int i = state.first; i < len; i++) + { + if (chars[i] == c) + { + chars[i] = ' '; + } + } + } + }); } /// <summary> @@ -376,7 +401,7 @@ namespace Emby.Server.Implementations.IO public virtual void SetHidden(string path, bool isHidden) { - if (OperatingSystem.Id != OperatingSystemId.Windows) + if (!OperatingSystem.IsWindows()) { return; } @@ -400,7 +425,7 @@ namespace Emby.Server.Implementations.IO public virtual void SetAttributes(string path, bool isHidden, bool isReadOnly) { - if (OperatingSystem.Id != OperatingSystemId.Windows) + if (!OperatingSystem.IsWindows()) { return; } @@ -487,26 +512,9 @@ namespace Emby.Server.Implementations.IO throw new ArgumentNullException(nameof(path)); } - var separatorChar = Path.DirectorySeparatorChar; - - return path.IndexOf(parentPath.TrimEnd(separatorChar) + separatorChar, StringComparison.OrdinalIgnoreCase) != -1; - } - - public virtual bool IsRootPath(string path) - { - if (string.IsNullOrEmpty(path)) - { - throw new ArgumentNullException(nameof(path)); - } - - var parent = Path.GetDirectoryName(path); - - if (!string.IsNullOrEmpty(parent)) - { - return false; - } - - return true; + return path.Contains( + Path.TrimEndingDirectorySeparator(parentPath) + Path.DirectorySeparatorChar, + _isEnvironmentCaseInsensitive ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal); } public virtual string NormalizePath(string path) @@ -521,7 +529,7 @@ namespace Emby.Server.Implementations.IO return path; } - return path.TrimEnd(Path.DirectorySeparatorChar); + return Path.TrimEndingDirectorySeparator(path); } public virtual bool AreEqual(string path1, string path2) @@ -536,7 +544,10 @@ namespace Emby.Server.Implementations.IO return false; } - return string.Equals(NormalizePath(path1), NormalizePath(path2), StringComparison.OrdinalIgnoreCase); + return string.Equals( + NormalizePath(path1), + NormalizePath(path2), + _isEnvironmentCaseInsensitive ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal); } public virtual string GetFileNameWithoutExtension(FileSystemMetadata info) @@ -582,9 +593,7 @@ namespace Emby.Server.Implementations.IO public virtual IEnumerable<FileSystemMetadata> GetDirectories(string path, bool recursive = false) { - var searchOption = recursive ? SearchOption.AllDirectories : SearchOption.TopDirectoryOnly; - - return ToMetadata(new DirectoryInfo(path).EnumerateDirectories("*", searchOption)); + return ToMetadata(new DirectoryInfo(path).EnumerateDirectories("*", GetEnumerationOptions(recursive))); } public virtual IEnumerable<FileSystemMetadata> GetFiles(string path, bool recursive = false) @@ -592,30 +601,30 @@ namespace Emby.Server.Implementations.IO return GetFiles(path, null, false, recursive); } - public virtual IEnumerable<FileSystemMetadata> GetFiles(string path, IReadOnlyList<string> extensions, bool enableCaseSensitiveExtensions, bool recursive = false) + public virtual IEnumerable<FileSystemMetadata> GetFiles(string path, IReadOnlyList<string>? extensions, bool enableCaseSensitiveExtensions, bool recursive = false) { - var searchOption = recursive ? SearchOption.AllDirectories : SearchOption.TopDirectoryOnly; + var enumerationOptions = GetEnumerationOptions(recursive); // On linux and osx the search pattern is case sensitive // If we're OK with case-sensitivity, and we're only filtering for one extension, then use the native method if ((enableCaseSensitiveExtensions || _isEnvironmentCaseInsensitive) && extensions != null && extensions.Count == 1) { - return ToMetadata(new DirectoryInfo(path).EnumerateFiles("*" + extensions[0], searchOption)); + return ToMetadata(new DirectoryInfo(path).EnumerateFiles("*" + extensions[0], enumerationOptions)); } - var files = new DirectoryInfo(path).EnumerateFiles("*", searchOption); + var files = new DirectoryInfo(path).EnumerateFiles("*", enumerationOptions); if (extensions != null && extensions.Count > 0) { files = files.Where(i => { - var ext = i.Extension; - if (ext == null) + var ext = i.Extension.AsSpan(); + if (ext.IsEmpty) { return false; } - return extensions.Contains(ext, StringComparer.OrdinalIgnoreCase); + return extensions.Contains(ext, StringComparison.OrdinalIgnoreCase); }); } @@ -625,10 +634,9 @@ namespace Emby.Server.Implementations.IO public virtual IEnumerable<FileSystemMetadata> GetFileSystemEntries(string path, bool recursive = false) { var directoryInfo = new DirectoryInfo(path); - var searchOption = recursive ? SearchOption.AllDirectories : SearchOption.TopDirectoryOnly; + var enumerationOptions = GetEnumerationOptions(recursive); - return ToMetadata(directoryInfo.EnumerateDirectories("*", searchOption)) - .Concat(ToMetadata(directoryInfo.EnumerateFiles("*", searchOption))); + return ToMetadata(directoryInfo.EnumerateFileSystemInfos("*", enumerationOptions)); } private IEnumerable<FileSystemMetadata> ToMetadata(IEnumerable<FileSystemInfo> infos) @@ -638,8 +646,7 @@ namespace Emby.Server.Implementations.IO public virtual IEnumerable<string> GetDirectoryPaths(string path, bool recursive = false) { - var searchOption = recursive ? SearchOption.AllDirectories : SearchOption.TopDirectoryOnly; - return Directory.EnumerateDirectories(path, "*", searchOption); + return Directory.EnumerateDirectories(path, "*", GetEnumerationOptions(recursive)); } public virtual IEnumerable<string> GetFilePaths(string path, bool recursive = false) @@ -647,30 +654,30 @@ namespace Emby.Server.Implementations.IO return GetFilePaths(path, null, false, recursive); } - public virtual IEnumerable<string> GetFilePaths(string path, string[] extensions, bool enableCaseSensitiveExtensions, bool recursive = false) + public virtual IEnumerable<string> GetFilePaths(string path, string[]? extensions, bool enableCaseSensitiveExtensions, bool recursive = false) { - var searchOption = recursive ? SearchOption.AllDirectories : SearchOption.TopDirectoryOnly; + var enumerationOptions = GetEnumerationOptions(recursive); // On linux and osx the search pattern is case sensitive // If we're OK with case-sensitivity, and we're only filtering for one extension, then use the native method if ((enableCaseSensitiveExtensions || _isEnvironmentCaseInsensitive) && extensions != null && extensions.Length == 1) { - return Directory.EnumerateFiles(path, "*" + extensions[0], searchOption); + return Directory.EnumerateFiles(path, "*" + extensions[0], enumerationOptions); } - var files = Directory.EnumerateFiles(path, "*", searchOption); + var files = Directory.EnumerateFiles(path, "*", enumerationOptions); if (extensions != null && extensions.Length > 0) { files = files.Where(i => { - var ext = Path.GetExtension(i); - if (ext == null) + var ext = Path.GetExtension(i.AsSpan()); + if (ext.IsEmpty) { return false; } - return extensions.Contains(ext, StringComparer.OrdinalIgnoreCase); + return extensions.Contains(ext, StringComparison.OrdinalIgnoreCase); }); } @@ -679,23 +686,18 @@ namespace Emby.Server.Implementations.IO public virtual IEnumerable<string> GetFileSystemEntryPaths(string path, bool recursive = false) { - var searchOption = recursive ? SearchOption.AllDirectories : SearchOption.TopDirectoryOnly; - return Directory.EnumerateFileSystemEntries(path, "*", searchOption); + return Directory.EnumerateFileSystemEntries(path, "*", GetEnumerationOptions(recursive)); } - private static void RunProcess(string path, string args, string workingDirectory) + private EnumerationOptions GetEnumerationOptions(bool recursive) { - using (var process = Process.Start(new ProcessStartInfo - { - Arguments = args, - FileName = path, - CreateNoWindow = true, - WorkingDirectory = workingDirectory, - WindowStyle = ProcessWindowStyle.Normal - })) + return new EnumerationOptions { - process.WaitForExit(); - } + RecurseSubdirectories = recursive, + IgnoreInaccessible = true, + // Don't skip any files. + AttributesToSkip = 0 + }; } } } diff --git a/Emby.Server.Implementations/IO/MbLinkShortcutHandler.cs b/Emby.Server.Implementations/IO/MbLinkShortcutHandler.cs index e6696b8c4c..76c58d5dcd 100644 --- a/Emby.Server.Implementations/IO/MbLinkShortcutHandler.cs +++ b/Emby.Server.Implementations/IO/MbLinkShortcutHandler.cs @@ -17,7 +17,7 @@ namespace Emby.Server.Implementations.IO public string Extension => ".mblink"; - public string Resolve(string shortcutPath) + public string? Resolve(string shortcutPath) { if (string.IsNullOrEmpty(shortcutPath)) { diff --git a/Emby.Server.Implementations/IO/StreamHelper.cs b/Emby.Server.Implementations/IO/StreamHelper.cs index c16ebd61b7..e4f5f4cf0b 100644 --- a/Emby.Server.Implementations/IO/StreamHelper.cs +++ b/Emby.Server.Implementations/IO/StreamHelper.cs @@ -11,7 +11,7 @@ namespace Emby.Server.Implementations.IO { public class StreamHelper : IStreamHelper { - public async Task CopyToAsync(Stream source, Stream destination, int bufferSize, Action onStarted, CancellationToken cancellationToken) + public async Task CopyToAsync(Stream source, Stream destination, int bufferSize, Action? onStarted, CancellationToken cancellationToken) { byte[] buffer = ArrayPool<byte>.Shared.Rent(bufferSize); try diff --git a/Emby.Server.Implementations/IStartupOptions.cs b/Emby.Server.Implementations/IStartupOptions.cs index 4bef59543f..a430b9e720 100644 --- a/Emby.Server.Implementations/IStartupOptions.cs +++ b/Emby.Server.Implementations/IStartupOptions.cs @@ -1,7 +1,5 @@ #pragma warning disable CS1591 -using System; - namespace Emby.Server.Implementations { public interface IStartupOptions @@ -9,7 +7,7 @@ namespace Emby.Server.Implementations /// <summary> /// Gets the value of the --ffmpeg command line option. /// </summary> - string FFmpegPath { get; } + string? FFmpegPath { get; } /// <summary> /// Gets the value of the --service command line option. @@ -19,21 +17,21 @@ namespace Emby.Server.Implementations /// <summary> /// Gets the value of the --package-name command line option. /// </summary> - string PackageName { get; } + string? PackageName { get; } /// <summary> /// Gets the value of the --restartpath command line option. /// </summary> - string RestartPath { get; } + string? RestartPath { get; } /// <summary> /// Gets the value of the --restartargs command line option. /// </summary> - string RestartArgs { get; } + string? RestartArgs { get; } /// <summary> /// Gets the value of the --published-server-url command line option. /// </summary> - Uri PublishedServerUrl { get; } + string? PublishedServerUrl { get; } } } diff --git a/Emby.Server.Implementations/Images/ArtistImageProvider.cs b/Emby.Server.Implementations/Images/ArtistImageProvider.cs index bf57382ed4..e96b64595c 100644 --- a/Emby.Server.Implementations/Images/ArtistImageProvider.cs +++ b/Emby.Server.Implementations/Images/ArtistImageProvider.cs @@ -2,20 +2,12 @@ using System; using System.Collections.Generic; -using System.Linq; -using Emby.Server.Implementations.Images; using MediaBrowser.Common.Configuration; using MediaBrowser.Controller.Drawing; -using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.Audio; -using MediaBrowser.Controller.Entities.Movies; -using MediaBrowser.Controller.Entities.TV; -using MediaBrowser.Controller.Playlists; using MediaBrowser.Controller.Providers; -using MediaBrowser.Model.Entities; using MediaBrowser.Model.IO; -using MediaBrowser.Model.Querying; namespace Emby.Server.Implementations.Images { @@ -42,7 +34,7 @@ namespace Emby.Server.Implementations.Images // return _libraryManager.GetItemList(new InternalItemsQuery // { // ArtistIds = new[] { item.Id }, - // IncludeItemTypes = new[] { typeof(MusicAlbum).Name }, + // IncludeItemTypes = new[] { nameof(MusicAlbum) }, // OrderBy = new[] { (ItemSortBy.Random, SortOrder.Ascending) }, // Limit = 4, // Recursive = true, diff --git a/Emby.Server.Implementations/Images/BaseDynamicImageProvider.cs b/Emby.Server.Implementations/Images/BaseDynamicImageProvider.cs index 5f7e51858a..833fb0b7a1 100644 --- a/Emby.Server.Implementations/Images/BaseDynamicImageProvider.cs +++ b/Emby.Server.Implementations/Images/BaseDynamicImageProvider.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System; @@ -191,7 +193,7 @@ namespace Emby.Server.Implementations.Images InputPaths = GetStripCollageImagePaths(primaryItem, items).ToArray() }; - if (options.InputPaths.Length == 0) + if (options.InputPaths.Count == 0) { return null; } diff --git a/Emby.Server.Implementations/Images/CollectionFolderImageProvider.cs b/Emby.Server.Implementations/Images/CollectionFolderImageProvider.cs index 161b4c4528..ff5f26ce09 100644 --- a/Emby.Server.Implementations/Images/CollectionFolderImageProvider.cs +++ b/Emby.Server.Implementations/Images/CollectionFolderImageProvider.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System; diff --git a/Emby.Server.Implementations/Images/DynamicImageProvider.cs b/Emby.Server.Implementations/Images/DynamicImageProvider.cs index 462eb03a80..900b3fd9c6 100644 --- a/Emby.Server.Implementations/Images/DynamicImageProvider.cs +++ b/Emby.Server.Implementations/Images/DynamicImageProvider.cs @@ -1,10 +1,11 @@ +#nullable disable + #pragma warning disable CS1591 using System; using System.Collections.Generic; using System.IO; using System.Linq; -using Emby.Server.Implementations.Images; using MediaBrowser.Common.Configuration; using MediaBrowser.Controller.Drawing; using MediaBrowser.Controller.Dto; diff --git a/Emby.Server.Implementations/Images/FolderImageProvider.cs b/Emby.Server.Implementations/Images/FolderImageProvider.cs index 0224ab32a0..859017f869 100644 --- a/Emby.Server.Implementations/Images/FolderImageProvider.cs +++ b/Emby.Server.Implementations/Images/FolderImageProvider.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System.Collections.Generic; diff --git a/Emby.Server.Implementations/Images/GenreImageProvider.cs b/Emby.Server.Implementations/Images/GenreImageProvider.cs index 1cd4cd66bd..6da431c68e 100644 --- a/Emby.Server.Implementations/Images/GenreImageProvider.cs +++ b/Emby.Server.Implementations/Images/GenreImageProvider.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System.Collections.Generic; @@ -42,7 +44,12 @@ namespace Emby.Server.Implementations.Images return _libraryManager.GetItemList(new InternalItemsQuery { Genres = new[] { item.Name }, - IncludeItemTypes = new[] { typeof(MusicAlbum).Name, typeof(MusicVideo).Name, typeof(Audio).Name }, + IncludeItemTypes = new[] + { + nameof(MusicAlbum), + nameof(MusicVideo), + nameof(Audio) + }, OrderBy = new[] { (ItemSortBy.Random, SortOrder.Ascending) }, Limit = 4, Recursive = true, @@ -77,7 +84,7 @@ namespace Emby.Server.Implementations.Images return _libraryManager.GetItemList(new InternalItemsQuery { Genres = new[] { item.Name }, - IncludeItemTypes = new[] { typeof(Series).Name, typeof(Movie).Name }, + IncludeItemTypes = new[] { nameof(Series), nameof(Movie) }, OrderBy = new[] { (ItemSortBy.Random, SortOrder.Ascending) }, Limit = 4, Recursive = true, diff --git a/Emby.Server.Implementations/Images/PlaylistImageProvider.cs b/Emby.Server.Implementations/Images/PlaylistImageProvider.cs index 0ce1b91e88..b8f0f0d65c 100644 --- a/Emby.Server.Implementations/Images/PlaylistImageProvider.cs +++ b/Emby.Server.Implementations/Images/PlaylistImageProvider.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System.Collections.Generic; @@ -29,9 +31,7 @@ namespace Emby.Server.Implementations.Images { var subItem = i.Item2; - var episode = subItem as Episode; - - if (episode != null) + if (subItem is Episode episode) { var series = episode.Series; if (series != null && series.HasImage(ImageType.Primary)) diff --git a/Emby.Server.Implementations/Library/CoreResolutionIgnoreRule.cs b/Emby.Server.Implementations/Library/CoreResolutionIgnoreRule.cs index 3380e29d48..c7d1139639 100644 --- a/Emby.Server.Implementations/Library/CoreResolutionIgnoreRule.cs +++ b/Emby.Server.Implementations/Library/CoreResolutionIgnoreRule.cs @@ -77,7 +77,7 @@ namespace Emby.Server.Implementations.Library if (parent != null) { // Don't resolve these into audio files - if (string.Equals(Path.GetFileNameWithoutExtension(filename), BaseItem.ThemeSongFilename, StringComparison.Ordinal) + if (Path.GetFileNameWithoutExtension(filename.AsSpan()).Equals(BaseItem.ThemeSongFilename, StringComparison.Ordinal) && _libraryManager.IsAudioFile(filename)) { return true; diff --git a/Emby.Server.Implementations/Library/ExclusiveLiveStream.cs b/Emby.Server.Implementations/Library/ExclusiveLiveStream.cs index 236453e805..6c65b58999 100644 --- a/Emby.Server.Implementations/Library/ExclusiveLiveStream.cs +++ b/Emby.Server.Implementations/Library/ExclusiveLiveStream.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System; diff --git a/Emby.Server.Implementations/Library/IgnorePatterns.cs b/Emby.Server.Implementations/Library/IgnorePatterns.cs index e30a675931..5384c04b3b 100644 --- a/Emby.Server.Implementations/Library/IgnorePatterns.cs +++ b/Emby.Server.Implementations/Library/IgnorePatterns.cs @@ -1,5 +1,3 @@ -#nullable enable - using System; using System.Linq; using DotNet.Globbing; diff --git a/Emby.Server.Implementations/Library/LibraryManager.cs b/Emby.Server.Implementations/Library/LibraryManager.cs index 00282b71a5..13fb8b2fd5 100644 --- a/Emby.Server.Implementations/Library/LibraryManager.cs +++ b/Emby.Server.Implementations/Library/LibraryManager.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System; @@ -6,6 +8,7 @@ using System.Globalization; using System.IO; using System.Linq; using System.Net; +using System.Net.Http; using System.Threading; using System.Threading.Tasks; using Emby.Naming.Audio; @@ -18,6 +21,7 @@ using Emby.Server.Implementations.Playlists; using Emby.Server.Implementations.ScheduledTasks; using Jellyfin.Data.Entities; using Jellyfin.Data.Enums; +using Jellyfin.Extensions; using MediaBrowser.Common.Extensions; using MediaBrowser.Common.Progress; using MediaBrowser.Controller; @@ -41,13 +45,13 @@ using MediaBrowser.Model.Dto; using MediaBrowser.Model.Entities; using MediaBrowser.Model.IO; using MediaBrowser.Model.Library; -using MediaBrowser.Model.Net; using MediaBrowser.Model.Querying; using MediaBrowser.Model.Tasks; using MediaBrowser.Providers.MediaInfo; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Logging; using Episode = MediaBrowser.Controller.Entities.TV.Episode; +using EpisodeInfo = Emby.Naming.TV.EpisodeInfo; using Genre = MediaBrowser.Controller.Entities.Genre; using Person = MediaBrowser.Controller.Entities.Person; using VideoResolver = Emby.Naming.Video.VideoResolver; @@ -175,10 +179,7 @@ namespace Emby.Server.Implementations.Library { lock (_rootFolderSyncLock) { - if (_rootFolder == null) - { - _rootFolder = CreateRootFolder(); - } + _rootFolder ??= CreateRootFolder(); } } @@ -196,33 +197,33 @@ namespace Emby.Server.Implementations.Library /// Gets or sets the postscan tasks. /// </summary> /// <value>The postscan tasks.</value> - private ILibraryPostScanTask[] PostscanTasks { get; set; } + private ILibraryPostScanTask[] PostscanTasks { get; set; } = Array.Empty<ILibraryPostScanTask>(); /// <summary> /// Gets or sets the intro providers. /// </summary> /// <value>The intro providers.</value> - private IIntroProvider[] IntroProviders { get; set; } + private IIntroProvider[] IntroProviders { get; set; } = Array.Empty<IIntroProvider>(); /// <summary> /// Gets or sets the list of entity resolution ignore rules. /// </summary> /// <value>The entity resolution ignore rules.</value> - private IResolverIgnoreRule[] EntityResolutionIgnoreRules { get; set; } + private IResolverIgnoreRule[] EntityResolutionIgnoreRules { get; set; } = Array.Empty<IResolverIgnoreRule>(); /// <summary> /// Gets or sets the list of currently registered entity resolvers. /// </summary> /// <value>The entity resolvers enumerable.</value> - private IItemResolver[] EntityResolvers { get; set; } + private IItemResolver[] EntityResolvers { get; set; } = Array.Empty<IItemResolver>(); - private IMultiItemResolver[] MultiItemResolvers { get; set; } + private IMultiItemResolver[] MultiItemResolvers { get; set; } = Array.Empty<IMultiItemResolver>(); /// <summary> /// Gets or sets the comparers. /// </summary> /// <value>The comparers.</value> - private IBaseItemComparer[] Comparers { get; set; } + private IBaseItemComparer[] Comparers { get; set; } = Array.Empty<IBaseItemComparer>(); public bool IsScanRunning { get; private set; } @@ -558,7 +559,6 @@ namespace Emby.Server.Implementations.Library var args = new ItemResolveArgs(_configurationManager.ApplicationPaths, directoryService) { Parent = parent, - Path = fullPath, FileInfo = fileInfo, CollectionType = collectionType, LibraryOptions = libraryOptions @@ -684,7 +684,7 @@ namespace Emby.Server.Implementations.Library foreach (var item in items) { - ResolverHelper.SetInitialItemValues(item, parent, _fileSystem, this, directoryService); + ResolverHelper.SetInitialItemValues(item, parent, this, directoryService); } items.AddRange(ResolveFileList(result.ExtraFiles, directoryService, parent, collectionType, resolvers, libraryOptions)); @@ -697,25 +697,32 @@ namespace Emby.Server.Implementations.Library } private IEnumerable<BaseItem> ResolveFileList( - IEnumerable<FileSystemMetadata> fileList, + IReadOnlyList<FileSystemMetadata> fileList, IDirectoryService directoryService, Folder parent, string collectionType, IItemResolver[] resolvers, LibraryOptions libraryOptions) { - return fileList.Select(f => + // Given that fileList is a list we can save enumerator allocations by indexing + for (var i = 0; i < fileList.Count; i++) { + var file = fileList[i]; + BaseItem result = null; try { - return ResolvePath(f, directoryService, resolvers, parent, collectionType, libraryOptions); + result = ResolvePath(file, directoryService, resolvers, parent, collectionType, libraryOptions); } catch (Exception ex) { - _logger.LogError(ex, "Error resolving path {path}", f.FullName); - return null; + _logger.LogError(ex, "Error resolving path {Path}", file.FullName); } - }).Where(i => i != null); + + if (result != null) + { + yield return result; + } + } } /// <summary> @@ -857,7 +864,21 @@ namespace Emby.Server.Implementations.Library /// <returns>Task{Person}.</returns> public Person GetPerson(string name) { - return CreateItemByName<Person>(Person.GetPath, name, new DtoOptions(true)); + var path = Person.GetPath(name); + var id = GetItemByNameId<Person>(path); + if (!(GetItemById(id) is Person item)) + { + item = new Person + { + Name = name, + Id = id, + DateCreated = DateTime.UtcNow, + DateModified = DateTime.UtcNow, + Path = path + }; + } + + return item; } /// <summary> @@ -1052,17 +1073,17 @@ namespace Emby.Server.Implementations.Library // Start by just validating the children of the root, but go no further await RootFolder.ValidateChildren( new SimpleProgress<double>(), - cancellationToken, new MetadataRefreshOptions(new DirectoryService(_fileSystem)), - recursive: false).ConfigureAwait(false); + recursive: false, + cancellationToken).ConfigureAwait(false); await GetUserRootFolder().RefreshMetadata(cancellationToken).ConfigureAwait(false); await GetUserRootFolder().ValidateChildren( new SimpleProgress<double>(), - cancellationToken, new MetadataRefreshOptions(new DirectoryService(_fileSystem)), - recursive: false).ConfigureAwait(false); + recursive: false, + cancellationToken).ConfigureAwait(false); // Quickly scan CollectionFolders for changes foreach (var folder in GetUserRootFolder().Children.OfType<Folder>()) @@ -1082,7 +1103,7 @@ namespace Emby.Server.Implementations.Library innerProgress.RegisterAction(pct => progress.Report(pct * 0.96)); // Validate the entire media library - await RootFolder.ValidateChildren(innerProgress, cancellationToken, new MetadataRefreshOptions(new DirectoryService(_fileSystem)), recursive: true).ConfigureAwait(false); + await RootFolder.ValidateChildren(innerProgress, new MetadataRefreshOptions(new DirectoryService(_fileSystem)), recursive: true, cancellationToken).ConfigureAwait(false); progress.Report(96); @@ -1149,7 +1170,7 @@ namespace Emby.Server.Implementations.Library progress.Report(percent * 100); } - _itemRepository.UpdateInheritedValues(cancellationToken); + _itemRepository.UpdateInheritedValues(); progress.Report(100); } @@ -1226,11 +1247,20 @@ namespace Emby.Server.Implementations.Library return info; } - private string GetCollectionType(string path) + private CollectionTypeOptions? GetCollectionType(string path) { - return _fileSystem.GetFilePaths(path, new[] { ".collection" }, true, false) - .Select(Path.GetFileNameWithoutExtension) - .FirstOrDefault(i => !string.IsNullOrEmpty(i)); + var files = _fileSystem.GetFilePaths(path, new[] { ".collection" }, true, false); + foreach (var file in files) + { + // TODO: @bond use a ReadOnlySpan<char> here when Enum.TryParse supports it + // https://github.com/dotnet/runtime/issues/20008 + if (Enum.TryParse<CollectionTypeOptions>(Path.GetFileNameWithoutExtension(file), true, out var res)) + { + return res; + } + } + + return null; } /// <summary> @@ -1502,7 +1532,7 @@ namespace Emby.Server.Implementations.Library { if (query.AncestorIds.Length == 0 && query.ParentId.Equals(Guid.Empty) && - query.ChannelIds.Length == 0 && + query.ChannelIds.Count == 0 && query.TopParentIds.Length == 0 && string.IsNullOrEmpty(query.AncestorWithPresentationUniqueKey) && string.IsNullOrEmpty(query.SeriesPresentationUniqueKey) && @@ -1891,12 +1921,17 @@ namespace Emby.Server.Implementations.Library } catch (ArgumentException) { - _logger.LogWarning("Cannot get image index for {0}", img.Path); + _logger.LogWarning("Cannot get image index for {ImagePath}", img.Path); + continue; + } + catch (Exception ex) when (ex is InvalidOperationException || ex is IOException) + { + _logger.LogWarning(ex, "Cannot fetch image from {ImagePath}", img.Path); continue; } - catch (InvalidOperationException) + catch (HttpRequestException ex) { - _logger.LogWarning("Cannot fetch image from {0}", img.Path); + _logger.LogWarning(ex, "Cannot fetch image from {ImagePath}. Http status code: {HttpStatus}", img.Path, ex.StatusCode); continue; } } @@ -1909,7 +1944,7 @@ namespace Emby.Server.Implementations.Library } catch (Exception ex) { - _logger.LogError(ex, "Cannot get image dimensions for {0}", image.Path); + _logger.LogError(ex, "Cannot get image dimensions for {ImagePath}", image.Path); image.Width = 0; image.Height = 0; continue; @@ -1921,7 +1956,7 @@ namespace Emby.Server.Implementations.Library } catch (Exception ex) { - _logger.LogError(ex, "Cannot compute blurhash for {0}", image.Path); + _logger.LogError(ex, "Cannot compute blurhash for {ImagePath}", image.Path); image.BlurHash = string.Empty; } @@ -1931,7 +1966,7 @@ namespace Emby.Server.Implementations.Library } catch (Exception ex) { - _logger.LogError(ex, "Cannot update DateModified for {0}", image.Path); + _logger.LogError(ex, "Cannot update DateModified for {ImagePath}", image.Path); } } @@ -1944,14 +1979,7 @@ namespace Emby.Server.Implementations.Library { foreach (var item in items) { - if (item.IsFileProtocol) - { - ProviderManager.SaveMetadata(item, updateReason); - } - - item.DateLastSaved = DateTime.UtcNow; - - await UpdateImagesAsync(item, updateReason >= ItemUpdateType.ImageUpdate).ConfigureAwait(false); + await RunMetadataSavers(item, updateReason).ConfigureAwait(false); } _itemRepository.SaveItems(items, cancellationToken); @@ -1989,6 +2017,18 @@ namespace Emby.Server.Implementations.Library public Task UpdateItemAsync(BaseItem item, BaseItem parent, ItemUpdateType updateReason, CancellationToken cancellationToken) => UpdateItemsAsync(new[] { item }, parent, updateReason, cancellationToken); + public Task RunMetadataSavers(BaseItem item, ItemUpdateType updateReason) + { + if (item.IsFileProtocol) + { + ProviderManager.SaveMetadata(item, updateReason); + } + + item.DateLastSaved = DateTime.UtcNow; + + return UpdateImagesAsync(item, updateReason >= ItemUpdateType.ImageUpdate); + } + /// <summary> /// Reports the item removed. /// </summary> @@ -2044,7 +2084,7 @@ namespace Emby.Server.Implementations.Library return new List<Folder>(); } - return GetCollectionFoldersInternal(item, GetUserRootFolder().Children.OfType<Folder>().ToList()); + return GetCollectionFoldersInternal(item, GetUserRootFolder().Children.OfType<Folder>()); } public List<Folder> GetCollectionFolders(BaseItem item, List<Folder> allUserRootChildren) @@ -2069,10 +2109,10 @@ namespace Emby.Server.Implementations.Library return GetCollectionFoldersInternal(item, allUserRootChildren); } - private static List<Folder> GetCollectionFoldersInternal(BaseItem item, List<Folder> allUserRootChildren) + private static List<Folder> GetCollectionFoldersInternal(BaseItem item, IEnumerable<Folder> allUserRootChildren) { return allUserRootChildren - .Where(i => string.Equals(i.Path, item.Path, StringComparison.OrdinalIgnoreCase) || i.PhysicalLocations.Contains(item.Path, StringComparer.OrdinalIgnoreCase)) + .Where(i => string.Equals(i.Path, item.Path, StringComparison.OrdinalIgnoreCase) || i.PhysicalLocations.Contains(item.Path.AsSpan(), StringComparison.OrdinalIgnoreCase)) .ToList(); } @@ -2080,9 +2120,9 @@ namespace Emby.Server.Implementations.Library { if (!(item is CollectionFolder collectionFolder)) { + // List.Find is more performant than FirstOrDefault due to enumerator allocation collectionFolder = GetCollectionFolders(item) - .OfType<CollectionFolder>() - .FirstOrDefault(); + .Find(folder => folder is CollectionFolder) as CollectionFolder; } return collectionFolder == null ? new LibraryOptions() : collectionFolder.GetLibraryOptions(); @@ -2440,11 +2480,35 @@ namespace Emby.Server.Implementations.Library new SubtitleResolver(BaseItem.LocalizationManager).AddExternalSubtitleStreams(streams, videoPath, streams.Count, files); } + public BaseItem GetParentItem(string parentId, Guid? userId) + { + if (string.IsNullOrEmpty(parentId)) + { + return GetParentItem((Guid?)null, userId); + } + + return GetParentItem(new Guid(parentId), userId); + } + + public BaseItem GetParentItem(Guid? parentId, Guid? userId) + { + if (parentId.HasValue) + { + return GetItemById(parentId.Value); + } + + if (userId.HasValue && userId != Guid.Empty) + { + return GetUserRootFolder(); + } + + return RootFolder; + } + /// <inheritdoc /> public bool IsVideoFile(string path) { - var resolver = new VideoResolver(GetNamingOptions()); - return resolver.IsVideoFile(path); + return VideoResolver.IsVideoFile(path, GetNamingOptions()); } /// <inheritdoc /> @@ -2459,7 +2523,7 @@ namespace Emby.Server.Implementations.Library public bool FillMissingEpisodeNumbersFromPath(Episode episode, bool forceRefresh) { var series = episode.Series; - bool? isAbsoluteNaming = series == null ? false : string.Equals(series.DisplayOrder, "absolute", StringComparison.OrdinalIgnoreCase); + bool? isAbsoluteNaming = series != null && string.Equals(series.DisplayOrder, "absolute", StringComparison.OrdinalIgnoreCase); if (!isAbsoluteNaming.Value) { // In other words, no filter applied @@ -2470,9 +2534,25 @@ namespace Emby.Server.Implementations.Library var isFolder = episode.VideoType == VideoType.BluRay || episode.VideoType == VideoType.Dvd; - var episodeInfo = episode.IsFileProtocol - ? resolver.Resolve(episode.Path, isFolder, null, null, isAbsoluteNaming) ?? new Naming.TV.EpisodeInfo() - : new Naming.TV.EpisodeInfo(); + // TODO nullable - what are we trying to do there with empty episodeInfo? + EpisodeInfo episodeInfo = null; + if (episode.IsFileProtocol) + { + episodeInfo = resolver.Resolve(episode.Path, isFolder, null, null, isAbsoluteNaming); + // Resolve from parent folder if it's not the Season folder + var parent = episode.GetParent(); + if (episodeInfo == null && parent.GetType() == typeof(Folder)) + { + episodeInfo = resolver.Resolve(parent.Path, true, null, null, isAbsoluteNaming); + if (episodeInfo != null) + { + // add the container + episodeInfo.Container = Path.GetExtension(episode.Path)?.TrimStart('.'); + } + } + } + + episodeInfo ??= new EpisodeInfo(episode.Path); try { @@ -2561,12 +2641,12 @@ namespace Emby.Server.Implementations.Library if (!episode.IndexNumberEnd.HasValue || forceRefresh) { - if (episode.IndexNumberEnd != episodeInfo.EndingEpsiodeNumber) + if (episode.IndexNumberEnd != episodeInfo.EndingEpisodeNumber) { changed = true; } - episode.IndexNumberEnd = episodeInfo.EndingEpsiodeNumber; + episode.IndexNumberEnd = episodeInfo.EndingEpisodeNumber; } if (!episode.ParentIndexNumber.HasValue || forceRefresh) @@ -2607,6 +2687,7 @@ namespace Emby.Server.Implementations.Library return changed; } + /// <inheritdoc /> public NamingOptions GetNamingOptions() { if (_namingOptions == null) @@ -2620,13 +2701,12 @@ namespace Emby.Server.Implementations.Library public ItemLookupInfo ParseName(string name) { - var resolver = new VideoResolver(GetNamingOptions()); - - var result = resolver.CleanDateTime(name); + var namingOptions = GetNamingOptions(); + var result = VideoResolver.CleanDateTime(name, namingOptions); return new ItemLookupInfo { - Name = resolver.TryCleanString(result.Name, out var newName) ? newName.ToString() : result.Name, + Name = VideoResolver.TryCleanString(result.Name, namingOptions, out var newName) ? newName.ToString() : result.Name, Year = result.Year }; } @@ -2640,9 +2720,7 @@ namespace Emby.Server.Implementations.Library .SelectMany(i => _fileSystem.GetFiles(i.FullName, _videoFileExtensions, false, false)) .ToList(); - var videoListResolver = new VideoListResolver(namingOptions); - - var videos = videoListResolver.Resolve(fileSystemChildren); + var videos = VideoListResolver.Resolve(fileSystemChildren, namingOptions); var currentVideo = videos.FirstOrDefault(i => string.Equals(owner.Path, i.Files[0].Path, StringComparison.OrdinalIgnoreCase)); @@ -2686,11 +2764,9 @@ namespace Emby.Server.Implementations.Library .SelectMany(i => _fileSystem.GetFiles(i.FullName, _videoFileExtensions, false, false)) .ToList(); - var videoListResolver = new VideoListResolver(namingOptions); - - var videos = videoListResolver.Resolve(fileSystemChildren); + var videos = VideoListResolver.Resolve(fileSystemChildren, namingOptions); - var currentVideo = videos.FirstOrDefault(i => string.Equals(owner.Path, i.Files.First().Path, StringComparison.OrdinalIgnoreCase)); + var currentVideo = videos.FirstOrDefault(i => string.Equals(owner.Path, i.Files[0].Path, StringComparison.OrdinalIgnoreCase)); if (currentVideo != null) { @@ -2722,6 +2798,7 @@ namespace Emby.Server.Implementations.Library public string GetPathAfterNetworkSubstitution(string path, BaseItem ownerItem) { + string newPath; if (ownerItem != null) { var libraryOptions = GetLibraryOptions(ownerItem); @@ -2729,15 +2806,9 @@ namespace Emby.Server.Implementations.Library { foreach (var pathInfo in libraryOptions.PathInfos) { - if (string.IsNullOrWhiteSpace(pathInfo.Path) || string.IsNullOrWhiteSpace(pathInfo.NetworkPath)) - { - continue; - } - - var substitutionResult = SubstitutePathInternal(path, pathInfo.Path, pathInfo.NetworkPath); - if (substitutionResult.Item2) + if (path.TryReplaceSubPath(pathInfo.Path, pathInfo.NetworkPath, out newPath)) { - return substitutionResult.Item1; + return newPath; } } } @@ -2746,24 +2817,16 @@ namespace Emby.Server.Implementations.Library var metadataPath = _configurationManager.Configuration.MetadataPath; var metadataNetworkPath = _configurationManager.Configuration.MetadataNetworkPath; - if (!string.IsNullOrWhiteSpace(metadataPath) && !string.IsNullOrWhiteSpace(metadataNetworkPath)) + if (path.TryReplaceSubPath(metadataPath, metadataNetworkPath, out newPath)) { - var metadataSubstitutionResult = SubstitutePathInternal(path, metadataPath, metadataNetworkPath); - if (metadataSubstitutionResult.Item2) - { - return metadataSubstitutionResult.Item1; - } + return newPath; } foreach (var map in _configurationManager.Configuration.PathSubstitutions) { - if (!string.IsNullOrWhiteSpace(map.From)) + if (path.TryReplaceSubPath(map.From, map.To, out newPath)) { - var substitutionResult = SubstitutePathInternal(path, map.From, map.To); - if (substitutionResult.Item2) - { - return substitutionResult.Item1; - } + return newPath; } } @@ -2772,47 +2835,12 @@ namespace Emby.Server.Implementations.Library public string SubstitutePath(string path, string from, string to) { - return SubstitutePathInternal(path, from, to).Item1; - } - - private Tuple<string, bool> SubstitutePathInternal(string path, string from, string to) - { - if (string.IsNullOrWhiteSpace(path)) - { - throw new ArgumentNullException(nameof(path)); - } - - if (string.IsNullOrWhiteSpace(from)) - { - throw new ArgumentNullException(nameof(from)); - } - - if (string.IsNullOrWhiteSpace(to)) - { - throw new ArgumentNullException(nameof(to)); - } - - from = from.Trim(); - to = to.Trim(); - - var newPath = path.Replace(from, to, StringComparison.OrdinalIgnoreCase); - var changed = false; - - if (!string.Equals(newPath, path, StringComparison.Ordinal)) + if (path.TryReplaceSubPath(from, to, out var newPath)) { - if (to.IndexOf('/', StringComparison.Ordinal) != -1) - { - newPath = newPath.Replace('\\', '/'); - } - else - { - newPath = newPath.Replace('/', '\\'); - } - - changed = true; + return newPath; } - return new Tuple<string, bool>(newPath, changed); + return path; } private void SetExtraTypeFromFilename(Video item) @@ -2869,6 +2897,12 @@ namespace Emby.Server.Implementations.Library } public void UpdatePeople(BaseItem item, List<PersonInfo> people) + { + UpdatePeopleAsync(item, people, CancellationToken.None).GetAwaiter().GetResult(); + } + + /// <inheritdoc /> + public async Task UpdatePeopleAsync(BaseItem item, List<PersonInfo> people, CancellationToken cancellationToken) { if (!item.SupportsPeople) { @@ -2876,6 +2910,8 @@ namespace Emby.Server.Implementations.Library } _itemRepository.UpdatePeople(item.Id, people); + + await SavePeopleMetadataAsync(people, cancellationToken).ConfigureAwait(false); } public async Task<ItemImageInfo> ConvertImageToLocal(BaseItem item, ItemImageInfo image, int imageIndex) @@ -2892,7 +2928,7 @@ namespace Emby.Server.Implementations.Library return item.GetImageInfo(image.Type, imageIndex); } - catch (HttpException ex) + catch (HttpRequestException ex) { if (ex.StatusCode.HasValue && (ex.StatusCode.Value == HttpStatusCode.NotFound || ex.StatusCode.Value == HttpStatusCode.Forbidden)) @@ -2911,7 +2947,7 @@ namespace Emby.Server.Implementations.Library throw new InvalidOperationException(); } - public async Task AddVirtualFolder(string name, string collectionType, LibraryOptions options, bool refreshLibrary) + public async Task AddVirtualFolder(string name, CollectionTypeOptions? collectionType, LibraryOptions options, bool refreshLibrary) { if (string.IsNullOrWhiteSpace(name)) { @@ -2945,9 +2981,9 @@ namespace Emby.Server.Implementations.Library { Directory.CreateDirectory(virtualFolderPath); - if (!string.IsNullOrEmpty(collectionType)) + if (collectionType != null) { - var path = Path.Combine(virtualFolderPath, collectionType + ".collection"); + var path = Path.Combine(virtualFolderPath, collectionType.ToString().ToLowerInvariant() + ".collection"); File.WriteAllBytes(path, Array.Empty<byte>()); } @@ -2979,6 +3015,58 @@ namespace Emby.Server.Implementations.Library } } + private async Task SavePeopleMetadataAsync(IEnumerable<PersonInfo> people, CancellationToken cancellationToken) + { + var personsToSave = new List<BaseItem>(); + + foreach (var person in people) + { + cancellationToken.ThrowIfCancellationRequested(); + + var itemUpdateType = ItemUpdateType.MetadataDownload; + var saveEntity = false; + var personEntity = GetPerson(person.Name); + + // if PresentationUniqueKey is empty it's likely a new item. + if (string.IsNullOrEmpty(personEntity.PresentationUniqueKey)) + { + personEntity.PresentationUniqueKey = personEntity.CreatePresentationUniqueKey(); + saveEntity = true; + } + + foreach (var id in person.ProviderIds) + { + if (!string.Equals(personEntity.GetProviderId(id.Key), id.Value, StringComparison.OrdinalIgnoreCase)) + { + personEntity.SetProviderId(id.Key, id.Value); + saveEntity = true; + } + } + + if (!string.IsNullOrWhiteSpace(person.ImageUrl) && !personEntity.HasImage(ImageType.Primary)) + { + personEntity.SetImage( + new ItemImageInfo + { + Path = person.ImageUrl, + Type = ImageType.Primary + }, + 0); + + saveEntity = true; + itemUpdateType = ItemUpdateType.ImageUpdate; + } + + if (saveEntity) + { + personsToSave.Add(personEntity); + await RunMetadataSavers(personEntity, itemUpdateType).ConfigureAwait(false); + } + } + + CreateItems(personsToSave, null, CancellationToken.None); + } + private void StartScanInBackground() { Task.Run(() => diff --git a/Emby.Server.Implementations/Library/LiveStreamHelper.cs b/Emby.Server.Implementations/Library/LiveStreamHelper.cs index 041619d1e5..8062691827 100644 --- a/Emby.Server.Implementations/Library/LiveStreamHelper.cs +++ b/Emby.Server.Implementations/Library/LiveStreamHelper.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System; @@ -5,16 +7,17 @@ using System.Collections.Generic; using System.Globalization; using System.IO; using System.Linq; +using System.Text.Json; using System.Threading; using System.Threading.Tasks; using MediaBrowser.Common.Configuration; using MediaBrowser.Common.Extensions; +using Jellyfin.Extensions.Json; using MediaBrowser.Controller.MediaEncoding; using MediaBrowser.Model.Dlna; using MediaBrowser.Model.Dto; using MediaBrowser.Model.Entities; using MediaBrowser.Model.MediaInfo; -using MediaBrowser.Model.Serialization; using Microsoft.Extensions.Logging; namespace Emby.Server.Implementations.Library @@ -23,14 +26,13 @@ namespace Emby.Server.Implementations.Library { private readonly IMediaEncoder _mediaEncoder; private readonly ILogger _logger; - private readonly IJsonSerializer _json; private readonly IApplicationPaths _appPaths; + private readonly JsonSerializerOptions _jsonOptions = JsonDefaults.Options; - public LiveStreamHelper(IMediaEncoder mediaEncoder, ILogger logger, IJsonSerializer json, IApplicationPaths appPaths) + public LiveStreamHelper(IMediaEncoder mediaEncoder, ILogger logger, IApplicationPaths appPaths) { _mediaEncoder = mediaEncoder; _logger = logger; - _json = json; _appPaths = appPaths; } @@ -47,7 +49,8 @@ namespace Emby.Server.Implementations.Library { try { - mediaInfo = _json.DeserializeFromFile<MediaInfo>(cacheFilePath); + await using FileStream jsonStream = File.OpenRead(cacheFilePath); + mediaInfo = await JsonSerializer.DeserializeAsync<MediaInfo>(jsonStream, _jsonOptions, cancellationToken).ConfigureAwait(false); // _logger.LogDebug("Found cached media info"); } @@ -83,7 +86,8 @@ namespace Emby.Server.Implementations.Library if (cacheFilePath != null) { Directory.CreateDirectory(Path.GetDirectoryName(cacheFilePath)); - _json.SerializeToFile(mediaInfo, cacheFilePath); + await using FileStream createStream = File.OpenWrite(cacheFilePath); + await JsonSerializer.SerializeAsync(createStream, mediaInfo, _jsonOptions, cancellationToken).ConfigureAwait(false); // _logger.LogDebug("Saved media info to {0}", cacheFilePath); } diff --git a/Emby.Server.Implementations/Library/MediaSourceManager.cs b/Emby.Server.Implementations/Library/MediaSourceManager.cs index 376a155705..91c9e61cf3 100644 --- a/Emby.Server.Implementations/Library/MediaSourceManager.cs +++ b/Emby.Server.Implementations/Library/MediaSourceManager.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System; @@ -6,12 +8,14 @@ using System.Collections.Generic; using System.Globalization; using System.IO; using System.Linq; +using System.Text.Json; using System.Threading; using System.Threading.Tasks; using Jellyfin.Data.Entities; using Jellyfin.Data.Enums; using MediaBrowser.Common.Configuration; using MediaBrowser.Common.Extensions; +using Jellyfin.Extensions.Json; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.MediaEncoding; @@ -23,7 +27,6 @@ using MediaBrowser.Model.Entities; using MediaBrowser.Model.Globalization; using MediaBrowser.Model.IO; using MediaBrowser.Model.MediaInfo; -using MediaBrowser.Model.Serialization; using Microsoft.Extensions.Logging; namespace Emby.Server.Implementations.Library @@ -36,7 +39,6 @@ namespace Emby.Server.Implementations.Library private readonly IItemRepository _itemRepo; private readonly IUserManager _userManager; private readonly ILibraryManager _libraryManager; - private readonly IJsonSerializer _jsonSerializer; private readonly IFileSystem _fileSystem; private readonly ILogger<MediaSourceManager> _logger; private readonly IUserDataManager _userDataManager; @@ -46,6 +48,7 @@ namespace Emby.Server.Implementations.Library private readonly ConcurrentDictionary<string, ILiveStream> _openStreams = new ConcurrentDictionary<string, ILiveStream>(StringComparer.OrdinalIgnoreCase); private readonly SemaphoreSlim _liveStreamSemaphore = new SemaphoreSlim(1, 1); + private readonly JsonSerializerOptions _jsonOptions = JsonDefaults.Options; private IMediaSourceProvider[] _providers; @@ -56,7 +59,6 @@ namespace Emby.Server.Implementations.Library IUserManager userManager, ILibraryManager libraryManager, ILogger<MediaSourceManager> logger, - IJsonSerializer jsonSerializer, IFileSystem fileSystem, IUserDataManager userDataManager, IMediaEncoder mediaEncoder) @@ -65,7 +67,6 @@ namespace Emby.Server.Implementations.Library _userManager = userManager; _libraryManager = libraryManager; _logger = logger; - _jsonSerializer = jsonSerializer; _fileSystem = fileSystem; _userDataManager = userDataManager; _mediaEncoder = mediaEncoder; @@ -200,10 +201,15 @@ namespace Emby.Server.Implementations.Library { source.SupportsTranscoding = user.HasPermission(PermissionKind.EnableAudioPlaybackTranscoding); } + else if (string.Equals(item.MediaType, MediaType.Video, StringComparison.OrdinalIgnoreCase)) + { + source.SupportsTranscoding = user.HasPermission(PermissionKind.EnableVideoPlaybackTranscoding); + source.SupportsDirectStream = user.HasPermission(PermissionKind.EnablePlaybackRemuxing); + } } } - return SortMediaSources(list).Where(i => i.Type != MediaSourceType.Placeholder).ToList(); + return SortMediaSources(list); } public MediaProtocol GetPathProtocol(string path) @@ -346,7 +352,7 @@ namespace Emby.Server.Implementations.Library private string[] NormalizeLanguage(string language) { - if (language == null) + if (string.IsNullOrEmpty(language)) { return Array.Empty<string>(); } @@ -375,8 +381,7 @@ namespace Emby.Server.Implementations.Library } } - var preferredSubs = string.IsNullOrEmpty(user.SubtitleLanguagePreference) - ? Array.Empty<string>() : NormalizeLanguage(user.SubtitleLanguagePreference); + var preferredSubs = NormalizeLanguage(user.SubtitleLanguagePreference); var defaultAudioIndex = source.DefaultAudioStreamIndex; var audioLangage = defaultAudioIndex == null @@ -405,9 +410,7 @@ namespace Emby.Server.Implementations.Library } } - var preferredAudio = string.IsNullOrEmpty(user.AudioLanguagePreference) - ? Array.Empty<string>() - : NormalizeLanguage(user.AudioLanguagePreference); + var preferredAudio = NormalizeLanguage(user.AudioLanguagePreference); source.DefaultAudioStreamIndex = MediaStreamSelector.GetDefaultAudioStreamIndex(source.MediaStreams, preferredAudio, user.PlayDefaultAudioTrack); } @@ -437,7 +440,7 @@ namespace Emby.Server.Implementations.Library } } - private static IEnumerable<MediaSourceInfo> SortMediaSources(IEnumerable<MediaSourceInfo> sources) + private static List<MediaSourceInfo> SortMediaSources(IEnumerable<MediaSourceInfo> sources) { return sources.OrderBy(i => { @@ -452,8 +455,9 @@ namespace Emby.Server.Implementations.Library { var stream = i.VideoStream; - return stream == null || stream.Width == null ? 0 : stream.Width.Value; + return stream?.Width ?? 0; }) + .Where(i => i.Type != MediaSourceType.Placeholder) .ToList(); } @@ -504,7 +508,7 @@ namespace Emby.Server.Implementations.Library // hack - these two values were taken from LiveTVMediaSourceProvider string cacheKey = request.OpenToken; - await new LiveStreamHelper(_mediaEncoder, _logger, _jsonSerializer, _appPaths) + await new LiveStreamHelper(_mediaEncoder, _logger, _appPaths) .AddMediaInfoWithProbe(mediaSource, isAudio, cacheKey, true, cancellationToken) .ConfigureAwait(false); } @@ -516,9 +520,9 @@ namespace Emby.Server.Implementations.Library } // TODO: @bond Fix - var json = _jsonSerializer.SerializeToString(mediaSource); + var json = JsonSerializer.SerializeToUtf8Bytes(mediaSource, _jsonOptions); _logger.LogInformation("Live stream opened: " + json); - var clone = _jsonSerializer.DeserializeFromString<MediaSourceInfo>(json); + var clone = JsonSerializer.Deserialize<MediaSourceInfo>(json, _jsonOptions); if (!request.UserId.Equals(Guid.Empty)) { @@ -585,18 +589,9 @@ namespace Emby.Server.Implementations.Library public Task<IDirectStreamProvider> GetDirectStreamProviderByUniqueId(string uniqueId, CancellationToken cancellationToken) { - var info = _openStreams.Values.FirstOrDefault(i => - { - var liveStream = i as ILiveStream; - if (liveStream != null) - { - return string.Equals(liveStream.UniqueId, uniqueId, StringComparison.OrdinalIgnoreCase); - } - - return false; - }); + var info = _openStreams.FirstOrDefault(i => i.Value != null && string.Equals(i.Value.UniqueId, uniqueId, StringComparison.OrdinalIgnoreCase)); - return Task.FromResult(info as IDirectStreamProvider); + return Task.FromResult(info.Value as IDirectStreamProvider); } public async Task<LiveStreamResponse> OpenLiveStream(LiveStreamRequest request, CancellationToken cancellationToken) @@ -643,7 +638,8 @@ namespace Emby.Server.Implementations.Library { try { - mediaInfo = _jsonSerializer.DeserializeFromFile<MediaInfo>(cacheFilePath); + await using FileStream jsonStream = File.OpenRead(cacheFilePath); + mediaInfo = await JsonSerializer.DeserializeAsync<MediaInfo>(jsonStream, _jsonOptions, cancellationToken).ConfigureAwait(false); // _logger.LogDebug("Found cached media info"); } @@ -679,7 +675,8 @@ namespace Emby.Server.Implementations.Library if (cacheFilePath != null) { Directory.CreateDirectory(Path.GetDirectoryName(cacheFilePath)); - _jsonSerializer.SerializeToFile(mediaInfo, cacheFilePath); + await using FileStream createStream = File.Create(cacheFilePath); + await JsonSerializer.SerializeAsync(createStream, mediaInfo, _jsonOptions, cancellationToken).ConfigureAwait(false); // _logger.LogDebug("Saved media info to {0}", cacheFilePath); } @@ -849,7 +846,7 @@ namespace Emby.Server.Implementations.Library throw new ArgumentException("Key can't be empty.", nameof(key)); } - var keys = key.Split(new[] { LiveStreamIdDelimeter }, 2); + var keys = key.Split(LiveStreamIdDelimeter, 2); var provider = _providers.FirstOrDefault(i => string.Equals(i.GetType().FullName.GetMD5().ToString("N", CultureInfo.InvariantCulture), keys[0], StringComparison.OrdinalIgnoreCase)); diff --git a/Emby.Server.Implementations/Library/MediaStreamSelector.cs b/Emby.Server.Implementations/Library/MediaStreamSelector.cs index 179e0ed986..b833122ea0 100644 --- a/Emby.Server.Implementations/Library/MediaStreamSelector.cs +++ b/Emby.Server.Implementations/Library/MediaStreamSelector.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System; @@ -101,7 +103,7 @@ namespace Emby.Server.Implementations.Library private static IEnumerable<MediaStream> GetSortedStreams(IEnumerable<MediaStream> streams, MediaStreamType type, string[] languagePreferences) { - // Give some preferance to external text subs for better performance + // Give some preference to external text subs for better performance return streams.Where(i => i.Type == type) .OrderBy(i => { diff --git a/Emby.Server.Implementations/Library/MusicManager.cs b/Emby.Server.Implementations/Library/MusicManager.cs index 877fdec86e..06300adebc 100644 --- a/Emby.Server.Implementations/Library/MusicManager.cs +++ b/Emby.Server.Implementations/Library/MusicManager.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System; @@ -49,7 +51,7 @@ namespace Emby.Server.Implementations.Library var genres = item .GetRecursiveChildren(user, new InternalItemsQuery(user) { - IncludeItemTypes = new[] { typeof(Audio).Name }, + IncludeItemTypes = new[] { nameof(Audio) }, DtoOptions = dtoOptions }) .Cast<Audio>() @@ -86,7 +88,7 @@ namespace Emby.Server.Implementations.Library { return _libraryManager.GetItemList(new InternalItemsQuery(user) { - IncludeItemTypes = new[] { typeof(Audio).Name }, + IncludeItemTypes = new[] { nameof(Audio) }, GenreIds = genreIds.ToArray(), @@ -100,8 +102,7 @@ namespace Emby.Server.Implementations.Library public List<BaseItem> GetInstantMixFromItem(BaseItem item, User user, DtoOptions dtoOptions) { - var genre = item as MusicGenre; - if (genre != null) + if (item is MusicGenre genre) { return GetInstantMixFromGenreIds(new[] { item.Id }, user, dtoOptions); } diff --git a/Emby.Server.Implementations/Library/PathExtensions.cs b/Emby.Server.Implementations/Library/PathExtensions.cs index 06ff3e611b..86b8039fab 100644 --- a/Emby.Server.Implementations/Library/PathExtensions.cs +++ b/Emby.Server.Implementations/Library/PathExtensions.cs @@ -1,7 +1,6 @@ -#nullable enable - using System; -using System.Text.RegularExpressions; +using System.Diagnostics.CodeAnalysis; +using MediaBrowser.Common.Providers; namespace Emby.Server.Implementations.Library { @@ -41,11 +40,78 @@ namespace Emby.Server.Implementations.Library // for imdbid we also accept pattern matching if (string.Equals(attribute, "imdbid", StringComparison.OrdinalIgnoreCase)) { - var m = Regex.Match(str, "tt([0-9]{7,8})", RegexOptions.IgnoreCase); - return m.Success ? m.Value : null; + var match = ProviderIdParsers.TryFindImdbId(str, out var imdbId); + return match ? imdbId.ToString() : null; } return null; } + + /// <summary> + /// Replaces a sub path with another sub path and normalizes the final path. + /// </summary> + /// <param name="path">The original path.</param> + /// <param name="subPath">The original sub path.</param> + /// <param name="newSubPath">The new sub path.</param> + /// <param name="newPath">The result of the sub path replacement</param> + /// <returns>The path after replacing the sub path.</returns> + /// <exception cref="ArgumentNullException"><paramref name="path" />, <paramref name="newSubPath" /> or <paramref name="newSubPath" /> is empty.</exception> + public static bool TryReplaceSubPath( + [NotNullWhen(true)] this string? path, + [NotNullWhen(true)] string? subPath, + [NotNullWhen(true)] string? newSubPath, + [NotNullWhen(true)] out string? newPath) + { + newPath = null; + + if (string.IsNullOrEmpty(path) + || string.IsNullOrEmpty(subPath) + || string.IsNullOrEmpty(newSubPath) + || subPath.Length > path.Length) + { + 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); + + // 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 + var oldSubPathEndsWithSeparator = subPath[^1] == newDirectorySeparatorChar; + if (!path.StartsWith(subPath, StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + if (path.Length > subPath.Length + && !oldSubPathEndsWithSeparator + && path[subPath.Length] != newDirectorySeparatorChar) + { + return false; + } + + var newSubPathTrimmed = newSubPath.AsSpan().TrimEnd(newDirectorySeparatorChar); + // Ensure that the path with the old subpath removed starts with a leading dir separator + int idx = oldSubPathEndsWithSeparator ? subPath.Length - 1 : subPath.Length; + newPath = string.Concat(newSubPathTrimmed, path.AsSpan(idx)); + + return true; + } } } diff --git a/Emby.Server.Implementations/Library/ResolverHelper.cs b/Emby.Server.Implementations/Library/ResolverHelper.cs index 4e4cac75bf..ac75e5d3a1 100644 --- a/Emby.Server.Implementations/Library/ResolverHelper.cs +++ b/Emby.Server.Implementations/Library/ResolverHelper.cs @@ -18,11 +18,10 @@ namespace Emby.Server.Implementations.Library /// </summary> /// <param name="item">The item.</param> /// <param name="parent">The parent.</param> - /// <param name="fileSystem">The file system.</param> /// <param name="libraryManager">The library manager.</param> /// <param name="directoryService">The directory service.</param> - /// <exception cref="ArgumentException">Item must have a path</exception> - public static void SetInitialItemValues(BaseItem item, Folder parent, IFileSystem fileSystem, ILibraryManager libraryManager, IDirectoryService directoryService) + /// <exception cref="ArgumentException">Item must have a path.</exception> + public static void SetInitialItemValues(BaseItem item, Folder? parent, ILibraryManager libraryManager, IDirectoryService directoryService) { // This version of the below method has no ItemResolveArgs, so we have to require the path already being set if (string.IsNullOrEmpty(item.Path)) @@ -43,9 +42,14 @@ namespace Emby.Server.Implementations.Library // Make sure DateCreated and DateModified have values var fileInfo = directoryService.GetFile(item.Path); - SetDateCreated(item, fileSystem, fileInfo); + if (fileInfo == null) + { + throw new FileNotFoundException("Can't find item path.", item.Path); + } - EnsureName(item, item.Path, fileInfo); + SetDateCreated(item, fileInfo); + + EnsureName(item, fileInfo); } /// <summary> @@ -72,9 +76,9 @@ namespace Emby.Server.Implementations.Library item.Id = libraryManager.GetNewItemId(item.Path, item.GetType()); // Make sure the item has a name - EnsureName(item, item.Path, args.FileInfo); + EnsureName(item, args.FileInfo); - item.IsLocked = item.Path.IndexOf("[dontfetchmeta]", StringComparison.OrdinalIgnoreCase) != -1 || + item.IsLocked = item.Path.Contains("[dontfetchmeta]", StringComparison.OrdinalIgnoreCase) || item.GetParents().Any(i => i.IsLocked); // Make sure DateCreated and DateModified have values @@ -84,28 +88,15 @@ namespace Emby.Server.Implementations.Library /// <summary> /// Ensures the name. /// </summary> - private static void EnsureName(BaseItem item, string fullPath, FileSystemMetadata fileInfo) + private static void EnsureName(BaseItem item, FileSystemMetadata fileInfo) { // If the subclass didn't supply a name, add it here - if (string.IsNullOrEmpty(item.Name) && !string.IsNullOrEmpty(fullPath)) + if (string.IsNullOrEmpty(item.Name) && !string.IsNullOrEmpty(item.Path)) { - var fileName = fileInfo == null ? Path.GetFileName(fullPath) : fileInfo.Name; - - item.Name = GetDisplayName(fileName, fileInfo != null && fileInfo.IsDirectory); + item.Name = fileInfo.IsDirectory ? fileInfo.Name : Path.GetFileNameWithoutExtension(fileInfo.Name); } } - /// <summary> - /// Gets the display name. - /// </summary> - /// <param name="path">The path.</param> - /// <param name="isDirectory">if set to <c>true</c> [is directory].</param> - /// <returns>System.String.</returns> - private static string GetDisplayName(string path, bool isDirectory) - { - return isDirectory ? Path.GetFileName(path) : Path.GetFileNameWithoutExtension(path); - } - /// <summary> /// Ensures DateCreated and DateModified have values. /// </summary> @@ -114,21 +105,6 @@ namespace Emby.Server.Implementations.Library /// <param name="args">The args.</param> private static void EnsureDates(IFileSystem fileSystem, BaseItem item, ItemResolveArgs args) { - if (fileSystem == null) - { - throw new ArgumentNullException(nameof(fileSystem)); - } - - if (item == null) - { - throw new ArgumentNullException(nameof(item)); - } - - if (args == null) - { - throw new ArgumentNullException(nameof(args)); - } - // See if a different path came out of the resolver than what went in if (!fileSystem.AreEqual(args.Path, item.Path)) { @@ -136,7 +112,7 @@ namespace Emby.Server.Implementations.Library if (childData != null) { - SetDateCreated(item, fileSystem, childData); + SetDateCreated(item, childData); } else { @@ -144,17 +120,17 @@ namespace Emby.Server.Implementations.Library if (fileData.Exists) { - SetDateCreated(item, fileSystem, fileData); + SetDateCreated(item, fileData); } } } else { - SetDateCreated(item, fileSystem, args.FileInfo); + SetDateCreated(item, args.FileInfo); } } - private static void SetDateCreated(BaseItem item, IFileSystem fileSystem, FileSystemMetadata info) + private static void SetDateCreated(BaseItem item, FileSystemMetadata? info) { var config = BaseItem.ConfigurationManager.GetMetadataConfiguration(); @@ -163,7 +139,7 @@ namespace Emby.Server.Implementations.Library // directoryService.getFile may return null if (info != null) { - var dateCreated = fileSystem.GetCreationTimeUtc(info); + var dateCreated = info.CreationTimeUtc; if (dateCreated.Equals(DateTime.MinValue)) { diff --git a/Emby.Server.Implementations/Library/Resolvers/Audio/AudioResolver.cs b/Emby.Server.Implementations/Library/Resolvers/Audio/AudioResolver.cs index 70be524116..e893d63350 100644 --- a/Emby.Server.Implementations/Library/Resolvers/Audio/AudioResolver.cs +++ b/Emby.Server.Implementations/Library/Resolvers/Audio/AudioResolver.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System; @@ -30,7 +32,7 @@ namespace Emby.Server.Implementations.Library.Resolvers.Audio /// Gets the priority. /// </summary> /// <value>The priority.</value> - public override ResolverPriority Priority => ResolverPriority.Fourth; + public override ResolverPriority Priority => ResolverPriority.Fifth; public MultiItemResolverResult ResolveMultiple( Folder parent, @@ -201,7 +203,12 @@ namespace Emby.Server.Implementations.Library.Resolvers.Audio continue; } - var firstMedia = resolvedItem.Files.First(); + if (resolvedItem.Files.Count == 0) + { + continue; + } + + var firstMedia = resolvedItem.Files[0]; var libraryItem = new T { diff --git a/Emby.Server.Implementations/Library/Resolvers/Audio/MusicAlbumResolver.cs b/Emby.Server.Implementations/Library/Resolvers/Audio/MusicAlbumResolver.cs index 18ceb5e761..8e1eccb10a 100644 --- a/Emby.Server.Implementations/Library/Resolvers/Audio/MusicAlbumResolver.cs +++ b/Emby.Server.Implementations/Library/Resolvers/Audio/MusicAlbumResolver.cs @@ -1,3 +1,5 @@ +#nullable disable + using System; using System.Collections.Generic; using System.Linq; @@ -40,7 +42,7 @@ namespace Emby.Server.Implementations.Library.Resolvers.Audio /// Gets the priority. /// </summary> /// <value>The priority.</value> - public override ResolverPriority Priority => ResolverPriority.Second; + public override ResolverPriority Priority => ResolverPriority.Third; /// <summary> /// Resolves the specified args. diff --git a/Emby.Server.Implementations/Library/Resolvers/Audio/MusicArtistResolver.cs b/Emby.Server.Implementations/Library/Resolvers/Audio/MusicArtistResolver.cs index e9e688fa67..3d2ae95d24 100644 --- a/Emby.Server.Implementations/Library/Resolvers/Audio/MusicArtistResolver.cs +++ b/Emby.Server.Implementations/Library/Resolvers/Audio/MusicArtistResolver.cs @@ -1,3 +1,5 @@ +#nullable disable + using System; using System.Linq; using System.Threading.Tasks; @@ -79,11 +81,6 @@ namespace Emby.Server.Implementations.Library.Resolvers.Audio return new MusicArtist(); } - if (_config.Configuration.EnableSimpleArtistDetection) - { - return null; - } - // Avoid mis-identifying top folders if (args.Parent.IsRoot) { diff --git a/Emby.Server.Implementations/Library/Resolvers/BaseVideoResolver.cs b/Emby.Server.Implementations/Library/Resolvers/BaseVideoResolver.cs index 2f5e46038d..cdb492022b 100644 --- a/Emby.Server.Implementations/Library/Resolvers/BaseVideoResolver.cs +++ b/Emby.Server.Implementations/Library/Resolvers/BaseVideoResolver.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System; @@ -30,7 +32,7 @@ namespace Emby.Server.Implementations.Library.Resolvers /// </summary> /// <param name="args">The args.</param> /// <returns>`0.</returns> - protected override T Resolve(ItemResolveArgs args) + public override T Resolve(ItemResolveArgs args) { return ResolveVideo<T>(args, false); } @@ -42,14 +44,12 @@ namespace Emby.Server.Implementations.Library.Resolvers /// <param name="args">The args.</param> /// <param name="parseName">if set to <c>true</c> [parse name].</param> /// <returns>``0.</returns> - protected TVideoType ResolveVideo<TVideoType>(ItemResolveArgs args, bool parseName) + protected virtual TVideoType ResolveVideo<TVideoType>(ItemResolveArgs args, bool parseName) where TVideoType : Video, new() { - var namingOptions = ((LibraryManager)LibraryManager).GetNamingOptions(); + var namingOptions = LibraryManager.GetNamingOptions(); // If the path is a file check for a matching extensions - var parser = new VideoResolver(namingOptions); - if (args.IsDirectory) { TVideoType video = null; @@ -64,7 +64,7 @@ namespace Emby.Server.Implementations.Library.Resolvers { if (IsDvdDirectory(child.FullName, filename, args.DirectoryService)) { - videoInfo = parser.ResolveDirectory(args.Path); + videoInfo = VideoResolver.ResolveDirectory(args.Path, namingOptions); if (videoInfo == null) { @@ -82,7 +82,7 @@ namespace Emby.Server.Implementations.Library.Resolvers if (IsBluRayDirectory(child.FullName, filename, args.DirectoryService)) { - videoInfo = parser.ResolveDirectory(args.Path); + videoInfo = VideoResolver.ResolveDirectory(args.Path, namingOptions); if (videoInfo == null) { @@ -100,7 +100,7 @@ namespace Emby.Server.Implementations.Library.Resolvers } else if (IsDvdFile(filename)) { - videoInfo = parser.ResolveDirectory(args.Path); + videoInfo = VideoResolver.ResolveDirectory(args.Path, namingOptions); if (videoInfo == null) { @@ -130,7 +130,7 @@ namespace Emby.Server.Implementations.Library.Resolvers } else { - var videoInfo = parser.Resolve(args.Path, false, false); + var videoInfo = VideoResolver.Resolve(args.Path, false, namingOptions, false); if (videoInfo == null) { @@ -165,13 +165,13 @@ namespace Emby.Server.Implementations.Library.Resolvers protected void SetVideoType(Video video, VideoFileInfo videoInfo) { - var extension = Path.GetExtension(video.Path); - video.VideoType = string.Equals(extension, ".iso", StringComparison.OrdinalIgnoreCase) || - string.Equals(extension, ".img", StringComparison.OrdinalIgnoreCase) ? - VideoType.Iso : - VideoType.VideoFile; + var extension = Path.GetExtension(video.Path.AsSpan()); + video.VideoType = extension.Equals(".iso", StringComparison.OrdinalIgnoreCase) + || extension.Equals(".img", StringComparison.OrdinalIgnoreCase) + ? VideoType.Iso + : VideoType.VideoFile; - video.IsShortcut = string.Equals(extension, ".strm", StringComparison.OrdinalIgnoreCase); + video.IsShortcut = extension.Equals(".strm", StringComparison.OrdinalIgnoreCase); video.IsPlaceHolder = videoInfo.IsStub; if (videoInfo.IsStub) @@ -193,11 +193,11 @@ namespace Emby.Server.Implementations.Library.Resolvers { if (video.VideoType == VideoType.Iso) { - if (video.Path.IndexOf("dvd", StringComparison.OrdinalIgnoreCase) != -1) + if (video.Path.Contains("dvd", StringComparison.OrdinalIgnoreCase)) { video.IsoType = IsoType.Dvd; } - else if (video.Path.IndexOf("bluray", StringComparison.OrdinalIgnoreCase) != -1) + else if (video.Path.Contains("bluray", StringComparison.OrdinalIgnoreCase)) { video.IsoType = IsoType.BluRay; } @@ -250,10 +250,7 @@ namespace Emby.Server.Implementations.Library.Resolvers protected void Set3DFormat(Video video) { - var namingOptions = ((LibraryManager)LibraryManager).GetNamingOptions(); - - var resolver = new Format3DParser(namingOptions); - var result = resolver.Parse(video.Path); + var result = Format3DParser.Parse(video.Path, LibraryManager.GetNamingOptions()); Set3DFormat(video, result.Is3D, result.Format3D); } diff --git a/Emby.Server.Implementations/Library/Resolvers/Books/BookResolver.cs b/Emby.Server.Implementations/Library/Resolvers/Books/BookResolver.cs index 59af7ce8ac..68076730b3 100644 --- a/Emby.Server.Implementations/Library/Resolvers/Books/BookResolver.cs +++ b/Emby.Server.Implementations/Library/Resolvers/Books/BookResolver.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System; @@ -11,9 +13,9 @@ namespace Emby.Server.Implementations.Library.Resolvers.Books { public class BookResolver : MediaBrowser.Controller.Resolvers.ItemResolver<Book> { - private readonly string[] _validExtensions = { ".azw", ".azw3", ".cb7", ".cbr", ".cbt", ".cbz", ".epub", ".mobi", ".opf", ".pdf" }; + private readonly string[] _validExtensions = { ".azw", ".azw3", ".cb7", ".cbr", ".cbt", ".cbz", ".epub", ".mobi", ".pdf" }; - protected override Book Resolve(ItemResolveArgs args) + public override Book Resolve(ItemResolveArgs args) { var collectionType = args.GetCollectionType(); diff --git a/Emby.Server.Implementations/Library/Resolvers/FolderResolver.cs b/Emby.Server.Implementations/Library/Resolvers/FolderResolver.cs index 7dbce7a6ef..7aaee017dd 100644 --- a/Emby.Server.Implementations/Library/Resolvers/FolderResolver.cs +++ b/Emby.Server.Implementations/Library/Resolvers/FolderResolver.cs @@ -1,3 +1,5 @@ +#nullable disable + using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Resolvers; diff --git a/Emby.Server.Implementations/Library/Resolvers/ItemResolver.cs b/Emby.Server.Implementations/Library/Resolvers/ItemResolver.cs index 9ca76095b2..fa45ccf840 100644 --- a/Emby.Server.Implementations/Library/Resolvers/ItemResolver.cs +++ b/Emby.Server.Implementations/Library/Resolvers/ItemResolver.cs @@ -1,3 +1,5 @@ +#nullable disable + using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Resolvers; @@ -11,6 +13,12 @@ namespace Emby.Server.Implementations.Library.Resolvers public abstract class ItemResolver<T> : IItemResolver where T : BaseItem, new() { + /// <summary> + /// Gets the priority. + /// </summary> + /// <value>The priority.</value> + public virtual ResolverPriority Priority => ResolverPriority.First; + /// <summary> /// Resolves the specified args. /// </summary> @@ -21,12 +29,6 @@ namespace Emby.Server.Implementations.Library.Resolvers return null; } - /// <summary> - /// Gets the priority. - /// </summary> - /// <value>The priority.</value> - public virtual ResolverPriority Priority => ResolverPriority.First; - /// <summary> /// Sets initial values on the newly resolved item. /// </summary> diff --git a/Emby.Server.Implementations/Library/Resolvers/Movies/BoxSetResolver.cs b/Emby.Server.Implementations/Library/Resolvers/Movies/BoxSetResolver.cs index 295e9e120b..69d71d0d9a 100644 --- a/Emby.Server.Implementations/Library/Resolvers/Movies/BoxSetResolver.cs +++ b/Emby.Server.Implementations/Library/Resolvers/Movies/BoxSetResolver.cs @@ -1,3 +1,5 @@ +#nullable disable + using System; using System.IO; using MediaBrowser.Controller.Entities; diff --git a/Emby.Server.Implementations/Library/Resolvers/Movies/MovieResolver.cs b/Emby.Server.Implementations/Library/Resolvers/Movies/MovieResolver.cs index baf0e3cf91..889e29a6ba 100644 --- a/Emby.Server.Implementations/Library/Resolvers/Movies/MovieResolver.cs +++ b/Emby.Server.Implementations/Library/Resolvers/Movies/MovieResolver.cs @@ -1,9 +1,12 @@ +#nullable disable + using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Text.RegularExpressions; using Emby.Naming.Video; +using Jellyfin.Extensions; using MediaBrowser.Controller.Drawing; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.Movies; @@ -47,7 +50,7 @@ namespace Emby.Server.Implementations.Library.Resolvers.Movies /// Gets the priority. /// </summary> /// <value>The priority.</value> - public override ResolverPriority Priority => ResolverPriority.Third; + public override ResolverPriority Priority => ResolverPriority.Fourth; /// <inheritdoc /> public MultiItemResolverResult ResolveMultiple( @@ -69,6 +72,110 @@ namespace Emby.Server.Implementations.Library.Resolvers.Movies return result; } + /// <summary> + /// Resolves the specified args. + /// </summary> + /// <param name="args">The args.</param> + /// <returns>Video.</returns> + public override Video Resolve(ItemResolveArgs args) + { + var collectionType = args.GetCollectionType(); + + // Find movies with their own folders + if (args.IsDirectory) + { + if (IsInvalid(args.Parent, collectionType)) + { + return null; + } + + var files = args.FileSystemChildren + .Where(i => !LibraryManager.IgnoreFile(i, args.Parent)) + .ToList(); + + if (string.Equals(collectionType, CollectionType.MusicVideos, StringComparison.OrdinalIgnoreCase)) + { + return FindMovie<MusicVideo>(args, args.Path, args.Parent, files, args.DirectoryService, collectionType, false); + } + + if (string.Equals(collectionType, CollectionType.HomeVideos, StringComparison.OrdinalIgnoreCase)) + { + return FindMovie<Video>(args, args.Path, args.Parent, files, args.DirectoryService, collectionType, false); + } + + if (string.IsNullOrEmpty(collectionType)) + { + // Owned items will be caught by the plain video resolver + if (args.Parent == null) + { + // return FindMovie<Video>(args.Path, args.Parent, files, args.DirectoryService, collectionType); + return null; + } + + if (args.HasParent<Series>()) + { + return null; + } + + { + return FindMovie<Movie>(args, args.Path, args.Parent, files, args.DirectoryService, collectionType, true); + } + } + + if (string.Equals(collectionType, CollectionType.Movies, StringComparison.OrdinalIgnoreCase)) + { + return FindMovie<Movie>(args, args.Path, args.Parent, files, args.DirectoryService, collectionType, true); + } + + return null; + } + + // Handle owned items + if (args.Parent == null) + { + return base.Resolve(args); + } + + if (IsInvalid(args.Parent, collectionType)) + { + return null; + } + + Video item = null; + + if (string.Equals(collectionType, CollectionType.MusicVideos, StringComparison.OrdinalIgnoreCase)) + { + item = ResolveVideo<MusicVideo>(args, false); + } + + // To find a movie file, the collection type must be movies or boxsets + else if (string.Equals(collectionType, CollectionType.Movies, StringComparison.OrdinalIgnoreCase)) + { + item = ResolveVideo<Movie>(args, true); + } + else if (string.Equals(collectionType, CollectionType.HomeVideos, StringComparison.OrdinalIgnoreCase) || + string.Equals(collectionType, CollectionType.Photos, StringComparison.OrdinalIgnoreCase)) + { + item = ResolveVideo<Video>(args, false); + } + else if (string.IsNullOrEmpty(collectionType)) + { + if (args.HasParent<Series>()) + { + return null; + } + + item = ResolveVideo<Video>(args, false); + } + + if (item != null) + { + item.IsInMixedFolder = true; + } + + return item; + } + private MultiItemResolverResult ResolveMultipleInternal( Folder parent, List<FileSystemMetadata> files, @@ -151,10 +258,9 @@ namespace Emby.Server.Implementations.Library.Resolvers.Movies } } - var namingOptions = ((LibraryManager)LibraryManager).GetNamingOptions(); + var namingOptions = LibraryManager.GetNamingOptions(); - var resolver = new VideoListResolver(namingOptions); - var resolverResult = resolver.Resolve(files, suppportMultiEditions).ToList(); + var resolverResult = VideoListResolver.Resolve(files, namingOptions, suppportMultiEditions).ToList(); var result = new MultiItemResolverResult { @@ -216,110 +322,6 @@ namespace Emby.Server.Implementations.Library.Resolvers.Movies return string.Equals(result.Path, file.FullName, StringComparison.OrdinalIgnoreCase); } - /// <summary> - /// Resolves the specified args. - /// </summary> - /// <param name="args">The args.</param> - /// <returns>Video.</returns> - protected override Video Resolve(ItemResolveArgs args) - { - var collectionType = args.GetCollectionType(); - - // Find movies with their own folders - if (args.IsDirectory) - { - if (IsInvalid(args.Parent, collectionType)) - { - return null; - } - - var files = args.FileSystemChildren - .Where(i => !LibraryManager.IgnoreFile(i, args.Parent)) - .ToList(); - - if (string.Equals(collectionType, CollectionType.MusicVideos, StringComparison.OrdinalIgnoreCase)) - { - return FindMovie<MusicVideo>(args, args.Path, args.Parent, files, args.DirectoryService, collectionType, false); - } - - if (string.Equals(collectionType, CollectionType.HomeVideos, StringComparison.OrdinalIgnoreCase)) - { - return FindMovie<Video>(args, args.Path, args.Parent, files, args.DirectoryService, collectionType, false); - } - - if (string.IsNullOrEmpty(collectionType)) - { - // Owned items will be caught by the plain video resolver - if (args.Parent == null) - { - // return FindMovie<Video>(args.Path, args.Parent, files, args.DirectoryService, collectionType); - return null; - } - - if (args.HasParent<Series>()) - { - return null; - } - - { - return FindMovie<Movie>(args, args.Path, args.Parent, files, args.DirectoryService, collectionType, true); - } - } - - if (string.Equals(collectionType, CollectionType.Movies, StringComparison.OrdinalIgnoreCase)) - { - return FindMovie<Movie>(args, args.Path, args.Parent, files, args.DirectoryService, collectionType, true); - } - - return null; - } - - // Handle owned items - if (args.Parent == null) - { - return base.Resolve(args); - } - - if (IsInvalid(args.Parent, collectionType)) - { - return null; - } - - Video item = null; - - if (string.Equals(collectionType, CollectionType.MusicVideos, StringComparison.OrdinalIgnoreCase)) - { - item = ResolveVideo<MusicVideo>(args, false); - } - - // To find a movie file, the collection type must be movies or boxsets - else if (string.Equals(collectionType, CollectionType.Movies, StringComparison.OrdinalIgnoreCase)) - { - item = ResolveVideo<Movie>(args, true); - } - else if (string.Equals(collectionType, CollectionType.HomeVideos, StringComparison.OrdinalIgnoreCase) || - string.Equals(collectionType, CollectionType.Photos, StringComparison.OrdinalIgnoreCase)) - { - item = ResolveVideo<Video>(args, false); - } - else if (string.IsNullOrEmpty(collectionType)) - { - if (args.HasParent<Series>()) - { - return null; - } - - item = ResolveVideo<Video>(args, false); - } - - if (item != null) - { - item.IsInMixedFolder = true; - } - - return item; - } - /// <summary> /// Sets the initial item values. /// </summary> @@ -376,7 +378,7 @@ namespace Emby.Server.Implementations.Library.Resolvers.Movies { var multiDiscFolders = new List<FileSystemMetadata>(); - var libraryOptions = args.GetLibraryOptions(); + var libraryOptions = args.LibraryOptions; var supportPhotos = string.Equals(collectionType, CollectionType.HomeVideos, StringComparison.OrdinalIgnoreCase) && libraryOptions.EnablePhotos; var photos = new List<FileSystemMetadata>(); @@ -535,7 +537,7 @@ namespace Emby.Server.Implementations.Library.Resolvers.Movies return returnVideo; } - private bool IsInvalid(Folder parent, string collectionType) + private bool IsInvalid(Folder parent, ReadOnlySpan<char> collectionType) { if (parent != null) { @@ -545,12 +547,12 @@ namespace Emby.Server.Implementations.Library.Resolvers.Movies } } - if (string.IsNullOrEmpty(collectionType)) + if (collectionType.IsEmpty) { return false; } - return !_validCollectionTypes.Contains(collectionType, StringComparer.OrdinalIgnoreCase); + return !_validCollectionTypes.Contains(collectionType, StringComparison.OrdinalIgnoreCase); } } } diff --git a/Emby.Server.Implementations/Library/Resolvers/PhotoAlbumResolver.cs b/Emby.Server.Implementations/Library/Resolvers/PhotoAlbumResolver.cs index 3ac837057a..534bc80ddc 100644 --- a/Emby.Server.Implementations/Library/Resolvers/PhotoAlbumResolver.cs +++ b/Emby.Server.Implementations/Library/Resolvers/PhotoAlbumResolver.cs @@ -1,3 +1,5 @@ +#nullable disable + using System; using MediaBrowser.Controller.Drawing; using MediaBrowser.Controller.Entities; @@ -13,7 +15,7 @@ namespace Emby.Server.Implementations.Library.Resolvers public class PhotoAlbumResolver : FolderResolver<PhotoAlbum> { private readonly IImageProcessor _imageProcessor; - private ILibraryManager _libraryManager; + private readonly ILibraryManager _libraryManager; /// <summary> /// Initializes a new instance of the <see cref="PhotoAlbumResolver"/> class. @@ -26,6 +28,9 @@ namespace Emby.Server.Implementations.Library.Resolvers _libraryManager = libraryManager; } + /// <inheritdoc /> + public override ResolverPriority Priority => ResolverPriority.Second; + /// <summary> /// Resolves the specified args. /// </summary> @@ -39,8 +44,8 @@ namespace Emby.Server.Implementations.Library.Resolvers // Must be an image file within a photo collection var collectionType = args.GetCollectionType(); - if (string.Equals(collectionType, CollectionType.Photos, StringComparison.OrdinalIgnoreCase) || - (string.Equals(collectionType, CollectionType.HomeVideos, StringComparison.OrdinalIgnoreCase) && args.GetLibraryOptions().EnablePhotos)) + if (string.Equals(collectionType, CollectionType.Photos, StringComparison.OrdinalIgnoreCase) + || (string.Equals(collectionType, CollectionType.HomeVideos, StringComparison.OrdinalIgnoreCase) && args.LibraryOptions.EnablePhotos)) { if (HasPhotos(args)) { @@ -84,8 +89,5 @@ namespace Emby.Server.Implementations.Library.Resolvers return false; } - - /// <inheritdoc /> - public override ResolverPriority Priority => ResolverPriority.Second; } } diff --git a/Emby.Server.Implementations/Library/Resolvers/PhotoResolver.cs b/Emby.Server.Implementations/Library/Resolvers/PhotoResolver.cs index bcfcee9c6d..57bf40e9ec 100644 --- a/Emby.Server.Implementations/Library/Resolvers/PhotoResolver.cs +++ b/Emby.Server.Implementations/Library/Resolvers/PhotoResolver.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System; @@ -47,7 +49,7 @@ namespace Emby.Server.Implementations.Library.Resolvers var collectionType = args.CollectionType; if (string.Equals(collectionType, CollectionType.Photos, StringComparison.OrdinalIgnoreCase) - || (string.Equals(collectionType, CollectionType.HomeVideos, StringComparison.OrdinalIgnoreCase) && args.GetLibraryOptions().EnablePhotos)) + || (string.Equals(collectionType, CollectionType.HomeVideos, StringComparison.OrdinalIgnoreCase) && args.LibraryOptions.EnablePhotos)) { if (IsImageFile(args.Path, _imageProcessor)) { diff --git a/Emby.Server.Implementations/Library/Resolvers/PlaylistResolver.cs b/Emby.Server.Implementations/Library/Resolvers/PlaylistResolver.cs index 41561916f7..ecd44be477 100644 --- a/Emby.Server.Implementations/Library/Resolvers/PlaylistResolver.cs +++ b/Emby.Server.Implementations/Library/Resolvers/PlaylistResolver.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System; @@ -41,7 +43,7 @@ namespace Emby.Server.Implementations.Library.Resolvers } // It's a directory-based playlist if the directory contains a playlist file - var filePaths = Directory.EnumerateFiles(args.Path); + var filePaths = Directory.EnumerateFiles(args.Path, "*", new EnumerationOptions { IgnoreInaccessible = true }); if (filePaths.Any(f => f.EndsWith(PlaylistXmlSaver.DefaultPlaylistFilename, StringComparison.OrdinalIgnoreCase))) { return new Playlist @@ -63,7 +65,8 @@ namespace Emby.Server.Implementations.Library.Resolvers { Path = args.Path, Name = Path.GetFileNameWithoutExtension(args.Path), - IsInMixedFolder = true + IsInMixedFolder = true, + PlaylistMediaType = MediaType.Audio }; } } diff --git a/Emby.Server.Implementations/Library/Resolvers/SpecialFolderResolver.cs b/Emby.Server.Implementations/Library/Resolvers/SpecialFolderResolver.cs index 99f3041909..7b4e143348 100644 --- a/Emby.Server.Implementations/Library/Resolvers/SpecialFolderResolver.cs +++ b/Emby.Server.Implementations/Library/Resolvers/SpecialFolderResolver.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System; diff --git a/Emby.Server.Implementations/Library/Resolvers/TV/EpisodeResolver.cs b/Emby.Server.Implementations/Library/Resolvers/TV/EpisodeResolver.cs index 2f7af60c0d..d6ae910565 100644 --- a/Emby.Server.Implementations/Library/Resolvers/TV/EpisodeResolver.cs +++ b/Emby.Server.Implementations/Library/Resolvers/TV/EpisodeResolver.cs @@ -1,5 +1,8 @@ +#nullable disable + using System; using System.Linq; +using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.TV; using MediaBrowser.Controller.Library; using MediaBrowser.Model.Entities; @@ -11,12 +14,21 @@ namespace Emby.Server.Implementations.Library.Resolvers.TV /// </summary> public class EpisodeResolver : BaseVideoResolver<Episode> { + /// <summary> + /// Initializes a new instance of the <see cref="EpisodeResolver"/> class. + /// </summary> + /// <param name="libraryManager">The library manager.</param> + public EpisodeResolver(ILibraryManager libraryManager) + : base(libraryManager) + { + } + /// <summary> /// Resolves the specified args. /// </summary> /// <param name="args">The args.</param> /// <returns>Episode.</returns> - protected override Episode Resolve(ItemResolveArgs args) + public override Episode Resolve(ItemResolveArgs args) { var parent = args.Parent; @@ -25,30 +37,23 @@ namespace Emby.Server.Implementations.Library.Resolvers.TV return null; } - var season = parent as Season; - // Just in case the user decided to nest episodes. // Not officially supported but in some cases we can handle it. - if (season == null) - { - season = parent.GetParents().OfType<Season>().FirstOrDefault(); - } - // If the parent is a Season or Series, then this is an Episode if the VideoResolver returns something + var season = parent as Season ?? parent.GetParents().OfType<Season>().FirstOrDefault(); + + // If the parent is a Season or Series and the parent is not an extras folder, then this is an Episode if the VideoResolver returns something // Also handle flat tv folders - if (season != null || - string.Equals(args.GetCollectionType(), CollectionType.TvShows, StringComparison.OrdinalIgnoreCase) || - args.HasParent<Series>()) + if ((season != null || + string.Equals(args.GetCollectionType(), CollectionType.TvShows, StringComparison.OrdinalIgnoreCase) || + args.HasParent<Series>()) + && (parent is Series || !BaseItem.AllExtrasTypesFolderNames.Contains(parent.Name, StringComparer.OrdinalIgnoreCase))) { var episode = ResolveVideo<Episode>(args, false); if (episode != null) { - var series = parent as Series; - if (series == null) - { - series = parent.GetParents().OfType<Series>().FirstOrDefault(); - } + var series = parent as Series ?? parent.GetParents().OfType<Series>().FirstOrDefault(); if (series != null) { @@ -74,14 +79,5 @@ namespace Emby.Server.Implementations.Library.Resolvers.TV return null; } - - /// <summary> - /// Initializes a new instance of the <see cref="EpisodeResolver"/> class. - /// </summary> - /// <param name="libraryManager">The library manager.</param> - public EpisodeResolver(ILibraryManager libraryManager) - : base(libraryManager) - { - } } } diff --git a/Emby.Server.Implementations/Library/Resolvers/TV/SeasonResolver.cs b/Emby.Server.Implementations/Library/Resolvers/TV/SeasonResolver.cs index 3332e1806c..7d707df182 100644 --- a/Emby.Server.Implementations/Library/Resolvers/TV/SeasonResolver.cs +++ b/Emby.Server.Implementations/Library/Resolvers/TV/SeasonResolver.cs @@ -1,3 +1,5 @@ +#nullable disable + using System.Globalization; using Emby.Naming.TV; using MediaBrowser.Controller.Entities.TV; @@ -88,7 +90,7 @@ namespace Emby.Server.Implementations.Library.Resolvers.TV CultureInfo.InvariantCulture, _localization.GetLocalizedString("NameSeasonNumber"), seasonNumber, - args.GetLibraryOptions().PreferredMetadataLanguage); + args.LibraryOptions.PreferredMetadataLanguage); } return season; diff --git a/Emby.Server.Implementations/Library/Resolvers/TV/SeriesResolver.cs b/Emby.Server.Implementations/Library/Resolvers/TV/SeriesResolver.cs index 732bfd94dc..a1562abd31 100644 --- a/Emby.Server.Implementations/Library/Resolvers/TV/SeriesResolver.cs +++ b/Emby.Server.Implementations/Library/Resolvers/TV/SeriesResolver.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System; @@ -19,19 +21,16 @@ namespace Emby.Server.Implementations.Library.Resolvers.TV /// </summary> public class SeriesResolver : FolderResolver<Series> { - private readonly IFileSystem _fileSystem; private readonly ILogger<SeriesResolver> _logger; private readonly ILibraryManager _libraryManager; /// <summary> /// Initializes a new instance of the <see cref="SeriesResolver"/> class. /// </summary> - /// <param name="fileSystem">The file system.</param> /// <param name="logger">The logger.</param> /// <param name="libraryManager">The library manager.</param> - public SeriesResolver(IFileSystem fileSystem, ILogger<SeriesResolver> logger, ILibraryManager libraryManager) + public SeriesResolver(ILogger<SeriesResolver> logger, ILibraryManager libraryManager) { - _fileSystem = fileSystem; _logger = logger; _libraryManager = libraryManager; } @@ -59,15 +58,6 @@ namespace Emby.Server.Implementations.Library.Resolvers.TV var collectionType = args.GetCollectionType(); if (string.Equals(collectionType, CollectionType.TvShows, StringComparison.OrdinalIgnoreCase)) { - // if (args.ContainsFileSystemEntryByName("tvshow.nfo")) - //{ - // return new Series - // { - // Path = args.Path, - // Name = Path.GetFileName(args.Path) - // }; - //} - var configuredContentType = _libraryManager.GetConfiguredContentType(args.Path); if (!string.Equals(configuredContentType, CollectionType.TvShows, StringComparison.OrdinalIgnoreCase)) { @@ -100,7 +90,7 @@ namespace Emby.Server.Implementations.Library.Resolvers.TV return null; } - if (IsSeriesFolder(args.Path, args.FileSystemChildren, args.DirectoryService, _fileSystem, _logger, _libraryManager, false)) + if (IsSeriesFolder(args.Path, args.FileSystemChildren, _logger, _libraryManager, false)) { return new Series { @@ -117,8 +107,6 @@ namespace Emby.Server.Implementations.Library.Resolvers.TV public static bool IsSeriesFolder( string path, IEnumerable<FileSystemMetadata> fileSystemChildren, - IDirectoryService directoryService, - IFileSystem fileSystem, ILogger<SeriesResolver> logger, ILibraryManager libraryManager, bool isTvContentType) @@ -127,7 +115,7 @@ namespace Emby.Server.Implementations.Library.Resolvers.TV { if (child.IsDirectory) { - if (IsSeasonFolder(child.FullName, isTvContentType, libraryManager)) + if (IsSeasonFolder(child.FullName, isTvContentType)) { logger.LogDebug("{Path} is a series because of season folder {Dir}.", path, child.FullName); return true; @@ -160,32 +148,13 @@ namespace Emby.Server.Implementations.Library.Resolvers.TV return false; } - /// <summary> - /// Determines whether [is place holder] [the specified path]. - /// </summary> - /// <param name="path">The path.</param> - /// <returns><c>true</c> if [is place holder] [the specified path]; otherwise, <c>false</c>.</returns> - /// <exception cref="ArgumentNullException">path</exception> - private static bool IsVideoPlaceHolder(string path) - { - if (string.IsNullOrEmpty(path)) - { - throw new ArgumentNullException(nameof(path)); - } - - var extension = Path.GetExtension(path); - - return string.Equals(extension, ".disc", StringComparison.OrdinalIgnoreCase); - } - /// <summary> /// Determines whether [is season folder] [the specified path]. /// </summary> /// <param name="path">The path.</param> /// <param name="isTvContentType">if set to <c>true</c> [is tv content type].</param> - /// <param name="libraryManager">The library manager.</param> /// <returns><c>true</c> if [is season folder] [the specified path]; otherwise, <c>false</c>.</returns> - private static bool IsSeasonFolder(string path, bool isTvContentType, ILibraryManager libraryManager) + private static bool IsSeasonFolder(string path, bool isTvContentType) { var seasonNumber = SeasonPathParser.Parse(path, isTvContentType, isTvContentType).SeasonNumber; diff --git a/Emby.Server.Implementations/Library/Resolvers/VideoResolver.cs b/Emby.Server.Implementations/Library/Resolvers/VideoResolver.cs index 62268fce90..9599faea4b 100644 --- a/Emby.Server.Implementations/Library/Resolvers/VideoResolver.cs +++ b/Emby.Server.Implementations/Library/Resolvers/VideoResolver.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using MediaBrowser.Controller.Entities; diff --git a/Emby.Server.Implementations/Library/SearchEngine.cs b/Emby.Server.Implementations/Library/SearchEngine.cs index 9a69bce0e6..9d0a24a88a 100644 --- a/Emby.Server.Implementations/Library/SearchEngine.cs +++ b/Emby.Server.Implementations/Library/SearchEngine.cs @@ -1,18 +1,19 @@ +#nullable disable + #pragma warning disable CS1591 using System; using System.Collections.Generic; using System.Linq; +using Diacritics.Extensions; using Jellyfin.Data.Entities; using Jellyfin.Data.Enums; using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.Audio; -using MediaBrowser.Controller.Extensions; using MediaBrowser.Controller.Library; using MediaBrowser.Model.Querying; using MediaBrowser.Model.Search; -using Microsoft.Extensions.Logging; using Genre = MediaBrowser.Controller.Entities.Genre; using Person = MediaBrowser.Controller.Entities.Person; @@ -47,7 +48,7 @@ namespace Emby.Server.Implementations.Library if (query.Limit.HasValue) { - results = results.GetRange(0, query.Limit.Value); + results = results.GetRange(0, Math.Min(query.Limit.Value, results.Count)); } return new QueryResult<SearchHintInfo> @@ -87,61 +88,61 @@ namespace Emby.Server.Implementations.Library var excludeItemTypes = query.ExcludeItemTypes.ToList(); var includeItemTypes = (query.IncludeItemTypes ?? Array.Empty<string>()).ToList(); - excludeItemTypes.Add(typeof(Year).Name); - excludeItemTypes.Add(typeof(Folder).Name); + excludeItemTypes.Add(nameof(Year)); + excludeItemTypes.Add(nameof(Folder)); if (query.IncludeGenres && (includeItemTypes.Count == 0 || includeItemTypes.Contains("Genre", StringComparer.OrdinalIgnoreCase))) { if (!query.IncludeMedia) { - AddIfMissing(includeItemTypes, typeof(Genre).Name); - AddIfMissing(includeItemTypes, typeof(MusicGenre).Name); + AddIfMissing(includeItemTypes, nameof(Genre)); + AddIfMissing(includeItemTypes, nameof(MusicGenre)); } } else { - AddIfMissing(excludeItemTypes, typeof(Genre).Name); - AddIfMissing(excludeItemTypes, typeof(MusicGenre).Name); + AddIfMissing(excludeItemTypes, nameof(Genre)); + AddIfMissing(excludeItemTypes, nameof(MusicGenre)); } if (query.IncludePeople && (includeItemTypes.Count == 0 || includeItemTypes.Contains("People", StringComparer.OrdinalIgnoreCase) || includeItemTypes.Contains("Person", StringComparer.OrdinalIgnoreCase))) { if (!query.IncludeMedia) { - AddIfMissing(includeItemTypes, typeof(Person).Name); + AddIfMissing(includeItemTypes, nameof(Person)); } } else { - AddIfMissing(excludeItemTypes, typeof(Person).Name); + AddIfMissing(excludeItemTypes, nameof(Person)); } if (query.IncludeStudios && (includeItemTypes.Count == 0 || includeItemTypes.Contains("Studio", StringComparer.OrdinalIgnoreCase))) { if (!query.IncludeMedia) { - AddIfMissing(includeItemTypes, typeof(Studio).Name); + AddIfMissing(includeItemTypes, nameof(Studio)); } } else { - AddIfMissing(excludeItemTypes, typeof(Studio).Name); + AddIfMissing(excludeItemTypes, nameof(Studio)); } if (query.IncludeArtists && (includeItemTypes.Count == 0 || includeItemTypes.Contains("MusicArtist", StringComparer.OrdinalIgnoreCase))) { if (!query.IncludeMedia) { - AddIfMissing(includeItemTypes, typeof(MusicArtist).Name); + AddIfMissing(includeItemTypes, nameof(MusicArtist)); } } else { - AddIfMissing(excludeItemTypes, typeof(MusicArtist).Name); + AddIfMissing(excludeItemTypes, nameof(MusicArtist)); } - AddIfMissing(excludeItemTypes, typeof(CollectionFolder).Name); - AddIfMissing(excludeItemTypes, typeof(Folder).Name); + AddIfMissing(excludeItemTypes, nameof(CollectionFolder)); + AddIfMissing(excludeItemTypes, nameof(Folder)); var mediaTypes = query.MediaTypes.ToList(); if (includeItemTypes.Count > 0) @@ -156,8 +157,8 @@ namespace Emby.Server.Implementations.Library ExcludeItemTypes = excludeItemTypes.ToArray(), IncludeItemTypes = includeItemTypes.ToArray(), Limit = query.Limit, - IncludeItemsByName = string.IsNullOrEmpty(query.ParentId), - ParentId = string.IsNullOrEmpty(query.ParentId) ? Guid.Empty : new Guid(query.ParentId), + IncludeItemsByName = !query.ParentId.HasValue, + ParentId = query.ParentId ?? Guid.Empty, OrderBy = new[] { (ItemSortBy.SortName, SortOrder.Ascending) }, Recursive = true, diff --git a/Emby.Server.Implementations/Library/UserDataManager.cs b/Emby.Server.Implementations/Library/UserDataManager.cs index f9e5e6bbcd..8aa605a903 100644 --- a/Emby.Server.Implementations/Library/UserDataManager.cs +++ b/Emby.Server.Implementations/Library/UserDataManager.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System; @@ -13,6 +15,7 @@ using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Persistence; using MediaBrowser.Model.Dto; using MediaBrowser.Model.Entities; +using AudioBook = MediaBrowser.Controller.Entities.AudioBook; using Book = MediaBrowser.Controller.Entities.Book; namespace Emby.Server.Implementations.Library @@ -219,7 +222,7 @@ namespace Emby.Server.Implementations.Library var hasRuntime = runtimeTicks > 0; // If a position has been reported, and if we know the duration - if (positionTicks > 0 && hasRuntime) + if (positionTicks > 0 && hasRuntime && item is not AudioBook && item is not Book) { var pctIn = decimal.Divide(positionTicks, runtimeTicks) * 100; @@ -238,13 +241,30 @@ namespace Emby.Server.Implementations.Library { // Enforce MinResumeDuration var durationSeconds = TimeSpan.FromTicks(runtimeTicks).TotalSeconds; - if (durationSeconds < _config.Configuration.MinResumeDurationSeconds && !(item is Book)) + if (durationSeconds < _config.Configuration.MinResumeDurationSeconds) { positionTicks = 0; data.Played = playedToCompletion = true; } } } + else if (positionTicks > 0 && hasRuntime && item is AudioBook) + { + var playbackPositionInMinutes = TimeSpan.FromTicks(positionTicks).TotalMinutes; + var remainingTimeInMinutes = TimeSpan.FromTicks(runtimeTicks - positionTicks).TotalMinutes; + + if (playbackPositionInMinutes < _config.Configuration.MinAudiobookResume) + { + // ignore progress during the beginning + positionTicks = 0; + } + else if (remainingTimeInMinutes < _config.Configuration.MaxAudiobookResume || positionTicks >= runtimeTicks) + { + // mark as completed close to the end + positionTicks = 0; + data.Played = playedToCompletion = true; + } + } else if (!hasRuntime) { // If we don't know the runtime we'll just have to assume it was fully played diff --git a/Emby.Server.Implementations/Library/UserViewManager.cs b/Emby.Server.Implementations/Library/UserViewManager.cs index f51657c63b..e2da672a3c 100644 --- a/Emby.Server.Implementations/Library/UserViewManager.cs +++ b/Emby.Server.Implementations/Library/UserViewManager.cs @@ -1,8 +1,9 @@ +#nullable disable + #pragma warning disable CS1591 using System; using System.Collections.Generic; -using System.Globalization; using System.Linq; using System.Threading; using Jellyfin.Data.Entities; @@ -129,23 +130,23 @@ namespace Emby.Server.Implementations.Library if (!query.IncludeHidden) { - list = list.Where(i => !user.GetPreference(PreferenceKind.MyMediaExcludes).Contains(i.Id.ToString("N", CultureInfo.InvariantCulture))).ToList(); + list = list.Where(i => !user.GetPreferenceValues<Guid>(PreferenceKind.MyMediaExcludes).Contains(i.Id)).ToList(); } var sorted = _libraryManager.Sort(list, user, new[] { ItemSortBy.SortName }, SortOrder.Ascending).ToList(); - var orders = user.GetPreference(PreferenceKind.OrderedViews).ToList(); + var orders = user.GetPreferenceValues<Guid>(PreferenceKind.OrderedViews); return list .OrderBy(i => { - var index = orders.IndexOf(i.Id.ToString("N", CultureInfo.InvariantCulture)); + var index = Array.IndexOf(orders, i.Id); if (index == -1 && i is UserView view && view.DisplayParentId != Guid.Empty) { - index = orders.IndexOf(view.DisplayParentId.ToString("N", CultureInfo.InvariantCulture)); + index = Array.IndexOf(orders, view.DisplayParentId); } return index == -1 ? int.MaxValue : index; @@ -280,8 +281,8 @@ namespace Emby.Server.Implementations.Library { parents = _libraryManager.GetUserRootFolder().GetChildren(user, true) .Where(i => i is Folder) - .Where(i => !user.GetPreference(PreferenceKind.LatestItemExcludes) - .Contains(i.Id.ToString("N", CultureInfo.InvariantCulture))) + .Where(i => !user.GetPreferenceValues<Guid>(PreferenceKind.LatestItemExcludes) + .Contains(i.Id)) .ToList(); } diff --git a/Emby.Server.Implementations/Library/Validators/ArtistsValidator.cs b/Emby.Server.Implementations/Library/Validators/ArtistsValidator.cs index d4c8c35e65..f9a3e2c64b 100644 --- a/Emby.Server.Implementations/Library/Validators/ArtistsValidator.cs +++ b/Emby.Server.Implementations/Library/Validators/ArtistsValidator.cs @@ -81,7 +81,7 @@ namespace Emby.Server.Implementations.Library.Validators var deadEntities = _libraryManager.GetItemList(new InternalItemsQuery { - IncludeItemTypes = new[] { typeof(MusicArtist).Name }, + IncludeItemTypes = new[] { nameof(MusicArtist) }, IsDeadArtist = true, IsLocked = false }).Cast<MusicArtist>().ToList(); diff --git a/Emby.Server.Implementations/Library/Validators/PeopleValidator.cs b/Emby.Server.Implementations/Library/Validators/PeopleValidator.cs index 8275c873af..8739a9e1be 100644 --- a/Emby.Server.Implementations/Library/Validators/PeopleValidator.cs +++ b/Emby.Server.Implementations/Library/Validators/PeopleValidator.cs @@ -91,7 +91,7 @@ namespace Emby.Server.Implementations.Library.Validators var deadEntities = _libraryManager.GetItemList(new InternalItemsQuery { - IncludeItemTypes = new[] { typeof(Person).Name }, + IncludeItemTypes = new[] { nameof(Person) }, IsDeadPerson = true, IsLocked = false }); diff --git a/Emby.Server.Implementations/Library/Validators/StudiosValidator.cs b/Emby.Server.Implementations/Library/Validators/StudiosValidator.cs index ca35adfff7..9a8c5f39d5 100644 --- a/Emby.Server.Implementations/Library/Validators/StudiosValidator.cs +++ b/Emby.Server.Implementations/Library/Validators/StudiosValidator.cs @@ -80,7 +80,7 @@ namespace Emby.Server.Implementations.Library.Validators var deadEntities = _libraryManager.GetItemList(new InternalItemsQuery { - IncludeItemTypes = new[] { typeof(Studio).Name }, + IncludeItemTypes = new[] { nameof(Studio) }, IsDeadStudio = true, IsLocked = false }); diff --git a/Emby.Server.Implementations/LiveTv/EmbyTV/DirectRecorder.cs b/Emby.Server.Implementations/LiveTv/EmbyTV/DirectRecorder.cs index 44560d1e21..3fcadf5b1b 100644 --- a/Emby.Server.Implementations/LiveTv/EmbyTV/DirectRecorder.cs +++ b/Emby.Server.Implementations/LiveTv/EmbyTV/DirectRecorder.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System; @@ -45,7 +47,8 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV { Directory.CreateDirectory(Path.GetDirectoryName(targetFile)); - using (var output = new FileStream(targetFile, FileMode.Create, FileAccess.Write, FileShare.Read)) + // use FileShare.None as this bypasses dotnet bug dotnet/runtime#42790 . + using (var output = new FileStream(targetFile, FileMode.Create, FileAccess.Write, FileShare.None)) { onStarted(); @@ -70,18 +73,20 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV Directory.CreateDirectory(Path.GetDirectoryName(targetFile)); - await using var output = new FileStream(targetFile, FileMode.Create, FileAccess.Write, FileShare.Read); + // use FileShare.None as this bypasses dotnet bug dotnet/runtime#42790 . + await using var output = new FileStream(targetFile, FileMode.Create, FileAccess.Write, FileShare.None); onStarted(); _logger.LogInformation("Copying recording stream to file {0}", targetFile); // The media source if infinite so we need to handle stopping ourselves - var durationToken = new CancellationTokenSource(duration); - cancellationToken = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, durationToken.Token).Token; + using var durationToken = new CancellationTokenSource(duration); + using var linkedCancellationToken = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, durationToken.Token); + cancellationToken = linkedCancellationToken.Token; await _streamHelper.CopyUntilCancelled( - await response.Content.ReadAsStreamAsync().ConfigureAwait(false), + await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false), output, IODefaults.CopyToBufferSize, cancellationToken).ConfigureAwait(false); diff --git a/Emby.Server.Implementations/LiveTv/EmbyTV/EmbyTV.cs b/Emby.Server.Implementations/LiveTv/EmbyTV/EmbyTV.cs index ca60c33664..7970631201 100644 --- a/Emby.Server.Implementations/LiveTv/EmbyTV/EmbyTV.cs +++ b/Emby.Server.Implementations/LiveTv/EmbyTV/EmbyTV.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System; @@ -17,7 +19,6 @@ using Jellyfin.Data.Enums; using Jellyfin.Data.Events; using MediaBrowser.Common.Configuration; using MediaBrowser.Common.Extensions; -using MediaBrowser.Common.Net; using MediaBrowser.Common.Progress; using MediaBrowser.Controller; using MediaBrowser.Controller.Configuration; @@ -36,7 +37,6 @@ using MediaBrowser.Model.LiveTv; using MediaBrowser.Model.MediaInfo; using MediaBrowser.Model.Providers; using MediaBrowser.Model.Querying; -using MediaBrowser.Model.Serialization; using Microsoft.Extensions.Logging; namespace Emby.Server.Implementations.LiveTv.EmbyTV @@ -51,7 +51,6 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV private readonly ILogger<EmbyTV> _logger; private readonly IHttpClientFactory _httpClientFactory; private readonly IServerConfigurationManager _config; - private readonly IJsonSerializer _jsonSerializer; private readonly ItemDataProvider<SeriesTimerInfo> _seriesTimerProvider; private readonly TimerManager _timerProvider; @@ -81,7 +80,6 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV IStreamHelper streamHelper, IMediaSourceManager mediaSourceManager, ILogger<EmbyTV> logger, - IJsonSerializer jsonSerializer, IHttpClientFactory httpClientFactory, IServerConfigurationManager config, ILiveTvManager liveTvManager, @@ -103,12 +101,11 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV _providerManager = providerManager; _mediaEncoder = mediaEncoder; _liveTvManager = (LiveTvManager)liveTvManager; - _jsonSerializer = jsonSerializer; _mediaSourceManager = mediaSourceManager; _streamHelper = streamHelper; - _seriesTimerProvider = new SeriesTimerManager(jsonSerializer, _logger, Path.Combine(DataPath, "seriestimers.json")); - _timerProvider = new TimerManager(jsonSerializer, _logger, Path.Combine(DataPath, "timers.json")); + _seriesTimerProvider = new SeriesTimerManager(_logger, Path.Combine(DataPath, "seriestimers.json")); + _timerProvider = new TimerManager(_logger, Path.Combine(DataPath, "timers.json")); _timerProvider.TimerFired += OnTimerProviderTimerFired; _config.NamedConfigurationUpdated += OnNamedConfigurationUpdated; @@ -806,22 +803,22 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV public ActiveRecordingInfo GetActiveRecordingInfo(string path) { - if (string.IsNullOrWhiteSpace(path)) + if (string.IsNullOrWhiteSpace(path) || _activeRecordings.IsEmpty) { return null; } - foreach (var recording in _activeRecordings.Values) + foreach (var (_, recordingInfo) in _activeRecordings) { - if (string.Equals(recording.Path, path, StringComparison.Ordinal) && !recording.CancellationTokenSource.IsCancellationRequested) + if (string.Equals(recordingInfo.Path, path, StringComparison.Ordinal) && !recordingInfo.CancellationTokenSource.IsCancellationRequested) { - var timer = recording.Timer; + var timer = recordingInfo.Timer; if (timer.Status != RecordingStatus.InProgress) { return null; } - return recording; + return recordingInfo; } } @@ -1052,7 +1049,7 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV IgnoreIndex = true }; - await new LiveStreamHelper(_mediaEncoder, _logger, _jsonSerializer, _config.CommonApplicationPaths) + await new LiveStreamHelper(_mediaEncoder, _logger, _config.CommonApplicationPaths) .AddMediaInfoWithProbe(stream, false, false, cancellationToken).ConfigureAwait(false); return new List<MediaSourceInfo> @@ -1626,16 +1623,14 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV } return _activeRecordings - .Values - .ToList() - .Any(i => string.Equals(i.Path, path, StringComparison.OrdinalIgnoreCase) && !string.Equals(i.Timer.Id, timerId, StringComparison.OrdinalIgnoreCase)); + .Any(i => string.Equals(i.Value.Path, path, StringComparison.OrdinalIgnoreCase) && !string.Equals(i.Value.Timer.Id, timerId, StringComparison.OrdinalIgnoreCase)); } private IRecorder GetRecorder(MediaSourceInfo mediaSource) { if (mediaSource.RequiresLooping || !(mediaSource.Container ?? string.Empty).EndsWith("ts", StringComparison.OrdinalIgnoreCase) || (mediaSource.Protocol != MediaProtocol.File && mediaSource.Protocol != MediaProtocol.Http)) { - return new EncodedRecorder(_logger, _mediaEncoder, _config.ApplicationPaths, _jsonSerializer); + return new EncodedRecorder(_logger, _mediaEncoder, _config.ApplicationPaths, _config); } return new DirectRecorder(_logger, _httpClientFactory, _streamHelper); @@ -1790,7 +1785,7 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV { var program = string.IsNullOrWhiteSpace(timer.ProgramId) ? null : _libraryManager.GetItemList(new InternalItemsQuery { - IncludeItemTypes = new[] { typeof(LiveTvProgram).Name }, + IncludeItemTypes = new[] { nameof(LiveTvProgram) }, Limit = 1, ExternalId = timer.ProgramId, DtoOptions = new DtoOptions(true) @@ -1860,7 +1855,8 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV return; } - using (var stream = new FileStream(nfoPath, FileMode.Create, FileAccess.Write, FileShare.Read)) + // use FileShare.None as this bypasses dotnet bug dotnet/runtime#42790 . + using (var stream = new FileStream(nfoPath, FileMode.Create, FileAccess.Write, FileShare.None)) { var settings = new XmlWriterSettings { @@ -1924,7 +1920,8 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV return; } - using (var stream = new FileStream(nfoPath, FileMode.Create, FileAccess.Write, FileShare.Read)) + // use FileShare.None as this bypasses dotnet bug dotnet/runtime#42790 . + using (var stream = new FileStream(nfoPath, FileMode.Create, FileAccess.Write, FileShare.None)) { var settings = new XmlWriterSettings { @@ -2151,7 +2148,7 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV { var query = new InternalItemsQuery { - IncludeItemTypes = new string[] { typeof(LiveTvProgram).Name }, + IncludeItemTypes = new string[] { nameof(LiveTvProgram) }, Limit = 1, DtoOptions = new DtoOptions(true) { @@ -2242,14 +2239,10 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV var enabledTimersForSeries = new List<TimerInfo>(); foreach (var timer in allTimers) { - var existingTimer = _timerProvider.GetTimer(timer.Id); - - if (existingTimer == null) - { - existingTimer = string.IsNullOrWhiteSpace(timer.ProgramId) + var existingTimer = _timerProvider.GetTimer(timer.Id) + ?? (string.IsNullOrWhiteSpace(timer.ProgramId) ? null - : _timerProvider.GetTimerByProgramId(timer.ProgramId); - } + : _timerProvider.GetTimerByProgramId(timer.ProgramId)); if (existingTimer == null) { @@ -2370,7 +2363,7 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV var query = new InternalItemsQuery { - IncludeItemTypes = new string[] { typeof(LiveTvProgram).Name }, + IncludeItemTypes = new string[] { nameof(LiveTvProgram) }, ExternalSeriesId = seriesTimer.SeriesId, DtoOptions = new DtoOptions(true) { @@ -2405,7 +2398,7 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV channel = _libraryManager.GetItemList( new InternalItemsQuery { - IncludeItemTypes = new string[] { typeof(LiveTvChannel).Name }, + IncludeItemTypes = new string[] { nameof(LiveTvChannel) }, ItemIds = new[] { parent.ChannelId }, DtoOptions = new DtoOptions() }).FirstOrDefault() as LiveTvChannel; @@ -2464,7 +2457,7 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV channel = _libraryManager.GetItemList( new InternalItemsQuery { - IncludeItemTypes = new string[] { typeof(LiveTvChannel).Name }, + IncludeItemTypes = new string[] { nameof(LiveTvChannel) }, ItemIds = new[] { programInfo.ChannelId }, DtoOptions = new DtoOptions() }).FirstOrDefault() as LiveTvChannel; @@ -2529,7 +2522,7 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV var seriesIds = _libraryManager.GetItemIds( new InternalItemsQuery { - IncludeItemTypes = new[] { typeof(Series).Name }, + IncludeItemTypes = new[] { nameof(Series) }, Name = program.Name }).ToArray(); @@ -2542,7 +2535,7 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV { var result = _libraryManager.GetItemIds(new InternalItemsQuery { - IncludeItemTypes = new[] { typeof(Episode).Name }, + IncludeItemTypes = new[] { nameof(Episode) }, ParentIndexNumber = program.SeasonNumber.Value, IndexNumber = program.EpisodeNumber.Value, AncestorIds = seriesIds, @@ -2608,7 +2601,7 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV { Locations = new string[] { customPath }, Name = "Recorded Movies", - CollectionType = CollectionType.Movies + CollectionType = CollectionTypeOptions.Movies }; } @@ -2619,7 +2612,7 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV { Locations = new string[] { customPath }, Name = "Recorded Shows", - CollectionType = CollectionType.TvShows + CollectionType = CollectionTypeOptions.TvShows }; } } diff --git a/Emby.Server.Implementations/LiveTv/EmbyTV/EncodedRecorder.cs b/Emby.Server.Implementations/LiveTv/EmbyTV/EncodedRecorder.cs index 3e5457dbd4..93781cb7ba 100644 --- a/Emby.Server.Implementations/LiveTv/EmbyTV/EncodedRecorder.cs +++ b/Emby.Server.Implementations/LiveTv/EmbyTV/EncodedRecorder.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System; @@ -6,14 +8,18 @@ using System.Diagnostics; using System.Globalization; using System.IO; using System.Text; +using System.Text.Json; using System.Threading; using System.Threading.Tasks; +using Jellyfin.Extensions; +using Jellyfin.Extensions.Json; +using MediaBrowser.Common.Configuration; using MediaBrowser.Controller; +using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.MediaEncoding; using MediaBrowser.Model.Dto; using MediaBrowser.Model.IO; -using MediaBrowser.Model.Serialization; using Microsoft.Extensions.Logging; namespace Emby.Server.Implementations.LiveTv.EmbyTV @@ -23,9 +29,9 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV private readonly ILogger _logger; private readonly IMediaEncoder _mediaEncoder; private readonly IServerApplicationPaths _appPaths; - private readonly IJsonSerializer _json; private readonly TaskCompletionSource<bool> _taskCompletionSource = new TaskCompletionSource<bool>(); - + private readonly IServerConfigurationManager _serverConfigurationManager; + private readonly JsonSerializerOptions _jsonOptions = JsonDefaults.Options; private bool _hasExited; private Stream _logFileStream; private string _targetPath; @@ -35,12 +41,12 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV ILogger logger, IMediaEncoder mediaEncoder, IServerApplicationPaths appPaths, - IJsonSerializer json) + IServerConfigurationManager serverConfigurationManager) { _logger = logger; _mediaEncoder = mediaEncoder; _appPaths = appPaths; - _json = json; + _serverConfigurationManager = serverConfigurationManager; } private static bool CopySubtitles => false; @@ -61,7 +67,7 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV _logger.LogInformation("Recording completed to file {0}", targetFile); } - private Task RecordFromFile(MediaSourceInfo mediaSource, string inputFile, string targetFile, TimeSpan duration, Action onStarted, CancellationToken cancellationToken) + private async Task RecordFromFile(MediaSourceInfo mediaSource, string inputFile, string targetFile, TimeSpan duration, Action onStarted, CancellationToken cancellationToken) { _targetPath = targetFile; Directory.CreateDirectory(Path.GetDirectoryName(targetFile)); @@ -90,8 +96,8 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV // FFMpeg writes debug/error info to stderr. This is useful when debugging so let's put it in the log directory. _logFileStream = new FileStream(logFilePath, FileMode.Create, FileAccess.Write, FileShare.Read, IODefaults.FileStreamBufferSize, true); - var commandLineLogMessageBytes = Encoding.UTF8.GetBytes(_json.SerializeToString(mediaSource) + Environment.NewLine + Environment.NewLine + commandLineLogMessage + Environment.NewLine + Environment.NewLine); - _logFileStream.Write(commandLineLogMessageBytes, 0, commandLineLogMessageBytes.Length); + await JsonSerializer.SerializeAsync(_logFileStream, mediaSource, _jsonOptions, cancellationToken).ConfigureAwait(false); + await _logFileStream.WriteAsync(Encoding.UTF8.GetBytes(Environment.NewLine + Environment.NewLine + commandLineLogMessage + Environment.NewLine + Environment.NewLine), cancellationToken).ConfigureAwait(false); _process = new Process { @@ -110,8 +116,6 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV _ = StartStreamingLog(_process.StandardError.BaseStream, _logFileStream); _logger.LogInformation("ffmpeg recording process started for {0}", _targetPath); - - return _taskCompletionSource.Task; } private string GetCommandLineArgs(MediaSourceInfo mediaSource, string inputTempFile, string targetFile, TimeSpan duration) @@ -179,15 +183,17 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV var outputParam = string.Empty; + var threads = EncodingHelper.GetNumberOfThreads(null, _serverConfigurationManager.GetEncodingOptions(), null); var commandLineArgs = string.Format( CultureInfo.InvariantCulture, - "-i \"{0}\" {2} -map_metadata -1 -threads 0 {3}{4}{5} -y \"{1}\"", + "-i \"{0}\" {2} -map_metadata -1 -threads {6} {3}{4}{5} -y \"{1}\"", inputTempFile, targetFile, videoArgs, GetAudioArgs(mediaSource), subtitleArgs, - outputParam); + outputParam, + threads); return inputModifier + " " + commandLineArgs; } @@ -304,13 +310,11 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV { using (var reader = new StreamReader(source)) { - while (!reader.EndOfStream) + await foreach (var line in reader.ReadAllLinesAsync().ConfigureAwait(false)) { - var line = await reader.ReadLineAsync().ConfigureAwait(false); - var bytes = Encoding.UTF8.GetBytes(Environment.NewLine + line); - await target.WriteAsync(bytes, 0, bytes.Length).ConfigureAwait(false); + await target.WriteAsync(bytes.AsMemory()).ConfigureAwait(false); await target.FlushAsync().ConfigureAwait(false); } } diff --git a/Emby.Server.Implementations/LiveTv/EmbyTV/EpgChannelData.cs b/Emby.Server.Implementations/LiveTv/EmbyTV/EpgChannelData.cs index 463d0ed0a3..0ec52a9598 100644 --- a/Emby.Server.Implementations/LiveTv/EmbyTV/EpgChannelData.cs +++ b/Emby.Server.Implementations/LiveTv/EmbyTV/EpgChannelData.cs @@ -6,58 +6,46 @@ using MediaBrowser.Controller.LiveTv; namespace Emby.Server.Implementations.LiveTv.EmbyTV { - internal class EpgChannelData { + + private readonly Dictionary<string, ChannelInfo> _channelsById; + + private readonly Dictionary<string, ChannelInfo> _channelsByNumber; + + private readonly Dictionary<string, ChannelInfo> _channelsByName; + public EpgChannelData(IEnumerable<ChannelInfo> channels) { - ChannelsById = new Dictionary<string, ChannelInfo>(StringComparer.OrdinalIgnoreCase); - ChannelsByNumber = new Dictionary<string, ChannelInfo>(StringComparer.OrdinalIgnoreCase); - ChannelsByName = new Dictionary<string, ChannelInfo>(StringComparer.OrdinalIgnoreCase); + _channelsById = new Dictionary<string, ChannelInfo>(StringComparer.OrdinalIgnoreCase); + _channelsByNumber = new Dictionary<string, ChannelInfo>(StringComparer.OrdinalIgnoreCase); + _channelsByName = new Dictionary<string, ChannelInfo>(StringComparer.OrdinalIgnoreCase); foreach (var channel in channels) { - ChannelsById[channel.Id] = channel; + _channelsById[channel.Id] = channel; if (!string.IsNullOrEmpty(channel.Number)) { - ChannelsByNumber[channel.Number] = channel; + _channelsByNumber[channel.Number] = channel; } var normalizedName = NormalizeName(channel.Name ?? string.Empty); if (!string.IsNullOrWhiteSpace(normalizedName)) { - ChannelsByName[normalizedName] = channel; + _channelsByName[normalizedName] = channel; } } } - private Dictionary<string, ChannelInfo> ChannelsById { get; set; } + public ChannelInfo? GetChannelById(string id) + => _channelsById.GetValueOrDefault(id); - private Dictionary<string, ChannelInfo> ChannelsByNumber { get; set; } + public ChannelInfo? GetChannelByNumber(string number) + => _channelsByNumber.GetValueOrDefault(number); - private Dictionary<string, ChannelInfo> ChannelsByName { get; set; } - - public ChannelInfo GetChannelById(string id) - { - ChannelsById.TryGetValue(id, out var result); - - return result; - } - - public ChannelInfo GetChannelByNumber(string number) - { - ChannelsByNumber.TryGetValue(number, out var result); - - return result; - } - - public ChannelInfo GetChannelByName(string name) - { - ChannelsByName.TryGetValue(name, out var result); - - return result; - } + public ChannelInfo? GetChannelByName(string name) + => _channelsByName.GetValueOrDefault(name); public static string NormalizeName(string value) { diff --git a/Emby.Server.Implementations/LiveTv/EmbyTV/ItemDataProvider.cs b/Emby.Server.Implementations/LiveTv/EmbyTV/ItemDataProvider.cs index fc543dc551..4a031e4752 100644 --- a/Emby.Server.Implementations/LiveTv/EmbyTV/ItemDataProvider.cs +++ b/Emby.Server.Implementations/LiveTv/EmbyTV/ItemDataProvider.cs @@ -1,10 +1,13 @@ +#nullable disable + #pragma warning disable CS1591 using System; using System.Collections.Generic; using System.IO; using System.Linq; -using MediaBrowser.Model.Serialization; +using System.Text.Json; +using Jellyfin.Extensions.Json; using Microsoft.Extensions.Logging; namespace Emby.Server.Implementations.LiveTv.EmbyTV @@ -12,18 +15,16 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV public class ItemDataProvider<T> where T : class { - private readonly IJsonSerializer _jsonSerializer; private readonly string _dataPath; private readonly object _fileDataLock = new object(); + private readonly JsonSerializerOptions _jsonOptions = JsonDefaults.Options; private T[] _items; public ItemDataProvider( - IJsonSerializer jsonSerializer, ILogger logger, string dataPath, Func<T, T, bool> equalityComparer) { - _jsonSerializer = jsonSerializer; Logger = logger; _dataPath = dataPath; EqualityComparer = equalityComparer; @@ -46,10 +47,11 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV try { - _items = _jsonSerializer.DeserializeFromFile<T[]>(_dataPath); + var bytes = File.ReadAllBytes(_dataPath); + _items = JsonSerializer.Deserialize<T[]>(bytes, _jsonOptions); return; } - catch (Exception ex) + catch (JsonException ex) { Logger.LogError(ex, "Error deserializing {Path}", _dataPath); } @@ -61,7 +63,8 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV private void SaveList() { Directory.CreateDirectory(Path.GetDirectoryName(_dataPath)); - _jsonSerializer.SerializeToFile(_items, _dataPath); + var jsonString = JsonSerializer.Serialize(_items, _jsonOptions); + File.WriteAllText(_dataPath, jsonString); } public IReadOnlyList<T> GetAll() diff --git a/Emby.Server.Implementations/LiveTv/EmbyTV/RecordingHelper.cs b/Emby.Server.Implementations/LiveTv/EmbyTV/RecordingHelper.cs index 142c59542a..32245f899e 100644 --- a/Emby.Server.Implementations/LiveTv/EmbyTV/RecordingHelper.cs +++ b/Emby.Server.Implementations/LiveTv/EmbyTV/RecordingHelper.cs @@ -6,7 +6,7 @@ using MediaBrowser.Controller.LiveTv; namespace Emby.Server.Implementations.LiveTv.EmbyTV { - internal class RecordingHelper + internal static class RecordingHelper { public static DateTime GetStartTime(TimerInfo timer) { @@ -70,17 +70,7 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV private static string GetDateString(DateTime date) { - date = date.ToLocalTime(); - - return string.Format( - CultureInfo.InvariantCulture, - "{0}_{1}_{2}_{3}_{4}_{5}", - date.Year.ToString("0000", CultureInfo.InvariantCulture), - date.Month.ToString("00", CultureInfo.InvariantCulture), - date.Day.ToString("00", CultureInfo.InvariantCulture), - date.Hour.ToString("00", CultureInfo.InvariantCulture), - date.Minute.ToString("00", CultureInfo.InvariantCulture), - date.Second.ToString("00", CultureInfo.InvariantCulture)); + return date.ToLocalTime().ToString("yyyy_MM_dd_HH_mm_ss", CultureInfo.InvariantCulture); } } } diff --git a/Emby.Server.Implementations/LiveTv/EmbyTV/SeriesTimerManager.cs b/Emby.Server.Implementations/LiveTv/EmbyTV/SeriesTimerManager.cs index 194e4606de..b1259de232 100644 --- a/Emby.Server.Implementations/LiveTv/EmbyTV/SeriesTimerManager.cs +++ b/Emby.Server.Implementations/LiveTv/EmbyTV/SeriesTimerManager.cs @@ -2,15 +2,14 @@ using System; using MediaBrowser.Controller.LiveTv; -using MediaBrowser.Model.Serialization; using Microsoft.Extensions.Logging; namespace Emby.Server.Implementations.LiveTv.EmbyTV { public class SeriesTimerManager : ItemDataProvider<SeriesTimerInfo> { - public SeriesTimerManager(IJsonSerializer jsonSerializer, ILogger logger, string dataPath) - : base(jsonSerializer, logger, dataPath, (r1, r2) => string.Equals(r1.Id, r2.Id, StringComparison.OrdinalIgnoreCase)) + public SeriesTimerManager(ILogger logger, string dataPath) + : base(logger, dataPath, (r1, r2) => string.Equals(r1.Id, r2.Id, StringComparison.OrdinalIgnoreCase)) { } diff --git a/Emby.Server.Implementations/LiveTv/EmbyTV/TimerManager.cs b/Emby.Server.Implementations/LiveTv/EmbyTV/TimerManager.cs index dd479b7d1b..6c52a9a73d 100644 --- a/Emby.Server.Implementations/LiveTv/EmbyTV/TimerManager.cs +++ b/Emby.Server.Implementations/LiveTv/EmbyTV/TimerManager.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System; @@ -8,7 +10,6 @@ using System.Threading; using Jellyfin.Data.Events; using MediaBrowser.Controller.LiveTv; using MediaBrowser.Model.LiveTv; -using MediaBrowser.Model.Serialization; using Microsoft.Extensions.Logging; namespace Emby.Server.Implementations.LiveTv.EmbyTV @@ -17,8 +18,8 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV { private readonly ConcurrentDictionary<string, Timer> _timers = new ConcurrentDictionary<string, Timer>(StringComparer.OrdinalIgnoreCase); - public TimerManager(IJsonSerializer jsonSerializer, ILogger logger, string dataPath) - : base(jsonSerializer, logger, dataPath, (r1, r2) => string.Equals(r1.Id, r2.Id, StringComparison.OrdinalIgnoreCase)) + public TimerManager(ILogger logger, string dataPath) + : base(logger, dataPath, (r1, r2) => string.Equals(r1.Id, r2.Id, StringComparison.OrdinalIgnoreCase)) { } diff --git a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirect.cs b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirect.cs index 28aabc1599..b7639a51ce 100644 --- a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirect.cs +++ b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirect.cs @@ -1,25 +1,27 @@ +#nullable disable + #pragma warning disable CS1591 using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.Globalization; -using System.IO; using System.Linq; using System.Net; using System.Net.Http; using System.Net.Mime; using System.Text; +using System.Text.Json; using System.Threading; using System.Threading.Tasks; using MediaBrowser.Common; +using Jellyfin.Extensions.Json; using MediaBrowser.Common.Net; using MediaBrowser.Controller.LiveTv; +using MediaBrowser.Model.Cryptography; using MediaBrowser.Model.Dto; using MediaBrowser.Model.Entities; using MediaBrowser.Model.LiveTv; -using MediaBrowser.Model.Net; -using MediaBrowser.Model.Serialization; using Microsoft.Extensions.Logging; namespace Emby.Server.Implementations.LiveTv.Listings @@ -29,25 +31,27 @@ namespace Emby.Server.Implementations.LiveTv.Listings private const string ApiUrl = "https://json.schedulesdirect.org/20141201"; private readonly ILogger<SchedulesDirect> _logger; - private readonly IJsonSerializer _jsonSerializer; private readonly IHttpClientFactory _httpClientFactory; private readonly SemaphoreSlim _tokenSemaphore = new SemaphoreSlim(1, 1); private readonly IApplicationHost _appHost; + private readonly ICryptoProvider _cryptoProvider; + + private readonly ConcurrentDictionary<string, NameValuePair> _tokens = new ConcurrentDictionary<string, NameValuePair>(); + private readonly JsonSerializerOptions _jsonOptions = JsonDefaults.Options; + private DateTime _lastErrorResponse; public SchedulesDirect( ILogger<SchedulesDirect> logger, - IJsonSerializer jsonSerializer, IHttpClientFactory httpClientFactory, - IApplicationHost appHost) + IApplicationHost appHost, + ICryptoProvider cryptoProvider) { _logger = logger; - _jsonSerializer = jsonSerializer; _httpClientFactory = httpClientFactory; _appHost = appHost; + _cryptoProvider = cryptoProvider; } - private string UserAgent => _appHost.ApplicationUserAgent; - /// <inheritdoc /> public string Name => "Schedules Direct"; @@ -101,15 +105,15 @@ namespace Emby.Server.Implementations.LiveTv.Listings } }; - var requestString = _jsonSerializer.SerializeToString(requestList); + var requestString = JsonSerializer.Serialize(requestList, _jsonOptions); _logger.LogDebug("Request string for schedules is: {RequestString}", requestString); using var options = new HttpRequestMessage(HttpMethod.Post, ApiUrl + "/schedules"); options.Content = new StringContent(requestString, Encoding.UTF8, MediaTypeNames.Application.Json); options.Headers.TryAddWithoutValidation("token", token); using var response = await Send(options, true, info, cancellationToken).ConfigureAwait(false); - await using var responseStream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false); - var dailySchedules = await _jsonSerializer.DeserializeFromStreamAsync<List<ScheduleDirect.Day>>(responseStream).ConfigureAwait(false); + await using var responseStream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); + var dailySchedules = await JsonSerializer.DeserializeAsync<List<ScheduleDirect.Day>>(responseStream, _jsonOptions, cancellationToken).ConfigureAwait(false); _logger.LogDebug("Found {ScheduleCount} programs on {ChannelID} ScheduleDirect", dailySchedules.Count, channelId); using var programRequestOptions = new HttpRequestMessage(HttpMethod.Post, ApiUrl + "/programs"); @@ -119,13 +123,13 @@ namespace Emby.Server.Implementations.LiveTv.Listings programRequestOptions.Content = new StringContent("[\"" + string.Join("\", \"", programsID) + "\"]", Encoding.UTF8, MediaTypeNames.Application.Json); using var innerResponse = await Send(programRequestOptions, true, info, cancellationToken).ConfigureAwait(false); - await using var innerResponseStream = await innerResponse.Content.ReadAsStreamAsync().ConfigureAwait(false); - var programDetails = await _jsonSerializer.DeserializeFromStreamAsync<List<ScheduleDirect.ProgramDetails>>(innerResponseStream).ConfigureAwait(false); + await using var innerResponseStream = await innerResponse.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); + var programDetails = await JsonSerializer.DeserializeAsync<List<ScheduleDirect.ProgramDetails>>(innerResponseStream, _jsonOptions, cancellationToken).ConfigureAwait(false); var programDict = programDetails.ToDictionary(p => p.programID, y => y); - var programIdsWithImages = - programDetails.Where(p => p.hasImageArtwork).Select(p => p.programID) - .ToList(); + var programIdsWithImages = programDetails + .Where(p => p.hasImageArtwork).Select(p => p.programID) + .ToList(); var images = await GetImageForPrograms(info, programIdsWithImages, cancellationToken).ConfigureAwait(false); @@ -180,8 +184,7 @@ namespace Emby.Server.Implementations.LiveTv.Listings private static int GetSizeOrder(ScheduleDirect.ImageData image) { - if (!string.IsNullOrWhiteSpace(image.height) - && int.TryParse(image.height, out int value)) + if (int.TryParse(image.height, out int value)) { return value; } @@ -257,7 +260,7 @@ namespace Emby.Server.Implementations.LiveTv.Listings Id = newID, StartDate = startAt, EndDate = endAt, - Name = details.titles[0].title120 ?? "Unkown", + Name = details.titles[0].title120 ?? "Unknown", OfficialRating = null, CommunityRating = null, EpisodeTitle = episodeTitle, @@ -303,7 +306,8 @@ namespace Emby.Server.Implementations.LiveTv.Listings if (details.contentRating != null && details.contentRating.Count > 0) { - info.OfficialRating = details.contentRating[0].code.Replace("TV", "TV-").Replace("--", "-"); + info.OfficialRating = details.contentRating[0].code.Replace("TV", "TV-", StringComparison.Ordinal) + .Replace("--", "-", StringComparison.Ordinal); var invalid = new[] { "N/A", "Approved", "Not Rated", "Passed" }; if (invalid.Contains(info.OfficialRating, StringComparer.OrdinalIgnoreCase)) @@ -446,7 +450,7 @@ namespace Emby.Server.Implementations.LiveTv.Listings private async Task<List<ScheduleDirect.ShowImages>> GetImageForPrograms( ListingsProviderInfo info, - List<string> programIds, + IReadOnlyList<string> programIds, CancellationToken cancellationToken) { if (programIds.Count == 0) @@ -454,31 +458,28 @@ namespace Emby.Server.Implementations.LiveTv.Listings return new List<ScheduleDirect.ShowImages>(); } - var imageIdString = "["; - - foreach (var i in programIds) + StringBuilder str = new StringBuilder("[", 1 + (programIds.Count * 13)); + foreach (ReadOnlySpan<char> i in programIds) { - var imageId = i.Substring(0, 10); - - if (!imageIdString.Contains(imageId, StringComparison.Ordinal)) - { - imageIdString += "\"" + imageId + "\","; - } + str.Append('"') + .Append(i.Slice(0, 10)) + .Append("\","); } - imageIdString = imageIdString.TrimEnd(',') + "]"; + // Remove last , + str.Length--; + str.Append(']'); using var message = new HttpRequestMessage(HttpMethod.Post, ApiUrl + "/metadata/programs") { - Content = new StringContent(imageIdString, Encoding.UTF8, MediaTypeNames.Application.Json) + Content = new StringContent(str.ToString(), Encoding.UTF8, MediaTypeNames.Application.Json) }; try { using var innerResponse2 = await Send(message, true, info, cancellationToken).ConfigureAwait(false); - await using var response = await innerResponse2.Content.ReadAsStreamAsync().ConfigureAwait(false); - return await _jsonSerializer.DeserializeFromStreamAsync<List<ScheduleDirect.ShowImages>>( - response).ConfigureAwait(false); + await using var response = await innerResponse2.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); + return await JsonSerializer.DeserializeAsync<List<ScheduleDirect.ShowImages>>(response, _jsonOptions, cancellationToken).ConfigureAwait(false); } catch (Exception ex) { @@ -505,9 +506,9 @@ namespace Emby.Server.Implementations.LiveTv.Listings try { using var httpResponse = await Send(options, false, info, cancellationToken).ConfigureAwait(false); - await using var response = await httpResponse.Content.ReadAsStreamAsync().ConfigureAwait(false); + await using var response = await httpResponse.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); - var root = await _jsonSerializer.DeserializeFromStreamAsync<List<ScheduleDirect.Headends>>(response).ConfigureAwait(false); + var root = await JsonSerializer.DeserializeAsync<List<ScheduleDirect.Headends>>(response, _jsonOptions, cancellationToken).ConfigureAwait(false); if (root != null) { @@ -536,8 +537,6 @@ namespace Emby.Server.Implementations.LiveTv.Listings return lineups; } - private readonly ConcurrentDictionary<string, NameValuePair> _tokens = new ConcurrentDictionary<string, NameValuePair>(); - private DateTime _lastErrorResponse; private async Task<string> GetToken(ListingsProviderInfo info, CancellationToken cancellationToken) { var username = info.Username; @@ -560,8 +559,7 @@ namespace Emby.Server.Implementations.LiveTv.Listings return null; } - NameValuePair savedToken; - if (!_tokens.TryGetValue(username, out savedToken)) + if (!_tokens.TryGetValue(username, out NameValuePair savedToken)) { savedToken = new NameValuePair(); _tokens.TryAdd(username, savedToken); @@ -587,7 +585,7 @@ namespace Emby.Server.Implementations.LiveTv.Listings savedToken.Value = DateTime.UtcNow.Ticks.ToString(CultureInfo.InvariantCulture); return result; } - catch (HttpException ex) + catch (HttpRequestException ex) { if (ex.StatusCode.HasValue) { @@ -613,25 +611,25 @@ namespace Emby.Server.Implementations.LiveTv.Listings CancellationToken cancellationToken, HttpCompletionOption completionOption = HttpCompletionOption.ResponseContentRead) { - try + var response = await _httpClientFactory.CreateClient(NamedClient.Default) + .SendAsync(options, completionOption, cancellationToken).ConfigureAwait(false); + if (response.IsSuccessStatusCode) { - return await _httpClientFactory.CreateClient(NamedClient.Default).SendAsync(options, completionOption, cancellationToken).ConfigureAwait(false); + return response; } - catch (HttpException ex) - { - _tokens.Clear(); - if (!ex.StatusCode.HasValue || (int)ex.StatusCode.Value >= 500) - { - enableRetry = false; - } - - if (!enableRetry) - { - throw; - } + // Response is automatically disposed in the calling function, + // so dispose manually if not returning. + response.Dispose(); + if (!enableRetry || (int)response.StatusCode >= 500) + { + throw new HttpRequestException( + string.Format(CultureInfo.InvariantCulture, "Request failed: {0}", response.ReasonPhrase), + null, + response.StatusCode); } + _tokens.Clear(); options.Headers.TryAddWithoutValidation("token", await GetToken(providerInfo, cancellationToken).ConfigureAwait(false)); return await Send(options, false, providerInfo, cancellationToken).ConfigureAwait(false); } @@ -642,12 +640,17 @@ namespace Emby.Server.Implementations.LiveTv.Listings CancellationToken cancellationToken) { using var options = new HttpRequestMessage(HttpMethod.Post, ApiUrl + "/token"); - options.Content = new StringContent("{\"username\":\"" + username + "\",\"password\":\"" + password + "\"}", Encoding.UTF8, MediaTypeNames.Application.Json); + var hashedPasswordBytes = _cryptoProvider.ComputeHash("SHA1", Encoding.ASCII.GetBytes(password), Array.Empty<byte>()); + // TODO: remove ToLower when Convert.ToHexString supports lowercase + // Schedules Direct requires the hex to be lowercase + string hashedPassword = Convert.ToHexString(hashedPasswordBytes).ToLowerInvariant(); + options.Content = new StringContent("{\"username\":\"" + username + "\",\"password\":\"" + hashedPassword + "\"}", Encoding.UTF8, MediaTypeNames.Application.Json); using var response = await Send(options, false, null, cancellationToken).ConfigureAwait(false); - await using var stream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false); - var root = await _jsonSerializer.DeserializeFromStreamAsync<ScheduleDirect.Token>(stream).ConfigureAwait(false); - if (root.message == "OK") + response.EnsureSuccessStatusCode(); + await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); + var root = await JsonSerializer.DeserializeAsync<ScheduleDirect.Token>(stream, _jsonOptions, cancellationToken).ConfigureAwait(false); + if (string.Equals(root.message, "OK", StringComparison.Ordinal)) { _logger.LogInformation("Authenticated with Schedules Direct token: " + root.token); return root.token; @@ -699,15 +702,16 @@ namespace Emby.Server.Implementations.LiveTv.Listings try { using var httpResponse = await Send(options, false, null, cancellationToken).ConfigureAwait(false); - await using var stream = await httpResponse.Content.ReadAsStreamAsync().ConfigureAwait(false); + httpResponse.EnsureSuccessStatusCode(); + await using var stream = await httpResponse.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); using var response = httpResponse.Content; - var root = await _jsonSerializer.DeserializeFromStreamAsync<ScheduleDirect.Lineups>(stream).ConfigureAwait(false); + var root = await JsonSerializer.DeserializeAsync<ScheduleDirect.Lineups>(stream, _jsonOptions, cancellationToken).ConfigureAwait(false); return root.lineups.Any(i => string.Equals(info.ListingsId, i.lineup, StringComparison.OrdinalIgnoreCase)); } - catch (HttpException ex) + catch (HttpRequestException ex) { - // Apparently we're supposed to swallow this + // SchedulesDirect returns 400 if no lineups are configured. if (ex.StatusCode.HasValue && ex.StatusCode.Value == HttpStatusCode.BadRequest) { return false; @@ -771,25 +775,25 @@ namespace Emby.Server.Implementations.LiveTv.Listings using var options = new HttpRequestMessage(HttpMethod.Get, ApiUrl + "/lineups/" + listingsId); options.Headers.TryAddWithoutValidation("token", token); - var list = new List<ChannelInfo>(); - using var httpResponse = await Send(options, true, info, cancellationToken).ConfigureAwait(false); - await using var stream = await httpResponse.Content.ReadAsStreamAsync().ConfigureAwait(false); - var root = await _jsonSerializer.DeserializeFromStreamAsync<ScheduleDirect.Channel>(stream).ConfigureAwait(false); + await using var stream = await httpResponse.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); + var root = await JsonSerializer.DeserializeAsync<ScheduleDirect.Channel>(stream, _jsonOptions, cancellationToken).ConfigureAwait(false); _logger.LogInformation("Found {ChannelCount} channels on the lineup on ScheduleDirect", root.map.Count); _logger.LogInformation("Mapping Stations to Channel"); - var allStations = root.stations ?? Enumerable.Empty<ScheduleDirect.Station>(); + var allStations = root.stations ?? new List<ScheduleDirect.Station>(); - foreach (ScheduleDirect.Map map in root.map) + var map = root.map; + var list = new List<ChannelInfo>(map.Count); + foreach (var channel in map) { - var channelNumber = GetChannelNumber(map); + var channelNumber = GetChannelNumber(channel); - var station = allStations.FirstOrDefault(item => string.Equals(item.stationID, map.stationID, StringComparison.OrdinalIgnoreCase)); - if (station == null) - { - station = new ScheduleDirect.Station { stationID = map.stationID }; - } + var station = allStations.Find(item => string.Equals(item.stationID, channel.stationID, StringComparison.OrdinalIgnoreCase)) + ?? new ScheduleDirect.Station + { + stationID = channel.stationID + }; var channelInfo = new ChannelInfo { @@ -810,28 +814,6 @@ namespace Emby.Server.Implementations.LiveTv.Listings return list; } - private ScheduleDirect.Station GetStation(List<ScheduleDirect.Station> allStations, string channelNumber, string channelName) - { - if (!string.IsNullOrWhiteSpace(channelName)) - { - channelName = NormalizeName(channelName); - - var result = allStations.FirstOrDefault(i => string.Equals(NormalizeName(i.callsign ?? string.Empty), channelName, StringComparison.OrdinalIgnoreCase)); - - if (result != null) - { - return result; - } - } - - if (!string.IsNullOrWhiteSpace(channelNumber)) - { - return allStations.FirstOrDefault(i => string.Equals(NormalizeName(i.stationID ?? string.Empty), channelNumber, StringComparison.OrdinalIgnoreCase)); - } - - return null; - } - private static string NormalizeName(string value) { return value.Replace(" ", string.Empty, StringComparison.Ordinal).Replace("-", string.Empty, StringComparison.Ordinal); @@ -1038,7 +1020,6 @@ namespace Emby.Server.Implementations.LiveTv.Listings } } - // public class Title { public string title120 { get; set; } diff --git a/Emby.Server.Implementations/LiveTv/Listings/XmlTvListingsProvider.cs b/Emby.Server.Implementations/LiveTv/Listings/XmlTvListingsProvider.cs index 2d6f453bd4..ebad4eddf1 100644 --- a/Emby.Server.Implementations/LiveTv/Listings/XmlTvListingsProvider.cs +++ b/Emby.Server.Implementations/LiveTv/Listings/XmlTvListingsProvider.cs @@ -1,10 +1,11 @@ +#nullable disable + #pragma warning disable CS1591 using System; using System.Collections.Generic; using System.Globalization; using System.IO; -using System.IO.Compression; using System.Linq; using System.Net.Http; using System.Threading; @@ -79,7 +80,7 @@ namespace Emby.Server.Implementations.LiveTv.Listings Directory.CreateDirectory(Path.GetDirectoryName(cacheFile)); using var response = await _httpClientFactory.CreateClient(NamedClient.Default).GetAsync(path, cancellationToken).ConfigureAwait(false); - await using var stream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false); + await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); await using (var fileStream = new FileStream(cacheFile, FileMode.CreateNew)) { await stream.CopyToAsync(fileStream, cancellationToken).ConfigureAwait(false); diff --git a/Emby.Server.Implementations/LiveTv/LiveTvConfigurationFactory.cs b/Emby.Server.Implementations/LiveTv/LiveTvConfigurationFactory.cs index ba916af389..098f193fba 100644 --- a/Emby.Server.Implementations/LiveTv/LiveTvConfigurationFactory.cs +++ b/Emby.Server.Implementations/LiveTv/LiveTvConfigurationFactory.cs @@ -1,21 +1,23 @@ -#pragma warning disable CS1591 - using System.Collections.Generic; using MediaBrowser.Common.Configuration; using MediaBrowser.Model.LiveTv; namespace Emby.Server.Implementations.LiveTv { + /// <summary> + /// <see cref="IConfigurationFactory" /> implementation for <see cref="LiveTvOptions" />. + /// </summary> public class LiveTvConfigurationFactory : IConfigurationFactory { + /// <inheritdoc /> public IEnumerable<ConfigurationStore> GetConfigurations() { return new ConfigurationStore[] { new ConfigurationStore { - ConfigurationType = typeof(LiveTvOptions), - Key = "livetv" + ConfigurationType = typeof(LiveTvOptions), + Key = "livetv" } }; } diff --git a/Emby.Server.Implementations/LiveTv/LiveTvDtoService.cs b/Emby.Server.Implementations/LiveTv/LiveTvDtoService.cs index 49ad73af3f..21e1409ac0 100644 --- a/Emby.Server.Implementations/LiveTv/LiveTvDtoService.cs +++ b/Emby.Server.Implementations/LiveTv/LiveTvDtoService.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System; @@ -159,7 +161,7 @@ namespace Emby.Server.Implementations.LiveTv { var librarySeries = _libraryManager.GetItemList(new InternalItemsQuery { - IncludeItemTypes = new string[] { typeof(Series).Name }, + IncludeItemTypes = new string[] { nameof(Series) }, Name = seriesName, Limit = 1, ImageTypes = new ImageType[] { ImageType.Thumb }, @@ -253,7 +255,7 @@ namespace Emby.Server.Implementations.LiveTv { var librarySeries = _libraryManager.GetItemList(new InternalItemsQuery { - IncludeItemTypes = new string[] { typeof(Series).Name }, + IncludeItemTypes = new string[] { nameof(Series) }, Name = seriesName, Limit = 1, ImageTypes = new ImageType[] { ImageType.Thumb }, @@ -296,7 +298,7 @@ namespace Emby.Server.Implementations.LiveTv var program = _libraryManager.GetItemList(new InternalItemsQuery { - IncludeItemTypes = new string[] { typeof(Series).Name }, + IncludeItemTypes = new string[] { nameof(Series) }, Name = seriesName, Limit = 1, ImageTypes = new ImageType[] { ImageType.Primary }, @@ -307,7 +309,7 @@ namespace Emby.Server.Implementations.LiveTv { program = _libraryManager.GetItemList(new InternalItemsQuery { - IncludeItemTypes = new string[] { typeof(LiveTvProgram).Name }, + IncludeItemTypes = new string[] { nameof(LiveTvProgram) }, ExternalSeriesId = programSeriesId, Limit = 1, ImageTypes = new ImageType[] { ImageType.Primary }, diff --git a/Emby.Server.Implementations/LiveTv/LiveTvManager.cs b/Emby.Server.Implementations/LiveTv/LiveTvManager.cs index 5bdd1c23c4..d964769b59 100644 --- a/Emby.Server.Implementations/LiveTv/LiveTvManager.cs +++ b/Emby.Server.Implementations/LiveTv/LiveTvManager.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System; @@ -187,7 +189,7 @@ namespace Emby.Server.Implementations.LiveTv IsKids = query.IsKids, IsSports = query.IsSports, IsSeries = query.IsSeries, - IncludeItemTypes = new[] { typeof(LiveTvChannel).Name }, + IncludeItemTypes = new[] { nameof(LiveTvChannel) }, TopParentIds = new[] { topFolder.Id }, IsFavorite = query.IsFavorite, IsLiked = query.IsLiked, @@ -808,7 +810,7 @@ namespace Emby.Server.Implementations.LiveTv var internalQuery = new InternalItemsQuery(user) { - IncludeItemTypes = new[] { typeof(LiveTvProgram).Name }, + IncludeItemTypes = new[] { nameof(LiveTvProgram) }, MinEndDate = query.MinEndDate, MinStartDate = query.MinStartDate, MaxEndDate = query.MaxEndDate, @@ -872,7 +874,7 @@ namespace Emby.Server.Implementations.LiveTv var internalQuery = new InternalItemsQuery(user) { - IncludeItemTypes = new[] { typeof(LiveTvProgram).Name }, + IncludeItemTypes = new[] { nameof(LiveTvProgram) }, IsAiring = query.IsAiring, HasAired = query.HasAired, IsNews = query.IsNews, @@ -987,10 +989,7 @@ namespace Emby.Server.Implementations.LiveTv var externalProgramId = programTuple.Item2; string externalSeriesId = programTuple.Item3; - if (timerList == null) - { - timerList = (await GetTimersInternal(new TimerQuery(), cancellationToken).ConfigureAwait(false)).Items; - } + timerList ??= (await GetTimersInternal(new TimerQuery(), cancellationToken).ConfigureAwait(false)).Items; var timer = timerList.FirstOrDefault(i => string.Equals(i.ProgramId, externalProgramId, StringComparison.OrdinalIgnoreCase)); var foundSeriesTimer = false; @@ -1018,10 +1017,7 @@ namespace Emby.Server.Implementations.LiveTv continue; } - if (seriesTimerList == null) - { - seriesTimerList = (await GetSeriesTimersInternal(new SeriesTimerQuery(), cancellationToken).ConfigureAwait(false)).Items; - } + seriesTimerList ??= (await GetSeriesTimersInternal(new SeriesTimerQuery(), cancellationToken).ConfigureAwait(false)).Items; var seriesTimer = seriesTimerList.FirstOrDefault(i => string.Equals(i.SeriesId, externalSeriesId, StringComparison.OrdinalIgnoreCase)); @@ -1089,8 +1085,8 @@ namespace Emby.Server.Implementations.LiveTv if (cleanDatabase) { - CleanDatabaseInternal(newChannelIdList.ToArray(), new[] { typeof(LiveTvChannel).Name }, progress, cancellationToken); - CleanDatabaseInternal(newProgramIdList.ToArray(), new[] { typeof(LiveTvProgram).Name }, progress, cancellationToken); + CleanDatabaseInternal(newChannelIdList.ToArray(), new[] { nameof(LiveTvChannel) }, progress, cancellationToken); + CleanDatabaseInternal(newProgramIdList.ToArray(), new[] { nameof(LiveTvProgram) }, progress, cancellationToken); } var coreService = _services.OfType<EmbyTV.EmbyTV>().FirstOrDefault(); @@ -1181,7 +1177,7 @@ namespace Emby.Server.Implementations.LiveTv var existingPrograms = _libraryManager.GetItemList(new InternalItemsQuery { - IncludeItemTypes = new string[] { typeof(LiveTvProgram).Name }, + IncludeItemTypes = new string[] { nameof(LiveTvProgram) }, ChannelIds = new Guid[] { currentChannel.Id }, DtoOptions = new DtoOptions(true) }).Cast<LiveTvProgram>().ToDictionary(i => i.Id); @@ -1346,11 +1342,11 @@ namespace Emby.Server.Implementations.LiveTv { if (query.IsMovie.Value) { - includeItemTypes.Add(typeof(Movie).Name); + includeItemTypes.Add(nameof(Movie)); } else { - excludeItemTypes.Add(typeof(Movie).Name); + excludeItemTypes.Add(nameof(Movie)); } } @@ -1358,11 +1354,11 @@ namespace Emby.Server.Implementations.LiveTv { if (query.IsSeries.Value) { - includeItemTypes.Add(typeof(Episode).Name); + includeItemTypes.Add(nameof(Episode)); } else { - excludeItemTypes.Add(typeof(Episode).Name); + excludeItemTypes.Add(nameof(Episode)); } } @@ -1429,7 +1425,7 @@ namespace Emby.Server.Implementations.LiveTv return result; } - public Task AddInfoToProgramDto(IReadOnlyCollection<(BaseItem, BaseItemDto)> tuples, ItemFields[] fields, User user = null) + public Task AddInfoToProgramDto(IReadOnlyCollection<(BaseItem, BaseItemDto)> tuples, IReadOnlyList<ItemFields> fields, User user = null) { var programTuples = new List<Tuple<BaseItemDto, string, string>>(); var hasChannelImage = fields.Contains(ItemFields.ChannelImage); @@ -1883,7 +1879,7 @@ namespace Emby.Server.Implementations.LiveTv var programs = options.AddCurrentProgram ? _libraryManager.GetItemList(new InternalItemsQuery(user) { - IncludeItemTypes = new[] { typeof(LiveTvProgram).Name }, + IncludeItemTypes = new[] { nameof(LiveTvProgram) }, ChannelIds = channelIds, MaxStartDate = now, MinEndDate = now, @@ -1928,7 +1924,7 @@ namespace Emby.Server.Implementations.LiveTv foreach (var programDto in currentProgramDtos) { - if (currentChannelsDict.TryGetValue(programDto.ChannelId, out BaseItemDto channelDto)) + if (programDto.ChannelId.HasValue && currentChannelsDict.TryGetValue(programDto.ChannelId.Value, out BaseItemDto channelDto)) { channelDto.CurrentProgram = programDto; } @@ -1974,10 +1970,7 @@ namespace Emby.Server.Implementations.LiveTv }; } - if (service == null) - { - service = _services[0]; - } + service ??= _services[0]; var info = await service.GetNewTimerDefaultsAsync(cancellationToken, programInfo).ConfigureAwait(false); @@ -2018,7 +2011,7 @@ namespace Emby.Server.Implementations.LiveTv info.DayPattern = _tvDtoService.GetDayPattern(info.Days); info.Name = program.Name; - info.ChannelId = programDto.ChannelId; + info.ChannelId = programDto.ChannelId ?? Guid.Empty; info.ChannelName = programDto.ChannelName; info.StartDate = program.StartDate; info.Name = program.Name; @@ -2208,7 +2201,7 @@ namespace Emby.Server.Implementations.LiveTv /// <returns>Task.</returns> public Task ResetTuner(string id, CancellationToken cancellationToken) { - var parts = id.Split(new[] { '_' }, 2); + var parts = id.Split('_', 2); var service = _services.FirstOrDefault(i => string.Equals(i.GetType().FullName.GetMD5().ToString("N", CultureInfo.InvariantCulture), parts[0], StringComparison.OrdinalIgnoreCase)); @@ -2239,7 +2232,7 @@ namespace Emby.Server.Implementations.LiveTv public async Task<TunerHostInfo> SaveTunerHost(TunerHostInfo info, bool dataSourceChanged = true) { - info = JsonSerializer.Deserialize<TunerHostInfo>(JsonSerializer.Serialize(info)); + info = JsonSerializer.Deserialize<TunerHostInfo>(JsonSerializer.SerializeToUtf8Bytes(info)); var provider = _tunerHosts.FirstOrDefault(i => string.Equals(info.Type, i.Type, StringComparison.OrdinalIgnoreCase)); @@ -2273,7 +2266,7 @@ namespace Emby.Server.Implementations.LiveTv if (dataSourceChanged) { - _taskManager.CancelIfRunningAndQueue<RefreshChannelsScheduledTask>(); + _taskManager.CancelIfRunningAndQueue<RefreshGuideScheduledTask>(); } return info; @@ -2283,7 +2276,7 @@ namespace Emby.Server.Implementations.LiveTv { // Hack to make the object a pure ListingsProviderInfo instead of an AddListingProvider // ServerConfiguration.SaveConfiguration crashes during xml serialization for AddListingProvider - info = JsonSerializer.Deserialize<ListingsProviderInfo>(JsonSerializer.Serialize(info)); + info = JsonSerializer.Deserialize<ListingsProviderInfo>(JsonSerializer.SerializeToUtf8Bytes(info)); var provider = _listingProviders.FirstOrDefault(i => string.Equals(info.Type, i.Type, StringComparison.OrdinalIgnoreCase)); @@ -2316,7 +2309,7 @@ namespace Emby.Server.Implementations.LiveTv _config.SaveConfiguration("livetv", config); - _taskManager.CancelIfRunningAndQueue<RefreshChannelsScheduledTask>(); + _taskManager.CancelIfRunningAndQueue<RefreshGuideScheduledTask>(); return info; } @@ -2328,7 +2321,7 @@ namespace Emby.Server.Implementations.LiveTv config.ListingProviders = config.ListingProviders.Where(i => !string.Equals(id, i.Id, StringComparison.OrdinalIgnoreCase)).ToArray(); _config.SaveConfiguration("livetv", config); - _taskManager.CancelIfRunningAndQueue<RefreshChannelsScheduledTask>(); + _taskManager.CancelIfRunningAndQueue<RefreshGuideScheduledTask>(); } public async Task<TunerChannelMapping> SetChannelMapping(string providerId, string tunerChannelId, string providerChannelId) @@ -2362,7 +2355,7 @@ namespace Emby.Server.Implementations.LiveTv var tunerChannelMappings = tunerChannels.Select(i => GetTunerChannelMapping(i, mappings, providerChannels)).ToList(); - _taskManager.CancelIfRunningAndQueue<RefreshChannelsScheduledTask>(); + _taskManager.CancelIfRunningAndQueue<RefreshGuideScheduledTask>(); return tunerChannelMappings.First(i => string.Equals(i.Id, tunerChannelId, StringComparison.OrdinalIgnoreCase)); } diff --git a/Emby.Server.Implementations/LiveTv/LiveTvMediaSourceProvider.cs b/Emby.Server.Implementations/LiveTv/LiveTvMediaSourceProvider.cs index 8a0c0043a9..ecd28097d3 100644 --- a/Emby.Server.Implementations/LiveTv/LiveTvMediaSourceProvider.cs +++ b/Emby.Server.Implementations/LiveTv/LiveTvMediaSourceProvider.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System; @@ -76,7 +78,6 @@ namespace Emby.Server.Implementations.LiveTv } var list = sources.ToList(); - var serverUrl = await _appHost.GetLocalApiUrl(cancellationToken).ConfigureAwait(false); foreach (var source in list) { @@ -103,7 +104,7 @@ namespace Emby.Server.Implementations.LiveTv // Dummy this up so that direct play checks can still run if (string.IsNullOrEmpty(source.Path) && source.Protocol == MediaProtocol.Http) { - source.Path = serverUrl; + source.Path = _appHost.GetSmartApiUrl(string.Empty); } } diff --git a/Emby.Server.Implementations/LiveTv/RefreshChannelsScheduledTask.cs b/Emby.Server.Implementations/LiveTv/RefreshGuideScheduledTask.cs similarity index 64% rename from Emby.Server.Implementations/LiveTv/RefreshChannelsScheduledTask.cs rename to Emby.Server.Implementations/LiveTv/RefreshGuideScheduledTask.cs index 582b649235..15df0dcf13 100644 --- a/Emby.Server.Implementations/LiveTv/RefreshChannelsScheduledTask.cs +++ b/Emby.Server.Implementations/LiveTv/RefreshGuideScheduledTask.cs @@ -1,7 +1,6 @@ -#pragma warning disable CS1591 - using System; using System.Collections.Generic; +using System.Threading; using System.Threading.Tasks; using MediaBrowser.Common.Configuration; using MediaBrowser.Controller.LiveTv; @@ -10,34 +9,55 @@ using MediaBrowser.Model.Tasks; namespace Emby.Server.Implementations.LiveTv { - public class RefreshChannelsScheduledTask : IScheduledTask, IConfigurableScheduledTask + /// <summary> + /// The "Refresh Guide" scheduled task. + /// </summary> + public class RefreshGuideScheduledTask : IScheduledTask, IConfigurableScheduledTask { private readonly ILiveTvManager _liveTvManager; private readonly IConfigurationManager _config; - public RefreshChannelsScheduledTask(ILiveTvManager liveTvManager, IConfigurationManager config) + /// <summary> + /// Initializes a new instance of the <see cref="RefreshGuideScheduledTask"/> class. + /// </summary> + /// <param name="liveTvManager">The live tv manager.</param> + /// <param name="config">The configuration manager.</param> + public RefreshGuideScheduledTask(ILiveTvManager liveTvManager, IConfigurationManager config) { _liveTvManager = liveTvManager; _config = config; } + /// <inheritdoc /> public string Name => "Refresh Guide"; + /// <inheritdoc /> public string Description => "Downloads channel information from live tv services."; + /// <inheritdoc /> public string Category => "Live TV"; - public Task Execute(System.Threading.CancellationToken cancellationToken, IProgress<double> progress) + /// <inheritdoc /> + public bool IsHidden => _liveTvManager.Services.Count == 1 && GetConfiguration().TunerHosts.Length == 0; + + /// <inheritdoc /> + public bool IsEnabled => true; + + /// <inheritdoc /> + public bool IsLogged => true; + + /// <inheritdoc /> + public string Key => "RefreshGuide"; + + /// <inheritdoc /> + public Task Execute(CancellationToken cancellationToken, IProgress<double> progress) { var manager = (LiveTvManager)_liveTvManager; return manager.RefreshChannels(progress, cancellationToken); } - /// <summary> - /// Creates the triggers that define when the task will run. - /// </summary> - /// <returns>IEnumerable{BaseTaskTrigger}.</returns> + /// <inheritdoc /> public IEnumerable<TaskTriggerInfo> GetDefaultTriggers() { return new[] @@ -51,13 +71,5 @@ namespace Emby.Server.Implementations.LiveTv { return _config.GetConfiguration<LiveTvOptions>("livetv"); } - - public bool IsHidden => _liveTvManager.Services.Count == 1 && GetConfiguration().TunerHosts.Length == 0; - - public bool IsEnabled => true; - - public bool IsLogged => true; - - public string Key => "RefreshGuide"; } } diff --git a/Emby.Server.Implementations/LiveTv/TunerHosts/BaseTunerHost.cs b/Emby.Server.Implementations/LiveTv/TunerHosts/BaseTunerHost.cs index fbcd4ef372..5941613cf9 100644 --- a/Emby.Server.Implementations/LiveTv/TunerHosts/BaseTunerHost.cs +++ b/Emby.Server.Implementations/LiveTv/TunerHosts/BaseTunerHost.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System; @@ -38,6 +40,7 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts public virtual bool IsSupported => true; protected abstract Task<List<ChannelInfo>> GetChannelsInternal(TunerHostInfo tuner, CancellationToken cancellationToken); + public abstract string Type { get; } public async Task<List<ChannelInfo>> GetChannels(TunerHostInfo tuner, bool enableCache, CancellationToken cancellationToken) diff --git a/Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/Channels.cs b/Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/Channels.cs new file mode 100644 index 0000000000..0f04531896 --- /dev/null +++ b/Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/Channels.cs @@ -0,0 +1,23 @@ +#nullable disable + +namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun +{ + internal class Channels + { + public string GuideNumber { get; set; } + + public string GuideName { get; set; } + + public string VideoCodec { get; set; } + + public string AudioCodec { get; set; } + + public string URL { get; set; } + + public bool Favorite { get; set; } + + public bool DRM { get; set; } + + public bool HD { get; set; } + } +} diff --git a/Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/DiscoverResponse.cs b/Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/DiscoverResponse.cs new file mode 100644 index 0000000000..42068cd340 --- /dev/null +++ b/Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/DiscoverResponse.cs @@ -0,0 +1,42 @@ +#nullable disable + +using System; + +namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun +{ + internal class DiscoverResponse + { + public string FriendlyName { get; set; } + + public string ModelNumber { get; set; } + + public string FirmwareName { get; set; } + + public string FirmwareVersion { get; set; } + + public string DeviceID { get; set; } + + public string DeviceAuth { get; set; } + + public string BaseURL { get; set; } + + public string LineupURL { get; set; } + + public int TunerCount { get; set; } + + public bool SupportsTranscoding + { + get + { + var model = ModelNumber ?? string.Empty; + + if (model.IndexOf("hdtc", StringComparison.OrdinalIgnoreCase) != -1) + { + return true; + } + + return false; + } + } + } +} diff --git a/Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/HdHomerunHost.cs b/Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/HdHomerunHost.cs index 2f4c601172..011748d1d6 100644 --- a/Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/HdHomerunHost.cs +++ b/Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/HdHomerunHost.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System; @@ -10,14 +12,14 @@ using System.Net.Http; using System.Text.Json; using System.Threading; using System.Threading.Tasks; -using MediaBrowser.Common.Configuration; +using Jellyfin.Extensions; +using Jellyfin.Extensions.Json; using MediaBrowser.Common.Extensions; using MediaBrowser.Common.Net; using MediaBrowser.Controller; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.LiveTv; -using MediaBrowser.Model.Configuration; using MediaBrowser.Model.Dto; using MediaBrowser.Model.Entities; using MediaBrowser.Model.IO; @@ -37,6 +39,8 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun private readonly INetworkManager _networkManager; private readonly IStreamHelper _streamHelper; + private readonly JsonSerializerOptions _jsonOptions; + private readonly Dictionary<string, DiscoverResponse> _modelCache = new Dictionary<string, DiscoverResponse>(); public HdHomerunHost( @@ -56,6 +60,8 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun _socketFactory = socketFactory; _networkManager = networkManager; _streamHelper = streamHelper; + + _jsonOptions = JsonDefaults.Options; } public string Name => "HD Homerun"; @@ -67,13 +73,13 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun private string GetChannelId(TunerHostInfo info, Channels i) => ChannelIdPrefix + i.GuideNumber; - private async Task<List<Channels>> GetLineup(TunerHostInfo info, CancellationToken cancellationToken) + internal async Task<List<Channels>> GetLineup(TunerHostInfo info, CancellationToken cancellationToken) { var model = await GetModelInfo(info, false, cancellationToken).ConfigureAwait(false); - using var response = await _httpClientFactory.CreateClient(NamedClient.Default).GetAsync(model.LineupURL, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); - await using var stream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false); - var lineup = await JsonSerializer.DeserializeAsync<List<Channels>>(stream, cancellationToken: cancellationToken) + using var response = await _httpClientFactory.CreateClient(NamedClient.Default).GetAsync(model.LineupURL ?? model.BaseURL + "/lineup.json", HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); + var lineup = await JsonSerializer.DeserializeAsync<List<Channels>>(stream, _jsonOptions, cancellationToken) .ConfigureAwait(false) ?? new List<Channels>(); if (info.ImportFavoritesOnly) @@ -100,7 +106,7 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun Id = GetChannelId(info, i), IsFavorite = i.Favorite, TunerHostId = info.Id, - IsHD = i.HD == 1, + IsHD = i.HD, AudioCodec = i.AudioCodec, VideoCodec = i.VideoCodec, ChannelType = ChannelType.TV, @@ -109,7 +115,7 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun }).Cast<ChannelInfo>().ToList(); } - private async Task<DiscoverResponse> GetModelInfo(TunerHostInfo info, bool throwAllExceptions, CancellationToken cancellationToken) + internal async Task<DiscoverResponse> GetModelInfo(TunerHostInfo info, bool throwAllExceptions, CancellationToken cancellationToken) { var cacheKey = info.Id; @@ -127,10 +133,11 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun try { using var response = await _httpClientFactory.CreateClient(NamedClient.Default) - .GetAsync(string.Format(CultureInfo.InvariantCulture, "{0}/discover.json", GetApiUrl(info)), HttpCompletionOption.ResponseHeadersRead, cancellationToken) + .GetAsync(GetApiUrl(info) + "/discover.json", HttpCompletionOption.ResponseHeadersRead, cancellationToken) .ConfigureAwait(false); - await using var stream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false); - var discoverResponse = await JsonSerializer.DeserializeAsync<DiscoverResponse>(stream, cancellationToken: cancellationToken) + response.EnsureSuccessStatusCode(); + await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); + var discoverResponse = await JsonSerializer.DeserializeAsync<DiscoverResponse>(stream, _jsonOptions, cancellationToken) .ConfigureAwait(false); if (!string.IsNullOrEmpty(cacheKey)) @@ -143,7 +150,7 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun return discoverResponse; } - catch (HttpException ex) + catch (HttpRequestException ex) { if (!throwAllExceptions && ex.StatusCode.HasValue && ex.StatusCode.Value == HttpStatusCode.NotFound) { @@ -175,19 +182,19 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun using var response = await _httpClientFactory.CreateClient(NamedClient.Default) .GetAsync(string.Format(CultureInfo.InvariantCulture, "{0}/tuners.html", GetApiUrl(info)), HttpCompletionOption.ResponseHeadersRead, cancellationToken) .ConfigureAwait(false); - await using var stream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false); + await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); using var sr = new StreamReader(stream, System.Text.Encoding.UTF8); var tuners = new List<LiveTvTunerInfo>(); - while (!sr.EndOfStream) + await foreach (var line in sr.ReadAllLinesAsync().ConfigureAwait(false)) { - string line = StripXML(sr.ReadLine()); - if (line.Contains("Channel", StringComparison.Ordinal)) + string stripedLine = StripXML(line); + if (stripedLine.Contains("Channel", StringComparison.Ordinal)) { LiveTvTunerStatus status; - var index = line.IndexOf("Channel", StringComparison.OrdinalIgnoreCase); - var name = line.Substring(0, index - 1); - var currentChannel = line.Substring(index + 7); - if (currentChannel != "none") + var index = stripedLine.IndexOf("Channel", StringComparison.OrdinalIgnoreCase); + var name = stripedLine.Substring(0, index - 1); + var currentChannel = stripedLine.Substring(index + 7); + if (string.Equals(currentChannel, "none", StringComparison.Ordinal)) { status = LiveTvTunerStatus.LiveTv; } @@ -237,8 +244,7 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun if (!inside) { - buffer[bufferIndex] = let; - bufferIndex++; + buffer[bufferIndex++] = let; } } @@ -329,30 +335,6 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun return new Uri(url).AbsoluteUri.TrimEnd('/'); } - private class Channels - { - public string GuideNumber { get; set; } - - public string GuideName { get; set; } - - public string VideoCodec { get; set; } - - public string AudioCodec { get; set; } - - public string URL { get; set; } - - public bool Favorite { get; set; } - - public bool DRM { get; set; } - - public int HD { get; set; } - } - - protected EncodingOptions GetEncodingOptions() - { - return Config.GetConfiguration<EncodingOptions>("encoding"); - } - private static string GetHdHrIdFromChannelId(string channelId) { return channelId.Split('_')[1]; @@ -442,10 +424,7 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun string audioCodec = channelInfo.AudioCodec; - if (!videoBitrate.HasValue) - { - videoBitrate = isHd ? 15000000 : 2000000; - } + videoBitrate ??= isHd ? 15000000 : 2000000; int? audioBitrate = isHd ? 448000 : 192000; @@ -605,7 +584,6 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun Logger, Config, _appHost, - _networkManager, _streamHelper); } @@ -646,7 +624,6 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun Logger, Config, _appHost, - _networkManager, _streamHelper); } @@ -663,7 +640,7 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun var modelInfo = await GetModelInfo(info, true, CancellationToken.None).ConfigureAwait(false); info.DeviceId = modelInfo.DeviceID; } - catch (HttpException ex) + catch (HttpRequestException ex) { if (ex.StatusCode.HasValue && ex.StatusCode.Value == System.Net.HttpStatusCode.NotFound) { @@ -675,42 +652,6 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun } } - public class DiscoverResponse - { - public string FriendlyName { get; set; } - - public string ModelNumber { get; set; } - - public string FirmwareName { get; set; } - - public string FirmwareVersion { get; set; } - - public string DeviceID { get; set; } - - public string DeviceAuth { get; set; } - - public string BaseURL { get; set; } - - public string LineupURL { get; set; } - - public int TunerCount { get; set; } - - public bool SupportsTranscoding - { - get - { - var model = ModelNumber ?? string.Empty; - - if (model.IndexOf("hdtc", StringComparison.OrdinalIgnoreCase) != -1) - { - return true; - } - - return false; - } - } - } - public async Task<List<TunerHostInfo>> DiscoverDevices(int discoveryDurationMs, CancellationToken cancellationToken) { lock (_modelCache) @@ -718,7 +659,9 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun _modelCache.Clear(); } - cancellationToken = CancellationTokenSource.CreateLinkedTokenSource(new CancellationTokenSource(discoveryDurationMs).Token, cancellationToken).Token; + using var timedCancellationToken = new CancellationTokenSource(discoveryDurationMs); + using var linkedCancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(timedCancellationToken.Token, cancellationToken); + cancellationToken = linkedCancellationTokenSource.Token; var list = new List<TunerHostInfo>(); // Create udp broadcast discovery message @@ -763,7 +706,7 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun return list; } - private async Task<TunerHostInfo> TryGetTunerHostInfo(string url, CancellationToken cancellationToken) + internal async Task<TunerHostInfo> TryGetTunerHostInfo(string url, CancellationToken cancellationToken) { var hostInfo = new TunerHostInfo { @@ -775,6 +718,7 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun hostInfo.DeviceId = modelInfo.DeviceID; hostInfo.FriendlyName = modelInfo.FriendlyName; + hostInfo.TunerCount = modelInfo.TunerCount; return hostInfo; } diff --git a/Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/HdHomerunManager.cs b/Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/HdHomerunManager.cs index d4a88e299f..3016eeda24 100644 --- a/Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/HdHomerunManager.cs +++ b/Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/HdHomerunManager.cs @@ -1,7 +1,10 @@ +#nullable disable + #pragma warning disable CS1591 using System; using System.Buffers; +using System.Buffers.Binary; using System.Collections.Generic; using System.Globalization; using System.Net; @@ -10,6 +13,7 @@ using System.Text; using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; +using MediaBrowser.Common; using MediaBrowser.Controller.LiveTv; namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun @@ -26,7 +30,7 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun public LegacyHdHomerunChannelCommands(string url) { // parse url for channel and program - var regExp = new Regex(@"\/ch(\d+)-?(\d*)"); + var regExp = new Regex(@"\/ch([0-9]+)-?([0-9]*)"); var match = regExp.Match(url); if (match.Success) { @@ -111,26 +115,24 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun public async Task<bool> CheckTunerAvailability(IPAddress remoteIp, int tuner, CancellationToken cancellationToken) { - using (var client = new TcpClient(new IPEndPoint(remoteIp, HdHomeRunPort))) - using (var stream = client.GetStream()) - { - return await CheckTunerAvailability(stream, tuner, cancellationToken).ConfigureAwait(false); - } + using var client = new TcpClient(); + client.Connect(remoteIp, HdHomeRunPort); + + using var stream = client.GetStream(); + return await CheckTunerAvailability(stream, tuner, cancellationToken).ConfigureAwait(false); } private static async Task<bool> CheckTunerAvailability(NetworkStream stream, int tuner, CancellationToken cancellationToken) { - var lockkeyMsg = CreateGetMessage(tuner, "lockkey"); - await stream.WriteAsync(lockkeyMsg, 0, lockkeyMsg.Length, cancellationToken).ConfigureAwait(false); - byte[] buffer = ArrayPool<byte>.Shared.Rent(8192); try { - int receivedBytes = await stream.ReadAsync(buffer, 0, buffer.Length, cancellationToken).ConfigureAwait(false); + var msgLen = WriteGetMessage(buffer, tuner, "lockkey"); + await stream.WriteAsync(buffer.AsMemory(0, msgLen), cancellationToken).ConfigureAwait(false); - ParseReturnMessage(buffer, receivedBytes, out string returnVal); + int receivedBytes = await stream.ReadAsync(buffer, cancellationToken).ConfigureAwait(false); - return string.Equals(returnVal, "none", StringComparison.OrdinalIgnoreCase); + return VerifyReturnValueOfGetSet(buffer.AsSpan(receivedBytes), "none"); } finally { @@ -142,7 +144,8 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun { _remoteEndPoint = new IPEndPoint(remoteIp, HdHomeRunPort); - _tcpClient = new TcpClient(_remoteEndPoint); + _tcpClient = new TcpClient(); + _tcpClient.Connect(_remoteEndPoint); if (!_lockkey.HasValue) { @@ -165,24 +168,24 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun _activeTuner = i; var lockKeyString = string.Format(CultureInfo.InvariantCulture, "{0:d}", lockKeyValue); - var lockkeyMsg = CreateSetMessage(i, "lockkey", lockKeyString, null); - await stream.WriteAsync(lockkeyMsg, 0, lockkeyMsg.Length, cancellationToken).ConfigureAwait(false); - int receivedBytes = await stream.ReadAsync(buffer, 0, buffer.Length, cancellationToken).ConfigureAwait(false); + var lockkeyMsgLen = WriteSetMessage(buffer, i, "lockkey", lockKeyString, null); + await stream.WriteAsync(buffer.AsMemory(0, lockkeyMsgLen), cancellationToken).ConfigureAwait(false); + int receivedBytes = await stream.ReadAsync(buffer, cancellationToken).ConfigureAwait(false); // parse response to make sure it worked - if (!ParseReturnMessage(buffer, receivedBytes, out _)) + if (!TryGetReturnValueOfGetSet(buffer.AsSpan(0, receivedBytes), out _)) { continue; } foreach (var command in commands.GetCommands()) { - var channelMsg = CreateSetMessage(i, command.Item1, command.Item2, lockKeyValue); - await stream.WriteAsync(channelMsg, 0, channelMsg.Length, cancellationToken).ConfigureAwait(false); - receivedBytes = await stream.ReadAsync(buffer, 0, buffer.Length, cancellationToken).ConfigureAwait(false); + var channelMsgLen = WriteSetMessage(buffer, i, command.Item1, command.Item2, lockKeyValue); + await stream.WriteAsync(buffer.AsMemory(0, channelMsgLen), cancellationToken).ConfigureAwait(false); + receivedBytes = await stream.ReadAsync(buffer, cancellationToken).ConfigureAwait(false); // parse response to make sure it worked - if (!ParseReturnMessage(buffer, receivedBytes, out _)) + if (!TryGetReturnValueOfGetSet(buffer.AsSpan(0, receivedBytes), out _)) { await ReleaseLockkey(_tcpClient, lockKeyValue).ConfigureAwait(false); continue; @@ -190,13 +193,13 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun } var targetValue = string.Format(CultureInfo.InvariantCulture, "rtp://{0}:{1}", localIp, localPort); - var targetMsg = CreateSetMessage(i, "target", targetValue, lockKeyValue); + var targetMsgLen = WriteSetMessage(buffer, i, "target", targetValue, lockKeyValue); - await stream.WriteAsync(targetMsg, 0, targetMsg.Length, cancellationToken).ConfigureAwait(false); - receivedBytes = await stream.ReadAsync(buffer, 0, buffer.Length, cancellationToken).ConfigureAwait(false); + await stream.WriteAsync(buffer.AsMemory(0, targetMsgLen), cancellationToken).ConfigureAwait(false); + receivedBytes = await stream.ReadAsync(buffer, cancellationToken).ConfigureAwait(false); // parse response to make sure it worked - if (!ParseReturnMessage(buffer, receivedBytes, out _)) + if (!TryGetReturnValueOfGetSet(buffer.AsSpan(0, receivedBytes), out _)) { await ReleaseLockkey(_tcpClient, lockKeyValue).ConfigureAwait(false); continue; @@ -221,30 +224,30 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun return; } - using (var tcpClient = new TcpClient(_remoteEndPoint)) - using (var stream = tcpClient.GetStream()) + using var tcpClient = new TcpClient(); + tcpClient.Connect(_remoteEndPoint); + + using var stream = tcpClient.GetStream(); + var commandList = commands.GetCommands(); + byte[] buffer = ArrayPool<byte>.Shared.Rent(8192); + try { - var commandList = commands.GetCommands(); - byte[] buffer = ArrayPool<byte>.Shared.Rent(8192); - try + foreach (var command in commandList) { - foreach (var command in commandList) - { - var channelMsg = CreateSetMessage(_activeTuner, command.Item1, command.Item2, _lockkey); - await stream.WriteAsync(channelMsg, 0, channelMsg.Length, cancellationToken).ConfigureAwait(false); - int receivedBytes = await stream.ReadAsync(buffer, 0, buffer.Length, cancellationToken).ConfigureAwait(false); + var channelMsgLen = WriteSetMessage(buffer, _activeTuner, command.Item1, command.Item2, _lockkey); + await stream.WriteAsync(buffer.AsMemory(0, channelMsgLen), cancellationToken).ConfigureAwait(false); + int receivedBytes = await stream.ReadAsync(buffer, cancellationToken).ConfigureAwait(false); - // parse response to make sure it worked - if (!ParseReturnMessage(buffer, receivedBytes, out _)) - { - return; - } + // parse response to make sure it worked + if (!TryGetReturnValueOfGetSet(buffer.AsSpan(0, receivedBytes), out _)) + { + return; } } - finally - { - ArrayPool<byte>.Shared.Return(buffer); - } + } + finally + { + ArrayPool<byte>.Shared.Return(buffer); } } @@ -264,17 +267,17 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun { var stream = client.GetStream(); - var releaseTarget = CreateSetMessage(_activeTuner, "target", "none", lockKeyValue); - await stream.WriteAsync(releaseTarget, 0, releaseTarget.Length).ConfigureAwait(false); - var buffer = ArrayPool<byte>.Shared.Rent(8192); try { - await stream.ReadAsync(buffer, 0, buffer.Length).ConfigureAwait(false); - var releaseKeyMsg = CreateSetMessage(_activeTuner, "lockkey", "none", lockKeyValue); + var releaseTargetLen = WriteSetMessage(buffer, _activeTuner, "target", "none", lockKeyValue); + await stream.WriteAsync(buffer.AsMemory(0, releaseTargetLen)).ConfigureAwait(false); + + await stream.ReadAsync(buffer).ConfigureAwait(false); + var releaseKeyMsgLen = WriteSetMessage(buffer, _activeTuner, "lockkey", "none", lockKeyValue); _lockkey = null; - await stream.WriteAsync(releaseKeyMsg, 0, releaseKeyMsg.Length).ConfigureAwait(false); - await stream.ReadAsync(buffer, 0, buffer.Length).ConfigureAwait(false); + await stream.WriteAsync(buffer.AsMemory(0, releaseKeyMsgLen)).ConfigureAwait(false); + await stream.ReadAsync(buffer).ConfigureAwait(false); } finally { @@ -282,249 +285,136 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun } } - private static byte[] CreateGetMessage(int tuner, string name) + internal static int WriteGetMessage(Span<byte> buffer, int tuner, string name) { - var byteName = Encoding.UTF8.GetBytes(string.Format(CultureInfo.InvariantCulture, "/tuner{0}/{1}\0", tuner, name)); - int messageLength = byteName.Length + 10; // 4 bytes for header + 4 bytes for crc + 2 bytes for tag name and length - - var message = new byte[messageLength]; + var byteName = string.Format(CultureInfo.InvariantCulture, "/tuner{0}/{1}", tuner, name); + int offset = WriteHeaderAndPayload(buffer, byteName); + return FinishPacket(buffer, offset); + } - int offset = InsertHeaderAndName(byteName, messageLength, message); + internal static int WriteSetMessage(Span<byte> buffer, int tuner, string name, string value, uint? lockkey) + { + var byteName = string.Format(CultureInfo.InvariantCulture, "/tuner{0}/{1}", tuner, name); + int offset = WriteHeaderAndPayload(buffer, byteName); - bool flipEndian = BitConverter.IsLittleEndian; + buffer[offset++] = GetSetValue; + offset += WriteNullTerminatedString(buffer.Slice(offset), value); - // calculate crc and insert at the end of the message - var crcBytes = BitConverter.GetBytes(HdHomerunCrc.GetCrc32(message, messageLength - 4)); - if (flipEndian) + if (lockkey.HasValue) { - Array.Reverse(crcBytes); + buffer[offset++] = GetSetLockkey; + buffer[offset++] = 4; + BinaryPrimitives.WriteUInt32BigEndian(buffer.Slice(offset), lockkey.Value); + offset += 4; } - Buffer.BlockCopy(crcBytes, 0, message, offset, 4); - - return message; + return FinishPacket(buffer, offset); } - private static byte[] CreateSetMessage(int tuner, string name, string value, uint? lockkey) + internal static int WriteNullTerminatedString(Span<byte> buffer, ReadOnlySpan<char> payload) { - var byteName = Encoding.UTF8.GetBytes(string.Format(CultureInfo.InvariantCulture, "/tuner{0}/{1}\0", tuner, name)); - var byteValue = Encoding.UTF8.GetBytes(string.Format(CultureInfo.InvariantCulture, "{0}\0", value)); + int len = Encoding.UTF8.GetBytes(payload, buffer.Slice(1)) + 1; - int messageLength = byteName.Length + byteValue.Length + 12; - if (lockkey.HasValue) - { - messageLength += 6; - } + // TODO: variable length: this can be 2 bytes if len > 127 + // Write length in front of value + buffer[0] = Convert.ToByte(len); - var message = new byte[messageLength]; + // null-terminate + buffer[len++] = 0; - int offset = InsertHeaderAndName(byteName, messageLength, message); + return len; + } - bool flipEndian = BitConverter.IsLittleEndian; + private static int WriteHeaderAndPayload(Span<byte> buffer, ReadOnlySpan<char> payload) + { + // Packet type + BinaryPrimitives.WriteUInt16BigEndian(buffer, GetSetRequest); - message[offset++] = GetSetValue; - message[offset++] = Convert.ToByte(byteValue.Length); - Buffer.BlockCopy(byteValue, 0, message, offset, byteValue.Length); - offset += byteValue.Length; - if (lockkey.HasValue) - { - message[offset++] = GetSetLockkey; - message[offset++] = 4; - var lockKeyBytes = BitConverter.GetBytes(lockkey.Value); - if (flipEndian) - { - Array.Reverse(lockKeyBytes); - } + // We write the payload length at the end + int offset = 4; - Buffer.BlockCopy(lockKeyBytes, 0, message, offset, 4); - offset += 4; - } - - // calculate crc and insert at the end of the message - var crcBytes = BitConverter.GetBytes(HdHomerunCrc.GetCrc32(message, messageLength - 4)); - if (flipEndian) - { - Array.Reverse(crcBytes); - } + // Tag + buffer[offset++] = GetSetName; - Buffer.BlockCopy(crcBytes, 0, message, offset, 4); + // Payload length + data + int strLen = WriteNullTerminatedString(buffer.Slice(offset), payload); + offset += strLen; - return message; + return offset; } - private static int InsertHeaderAndName(byte[] byteName, int messageLength, byte[] message) + private static int FinishPacket(Span<byte> buffer, int offset) { - // check to see if we need to flip endiannes - bool flipEndian = BitConverter.IsLittleEndian; - int offset = 0; - - // create header bytes - var getSetBytes = BitConverter.GetBytes(GetSetRequest); - var msgLenBytes = BitConverter.GetBytes((ushort)(messageLength - 8)); // Subtrace 4 bytes for header and 4 bytes for crc - - if (flipEndian) - { - Array.Reverse(getSetBytes); - Array.Reverse(msgLenBytes); - } + // Payload length + BinaryPrimitives.WriteUInt16BigEndian(buffer.Slice(2), (ushort)(offset - 4)); - // insert header bytes into message - Buffer.BlockCopy(getSetBytes, 0, message, offset, 2); - offset += 2; - Buffer.BlockCopy(msgLenBytes, 0, message, offset, 2); - offset += 2; - - // insert tag name and length - message[offset++] = GetSetName; - message[offset++] = Convert.ToByte(byteName.Length); + // calculate crc and insert at the end of the message + var crc = Crc32.Compute(buffer.Slice(0, offset)); + BinaryPrimitives.WriteUInt32LittleEndian(buffer.Slice(offset), crc); - // insert name string - Buffer.BlockCopy(byteName, 0, message, offset, byteName.Length); - offset += byteName.Length; + return offset + 4; + } - return offset; + internal static bool VerifyReturnValueOfGetSet(ReadOnlySpan<byte> buffer, string expected) + { + return TryGetReturnValueOfGetSet(buffer, out var value) + && string.Equals(Encoding.UTF8.GetString(value), expected, StringComparison.OrdinalIgnoreCase); } - private static bool ParseReturnMessage(byte[] buf, int numBytes, out string returnVal) + internal static bool TryGetReturnValueOfGetSet(ReadOnlySpan<byte> buffer, out ReadOnlySpan<byte> value) { - returnVal = string.Empty; + value = ReadOnlySpan<byte>.Empty; - if (numBytes < 4) + if (buffer.Length < 8) { return false; } - var flipEndian = BitConverter.IsLittleEndian; - int offset = 0; - byte[] msgTypeBytes = new byte[2]; - Buffer.BlockCopy(buf, offset, msgTypeBytes, 0, msgTypeBytes.Length); - - if (flipEndian) + uint crc = BinaryPrimitives.ReadUInt32LittleEndian(buffer[^4..]); + if (crc != Crc32.Compute(buffer[..^4])) { - Array.Reverse(msgTypeBytes); + return false; } - var msgType = BitConverter.ToUInt16(msgTypeBytes, 0); - offset += 2; - - if (msgType != GetSetReply) + if (BinaryPrimitives.ReadUInt16BigEndian(buffer) != GetSetReply) { return false; } - byte[] msgLengthBytes = new byte[2]; - Buffer.BlockCopy(buf, offset, msgLengthBytes, 0, msgLengthBytes.Length); - if (flipEndian) + var msgLength = BinaryPrimitives.ReadUInt16BigEndian(buffer.Slice(2)); + if (buffer.Length != 2 + 2 + 4 + msgLength) { - Array.Reverse(msgLengthBytes); + return false; } - var msgLength = BitConverter.ToUInt16(msgLengthBytes, 0); - offset += 2; - - if (numBytes < msgLength + 8) + var offset = 4; + if (buffer[offset++] != GetSetName) { return false; } - offset++; // Name Tag - - var nameLength = buf[offset++]; + var nameLength = buffer[offset++]; + if (buffer.Length < 4 + 1 + offset + nameLength) + { + return false; + } - // skip the name field to get to value for return offset += nameLength; - offset++; // Value Tag - - var valueLength = buf[offset++]; - - returnVal = Encoding.UTF8.GetString(buf, offset, valueLength - 1); // remove null terminator - return true; - } - - private static class HdHomerunCrc - { - private static uint[] crc_table = { - 0x00000000, 0x77073096, 0xee0e612c, 0x990951ba, - 0x076dc419, 0x706af48f, 0xe963a535, 0x9e6495a3, - 0x0edb8832, 0x79dcb8a4, 0xe0d5e91e, 0x97d2d988, - 0x09b64c2b, 0x7eb17cbd, 0xe7b82d07, 0x90bf1d91, - 0x1db71064, 0x6ab020f2, 0xf3b97148, 0x84be41de, - 0x1adad47d, 0x6ddde4eb, 0xf4d4b551, 0x83d385c7, - 0x136c9856, 0x646ba8c0, 0xfd62f97a, 0x8a65c9ec, - 0x14015c4f, 0x63066cd9, 0xfa0f3d63, 0x8d080df5, - 0x3b6e20c8, 0x4c69105e, 0xd56041e4, 0xa2677172, - 0x3c03e4d1, 0x4b04d447, 0xd20d85fd, 0xa50ab56b, - 0x35b5a8fa, 0x42b2986c, 0xdbbbc9d6, 0xacbcf940, - 0x32d86ce3, 0x45df5c75, 0xdcd60dcf, 0xabd13d59, - 0x26d930ac, 0x51de003a, 0xc8d75180, 0xbfd06116, - 0x21b4f4b5, 0x56b3c423, 0xcfba9599, 0xb8bda50f, - 0x2802b89e, 0x5f058808, 0xc60cd9b2, 0xb10be924, - 0x2f6f7c87, 0x58684c11, 0xc1611dab, 0xb6662d3d, - 0x76dc4190, 0x01db7106, 0x98d220bc, 0xefd5102a, - 0x71b18589, 0x06b6b51f, 0x9fbfe4a5, 0xe8b8d433, - 0x7807c9a2, 0x0f00f934, 0x9609a88e, 0xe10e9818, - 0x7f6a0dbb, 0x086d3d2d, 0x91646c97, 0xe6635c01, - 0x6b6b51f4, 0x1c6c6162, 0x856530d8, 0xf262004e, - 0x6c0695ed, 0x1b01a57b, 0x8208f4c1, 0xf50fc457, - 0x65b0d9c6, 0x12b7e950, 0x8bbeb8ea, 0xfcb9887c, - 0x62dd1ddf, 0x15da2d49, 0x8cd37cf3, 0xfbd44c65, - 0x4db26158, 0x3ab551ce, 0xa3bc0074, 0xd4bb30e2, - 0x4adfa541, 0x3dd895d7, 0xa4d1c46d, 0xd3d6f4fb, - 0x4369e96a, 0x346ed9fc, 0xad678846, 0xda60b8d0, - 0x44042d73, 0x33031de5, 0xaa0a4c5f, 0xdd0d7cc9, - 0x5005713c, 0x270241aa, 0xbe0b1010, 0xc90c2086, - 0x5768b525, 0x206f85b3, 0xb966d409, 0xce61e49f, - 0x5edef90e, 0x29d9c998, 0xb0d09822, 0xc7d7a8b4, - 0x59b33d17, 0x2eb40d81, 0xb7bd5c3b, 0xc0ba6cad, - 0xedb88320, 0x9abfb3b6, 0x03b6e20c, 0x74b1d29a, - 0xead54739, 0x9dd277af, 0x04db2615, 0x73dc1683, - 0xe3630b12, 0x94643b84, 0x0d6d6a3e, 0x7a6a5aa8, - 0xe40ecf0b, 0x9309ff9d, 0x0a00ae27, 0x7d079eb1, - 0xf00f9344, 0x8708a3d2, 0x1e01f268, 0x6906c2fe, - 0xf762575d, 0x806567cb, 0x196c3671, 0x6e6b06e7, - 0xfed41b76, 0x89d32be0, 0x10da7a5a, 0x67dd4acc, - 0xf9b9df6f, 0x8ebeeff9, 0x17b7be43, 0x60b08ed5, - 0xd6d6a3e8, 0xa1d1937e, 0x38d8c2c4, 0x4fdff252, - 0xd1bb67f1, 0xa6bc5767, 0x3fb506dd, 0x48b2364b, - 0xd80d2bda, 0xaf0a1b4c, 0x36034af6, 0x41047a60, - 0xdf60efc3, 0xa867df55, 0x316e8eef, 0x4669be79, - 0xcb61b38c, 0xbc66831a, 0x256fd2a0, 0x5268e236, - 0xcc0c7795, 0xbb0b4703, 0x220216b9, 0x5505262f, - 0xc5ba3bbe, 0xb2bd0b28, 0x2bb45a92, 0x5cb36a04, - 0xc2d7ffa7, 0xb5d0cf31, 0x2cd99e8b, 0x5bdeae1d, - 0x9b64c2b0, 0xec63f226, 0x756aa39c, 0x026d930a, - 0x9c0906a9, 0xeb0e363f, 0x72076785, 0x05005713, - 0x95bf4a82, 0xe2b87a14, 0x7bb12bae, 0x0cb61b38, - 0x92d28e9b, 0xe5d5be0d, 0x7cdcefb7, 0x0bdbdf21, - 0x86d3d2d4, 0xf1d4e242, 0x68ddb3f8, 0x1fda836e, - 0x81be16cd, 0xf6b9265b, 0x6fb077e1, 0x18b74777, - 0x88085ae6, 0xff0f6a70, 0x66063bca, 0x11010b5c, - 0x8f659eff, 0xf862ae69, 0x616bffd3, 0x166ccf45, - 0xa00ae278, 0xd70dd2ee, 0x4e048354, 0x3903b3c2, - 0xa7672661, 0xd06016f7, 0x4969474d, 0x3e6e77db, - 0xaed16a4a, 0xd9d65adc, 0x40df0b66, 0x37d83bf0, - 0xa9bcae53, 0xdebb9ec5, 0x47b2cf7f, 0x30b5ffe9, - 0xbdbdf21c, 0xcabac28a, 0x53b39330, 0x24b4a3a6, - 0xbad03605, 0xcdd70693, 0x54de5729, 0x23d967bf, - 0xb3667a2e, 0xc4614ab8, 0x5d681b02, 0x2a6f2b94, - 0xb40bbe37, 0xc30c8ea1, 0x5a05df1b, 0x2d02ef8d }; - - public static uint GetCrc32(byte[] bytes, int numBytes) + if (buffer[offset++] != GetSetValue) { - var hash = 0xffffffff; - for (var i = 0; i < numBytes; i++) - { - hash = (hash >> 8) ^ crc_table[(hash ^ bytes[i]) & 0xff]; - } + return false; + } - var tmp = ~hash & 0xffffffff; - var b0 = tmp & 0xff; - var b1 = (tmp >> 8) & 0xff; - var b2 = (tmp >> 16) & 0xff; - var b3 = (tmp >> 24) & 0xff; - return (b0 << 24) | (b1 << 16) | (b2 << 8) | b3; + var valueLength = buffer[offset++]; + if (buffer.Length < 4 + offset + valueLength) + { + return false; } + + // remove null terminator + value = buffer.Slice(offset, valueLength - 1); + return true; } } } diff --git a/Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/HdHomerunUdpStream.cs b/Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/HdHomerunUdpStream.cs index 6730751d50..58e0c7448a 100644 --- a/Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/HdHomerunUdpStream.cs +++ b/Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/HdHomerunUdpStream.cs @@ -1,14 +1,17 @@ +#nullable disable + #pragma warning disable CS1591 using System; using System.Collections.Generic; using System.IO; +using System.Linq; using System.Net; +using System.Net.NetworkInformation; using System.Net.Sockets; using System.Threading; using System.Threading.Tasks; using MediaBrowser.Common.Configuration; -using MediaBrowser.Common.Net; using MediaBrowser.Controller; using MediaBrowser.Controller.Library; using MediaBrowser.Model.Dto; @@ -26,7 +29,6 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun private readonly IServerApplicationHost _appHost; private readonly IHdHomerunChannelCommands _channelCommands; private readonly int _numTuners; - private readonly INetworkManager _networkManager; public HdHomerunUdpStream( MediaSourceInfo mediaSource, @@ -38,18 +40,36 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun ILogger logger, IConfigurationManager configurationManager, IServerApplicationHost appHost, - INetworkManager networkManager, IStreamHelper streamHelper) : base(mediaSource, tunerHostInfo, fileSystem, logger, configurationManager, streamHelper) { _appHost = appHost; - _networkManager = networkManager; OriginalStreamId = originalStreamId; _channelCommands = channelCommands; _numTuners = numTuners; EnableStreamSharing = true; } + /// <summary> + /// Returns an unused UDP port number in the range specified. + /// Temporarily placed here until future network PR merged. + /// </summary> + /// <param name="range">Upper and Lower boundary of ports to select.</param> + /// <returns>System.Int32.</returns> + private static int GetUdpPortFromRange((int Min, int Max) range) + { + var properties = IPGlobalProperties.GetIPGlobalProperties(); + + // Get active udp listeners. + var udpListenerPorts = properties.GetActiveUdpListeners() + .Where(n => n.Port >= range.Min && n.Port <= range.Max) + .Select(n => n.Port); + + return Enumerable + .Range(range.Min, range.Max) + .FirstOrDefault(i => !udpListenerPorts.Contains(i)); + } + public override async Task Open(CancellationToken openCancellationToken) { LiveStreamCancellationTokenSource.Token.ThrowIfCancellationRequested(); @@ -57,7 +77,8 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun var mediaSource = OriginalMediaSource; var uri = new Uri(mediaSource.Path); - var localPort = _networkManager.GetRandomUnusedUdpPort(); + // Temporary code to reduce PR size. This will be updated by a future network pr. + var localPort = GetUdpPortFromRange((49152, 65535)); Directory.CreateDirectory(Path.GetDirectoryName(TempFilePath)); @@ -69,8 +90,8 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun { try { - await tcpClient.ConnectAsync(remoteAddress, HdHomerunManager.HdHomeRunPort).ConfigureAwait(false); - localAddress = ((IPEndPoint)tcpClient.Client.RemoteEndPoint).Address; + await tcpClient.ConnectAsync(remoteAddress, HdHomerunManager.HdHomeRunPort, openCancellationToken).ConfigureAwait(false); + localAddress = ((IPEndPoint)tcpClient.Client.LocalEndPoint).Address; tcpClient.Close(); } catch (Exception ex) @@ -80,6 +101,10 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun } } + if (localAddress.IsIPv4MappedToIPv6) { + localAddress = localAddress.MapToIPv4(); + } + var udpClient = new UdpClient(localPort, AddressFamily.InterNetwork); var hdHomerunManager = new HdHomerunManager(); @@ -99,7 +124,7 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun using (udpClient) using (hdHomerunManager) { - if (!(ex is OperationCanceledException)) + if (ex is not OperationCanceledException) { Logger.LogError(ex, "Error opening live stream:"); } @@ -110,12 +135,12 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun var taskCompletionSource = new TaskCompletionSource<bool>(); - await StartStreaming( + _ = StartStreaming( udpClient, hdHomerunManager, remoteAddress, taskCompletionSource, - LiveStreamCancellationTokenSource.Token).ConfigureAwait(false); + LiveStreamCancellationTokenSource.Token); // OpenedMediaSource.Protocol = MediaProtocol.File; // OpenedMediaSource.Path = tempFile; @@ -131,33 +156,35 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun await taskCompletionSource.Task.ConfigureAwait(false); } - private Task StartStreaming(UdpClient udpClient, HdHomerunManager hdHomerunManager, IPAddress remoteAddress, TaskCompletionSource<bool> openTaskCompletionSource, CancellationToken cancellationToken) + public string GetFilePath() { - return Task.Run(async () => + return TempFilePath; + } + + private async Task StartStreaming(UdpClient udpClient, HdHomerunManager hdHomerunManager, IPAddress remoteAddress, TaskCompletionSource<bool> openTaskCompletionSource, CancellationToken cancellationToken) + { + using (udpClient) + using (hdHomerunManager) { - using (udpClient) - using (hdHomerunManager) + try { - try - { - await CopyTo(udpClient, TempFilePath, openTaskCompletionSource, cancellationToken).ConfigureAwait(false); - } - catch (OperationCanceledException ex) - { - Logger.LogInformation("HDHR UDP stream cancelled or timed out from {0}", remoteAddress); - openTaskCompletionSource.TrySetException(ex); - } - catch (Exception ex) - { - Logger.LogError(ex, "Error opening live stream:"); - openTaskCompletionSource.TrySetException(ex); - } - - EnableStreamSharing = false; + await CopyTo(udpClient, TempFilePath, openTaskCompletionSource, cancellationToken).ConfigureAwait(false); } + catch (OperationCanceledException ex) + { + Logger.LogInformation("HDHR UDP stream cancelled or timed out from {0}", remoteAddress); + openTaskCompletionSource.TrySetException(ex); + } + catch (Exception ex) + { + Logger.LogError(ex, "Error opening live stream:"); + openTaskCompletionSource.TrySetException(ex); + } + + EnableStreamSharing = false; + } - await DeleteTempFiles(new List<string> { TempFilePath }).ConfigureAwait(false); - }); + await DeleteTempFiles(new List<string> { TempFilePath }).ConfigureAwait(false); } private async Task CopyTo(UdpClient udpClient, string file, TaskCompletionSource<bool> openTaskCompletionSource, CancellationToken cancellationToken) diff --git a/Emby.Server.Implementations/LiveTv/TunerHosts/LiveStream.cs b/Emby.Server.Implementations/LiveTv/TunerHosts/LiveStream.cs index 0333e723bc..96a678c1d3 100644 --- a/Emby.Server.Implementations/LiveTv/TunerHosts/LiveStream.cs +++ b/Emby.Server.Implementations/LiveTv/TunerHosts/LiveStream.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System; @@ -150,7 +152,8 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts public async Task CopyToAsync(Stream stream, CancellationToken cancellationToken) { - cancellationToken = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, LiveStreamCancellationTokenSource.Token).Token; + using var linkedCancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, LiveStreamCancellationTokenSource.Token); + cancellationToken = linkedCancellationTokenSource.Token; // use non-async filestream on windows along with read due to https://github.com/dotnet/corefx/issues/6039 var allowAsync = Environment.OSVersion.Platform != PlatformID.Win32NT; @@ -182,7 +185,7 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts if (string.IsNullOrEmpty(currentFile)) { - return (files.Last(), true); + return (files[^1], true); } var nextIndex = files.FindIndex(i => string.Equals(i, currentFile, StringComparison.OrdinalIgnoreCase)) + 1; diff --git a/Emby.Server.Implementations/LiveTv/TunerHosts/M3UTunerHost.cs b/Emby.Server.Implementations/LiveTv/TunerHosts/M3UTunerHost.cs index 8107bc427b..8fa6f5ad68 100644 --- a/Emby.Server.Implementations/LiveTv/TunerHosts/M3UTunerHost.cs +++ b/Emby.Server.Implementations/LiveTv/TunerHosts/M3UTunerHost.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System; @@ -27,6 +29,14 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts { public class M3UTunerHost : BaseTunerHost, ITunerHost, IConfigurableTunerHost { + private static readonly string[] _disallowedSharedStreamExtensions = + { + ".mkv", + ".mp4", + ".m3u8", + ".mpd" + }; + private readonly IHttpClientFactory _httpClientFactory; private readonly IServerApplicationHost _appHost; private readonly INetworkManager _networkManager; @@ -65,7 +75,9 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts { var channelIdPrefix = GetFullChannelIdPrefix(info); - return await new M3uParser(Logger, _httpClientFactory, _appHost).Parse(info.Url, channelIdPrefix, info.Id, cancellationToken).ConfigureAwait(false); + return await new M3uParser(Logger, _httpClientFactory) + .Parse(info, channelIdPrefix, cancellationToken) + .ConfigureAwait(false); } public Task<List<LiveTvTunerInfo>> GetTunerInfos(CancellationToken cancellationToken) @@ -84,14 +96,6 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts return Task.FromResult(list); } - private static readonly string[] _disallowedSharedStreamExtensions = - { - ".mkv", - ".mp4", - ".m3u8", - ".mpd" - }; - protected override async Task<ILiveStream> GetChannelStream(TunerHostInfo info, ChannelInfo channelInfo, string streamId, List<ILiveStream> currentLiveStreams, CancellationToken cancellationToken) { var tunerCount = info.TunerCount; @@ -126,7 +130,7 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts public async Task Validate(TunerHostInfo info) { - using (var stream = await new M3uParser(Logger, _httpClientFactory, _appHost).GetListingsStream(info.Url, CancellationToken.None).ConfigureAwait(false)) + using (var stream = await new M3uParser(Logger, _httpClientFactory).GetListingsStream(info, CancellationToken.None).ConfigureAwait(false)) { } } diff --git a/Emby.Server.Implementations/LiveTv/TunerHosts/M3uParser.cs b/Emby.Server.Implementations/LiveTv/TunerHosts/M3uParser.cs index f066a749e3..16ff98a7d8 100644 --- a/Emby.Server.Implementations/LiveTv/TunerHosts/M3uParser.cs +++ b/Emby.Server.Implementations/LiveTv/TunerHosts/M3uParser.cs @@ -1,104 +1,110 @@ +#nullable disable + #pragma warning disable CS1591 using System; using System.Collections.Generic; using System.Globalization; using System.IO; -using System.Linq; using System.Net.Http; using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; +using Jellyfin.Extensions; using MediaBrowser.Common.Extensions; using MediaBrowser.Common.Net; -using MediaBrowser.Controller; using MediaBrowser.Controller.LiveTv; +using MediaBrowser.Model.LiveTv; using Microsoft.Extensions.Logging; namespace Emby.Server.Implementations.LiveTv.TunerHosts { public class M3uParser { + private const string ExtInfPrefix = "#EXTINF:"; + private readonly ILogger _logger; private readonly IHttpClientFactory _httpClientFactory; - private readonly IServerApplicationHost _appHost; - public M3uParser(ILogger logger, IHttpClientFactory httpClientFactory, IServerApplicationHost appHost) + public M3uParser(ILogger logger, IHttpClientFactory httpClientFactory) { _logger = logger; _httpClientFactory = httpClientFactory; - _appHost = appHost; } - public async Task<List<ChannelInfo>> Parse(string url, string channelIdPrefix, string tunerHostId, CancellationToken cancellationToken) + public async Task<List<ChannelInfo>> Parse(TunerHostInfo info, string channelIdPrefix, CancellationToken cancellationToken) { // Read the file and display it line by line. - using (var reader = new StreamReader(await GetListingsStream(url, cancellationToken).ConfigureAwait(false))) + using (var reader = new StreamReader(await GetListingsStream(info, cancellationToken).ConfigureAwait(false))) { - return GetChannels(reader, channelIdPrefix, tunerHostId); + return await GetChannelsAsync(reader, channelIdPrefix, info.Id).ConfigureAwait(false); } } - public List<ChannelInfo> ParseString(string text, string channelIdPrefix, string tunerHostId) + public async Task<Stream> GetListingsStream(TunerHostInfo info, CancellationToken cancellationToken) { - // Read the file and display it line by line. - using (var reader = new StringReader(text)) + if (info == null) { - return GetChannels(reader, channelIdPrefix, tunerHostId); + throw new ArgumentNullException(nameof(info)); } - } - public Task<Stream> GetListingsStream(string url, CancellationToken cancellationToken) - { - if (url.StartsWith("http", StringComparison.OrdinalIgnoreCase)) + if (!info.Url.StartsWith("http", StringComparison.OrdinalIgnoreCase)) { - return _httpClientFactory.CreateClient(NamedClient.Default) - .GetStreamAsync(url); + return File.OpenRead(info.Url); } - return Task.FromResult((Stream)File.OpenRead(url)); - } + using var requestMessage = new HttpRequestMessage(HttpMethod.Get, info.Url); + if (!string.IsNullOrEmpty(info.UserAgent)) + { + requestMessage.Headers.UserAgent.TryParseAdd(info.UserAgent); + } - private const string ExtInfPrefix = "#EXTINF:"; + // Set HttpCompletionOption.ResponseHeadersRead to prevent timeouts on larger files + var response = await _httpClientFactory.CreateClient(NamedClient.Default) + .SendAsync(requestMessage, HttpCompletionOption.ResponseHeadersRead, cancellationToken) + .ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + + return await response.Content.ReadAsStreamAsync(cancellationToken); + } - private List<ChannelInfo> GetChannels(TextReader reader, string channelIdPrefix, string tunerHostId) + private async Task<List<ChannelInfo>> GetChannelsAsync(TextReader reader, string channelIdPrefix, string tunerHostId) { var channels = new List<ChannelInfo>(); - string line; string extInf = string.Empty; - while ((line = reader.ReadLine()) != null) + await foreach (var line in reader.ReadAllLinesAsync().ConfigureAwait(false)) { - line = line.Trim(); - if (string.IsNullOrWhiteSpace(line)) + var trimmedLine = line.Trim(); + if (string.IsNullOrWhiteSpace(trimmedLine)) { continue; } - if (line.StartsWith("#EXTM3U", StringComparison.OrdinalIgnoreCase)) + if (trimmedLine.StartsWith("#EXTM3U", StringComparison.OrdinalIgnoreCase)) { continue; } - if (line.StartsWith(ExtInfPrefix, StringComparison.OrdinalIgnoreCase)) + if (trimmedLine.StartsWith(ExtInfPrefix, StringComparison.OrdinalIgnoreCase)) { - extInf = line.Substring(ExtInfPrefix.Length).Trim(); - _logger.LogInformation("Found m3u channel: {0}", extInf); + extInf = trimmedLine.Substring(ExtInfPrefix.Length).Trim(); } - else if (!string.IsNullOrWhiteSpace(extInf) && !line.StartsWith("#", StringComparison.OrdinalIgnoreCase)) + else if (!string.IsNullOrWhiteSpace(extInf) && !trimmedLine.StartsWith('#')) { - var channel = GetChannelnfo(extInf, tunerHostId, line); + var channel = GetChannelnfo(extInf, tunerHostId, trimmedLine); if (string.IsNullOrWhiteSpace(channel.Id)) { - channel.Id = channelIdPrefix + line.GetMD5().ToString("N", CultureInfo.InvariantCulture); + channel.Id = channelIdPrefix + trimmedLine.GetMD5().ToString("N", CultureInfo.InvariantCulture); } else { channel.Id = channelIdPrefix + channel.Id.GetMD5().ToString("N", CultureInfo.InvariantCulture); } - channel.Path = line; + channel.Path = trimmedLine; channels.Add(channel); + _logger.LogInformation("Parsed channel: {ChannelName}", channel.Name); extInf = string.Empty; } } @@ -123,6 +129,11 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts channel.ImageUrl = value; } + if (attributes.TryGetValue("group-title", out string groupTitle)) + { + channel.ChannelGroup = groupTitle; + } + channel.Name = GetChannelName(extInf, attributes); channel.Number = GetChannelNumber(extInf, attributes, mediaUrl); @@ -145,7 +156,7 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts if (channelIdValues.Count > 0) { - channel.Id = string.Join("_", channelIdValues); + channel.Id = string.Join('_', channelIdValues); } return channel; @@ -153,7 +164,7 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts private string GetChannelNumber(string extInf, Dictionary<string, string> attributes, string mediaUrl) { - var nameParts = extInf.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries); + var nameParts = extInf.Split(',', StringSplitOptions.RemoveEmptyEntries); var nameInExtInf = nameParts.Length > 1 ? nameParts[^1].AsSpan().Trim() : ReadOnlySpan<char>.Empty; string numberString = null; @@ -187,7 +198,7 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts if (string.IsNullOrWhiteSpace(numberString)) { // Using this as a fallback now as this leads to Problems with channels like "5 USA" - // where 5 isnt ment to be the channel number + // where 5 isn't ment to be the channel number // Check for channel number with the format from SatIp // #EXTINF:0,84. VOX Schweiz // #EXTINF:0,84.0 - VOX Schweiz @@ -263,8 +274,8 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts private static string GetChannelName(string extInf, Dictionary<string, string> attributes) { - var nameParts = extInf.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries); - var nameInExtInf = nameParts.Length > 1 ? nameParts.Last().Trim() : null; + var nameParts = extInf.Split(',', StringSplitOptions.RemoveEmptyEntries); + var nameInExtInf = nameParts.Length > 1 ? nameParts[^1].Trim() : null; // Check for channel number with the format from SatIp // #EXTINF:0,84. VOX Schweiz diff --git a/Emby.Server.Implementations/LiveTv/TunerHosts/SharedHttpStream.cs b/Emby.Server.Implementations/LiveTv/TunerHosts/SharedHttpStream.cs index 6c10fca8c6..f572151b8a 100644 --- a/Emby.Server.Implementations/LiveTv/TunerHosts/SharedHttpStream.cs +++ b/Emby.Server.Implementations/LiveTv/TunerHosts/SharedHttpStream.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System; @@ -55,14 +57,15 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts var typeName = GetType().Name; Logger.LogInformation("Opening " + typeName + " Live stream from {0}", url); - using var response = await _httpClientFactory.CreateClient(NamedClient.Default) + // Response stream is disposed manually. + var response = await _httpClientFactory.CreateClient(NamedClient.Default) .GetAsync(url, HttpCompletionOption.ResponseHeadersRead, CancellationToken.None) .ConfigureAwait(false); var extension = "ts"; var requiresRemux = false; - var contentType = response.Content.Headers.ContentType.ToString(); + var contentType = response.Content.Headers.ContentType?.ToString() ?? string.Empty; if (contentType.IndexOf("matroska", StringComparison.OrdinalIgnoreCase) != -1) { requiresRemux = true; @@ -88,8 +91,6 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts var taskCompletionSource = new TaskCompletionSource<bool>(); - var now = DateTime.UtcNow; - _ = StartStreaming(response, taskCompletionSource, LiveStreamCancellationTokenSource.Token); // OpenedMediaSource.Protocol = MediaProtocol.File; @@ -117,10 +118,15 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts if (!taskCompletionSource.Task.Result) { Logger.LogWarning("Zero bytes copied from stream {0} to {1} but no exception raised", GetType().Name, TempFilePath); - throw new EndOfStreamException(String.Format(CultureInfo.InvariantCulture, "Zero bytes copied from stream {0}", GetType().Name)); + throw new EndOfStreamException(string.Format(CultureInfo.InvariantCulture, "Zero bytes copied from stream {0}", GetType().Name)); } } + public string GetFilePath() + { + return TempFilePath; + } + private Task StartStreaming(HttpResponseMessage response, TaskCompletionSource<bool> openTaskCompletionSource, CancellationToken cancellationToken) { return Task.Run(async () => @@ -129,7 +135,7 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts { Logger.LogInformation("Beginning {0} stream to {1}", GetType().Name, TempFilePath); using var message = response; - await using var stream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false); + await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); await using var fileStream = new FileStream(TempFilePath, FileMode.Create, FileAccess.Write, FileShare.Read); await StreamHelper.CopyToAsync( stream, @@ -153,7 +159,7 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts EnableStreamSharing = false; await DeleteTempFiles(new List<string> { TempFilePath }).ConfigureAwait(false); - }); + }, CancellationToken.None); } private void Resolve(TaskCompletionSource<bool> openTaskCompletionSource) diff --git a/Emby.Server.Implementations/Localization/Core/af.json b/Emby.Server.Implementations/Localization/Core/af.json index 977a1c2d70..18f17dda95 100644 --- a/Emby.Server.Implementations/Localization/Core/af.json +++ b/Emby.Server.Implementations/Localization/Core/af.json @@ -2,24 +2,24 @@ "Artists": "Kunstenare", "Channels": "Kanale", "Folders": "Lêergidse", - "Favorites": "Gunstellinge", + "Favorites": "Gunstelinge", "HeaderFavoriteShows": "Gunsteling Vertonings", "ValueSpecialEpisodeName": "Spesiale - {0}", - "HeaderAlbumArtists": "Album Kunstenaars", + "HeaderAlbumArtists": "Kunstenaars se Album", "Books": "Boeke", "HeaderNextUp": "Volgende", "Movies": "Flieks", "Shows": "Televisie Reekse", "HeaderContinueWatching": "Kyk Verder", "HeaderFavoriteEpisodes": "Gunsteling Episodes", - "Photos": "Fotos", + "Photos": "Foto's", "Playlists": "Snitlyste", "HeaderFavoriteArtists": "Gunsteling Kunstenaars", "HeaderFavoriteAlbums": "Gunsteling Albums", "Sync": "Sinkroniseer", "HeaderFavoriteSongs": "Gunsteling Liedjies", "Songs": "Liedjies", - "DeviceOnlineWithName": "{0} gekoppel is", + "DeviceOnlineWithName": "{0} is gekoppel", "DeviceOfflineWithName": "{0} is ontkoppel", "Collections": "Versamelings", "Inherit": "Ontvang", @@ -71,7 +71,7 @@ "NameSeasonUnknown": "Seisoen Onbekend", "NameSeasonNumber": "Seisoen {0}", "NameInstallFailed": "{0} installering het misluk", - "MusicVideos": "Musiek videos", + "MusicVideos": "Musiek Videos", "Music": "Musiek", "MixedContent": "Gemengde inhoud", "MessageServerConfigurationUpdated": "Bediener konfigurasie is opgedateer", @@ -79,15 +79,15 @@ "MessageApplicationUpdatedTo": "Jellyfin Bediener is opgedateer na {0}", "MessageApplicationUpdated": "Jellyfin Bediener is opgedateer", "Latest": "Nuutste", - "LabelRunningTimeValue": "Lopende tyd: {0}", + "LabelRunningTimeValue": "Werktyd: {0}", "LabelIpAddressValue": "IP adres: {0}", "ItemRemovedWithName": "{0} is uit versameling verwyder", - "ItemAddedWithName": "{0} is in die versameling", - "HomeVideos": "Tuis opnames", + "ItemAddedWithName": "{0} is by die versameling gevoeg", + "HomeVideos": "Tuis Videos", "HeaderRecordingGroups": "Groep Opnames", "Genres": "Genres", "FailedLoginAttemptWithUserName": "Mislukte aansluiting van {0}", - "ChapterNameValue": "Hoofstuk", + "ChapterNameValue": "Hoofstuk {0}", "CameraImageUploadedFrom": "'n Nuwe kamera photo opgelaai van {0}", "AuthenticationSucceededWithUserName": "{0} suksesvol geverifieer", "Albums": "Albums", @@ -112,5 +112,12 @@ "TaskRefreshLibraryDescription": "Skandeer u media versameling vir nuwe lêers en verfris metadata.", "TaskRefreshLibrary": "Skandeer Media Versameling", "TaskRefreshChapterImagesDescription": "Maak kleinkiekeis (fotos) vir films wat hoofstukke het.", - "TaskRefreshChapterImages": "Verkry Hoofstuk Beelde" + "TaskRefreshChapterImages": "Verkry Hoofstuk Beelde", + "Undefined": "Ongedefineerd", + "Forced": "Geforseer", + "Default": "Oorspronklik", + "TaskCleanActivityLogDescription": "Verwyder aktiwiteitsaantekeninge ouer as die opgestelde ouderdom.", + "TaskCleanActivityLog": "Maak Aktiwiteitsaantekeninge Skoon", + "TaskOptimizeDatabaseDescription": "Komprimeer databasis en verkort vrye ruimte. As hierdie taak uitgevoer word nadat die media versameling geskandeer is of ander veranderings aangebring is wat databasisaanpassings impliseer, kan dit die prestasie verbeter.", + "TaskOptimizeDatabase": "Optimaliseer databasis" } diff --git a/Emby.Server.Implementations/Localization/Core/ar.json b/Emby.Server.Implementations/Localization/Core/ar.json index 4b898e6fe0..be629c8a42 100644 --- a/Emby.Server.Implementations/Localization/Core/ar.json +++ b/Emby.Server.Implementations/Localization/Core/ar.json @@ -113,5 +113,12 @@ "TaskRefreshPeopleDescription": "تحديث البيانات الوصفية للممثلين والمخرجين في مكتبة الوسائط الخاصة بك.", "TaskRefreshPeople": "إعادة تحميل الأشخاص", "TaskCleanLogsDescription": "حذف السجلات الأقدم من {0} يوم.", - "TaskCleanLogs": "حذف دليل السجل" + "TaskCleanLogs": "حذف دليل السجل", + "TaskCleanActivityLogDescription": "يحذف سجل الأنشطة الأقدم من الوقت الموضوع.", + "TaskCleanActivityLog": "حذف سجل الأنشطة", + "Default": "الإعدادات الافتراضية", + "Undefined": "غير معرف", + "Forced": "ملحقة", + "TaskOptimizeDatabaseDescription": "يضغط قاعدة البيانات ويقتطع المساحة الحرة. تشغيل هذه المهمة بعد فحص المكتبة أو إجراء تغييرات أخرى تشير ضمنًا إلى أن تعديلات قاعدة البيانات قد تؤدي إلى تحسين الأداء.", + "TaskOptimizeDatabase": "تحسين قاعدة البيانات" } diff --git a/Emby.Server.Implementations/Localization/Core/bg-BG.json b/Emby.Server.Implementations/Localization/Core/bg-BG.json index 1fed832768..f55287d15b 100644 --- a/Emby.Server.Implementations/Localization/Core/bg-BG.json +++ b/Emby.Server.Implementations/Localization/Core/bg-BG.json @@ -29,17 +29,17 @@ "Inherit": "Наследяване", "ItemAddedWithName": "{0} е добавено към библиотеката", "ItemRemovedWithName": "{0} е премахнато от библиотеката", - "LabelIpAddressValue": "ИП адрес: {0}", - "LabelRunningTimeValue": "Стартирано от: {0}", + "LabelIpAddressValue": "IP адрес: {0}", + "LabelRunningTimeValue": "Продължителност: {0}", "Latest": "Последни", - "MessageApplicationUpdated": "Сървърът е обновен", - "MessageApplicationUpdatedTo": "Сървърът е обновен до {0}", - "MessageNamedServerConfigurationUpdatedWithValue": "Секцията {0} от сървърната конфигурация се актуализира", - "MessageServerConfigurationUpdated": "Конфигурацията на сървъра се актуализира", + "MessageApplicationUpdated": "Сървърът беше обновен", + "MessageApplicationUpdatedTo": "Сървърът беше обновен до {0}", + "MessageNamedServerConfigurationUpdatedWithValue": "Секцията {0} от сървърната конфигурация беше актуализирана", + "MessageServerConfigurationUpdated": "Конфигурацията на сървъра беше актуализирана", "MixedContent": "Смесено съдържание", "Movies": "Филми", "Music": "Музика", - "MusicVideos": "Музикални клипове", + "MusicVideos": "Музикални видеа", "NameInstallFailed": "{0} не можа да се инсталира", "NameSeasonNumber": "Сезон {0}", "NameSeasonUnknown": "Неразпознат сезон", @@ -55,26 +55,26 @@ "NotificationOptionPluginInstalled": "Приставката е инсталирана", "NotificationOptionPluginUninstalled": "Приставката е деинсталирана", "NotificationOptionPluginUpdateInstalled": "Обновлението на приставката е инсталирано", - "NotificationOptionServerRestartRequired": "Нужно е повторно пускане на сървъра", + "NotificationOptionServerRestartRequired": "Сървърът трябва да се рестартира", "NotificationOptionTaskFailed": "Грешка в планирана задача", - "NotificationOptionUserLockedOut": "Потребителя е заключен", + "NotificationOptionUserLockedOut": "Потребителят е заключен", "NotificationOptionVideoPlayback": "Възпроизвеждането на видео започна", "NotificationOptionVideoPlaybackStopped": "Възпроизвеждането на видео е спряно", "Photos": "Снимки", "Playlists": "Списъци", - "Plugin": "Приставка", - "PluginInstalledWithName": "{0} е инсталирано", - "PluginUninstalledWithName": "{0} е деинсталирано", - "PluginUpdatedWithName": "{0} е обновено", + "Plugin": "Добавка", + "PluginInstalledWithName": "{0} е инсталиранa", + "PluginUninstalledWithName": "{0} е деинсталиранa", + "PluginUpdatedWithName": "{0} е обновенa", "ProviderValue": "Доставчик: {0}", "ScheduledTaskFailedWithName": "{0} се провали", "ScheduledTaskStartedWithName": "{0} започна", - "ServerNameNeedsToBeRestarted": "{0} е нужно да се рестартира", + "ServerNameNeedsToBeRestarted": "{0} трябва да се рестартира", "Shows": "Сериали", "Songs": "Песни", "StartupEmbyServerIsLoading": "Сървърът зарежда. Моля, опитайте отново след малко.", "SubtitleDownloadFailureForItem": "Неуспешно изтегляне на субтитри за {0}", - "SubtitleDownloadFailureFromForItem": "Поднадписите за {1} от {0} не можаха да се изтеглят", + "SubtitleDownloadFailureFromForItem": "Субтитрите за {1} от {0} не можаха да бъдат изтеглени", "Sync": "Синхронизиране", "System": "Система", "TvShows": "Телевизионни сериали", @@ -92,12 +92,12 @@ "ValueHasBeenAddedToLibrary": "{0} беше добавен във Вашата библиотека", "ValueSpecialEpisodeName": "Специални - {0}", "VersionNumber": "Версия {0}", - "TaskDownloadMissingSubtitlesDescription": "Търси Интернет за липсващи поднадписи, на база конфигурацията за мета-данни.", - "TaskDownloadMissingSubtitles": "Изтегляне на липсващи поднадписи", + "TaskDownloadMissingSubtitlesDescription": "Търси Интернет за липсващи субтитри, на база конфигурацията за мета-данни.", + "TaskDownloadMissingSubtitles": "Изтегляне на липсващи субтитри", "TaskRefreshChannelsDescription": "Обновява информацията за интернет канала.", "TaskRefreshChannels": "Обновяване на Канали", - "TaskCleanTranscodeDescription": "Изтрива прекодирани файлове по-стари от един ден.", - "TaskCleanTranscode": "Изчиства директорията за прекодиране", + "TaskCleanTranscodeDescription": "Изтрива транскодирани файлове по-стари от един ден.", + "TaskCleanTranscode": "Изчиства директорията за транскодиране", "TaskUpdatePluginsDescription": "Изтегля и инсталира актуализации за добавките, които са настроени за автоматична актуализация.", "TaskUpdatePlugins": "Актуализира добавките", "TaskRefreshPeopleDescription": "Актуализира мета-данните за артистите и режисьорите за Вашата медийна библиотека.", @@ -113,5 +113,12 @@ "TasksChannelsCategory": "Интернет Канали", "TasksApplicationCategory": "Приложение", "TasksLibraryCategory": "Библиотека", - "TasksMaintenanceCategory": "Поддръжка" + "TasksMaintenanceCategory": "Поддръжка", + "Undefined": "Неопределено", + "Forced": "Принудително", + "Default": "По подразбиране", + "TaskCleanActivityLogDescription": "Изтрива записите в дневника с активност по стари от конфигурираната възраст.", + "TaskCleanActivityLog": "Изчисти дневника с активност", + "TaskOptimizeDatabaseDescription": "Прави базата данни по-компактна и освобождава място. Пускането на тази задача след сканиране на библиотеката или правене на други промени, свързани с модификации на базата данни, може да подобри производителността.", + "TaskOptimizeDatabase": "Оптимизирай базата данни" } diff --git a/Emby.Server.Implementations/Localization/Core/bn.json b/Emby.Server.Implementations/Localization/Core/bn.json index 5667bf337e..c3fbe24082 100644 --- a/Emby.Server.Implementations/Localization/Core/bn.json +++ b/Emby.Server.Implementations/Localization/Core/bn.json @@ -1,7 +1,7 @@ { "DeviceOnlineWithName": "{0}-এর সাথে সংযুক্ত হয়েছে", "DeviceOfflineWithName": "{0}-এর সাথে সংযোগ বিচ্ছিন্ন হয়েছে", - "Collections": "কলেক্শন", + "Collections": "সংগ্রহ", "ChapterNameValue": "অধ্যায় {0}", "Channels": "চ্যানেল", "CameraImageUploadedFrom": "{0} থেকে একটি নতুন ক্যামেরার চিত্র আপলোড করা হয়েছে", @@ -14,8 +14,8 @@ "HeaderFavoriteArtists": "প্রিয় শিল্পীরা", "HeaderFavoriteAlbums": "প্রিয় এলবামগুলো", "HeaderContinueWatching": "দেখতে থাকুন", - "HeaderAlbumArtists": "এলবাম শিল্পী", - "Genres": "জেনার", + "HeaderAlbumArtists": "এলবাম শিল্পীবৃন্দ", + "Genres": "শৈলী", "Folders": "ফোল্ডারগুলো", "Favorites": "পছন্দসমূহ", "FailedLoginAttemptWithUserName": "{0} লগিন করতে ব্যর্থ হয়েছে", @@ -112,5 +112,10 @@ "TaskRefreshPeople": "পিপল রিফ্রেশ করুন", "TaskCleanLogsDescription": "{0} দিনের বেশী পুরানো লগ ফাইলগুলি মুছে ফেলুন।", "TaskCleanLogs": "লগ ডিরেক্টরি ক্লিন করুন", - "TaskRefreshLibraryDescription": "নতুন ফাইলের জন্য মিডিয়া লাইব্রেরি স্ক্যান এবং মেটাডাটা রিফ্রেশ করুন।" + "TaskRefreshLibraryDescription": "নতুন ফাইলের জন্য মিডিয়া লাইব্রেরি স্ক্যান এবং মেটাডাটা রিফ্রেশ করুন।", + "Undefined": "অসঙ্গায়িত", + "Forced": "জোরকরে", + "TaskCleanActivityLogDescription": "নির্ধারিত সময়ের আগের কাজের হিসাব মুছে দিন খালি করুন.", + "TaskCleanActivityLog": "কাজের ফাইল খালি করুন", + "Default": "প্রাথমিক" } diff --git a/Emby.Server.Implementations/Localization/Core/ca.json b/Emby.Server.Implementations/Localization/Core/ca.json index b7852eccb3..7715daa7ce 100644 --- a/Emby.Server.Implementations/Localization/Core/ca.json +++ b/Emby.Server.Implementations/Localization/Core/ca.json @@ -5,7 +5,7 @@ "Artists": "Artistes", "AuthenticationSucceededWithUserName": "{0} s'ha autenticat correctament", "Books": "Llibres", - "CameraImageUploadedFrom": "Una nova imatge de la càmera ha estat pujada des de {0}", + "CameraImageUploadedFrom": "S'ha pujat una nova imatge des de la camera desde {0}", "Channels": "Canals", "ChapterNameValue": "Capítol {0}", "Collections": "Col·leccions", @@ -18,10 +18,10 @@ "HeaderAlbumArtists": "Artistes del Àlbum", "HeaderContinueWatching": "Continua Veient", "HeaderFavoriteAlbums": "Àlbums Preferits", - "HeaderFavoriteArtists": "Artistes Preferits", - "HeaderFavoriteEpisodes": "Episodis Preferits", - "HeaderFavoriteShows": "Programes Preferits", - "HeaderFavoriteSongs": "Cançons Preferides", + "HeaderFavoriteArtists": "Artistes Predilectes", + "HeaderFavoriteEpisodes": "Episodis Predilectes", + "HeaderFavoriteShows": "Programes Predilectes", + "HeaderFavoriteSongs": "Cançons Predilectes", "HeaderLiveTV": "TV en Directe", "HeaderNextUp": "A continuació", "HeaderRecordingGroups": "Grups d'Enregistrament", @@ -36,7 +36,7 @@ "MessageApplicationUpdatedTo": "El Servidor de Jellyfin ha estat actualitzat a {0}", "MessageNamedServerConfigurationUpdatedWithValue": "La secció {0} de la configuració del servidor ha estat actualitzada", "MessageServerConfigurationUpdated": "S'ha actualitzat la configuració del servidor", - "MixedContent": "Contingut mesclat", + "MixedContent": "Contingut barrejat", "Movies": "Pel·lícules", "Music": "Música", "MusicVideos": "Vídeos musicals", @@ -76,7 +76,7 @@ "SubtitleDownloadFailureForItem": "Subtitles failed to download for {0}", "SubtitleDownloadFailureFromForItem": "Els subtítols no s'han pogut baixar de {0} per {1}", "Sync": "Sincronitzar", - "System": "System", + "System": "Sistema", "TvShows": "Espectacles de TV", "User": "User", "UserCreatedWithName": "S'ha creat l'usuari {0}", @@ -113,5 +113,12 @@ "TasksChannelsCategory": "Canals d'internet", "TasksApplicationCategory": "Aplicació", "TasksLibraryCategory": "Biblioteca", - "TasksMaintenanceCategory": "Manteniment" + "TasksMaintenanceCategory": "Manteniment", + "TaskCleanActivityLogDescription": "Eliminat entrades del registre d'activitats mes antigues que l'antiguitat configurada.", + "TaskCleanActivityLog": "Buidar Registre d'Activitat", + "Undefined": "Indefinit", + "Forced": "Forçat", + "Default": "Defecto", + "TaskOptimizeDatabaseDescription": "Compacta la base de dades i trunca l'espai lliure. Executar aquesta tasca després d’escanejar la biblioteca o fer altres canvis que impliquin modificacions a la base de dades pot millorar el rendiment.", + "TaskOptimizeDatabase": "Optimitzar la base de dades" } diff --git a/Emby.Server.Implementations/Localization/Core/cs.json b/Emby.Server.Implementations/Localization/Core/cs.json index b34fad066a..62b2b6328c 100644 --- a/Emby.Server.Implementations/Localization/Core/cs.json +++ b/Emby.Server.Implementations/Localization/Core/cs.json @@ -39,7 +39,7 @@ "MixedContent": "Smíšený obsah", "Movies": "Filmy", "Music": "Hudba", - "MusicVideos": "Hudební klipy", + "MusicVideos": "Hudební videa", "NameInstallFailed": "Instalace {0} selhala", "NameSeasonNumber": "Sezóna {0}", "NameSeasonUnknown": "Neznámá sezóna", @@ -113,5 +113,12 @@ "TasksChannelsCategory": "Internetové kanály", "TasksApplicationCategory": "Aplikace", "TasksLibraryCategory": "Knihovna", - "TasksMaintenanceCategory": "Údržba" + "TasksMaintenanceCategory": "Údržba", + "TaskCleanActivityLogDescription": "Smazat záznamy o aktivitě, které jsou starší než zadaná doba.", + "TaskCleanActivityLog": "Smazat záznam aktivity", + "Undefined": "Nedefinované", + "Forced": "Vynucené", + "Default": "Výchozí", + "TaskOptimizeDatabaseDescription": "Zmenší databázi a odstraní prázdné místo. Spuštění této úlohy po skenování knihovny či jiných změnách databáze může zlepšit výkon.", + "TaskOptimizeDatabase": "Optimalizovat databázi" } diff --git a/Emby.Server.Implementations/Localization/Core/da.json b/Emby.Server.Implementations/Localization/Core/da.json index b29ad94eff..b2c484a31e 100644 --- a/Emby.Server.Implementations/Localization/Core/da.json +++ b/Emby.Server.Implementations/Localization/Core/da.json @@ -1,5 +1,5 @@ { - "Albums": "Albums", + "Albums": "Albummer", "AppDeviceValues": "App: {0}, Enhed: {1}", "Application": "Applikation", "Artists": "Kunstnere", @@ -39,7 +39,7 @@ "MixedContent": "Blandet indhold", "Movies": "Film", "Music": "Musik", - "MusicVideos": "Musikvideoer", + "MusicVideos": "Musik videoer", "NameInstallFailed": "{0} installationen mislykkedes", "NameSeasonNumber": "Sæson {0}", "NameSeasonUnknown": "Ukendt Sæson", @@ -113,5 +113,12 @@ "TaskCleanTranscodeDescription": "Fjern transcode filer som er mere end en dag gammel.", "TaskCleanTranscode": "Rengør Transcode Mappen", "TaskRefreshPeople": "Genopfrisk Personer", - "TaskRefreshPeopleDescription": "Opdatere metadata for skuespillere og instruktører i dit bibliotek." + "TaskRefreshPeopleDescription": "Opdatere metadata for skuespillere og instruktører i dit bibliotek.", + "TaskCleanActivityLogDescription": "Sletter linjer i aktivitetsloggen ældre end den konfigureret alder.", + "TaskCleanActivityLog": "Ryd Aktivitetslog", + "Undefined": "Udefineret", + "Forced": "Tvunget", + "Default": "Standard", + "TaskOptimizeDatabaseDescription": "Kompakter database og forkorter fri plads. Ved at køre denne proces efter at scanne biblioteket eller efter at ændre noget som kunne have indflydelse på databasen, kan forbedre ydeevne.", + "TaskOptimizeDatabase": "Optimér database" } diff --git a/Emby.Server.Implementations/Localization/Core/de.json b/Emby.Server.Implementations/Localization/Core/de.json index 97ad1694a6..c924e5c150 100644 --- a/Emby.Server.Implementations/Localization/Core/de.json +++ b/Emby.Server.Implementations/Localization/Core/de.json @@ -3,7 +3,7 @@ "AppDeviceValues": "App: {0}, Gerät: {1}", "Application": "Anwendung", "Artists": "Interpreten", - "AuthenticationSucceededWithUserName": "{0} hat sich erfolgreich angemeldet", + "AuthenticationSucceededWithUserName": "{0} erfolgreich authentifiziert", "Books": "Bücher", "CameraImageUploadedFrom": "Ein neues Kamerafoto wurde von {0} hochgeladen", "Channels": "Kanäle", @@ -16,7 +16,7 @@ "Folders": "Verzeichnisse", "Genres": "Genres", "HeaderAlbumArtists": "Album-Interpreten", - "HeaderContinueWatching": "Fortsetzen", + "HeaderContinueWatching": "Weiterschauen", "HeaderFavoriteAlbums": "Lieblingsalben", "HeaderFavoriteArtists": "Lieblings-Interpreten", "HeaderFavoriteEpisodes": "Lieblingsepisoden", @@ -94,24 +94,31 @@ "VersionNumber": "Version {0}", "TaskDownloadMissingSubtitlesDescription": "Durchsucht das Internet nach fehlenden Untertiteln, basierend auf den Meta Einstellungen.", "TaskDownloadMissingSubtitles": "Lade fehlende Untertitel herunter", - "TaskRefreshChannelsDescription": "Erneuere Internet Kanal Informationen.", - "TaskRefreshChannels": "Erneuere Kanäle", - "TaskCleanTranscodeDescription": "Löscht Transkodierdateien welche älter als ein Tag sind.", - "TaskCleanTranscode": "Lösche Transkodier Pfad", - "TaskUpdatePluginsDescription": "Lädt Updates für Plugins herunter, welche dazu eingestellt sind automatisch zu updaten und installiert sie.", - "TaskUpdatePlugins": "Update Plugins", - "TaskRefreshPeopleDescription": "Erneuert Metadaten für Schauspieler und Regisseure in deinen Bibliotheken.", - "TaskRefreshPeople": "Erneuere Schauspieler", - "TaskCleanLogsDescription": "Lösche Log Dateien die älter als {0} Tage sind.", - "TaskCleanLogs": "Lösche Log Pfad", - "TaskRefreshLibraryDescription": "Scanne alle Bibliotheken für hinzugefügte Datein und erneuere Metadaten.", + "TaskRefreshChannelsDescription": "Aktualisiere Internet Kanal Informationen.", + "TaskRefreshChannels": "Aktualisiere Kanäle", + "TaskCleanTranscodeDescription": "Löscht Transkodierdateien, welche älter als einen Tag sind.", + "TaskCleanTranscode": "Lösche Transkodier-Pfad", + "TaskUpdatePluginsDescription": "Lädt Updates für Plugins herunter, welche für automatische Updates konfiguriert sind und installiert diese.", + "TaskUpdatePlugins": "Aktualisiere Plugins", + "TaskRefreshPeopleDescription": "Aktualisiert Metadaten für Schauspieler und Regisseure in deinen Bibliotheken.", + "TaskRefreshPeople": "Aktualisiere Schauspieler", + "TaskCleanLogsDescription": "Lösche Log Dateien, die älter als {0} Tage sind.", + "TaskCleanLogs": "Lösche Log-Verzeichnis", + "TaskRefreshLibraryDescription": "Scanne alle Bibliotheken nach neu hinzugefügten Dateien und aktualisiere Metadaten.", "TaskRefreshLibrary": "Scanne Medien-Bibliothek", - "TaskRefreshChapterImagesDescription": "Kreiert Vorschaubilder für Videos welche Kapitel haben.", + "TaskRefreshChapterImagesDescription": "Erstellt Vorschaubilder für Videos, welche Kapitel besitzen.", "TaskRefreshChapterImages": "Extrahiert Kapitel-Bilder", - "TaskCleanCacheDescription": "Löscht Zwischenspeicherdatein die nicht länger von System gebraucht werden.", - "TaskCleanCache": "Leere Cache Pfad", + "TaskCleanCacheDescription": "Löscht nicht mehr benötigte Zwischenspeicherdateien.", + "TaskCleanCache": "Leere Zwischenspeicher", "TasksChannelsCategory": "Internet Kanäle", "TasksApplicationCategory": "Anwendung", "TasksLibraryCategory": "Bibliothek", - "TasksMaintenanceCategory": "Wartung" + "TasksMaintenanceCategory": "Wartung", + "TaskCleanActivityLogDescription": "Löscht Aktivitätsprotokolleinträge, die älter als das konfigurierte Alter sind.", + "TaskCleanActivityLog": "Aktivitätsprotokoll aufräumen", + "Undefined": "Undefiniert", + "Forced": "Erzwungen", + "Default": "Standard", + "TaskOptimizeDatabaseDescription": "Komprimiert die Datenbank und trimmt den freien Speicherplatz. Die Ausführung dieser Aufgabe nach dem Scannen der Bibliothek oder nach anderen Änderungen, die Datenbankänderungen implizieren, kann die Leistung verbessern.", + "TaskOptimizeDatabase": "Datenbank optimieren" } diff --git a/Emby.Server.Implementations/Localization/Core/el.json b/Emby.Server.Implementations/Localization/Core/el.json index c45cc11cb8..697063f262 100644 --- a/Emby.Server.Implementations/Localization/Core/el.json +++ b/Emby.Server.Implementations/Localization/Core/el.json @@ -1,5 +1,5 @@ { - "Albums": "Άλμπουμς", + "Albums": "Άλμπουμ", "AppDeviceValues": "Εφαρμογή: {0}, Συσκευή: {1}", "Application": "Εφαρμογή", "Artists": "Καλλιτέχνες", @@ -15,7 +15,7 @@ "Favorites": "Αγαπημένα", "Folders": "Φάκελοι", "Genres": "Είδη", - "HeaderAlbumArtists": "Καλλιτέχνες του Άλμπουμ", + "HeaderAlbumArtists": "Άλμπουμ Καλλιτέχνη", "HeaderContinueWatching": "Συνεχίστε την παρακολούθηση", "HeaderFavoriteAlbums": "Αγαπημένα Άλμπουμ", "HeaderFavoriteArtists": "Αγαπημένοι Καλλιτέχνες", @@ -39,7 +39,7 @@ "MixedContent": "Ανάμεικτο Περιεχόμενο", "Movies": "Ταινίες", "Music": "Μουσική", - "MusicVideos": "Μουσικά βίντεο", + "MusicVideos": "Μουσικά Βίντεο", "NameInstallFailed": "{0} η εγκατάσταση απέτυχε", "NameSeasonNumber": "Κύκλος {0}", "NameSeasonUnknown": "Άγνωστος Κύκλος", @@ -62,7 +62,7 @@ "NotificationOptionVideoPlaybackStopped": "Η αναπαραγωγή βίντεο σταμάτησε", "Photos": "Φωτογραφίες", "Playlists": "Λίστες αναπαραγωγής", - "Plugin": "Plugin", + "Plugin": "Πρόσθετο", "PluginInstalledWithName": "{0} εγκαταστήθηκε", "PluginUninstalledWithName": "{0} έχει απεγκατασταθεί", "PluginUpdatedWithName": "{0} έχει αναβαθμιστεί", @@ -113,5 +113,12 @@ "TaskCleanTranscode": "Καθαρισμός Kαταλόγου Διακωδικοποιητή", "TaskUpdatePluginsDescription": "Κατεβάζει και εγκαθιστά ενημερώσεις για τις προσθήκες που έχουν ρυθμιστεί για αυτόματη ενημέρωση.", "TaskUpdatePlugins": "Ενημέρωση Προσθηκών", - "TaskRefreshPeopleDescription": "Ενημερώνει μεταδεδομένα για ηθοποιούς και σκηνοθέτες στην βιβλιοθήκη των πολυμέσων σας." + "TaskRefreshPeopleDescription": "Ενημερώνει μεταδεδομένα για ηθοποιούς και σκηνοθέτες στην βιβλιοθήκη των πολυμέσων σας.", + "TaskCleanActivityLogDescription": "Διαγράφει καταχωρήσεις απο το αρχείο καταγραφής παλαιότερες από την επιλεγμένη ηλικία.", + "TaskCleanActivityLog": "Καθαρό Αρχείο Καταγραφής Δραστηριοτήτων", + "Undefined": "Απροσδιόριστο", + "Forced": "Εξαναγκασμένο", + "Default": "Προεπιλογή", + "TaskOptimizeDatabaseDescription": "Συμπιέζει τη βάση δεδομένων και δημιουργεί ελεύθερο χώρο. Η εκτέλεση αυτής της εργασίας μετά τη σάρωση της βιβλιοθήκης ή την πραγματοποίηση άλλων αλλαγών που συνεπάγονται τροποποιήσεις της βάσης δεδομένων μπορεί να βελτιώσει την απόδοση.", + "TaskOptimizeDatabase": "Βελτιστοποίηση βάσης δεδομένων" } diff --git a/Emby.Server.Implementations/Localization/Core/en-GB.json b/Emby.Server.Implementations/Localization/Core/en-GB.json index 57ff13219f..8b2e8b6b1c 100644 --- a/Emby.Server.Implementations/Localization/Core/en-GB.json +++ b/Emby.Server.Implementations/Localization/Core/en-GB.json @@ -113,5 +113,12 @@ "TasksChannelsCategory": "Internet Channels", "TasksApplicationCategory": "Application", "TasksLibraryCategory": "Library", - "TasksMaintenanceCategory": "Maintenance" + "TasksMaintenanceCategory": "Maintenance", + "TaskCleanActivityLogDescription": "Deletes activity log entries older than the configured age.", + "TaskCleanActivityLog": "Clean Activity Log", + "Undefined": "Undefined", + "Forced": "Forced", + "Default": "Default", + "TaskOptimizeDatabaseDescription": "Compacts database and truncates free space. Running this task after scanning the library or doing other changes that imply database modifications might improve performance.", + "TaskOptimizeDatabase": "Optimise database" } diff --git a/Emby.Server.Implementations/Localization/Core/en-US.json b/Emby.Server.Implementations/Localization/Core/en-US.json index 92c54fb0e6..ca127cdb86 100644 --- a/Emby.Server.Implementations/Localization/Core/en-US.json +++ b/Emby.Server.Implementations/Localization/Core/en-US.json @@ -9,13 +9,15 @@ "Channels": "Channels", "ChapterNameValue": "Chapter {0}", "Collections": "Collections", + "Default": "Default", "DeviceOfflineWithName": "{0} has disconnected", "DeviceOnlineWithName": "{0} is connected", "FailedLoginAttemptWithUserName": "Failed login attempt from {0}", "Favorites": "Favorites", "Folders": "Folders", + "Forced": "Forced", "Genres": "Genres", - "HeaderAlbumArtists": "Album Artists", + "HeaderAlbumArtists": "Artist's Album", "HeaderContinueWatching": "Continue Watching", "HeaderFavoriteAlbums": "Favorite Albums", "HeaderFavoriteArtists": "Favorite Artists", @@ -25,7 +27,7 @@ "HeaderLiveTV": "Live TV", "HeaderNextUp": "Next Up", "HeaderRecordingGroups": "Recording Groups", - "HomeVideos": "Home videos", + "HomeVideos": "Home Videos", "Inherit": "Inherit", "ItemAddedWithName": "{0} was added to the library", "ItemRemovedWithName": "{0} was removed from the library", @@ -39,7 +41,7 @@ "MixedContent": "Mixed content", "Movies": "Movies", "Music": "Music", - "MusicVideos": "Music videos", + "MusicVideos": "Music Videos", "NameInstallFailed": "{0} installation failed", "NameSeasonNumber": "Season {0}", "NameSeasonUnknown": "Season Unknown", @@ -77,6 +79,7 @@ "Sync": "Sync", "System": "System", "TvShows": "TV Shows", + "Undefined": "Undefined", "User": "User", "UserCreatedWithName": "User {0} has been created", "UserDeletedWithName": "User {0} has been deleted", @@ -95,6 +98,8 @@ "TasksLibraryCategory": "Library", "TasksApplicationCategory": "Application", "TasksChannelsCategory": "Internet Channels", + "TaskCleanActivityLog": "Clean Activity Log", + "TaskCleanActivityLogDescription": "Deletes activity log entries older than the configured age.", "TaskCleanCache": "Clean Cache Directory", "TaskCleanCacheDescription": "Deletes cache files no longer needed by the system.", "TaskRefreshChapterImages": "Extract Chapter Images", @@ -112,5 +117,7 @@ "TaskRefreshChannels": "Refresh Channels", "TaskRefreshChannelsDescription": "Refreshes internet channel information.", "TaskDownloadMissingSubtitles": "Download missing subtitles", - "TaskDownloadMissingSubtitlesDescription": "Searches the internet for missing subtitles based on metadata configuration." + "TaskDownloadMissingSubtitlesDescription": "Searches the internet for missing subtitles based on metadata configuration.", + "TaskOptimizeDatabase": "Optimize database", + "TaskOptimizeDatabaseDescription": "Compacts database and truncates free space. Running this task after scanning the library or doing other changes that imply database modifications might improve performance." } diff --git a/Emby.Server.Implementations/Localization/Core/eo.json b/Emby.Server.Implementations/Localization/Core/eo.json new file mode 100644 index 0000000000..ca615cc8cf --- /dev/null +++ b/Emby.Server.Implementations/Localization/Core/eo.json @@ -0,0 +1,47 @@ +{ + "NotificationOptionInstallationFailed": "Instalada fiasko", + "NotificationOptionAudioPlaybackStopped": "Sono de ludado haltis", + "NotificationOptionAudioPlayback": "Ludado de sono startis", + "NameSeasonUnknown": "Sezono Nekonata", + "NameSeasonNumber": "Sezono {0}", + "NameInstallFailed": "{0} instalado fiaskis", + "Music": "Muziko", + "Movies": "Filmoj", + "ItemRemovedWithName": "{0} forigis el la biblioteko", + "ItemAddedWithName": "{0} aldonis al la biblioteko", + "HeaderLiveTV": "Viva Televido", + "HeaderContinueWatching": "Daŭrigi Spektado", + "HeaderAlbumArtists": "Artistoj de Albumo", + "Folders": "Dosierujoj", + "DeviceOnlineWithName": "{0} estas konektita", + "Default": "Defaŭlte", + "Collections": "Kolektoj", + "ChapterNameValue": "Ĉapitro {0}", + "Channels": "Kanaloj", + "Books": "Libroj", + "Artists": "Artistoj", + "Application": "Aplikaĵo", + "AppDeviceValues": "Aplikaĵo: {0}, Aparato: {1}", + "Albums": "Albumoj", + "TasksLibraryCategory": "Libraro", + "VersionNumber": "Versio {0}", + "UserDownloadingItemWithValues": "{0} elŝutas {1}", + "UserCreatedWithName": "Uzanto {0} kreiĝis", + "User": "Uzanto", + "System": "Sistemo", + "Songs": "Kantoj", + "ScheduledTaskStartedWithName": "{0} komencis", + "ScheduledTaskFailedWithName": "{0} malsukcesis", + "PluginUninstalledWithName": "{0} malinstaliĝis", + "PluginInstalledWithName": "{0} instaliĝis", + "Plugin": "Kromprogramo", + "Playlists": "Ludlistoj", + "Photos": "Fotoj", + "NotificationOptionPluginUninstalled": "Kromprogramo malinstaliĝis", + "NotificationOptionNewLibraryContent": "Nova enhavo aldoniĝis", + "NotificationOptionPluginInstalled": "Kromprogramo instaliĝis", + "MusicVideos": "Muzikvideoj", + "LabelIpAddressValue": "IP-adreso: {0}", + "Genres": "Ĝenroj", + "DeviceOfflineWithName": "{0} malkonektis" +} diff --git a/Emby.Server.Implementations/Localization/Core/es-AR.json b/Emby.Server.Implementations/Localization/Core/es-AR.json index 390074cdd7..6321f695c4 100644 --- a/Emby.Server.Implementations/Localization/Core/es-AR.json +++ b/Emby.Server.Implementations/Localization/Core/es-AR.json @@ -113,5 +113,12 @@ "TasksChannelsCategory": "Canales de internet", "TasksApplicationCategory": "Aplicación", "TasksLibraryCategory": "Biblioteca", - "TasksMaintenanceCategory": "Mantenimiento" + "TasksMaintenanceCategory": "Mantenimiento", + "TaskCleanActivityLogDescription": "Borrar log de actividades anteriores a la fecha establecida.", + "TaskCleanActivityLog": "Borrar log de actividades", + "Undefined": "Indefinido", + "Forced": "Forzado", + "Default": "Por Defecto", + "TaskOptimizeDatabaseDescription": "Compacta la base de datos y restaura el espacio libre. Ejecutar esta tarea después de actualizar las librerías o realizar otros cambios que impliquen modificar las bases de datos puede mejorar la performance.", + "TaskOptimizeDatabase": "Optimización de base de datos" } diff --git a/Emby.Server.Implementations/Localization/Core/es-MX.json b/Emby.Server.Implementations/Localization/Core/es-MX.json index ab54c0ea6a..432814dac1 100644 --- a/Emby.Server.Implementations/Localization/Core/es-MX.json +++ b/Emby.Server.Implementations/Localization/Core/es-MX.json @@ -15,7 +15,7 @@ "Favorites": "Favoritos", "Folders": "Carpetas", "Genres": "Géneros", - "HeaderAlbumArtists": "Artistas del álbum", + "HeaderAlbumArtists": "Artistas del Álbum", "HeaderContinueWatching": "Continuar viendo", "HeaderFavoriteAlbums": "Álbumes favoritos", "HeaderFavoriteArtists": "Artistas favoritos", @@ -25,7 +25,7 @@ "HeaderLiveTV": "TV en vivo", "HeaderNextUp": "A continuación", "HeaderRecordingGroups": "Grupos de grabación", - "HomeVideos": "Videos caseros", + "HomeVideos": "Videos Caseros", "Inherit": "Heredar", "ItemAddedWithName": "{0} fue agregado a la biblioteca", "ItemRemovedWithName": "{0} fue removido de la biblioteca", @@ -39,7 +39,7 @@ "MixedContent": "Contenido mezclado", "Movies": "Películas", "Music": "Música", - "MusicVideos": "Videos musicales", + "MusicVideos": "Videos Musicales", "NameInstallFailed": "Falló la instalación de {0}", "NameSeasonNumber": "Temporada {0}", "NameSeasonUnknown": "Temporada desconocida", @@ -49,7 +49,7 @@ "NotificationOptionAudioPlayback": "Reproducción de audio iniciada", "NotificationOptionAudioPlaybackStopped": "Reproducción de audio detenida", "NotificationOptionCameraImageUploaded": "Imagen de la cámara subida", - "NotificationOptionInstallationFailed": "Falla de instalación", + "NotificationOptionInstallationFailed": "Fallo en la instalación", "NotificationOptionNewLibraryContent": "Nuevo contenido agregado", "NotificationOptionPluginError": "Falla de complemento", "NotificationOptionPluginInstalled": "Complemento instalado", @@ -69,7 +69,7 @@ "ProviderValue": "Proveedor: {0}", "ScheduledTaskFailedWithName": "{0} falló", "ScheduledTaskStartedWithName": "{0} iniciado", - "ServerNameNeedsToBeRestarted": "{0} debe ser reiniciado", + "ServerNameNeedsToBeRestarted": "{0} necesita ser reiniciado", "Shows": "Programas", "Songs": "Canciones", "StartupEmbyServerIsLoading": "El servidor Jellyfin está cargando. Por favor, intente de nuevo pronto.", @@ -94,9 +94,9 @@ "VersionNumber": "Versión {0}", "TaskDownloadMissingSubtitlesDescription": "Busca subtítulos faltantes en Internet basándose en la configuración de metadatos.", "TaskDownloadMissingSubtitles": "Descargar subtítulos faltantes", - "TaskRefreshChannelsDescription": "Actualiza la información de canales de Internet.", + "TaskRefreshChannelsDescription": "Actualiza la información de los canales de Internet.", "TaskRefreshChannels": "Actualizar canales", - "TaskCleanTranscodeDescription": "Elimina archivos transcodificados que tengan más de un día.", + "TaskCleanTranscodeDescription": "Elimina archivos transcodificados que tengan más de un día de antigüedad.", "TaskCleanTranscode": "Limpiar directorio de transcodificado", "TaskUpdatePluginsDescription": "Descarga e instala actualizaciones para complementos que están configurados para actualizarse automáticamente.", "TaskUpdatePlugins": "Actualizar complementos", @@ -113,5 +113,12 @@ "TasksChannelsCategory": "Canales de Internet", "TasksApplicationCategory": "Aplicación", "TasksLibraryCategory": "Biblioteca", - "TasksMaintenanceCategory": "Mantenimiento" + "TasksMaintenanceCategory": "Mantenimiento", + "TaskCleanActivityLogDescription": "Elimina entradas del registro de actividad que sean más antiguas al periodo establecido.", + "TaskCleanActivityLog": "Limpiar registro de actividades", + "Undefined": "Sin definir", + "Forced": "Forzado", + "Default": "Predeterminado", + "TaskOptimizeDatabase": "Optimizar base de datos", + "TaskOptimizeDatabaseDescription": "Compacta la base de datos y trunca el espacio libre. Puede mejorar el rendimiento si se realiza esta tarea después de escanear la biblioteca o después de realizar otros cambios que impliquen modificar la base de datos." } diff --git a/Emby.Server.Implementations/Localization/Core/es.json b/Emby.Server.Implementations/Localization/Core/es.json index d4e0b299dd..d3d9d27038 100644 --- a/Emby.Server.Implementations/Localization/Core/es.json +++ b/Emby.Server.Implementations/Localization/Core/es.json @@ -15,7 +15,7 @@ "Favorites": "Favoritos", "Folders": "Carpetas", "Genres": "Géneros", - "HeaderAlbumArtists": "Artistas del álbum", + "HeaderAlbumArtists": "Artista del álbum", "HeaderContinueWatching": "Continuar viendo", "HeaderFavoriteAlbums": "Álbumes favoritos", "HeaderFavoriteArtists": "Artistas favoritos", @@ -70,14 +70,14 @@ "ScheduledTaskFailedWithName": "{0} falló", "ScheduledTaskStartedWithName": "{0} iniciada", "ServerNameNeedsToBeRestarted": "{0} necesita ser reiniciado", - "Shows": "Mostrar", + "Shows": "Series", "Songs": "Canciones", "StartupEmbyServerIsLoading": "Jellyfin Server se está cargando. Vuelve a intentarlo en breve.", "SubtitleDownloadFailureForItem": "Error al descargar subtítulos para {0}", "SubtitleDownloadFailureFromForItem": "Fallo de descarga de subtítulos desde {0} para {1}", "Sync": "Sincronizar", "System": "Sistema", - "TvShows": "Programas de televisión", + "TvShows": "Series", "User": "Usuario", "UserCreatedWithName": "El usuario {0} ha sido creado", "UserDeletedWithName": "El usuario {0} ha sido borrado", @@ -113,5 +113,12 @@ "TaskRefreshChannels": "Actualizar canales", "TaskRefreshChannelsDescription": "Actualiza la información de los canales de internet.", "TaskDownloadMissingSubtitles": "Descargar los subtítulos que faltan", - "TaskDownloadMissingSubtitlesDescription": "Busca en internet los subtítulos que falten en el contenido de tus bibliotecas, basándose en la configuración de los metadatos." + "TaskDownloadMissingSubtitlesDescription": "Busca en internet los subtítulos que falten en el contenido de tus bibliotecas, basándose en la configuración de los metadatos.", + "TaskCleanActivityLogDescription": "Elimina todos los registros de actividad anteriores a la fecha configurada.", + "TaskCleanActivityLog": "Limpiar registro de actividad", + "Undefined": "Indefinido", + "Forced": "Forzado", + "Default": "Predeterminado", + "TaskOptimizeDatabase": "Optimizar la base de datos", + "TaskOptimizeDatabaseDescription": "Compacta y libera el espacio libre en la base de datos. Ejecutar esta tarea tras escanear la biblioteca o hacer cambios que impliquen modificaciones en la base de datos puede mejorar el rendimiento." } diff --git a/Emby.Server.Implementations/Localization/Core/es_419.json b/Emby.Server.Implementations/Localization/Core/es_419.json index dcd30694f0..a968c6daba 100644 --- a/Emby.Server.Implementations/Localization/Core/es_419.json +++ b/Emby.Server.Implementations/Localization/Core/es_419.json @@ -112,5 +112,12 @@ "CameraImageUploadedFrom": "Una nueva imagen de cámara ha sido subida desde {0}", "AuthenticationSucceededWithUserName": "{0} autenticado con éxito", "Application": "Aplicación", - "AppDeviceValues": "App: {0}, Dispositivo: {1}" + "AppDeviceValues": "App: {0}, Dispositivo: {1}", + "TaskCleanActivityLogDescription": "Elimina las entradas del registro de actividad anteriores al periodo configurado.", + "TaskCleanActivityLog": "Limpiar Registro de Actividades", + "Undefined": "Sin definir", + "Forced": "Forzado", + "Default": "Por Defecto", + "TaskOptimizeDatabaseDescription": "Compacta la base de datos y restaura el espacio libre. Ejecutar esta tarea después de actualizar las librerías o realizar otros cambios que impliquen modificar las bases de datos puede mejorar la performance.", + "TaskOptimizeDatabase": "Optimización de base de datos" } diff --git a/Emby.Server.Implementations/Localization/Core/fa.json b/Emby.Server.Implementations/Localization/Core/fa.json index 1986decf03..8ab657e5b8 100644 --- a/Emby.Server.Implementations/Localization/Core/fa.json +++ b/Emby.Server.Implementations/Localization/Core/fa.json @@ -34,7 +34,7 @@ "Latest": "جدیدترین‌ها", "MessageApplicationUpdated": "سرور Jellyfin بروزرسانی شد", "MessageApplicationUpdatedTo": "سرور Jellyfin به نسخه {0} بروزرسانی شد", - "MessageNamedServerConfigurationUpdatedWithValue": "پکربندی بخش {0} سرور بروزرسانی شد", + "MessageNamedServerConfigurationUpdatedWithValue": "پکربندی بخش {0} سرور بروزرسانی شد", "MessageServerConfigurationUpdated": "پیکربندی سرور بروزرسانی شد", "MixedContent": "محتوای مخلوط", "Movies": "فیلم‌ها", @@ -49,7 +49,7 @@ "NotificationOptionAudioPlayback": "پخش صدا آغاز شد", "NotificationOptionAudioPlaybackStopped": "پخش صدا متوقف شد", "NotificationOptionCameraImageUploaded": "تصاویر دوربین آپلود شد", - "NotificationOptionInstallationFailed": "نصب شکست خورد", + "NotificationOptionInstallationFailed": "نصب ناموفق", "NotificationOptionNewLibraryContent": "محتوای جدید افزوده شد", "NotificationOptionPluginError": "خرابی افزونه", "NotificationOptionPluginInstalled": "افزونه نصب شد", @@ -113,5 +113,10 @@ "TasksChannelsCategory": "کانال‌های داخلی", "TasksApplicationCategory": "برنامه", "TasksLibraryCategory": "کتابخانه", - "TasksMaintenanceCategory": "تعمیر" + "TasksMaintenanceCategory": "تعمیر", + "Forced": "اجباری", + "Default": "پیشفرض", + "TaskCleanActivityLogDescription": "ورودی‌های قدیمی‌تر از سن تنظیم شده در سیاهه فعالیت را حذف می‌کند.", + "TaskCleanActivityLog": "پاکسازی سیاهه فعالیت", + "Undefined": "تعریف نشده" } diff --git a/Emby.Server.Implementations/Localization/Core/fi.json b/Emby.Server.Implementations/Localization/Core/fi.json index aae22583ab..4a1f4f1d52 100644 --- a/Emby.Server.Implementations/Localization/Core/fi.json +++ b/Emby.Server.Implementations/Localization/Core/fi.json @@ -1,116 +1,123 @@ { - "HeaderLiveTV": "Live-TV", - "NewVersionIsAvailable": "Uusi versio Jellyfin palvelimesta on ladattavissa.", - "NameSeasonUnknown": "Tuntematon Kausi", + "HeaderLiveTV": "Suora TV", + "NewVersionIsAvailable": "Uusi versio Jellyfin-palvelimesta on ladattavissa.", + "NameSeasonUnknown": "Tuntematon kausi", "NameSeasonNumber": "Kausi {0}", "NameInstallFailed": "{0} asennus epäonnistui", "MusicVideos": "Musiikkivideot", "Music": "Musiikki", "Movies": "Elokuvat", - "MixedContent": "Sekoitettu sisältö", + "MixedContent": "Sekalainen sisältö", "MessageServerConfigurationUpdated": "Palvelimen asetukset on päivitetty", - "MessageNamedServerConfigurationUpdatedWithValue": "Palvelimen asetusryhmä {0} on päivitetty", - "MessageApplicationUpdatedTo": "Jellyfin palvelin on päivitetty versioon {0}", - "MessageApplicationUpdated": "Jellyfin palvelin on päivitetty", - "Latest": "Uusimmat", - "LabelRunningTimeValue": "Toiston kesto: {0}", + "MessageNamedServerConfigurationUpdatedWithValue": "Palvelimen asetusten osio {0} on päivitetty", + "MessageApplicationUpdatedTo": "Jellyfin-palvelin on päivitetty versioon {0}", + "MessageApplicationUpdated": "Jellyfin-palvelin on päivitetty", + "Latest": "Viimeisimmät", + "LabelRunningTimeValue": "Kesto: {0}", "LabelIpAddressValue": "IP-osoite: {0}", "ItemRemovedWithName": "{0} poistettiin kirjastosta", "ItemAddedWithName": "{0} lisättiin kirjastoon", - "Inherit": "Periytyä", + "Inherit": "Peri", "HomeVideos": "Kotivideot", - "HeaderRecordingGroups": "Nauhoiteryhmät", + "HeaderRecordingGroups": "Tallennusryhmät", "HeaderNextUp": "Seuraavaksi", - "HeaderFavoriteSongs": "Lempikappaleet", - "HeaderFavoriteShows": "Lempisarjat", - "HeaderFavoriteEpisodes": "Lempijaksot", - "HeaderFavoriteArtists": "Lempiartistit", - "HeaderFavoriteAlbums": "Lempialbumit", - "HeaderContinueWatching": "Jatka katsomista", - "HeaderAlbumArtists": "Albumin esittäjä", + "HeaderFavoriteSongs": "Suosikkikappaleet", + "HeaderFavoriteShows": "Suosikkisarjat", + "HeaderFavoriteEpisodes": "Suosikkijaksot", + "HeaderFavoriteArtists": "Suosikkiesittäjät", + "HeaderFavoriteAlbums": "Suosikkialbumit", + "HeaderContinueWatching": "Jatka katselua", + "HeaderAlbumArtists": "Albumin esittäjät", "Genres": "Tyylilajit", "Folders": "Kansiot", "Favorites": "Suosikit", - "FailedLoginAttemptWithUserName": "Kirjautuminen epäonnistui kohteesta {0}", + "FailedLoginAttemptWithUserName": "Epäonnistunut kirjautumisyritys lähteestä \"{0}\"", "DeviceOnlineWithName": "{0} on yhdistetty", - "DeviceOfflineWithName": "{0} on katkaissut yhteytensä", + "DeviceOfflineWithName": "{0} on katkaissut yhteyden", "Collections": "Kokoelmat", - "ChapterNameValue": "Luku: {0}", + "ChapterNameValue": "Kappale {0}", "Channels": "Kanavat", - "CameraImageUploadedFrom": "Uusi kamerakuva on ladattu {0}", + "CameraImageUploadedFrom": "Uusi kameran kuva on sirretty lähteestä {0}", "Books": "Kirjat", - "AuthenticationSucceededWithUserName": "{0} todennus onnistui", - "Artists": "Artistit", + "AuthenticationSucceededWithUserName": "{0} on todennettu", + "Artists": "Esittäjät", "Application": "Sovellus", "AppDeviceValues": "Sovellus: {0}, Laite: {1}", "Albums": "Albumit", "User": "Käyttäjä", "System": "Järjestelmä", "ScheduledTaskFailedWithName": "{0} epäonnistui", - "PluginUpdatedWithName": "{0} päivitetty", - "PluginInstalledWithName": "{0} asennettu", - "Photos": "Kuvat", - "ScheduledTaskStartedWithName": "{0} aloitettu", - "PluginUninstalledWithName": "{0} poistettu", + "PluginUpdatedWithName": "{0} päivitettiin", + "PluginInstalledWithName": "{0} asennettiin", + "Photos": "Valokuvat", + "ScheduledTaskStartedWithName": "\"{0}\" käynnistetty", + "PluginUninstalledWithName": "{0} poistettiin", "Playlists": "Soittolistat", "VersionNumber": "Versio {0}", - "ValueSpecialEpisodeName": "Erikois - {0}", - "ValueHasBeenAddedToLibrary": "{0} lisättiin mediakirjastoon", - "UserStoppedPlayingItemWithValues": "{0} toistaminen valmistui {1} laitteella {2}", - "UserStartedPlayingItemWithValues": "{0} toistaa {1} laitteella {2}", - "UserPolicyUpdatedWithName": "Käyttöoikeudet päivitetty käyttäjälle {0}", - "UserPasswordChangedWithName": "Salasana vaihdettu käyttäjälle {0}", - "UserOnlineFromDevice": "{0} on paikalla osoitteesta {1}", - "UserOfflineFromDevice": "{0} yhteys katkaistu {1}", - "UserLockedOutWithName": "Käyttäjä {0} lukittu", - "UserDownloadingItemWithValues": "{0} lataa {1}", - "UserDeletedWithName": "Käyttäjä {0} poistettu", - "UserCreatedWithName": "Käyttäjä {0} luotu", - "TvShows": "TV-sarjat", - "Sync": "Synkronoi", - "SubtitleDownloadFailureFromForItem": "Tekstitysten lataus ({0} -> {1}) epäonnistui //this string would have to be generated for each provider and movie because of finnish cases, sorry", - "StartupEmbyServerIsLoading": "Jellyfin palvelin latautuu. Kokeile hetken kuluttua uudelleen.", + "ValueSpecialEpisodeName": "Erikoisjakso - {0}", + "ValueHasBeenAddedToLibrary": "\"{0}\" on lisätty mediakirjastoon", + "UserStoppedPlayingItemWithValues": "{0} lopetti kohteen \"{1}\" toiston sijainnissa \"{2}\"", + "UserStartedPlayingItemWithValues": "{0} toistaa kohdetta \"{1}\" sijainnissa \"{2}\"", + "UserPolicyUpdatedWithName": "Käyttäjän {0} käyttöoikeudet on päivitetty", + "UserPasswordChangedWithName": "Käyttäjän {0} salasana on vaihdettu", + "UserOnlineFromDevice": "{0} on yhdistänyt sijainnista \"{1}\"", + "UserOfflineFromDevice": "{0} on katkaissut yhteyden sijainnista \"{1}\"", + "UserLockedOutWithName": "Käyttäjä {0} on lukittu", + "UserDownloadingItemWithValues": "{0} lataa kohdetta \"{1}\"", + "UserDeletedWithName": "Käyttäjä {0} on poistettu", + "UserCreatedWithName": "Käyttäjä {0} on luotu", + "TvShows": "Sarjat", + "Sync": "Synkronointi", + "SubtitleDownloadFailureFromForItem": "Tekstityksen lataus lähteestä \"{0}\" kohteelle \"{1}\" epäonnistui", + "StartupEmbyServerIsLoading": "Jellyfin-palvelin latautuu. Yritä hetken kuluttua uudelleen.", "Songs": "Kappaleet", "Shows": "Sarjat", - "ServerNameNeedsToBeRestarted": "{0} täytyy käynnistää uudelleen", - "ProviderValue": "Tarjoaja: {0}", - "Plugin": "Liitännäinen", - "NotificationOptionVideoPlaybackStopped": "Videon toisto pysäytetty", - "NotificationOptionVideoPlayback": "Videota toistetaan", - "NotificationOptionUserLockedOut": "Käyttäjä kirjautui ulos", - "NotificationOptionTaskFailed": "Ajastettu tehtävä epäonnistui", - "NotificationOptionServerRestartRequired": "Palvelin pitää käynnistää uudelleen", - "NotificationOptionPluginUpdateInstalled": "Liitännäinen päivitetty", - "NotificationOptionPluginUninstalled": "Liitännäinen poistettu", - "NotificationOptionPluginInstalled": "Liitännäinen asennettu", - "NotificationOptionPluginError": "Ongelma liitännäisessä", - "NotificationOptionNewLibraryContent": "Uutta sisältöä lisätty", + "ServerNameNeedsToBeRestarted": "\"{0}\" on käynnistettävä uudelleen", + "ProviderValue": "Lähde: {0}", + "Plugin": "Laajennus", + "NotificationOptionVideoPlaybackStopped": "Videon toisto lopetettu", + "NotificationOptionVideoPlayback": "Videon toisto aloitettu", + "NotificationOptionUserLockedOut": "Käyttäjä on lukittu", + "NotificationOptionTaskFailed": "Ajoitettu tehtävä epäonnistui", + "NotificationOptionServerRestartRequired": "Tarvitaan palvelimen uudelleenkäynnistys", + "NotificationOptionPluginUpdateInstalled": "Laajennus on päivitetty", + "NotificationOptionPluginUninstalled": "Laajennus on poistettu", + "NotificationOptionPluginInstalled": "Laajennus on asennettu", + "NotificationOptionPluginError": "Laajennuksen virhe", + "NotificationOptionNewLibraryContent": "Sisältöä on lisätty", "NotificationOptionInstallationFailed": "Asennus epäonnistui", - "NotificationOptionCameraImageUploaded": "Kameran kuva ladattu", + "NotificationOptionCameraImageUploaded": "Kameran kuva on tallennettu", "NotificationOptionAudioPlaybackStopped": "Äänen toisto lopetettu", - "NotificationOptionAudioPlayback": "Toistetaan ääntä", - "NotificationOptionApplicationUpdateInstalled": "Sovelluspäivitys asennettu", - "NotificationOptionApplicationUpdateAvailable": "Ohjelmistopäivitys saatavilla", + "NotificationOptionAudioPlayback": "Äänen toisto aloitettu", + "NotificationOptionApplicationUpdateInstalled": "Sovelluspäivitys asennettiin", + "NotificationOptionApplicationUpdateAvailable": "Sovelluspäivitys on saatavilla", "TasksMaintenanceCategory": "Ylläpito", - "TaskDownloadMissingSubtitlesDescription": "Etsii puuttuvia tekstityksiä videon metadatatietojen pohjalta.", + "TaskDownloadMissingSubtitlesDescription": "Etsii puuttuvia tekstityksiä määritettyjen metatietoasetusten mukaisesti.", "TaskDownloadMissingSubtitles": "Lataa puuttuvat tekstitykset", "TaskRefreshChannelsDescription": "Päivittää internet-kanavien tiedot.", "TaskRefreshChannels": "Päivitä kanavat", - "TaskCleanTranscodeDescription": "Poistaa transkoodatut tiedostot jotka ovat yli päivän vanhoja.", - "TaskCleanTranscode": "Puhdista transkoodaushakemisto", - "TaskUpdatePluginsDescription": "Lataa ja asentaa päivitykset liitännäisille jotka on asetettu päivittymään automaattisesti.", - "TaskUpdatePlugins": "Päivitä liitännäiset", - "TaskRefreshPeopleDescription": "Päivittää näyttelijöiden ja ohjaajien mediatiedot kirjastossasi.", + "TaskCleanTranscodeDescription": "Poistaa päivää vanhemmat transkoodaustiedostot.", + "TaskCleanTranscode": "Puhdista transkoodauskansio", + "TaskUpdatePluginsDescription": "Lataa ja asentaa päivitykset laajennuksille, jotka on määritetty päivittymään automaattisesti.", + "TaskUpdatePlugins": "Päivitä laajennukset", + "TaskRefreshPeopleDescription": "Päivittää mediakirjaston näyttelijöiden ja ohjaajien metatiedot.", "TaskRefreshPeople": "Päivitä henkilöt", - "TaskCleanLogsDescription": "Poistaa lokitiedostot jotka ovat yli {0} päivää vanhoja.", - "TaskCleanLogs": "Puhdista lokihakemisto", - "TaskRefreshLibraryDescription": "Skannaa mediakirjastosi uusien tiedostojen varalle, sekä virkistää metatiedot.", - "TaskRefreshLibrary": "Skannaa mediakirjasto", - "TaskRefreshChapterImagesDescription": "Luo pienoiskuvat videoille joissa on lukuja.", - "TaskRefreshChapterImages": "Eristä lukujen kuvat", - "TaskCleanCacheDescription": "Poistaa järjestelmälle tarpeettomat väliaikaistiedostot.", - "TaskCleanCache": "Tyhjennä välimuisti-hakemisto", - "TasksChannelsCategory": "Internet kanavat", + "TaskCleanLogsDescription": "Poistaa {0} päivää vanhemmat lokitiedostot.", + "TaskCleanLogs": "Siivoa lokikansio", + "TaskRefreshLibraryDescription": "Tarkastaa mediakirjastosi sisällön uusien tiedostojen varalta ja päivittää metatiedot.", + "TaskRefreshLibrary": "Päivitä mediakirjasto", + "TaskRefreshChapterImagesDescription": "Luo esikatselukuvat videoille, jotka sisältävät kappalejaon.", + "TaskRefreshChapterImages": "Pura kappalejaon kuvat", + "TaskCleanCacheDescription": "Poistaa tarpeettomiksi jääneet väliaikaistiedostot.", + "TaskCleanCache": "Tyhjennä välimuistikansio", + "TasksChannelsCategory": "Internet-kanavat", "TasksApplicationCategory": "Sovellus", - "TasksLibraryCategory": "Kirjasto" + "TasksLibraryCategory": "Kirjasto", + "Forced": "Pakotettu", + "Default": "Oletus", + "TaskCleanActivityLogDescription": "Poistaa määritettyä ikää vanhemmat tapahtumat toimintahistoriasta.", + "TaskCleanActivityLog": "Tyhjennä toimintahistoria", + "Undefined": "Määrittelemätön", + "TaskOptimizeDatabaseDescription": "Tiivistää ja puhdistaa tietokannan. Tämän toiminnon suorittaminen kirjastojen skannauksen tai muiden tietokantaan liittyvien muutoksien jälkeen voi parantaa suorituskykyä.", + "TaskOptimizeDatabase": "Optimoi tietokanta" } diff --git a/Emby.Server.Implementations/Localization/Core/fil.json b/Emby.Server.Implementations/Localization/Core/fil.json index 1a3e188327..f18a1c0303 100644 --- a/Emby.Server.Implementations/Localization/Core/fil.json +++ b/Emby.Server.Implementations/Localization/Core/fil.json @@ -1,101 +1,121 @@ { "VersionNumber": "Bersyon {0}", "ValueSpecialEpisodeName": "Espesyal - {0}", - "ValueHasBeenAddedToLibrary": "Naidagdag na ang {0} sa iyong media library", + "ValueHasBeenAddedToLibrary": "Naidagdag na ang {0} sa iyong librerya ng medya", "UserStoppedPlayingItemWithValues": "Natapos ni {0} ang {1} sa {2}", - "UserStartedPlayingItemWithValues": "Si {0} ay nagplaplay ng {1} sa {2}", - "UserPolicyUpdatedWithName": "Ang user policy ay naiupdate para kay {0}", + "UserStartedPlayingItemWithValues": "Si {0} ay nagpla-play ng {1} sa {2}", + "UserPolicyUpdatedWithName": "Ang user policy ay nai-update para kay {0}", "UserPasswordChangedWithName": "Napalitan na ang password ni {0}", - "UserOnlineFromDevice": "Si {0} ay nakakonekta galing sa {1}", - "UserOfflineFromDevice": "Si {0} ay nadiskonekta galing sa {1}", + "UserOnlineFromDevice": "Si {0} ay naka-konekta galing sa {1}", + "UserOfflineFromDevice": "Si {0} ay na-diskonekta galing sa {1}", "UserLockedOutWithName": "Si {0} ay nalock out", "UserDownloadingItemWithValues": "Nagdadownload si {0} ng {1}", "UserDeletedWithName": "Natanggal na is user {0}", "UserCreatedWithName": "Nagawa na si user {0}", "User": "User", - "TvShows": "Pelikula", + "TvShows": "Mga Palabas sa Telebisyon", "System": "Sistema", "Sync": "Pag-sync", - "SubtitleDownloadFailureFromForItem": "Hindi naidownload ang subtitles {0} para sa {1}", - "StartupEmbyServerIsLoading": "Nagloload ang Jellyfin Server. Sandaling maghintay.", - "Songs": "Kanta", - "Shows": "Pelikula", + "SubtitleDownloadFailureFromForItem": "Hindi nai-download ang subtitles {0} para sa {1}", + "StartupEmbyServerIsLoading": "Naglo-load ang Jellyfin Server. Mangyaring subukan ulit sandali.", + "Songs": "Mga Kanta", + "Shows": "Mga Pelikula", "ServerNameNeedsToBeRestarted": "Kailangan irestart ang {0}", "ScheduledTaskStartedWithName": "Nagsimula na ang {0}", - "ScheduledTaskFailedWithName": "Hindi gumana and {0}", - "ProviderValue": "Ang provider ay {0}", + "ScheduledTaskFailedWithName": "Hindi gumana ang {0}", + "ProviderValue": "Tagapagtustos: {0}", "PluginUpdatedWithName": "Naiupdate na ang {0}", "PluginUninstalledWithName": "Naiuninstall na ang {0}", "PluginInstalledWithName": "Nainstall na ang {0}", "Plugin": "Plugin", - "Playlists": "Playlists", - "Photos": "Larawan", + "Playlists": "Mga Playlist", + "Photos": "Mga Larawan", "NotificationOptionVideoPlaybackStopped": "Huminto na ang pelikula", "NotificationOptionVideoPlayback": "Nagsimula na ang pelikula", - "NotificationOptionUserLockedOut": "Nakalock out ang user", + "NotificationOptionUserLockedOut": "Naka-lock out ang user", "NotificationOptionTaskFailed": "Hindi gumana ang scheduled task", - "NotificationOptionServerRestartRequired": "Kailangan irestart ang server", - "NotificationOptionPluginUpdateInstalled": "Naiupdate na ang plugin", - "NotificationOptionPluginUninstalled": "Naiuninstall na ang plugin", + "NotificationOptionServerRestartRequired": "Kailangan i-restart ang server", + "NotificationOptionPluginUpdateInstalled": "Nai-update na ang plugin", + "NotificationOptionPluginUninstalled": "Nai-uninstall na ang plugin", "NotificationOptionPluginInstalled": "Nainstall na ang plugin", "NotificationOptionPluginError": "Hindi gumagana ang plugin", "NotificationOptionNewLibraryContent": "May bagong content na naidagdag", "NotificationOptionInstallationFailed": "Hindi nainstall ng mabuti", - "NotificationOptionCameraImageUploaded": "Naiupload na ang picture", + "NotificationOptionCameraImageUploaded": "Naiupload na ang litrato", "NotificationOptionAudioPlaybackStopped": "Huminto na ang patugtog", "NotificationOptionAudioPlayback": "Nagsimula na ang patugtog", "NotificationOptionApplicationUpdateInstalled": "Naiupdate na ang aplikasyon", "NotificationOptionApplicationUpdateAvailable": "May bagong update ang aplikasyon", - "NewVersionIsAvailable": "May bagong version ng Jellyfin Server na pwede idownload.", - "NameSeasonUnknown": "Hindi alam ang season", + "NewVersionIsAvailable": "May bagong version ng Jellyfin Server na pwede i-download.", + "NameSeasonUnknown": "Hindi matukoy ang season", "NameSeasonNumber": "Season {0}", "NameInstallFailed": "Hindi nainstall ang {0}", - "MusicVideos": "Music video", - "Music": "Kanta", - "Movies": "Pelikula", + "MusicVideos": "Mga Music video", + "Music": "Mga Kanta", + "Movies": "Mga Pelikula", "MixedContent": "Halo-halong content", "MessageServerConfigurationUpdated": "Naiupdate na ang server configuration", "MessageNamedServerConfigurationUpdatedWithValue": "Naiupdate na ang server configuration section {0}", - "MessageApplicationUpdatedTo": "Ang Jellyfin Server ay naiupdate to {0}", + "MessageApplicationUpdatedTo": "Ang bersyon ng Jellyfin Server ay naiupdate sa {0}", "MessageApplicationUpdated": "Naiupdate na ang Jellyfin Server", "Latest": "Pinakabago", "LabelRunningTimeValue": "Oras: {0}", - "LabelIpAddressValue": "Ang IP Address ay {0}", - "ItemRemovedWithName": "Naitanggal ang {0} sa library", - "ItemAddedWithName": "Naidagdag ang {0} sa library", + "LabelIpAddressValue": "IP address: {0}", + "ItemRemovedWithName": "Naitanggal ang {0} sa librerya", + "ItemAddedWithName": "Naidagdag ang {0} sa librerya", "Inherit": "Manahin", "HeaderRecordingGroups": "Pagtatalang Grupo", "HeaderNextUp": "Susunod", "HeaderLiveTV": "Live TV", - "HeaderFavoriteSongs": "Paboritong Kanta", - "HeaderFavoriteShows": "Paboritong Pelikula", - "HeaderFavoriteEpisodes": "Paboritong Episodes", - "HeaderFavoriteArtists": "Paboritong Artista", - "HeaderFavoriteAlbums": "Paboritong Albums", - "HeaderContinueWatching": "Ituloy Manood", - "HeaderAlbumArtists": "Artista ng Album", - "Genres": "Kategorya", - "Folders": "Folders", - "Favorites": "Paborito", - "FailedLoginAttemptWithUserName": "maling login galing {0}", - "DeviceOnlineWithName": "nakakonekta si {0}", - "DeviceOfflineWithName": "nadiskonekta si {0}", - "Collections": "Koleksyon", + "HeaderFavoriteSongs": "Mga Paboritong Kanta", + "HeaderFavoriteShows": "Mga Paboritong Pelikula", + "HeaderFavoriteEpisodes": "Mga Paboritong Episode", + "HeaderFavoriteArtists": "Mga Paboritong Artista", + "HeaderFavoriteAlbums": "Mga Paboritong Album", + "HeaderContinueWatching": "Magpatuloy sa Panonood", + "HeaderAlbumArtists": "Mga Artista ng Album", + "Genres": "Mga Kategorya", + "Folders": "Mga Folder", + "Favorites": "Mga Paborito", + "FailedLoginAttemptWithUserName": "Maling login galing kay/sa {0}", + "DeviceOnlineWithName": "Nakakonekta si/ang {0}", + "DeviceOfflineWithName": "Nadiskonekta si/ang {0}", + "Collections": "Mga Koleksyon", "ChapterNameValue": "Kabanata {0}", - "Channels": "Channel", - "CameraImageUploadedFrom": "May bagong larawan na naupload galing {0}", - "Books": "Libro", - "AuthenticationSucceededWithUserName": "{0} na patunayan", - "Artists": "Artista", + "Channels": "Mga Channel", + "CameraImageUploadedFrom": "May bagong larawan na naupload galing sa/kay {0}", + "Books": "Mga Libro", + "AuthenticationSucceededWithUserName": "Napatunayan si/ang {0}", + "Artists": "Mga Artista", "Application": "Aplikasyon", "AppDeviceValues": "Aplikasyon: {0}, Aparato: {1}", - "Albums": "Albums", - "TaskRefreshLibrary": "Suriin ang nasa librerya", - "TaskRefreshChapterImagesDescription": "Gumawa ng larawan para sa mga pelikula na may kabanata", + "Albums": "Mga Album", + "TaskRefreshLibrary": "Suriin and Librerya ng Medya", + "TaskRefreshChapterImagesDescription": "Gumawa ng larawan para sa mga pelikula na may kabanata.", "TaskRefreshChapterImages": "Kunin ang mga larawan ng kabanata", - "TaskCleanCacheDescription": "Tanggalin ang mga cache file na hindi na kailangan ng systema.", + "TaskCleanCacheDescription": "Tanggalin ang mga cache file na hindi na kailangan ng sistema.", "TasksChannelsCategory": "Palabas sa internet", "TasksLibraryCategory": "Librerya", "TasksMaintenanceCategory": "Pagpapanatili", - "HomeVideos": "Sariling pelikula" + "HomeVideos": "Sariling video/pelikula", + "TaskRefreshPeopleDescription": "Ini-update ang metadata para sa mga aktor at direktor sa iyong librerya ng medya.", + "TaskRefreshPeople": "I-refresh ang Tauhan", + "TaskDownloadMissingSubtitlesDescription": "Hinahanap sa internet ang mga nawawalang subtiles base sa metadata configuration.", + "TaskDownloadMissingSubtitles": "I-download and nawawalang subtitles", + "TaskRefreshChannelsDescription": "Ni-rerefresh ang impormasyon sa internet channels.", + "TaskRefreshChannels": "I-refresh ang Channels", + "TaskCleanTranscodeDescription": "Binubura ang transcode files na mas matanda ng isang araw.", + "TaskUpdatePluginsDescription": "Nag download at install ng updates sa plugins na naka configure para sa awtomatikong pag-update.", + "TaskUpdatePlugins": "I-update ang Plugins", + "TaskCleanLogsDescription": "Binubura and files ng talaan na mas mantanda ng {0} araw.", + "TaskCleanTranscode": "Linisin and Direktoryo ng Transcode", + "TaskCleanLogs": "Linisin and Direktoryo ng Talaan", + "TaskRefreshLibraryDescription": "Sinusuri ang iyong librerya ng medya para sa bagong files at irefresh ang metadata.", + "TaskCleanCache": "Linisin and Direktoryo ng Cache", + "TasksApplicationCategory": "Aplikasyon", + "TaskCleanActivityLog": "Linisin ang Tala ng Aktibidad", + "TaskCleanActivityLogDescription": "Tanggalin ang mga tala ng aktibidad na mas luma sa nakatakda na edad.", + "Default": "Default", + "Undefined": "Hindi tiyak", + "Forced": "Sapilitan" } diff --git a/Emby.Server.Implementations/Localization/Core/fr-CA.json b/Emby.Server.Implementations/Localization/Core/fr-CA.json index 3d7592e3c8..3c51d64e04 100644 --- a/Emby.Server.Implementations/Localization/Core/fr-CA.json +++ b/Emby.Server.Implementations/Localization/Core/fr-CA.json @@ -1,9 +1,9 @@ { "Albums": "Albums", - "AppDeviceValues": "Application : {0}, Appareil : {1}", + "AppDeviceValues": "App : {0}, Appareil : {1}", "Application": "Application", "Artists": "Artistes", - "AuthenticationSucceededWithUserName": "{0} s'est authentifié avec succès", + "AuthenticationSucceededWithUserName": "{0} authentifié avec succès", "Books": "Livres", "CameraImageUploadedFrom": "Une nouvelle image de caméra a été téléchargée depuis {0}", "Channels": "Chaînes", @@ -11,12 +11,12 @@ "Collections": "Collections", "DeviceOfflineWithName": "{0} s'est déconnecté", "DeviceOnlineWithName": "{0} est connecté", - "FailedLoginAttemptWithUserName": "Échec d'une tentative de connexion de {0}", + "FailedLoginAttemptWithUserName": "Tentative de connexion échoué par {0}", "Favorites": "Favoris", "Folders": "Dossiers", "Genres": "Genres", "HeaderAlbumArtists": "Artistes de l'album", - "HeaderContinueWatching": "Continuer à regarder", + "HeaderContinueWatching": "Reprendre le visionnement", "HeaderFavoriteAlbums": "Albums favoris", "HeaderFavoriteArtists": "Artistes favoris", "HeaderFavoriteEpisodes": "Épisodes favoris", @@ -26,12 +26,12 @@ "HeaderNextUp": "À Suivre", "HeaderRecordingGroups": "Groupes d'enregistrements", "HomeVideos": "Vidéos personnelles", - "Inherit": "Hériter", + "Inherit": "Hérite", "ItemAddedWithName": "{0} a été ajouté à la médiathèque", "ItemRemovedWithName": "{0} a été supprimé de la médiathèque", "LabelIpAddressValue": "Adresse IP : {0}", "LabelRunningTimeValue": "Durée : {0}", - "Latest": "Derniers", + "Latest": "Plus récent", "MessageApplicationUpdated": "Le serveur Jellyfin a été mis à jour", "MessageApplicationUpdatedTo": "Le serveur Jellyfin a été mis à jour vers la version {0}", "MessageNamedServerConfigurationUpdatedWithValue": "La configuration de la section {0} du serveur a été mise à jour", @@ -40,15 +40,15 @@ "Movies": "Films", "Music": "Musique", "MusicVideos": "Vidéos musicales", - "NameInstallFailed": "{0} échec d'installation", + "NameInstallFailed": "échec d'installation de {0}", "NameSeasonNumber": "Saison {0}", "NameSeasonUnknown": "Saison Inconnue", - "NewVersionIsAvailable": "Une nouvelle version du serveur Jellyfin est disponible au téléchargement.", + "NewVersionIsAvailable": "Une nouvelle version du serveur Jellyfin est disponible.", "NotificationOptionApplicationUpdateAvailable": "Mise à jour de l'application disponible", "NotificationOptionApplicationUpdateInstalled": "Mise à jour de l'application installée", "NotificationOptionAudioPlayback": "Lecture audio démarrée", "NotificationOptionAudioPlaybackStopped": "Lecture audio arrêtée", - "NotificationOptionCameraImageUploaded": "L'image de l'appareil photo a été transférée", + "NotificationOptionCameraImageUploaded": "Image d'appareil photo transférée", "NotificationOptionInstallationFailed": "Échec d'installation", "NotificationOptionNewLibraryContent": "Nouveau contenu ajouté", "NotificationOptionPluginError": "Erreur d'extension", @@ -70,9 +70,9 @@ "ScheduledTaskFailedWithName": "{0} a échoué", "ScheduledTaskStartedWithName": "{0} a commencé", "ServerNameNeedsToBeRestarted": "{0} doit être redémarré", - "Shows": "Émissions", + "Shows": "Séries", "Songs": "Chansons", - "StartupEmbyServerIsLoading": "Le serveur Jellyfin est en cours de chargement. Veuillez réessayer dans quelques instants.", + "StartupEmbyServerIsLoading": "Serveur Jellyfin en cours de chargement. Réessayez dans quelques instants.", "SubtitleDownloadFailureForItem": "Subtitles failed to download for {0}", "SubtitleDownloadFailureFromForItem": "Échec du téléchargement des sous-titres depuis {0} pour {1}", "Sync": "Synchroniser", @@ -80,38 +80,43 @@ "TvShows": "Séries Télé", "User": "Utilisateur", "UserCreatedWithName": "L'utilisateur {0} a été créé", - "UserDeletedWithName": "L'utilisateur {0} a été supprimé", - "UserDownloadingItemWithValues": "{0} est en train de télécharger {1}", + "UserDeletedWithName": "L'utilisateur {0} supprimé", + "UserDownloadingItemWithValues": "{0} télécharge {1}", "UserLockedOutWithName": "L'utilisateur {0} a été verrouillé", - "UserOfflineFromDevice": "{0} s'est déconnecté depuis {1}", - "UserOnlineFromDevice": "{0} s'est connecté depuis {1}", - "UserPasswordChangedWithName": "Le mot de passe pour l'utilisateur {0} a été modifié", + "UserOfflineFromDevice": "{0} s'est déconnecté de {1}", + "UserOnlineFromDevice": "{0} s'est connecté de {1}", + "UserPasswordChangedWithName": "Le mot de passe de utilisateur {0} a été modifié", "UserPolicyUpdatedWithName": "La politique de l'utilisateur a été mise à jour pour {0}", - "UserStartedPlayingItemWithValues": "{0} est en train de lire {1} sur {2}", - "UserStoppedPlayingItemWithValues": "{0} vient d'arrêter la lecture de {1} sur {2}", + "UserStartedPlayingItemWithValues": "{0} joue {1} sur {2}", + "UserStoppedPlayingItemWithValues": "{0} a terminé la lecture de {1} sur {2}", "ValueHasBeenAddedToLibrary": "{0} a été ajouté à votre médiathèque", "ValueSpecialEpisodeName": "Spécial - {0}", "VersionNumber": "Version {0}", - "TasksLibraryCategory": "Bibliothèque", + "TasksLibraryCategory": "Médiathèque", "TasksMaintenanceCategory": "Entretien", - "TaskDownloadMissingSubtitlesDescription": "Recherche l'internet pour des sous-titres manquants à base de métadonnées configurées.", + "TaskDownloadMissingSubtitlesDescription": "Recherche les sous-titres manquant sur l'internet selon la configuration des métadonnées.", "TaskDownloadMissingSubtitles": "Télécharger les sous-titres manquants", - "TaskRefreshChannelsDescription": "Rafraîchit des informations des chaines internet.", - "TaskRefreshChannels": "Rafraîchir des chaines", - "TaskCleanTranscodeDescription": "Supprime les fichiers de transcodage de plus d'un jour.", + "TaskRefreshChannelsDescription": "Rafraîchit les informations des chaines internet.", + "TaskRefreshChannels": "Rafraîchir les chaines", + "TaskCleanTranscodeDescription": "Supprime les fichiers de transcodage datant de plus d'un jour.", "TaskCleanTranscode": "Nettoyer le répertoire de transcodage", - "TaskUpdatePluginsDescription": "Télécharger et installer les mises à jours des extensions qui sont configurés pour les m.à.j. automisés.", + "TaskUpdatePluginsDescription": "Télécharge et installe les mises à jours des extensions configurés pour les m.à.j. automatiques.", "TaskUpdatePlugins": "Mise à jour des extensions", - "TaskRefreshPeopleDescription": "Met à jour les métadonnées pour les acteurs et réalisateurs dans votre bibliothèque de médias.", - "TaskRefreshPeople": "Rafraîchir les acteurs", - "TaskCleanLogsDescription": "Supprime les journaux qui ont plus que {0} jours.", + "TaskRefreshPeopleDescription": "Met à jour les métadonnées pour les acteurs et réalisateurs dans votre médiathèque.", + "TaskRefreshPeople": "Rafraîchir les personnes", + "TaskCleanLogsDescription": "Supprime les journaux plus vieux que {0} jours.", "TaskCleanLogs": "Nettoyer le répertoire des journaux", - "TaskRefreshLibraryDescription": "Analyse votre bibliothèque média pour trouver de nouveaux fichiers et rafraîchit les métadonnées.", + "TaskRefreshLibraryDescription": "Analyse votre médiathèque pour trouver de nouveaux fichiers et rafraîchit les métadonnées.", "TaskRefreshChapterImages": "Extraire les images de chapitre", "TaskRefreshChapterImagesDescription": "Créer des vignettes pour les vidéos qui ont des chapitres.", - "TaskRefreshLibrary": "Analyser la bibliothèque de médias", + "TaskRefreshLibrary": "Analyser la médiathèque", "TaskCleanCache": "Nettoyer le répertoire des fichiers temporaires", "TasksApplicationCategory": "Application", "TaskCleanCacheDescription": "Supprime les fichiers temporaires qui ne sont plus nécessaire pour le système.", - "TasksChannelsCategory": "Canaux Internet" + "TasksChannelsCategory": "Chaines Internet", + "Default": "Par défaut", + "TaskCleanActivityLogDescription": "Éfface les entrées du journal plus anciennes que l'âge configuré.", + "TaskCleanActivityLog": "Nettoyer le journal d'activité", + "Undefined": "Indéfini", + "Forced": "Forcé" } diff --git a/Emby.Server.Implementations/Localization/Core/fr.json b/Emby.Server.Implementations/Localization/Core/fr.json index f4ca8575a1..0e4c38425c 100644 --- a/Emby.Server.Implementations/Localization/Core/fr.json +++ b/Emby.Server.Implementations/Localization/Core/fr.json @@ -15,7 +15,7 @@ "Favorites": "Favoris", "Folders": "Dossiers", "Genres": "Genres", - "HeaderAlbumArtists": "Artistes", + "HeaderAlbumArtists": "Artistes de l'album", "HeaderContinueWatching": "Continuer à regarder", "HeaderFavoriteAlbums": "Albums favoris", "HeaderFavoriteArtists": "Artistes préférés", @@ -39,7 +39,7 @@ "MixedContent": "Contenu mixte", "Movies": "Films", "Music": "Musique", - "MusicVideos": "Vidéos musicales", + "MusicVideos": "Clips musicaux", "NameInstallFailed": "{0} échec de l'installation", "NameSeasonNumber": "Saison {0}", "NameSeasonUnknown": "Saison Inconnue", @@ -93,13 +93,13 @@ "ValueSpecialEpisodeName": "Spécial - {0}", "VersionNumber": "Version {0}", "TasksChannelsCategory": "Chaines en ligne", - "TaskDownloadMissingSubtitlesDescription": "Cherche les sous-titres manquant sur internet en se basant sur la configuration des métadonnées.", - "TaskDownloadMissingSubtitles": "Télécharger les sous-titres manquant", + "TaskDownloadMissingSubtitlesDescription": "Recherche les sous-titres manquants sur internet en se basant sur la configuration des métadonnées.", + "TaskDownloadMissingSubtitles": "Télécharger les sous-titres manquants", "TaskRefreshChannelsDescription": "Rafraîchit les informations des chaines en ligne.", "TaskRefreshChannels": "Rafraîchir les chaines", "TaskCleanTranscodeDescription": "Supprime les fichiers transcodés de plus d'un jour.", "TaskCleanTranscode": "Nettoyer les dossier des transcodages", - "TaskUpdatePluginsDescription": "Télécharge et installe les mises à jours des extensions configurés pour être mises à jour automatiquement.", + "TaskUpdatePluginsDescription": "Télécharge et installe les mises à jours des extensions configurées pour être mises à jour automatiquement.", "TaskUpdatePlugins": "Mettre à jour les extensions", "TaskRefreshPeopleDescription": "Met à jour les métadonnées pour les acteurs et réalisateurs dans votre bibliothèque.", "TaskRefreshPeople": "Rafraîchir les acteurs", @@ -107,11 +107,18 @@ "TaskCleanLogs": "Nettoyer le répertoire des journaux", "TaskRefreshLibraryDescription": "Scanne toute les bibliothèques pour trouver les nouveaux fichiers et rafraîchit les métadonnées.", "TaskRefreshLibrary": "Scanner toutes les Bibliothèques", - "TaskRefreshChapterImagesDescription": "Crée des images de miniature pour les vidéos ayant des chapitres.", + "TaskRefreshChapterImagesDescription": "Crée des vignettes pour les vidéos ayant des chapitres.", "TaskRefreshChapterImages": "Extraire les images de chapitre", "TaskCleanCacheDescription": "Supprime les fichiers de cache dont le système n'a plus besoin.", "TaskCleanCache": "Vider le répertoire cache", "TasksApplicationCategory": "Application", "TasksLibraryCategory": "Bibliothèque", - "TasksMaintenanceCategory": "Maintenance" + "TasksMaintenanceCategory": "Maintenance", + "TaskCleanActivityLogDescription": "Supprime les entrées du journal d'activité antérieures à l'âge configuré.", + "TaskCleanActivityLog": "Nettoyer le journal d'activité", + "Undefined": "Non défini", + "Forced": "Forcé", + "Default": "Par défaut", + "TaskOptimizeDatabaseDescription": "Réduit les espaces vides/inutiles et compacte la base de données. Utiliser cette fonction après une mise à jour de la bibliothèque ou toute autre modification de la base de données peut améliorer les performances du serveur.", + "TaskOptimizeDatabase": "Optimiser la base de données" } diff --git a/Emby.Server.Implementations/Localization/Core/gl.json b/Emby.Server.Implementations/Localization/Core/gl.json index faee2519a1..afb22ab472 100644 --- a/Emby.Server.Implementations/Localization/Core/gl.json +++ b/Emby.Server.Implementations/Localization/Core/gl.json @@ -7,5 +7,115 @@ "Books": "Libros", "AuthenticationSucceededWithUserName": "{0} autenticouse correctamente", "Artists": "Artistas", - "Application": "Aplicativo" + "Application": "Aplicativo", + "NotificationOptionServerRestartRequired": "Necesario un reinicio do servidor", + "NotificationOptionPluginUpdateInstalled": "Actualización do Plugin instalada", + "NotificationOptionPluginUninstalled": "Plugin desinstalado", + "NotificationOptionPluginInstalled": "Plugin instalado", + "NotificationOptionPluginError": "Fallo do Plugin", + "NotificationOptionNewLibraryContent": "Novo contido engadido", + "NotificationOptionInstallationFailed": "Fallo na instalación", + "NotificationOptionCameraImageUploaded": "Imaxe da cámara subida", + "NotificationOptionAudioPlaybackStopped": "Reproducción de audio parada", + "NotificationOptionAudioPlayback": "Reproducción de audio comezada", + "NotificationOptionApplicationUpdateInstalled": "Actualización da aplicación instalada", + "NotificationOptionApplicationUpdateAvailable": "Actualización da aplicación dispoñible", + "NewVersionIsAvailable": "Unha nova versión do Servidor Jellyfin está dispoñible para descarga.", + "NameSeasonUnknown": "Tempada descoñecida", + "NameSeasonNumber": "Tempada {0}", + "NameInstallFailed": "{0} instalación fallida", + "MusicVideos": "Vídeos Musicais", + "Music": "Música", + "Movies": "Películas", + "MixedContent": "Contido Mixto", + "MessageServerConfigurationUpdated": "A configuración do servidor foi actualizada", + "MessageNamedServerConfigurationUpdatedWithValue": "A sección de configuración {0} do servidor foi actualizada", + "MessageApplicationUpdatedTo": "O servidor Jellyfin foi actualizado a {0}", + "MessageApplicationUpdated": "O servidor Jellyfin foi actualizado", + "Latest": "Último", + "LabelRunningTimeValue": "Tempo de execución: {0}", + "LabelIpAddressValue": "Enderezo IP: {0}", + "ItemRemovedWithName": "{0} foi eliminado da biblioteca", + "ItemAddedWithName": "{0} foi engadido a biblioteca", + "Inherit": "Herdar", + "HomeVideos": "Videos caseiros", + "HeaderRecordingGroups": "Grupos de Grabación", + "HeaderNextUp": "De seguido", + "HeaderLiveTV": "TV en directo", + "HeaderFavoriteSongs": "Cancións Favoritas", + "HeaderFavoriteShows": "Series de TV Favoritas", + "HeaderFavoriteEpisodes": "Episodios Favoritos", + "HeaderFavoriteArtists": "Artistas Favoritos", + "HeaderFavoriteAlbums": "Álbunes Favoritos", + "HeaderContinueWatching": "Seguir mirando", + "HeaderAlbumArtists": "Artistas de Album", + "Genres": "Xéneros", + "Forced": "Forzado", + "Folders": "Cartafoles", + "Favorites": "Favoritos", + "FailedLoginAttemptWithUserName": "Intento de incio de sesión fallido {0}", + "DeviceOnlineWithName": "{0} conectouse", + "DeviceOfflineWithName": "{0} desconectouse", + "Default": "Por defecto", + "AppDeviceValues": "Aplicación: {0}, Dispositivo: {1}", + "TaskCleanLogs": "Limpar Carpeta de Rexistros", + "TaskCleanActivityLog": "Limpar Rexistro de Actividade", + "TasksChannelsCategory": "Canáis de Internet", + "TaskUpdatePlugins": "Actualizar Plugins", + "User": "Usuario", + "Undefined": "Sen definir", + "TvShows": "Programas de TV", + "System": "Sistema", + "Sync": "Sincronizar", + "SubtitleDownloadFailureFromForItem": "Fallou a descarga de subtítulos para {1} dende {0}", + "StartupEmbyServerIsLoading": "O Servidor Jellyfin está cargando. Por favor, reinténteo en breve.", + "Songs": "Cancións", + "Shows": "Programas", + "ServerNameNeedsToBeRestarted": "{0} precisa ser reiniciado", + "ScheduledTaskStartedWithName": "{0} comezou", + "ScheduledTaskFailedWithName": "{0} fallou", + "ProviderValue": "Provedor: {0}", + "PluginUpdatedWithName": "{0} foi actualizado", + "PluginUninstalledWithName": "{0} foi desinstalado", + "PluginInstalledWithName": "{0} foi instalado", + "Playlists": "Listas de reproducción", + "Photos": "Fotos", + "UserLockedOutWithName": "O usuario {0} foi bloqueado", + "UserDownloadingItemWithValues": "{0} está a ser transferido {1}", + "UserDeletedWithName": "O usuario {0} foi borrado", + "UserCreatedWithName": "O usuario {0} foi creado", + "Plugin": "Plugin", + "NotificationOptionVideoPlaybackStopped": "Reproducción de vídeo parada", + "NotificationOptionVideoPlayback": "Reproducción de vídeo iniciada", + "NotificationOptionUserLockedOut": "Usuario bloqueado", + "NotificationOptionTaskFailed": "Falla na tarefa axendada", + "TaskCleanTranscodeDescription": "Borra os arquivos de transcode anteriores a un día.", + "TaskCleanTranscode": "Limpar Directorio de Transcode", + "UserStoppedPlayingItemWithValues": "{0} rematou de reproducir {1} en {2}", + "UserStartedPlayingItemWithValues": "{0} está reproducindo {1} en {2}", + "TaskDownloadMissingSubtitlesDescription": "Busca en internet por subtítulos que faltan baseado na configuración de metadatos.", + "TaskDownloadMissingSubtitles": "Descargar subtítulos que faltan", + "TaskRefreshChannelsDescription": "Refresca a información do canle de internet.", + "TaskRefreshChannels": "Refrescar Canles", + "TaskUpdatePluginsDescription": "Descarga e instala actualizacións para plugins que están configurados para actualizarse automáticamente.", + "TaskRefreshPeopleDescription": "Actualiza os metadatos dos actores e directores na túa libraría multimedia.", + "TaskRefreshPeople": "Refrescar Persoas", + "TaskCleanLogsDescription": "Borra arquivos de rexistro que son mais antigos que {0} días.", + "TaskRefreshLibraryDescription": "Escanea a tua libraría multimedia buscando novos arquivos e refrescando os metadatos.", + "TaskRefreshLibrary": "Escanear Libraría Multimedia", + "TaskRefreshChapterImagesDescription": "Crea previsualizacións para videos que teñen capítulos.", + "TaskRefreshChapterImages": "Extraer Imaxes dos Capítulos", + "TaskCleanCacheDescription": "Borra ficheiros da caché que xa non son necesarios para o sistema.", + "TaskCleanCache": "Limpa Directorio de Caché", + "TaskCleanActivityLogDescription": "Borra as entradas no rexistro de actividade anteriores á data configurada.", + "TasksApplicationCategory": "Aplicación", + "ValueSpecialEpisodeName": "Especial - {0}", + "ValueHasBeenAddedToLibrary": "{0} foi engadido a túa libraría multimedia", + "TasksLibraryCategory": "Libraría", + "TasksMaintenanceCategory": "Mantemento", + "VersionNumber": "Versión {0}", + "UserPolicyUpdatedWithName": "A política de usuario foi actualizada para {0}", + "UserPasswordChangedWithName": "Cambiouse o contrasinal para o usuario {0}", + "UserOnlineFromDevice": "{0} está en liña desde {1}", + "UserOfflineFromDevice": "{0} desconectouse desde {1}" } diff --git a/Emby.Server.Implementations/Localization/Core/gsw.json b/Emby.Server.Implementations/Localization/Core/gsw.json index ee1f8775e5..3364ee3332 100644 --- a/Emby.Server.Implementations/Localization/Core/gsw.json +++ b/Emby.Server.Implementations/Localization/Core/gsw.json @@ -113,5 +113,10 @@ "TaskUpdatePlugins": "Aktualisiere Erweiterungen", "TaskRefreshPeopleDescription": "Aktualisiert Metadaten für Schausteller und Regisseure in deiner Bibliothek.", "TaskRefreshPeople": "Aktualisiere Schauspieler", - "TaskCleanLogsDescription": "Löscht Log Dateien die älter als {0} Tage sind." + "TaskCleanLogsDescription": "Löscht Log Dateien die älter als {0} Tage sind.", + "TaskCleanActivityLogDescription": "Löscht Aktivitätsprotokolleinträge, die älter als das konfigurierte Alter sind.", + "TaskCleanActivityLog": "Aktivitätsprotokoll aufräumen", + "Undefined": "Undefiniert", + "Forced": "Erzwungen", + "Default": "Standard" } diff --git a/Emby.Server.Implementations/Localization/Core/he.json b/Emby.Server.Implementations/Localization/Core/he.json index f906d6e117..981e8a06ed 100644 --- a/Emby.Server.Implementations/Localization/Core/he.json +++ b/Emby.Server.Implementations/Localization/Core/he.json @@ -113,5 +113,10 @@ "TaskRefreshChannels": "רענן ערוץ", "TaskCleanTranscodeDescription": "מחק קבצי transcode שנוצרו מלפני יותר מיום.", "TaskCleanTranscode": "נקה תקיית Transcode", - "TaskUpdatePluginsDescription": "הורד והתקן עדכונים עבור תוספים שמוגדרים לעדכון אוטומטי." + "TaskUpdatePluginsDescription": "הורד והתקן עדכונים עבור תוספים שמוגדרים לעדכון אוטומטי.", + "TaskCleanActivityLogDescription": "מחק רשומת פעילות הישנה יותר מהגיל המוגדר.", + "TaskCleanActivityLog": "נקה רשומת פעילות", + "Undefined": "לא מוגדר", + "Forced": "כפוי", + "Default": "ברירת מחדל" } diff --git a/Emby.Server.Implementations/Localization/Core/hi.json b/Emby.Server.Implementations/Localization/Core/hi.json new file mode 100644 index 0000000000..82dc601bcb --- /dev/null +++ b/Emby.Server.Implementations/Localization/Core/hi.json @@ -0,0 +1,64 @@ +{ + "Albums": "एल्बम", + "HeaderRecordingGroups": "रिकॉर्डिंग समूह", + "HeaderNextUp": "इसके बाद", + "HeaderLiveTV": "लाइव टीवी", + "HeaderFavoriteSongs": "पसंदीदा गीत", + "HeaderFavoriteShows": "पसंदीदा शोज", + "HeaderFavoriteEpisodes": "पसंदीदा एपिसोड्स", + "HeaderFavoriteArtists": "पसंदीदा कलाकारसमूह", + "HeaderFavoriteAlbums": "पसंदीदा एलबम्स", + "HeaderContinueWatching": "देखते रहिए", + "HeaderAlbumArtists": "एल्बम कलकरसमुह", + "Genres": "शैली", + "Forced": "बलपूर्वक", + "Folders": "फोल्डेरें", + "Favorites": "पसंदीदा", + "FailedLoginAttemptWithUserName": "{0} से लॉगिन असफल हुआ है", + "DeviceOnlineWithName": "{0} से संयोग हो गया है", + "DeviceOfflineWithName": "{0} से संयोग विच्छिन्न हो गया है", + "Default": "प्राथमिक", + "Collections": "संग्रह", + "ChapterNameValue": "अध्याय", + "Channels": "चैनल", + "CameraImageUploadedFrom": "कैमरा से एक नया चित्र अपलोड किया गया है", + "Books": "किताब", + "AuthenticationSucceededWithUserName": "सफलता से प्रमाणीकृत", + "Artists": "कलाकारों", + "Application": "एप्लिकेशन", + "AppDeviceValues": "एप: {0}, उपकरण: {1}", + "NotificationOptionPluginUninstalled": "प्लगइन अनइंस्टाल हो गया", + "NotificationOptionPluginInstalled": "प्लगइन इनस्टॉल हो गया", + "NotificationOptionPluginError": "प्लगइन फ़ैल हो गया", + "NotificationOptionInstallationFailed": "इंस्टालेशन फ़ैल हो गया", + "NotificationOptionAudioPlaybackStopped": "संगीत बंद कर दिया गया", + "NotificationOptionAudioPlayback": "संगीत शुरू कर दिया गया", + "NotificationOptionCameraImageUploaded": "कैमरा फोटो अपलोड किया गया", + "NotificationOptionApplicationUpdateInstalled": "एप्लीकेशन अपडेट इनस्टॉल कर दिया है", + "NotificationOptionApplicationUpdateAvailable": "एप्लीकेशन अपडेट उपलभ्द है", + "NewVersionIsAvailable": "जेलीफिन सर्वर का एक नया वर्जन डाउनलोड के लिए उपलब्ध है।", + "NameSeasonUnknown": "अनजान भाग", + "NameSeasonNumber": "भाग {0}", + "NameInstallFailed": "{0} इनस्टॉल करते समय फेल हो गया है", + "MusicVideos": "संगीत वीडियो", + "Music": "संगीत", + "Movies": "फ़िल्म", + "MixedContent": "मिला-जुला कंटेंट", + "MessageServerConfigurationUpdated": "सर्वर कॉन्फ़िगरेशन अपडेट हो गया है", + "MessageNamedServerConfigurationUpdatedWithValue": "सर्वर कॉन्फ़िगरेशन भाग {0} अपडेट हो गया है", + "MessageApplicationUpdatedTo": "जैलीफिन सर्वर {0} में अपडेट हो गया है", + "MessageApplicationUpdated": "जैलीफिन सर्वर अपडेट हो गया है", + "Latest": "सबसे नया", + "LabelIpAddressValue": "आई पी एड्रेस: {0}", + "ItemRemovedWithName": "{0} लाइब्रेरी में से निकाल दिया है", + "HomeVideos": "होम वीडियोस", + "NotificationOptionVideoPlayback": "वीडियो प्लेबैक शुरू हुआ", + "NotificationOptionUserLockedOut": "उपयोगकर्ता लॉक हो गया", + "NotificationOptionTaskFailed": "निर्धारित कार्य विफलता", + "NotificationOptionServerRestartRequired": "सर्वर पुनरारंभ आवश्यक है", + "NotificationOptionPluginUpdateInstalled": "प्लगइन अद्यतन स्थापित", + "NotificationOptionNewLibraryContent": "नई सामग्री जोड़ी गई", + "LabelRunningTimeValue": "चलने का समय: {0}", + "ItemAddedWithName": "{0} को लाइब्रेरी में जोड़ा गया", + "Inherit": "इनहेरिट" +} diff --git a/Emby.Server.Implementations/Localization/Core/hr.json b/Emby.Server.Implementations/Localization/Core/hr.json index a3c936240d..9eb80b83ba 100644 --- a/Emby.Server.Implementations/Localization/Core/hr.json +++ b/Emby.Server.Implementations/Localization/Core/hr.json @@ -5,13 +5,13 @@ "Artists": "Izvođači", "AuthenticationSucceededWithUserName": "{0} uspješno ovjerena", "Books": "Knjige", - "CameraImageUploadedFrom": "Nova fotografija sa kamere je uploadana iz {0}", + "CameraImageUploadedFrom": "Nova fotografija sa kamere je učitana iz {0}", "Channels": "Kanali", "ChapterNameValue": "Poglavlje {0}", "Collections": "Kolekcije", - "DeviceOfflineWithName": "{0} se odspojilo", - "DeviceOnlineWithName": "{0} je spojeno", - "FailedLoginAttemptWithUserName": "Neuspjeli pokušaj prijave za {0}", + "DeviceOfflineWithName": "{0} je prekinuo vezu", + "DeviceOnlineWithName": "{0} je povezan", + "FailedLoginAttemptWithUserName": "Neuspjeli pokušaj prijave od {0}", "Favorites": "Favoriti", "Folders": "Mape", "Genres": "Žanrovi", @@ -23,95 +23,100 @@ "HeaderFavoriteShows": "Omiljene serije", "HeaderFavoriteSongs": "Omiljene pjesme", "HeaderLiveTV": "TV uživo", - "HeaderNextUp": "Sljedeće je", + "HeaderNextUp": "Slijedi", "HeaderRecordingGroups": "Grupa snimka", - "HomeVideos": "Kućni videi", + "HomeVideos": "Kućni video", "Inherit": "Naslijedi", "ItemAddedWithName": "{0} je dodano u biblioteku", - "ItemRemovedWithName": "{0} je uklonjen iz biblioteke", + "ItemRemovedWithName": "{0} je uklonjeno iz biblioteke", "LabelIpAddressValue": "IP adresa: {0}", "LabelRunningTimeValue": "Vrijeme rada: {0}", "Latest": "Najnovije", - "MessageApplicationUpdated": "Jellyfin Server je ažuriran", - "MessageApplicationUpdatedTo": "Jellyfin Server je ažuriran na {0}", - "MessageNamedServerConfigurationUpdatedWithValue": "Odjeljak postavka servera {0} je ažuriran", - "MessageServerConfigurationUpdated": "Postavke servera su ažurirane", + "MessageApplicationUpdated": "Jellyfin server je ažuriran", + "MessageApplicationUpdatedTo": "Jellyfin server je ažuriran na {0}", + "MessageNamedServerConfigurationUpdatedWithValue": "Dio konfiguracije servera {0} je ažuriran", + "MessageServerConfigurationUpdated": "Konfiguracija servera je ažurirana", "MixedContent": "Miješani sadržaj", "Movies": "Filmovi", "Music": "Glazba", "MusicVideos": "Glazbeni spotovi", "NameInstallFailed": "{0} neuspješnih instalacija", "NameSeasonNumber": "Sezona {0}", - "NameSeasonUnknown": "Nepoznata sezona", + "NameSeasonUnknown": "Sezona nepoznata", "NewVersionIsAvailable": "Nova verzija Jellyfin servera je dostupna za preuzimanje.", - "NotificationOptionApplicationUpdateAvailable": "Dostupno ažuriranje aplikacije", - "NotificationOptionApplicationUpdateInstalled": "Instalirano ažuriranje aplikacije", - "NotificationOptionAudioPlayback": "Reprodukcija glazbe započeta", - "NotificationOptionAudioPlaybackStopped": "Reprodukcija audiozapisa je zaustavljena", - "NotificationOptionCameraImageUploaded": "Slike kamere preuzete", - "NotificationOptionInstallationFailed": "Instalacija neuspješna", - "NotificationOptionNewLibraryContent": "Novi sadržaj je dodan", - "NotificationOptionPluginError": "Dodatak otkazao", + "NotificationOptionApplicationUpdateAvailable": "Dostupno je ažuriranje aplikacije", + "NotificationOptionApplicationUpdateInstalled": "Instalirano je ažuriranje aplikacije", + "NotificationOptionAudioPlayback": "Reprodukcija glazbe započela", + "NotificationOptionAudioPlaybackStopped": "Reprodukcija glazbe zaustavljena", + "NotificationOptionCameraImageUploaded": "Slika s kamere učitana", + "NotificationOptionInstallationFailed": "Instalacija nije uspjela", + "NotificationOptionNewLibraryContent": "Novi sadržaj dodan", + "NotificationOptionPluginError": "Dodatak zakazao", "NotificationOptionPluginInstalled": "Dodatak instaliran", - "NotificationOptionPluginUninstalled": "Dodatak uklonjen", - "NotificationOptionPluginUpdateInstalled": "Instalirano ažuriranje za dodatak", - "NotificationOptionServerRestartRequired": "Potrebno ponovo pokretanje servera", - "NotificationOptionTaskFailed": "Zakazan zadatak nije izvršen", + "NotificationOptionPluginUninstalled": "Dodatak deinstaliran", + "NotificationOptionPluginUpdateInstalled": "Instalirano ažuriranje dodatka", + "NotificationOptionServerRestartRequired": "Ponovno pokrenite server", + "NotificationOptionTaskFailed": "Greška zakazanog zadatka", "NotificationOptionUserLockedOut": "Korisnik zaključan", - "NotificationOptionVideoPlayback": "Reprodukcija videa započeta", - "NotificationOptionVideoPlaybackStopped": "Reprodukcija videozapisa je zaustavljena", - "Photos": "Slike", - "Playlists": "Popis za reprodukciju", + "NotificationOptionVideoPlayback": "Reprodukcija videa započela", + "NotificationOptionVideoPlaybackStopped": "Reprodukcija videa zaustavljena", + "Photos": "Fotografije", + "Playlists": "Popisi za reprodukciju", "Plugin": "Dodatak", "PluginInstalledWithName": "{0} je instalirano", "PluginUninstalledWithName": "{0} je deinstalirano", "PluginUpdatedWithName": "{0} je ažurirano", - "ProviderValue": "Pružitelj: {0}", + "ProviderValue": "Pružatelj: {0}", "ScheduledTaskFailedWithName": "{0} neuspjelo", "ScheduledTaskStartedWithName": "{0} pokrenuto", - "ServerNameNeedsToBeRestarted": "{0} treba biti ponovno pokrenuto", + "ServerNameNeedsToBeRestarted": "{0} treba ponovno pokrenuti", "Shows": "Serije", "Songs": "Pjesme", - "StartupEmbyServerIsLoading": "Jellyfin Server se učitava. Pokušajte ponovo kasnije.", + "StartupEmbyServerIsLoading": "Jellyfin server se učitava. Pokušajte ponovo uskoro.", "SubtitleDownloadFailureForItem": "Titlovi prijevoda nisu preuzeti za {0}", - "SubtitleDownloadFailureFromForItem": "Prijevodi nisu uspješno preuzeti {0} od {1}", - "Sync": "Sink.", - "System": "Sistem", + "SubtitleDownloadFailureFromForItem": "Prijevod nije uspješno preuzet od {0} za {1}", + "Sync": "Sinkronizacija", + "System": "Sustav", "TvShows": "Serije", "User": "Korisnik", - "UserCreatedWithName": "Korisnik {0} je stvoren", + "UserCreatedWithName": "Korisnik {0} je kreiran", "UserDeletedWithName": "Korisnik {0} je obrisan", - "UserDownloadingItemWithValues": "{0} se preuzima {1}", + "UserDownloadingItemWithValues": "{0} preuzima {1}", "UserLockedOutWithName": "Korisnik {0} je zaključan", - "UserOfflineFromDevice": "{0} se odspojilo od {1}", - "UserOnlineFromDevice": "{0} je online od {1}", + "UserOfflineFromDevice": "{0} prekinuo vezu od {1}", + "UserOnlineFromDevice": "{0} povezan od {1}", "UserPasswordChangedWithName": "Lozinka je promijenjena za korisnika {0}", - "UserPolicyUpdatedWithName": "Pravila za korisnika su ažurirana za {0}", - "UserStartedPlayingItemWithValues": "{0} je pokrenuo {1}", - "UserStoppedPlayingItemWithValues": "{0} je zaustavio {1}", + "UserPolicyUpdatedWithName": "Pravila za korisnika ažurirana su za {0}", + "UserStartedPlayingItemWithValues": "{0} je pokrenuo reprodukciju {1} na {2}", + "UserStoppedPlayingItemWithValues": "{0} je završio reprodukciju {1} na {2}", "ValueHasBeenAddedToLibrary": "{0} je dodano u medijsku biblioteku", - "ValueSpecialEpisodeName": "Specijal - {0}", + "ValueSpecialEpisodeName": "Posebno - {0}", "VersionNumber": "Verzija {0}", - "TaskRefreshLibraryDescription": "Skenira vašu medijsku knjižnicu sa novim datotekama i osvježuje metapodatke.", - "TaskRefreshLibrary": "Skeniraj medijsku knjižnicu", - "TaskRefreshChapterImagesDescription": "Stvara sličice za videozapise koji imaju poglavlja.", - "TaskRefreshChapterImages": "Raspakiraj slike poglavlja", - "TaskCleanCacheDescription": "Briše priručne datoteke nepotrebne za sistem.", - "TaskCleanCache": "Očisti priručnu memoriju", + "TaskRefreshLibraryDescription": "Skenira medijsku biblioteku radi novih datoteka i osvježava metapodatke.", + "TaskRefreshLibrary": "Skeniraj medijsku biblioteku", + "TaskRefreshChapterImagesDescription": "Kreira sličice za videozapise koji imaju poglavlja.", + "TaskRefreshChapterImages": "Izdvoji slike poglavlja", + "TaskCleanCacheDescription": "Briše nepotrebne datoteke iz predmemorije.", + "TaskCleanCache": "Očisti mapu predmemorije", "TasksApplicationCategory": "Aplikacija", "TasksMaintenanceCategory": "Održavanje", - "TaskDownloadMissingSubtitlesDescription": "Pretraživanje interneta za prijevodima koji nedostaju bazirano na konfiguraciji meta podataka.", - "TaskDownloadMissingSubtitles": "Preuzimanje prijevoda koji nedostaju", - "TaskRefreshChannelsDescription": "Osvježava informacije o internet kanalima.", + "TaskDownloadMissingSubtitlesDescription": "Pretraži Internet za prijevodima koji nedostaju prema konfiguraciji metapodataka.", + "TaskDownloadMissingSubtitles": "Preuzmi prijevod koji nedostaje", + "TaskRefreshChannelsDescription": "Osvježava informacije Internet kanala.", "TaskRefreshChannels": "Osvježi kanale", - "TaskCleanTranscodeDescription": "Briše transkodirane fajlove starije od jednog dana.", - "TaskCleanTranscode": "Očisti direktorij za transkodiranje", - "TaskUpdatePluginsDescription": "Preuzima i instalira ažuriranja za dodatke koji su podešeni da se ažuriraju automatski.", + "TaskCleanTranscodeDescription": "Briše transkodirane datoteke starije od jednog dana.", + "TaskCleanTranscode": "Očisti mapu transkodiranja", + "TaskUpdatePluginsDescription": "Preuzima i instalira ažuriranja za dodatke koji su konfigurirani da se ažuriraju automatski.", "TaskUpdatePlugins": "Ažuriraj dodatke", - "TaskRefreshPeopleDescription": "Ažurira meta podatke za glumce i redatelje u vašoj medijskoj biblioteci.", - "TaskRefreshPeople": "Osvježi ljude", - "TaskCleanLogsDescription": "Briši logove koji su stariji od {0} dana.", - "TaskCleanLogs": "Očisti direktorij sa logovima", + "TaskRefreshPeopleDescription": "Ažurira metapodatke za glumce i redatelje u medijskoj biblioteci.", + "TaskRefreshPeople": "Osvježi osobe", + "TaskCleanLogsDescription": "Briše zapise dnevnika koji su stariji od {0} dana.", + "TaskCleanLogs": "Očisti mapu dnevnika zapisa", "TasksChannelsCategory": "Internet kanali", - "TasksLibraryCategory": "Biblioteka" + "TasksLibraryCategory": "Biblioteka", + "TaskCleanActivityLogDescription": "Briše zapise dnevnika aktivnosti starije od navedenog vremena.", + "TaskCleanActivityLog": "Očisti dnevnik aktivnosti", + "Undefined": "Nedefinirano", + "Forced": "Forsirani", + "Default": "Zadano" } diff --git a/Emby.Server.Implementations/Localization/Core/hu.json b/Emby.Server.Implementations/Localization/Core/hu.json index 343d213d41..85ab1511a4 100644 --- a/Emby.Server.Implementations/Localization/Core/hu.json +++ b/Emby.Server.Implementations/Localization/Core/hu.json @@ -15,7 +15,7 @@ "Favorites": "Kedvencek", "Folders": "Könyvtárak", "Genres": "Műfajok", - "HeaderAlbumArtists": "Album előadók", + "HeaderAlbumArtists": "Előadó albumai", "HeaderContinueWatching": "Megtekintés folytatása", "HeaderFavoriteAlbums": "Kedvenc albumok", "HeaderFavoriteArtists": "Kedvenc előadók", @@ -39,7 +39,7 @@ "MixedContent": "Vegyes tartalom", "Movies": "Filmek", "Music": "Zene", - "MusicVideos": "Zenei videók", + "MusicVideos": "Zenei videóklippek", "NameInstallFailed": "{0} sikertelen telepítés", "NameSeasonNumber": "{0}. évad", "NameSeasonUnknown": "Ismeretlen évad", @@ -49,7 +49,7 @@ "NotificationOptionAudioPlayback": "Audió lejátszás elkezdve", "NotificationOptionAudioPlaybackStopped": "Audió lejátszás leállítva", "NotificationOptionCameraImageUploaded": "Kamera kép feltöltve", - "NotificationOptionInstallationFailed": "Telepítési hiba", + "NotificationOptionInstallationFailed": "Telepítés sikertelen", "NotificationOptionNewLibraryContent": "Új tartalom hozzáadva", "NotificationOptionPluginError": "Bővítmény hiba", "NotificationOptionPluginInstalled": "Bővítmény telepítve", @@ -74,7 +74,7 @@ "Songs": "Dalok", "StartupEmbyServerIsLoading": "A Jellyfin Szerver betöltődik. Kérlek, próbáld újra hamarosan.", "SubtitleDownloadFailureForItem": "Subtitles failed to download for {0}", - "SubtitleDownloadFailureFromForItem": "Nem sikerült a felirat letöltése innen: {0} ehhez: {1}", + "SubtitleDownloadFailureFromForItem": "Nem sikerült a felirat letöltése innen: {0} ehhez: {1}", "Sync": "Szinkronizál", "System": "Rendszer", "TvShows": "TV műsorok", @@ -82,12 +82,12 @@ "UserCreatedWithName": "{0} felhasználó létrehozva", "UserDeletedWithName": "{0} felhasználó törölve", "UserDownloadingItemWithValues": "{0} letölti {1}", - "UserLockedOutWithName": "{0} felhasználó zárolva van", - "UserOfflineFromDevice": "{0} kijelentkezett innen: {1}", + "UserLockedOutWithName": "{0} felhasználó zárolva van", + "UserOfflineFromDevice": "{0} kijelentkezett innen: {1}", "UserOnlineFromDevice": "{0} online innen: {1}", "UserPasswordChangedWithName": "Jelszó megváltozott a következő felhasználó számára: {0}", "UserPolicyUpdatedWithName": "A felhasználói házirend frissítve lett neki: {0}", - "UserStartedPlayingItemWithValues": "{0} elkezdte játszani a következőt: {1} itt: {2}", + "UserStartedPlayingItemWithValues": "{0} elkezdte játszani a következőt: {1} itt: {2}", "UserStoppedPlayingItemWithValues": "{0} befejezte {1} lejátászását itt: {2}", "ValueHasBeenAddedToLibrary": "{0} hozzáadva a médiatárhoz", "ValueSpecialEpisodeName": "Special - {0}", @@ -113,5 +113,12 @@ "TaskDownloadMissingSubtitles": "Hiányzó feliratok letöltése", "TaskRefreshChannelsDescription": "Frissíti az internetes csatornák adatait.", "TaskRefreshChannels": "Csatornák frissítése", - "TaskCleanTranscodeDescription": "Törli az egy napnál régebbi átkódolási fájlokat." + "TaskCleanTranscodeDescription": "Törli az egy napnál régebbi átkódolási fájlokat.", + "TaskCleanActivityLogDescription": "A beállítottnál korábbi bejegyzések törlése a tevékenységnaplóból.", + "TaskCleanActivityLog": "Tevékenységnapló törlése", + "Undefined": "Meghatározatlan", + "Forced": "Kényszerített", + "Default": "Alapértelmezett", + "TaskOptimizeDatabaseDescription": "Tömöríti az adatbázist és csonkolja a szabad helyet. A feladat futtatása a könyvtár beolvasása után, vagy egyéb, adatbázis-módosítást igénylő változtatások végrehajtása javíthatja a teljesítményt.", + "TaskOptimizeDatabase": "Adatbázis optimalizálása" } diff --git a/Emby.Server.Implementations/Localization/Core/id.json b/Emby.Server.Implementations/Localization/Core/id.json index ef3ed2580a..ba35138700 100644 --- a/Emby.Server.Implementations/Localization/Core/id.json +++ b/Emby.Server.Implementations/Localization/Core/id.json @@ -1,7 +1,7 @@ { "Albums": "Album", "AuthenticationSucceededWithUserName": "{0} berhasil diautentikasi", - "AppDeviceValues": "Aplikasi : {0}, Alat : {1}", + "AppDeviceValues": "Aplikasi : {0}, Perangkat : {1}", "LabelRunningTimeValue": "Waktu berjalan: {0}", "MessageApplicationUpdatedTo": "Jellyfin Server sudah diperbarui ke {0}", "MessageApplicationUpdated": "Jellyfin Server sudah diperbarui", @@ -112,5 +112,10 @@ "TaskRefreshPeople": "Muat ulang Orang", "TaskCleanLogsDescription": "Menghapus file log yang lebih dari {0} hari.", "TaskCleanLogs": "Bersihkan Log Direktori", - "TaskRefreshLibrary": "Pindai Pustaka Media" + "TaskRefreshLibrary": "Pindai Pustaka Media", + "TaskCleanActivityLogDescription": "Menghapus log aktivitas yang lebih tua dari umur yang dikonfigurasi.", + "TaskCleanActivityLog": "Bersihkan Log Aktivitas", + "Undefined": "Tidak terdefinisi", + "Forced": "Dipaksa", + "Default": "Bawaan" } diff --git a/Emby.Server.Implementations/Localization/Core/is.json b/Emby.Server.Implementations/Localization/Core/is.json index 0f769eaada..b262a8b424 100644 --- a/Emby.Server.Implementations/Localization/Core/is.json +++ b/Emby.Server.Implementations/Localization/Core/is.json @@ -25,7 +25,7 @@ "Channels": "Stöðvar", "CameraImageUploadedFrom": "Ný ljósmynd frá myndavél hefur verið hlaðið upp frá {0}", "Books": "Bækur", - "AuthenticationSucceededWithUserName": "{0} náði að auðkennast", + "AuthenticationSucceededWithUserName": "{0} auðkenning tókst", "Artists": "Listamaður", "Application": "Forrit", "AppDeviceValues": "Snjallforrit: {0}, Tæki: {1}", @@ -106,5 +106,6 @@ "TasksChannelsCategory": "Netrásir", "TasksApplicationCategory": "Forrit", "TasksLibraryCategory": "Miðlasafn", - "TasksMaintenanceCategory": "Viðhald" + "TasksMaintenanceCategory": "Viðhald", + "Default": "Sjálfgefið" } diff --git a/Emby.Server.Implementations/Localization/Core/it.json b/Emby.Server.Implementations/Localization/Core/it.json index 0a6238578d..5e28cf09fb 100644 --- a/Emby.Server.Implementations/Localization/Core/it.json +++ b/Emby.Server.Implementations/Localization/Core/it.json @@ -15,7 +15,7 @@ "Favorites": "Preferiti", "Folders": "Cartelle", "Genres": "Generi", - "HeaderAlbumArtists": "Artisti degli Album", + "HeaderAlbumArtists": "Artisti dell'Album", "HeaderContinueWatching": "Continua a guardare", "HeaderFavoriteAlbums": "Album Preferiti", "HeaderFavoriteArtists": "Artisti Preferiti", @@ -25,7 +25,7 @@ "HeaderLiveTV": "Diretta TV", "HeaderNextUp": "Prossimo", "HeaderRecordingGroups": "Gruppi di Registrazione", - "HomeVideos": "Video personali", + "HomeVideos": "Video Personali", "Inherit": "Eredita", "ItemAddedWithName": "{0} è stato aggiunto alla libreria", "ItemRemovedWithName": "{0} è stato rimosso dalla libreria", @@ -39,7 +39,7 @@ "MixedContent": "Contenuto misto", "Movies": "Film", "Music": "Musica", - "MusicVideos": "Video musicali", + "MusicVideos": "Video Musicali", "NameInstallFailed": "{0} installazione fallita", "NameSeasonNumber": "Stagione {0}", "NameSeasonUnknown": "Stagione sconosciuta", @@ -62,7 +62,7 @@ "NotificationOptionVideoPlaybackStopped": "La riproduzione video è stata interrotta", "Photos": "Foto", "Playlists": "Playlist", - "Plugin": "Plug-in", + "Plugin": "Plugin", "PluginInstalledWithName": "{0} è stato Installato", "PluginUninstalledWithName": "{0} è stato disinstallato", "PluginUpdatedWithName": "{0} è stato aggiornato", @@ -70,7 +70,7 @@ "ScheduledTaskFailedWithName": "{0} fallito", "ScheduledTaskStartedWithName": "{0} avviati", "ServerNameNeedsToBeRestarted": "{0} deve essere riavviato", - "Shows": "Programmi", + "Shows": "Serie TV", "Songs": "Canzoni", "StartupEmbyServerIsLoading": "Jellyfin server si sta avviando. Per favore riprova più tardi.", "SubtitleDownloadFailureForItem": "Impossibile scaricare i sottotitoli per {0}", @@ -87,7 +87,7 @@ "UserOnlineFromDevice": "{0} è online su {1}", "UserPasswordChangedWithName": "La password è stata cambiata per l'utente {0}", "UserPolicyUpdatedWithName": "La policy dell'utente è stata aggiornata per {0}", - "UserStartedPlayingItemWithValues": "{0} ha avviato la riproduzione di {1} su {2}", + "UserStartedPlayingItemWithValues": "{0} ha avviato la riproduzione di \"{1}\" su {2}", "UserStoppedPlayingItemWithValues": "{0} ha interrotto la riproduzione di {1} su {2}", "ValueHasBeenAddedToLibrary": "{0} è stato aggiunto alla tua libreria multimediale", "ValueSpecialEpisodeName": "Speciale - {0}", @@ -113,5 +113,12 @@ "TasksChannelsCategory": "Canali su Internet", "TasksApplicationCategory": "Applicazione", "TasksLibraryCategory": "Libreria", - "TasksMaintenanceCategory": "Manutenzione" + "TasksMaintenanceCategory": "Manutenzione", + "TaskCleanActivityLog": "Attività di Registro Completate", + "TaskCleanActivityLogDescription": "Elimina gli inserimenti nel registro delle attività più vecchie dell’età configurata.", + "Undefined": "Non Definito", + "Forced": "Forzato", + "Default": "Predefinito", + "TaskOptimizeDatabaseDescription": "Compatta Database e tronca spazi liberi. Eseguire questa azione dopo la scansione o dopo aver fatto altri cambiamenti inerenti il database potrebbe aumentarne la performance.", + "TaskOptimizeDatabase": "Ottimizza Database" } diff --git a/Emby.Server.Implementations/Localization/Core/ja.json b/Emby.Server.Implementations/Localization/Core/ja.json index 35004f0eb4..0d90ad31cc 100644 --- a/Emby.Server.Implementations/Localization/Core/ja.json +++ b/Emby.Server.Implementations/Localization/Core/ja.json @@ -4,7 +4,7 @@ "Application": "アプリケーション", "Artists": "アーティスト", "AuthenticationSucceededWithUserName": "{0} 認証に成功しました", - "Books": "ブック", + "Books": "ブックス", "CameraImageUploadedFrom": "新しいカメライメージが {0}からアップロードされました", "Channels": "チャンネル", "ChapterNameValue": "チャプター {0}", @@ -96,7 +96,7 @@ "TaskRefreshLibraryDescription": "メディアライブラリをスキャンして新しいファイルを探し、メタデータをリフレッシュします。", "TaskRefreshLibrary": "メディアライブラリのスキャン", "TaskCleanCacheDescription": "不要なキャッシュを消去します。", - "TaskCleanCache": "キャッシュの掃除", + "TaskCleanCache": "キャッシュを消去", "TasksChannelsCategory": "ネットチャンネル", "TasksApplicationCategory": "アプリケーション", "TasksLibraryCategory": "ライブラリ", @@ -112,5 +112,12 @@ "TaskDownloadMissingSubtitlesDescription": "メタデータ構成に基づいて、欠落している字幕をインターネットで検索します。", "TaskRefreshChapterImagesDescription": "チャプターのあるビデオのサムネイルを作成します。", "TaskRefreshChapterImages": "チャプター画像を抽出する", - "TaskDownloadMissingSubtitles": "不足している字幕をダウンロードする" + "TaskDownloadMissingSubtitles": "不足している字幕をダウンロードする", + "TaskCleanActivityLogDescription": "設定された期間よりも古いアクティビティの履歴を削除します。", + "TaskCleanActivityLog": "アクティビティの履歴を消去", + "Undefined": "未定義", + "Forced": "強制", + "Default": "デフォルト", + "TaskOptimizeDatabaseDescription": "データベースをコンパクトにして、空き領域を切り詰めます。メディアライブラリのスキャン後でこのタスクを実行するとパフォーマンスが向上する可能性があります。", + "TaskOptimizeDatabase": "データベースの最適化" } diff --git a/Emby.Server.Implementations/Localization/Core/kk.json b/Emby.Server.Implementations/Localization/Core/kk.json index 91c1fb15bc..d28564a7c6 100644 --- a/Emby.Server.Implementations/Localization/Core/kk.json +++ b/Emby.Server.Implementations/Localization/Core/kk.json @@ -1,95 +1,124 @@ { - "Albums": "Álbomdar", - "AppDeviceValues": "Qoldanba: {0}, Qurylǵy: {1}", + "Albums": "Älbomdar", + "AppDeviceValues": "Qoldanba: {0}, Qūrylğy: {1}", "Application": "Qoldanba", - "Artists": "Oryndaýshylar", - "AuthenticationSucceededWithUserName": "{0} túpnusqalyq rastalýy sátti aıaqtaldy", - "Books": "Kitaptar", - "CameraImageUploadedFrom": "{0} kamerasynan jańa sýret júktep salyndy", + "Artists": "Oryndauşylar", + "AuthenticationSucceededWithUserName": "{0} tüpnūsqalyq rastaluy sättı aiaqtaldy", + "Books": "Kıtaptar", + "CameraImageUploadedFrom": "{0} kamerasynan jaña suret jüktep salyndy", "Channels": "Arnalar", "ChapterNameValue": "{0}-sahna", - "Collections": "Jıyntyqtar", - "DeviceOfflineWithName": "{0} ajyratylǵan", - "DeviceOnlineWithName": "{0} qosylǵan", - "FailedLoginAttemptWithUserName": "{0} tarapynan kirý áreketi sátsiz aıaqtaldy", - "Favorites": "Tańdaýlylar", + "Collections": "Jiyntyqtar", + "DeviceOfflineWithName": "{0} ajyratylğan", + "DeviceOnlineWithName": "{0} qosylğan", + "FailedLoginAttemptWithUserName": "{0} tarapynan kıru äreketı sätsız aiaqtaldy", + "Favorites": "Tañdaulylar", "Folders": "Qaltalar", "Genres": "Janrlar", - "HeaderAlbumArtists": "Álbom oryndaýshylary", - "HeaderContinueWatching": "Qaraýdy jalǵastyrý", - "HeaderFavoriteAlbums": "Tańdaýly álbomdar", - "HeaderFavoriteArtists": "Tańdaýly oryndaýshylar", - "HeaderFavoriteEpisodes": "Tańdaýly bólimder", - "HeaderFavoriteShows": "Tańdaýly kórsetimder", - "HeaderFavoriteSongs": "Tańdaýly áýender", - "HeaderLiveTV": "Efır", - "HeaderNextUp": "Kezekti", + "HeaderAlbumArtists": "Oryndauşynyñ älbomy", + "HeaderContinueWatching": "Qaraudy jalğastyru", + "HeaderFavoriteAlbums": "Tañdauly älbomdar", + "HeaderFavoriteArtists": "Tañdauly oryndauşylar", + "HeaderFavoriteEpisodes": "Tañdauly telebölımder", + "HeaderFavoriteShows": "Tañdauly körsetımder", + "HeaderFavoriteSongs": "Tañdauly äuender", + "HeaderLiveTV": "Efir", + "HeaderNextUp": "Kezektı", "HeaderRecordingGroups": "Jazba toptary", - "HomeVideos": "Úılik beıneler", - "Inherit": "Muraǵa ıelený", - "ItemAddedWithName": "{0} tasyǵyshhanaǵa ústeldi", - "ItemRemovedWithName": "{0} tasyǵyshhanadan alastaldy", - "LabelIpAddressValue": "IP-mekenjaıy: {0}", - "LabelRunningTimeValue": "Oınatý ýaqyty: {0}", - "Latest": "Eń keıingi", - "MessageApplicationUpdated": "Jellyfin Serveri jańartyldy", - "MessageApplicationUpdatedTo": "Jellyfin Serveri {0} nusqasyna jańartyldy", - "MessageNamedServerConfigurationUpdatedWithValue": "Server konfıgýrasýasynyń {0} bólimi jańartyldy", - "MessageServerConfigurationUpdated": "Server konfıgýrasıasy jańartyldy", - "MixedContent": "Aralas mazmun", - "Movies": "Fılmder", - "Music": "Mýzyka", - "MusicVideos": "Mýzykalyq beıneler", - "NameInstallFailed": "{0} ornatylýy sátsiz", - "NameSeasonNumber": "{0}-maýsym", - "NameSeasonUnknown": "Belgisiz maýsym", - "NewVersionIsAvailable": "Jańa Jellyfin Server nusqasy júktep alýǵa qoljetimdi.", - "NotificationOptionApplicationUpdateAvailable": "Qoldanba jańartýy qoljetimdi", - "NotificationOptionApplicationUpdateInstalled": "Qoldanba jańartýy ornatyldy", - "NotificationOptionAudioPlayback": "Dybys oınatýy bastaldy", - "NotificationOptionAudioPlaybackStopped": "Dybys oınatýy toqtatyldy", - "NotificationOptionCameraImageUploaded": "Kameradan fotosýret júktep salynǵan", - "NotificationOptionInstallationFailed": "Ornatý sátsizdigi", - "NotificationOptionNewLibraryContent": "Jańa mazmun ústelgen", - "NotificationOptionPluginError": "Plagın sátsizdigi", - "NotificationOptionPluginInstalled": "Plagın ornatyldy", - "NotificationOptionPluginUninstalled": "Plagın ornatýy boldyrylmady", - "NotificationOptionPluginUpdateInstalled": "Plagın jańartýy ornatyldy", - "NotificationOptionServerRestartRequired": "Serverdi qaıta iske qosý qajet", - "NotificationOptionTaskFailed": "Josparlaǵan tapsyrma sátsizdigi", - "NotificationOptionUserLockedOut": "Paıdalanýshy qursaýly", - "NotificationOptionVideoPlayback": "Beıne oınatýy bastaldy", - "NotificationOptionVideoPlaybackStopped": "Beıne oınatýy toqtatyldy", - "Photos": "Fotosýretter", - "Playlists": "Oınatý tizimderi", - "Plugin": "Plagın", + "HomeVideos": "Üilık beineler", + "Inherit": "İelenu", + "ItemAddedWithName": "{0} tasyğyşhanağa üstelindı", + "ItemRemovedWithName": "{0} tasyğyşhanadan alastaldy", + "LabelIpAddressValue": "IP-mekenjaiy: {0}", + "LabelRunningTimeValue": "Oinatu uaqyty: {0}", + "Latest": "Eñ keiıngı", + "MessageApplicationUpdated": "Jellyfin Serverı jañartyldy", + "MessageApplicationUpdatedTo": "Jellyfin Serverı {0} nūsqasyna jañartyldy", + "MessageNamedServerConfigurationUpdatedWithValue": "Server teñşelımderınıñ {0} bölımı jañartyldy", + "MessageServerConfigurationUpdated": "Server teñşelımderı jañartyldy", + "MixedContent": "Aralas mazmūn", + "Movies": "Filmder", + "Music": "Muzyka", + "MusicVideos": "Muzykalyq beineler", + "NameInstallFailed": "{0} ornatyluy sätsız", + "NameSeasonNumber": "{0}-mausym", + "NameSeasonUnknown": "Belgısız mausym", + "NewVersionIsAvailable": "Jaña Jellyfin Server nūsqasy jüktep aluğa qoljetımdı.", + "NotificationOptionApplicationUpdateAvailable": "Qoldanba jañartuy qoljetımdı", + "NotificationOptionApplicationUpdateInstalled": "Qoldanba jañartuy ornatyldy", + "NotificationOptionAudioPlayback": "Dybys oinatuy bastaldy", + "NotificationOptionAudioPlaybackStopped": "Dybys oinatuy toqtatyldy", + "NotificationOptionCameraImageUploaded": "Kameradan fotosuret jüktep salynğan", + "NotificationOptionInstallationFailed": "Ornatu sätsızdıgı", + "NotificationOptionNewLibraryContent": "Jaña mazmūn üstelıngen", + "NotificationOptionPluginError": "Plagin sätsızdıgı", + "NotificationOptionPluginInstalled": "Plagin ornatyldy", + "NotificationOptionPluginUninstalled": "Plagin ornatuy boldyrylmady", + "NotificationOptionPluginUpdateInstalled": "Plagin jañartuy ornatyldy", + "NotificationOptionServerRestartRequired": "Serverdı qaita ıske qosu qajet", + "NotificationOptionTaskFailed": "Josparlağan tapsyrma sätsızdıgı", + "NotificationOptionUserLockedOut": "Paidalanuşy qūrsauly", + "NotificationOptionVideoPlayback": "Beine oinatuy bastaldy", + "NotificationOptionVideoPlaybackStopped": "Beine oinatuy toqtatyldy", + "Photos": "Fotosuretter", + "Playlists": "Oinatu tızımderı", + "Plugin": "Plagin", "PluginInstalledWithName": "{0} ornatyldy", - "PluginUninstalledWithName": "{0} joıyldy", - "PluginUpdatedWithName": "{0} jańartyldy", - "ProviderValue": "Jetkizýshi: {0}", - "ScheduledTaskFailedWithName": "{0} sátsiz", - "ScheduledTaskStartedWithName": "{0} iske qosyldy", - "ServerNameNeedsToBeRestarted": "{0} qaıta iske qosý qajet", - "Shows": "Kórsetimder", - "Songs": "Áýender", - "StartupEmbyServerIsLoading": "Jellyfin Server júktelýde. Áreketti kóp uzamaı qaıtalańyz.", + "PluginUninstalledWithName": "{0} joiyldy", + "PluginUpdatedWithName": "{0} jañartyldy", + "ProviderValue": "Jetkızuşı: {0}", + "ScheduledTaskFailedWithName": "{0} sätsız", + "ScheduledTaskStartedWithName": "{0} ıske qosyldy", + "ServerNameNeedsToBeRestarted": "{0} qaita ıske qosu qajet", + "Shows": "Körsetımder", + "Songs": "Äuender", + "StartupEmbyServerIsLoading": "Jellyfin Server jüktelude. Ärekettı köp ūzamai qaitalañyz.", "SubtitleDownloadFailureForItem": "Субтитрлер {0} үшін жүктеліп алынуы сәтсіз", - "SubtitleDownloadFailureFromForItem": "{1} úshin sýbtıtrlerdi {0} kózinen júktep alý sátsiz", - "Sync": "Úndestirý", - "System": "Júıe", - "TvShows": "TD-kórsetimder", - "User": "Paıdalanýshy", - "UserCreatedWithName": "Paıdalanýshy {0} jasalǵan", - "UserDeletedWithName": "Paıdalanýshy {0} joıylǵan", - "UserDownloadingItemWithValues": "{0} mynany júktep alýda: {1}", - "UserLockedOutWithName": "Paıdalanýshy {0} qursaýly", - "UserOfflineFromDevice": "{0} - {1} tarapynan ajyratylǵan", - "UserOnlineFromDevice": "{0} - {1} arqyly qosylǵan", - "UserPasswordChangedWithName": "Paıdalanýshy {0} úshin paról ózgertildi", - "UserPolicyUpdatedWithName": "Paıdalanýshy {0} úshin saıasattary jańartyldy", - "UserStartedPlayingItemWithValues": "{0} - {1} oınatýyn {2} bastady", - "UserStoppedPlayingItemWithValues": "{0} - {1} oınatýyn {2} toqtatty", - "ValueHasBeenAddedToLibrary": "{0} (tasyǵyshhanaǵa ústelindi)", - "ValueSpecialEpisodeName": "Arnaıy - {0}", - "VersionNumber": "Nusqasy {0}" + "SubtitleDownloadFailureFromForItem": "{1} üşın subtitrlerdı {0} közınen jüktep alu sätsız", + "Sync": "Ündestıru", + "System": "Jüie", + "TvShows": "TD-körsetımder", + "User": "Paidalanuşy", + "UserCreatedWithName": "Paidalanuşy {0} jasalğan", + "UserDeletedWithName": "Paidalanuşy {0} joiylğan", + "UserDownloadingItemWithValues": "{0} — {1} jüktep aluda", + "UserLockedOutWithName": "Paidalanuşy {0} qūrsaulanğan", + "UserOfflineFromDevice": "{0} — {1} tarapynan ajyratyldy", + "UserOnlineFromDevice": "{0} — {1} tarapynan qosyldy", + "UserPasswordChangedWithName": "Paidalanuşy {0} üşın paröl özgertıldı", + "UserPolicyUpdatedWithName": "Paidalanuşy {0} üşın saiasattary jañartyldy", + "UserStartedPlayingItemWithValues": "{0} — {2} tarapynan {1} oinatuda", + "UserStoppedPlayingItemWithValues": "{0} — {2} tarapynan {1} oinatuyn toqtatty", + "ValueHasBeenAddedToLibrary": "{0} tasyğyşhanağa üstelındı", + "ValueSpecialEpisodeName": "Arnaiy - {0}", + "VersionNumber": "Nūsqasy {0}", + "Default": "Ädepkı", + "TaskDownloadMissingSubtitles": "Joq subtitrlerdı jüktep alu", + "TaskRefreshChannels": "Arnalardy jañğyrtu", + "TaskCleanTranscode": "Qaita kodtau katalogyn tazalau", + "TaskUpdatePlugins": "Plaginderdı jañartu", + "TaskRefreshPeople": "Adamdardy jañğyrtu", + "TaskCleanLogs": "Jūrnal katalogyn tazalau", + "TaskRefreshLibrary": "Tasyğyşhanany skanerleu", + "TaskRefreshChapterImages": "Sahna suretterın şyğaryp alu", + "TaskCleanCache": "Keş katalogyn tazalau", + "TaskCleanActivityLog": "Äreket jūrnalyn tazalau", + "TasksChannelsCategory": "Internet-arnalar", + "TasksApplicationCategory": "Qoldanba", + "TasksLibraryCategory": "Tasyğyşhana", + "TasksMaintenanceCategory": "Qyzmet körsetu", + "Undefined": "Anyqtalmağan", + "Forced": "Mäjbürlı", + "TaskDownloadMissingSubtitlesDescription": "Metaderekter teñşelımderı negızınde joq subtitrlerdı İnternetten ızdeidı.", + "TaskRefreshChannelsDescription": "Internet-arnalar mälımetterın jañğyrtady.", + "TaskCleanTranscodeDescription": "Bіr künnen asqan qaita kodtau faildaryn joiady.", + "TaskUpdatePluginsDescription": "Avtomatty türde jañartuğa teñşelgen plaginder üşın jañartulardy jüktep alady jäne ornatady.", + "TaskRefreshPeopleDescription": "Tasyğyşhanadağy aktörler men rejisörler metaderekterın jañartady.", + "TaskCleanLogsDescription": "{0} künnen asqan jūrnal faildaryn joiady.", + "TaskRefreshLibraryDescription": "Tasyğyşhanadağy jaña faildardy skanerleidі jäne metaderekterdı jañğyrtady.", + "TaskRefreshChapterImagesDescription": "Sahnalary bar beineler üşın nobailar jasaidy.", + "TaskCleanCacheDescription": "Jüiede qajet emes keştelgen faildardy joiady.", + "TaskCleanActivityLogDescription": "Äreket jūrnalyndağy teñşelgen jasynan asqan jazbalary joiady.", + "TaskOptimizeDatabaseDescription": "Derekqordy qysyp, bos oryndy qysqartady. Būl tapsyrmany tasyğyşhanany skanerlegennen keiın nemese derekqorğa meñzeitın basqa özgertuler ıstelgennen keiın oryndau önımdılıktı damytuy mümkın.", + "TaskOptimizeDatabase": "Derekqordy oñtailandyru" } diff --git a/Emby.Server.Implementations/Localization/Core/ko.json b/Emby.Server.Implementations/Localization/Core/ko.json index fb01e46451..409b4d26b3 100644 --- a/Emby.Server.Implementations/Localization/Core/ko.json +++ b/Emby.Server.Implementations/Localization/Core/ko.json @@ -27,7 +27,7 @@ "HeaderRecordingGroups": "녹화 그룹", "HomeVideos": "홈 비디오", "Inherit": "상속", - "ItemAddedWithName": "{0}가 라이브러리에 추가됨", + "ItemAddedWithName": "{0}가 라이브러리에 추가되었습니다", "ItemRemovedWithName": "{0}가 라이브러리에서 제거됨", "LabelIpAddressValue": "IP 주소: {0}", "LabelRunningTimeValue": "상영 시간: {0}", @@ -113,5 +113,12 @@ "TaskCleanCacheDescription": "시스템에서 더 이상 필요하지 않은 캐시 파일을 삭제합니다.", "TaskCleanCache": "캐시 폴더 청소", "TasksChannelsCategory": "인터넷 채널", - "TasksLibraryCategory": "라이브러리" + "TasksLibraryCategory": "라이브러리", + "TaskCleanActivityLogDescription": "구성된 기간보다 오래된 활동내역 삭제.", + "TaskCleanActivityLog": "활동내역청소", + "Undefined": "일치하지 않음", + "Forced": "강제하기", + "Default": "기본 설정", + "TaskOptimizeDatabaseDescription": "데이터베이스를 압축하고 사용 가능한 공간을 늘립니다. 라이브러리를 검색한 후 이 작업을 실행하거나 데이터베이스 수정같은 비슷한 작업을 수행하면 성능이 향상될 수 있습니다.", + "TaskOptimizeDatabase": "데이터베이스 최적화" } diff --git a/Emby.Server.Implementations/Localization/Core/lt-LT.json b/Emby.Server.Implementations/Localization/Core/lt-LT.json index d4cb592efc..f3a131d405 100644 --- a/Emby.Server.Implementations/Localization/Core/lt-LT.json +++ b/Emby.Server.Implementations/Localization/Core/lt-LT.json @@ -113,5 +113,10 @@ "TasksChannelsCategory": "Internetiniai Kanalai", "TasksApplicationCategory": "Programa", "TasksLibraryCategory": "Mediateka", - "TasksMaintenanceCategory": "Priežiūra" + "TasksMaintenanceCategory": "Priežiūra", + "TaskCleanActivityLog": "Išvalyti veiklos žurnalą", + "Undefined": "Neapibrėžtas", + "Forced": "Priverstas", + "Default": "Numatytas", + "TaskCleanActivityLogDescription": "Ištrina veiklos žuranlo įrašus, kurie yra senesni nei nustatytas amžius." } diff --git a/Emby.Server.Implementations/Localization/Core/lv.json b/Emby.Server.Implementations/Localization/Core/lv.json index 5e3acfbe96..443a74a10f 100644 --- a/Emby.Server.Implementations/Localization/Core/lv.json +++ b/Emby.Server.Implementations/Localization/Core/lv.json @@ -15,9 +15,9 @@ "NotificationOptionUserLockedOut": "Lietotājs bloķēts", "LabelRunningTimeValue": "Garums: {0}", "Inherit": "Mantot", - "AppDeviceValues": "Lietotne:{0}, Ierīce:{1}", + "AppDeviceValues": "Lietotne: {0}, Ierīce: {1}", "VersionNumber": "Versija {0}", - "ValueHasBeenAddedToLibrary": "{0} ir ticis pievienots tavai multvides bibliotēkai", + "ValueHasBeenAddedToLibrary": "{0} ir ticis pievienots jūsu multvides bibliotēkai", "UserStoppedPlayingItemWithValues": "{0} ir beidzis atskaņot {1} uz {2}", "UserStartedPlayingItemWithValues": "{0} atskaņo {1} uz {2}", "UserPasswordChangedWithName": "Parole nomainīta lietotājam {0}", @@ -95,7 +95,7 @@ "TaskRefreshChapterImages": "Izvilkt Nodaļu Attēlus", "TasksApplicationCategory": "Lietotne", "TasksLibraryCategory": "Bibliotēka", - "TaskDownloadMissingSubtitlesDescription": "Internetā meklē trūkstošus subtitrus pēc metadatu uzstādījumiem.", + "TaskDownloadMissingSubtitlesDescription": "Internetā meklē trūkstošus subtitrus balstoties uz metadatu uzstādījumiem.", "TaskDownloadMissingSubtitles": "Lejupielādēt trūkstošus subtitrus", "TaskRefreshChannelsDescription": "Atjauno interneta kanālu informāciju.", "TaskRefreshChannels": "Atjaunot Kanālus", @@ -103,14 +103,21 @@ "TaskCleanTranscode": "Iztīrīt Trans-kodēšanas Mapi", "TaskUpdatePluginsDescription": "Lejupielādē un uzstāda atjauninājumus paplašinājumiem, kam ir uzstādīta automātiskā atjaunināšana.", "TaskUpdatePlugins": "Atjaunot Paplašinājumus", - "TaskRefreshPeopleDescription": "Atjauno metadatus priekš aktieriem un direktoriem tavā mediju bibliotēkā.", + "TaskRefreshPeopleDescription": "Atjauno metadatus aktieriem un direktoriem jūsu multivides bibliotēkā.", "TaskRefreshPeople": "Atjaunot Cilvēkus", "TaskCleanLogsDescription": "Nodzēš log datnes, kas ir vairāk par {0} dienām vecas.", "TaskCleanLogs": "Iztīrīt Logdatņu Mapi", - "TaskRefreshLibraryDescription": "Skenē tavas mediju bibliotēkas priekš jaunām datnēm un atjauno metadatus.", - "TaskRefreshLibrary": "Skanēt Mediju Bibliotēku", + "TaskRefreshLibraryDescription": "Skenē jūsu multivides bibliotēku, lai atrastu jaunas datnes, un atsvaidzina metadatus.", + "TaskRefreshLibrary": "Skenēt Multivides Bibliotēku", "TaskRefreshChapterImagesDescription": "Izveido sīktēlus priekš video ar sadaļām.", "TaskCleanCache": "Iztīrīt Kešošanas Mapi", "TasksChannelsCategory": "Interneta Kanāli", - "TasksMaintenanceCategory": "Apkope" + "TasksMaintenanceCategory": "Apkope", + "Forced": "Piespiests", + "TaskCleanActivityLogDescription": "Nodzēš darbību žurnāla ierakstus, kuri ir vecāki par doto vecumu.", + "TaskCleanActivityLog": "Notīrīt Darbību Žurnālu", + "Undefined": "Nenoteikts", + "Default": "Noklusējums", + "TaskOptimizeDatabaseDescription": "Saspiež datubāzi un atbrīvo atmiņu. Uzdevum palaišana pēc bibliotēku skenēšanas vai citām, ar datubāzi saistītām, izmaiņām iespējams uzlabos ātrdarbību.", + "TaskOptimizeDatabase": "Optimizēt datubāzi" } diff --git a/Emby.Server.Implementations/Localization/Core/ml.json b/Emby.Server.Implementations/Localization/Core/ml.json new file mode 100644 index 0000000000..09ef349136 --- /dev/null +++ b/Emby.Server.Implementations/Localization/Core/ml.json @@ -0,0 +1,123 @@ +{ + "AppDeviceValues": "അപ്ലിക്കേഷൻ: {0}, ഉപകരണം: {1}", + "Application": "അപ്ലിക്കേഷൻ", + "AuthenticationSucceededWithUserName": "{0} വിജയകരമായി പ്രാമാണീകരിച്ചു", + "CameraImageUploadedFrom": "Camera 0 from എന്നതിൽ നിന്ന് ഒരു പുതിയ ക്യാമറ ചിത്രം അപ്‌ലോഡുചെയ്‌തു", + "ChapterNameValue": "അധ്യായം {0}", + "DeviceOfflineWithName": "{0} വിച്ഛേദിച്ചു", + "DeviceOnlineWithName": "{0} ബന്ധിപ്പിച്ചു", + "FailedLoginAttemptWithUserName": "Log 0 from എന്നതിൽ നിന്നുള്ള പ്രവേശന ശ്രമം പരാജയപ്പെട്ടു", + "Forced": "നിർബന്ധിച്ചു", + "HeaderFavoriteAlbums": "പ്രിയപ്പെട്ട ആൽബങ്ങൾ", + "HeaderFavoriteArtists": "പ്രിയപ്പെട്ട കലാകാരന്മാർ", + "HeaderFavoriteEpisodes": "പ്രിയപ്പെട്ട എപ്പിസോഡുകൾ", + "HeaderFavoriteShows": "പ്രിയപ്പെട്ട ഷോകൾ", + "HeaderFavoriteSongs": "പ്രിയപ്പെട്ട ഗാനങ്ങൾ", + "HeaderLiveTV": "തത്സമയ ടിവി", + "HeaderNextUp": "അടുത്തത്", + "HeaderRecordingGroups": "ഗ്രൂപ്പുകൾ റെക്കോർഡുചെയ്യുന്നു", + "HomeVideos": "ഹോം വീഡിയോകൾ", + "Inherit": "അനന്തരാവകാശം", + "ItemAddedWithName": "{0} ലൈബ്രറിയിൽ ചേർത്തു", + "ItemRemovedWithName": "{0} ലൈബ്രറിയിൽ നിന്ന് നീക്കംചെയ്തു", + "LabelIpAddressValue": "IP വിലാസം: {0}", + "LabelRunningTimeValue": "പ്രവർത്തന സമയം: {0}", + "Latest": "ഏറ്റവും പുതിയ", + "MessageApplicationUpdated": "ജെല്ലിഫിൻ സെർവർ അപ്‌ഡേറ്റുചെയ്‌തു", + "MessageApplicationUpdatedTo": "ജെല്ലിഫിൻ സെർവർ {0 to ലേക്ക് അപ്‌ഡേറ്റുചെയ്‌തു", + "MessageNamedServerConfigurationUpdatedWithValue": "സെർവർ കോൺഫിഗറേഷൻ വിഭാഗം {0 അപ്‌ഡേറ്റുചെയ്‌തു", + "MessageServerConfigurationUpdated": "സെർവർ കോൺഫിഗറേഷൻ അപ്‌ഡേറ്റുചെയ്‌തു", + "MixedContent": "മിശ്രിത ഉള്ളടക്കം", + "Music": "സംഗീതം", + "MusicVideos": "സംഗീത വീഡിയോകൾ", + "NameInstallFailed": "{0} ഇൻസ്റ്റാളേഷൻ പരാജയപ്പെട്ടു", + "NameSeasonNumber": "സീസൺ {0}", + "NameSeasonUnknown": "സീസൺ അജ്ഞാതം", + "NewVersionIsAvailable": "ജെല്ലിഫിൻ സെർവറിന്റെ പുതിയ പതിപ്പ് ഡ .ൺ‌ലോഡിനായി ലഭ്യമാണ്.", + "NotificationOptionApplicationUpdateAvailable": "അപ്ലിക്കേഷൻ അപ്‌ഡേറ്റ് ലഭ്യമാണ്", + "NotificationOptionApplicationUpdateInstalled": "അപ്ലിക്കേഷൻ അപ്‌ഡേറ്റ് ഇൻസ്റ്റാളുചെയ്‌തു", + "NotificationOptionAudioPlayback": "ഓഡിയോ പ്ലേബാക്ക് ആരംഭിച്ചു", + "NotificationOptionAudioPlaybackStopped": "ഓഡിയോ പ്ലേബാക്ക് നിർത്തി", + "NotificationOptionCameraImageUploaded": "ക്യാമറ ചിത്രം അപ്‌ലോഡുചെയ്‌തു", + "NotificationOptionInstallationFailed": "ഇൻസ്റ്റാളേഷൻ പരാജയം", + "NotificationOptionNewLibraryContent": "പുതിയ ഉള്ളടക്കം ചേർത്തു", + "NotificationOptionPluginError": "പ്ലഗിൻ പരാജയം", + "NotificationOptionPluginInstalled": "പ്ലഗിൻ ഇൻസ്റ്റാളുചെയ്‌തു", + "NotificationOptionPluginUninstalled": "പ്ലഗിൻ അൺഇൻസ്റ്റാൾ ചെയ്തു", + "NotificationOptionPluginUpdateInstalled": "പ്ലഗിൻ അപ്‌ഡേറ്റ് ഇൻസ്റ്റാളുചെയ്‌തു", + "NotificationOptionServerRestartRequired": "സെർവർ പുനരാരംഭിക്കൽ ആവശ്യമാണ്", + "NotificationOptionTaskFailed": "ഷെഡ്യൂൾ ചെയ്ത ടാസ്‌ക് പരാജയം", + "NotificationOptionUserLockedOut": "ഉപയോക്താവ് ലോക്ക് out ട്ട് ചെയ്‌തു", + "NotificationOptionVideoPlayback": "വീഡിയോ പ്ലേബാക്ക് ആരംഭിച്ചു", + "NotificationOptionVideoPlaybackStopped": "വീഡിയോ പ്ലേബാക്ക് നിർത്തി", + "Plugin": "പ്ലഗിൻ", + "PluginInstalledWithName": "{0} ഇൻസ്റ്റാളുചെയ്‌തു", + "PluginUninstalledWithName": "{0 un അൺഇൻസ്റ്റാൾ ചെയ്തു", + "PluginUpdatedWithName": "{0} അപ്‌ഡേറ്റുചെയ്‌തു", + "ProviderValue": "ദാതാവ്: {0}", + "ScheduledTaskFailedWithName": "{0} പരാജയപ്പെട്ടു", + "ScheduledTaskStartedWithName": "{0} ആരംഭിച്ചു", + "ServerNameNeedsToBeRestarted": "{0} പുനരാരംഭിക്കേണ്ടതുണ്ട്", + "StartupEmbyServerIsLoading": "ജെല്ലിഫിൻ സെർവർ ലോഡുചെയ്യുന്നു. ഉടൻ തന്നെ വീണ്ടും ശ്രമിക്കുക.", + "SubtitleDownloadFailureFromForItem": "സബ്ടൈറ്റിലുകൾ {1} ന് {0 from ൽ നിന്ന് ഡ download ൺലോഡ് ചെയ്യുന്നതിൽ പരാജയപ്പെട്ടു", + "System": "സിസ്റ്റം", + "TvShows": "ടിവി ഷോകൾ", + "Undefined": "നിർവചിച്ചിട്ടില്ല", + "User": "ഉപയോക്താവ്", + "UserCreatedWithName": "ഉപയോക്താവ് {0 created സൃഷ്ടിച്ചു", + "UserDeletedWithName": "ഉപയോക്താവ് {0 deleted ഇല്ലാതാക്കി", + "UserDownloadingItemWithValues": "{0} ഡൗൺലോഡുചെയ്യുന്നു {1}", + "UserLockedOutWithName": "{0} ഉപയോക്താവ് ലോക്ക് out ട്ട് ചെയ്‌തു", + "UserOfflineFromDevice": "{0} {1} ൽ നിന്ന് വിച്ഛേദിച്ചു", + "UserOnlineFromDevice": "{0} {1} മുതൽ ഓൺ‌ലൈനിലാണ്", + "UserPasswordChangedWithName": "{0} ഉപയോക്താവിനായി പാസ്‌വേഡ് മാറ്റി", + "UserPolicyUpdatedWithName": "{0} എന്നതിനായി ഉപയോക്തൃ നയം അപ്‌ഡേറ്റുചെയ്‌തു", + "UserStartedPlayingItemWithValues": "{0} {2} ൽ {1} പ്ലേ ചെയ്യുന്നു", + "UserStoppedPlayingItemWithValues": "{0} {2} ൽ {1 play കളിക്കുന്നത് പൂർത്തിയാക്കി", + "ValueHasBeenAddedToLibrary": "Media 0 your നിങ്ങളുടെ മീഡിയ ലൈബ്രറിയിലേക്ക് ചേർത്തു", + "VersionNumber": "പതിപ്പ് {0}", + "TasksMaintenanceCategory": "പരിപാലനം", + "TasksLibraryCategory": "പുസ്തകശാല", + "TasksApplicationCategory": "അപ്ലിക്കേഷൻ", + "TasksChannelsCategory": "ഇന്റർനെറ്റ് ചാനലുകൾ", + "TaskCleanActivityLog": "പ്രവർത്തന ലോഗ് വൃത്തിയാക്കുക", + "TaskCleanActivityLogDescription": "കോൺഫിഗർ ചെയ്‌ത പ്രായത്തേക്കാൾ പഴയ പ്രവർത്തന ലോഗ് എൻട്രികൾ ഇല്ലാതാക്കുന്നു.", + "TaskCleanCache": "കാഷെ ഡയറക്ടറി വൃത്തിയാക്കുക", + "TaskCleanCacheDescription": "സിസ്റ്റത്തിന് ഇനി ആവശ്യമില്ലാത്ത കാഷെ ഫയലുകൾ ഇല്ലാതാക്കുന്നു.", + "TaskRefreshChapterImages": "ചാപ്റ്റർ ഇമേജുകൾ എക്‌സ്‌ട്രാക്റ്റുചെയ്യുക", + "TaskRefreshChapterImagesDescription": "അധ്യായങ്ങളുള്ള വീഡിയോകൾക്കായി ലഘുചിത്രങ്ങൾ സൃഷ്ടിക്കുന്നു.", + "TaskRefreshLibrary": "മീഡിയ ലൈബ്രറി സ്കാൻ ചെയ്യുക", + "TaskRefreshLibraryDescription": "പുതിയ ഫയലുകൾക്കായി നിങ്ങളുടെ മീഡിയ ലൈബ്രറി സ്കാൻ ചെയ്യുകയും മെറ്റാഡാറ്റ പുതുക്കുകയും ചെയ്യുന്നു.", + "TaskCleanLogs": "ലോഗ് ഡയറക്ടറി വൃത്തിയാക്കുക", + "TaskCleanLogsDescription": "Log 0} ദിവസത്തിൽ കൂടുതൽ പഴക്കമുള്ള ലോഗ് ഫയലുകൾ ഇല്ലാതാക്കുന്നു.", + "TaskRefreshPeople": "ആളുകളെ പുതുക്കുക", + "TaskRefreshPeopleDescription": "നിങ്ങളുടെ മീഡിയ ലൈബ്രറിയിലെ അഭിനേതാക്കൾക്കും സംവിധായകർക്കും മെറ്റാഡാറ്റ അപ്‌ഡേറ്റുചെയ്യുന്നു.", + "TaskUpdatePlugins": "പ്ലഗിനുകൾ അപ്‌ഡേറ്റുചെയ്യുക", + "TaskUpdatePluginsDescription": "യാന്ത്രികമായി അപ്‌ഡേറ്റുചെയ്യുന്നതിന് കോൺഫിഗർ ചെയ്‌തിരിക്കുന്ന പ്ലഗിനുകൾക്കായുള്ള അപ്‌ഡേറ്റുകൾ ഡൗൺലോഡുചെയ്യുകയും ഇൻസ്റ്റാളുചെയ്യുകയും ചെയ്യുന്നു.", + "TaskCleanTranscode": "ട്രാൻസ്‌കോഡ് ഡയറക്‌ടറി വൃത്തിയാക്കുക", + "TaskCleanTranscodeDescription": "ഒരു ദിവസത്തിൽ കൂടുതൽ പഴക്കമുള്ള ട്രാൻസ്‌കോഡ് ഫയലുകൾ ഇല്ലാതാക്കുന്നു.", + "TaskRefreshChannels": "ചാനലുകൾ പുതുക്കുക", + "TaskRefreshChannelsDescription": "ഇന്റർനെറ്റ് ചാനൽ വിവരങ്ങൾ പുതുക്കുന്നു.", + "TaskDownloadMissingSubtitles": "നഷ്‌ടമായ സബ്‌ടൈറ്റിലുകൾ ഡൗൺലോഡുചെയ്യുക", + "TaskDownloadMissingSubtitlesDescription": "മെറ്റാഡാറ്റ കോൺഫിഗറേഷനെ അടിസ്ഥാനമാക്കി നഷ്‌ടമായ സബ്‌ടൈറ്റിലുകൾക്കായി ഇന്റർനെറ്റ് തിരയുന്നു.", + "ValueSpecialEpisodeName": "പ്രത്യേക - {0}", + "Collections": "ശേഖരങ്ങൾ", + "Folders": "ഫോൾഡറുകൾ", + "HeaderAlbumArtists": "കലാകാരന്റെ ആൽബം", + "Sync": "സമന്വയിപ്പിക്കുക", + "Movies": "സിനിമകൾ", + "Photos": "ഫോട്ടോകൾ", + "Albums": "ആൽബങ്ങൾ", + "Playlists": "പ്ലേലിസ്റ്റുകൾ", + "Songs": "ഗാനങ്ങൾ", + "HeaderContinueWatching": "കാണുന്നത് തുടരുക", + "Artists": "കലാകാരന്മാർ", + "Shows": "ഷോകൾ", + "Default": "സ്ഥിരസ്ഥിതി", + "Favorites": "പ്രിയങ്കരങ്ങൾ", + "Books": "പുസ്തകങ്ങൾ", + "Genres": "വിഭാഗങ്ങൾ", + "Channels": "ചാനലുകൾ", + "TaskOptimizeDatabaseDescription": "ഡാറ്റാബേസ് ചുരുക്കുകയും സ്വതന്ത്ര ഇടം വെട്ടിച്ചുരുക്കുകയും ചെയ്യുന്നു. ലൈബ്രറി സ്‌കാൻ ചെയ്‌തതിനുശേഷം അല്ലെങ്കിൽ ഡാറ്റാബേസ് പരിഷ്‌ക്കരണങ്ങളെ സൂചിപ്പിക്കുന്ന മറ്റ് മാറ്റങ്ങൾ ചെയ്‌തതിന് ശേഷം ഈ ടാസ്‌ക് പ്രവർത്തിപ്പിക്കുന്നത് പ്രകടനം മെച്ചപ്പെടുത്തും.", + "TaskOptimizeDatabase": "ഡാറ്റാബേസ് ഒപ്റ്റിമൈസ് ചെയ്യുക" +} diff --git a/Emby.Server.Implementations/Localization/Core/ms.json b/Emby.Server.Implementations/Localization/Core/ms.json index 5e3d095ff1..b2dcf270c1 100644 --- a/Emby.Server.Implementations/Localization/Core/ms.json +++ b/Emby.Server.Implementations/Localization/Core/ms.json @@ -5,7 +5,7 @@ "Artists": "Artis", "AuthenticationSucceededWithUserName": "{0} berjaya disahkan", "Books": "Buku-buku", - "CameraImageUploadedFrom": "Ada gambar dari kamera yang baru dimuat naik melalui {0}", + "CameraImageUploadedFrom": "Gambar baharu telah dimuat naik melalui {0}", "Channels": "Saluran", "ChapterNameValue": "Bab {0}", "Collections": "Koleksi", @@ -39,29 +39,29 @@ "MixedContent": "Kandungan campuran", "Movies": "Filem", "Music": "Muzik", - "MusicVideos": "Video muzik", + "MusicVideos": "Muzik video", "NameInstallFailed": "{0} pemasangan gagal", "NameSeasonNumber": "Musim {0}", "NameSeasonUnknown": "Musim Tidak Diketahui", "NewVersionIsAvailable": "Versi terbaru Jellyfin Server bersedia untuk dimuat turunkan.", "NotificationOptionApplicationUpdateAvailable": "Kemas kini aplikasi telah sedia", - "NotificationOptionApplicationUpdateInstalled": "Application update installed", - "NotificationOptionAudioPlayback": "Audio playback started", - "NotificationOptionAudioPlaybackStopped": "Audio playback stopped", - "NotificationOptionCameraImageUploaded": "Camera image uploaded", + "NotificationOptionApplicationUpdateInstalled": "Kemas kini aplikasi telah dipasang", + "NotificationOptionAudioPlayback": "Ulangmain audio bermula", + "NotificationOptionAudioPlaybackStopped": "Ulangmain audio dihentikan", + "NotificationOptionCameraImageUploaded": "Imej kamera telah dimuatnaik", "NotificationOptionInstallationFailed": "Pemasangan gagal", - "NotificationOptionNewLibraryContent": "New content added", - "NotificationOptionPluginError": "Plugin failure", - "NotificationOptionPluginInstalled": "Plugin installed", + "NotificationOptionNewLibraryContent": "Kandungan baru telah ditambah", + "NotificationOptionPluginError": "Kegagalan plugin", + "NotificationOptionPluginInstalled": "Plugin telah dipasang", "NotificationOptionPluginUninstalled": "Plugin uninstalled", "NotificationOptionPluginUpdateInstalled": "Plugin update installed", "NotificationOptionServerRestartRequired": "Server restart required", "NotificationOptionTaskFailed": "Scheduled task failure", "NotificationOptionUserLockedOut": "User locked out", "NotificationOptionVideoPlayback": "Video playback started", - "NotificationOptionVideoPlaybackStopped": "Video playback stopped", - "Photos": "Photos", - "Playlists": "Playlists", + "NotificationOptionVideoPlaybackStopped": "Ulangmain video dihentikan", + "Photos": "Gambar-gambar", + "Playlists": "Senarai main", "Plugin": "Plugin", "PluginInstalledWithName": "{0} was installed", "PluginUninstalledWithName": "{0} was uninstalled", @@ -71,10 +71,10 @@ "ScheduledTaskStartedWithName": "{0} bermula", "ServerNameNeedsToBeRestarted": "{0} needs to be restarted", "Shows": "Series", - "Songs": "Songs", - "StartupEmbyServerIsLoading": "Jellyfin Server is loading. Please try again shortly.", + "Songs": "Lagu-lagu", + "StartupEmbyServerIsLoading": "Pelayan Jellyfin sedang dimuatkan. Sila cuba sebentar lagi.", "SubtitleDownloadFailureForItem": "Subtitles failed to download for {0}", - "SubtitleDownloadFailureFromForItem": "Subtitles failed to download from {0} for {1}", + "SubtitleDownloadFailureFromForItem": "Muat turun sarikata gagal dari {0} untuk {1}", "Sync": "Sync", "System": "Sistem", "TvShows": "TV Shows", @@ -82,14 +82,32 @@ "UserCreatedWithName": "User {0} has been created", "UserDeletedWithName": "User {0} has been deleted", "UserDownloadingItemWithValues": "{0} is downloading {1}", - "UserLockedOutWithName": "User {0} has been locked out", - "UserOfflineFromDevice": "{0} has disconnected from {1}", - "UserOnlineFromDevice": "{0} is online from {1}", - "UserPasswordChangedWithName": "Password has been changed for user {0}", - "UserPolicyUpdatedWithName": "User policy has been updated for {0}", + "UserLockedOutWithName": "Pengguna {0} telah dikunci", + "UserOfflineFromDevice": "{0} telah terputus dari {1}", + "UserOnlineFromDevice": "{0} berada dalam talian dari {1}", + "UserPasswordChangedWithName": "Kata laluan telah ditukar bagi pengguna {0}", + "UserPolicyUpdatedWithName": "Dasar pengguna telah dikemas kini untuk {0}", "UserStartedPlayingItemWithValues": "{0} is playing {1} on {2}", "UserStoppedPlayingItemWithValues": "{0} has finished playing {1} on {2}", "ValueHasBeenAddedToLibrary": "{0} has been added to your media library", "ValueSpecialEpisodeName": "Khas - {0}", - "VersionNumber": "Versi {0}" + "VersionNumber": "Versi {0}", + "TaskCleanActivityLog": "Log Aktiviti Bersih", + "TasksChannelsCategory": "Saluran Internet", + "TasksApplicationCategory": "Aplikasi", + "TasksLibraryCategory": "Perpustakaan", + "TasksMaintenanceCategory": "Penyelenggaraan", + "Undefined": "Tidak ditentukan", + "Forced": "Paksa", + "Default": "Asal", + "TaskCleanCache": "Bersihkan Direktori Cache", + "TaskCleanActivityLogDescription": "Padamkan entri log aktiviti yang lebih tua daripada usia yang dikonfigurasi.", + "TaskRefreshPeople": "Segarkan Orang", + "TaskCleanLogsDescription": "Padamkan fail log yang berumur lebih dari {0} hari.", + "TaskCleanLogs": "Bersihkan Direktotri Log", + "TaskRefreshLibraryDescription": "Imbas perpustakaan media untuk mencari fail-fail baru dan menyegarkan metadata.", + "TaskRefreshLibrary": "Imbas Perpustakaan Media", + "TaskRefreshChapterImagesDescription": "Membuat gambaran kecil untuk video yang mempunyai bab.", + "TaskRefreshChapterImages": "Ekstrak Gambar-gambar Bab", + "TaskCleanCacheDescription": "Menghapuskan fail cache yang tidak lagi diperlukan oleh sistem." } diff --git a/Emby.Server.Implementations/Localization/Core/nb.json b/Emby.Server.Implementations/Localization/Core/nb.json index 245c3cd636..81c1eefe74 100644 --- a/Emby.Server.Implementations/Localization/Core/nb.json +++ b/Emby.Server.Implementations/Localization/Core/nb.json @@ -30,20 +30,20 @@ "ItemAddedWithName": "{0} ble lagt til i biblioteket", "ItemRemovedWithName": "{0} ble fjernet fra biblioteket", "LabelIpAddressValue": "IP-adresse: {0}", - "LabelRunningTimeValue": "Kjøretid {0}", + "LabelRunningTimeValue": "Spilletid {0}", "Latest": "Siste", - "MessageApplicationUpdated": "Jellyfin Server har blitt oppdatert", - "MessageApplicationUpdatedTo": "Jellyfin Server ble oppdatert til {0}", - "MessageNamedServerConfigurationUpdatedWithValue": "Serverkonfigurasjon seksjon {0} har blitt oppdatert", - "MessageServerConfigurationUpdated": "Serverkonfigurasjon er oppdatert", + "MessageApplicationUpdated": "Jellyfin-tjeneren har blitt oppdatert", + "MessageApplicationUpdatedTo": "Jellyfin-tjeneren ble oppdatert til {0}", + "MessageNamedServerConfigurationUpdatedWithValue": "Tjenerkonfigurasjonsseksjon {0} har blitt oppdatert", + "MessageServerConfigurationUpdated": "Tjenerkonfigurasjon er oppdatert", "MixedContent": "Blandet innhold", "Movies": "Filmer", "Music": "Musikk", "MusicVideos": "Musikkvideoer", - "NameInstallFailed": "{0}-installasjonen mislyktes", + "NameInstallFailed": "Installasjonen av {0} mislyktes", "NameSeasonNumber": "Sesong {0}", - "NameSeasonUnknown": "Sesong ukjent", - "NewVersionIsAvailable": "En ny versjon av Jellyfin Server er tilgjengelig for nedlasting.", + "NameSeasonUnknown": "Ukjent sesong", + "NewVersionIsAvailable": "En ny versjon av Jellyfin-tjeneren er tilgjengelig for nedlasting.", "NotificationOptionApplicationUpdateAvailable": "En programvareoppdatering er tilgjengelig", "NotificationOptionApplicationUpdateInstalled": "Applikasjonsoppdatering installert", "NotificationOptionAudioPlayback": "Lydavspilling startet", @@ -51,18 +51,18 @@ "NotificationOptionCameraImageUploaded": "Kamerabilde lastet opp", "NotificationOptionInstallationFailed": "Installasjonen feilet", "NotificationOptionNewLibraryContent": "Nytt innhold lagt til", - "NotificationOptionPluginError": "Pluginfeil", - "NotificationOptionPluginInstalled": "Plugin installert", - "NotificationOptionPluginUninstalled": "Plugin avinstallert", - "NotificationOptionPluginUpdateInstalled": "Pluginoppdatering installert", - "NotificationOptionServerRestartRequired": "Serveromstart er nødvendig", + "NotificationOptionPluginError": "Programvareutvidelsesfeil", + "NotificationOptionPluginInstalled": "Programvareutvidelse installert", + "NotificationOptionPluginUninstalled": "Programvareutvidelse avinstallert", + "NotificationOptionPluginUpdateInstalled": "Programvareutvidelsesoppdatering installert", + "NotificationOptionServerRestartRequired": "Tjeneromstart er nødvendig", "NotificationOptionTaskFailed": "Feil under utføring av planlagt oppgave", "NotificationOptionUserLockedOut": "Bruker er utestengt", "NotificationOptionVideoPlayback": "Videoavspilling startet", "NotificationOptionVideoPlaybackStopped": "Videoavspilling stoppet", "Photos": "Bilder", "Playlists": "Spillelister", - "Plugin": "Plugin", + "Plugin": "Programvareutvidelse", "PluginInstalledWithName": "{0} ble installert", "PluginUninstalledWithName": "{0} ble avinstallert", "PluginUpdatedWithName": "{0} ble oppdatert", @@ -72,7 +72,7 @@ "ServerNameNeedsToBeRestarted": "{0} må startes på nytt", "Shows": "Program", "Songs": "Sanger", - "StartupEmbyServerIsLoading": "Jellyfin Server laster. Prøv igjen snart.", + "StartupEmbyServerIsLoading": "Jellyfin-tjener laster. Prøv igjen snart.", "SubtitleDownloadFailureForItem": "En feil oppstå under nedlasting av undertekster for {0}", "SubtitleDownloadFailureFromForItem": "Kunne ikke laste ned undertekster fra {0} for {1}", "Sync": "Synkroniser", @@ -86,32 +86,38 @@ "UserOfflineFromDevice": "{0} har koblet fra {1}", "UserOnlineFromDevice": "{0} er tilkoblet fra {1}", "UserPasswordChangedWithName": "Passordet for {0} er oppdatert", - "UserPolicyUpdatedWithName": "Brukerpolicyen har blitt oppdatert for {0}", + "UserPolicyUpdatedWithName": "Brukerretningslinjene har blitt oppdatert for {0}", "UserStartedPlayingItemWithValues": "{0} har startet avspilling {1} på {2}", - "UserStoppedPlayingItemWithValues": "{0} har stoppet avspilling {1}", + "UserStoppedPlayingItemWithValues": "{0} har stoppet avspilling {1}", "ValueHasBeenAddedToLibrary": "{0} har blitt lagt til i mediebiblioteket ditt", "ValueSpecialEpisodeName": "Spesialepisode - {0}", "VersionNumber": "Versjon {0}", - "TasksChannelsCategory": "Internett kanaler", + "TasksChannelsCategory": "Internettkanaler", "TasksApplicationCategory": "Applikasjon", "TasksLibraryCategory": "Bibliotek", "TasksMaintenanceCategory": "Vedlikehold", - "TaskCleanCache": "Tøm buffer katalog", + "TaskCleanCache": "Tøm hurtigbuffer", "TaskRefreshLibrary": "Skann mediebibliotek", "TaskRefreshChapterImagesDescription": "Lager forhåndsvisningsbilder for videoer som har kapitler.", - "TaskRefreshChapterImages": "Trekk ut Kapittelbilder", + "TaskRefreshChapterImages": "Trekk ut kapittelbilder", "TaskCleanCacheDescription": "Sletter mellomlagrede filer som ikke lengre trengs av systemet.", - "TaskDownloadMissingSubtitlesDescription": "Søker etter manglende underteksting på nett basert på metadatakonfigurasjon.", - "TaskDownloadMissingSubtitles": "Last ned manglende underteksting", - "TaskRefreshChannelsDescription": "Frisker opp internettkanalinformasjon.", - "TaskRefreshChannels": "Oppfrisk kanaler", + "TaskDownloadMissingSubtitlesDescription": "Søker etter manglende undertekster på nett basert på metadatakonfigurasjon.", + "TaskDownloadMissingSubtitles": "Last ned manglende undertekster", + "TaskRefreshChannelsDescription": "Oppdaterer internettkanalinformasjon.", + "TaskRefreshChannels": "Oppdater kanaler", "TaskCleanTranscodeDescription": "Sletter omkodede filer som er mer enn én dag gamle.", "TaskCleanTranscode": "Tøm transkodingmappe", - "TaskUpdatePluginsDescription": "Laster ned og installerer oppdateringer for utvidelser som er stilt inn til å oppdatere automatisk.", - "TaskUpdatePlugins": "Oppdater utvidelser", + "TaskUpdatePluginsDescription": "Laster ned og installerer oppdateringer for programvareutvidelser som er stilt inn til å oppdatere automatisk.", + "TaskUpdatePlugins": "Oppdater programvareutvidelse", "TaskRefreshPeopleDescription": "Oppdaterer metadata for skuespillere og regissører i mediebiblioteket ditt.", - "TaskRefreshPeople": "Oppfrisk personer", + "TaskRefreshPeople": "Oppdater personer", "TaskCleanLogsDescription": "Sletter loggfiler som er eldre enn {0} dager gamle.", "TaskCleanLogs": "Tøm loggmappe", - "TaskRefreshLibraryDescription": "Skanner mediebibliotekene dine for nye filer og oppdaterer metadata." + "TaskRefreshLibraryDescription": "Skanner mediebibliotekene dine for nye filer og oppdaterer metadata.", + "TaskCleanActivityLog": "Tøm aktivitetslogg", + "Undefined": "Udefinert", + "Forced": "Tvunget", + "Default": "Standard", + "TaskCleanActivityLogDescription": "Sletter oppføringer i aktivitetsloggen som er eldre enn den konfigurerte alderen.", + "TaskOptimizeDatabase": "Optimiser database" } diff --git a/Emby.Server.Implementations/Localization/Core/nl.json b/Emby.Server.Implementations/Localization/Core/nl.json index e102b92b90..f79840c781 100644 --- a/Emby.Server.Implementations/Localization/Core/nl.json +++ b/Emby.Server.Implementations/Localization/Core/nl.json @@ -1,11 +1,11 @@ { "Albums": "Albums", "AppDeviceValues": "App: {0}, Apparaat: {1}", - "Application": "Programma", + "Application": "Toepassing", "Artists": "Artiesten", - "AuthenticationSucceededWithUserName": "{0} is succesvol geverifieerd", + "AuthenticationSucceededWithUserName": "{0} is succesvol geauthenticeerd", "Books": "Boeken", - "CameraImageUploadedFrom": "Er is een nieuwe camera afbeelding toegevoegd via {0}", + "CameraImageUploadedFrom": "Nieuwe camera afbeelding toegevoegd vanaf {0}", "Channels": "Kanalen", "ChapterNameValue": "Hoofdstuk {0}", "Collections": "Verzamelingen", @@ -25,8 +25,8 @@ "HeaderLiveTV": "Live TV", "HeaderNextUp": "Volgende", "HeaderRecordingGroups": "Opnamegroepen", - "HomeVideos": "Home video's", - "Inherit": "Overerven", + "HomeVideos": "Thuis video's", + "Inherit": "Erven", "ItemAddedWithName": "{0} is toegevoegd aan de bibliotheek", "ItemRemovedWithName": "{0} is verwijderd uit de bibliotheek", "LabelIpAddressValue": "IP-adres: {0}", @@ -87,16 +87,16 @@ "UserOnlineFromDevice": "{0} heeft verbinding met {1}", "UserPasswordChangedWithName": "Wachtwoord voor {0} is gewijzigd", "UserPolicyUpdatedWithName": "Gebruikersbeleid gewijzigd voor {0}", - "UserStartedPlayingItemWithValues": "{0} heeft afspelen van {1} gestart op {2}", + "UserStartedPlayingItemWithValues": "{0} speelt {1} af op {2}", "UserStoppedPlayingItemWithValues": "{0} heeft afspelen van {1} gestopt op {2}", "ValueHasBeenAddedToLibrary": "{0} is toegevoegd aan je mediabibliotheek", "ValueSpecialEpisodeName": "Speciaal - {0}", "VersionNumber": "Versie {0}", - "TaskDownloadMissingSubtitlesDescription": "Zoekt op het internet naar missende ondertitels gebaseerd op metadata configuratie.", - "TaskDownloadMissingSubtitles": "Download missende ondertitels", + "TaskDownloadMissingSubtitlesDescription": "Zoekt op het internet naar missende ondertiteling gebaseerd op metadata configuratie.", + "TaskDownloadMissingSubtitles": "Download missende ondertiteling", "TaskRefreshChannelsDescription": "Vernieuwt informatie van internet kanalen.", "TaskRefreshChannels": "Vernieuw Kanalen", - "TaskCleanTranscodeDescription": "Verwijder transcode bestanden ouder dan 1 dag.", + "TaskCleanTranscodeDescription": "Verwijdert transcode bestanden ouder dan 1 dag.", "TaskCleanLogs": "Log Folder Opschonen", "TaskCleanTranscode": "Transcode Folder Opschonen", "TaskUpdatePluginsDescription": "Download en installeert updates voor plugins waar automatisch updaten aan staat.", @@ -108,10 +108,17 @@ "TaskRefreshLibrary": "Scan Media Bibliotheek", "TaskRefreshChapterImagesDescription": "Maakt thumbnails aan voor videos met hoofdstukken.", "TaskRefreshChapterImages": "Hoofdstukafbeeldingen Uitpakken", - "TaskCleanCacheDescription": "Verwijder gecachte bestanden die het systeem niet langer nodig heeft.", + "TaskCleanCacheDescription": "Verwijdert gecachte bestanden die het systeem niet langer nodig heeft.", "TaskCleanCache": "Cache Folder Opschonen", "TasksChannelsCategory": "Internet Kanalen", "TasksApplicationCategory": "Applicatie", "TasksLibraryCategory": "Bibliotheek", - "TasksMaintenanceCategory": "Onderhoud" + "TasksMaintenanceCategory": "Onderhoud", + "TaskCleanActivityLogDescription": "Verwijdert activiteiten logs ouder dan de ingestelde tijd.", + "TaskCleanActivityLog": "Leeg activiteiten logboek", + "Undefined": "Niet gedefinieerd", + "Forced": "Geforceerd", + "Default": "Standaard", + "TaskOptimizeDatabaseDescription": "Comprimeert de database en trimt vrije ruimte. Het uitvoeren van deze taak kan de prestaties verbeteren, na het scannen van de bibliotheek of andere aanpassingen die invloed hebben op de database.", + "TaskOptimizeDatabase": "Database optimaliseren" } diff --git a/Emby.Server.Implementations/Localization/Core/nn.json b/Emby.Server.Implementations/Localization/Core/nn.json index 6236515b2c..32d4f3a8b6 100644 --- a/Emby.Server.Implementations/Localization/Core/nn.json +++ b/Emby.Server.Implementations/Localization/Core/nn.json @@ -1,25 +1,25 @@ { - "MessageServerConfigurationUpdated": "Tenar konfigurasjonen har blitt oppdatert", + "MessageServerConfigurationUpdated": "Tenarkonfigurasjonen har blitt oppdatert", "MessageNamedServerConfigurationUpdatedWithValue": "Tenar konfigurasjon seksjon {0} har blitt oppdatert", - "MessageApplicationUpdatedTo": "Jellyfin Tenaren har blitt oppdatert til {0}", - "MessageApplicationUpdated": "Jellyfin Tenaren har blitt oppdatert", + "MessageApplicationUpdatedTo": "Jellyfin-tenaren har blitt oppdatert til {0}", + "MessageApplicationUpdated": "Jellyfin-tenaren har blitt oppdatert", "Latest": "Nyaste", "LabelRunningTimeValue": "Speletid: {0}", - "LabelIpAddressValue": "IP adresse: {0}", + "LabelIpAddressValue": "IP-adresse: {0}", "ItemRemovedWithName": "{0} vart fjerna frå biblioteket", "ItemAddedWithName": "{0} vart lagt til i biblioteket", - "Inherit": "Arv", - "HomeVideos": "Heime Videoar", + "Inherit": "Arve", + "HomeVideos": "Heimevideoar", "HeaderRecordingGroups": "Innspelingsgrupper", "HeaderNextUp": "Neste", "HeaderLiveTV": "Direkte TV", - "HeaderFavoriteSongs": "Favoritt Songar", - "HeaderFavoriteShows": "Favoritt Seriar", - "HeaderFavoriteEpisodes": "Favoritt Episodar", - "HeaderFavoriteArtists": "Favoritt Artistar", - "HeaderFavoriteAlbums": "Favoritt Album", + "HeaderFavoriteSongs": "Favorittsongar", + "HeaderFavoriteShows": "Favorittseriar", + "HeaderFavoriteEpisodes": "Favorittepisodar", + "HeaderFavoriteArtists": "Favorittartistar", + "HeaderFavoriteAlbums": "Favorittalbum", "HeaderContinueWatching": "Fortsett å sjå", - "HeaderAlbumArtists": "Album Artist", + "HeaderAlbumArtists": "Albumartist", "Genres": "Sjangrar", "Folders": "Mapper", "Favorites": "Favorittar", @@ -29,18 +29,18 @@ "Collections": "Samlingar", "ChapterNameValue": "Kapittel {0}", "Channels": "Kanalar", - "CameraImageUploadedFrom": "Eit nytt kamera bilete har blitt lasta opp frå {0}", + "CameraImageUploadedFrom": "Eit nytt kamerabilete har blitt lasta opp frå {0}", "Books": "Bøker", - "AuthenticationSucceededWithUserName": "{0} Har logga inn", + "AuthenticationSucceededWithUserName": "{0} har logga inn", "Artists": "Artistar", "Application": "Program", "AppDeviceValues": "App: {0}, Eining: {1}", "Albums": "Album", "NotificationOptionServerRestartRequired": "Tenaren krev omstart", - "NotificationOptionPluginUpdateInstalled": "Tilleggsprogram-oppdatering vart installert", - "NotificationOptionPluginUninstalled": "Tilleggsprogram avinstallert", - "NotificationOptionPluginInstalled": "Tilleggsprogram installert", - "NotificationOptionPluginError": "Tilleggsprogram feila", + "NotificationOptionPluginUpdateInstalled": "Programvaretilleggoppdatering vart installert", + "NotificationOptionPluginUninstalled": "Programvaretillegg avinstallert", + "NotificationOptionPluginInstalled": "Programvaretillegg installert", + "NotificationOptionPluginError": "Programvaretillegg feila", "NotificationOptionNewLibraryContent": "Nytt innhald er lagt til", "NotificationOptionInstallationFailed": "Installasjonsfeil", "NotificationOptionCameraImageUploaded": "Kamerabilde vart lasta opp", @@ -48,33 +48,33 @@ "NotificationOptionAudioPlayback": "Lydavspilling påbyrja", "NotificationOptionApplicationUpdateInstalled": "Applikasjonsoppdatering er installert", "NotificationOptionApplicationUpdateAvailable": "Applikasjonsoppdatering er tilgjengeleg", - "NewVersionIsAvailable": "Ein ny versjon av Jellyfin serveren er tilgjengeleg for nedlasting.", + "NewVersionIsAvailable": "Ein ny versjon av Jellyfin-tjenaren er tilgjengeleg for nedlasting.", "NameSeasonUnknown": "Ukjend sesong", "NameSeasonNumber": "Sesong {0}", - "NameInstallFailed": "{0} Installasjonen feila", + "NameInstallFailed": "Installasjonen av {0} feila", "MusicVideos": "Musikkvideoar", "Music": "Musikk", "Movies": "Filmar", "MixedContent": "Blanda innhald", - "Sync": "Synkronisera", + "Sync": "Synkroniser", "TaskDownloadMissingSubtitlesDescription": "Søk Internettet for manglande undertekstar basert på metadatainnstillingar.", "TaskDownloadMissingSubtitles": "Last ned manglande undertekstar", "TaskRefreshChannelsDescription": "Oppdater internettkanalinformasjon.", "TaskRefreshChannels": "Oppdater kanalar", - "TaskCleanTranscodeDescription": "Slett transkodefiler som er meir enn ein dag gamal.", - "TaskCleanTranscode": "Reins transkodemappe", + "TaskCleanTranscodeDescription": "Slett transkodefiler som er meir enn ein dag gammal.", + "TaskCleanTranscode": "Fjern transkodemappe", "TaskUpdatePluginsDescription": "Laster ned og installerer oppdateringar for programtillegg som er sette opp til å oppdaterast automatisk.", - "TaskUpdatePlugins": "Oppdaterer programtillegg", + "TaskUpdatePlugins": "Oppdaterer programvaretillegg", "TaskRefreshPeopleDescription": "Oppdaterer metadata for skodespelarar og regissørar i mediebiblioteket ditt.", "TaskRefreshPeople": "Oppdater personar", "TaskCleanLogsDescription": "Slett loggfiler som er meir enn {0} dagar gamle.", - "TaskCleanLogs": "Reins loggmappe", + "TaskCleanLogs": "Slett loggmappa", "TaskRefreshLibraryDescription": "Skannar mediebiblioteket ditt for nye filer og oppdaterer metadata.", "TaskRefreshLibrary": "Skann mediebibliotek", "TaskRefreshChapterImagesDescription": "Lager miniatyrbilete for videoar som har kapittel.", "TaskRefreshChapterImages": "Trekk ut kapittelbilete", - "TaskCleanCacheDescription": "Slettar mellomlagra filer som ikkje lengre trengst av systemet.", - "TaskCleanCache": "Rens mappe for hurtiglager", + "TaskCleanCacheDescription": "Sletter mellomlagra filer som ikkje lengre trengst av systemet.", + "TaskCleanCache": "Fjern hurtigbuffer", "TasksChannelsCategory": "Internettkanalar", "TasksApplicationCategory": "Applikasjon", "TasksLibraryCategory": "Bibliotek", @@ -96,9 +96,9 @@ "TvShows": "TV-seriar", "System": "System", "SubtitleDownloadFailureFromForItem": "Feila å laste ned undertekstar frå {0} for {1}", - "StartupEmbyServerIsLoading": "Jellyfintenaren laster. Prøv igjen om litt.", - "Songs": "Songar", - "Shows": "Program", + "StartupEmbyServerIsLoading": "Jellyfin-tenaren laster. Prøv igjen seinare.", + "Songs": "Sangar", + "Shows": "Seriar", "ServerNameNeedsToBeRestarted": "{0} må omstartast", "ScheduledTaskStartedWithName": "{0} starta", "ScheduledTaskFailedWithName": "{0} feila", @@ -106,11 +106,16 @@ "PluginUpdatedWithName": "{0} blei oppdatert", "PluginUninstalledWithName": "{0} blei avinstallert", "PluginInstalledWithName": "{0} blei installert", - "Plugin": "Programtillegg", - "Playlists": "Speleliste", - "Photos": "Foto", + "Plugin": "Programvaretillegg", + "Playlists": "Spelelister", + "Photos": "Bilete", "NotificationOptionVideoPlaybackStopped": "Videoavspeling stoppa", "NotificationOptionVideoPlayback": "Videoavspeling starta", "NotificationOptionUserLockedOut": "Brukar er utestengd", - "NotificationOptionTaskFailed": "Planlagt oppgåve feila" + "NotificationOptionTaskFailed": "Planlagt oppgåve feila", + "TaskCleanActivityLogDescription": "Sletter aktivitetslogginnlegg som er eldre enn den konfigurerte alderen.", + "TaskCleanActivityLog": "Slett aktivitetslogg", + "Undefined": "Udefinert", + "Forced": "Tvungen", + "Default": "Standard" } diff --git a/Emby.Server.Implementations/Localization/Core/pa.json b/Emby.Server.Implementations/Localization/Core/pa.json new file mode 100644 index 0000000000..d1db09232e --- /dev/null +++ b/Emby.Server.Implementations/Localization/Core/pa.json @@ -0,0 +1,121 @@ +{ + "TaskRefreshChapterImages": "ਐਕਸਟਰੈਕਟ ਚੈਪਟਰ ਚਿੱਤਰ", + "TaskDownloadMissingSubtitlesDescription": "ਮੈਟਾਡੇਟਾ ਕੌਂਫਿਗਰੇਸ਼ਨ ਦੇ ਅਧਾਰ ਤੇ ਗਾਇਬ ਉਪਸਿਰਲੇਖਾਂ ਲਈ ਇੰਟਰਨੈਟ ਦੀ ਭਾਲ ਕਰਦਾ ਹੈ.", + "TaskDownloadMissingSubtitles": "ਗਾਇਬ ਉਪਸਿਰਲੇਖ ਡਾ Download ਨਲੋਡ ਕਰੋ", + "TaskRefreshChannelsDescription": "ਇੰਟਰਨੈੱਟ ਚੈਨਲ ਦੀ ਜਾਣਕਾਰੀ ਨੂੰ ਤਾਜ਼ਾ ਕਰਦਾ ਹੈ.", + "TaskRefreshChannels": "ਚੈਨਲਾਂ ਨੂੰ ਤਾਜ਼ਾ ਕਰੋ", + "TaskCleanTranscodeDescription": "ਇੱਕ ਦਿਨ ਤੋਂ ਵੱਧ ਪੁਰਾਣੀ ਟ੍ਰਾਂਸਕੋਡ ਫਾਈਲਾਂ ਨੂੰ ਮਿਟਾਉਂਦਾ ਹੈ.", + "TaskCleanTranscode": "ਕਲੀਨ ਟ੍ਰਾਂਸਕੋਡ ਡਾਇਰੈਕਟਰੀ", + "TaskUpdatePluginsDescription": "ਪਲਗਇੰਸਾਂ ਲਈ ਡਾਉਨਲੋਡ ਅਤੇ ਸਥਾਪਨਾ ਅਪਡੇਟਾਂ ਜੋ ਆਪਣੇ ਆਪ ਅਪਡੇਟ ਕਰਨ ਲਈ ਕੌਂਫਿਗਰ ਕੀਤੀਆਂ ਜਾਂਦੀਆਂ ਹਨ.", + "TaskUpdatePlugins": "ਪਲੱਗਇਨ ਅਪਡੇਟ ਕਰੋ", + "TaskRefreshPeopleDescription": "ਤੁਹਾਡੀ ਮੀਡੀਆ ਲਾਇਬ੍ਰੇਰੀ ਵਿੱਚ ਅਦਾਕਾਰਾਂ ਅਤੇ ਨਿਰਦੇਸ਼ਕਾਂ ਲਈ ਮੈਟਾਡੇਟਾ ਨੂੰ ਅਪਡੇਟ ਕਰਦਾ ਹੈ.", + "TaskRefreshPeople": "ਲੋਕਾਂ ਨੂੰ ਤਾਜ਼ਾ ਕਰੋ", + "TaskCleanLogsDescription": "ਲੌਗ ਫਾਈਲਾਂ ਨੂੰ ਮਿਟਾਉਂਦਾ ਹੈ ਜੋ {0} ਦਿਨਾਂ ਤੋਂ ਵੱਧ ਪੁਰਾਣੀਆਂ ਹਨ.", + "TaskCleanLogs": "ਕਲੀਨ ਲਾਗ ਡਾਇਰੈਕਟਰੀ", + "TaskRefreshLibraryDescription": "ਨਵੀਆਂ ਫਾਈਲਾਂ ਲਈ ਆਪਣੀ ਮੀਡੀਆ ਲਾਇਬ੍ਰੇਰੀ ਨੂੰ ਸਕੈਨ ਕਰਦਾ ਹੈ ਅਤੇ ਮੈਟਾਡੇਟਾ ਨੂੰ ਤਾਜ਼ਾ ਕਰਦਾ ਹੈ.", + "TaskRefreshLibrary": "ਸਕੈਨ ਮੀਡੀਆ ਲਾਇਬ੍ਰੇਰੀ", + "TaskRefreshChapterImagesDescription": "ਚੈਪਟਰਾਂ ਵਾਲੇ ਵੀਡੀਓ ਲਈ ਥੰਬਨੇਲ ਬਣਾਉਂਦੇ ਹਨ.", + "TaskCleanCacheDescription": "ਸਿਸਟਮ ਦੁਆਰਾ ਹੁਣ ਕੈਚੇ ਫਾਈਲਾਂ ਦੀ ਜਰੂਰਤ ਨਹੀਂ ਹੈ.", + "TaskCleanCache": "ਸਾਫ਼ ਕੈਸ਼ ਡਾਇਰੈਕਟਰੀ", + "TaskCleanActivityLogDescription": "ਕੌਂਫਿਗਰ ਕੀਤੀ ਉਮਰ ਤੋਂ ਪੁਰਾਣੀ ਗਤੀਵਿਧੀ ਲੌਗ ਐਂਟਰੀਜ ਨੂੰ ਮਿਟਾਉਂਦਾ ਹੈ.", + "TaskCleanActivityLog": "ਸਾਫ਼ ਗਤੀਵਿਧੀ ਲਾਗ", + "TasksChannelsCategory": "ਇੰਟਰਨੈੱਟ ਚੈਨਲ", + "TasksApplicationCategory": "ਐਪਲੀਕੇਸ਼ਨ", + "TasksLibraryCategory": "ਲਾਇਬ੍ਰੇਰੀ", + "TasksMaintenanceCategory": "ਰੱਖ-ਰਖਾਅ", + "VersionNumber": "ਵਰਜਨ {0}", + "ValueSpecialEpisodeName": "ਵਿਸ਼ੇਸ਼ - {0}", + "ValueHasBeenAddedToLibrary": "{0} ਤੁਹਾਡੀ ਮੀਡੀਆ ਲਾਇਬ੍ਰੇਰੀ ਵਿੱਚ ਸ਼ਾਮਲ ਕੀਤਾ ਗਿਆ ਹੈ", + "UserStoppedPlayingItemWithValues": "{0} ਨੇ {2} 'ਤੇ {1} ਖੇਡਣਾ ਪੂਰਾ ਕਰ ਲਿਆ ਹੈ", + "UserStartedPlayingItemWithValues": "{0} {2} 'ਤੇ {1} ਖੇਡ ਰਿਹਾ ਹੈ", + "UserPolicyUpdatedWithName": "ਉਪਭੋਗਤਾ ਨੀਤੀ ਨੂੰ {0} ਲਈ ਅਪਡੇਟ ਕੀਤਾ ਗਿਆ ਹੈ", + "UserPasswordChangedWithName": "ਪਾਸਵਰਡ ਯੂਜ਼ਰ ਲਈ ਬਦਲਿਆ ਗਿਆ ਹੈ {0}", + "UserOnlineFromDevice": "{0} ਤੋਂ isਨਲਾਈਨ ਹੈ {1}", + "UserOfflineFromDevice": "{0} ਤੋਂ ਡਿਸਕਨੈਕਟ ਹੋ ਗਿਆ ਹੈ {1}", + "UserLockedOutWithName": "ਯੂਜ਼ਰ {0} ਨੂੰ ਲਾਕ ਆਉਟ ਕਰ ਦਿੱਤਾ ਗਿਆ ਹੈ", + "UserDownloadingItemWithValues": "{0} ਡਾ{ਨਲੋਡ ਕਰ ਰਿਹਾ ਹੈ {1}", + "UserDeletedWithName": "ਯੂਜ਼ਰ {0} ਨੂੰ ਮਿਟਾ ਦਿੱਤਾ ਗਿਆ ਹੈ", + "UserCreatedWithName": "ਯੂਜ਼ਰ {0} ਬਣਾਇਆ ਗਿਆ ਹੈ", + "User": "ਯੂਜ਼ਰ", + "Undefined": "ਪਰਿਭਾਸ਼ਤ", + "TvShows": "ਟੀਵੀ ਸ਼ੋਅਜ਼", + "System": "ਸਿਸਟਮ", + "Sync": "ਸਿੰਕ", + "SubtitleDownloadFailureFromForItem": "ਉਪਸਿਰਲੇਖ {1} ਲਈ {0} ਤੋਂ ਡਾ toਨਲੋਡ ਕਰਨ ਵਿੱਚ ਅਸਫਲ ਰਹੇ", + "StartupEmbyServerIsLoading": "ਜੈਲੀਫਿਨ ਸਰਵਰ ਲੋਡ ਹੋ ਰਿਹਾ ਹੈ. ਕਿਰਪਾ ਕਰਕੇ ਜਲਦੀ ਹੀ ਦੁਬਾਰਾ ਕੋਸ਼ਿਸ਼ ਕਰੋ.", + "Songs": "ਗਾਣੇ", + "Shows": "ਸ਼ੋਅਜ਼", + "ServerNameNeedsToBeRestarted": "{0} ਮੁੜ ਚਾਲੂ ਕਰਨ ਦੀ ਲੋੜ ਹੈ", + "ScheduledTaskStartedWithName": "{0} ਸ਼ੁਰੂ ਹੋਇਆ", + "ScheduledTaskFailedWithName": "{0} ਅਸਫਲ", + "ProviderValue": "ਦੇਣ ਵਾਲੇ: {0}", + "PluginUpdatedWithName": "{0} ਅਪਡੇਟ ਕੀਤਾ ਗਿਆ ਸੀ", + "PluginUninstalledWithName": "{0} ਅਣਇੰਸਟੌਲ ਕੀਤਾ ਗਿਆ ਸੀ", + "PluginInstalledWithName": "{0} ਲਗਾਇਆ ਗਿਆ ਸੀ", + "Plugin": "ਪਲੱਗਇਨ", + "Playlists": "ਪਲੇਲਿਸਟਸ", + "Photos": "ਫੋਟੋਆਂ", + "NotificationOptionVideoPlaybackStopped": "ਵੀਡੀਓ ਪਲੇਬੈਕ ਰੋਕਿਆ ਗਿਆ", + "NotificationOptionVideoPlayback": "ਵੀਡੀਓ ਪਲੇਬੈਕ ਸ਼ੁਰੂ ਹੋਇਆ", + "NotificationOptionUserLockedOut": "ਉਪਭੋਗਤਾ ਨੂੰ ਲਾਕ ਆਉਟ ਕੀਤਾ ਗਿਆ", + "NotificationOptionTaskFailed": "ਨਿਰਧਾਰਤ ਕਾਰਜ ਅਸਫਲਤਾ", + "NotificationOptionServerRestartRequired": "ਸਰਵਰ ਨੂੰ ਮੁੜ ਚਾਲੂ ਕਰਨ ਦੀ ਲੋੜ ਹੈ", + "NotificationOptionPluginUpdateInstalled": "ਪਲੱਗਇਨ ਅਪਡੇਟ ਇੰਸਟੌਲ ਕੀਤਾ ਗਿਆ", + "NotificationOptionPluginUninstalled": "ਪਲੱਗਇਨ ਅਣਇੰਸਟੌਲ ਕੀਤਾ", + "NotificationOptionPluginInstalled": "ਪਲੱਗਇਨ ਸਥਾਪਿਤ ਕੀਤਾ", + "NotificationOptionPluginError": "ਪਲੱਗਇਨ ਅਸਫਲ", + "NotificationOptionNewLibraryContent": "ਨਵੀਂ ਸਮੱਗਰੀ ਸ਼ਾਮਲ ਕੀਤੀ ਗਈ", + "NotificationOptionInstallationFailed": "ਇੰਸਟਾਲੇਸ਼ਨ ਅਸਫਲ", + "NotificationOptionCameraImageUploaded": "ਕੈਮਰਾ ਤਸਵੀਰ ਅਪਲੋਡ ਕੀਤੀ ਗਈ", + "NotificationOptionAudioPlaybackStopped": "ਆਡੀਓ ਪਲੇਅਬੈਕ ਰੋਕਿਆ ਗਿਆ", + "NotificationOptionAudioPlayback": "ਆਡੀਓ ਪਲੇਅਬੈਕ ਸ਼ੁਰੂ ਹੋਇਆ", + "NotificationOptionApplicationUpdateInstalled": "ਐਪਲੀਕੇਸ਼ਨ ਅਪਡੇਟ ਇੰਸਟੌਲ ਕੀਤਾ ਗਿਆ", + "NotificationOptionApplicationUpdateAvailable": "ਐਪਲੀਕੇਸ਼ਨ ਅਪਡੇਟ ਉਪਲਬਧ ਹੈ", + "NewVersionIsAvailable": "ਜੈਲੀਫਿਨ ਸਰਵਰ ਦਾ ਨਵਾਂ ਸੰਸਕਰਣ ਡਾਉਨਲੋਡ ਲਈ ਉਪਲਬਧ ਹੈ.", + "NameSeasonUnknown": "ਸੀਜ਼ਨ ਅਣਜਾਣ", + "NameSeasonNumber": "ਸੀਜ਼ਨ {0}", + "NameInstallFailed": "{0} ਇੰਸਟਾਲੇਸ਼ਨ ਫੇਲ੍ਹ ਹੋਈ", + "MusicVideos": "ਸੰਗੀਤ ਵੀਡੀਓ", + "Music": "ਸੰਗੀਤ", + "Movies": "ਫਿਲਮਾਂ", + "MixedContent": "ਮਿਸ਼ਰਤ ਸਮੱਗਰੀ", + "MessageServerConfigurationUpdated": "ਸਰਵਰ ਕੌਂਫਿਗਰੇਸ਼ਨ ਨੂੰ ਅਪਡੇਟ ਕੀਤਾ ਗਿਆ ਹੈ", + "MessageNamedServerConfigurationUpdatedWithValue": "ਸਰਵਰ ਕੌਂਫਿਗਰੇਸ਼ਨ ਸੈਕਸ਼ਨ {0} ਅਪਡੇਟ ਕੀਤਾ ਗਿਆ ਹੈ", + "MessageApplicationUpdatedTo": "ਜੈਲੀਫਿਨ ਸਰਵਰ ਨੂੰ ਅੱਪਡੇਟ ਕੀਤਾ ਗਿਆ ਹੈ {0}", + "MessageApplicationUpdated": "ਜੈਲੀਫਿਨ ਸਰਵਰ ਅਪਡੇਟ ਕੀਤਾ ਗਿਆ ਹੈ", + "Latest": "ਤਾਜ਼ਾ", + "LabelRunningTimeValue": "ਚੱਲਦਾ ਸਮਾਂ: {0}", + "LabelIpAddressValue": "IP ਪਤਾ: {0}", + "ItemRemovedWithName": "{0} ਲਾਇਬ੍ਰੇਰੀ ਵਿੱਚੋਂ ਹਟਾ ਦਿੱਤਾ ਗਿਆ ਸੀ", + "ItemAddedWithName": "{0} ਲਾਇਬ੍ਰੇਰੀ ਵਿੱਚ ਸ਼ਾਮਲ ਕੀਤਾ ਗਿਆ ਸੀ", + "Inherit": "ਵਿਰਾਸਤ", + "HomeVideos": "ਘਰੇਲੂ ਵੀਡੀਓ", + "HeaderRecordingGroups": "ਰਿਕਾਰਡਿੰਗ ਸਮੂਹ", + "HeaderNextUp": "ਅੱਗੇ", + "HeaderLiveTV": "ਲਾਈਵ ਟੀ", + "HeaderFavoriteSongs": "ਮਨਪਸੰਦ ਗਾਣੇ", + "HeaderFavoriteShows": "ਮਨਪਸੰਦ ਸ਼ੋਅ", + "HeaderFavoriteEpisodes": "ਮਨਪਸੰਦ ਐਪੀਸੋਡ", + "HeaderFavoriteArtists": "ਮਨਪਸੰਦ ਕਲਾਕਾਰ", + "HeaderFavoriteAlbums": "ਮਨਪਸੰਦ ਐਲਬਮ", + "HeaderContinueWatching": "ਵੇਖਣਾ ਜਾਰੀ ਰੱਖੋ", + "HeaderAlbumArtists": "ਐਲਬਮ ਕਲਾਕਾਰ", + "Genres": "ਸ਼ੈਲੀਆਂ", + "Forced": "ਮਜਬੂਰ", + "Folders": "ਫੋਲਡਰ", + "Favorites": "ਮਨਪਸੰਦ", + "FailedLoginAttemptWithUserName": "ਤੋਂ ਲਾਗਇਨ ਕੋਸ਼ਿਸ਼ ਫੇਲ ਹੋਈ {0}", + "DeviceOnlineWithName": "{0} ਜੁੜਿਆ ਹੋਇਆ ਹੈ", + "DeviceOfflineWithName": "{0} ਡਿਸਕਨੈਕਟ ਹੋ ਗਿਆ ਹੈ", + "Default": "ਮੂਲ", + "Collections": "ਸੰਗ੍ਰਹਿ", + "ChapterNameValue": "ਅਧਿਆਇ {0}", + "Channels": "ਚੈਨਲ", + "CameraImageUploadedFrom": "ਤੋਂ ਇੱਕ ਨਵਾਂ ਕੈਮਰਾ ਚਿੱਤਰ ਅਪਲੋਡ ਕੀਤਾ ਗਿਆ ਹੈ {0}", + "Books": "ਕਿਤਾਬਾਂ", + "AuthenticationSucceededWithUserName": "{0} ਸਫਲਤਾਪੂਰਕ ਪ੍ਰਮਾਣਿਤ", + "Artists": "ਕਲਾਕਾਰ", + "Application": "ਐਪਲੀਕੇਸ਼ਨ", + "AppDeviceValues": "ਐਪ: {0}, ਜੰਤਰ: {1}", + "Albums": "ਐਲਬਮਾਂ" +} diff --git a/Emby.Server.Implementations/Localization/Core/pl.json b/Emby.Server.Implementations/Localization/Core/pl.json index 003e591b37..275bdec6e6 100644 --- a/Emby.Server.Implementations/Localization/Core/pl.json +++ b/Emby.Server.Implementations/Localization/Core/pl.json @@ -113,5 +113,11 @@ "TasksChannelsCategory": "Kanały internetowe", "TasksApplicationCategory": "Aplikacja", "TasksLibraryCategory": "Biblioteka", - "TasksMaintenanceCategory": "Konserwacja" + "TasksMaintenanceCategory": "Konserwacja", + "TaskCleanActivityLogDescription": "Usuwa wpisy dziennika aktywności starsze niż skonfigurowany wiek.", + "TaskCleanActivityLog": "Czyść dziennik aktywności", + "Undefined": "Nieustalony", + "Forced": "Wymuszony", + "Default": "Domyślne", + "TaskOptimizeDatabase": "Optymalizuj bazę danych" } diff --git a/Emby.Server.Implementations/Localization/Core/pr.json b/Emby.Server.Implementations/Localization/Core/pr.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/Emby.Server.Implementations/Localization/Core/pr.json @@ -0,0 +1 @@ +{} diff --git a/Emby.Server.Implementations/Localization/Core/pt-BR.json b/Emby.Server.Implementations/Localization/Core/pt-BR.json index 5e49ca702e..323dcced07 100644 --- a/Emby.Server.Implementations/Localization/Core/pt-BR.json +++ b/Emby.Server.Implementations/Localization/Core/pt-BR.json @@ -16,7 +16,7 @@ "Folders": "Pastas", "Genres": "Gêneros", "HeaderAlbumArtists": "Artistas do Álbum", - "HeaderContinueWatching": "Continuar Assistindo", + "HeaderContinueWatching": "Continuar assistindo", "HeaderFavoriteAlbums": "Álbuns Favoritos", "HeaderFavoriteArtists": "Artistas favoritos", "HeaderFavoriteEpisodes": "Episódios favoritos", @@ -113,5 +113,10 @@ "TasksChannelsCategory": "Canais da Internet", "TasksApplicationCategory": "Aplicativo", "TasksLibraryCategory": "Biblioteca", - "TasksMaintenanceCategory": "Manutenção" + "TasksMaintenanceCategory": "Manutenção", + "TaskCleanActivityLogDescription": "Apaga o registro de atividades mais antigo que a idade configurada.", + "TaskCleanActivityLog": "Limpar Registro de Atividades", + "Undefined": "Indefinido", + "Forced": "Forçado", + "Default": "Padrão" } diff --git a/Emby.Server.Implementations/Localization/Core/pt-PT.json b/Emby.Server.Implementations/Localization/Core/pt-PT.json index 90a4941c56..8c41edf963 100644 --- a/Emby.Server.Implementations/Localization/Core/pt-PT.json +++ b/Emby.Server.Implementations/Localization/Core/pt-PT.json @@ -113,5 +113,10 @@ "TasksChannelsCategory": "Canais da Internet", "TasksApplicationCategory": "Aplicação", "TasksLibraryCategory": "Biblioteca", - "TasksMaintenanceCategory": "Manutenção" + "TasksMaintenanceCategory": "Manutenção", + "TaskCleanActivityLogDescription": "Apaga as entradas do registo de atividade anteriores à data configurada.", + "TaskCleanActivityLog": "Limpar registo de atividade", + "Undefined": "Indefinido", + "Forced": "Forçado", + "Default": "Padrão" } diff --git a/Emby.Server.Implementations/Localization/Core/pt.json b/Emby.Server.Implementations/Localization/Core/pt.json index 2079940cd1..b435672adf 100644 --- a/Emby.Server.Implementations/Localization/Core/pt.json +++ b/Emby.Server.Implementations/Localization/Core/pt.json @@ -61,7 +61,7 @@ "NameSeasonUnknown": "Temporada Desconhecida", "NameSeasonNumber": "Temporada {0}", "NameInstallFailed": "Falha na instalação de {0}", - "MusicVideos": "Videoclips", + "MusicVideos": "Videoclipes", "Music": "Música", "MixedContent": "Conteúdo Misto", "MessageServerConfigurationUpdated": "A configuração do servidor foi actualizada", @@ -112,5 +112,10 @@ "TaskUpdatePluginsDescription": "Download e instala as atualizações para plug-ins configurados para atualização automática.", "TaskRefreshPeopleDescription": "Atualiza os metadados para atores e diretores na tua biblioteca de media.", "TaskRefreshPeople": "Atualizar pessoas", - "TaskRefreshLibraryDescription": "Pesquisa a tua biblioteca de media por novos ficheiros e atualiza os metadados." + "TaskRefreshLibraryDescription": "Pesquisa a tua biblioteca de media por novos ficheiros e atualiza os metadados.", + "TaskCleanActivityLog": "Limpar registo de atividade", + "Undefined": "Indefinido", + "Forced": "Forçado", + "Default": "Predefinição", + "TaskCleanActivityLogDescription": "Apaga itens no registro com idade acima do que é configurado." } diff --git a/Emby.Server.Implementations/Localization/Core/ro.json b/Emby.Server.Implementations/Localization/Core/ro.json index bc008df3b4..510aac11c6 100644 --- a/Emby.Server.Implementations/Localization/Core/ro.json +++ b/Emby.Server.Implementations/Localization/Core/ro.json @@ -112,5 +112,10 @@ "TasksChannelsCategory": "Canale de pe Internet", "TasksApplicationCategory": "Aplicație", "TasksLibraryCategory": "Librărie", - "TasksMaintenanceCategory": "Mentenanță" + "TasksMaintenanceCategory": "Mentenanță", + "TaskCleanActivityLogDescription": "Șterge intrările din jurnalul de activitate mai vechi de data configurată.", + "TaskCleanActivityLog": "Curăță Jurnalul de Activitate", + "Undefined": "Nedefinit", + "Forced": "Forțat", + "Default": "Implicit" } diff --git a/Emby.Server.Implementations/Localization/Core/ru.json b/Emby.Server.Implementations/Localization/Core/ru.json index 95b93afb82..cd016b51b4 100644 --- a/Emby.Server.Implementations/Localization/Core/ru.json +++ b/Emby.Server.Implementations/Localization/Core/ru.json @@ -25,7 +25,7 @@ "HeaderLiveTV": "Эфир", "HeaderNextUp": "Очередное", "HeaderRecordingGroups": "Группы записей", - "HomeVideos": "Домашнее видео", + "HomeVideos": "Домашние видео", "Inherit": "Наследуемое", "ItemAddedWithName": "{0} - добавлено в медиатеку", "ItemRemovedWithName": "{0} - изъято из медиатеки", @@ -39,7 +39,7 @@ "MixedContent": "Смешанное содержимое", "Movies": "Кино", "Music": "Музыка", - "MusicVideos": "Музыкальные клипы", + "MusicVideos": "Муз. видео", "NameInstallFailed": "Установка {0} неудачна", "NameSeasonNumber": "Сезон {0}", "NameSeasonUnknown": "Сезон неопознан", @@ -75,7 +75,7 @@ "StartupEmbyServerIsLoading": "Jellyfin Server загружается. Повторите попытку в ближайшее время.", "SubtitleDownloadFailureForItem": "Субтитры к {0} не удалось загрузить", "SubtitleDownloadFailureFromForItem": "Субтитры к {1} не удалось загрузить с {0}", - "Sync": "Синхронизация", + "Sync": "Синхро", "System": "Система", "TvShows": "ТВ", "User": "Пользователь", @@ -89,14 +89,14 @@ "UserPolicyUpdatedWithName": "Политики пользователя {0} были обновлены", "UserStartedPlayingItemWithValues": "{0} - воспроизведение «{1}» на {2}", "UserStoppedPlayingItemWithValues": "{0} - воспроизведение остановлено «{1}» на {2}", - "ValueHasBeenAddedToLibrary": "{0} (добавлено в медиатеку)", - "ValueSpecialEpisodeName": "Специальный эпизод - {0}", + "ValueHasBeenAddedToLibrary": "{0} добавлено в медиатеку", + "ValueSpecialEpisodeName": "Спецэпизод - {0}", "VersionNumber": "Версия {0}", "TaskDownloadMissingSubtitles": "Загрузка отсутствующих субтитров", "TaskRefreshChannels": "Обновление каналов", "TaskCleanTranscode": "Очистка каталога перекодировки", "TaskUpdatePlugins": "Обновление плагинов", - "TaskRefreshPeople": "Обновление метаданных людей", + "TaskRefreshPeople": "Подновить людей", "TaskCleanLogs": "Очистка каталога журналов", "TaskRefreshLibrary": "Сканирование медиатеки", "TaskRefreshChapterImages": "Извлечение изображений сцен", @@ -109,9 +109,16 @@ "TaskRefreshChannelsDescription": "Обновляются данные интернет-каналов.", "TaskCleanTranscodeDescription": "Удаляются файлы перекодировки старше одного дня.", "TaskUpdatePluginsDescription": "Загружаются и устанавливаются обновления для плагинов, у которых включено автоматическое обновление.", - "TaskRefreshPeopleDescription": "Обновляются метаданные актеров и режиссёров в медиатеке.", + "TaskRefreshPeopleDescription": "Обновляются метаданные для актёров и режиссёров в медиатеке.", "TaskCleanLogsDescription": "Удаляются файлы журнала, возраст которых превышает {0} дн(я/ей).", "TaskRefreshLibraryDescription": "Сканируется медиатека на новые файлы и обновляются метаданные.", "TaskRefreshChapterImagesDescription": "Создаются эскизы для видео, которые содержат сцены.", - "TaskCleanCacheDescription": "Удаляются файлы кэша, которые больше не нужны системе." + "TaskCleanCacheDescription": "Удаляются файлы кэша, которые больше не нужны системе.", + "TaskCleanActivityLogDescription": "Удаляет записи журнала активности старше установленного возраста.", + "TaskCleanActivityLog": "Очистить журнал активности", + "Undefined": "Не определено", + "Forced": "Форсир-ые", + "Default": "По умолчанию", + "TaskOptimizeDatabaseDescription": "Сжимает базу данных и обрезает свободное место. Выполнение этой задачи после сканирования библиотеки или внесения других изменений, предполагающих модификации базы данных, может повысить производительность.", + "TaskOptimizeDatabase": "Оптимизировать базу данных" } diff --git a/Emby.Server.Implementations/Localization/Core/sk.json b/Emby.Server.Implementations/Localization/Core/sk.json index 8e50269444..ad90bd8134 100644 --- a/Emby.Server.Implementations/Localization/Core/sk.json +++ b/Emby.Server.Implementations/Localization/Core/sk.json @@ -2,7 +2,7 @@ "Albums": "Albumy", "AppDeviceValues": "Aplikácia: {0}, Zariadenie: {1}", "Application": "Aplikácia", - "Artists": "Umelci", + "Artists": "Interpreti", "AuthenticationSucceededWithUserName": "{0} úspešne overený", "Books": "Knihy", "CameraImageUploadedFrom": "Z {0} bola nahraná nová fotografia", @@ -15,13 +15,13 @@ "Favorites": "Obľúbené", "Folders": "Priečinky", "Genres": "Žánre", - "HeaderAlbumArtists": "Umelci albumu", + "HeaderAlbumArtists": "Interpreti albumu", "HeaderContinueWatching": "Pokračovať v pozeraní", "HeaderFavoriteAlbums": "Obľúbené albumy", - "HeaderFavoriteArtists": "Obľúbení umelci", + "HeaderFavoriteArtists": "Obľúbení interpreti", "HeaderFavoriteEpisodes": "Obľúbené epizódy", "HeaderFavoriteShows": "Obľúbené seriály", - "HeaderFavoriteSongs": "Obľúbené piesne", + "HeaderFavoriteSongs": "Obľúbené skladby", "HeaderLiveTV": "Živá TV", "HeaderNextUp": "Nasleduje", "HeaderRecordingGroups": "Skupiny nahrávok", @@ -33,7 +33,7 @@ "LabelRunningTimeValue": "Dĺžka: {0}", "Latest": "Najnovšie", "MessageApplicationUpdated": "Jellyfin Server bol aktualizovaný", - "MessageApplicationUpdatedTo": "Jellyfin Server bol aktualizový na verziu {0}", + "MessageApplicationUpdatedTo": "Jellyfin Server bol aktualizovaný na verziu {0}", "MessageNamedServerConfigurationUpdatedWithValue": "Sekcia {0} konfigurácie servera bola aktualizovaná", "MessageServerConfigurationUpdated": "Konfigurácia servera bola aktualizovaná", "MixedContent": "Zmiešaný obsah", @@ -71,7 +71,7 @@ "ScheduledTaskStartedWithName": "{0} zahájených", "ServerNameNeedsToBeRestarted": "{0} vyžaduje reštart", "Shows": "Seriály", - "Songs": "Piesne", + "Songs": "Skladby", "StartupEmbyServerIsLoading": "Jellyfin Server sa spúšťa. Prosím, skúste to o chvíľu znova.", "SubtitleDownloadFailureForItem": "Sťahovanie titulkov pre {0} zlyhalo", "SubtitleDownloadFailureFromForItem": "Sťahovanie titulkov z {0} pre {1} zlyhalo", @@ -89,29 +89,36 @@ "UserPolicyUpdatedWithName": "Používateľské zásady pre {0} boli aktualizované", "UserStartedPlayingItemWithValues": "{0} spustil prehrávanie {1} na {2}", "UserStoppedPlayingItemWithValues": "{0} ukončil prehrávanie {1} na {2}", - "ValueHasBeenAddedToLibrary": "{0} bol pridané do vašej knižnice médií", + "ValueHasBeenAddedToLibrary": "{0} bol pridaný do vašej knižnice médií", "ValueSpecialEpisodeName": "Špeciál - {0}", "VersionNumber": "Verzia {0}", "TaskDownloadMissingSubtitlesDescription": "Vyhľadá na internete chýbajúce titulky podľa toho, ako sú nakonfigurované metadáta.", "TaskDownloadMissingSubtitles": "Stiahnuť chýbajúce titulky", "TaskRefreshChannelsDescription": "Obnoví informácie o internetových kanáloch.", "TaskRefreshChannels": "Obnoviť kanály", - "TaskCleanTranscodeDescription": "Vymaže súbory transkódovania, ktoré sú staršie ako jeden deň.", - "TaskCleanTranscode": "Vyčistiť priečinok pre transkódovanie", + "TaskCleanTranscodeDescription": "Vymaže prekódované súbory, ktoré sú staršie ako jeden deň.", + "TaskCleanTranscode": "Vyčistiť priečinok pre prekódovanie", "TaskUpdatePluginsDescription": "Stiahne a nainštaluje aktualizácie pre zásuvné moduly, ktoré sú nastavené tak, aby sa aktualizovali automaticky.", "TaskUpdatePlugins": "Aktualizovať zásuvné moduly", "TaskRefreshPeopleDescription": "Aktualizuje metadáta pre hercov a režisérov vo vašej mediálnej knižnici.", "TaskRefreshPeople": "Obnoviť osoby", - "TaskCleanLogsDescription": "Vymaže log súbory, ktoré su staršie ako {0} deň/dni/dní.", + "TaskCleanLogsDescription": "Vymaže log súbory, ktoré sú staršie ako {0} deň/dni/dní.", "TaskCleanLogs": "Vyčistiť priečinok s logmi", "TaskRefreshLibraryDescription": "Hľadá vo vašej mediálnej knižnici nové súbory a obnovuje metadáta.", "TaskRefreshLibrary": "Prehľadávať knižnicu medií", "TaskRefreshChapterImagesDescription": "Vytvorí náhľady pre videá, ktoré majú kapitoly.", "TaskRefreshChapterImages": "Extrahovať obrázky kapitol", - "TaskCleanCacheDescription": "Vymaže cache súbory, ktoré nie sú už potrebné pre systém.", - "TaskCleanCache": "Vyčistiť Cache priečinok", + "TaskCleanCacheDescription": "Vymaže súbory vyrovnávacej pamäte, ktoré už nie sú potrebné pre systém.", + "TaskCleanCache": "Vyčistiť priečinok vyrovnávacej pamäte", "TasksChannelsCategory": "Internetové kanály", "TasksApplicationCategory": "Aplikácia", "TasksLibraryCategory": "Knižnica", - "TasksMaintenanceCategory": "Údržba" + "TasksMaintenanceCategory": "Údržba", + "TaskCleanActivityLogDescription": "Vymaže záznamy aktivít v logu, ktoré sú staršie ako zadaná doba.", + "TaskCleanActivityLog": "Vyčistiť log aktivít", + "Undefined": "Nedefinované", + "Forced": "Vynútené", + "Default": "Predvolené", + "TaskOptimizeDatabaseDescription": "Zmenší databázu a odstráni prázdne miesto. Spustenie tejto úlohy po skenovaní knižnice alebo po iných zmenách zahŕňajúcich úpravy databáze môže zlepšiť výkon.", + "TaskOptimizeDatabase": "Optimalizovať databázu" } diff --git a/Emby.Server.Implementations/Localization/Core/sl-SI.json b/Emby.Server.Implementations/Localization/Core/sl-SI.json index ff4b9e84fd..a6fcbd3e29 100644 --- a/Emby.Server.Implementations/Localization/Core/sl-SI.json +++ b/Emby.Server.Implementations/Localization/Core/sl-SI.json @@ -3,20 +3,20 @@ "AppDeviceValues": "Aplikacija: {0}, Naprava: {1}", "Application": "Aplikacija", "Artists": "Izvajalci", - "AuthenticationSucceededWithUserName": "{0} preverjanje pristnosti uspešno", + "AuthenticationSucceededWithUserName": "{0} se je uspešno prijavil", "Books": "Knjige", - "CameraImageUploadedFrom": "Nova fotografija je bila naložena z {0}", + "CameraImageUploadedFrom": "Nova fotografija je bila naložena iz {0}", "Channels": "Kanali", "ChapterNameValue": "Poglavje {0}", "Collections": "Zbirke", "DeviceOfflineWithName": "{0} je prekinil povezavo", "DeviceOnlineWithName": "{0} je povezan", - "FailedLoginAttemptWithUserName": "Neuspešen poskus prijave z {0}", + "FailedLoginAttemptWithUserName": "Neuspešen poskus prijave iz {0}", "Favorites": "Priljubljeno", "Folders": "Mape", "Genres": "Zvrsti", "HeaderAlbumArtists": "Izvajalci albuma", - "HeaderContinueWatching": "Nadaljuj gledanje", + "HeaderContinueWatching": "Nadaljuj ogled", "HeaderFavoriteAlbums": "Priljubljeni albumi", "HeaderFavoriteArtists": "Priljubljeni izvajalci", "HeaderFavoriteEpisodes": "Priljubljene epizode", @@ -32,23 +32,23 @@ "LabelIpAddressValue": "IP naslov: {0}", "LabelRunningTimeValue": "Čas trajanja: {0}", "Latest": "Najnovejše", - "MessageApplicationUpdated": "Jellyfin Server je bil posodobljen", - "MessageApplicationUpdatedTo": "Jellyfin Server je bil posodobljen na {0}", - "MessageNamedServerConfigurationUpdatedWithValue": "Oddelek nastavitve strežnika {0} je bil posodobljen", + "MessageApplicationUpdated": "Jellyfin strežnik je bil posodobljen", + "MessageApplicationUpdatedTo": "Jellyfin strežnik je bil posodobljen na {0}", + "MessageNamedServerConfigurationUpdatedWithValue": "Oddelek nastavitev {0} je bil posodobljen", "MessageServerConfigurationUpdated": "Nastavitve strežnika so bile posodobljene", - "MixedContent": "Razne vsebine", + "MixedContent": "Mešane vsebine", "Movies": "Filmi", "Music": "Glasba", "MusicVideos": "Glasbeni videi", "NameInstallFailed": "{0} namestitev neuspešna", "NameSeasonNumber": "Sezona {0}", - "NameSeasonUnknown": "Season neznana", + "NameSeasonUnknown": "Neznana sezona", "NewVersionIsAvailable": "Nova različica Jellyfin strežnika je na voljo za prenos.", "NotificationOptionApplicationUpdateAvailable": "Posodobitev aplikacije je na voljo", "NotificationOptionApplicationUpdateInstalled": "Posodobitev aplikacije je bila nameščena", - "NotificationOptionAudioPlayback": "Predvajanje zvoka začeto", - "NotificationOptionAudioPlaybackStopped": "Predvajanje zvoka zaustavljeno", - "NotificationOptionCameraImageUploaded": "Posnetek kamere naložen", + "NotificationOptionAudioPlayback": "Predvajanje zvoka se je začelo", + "NotificationOptionAudioPlaybackStopped": "Predvajanje zvoka se je ustavilo", + "NotificationOptionCameraImageUploaded": "Fotografija naložena", "NotificationOptionInstallationFailed": "Namestitev neuspešna", "NotificationOptionNewLibraryContent": "Nove vsebine dodane", "NotificationOptionPluginError": "Napaka dodatka", @@ -56,41 +56,41 @@ "NotificationOptionPluginUninstalled": "Dodatek odstranjen", "NotificationOptionPluginUpdateInstalled": "Posodobitev dodatka nameščena", "NotificationOptionServerRestartRequired": "Potreben je ponovni zagon strežnika", - "NotificationOptionTaskFailed": "Razporejena naloga neuspešna", + "NotificationOptionTaskFailed": "Načrtovano opravilo neuspešno", "NotificationOptionUserLockedOut": "Uporabnik zaklenjen", "NotificationOptionVideoPlayback": "Predvajanje videa se je začelo", "NotificationOptionVideoPlaybackStopped": "Predvajanje videa se je ustavilo", "Photos": "Fotografije", "Playlists": "Seznami predvajanja", - "Plugin": "Plugin", + "Plugin": "Dodatek", "PluginInstalledWithName": "{0} je bil nameščen", "PluginUninstalledWithName": "{0} je bil odstranjen", "PluginUpdatedWithName": "{0} je bil posodobljen", - "ProviderValue": "Provider: {0}", + "ProviderValue": "Ponudnik: {0}", "ScheduledTaskFailedWithName": "{0} ni uspelo", "ScheduledTaskStartedWithName": "{0} začeto", "ServerNameNeedsToBeRestarted": "{0} mora biti ponovno zagnan", "Shows": "Serije", "Songs": "Pesmi", - "StartupEmbyServerIsLoading": "Jellyfin Server se nalaga. Poskusi ponovno kasneje.", + "StartupEmbyServerIsLoading": "Jellyfin strežnik se zaganja. Poskusite ponovno kasneje.", "SubtitleDownloadFailureForItem": "Subtitles failed to download for {0}", "SubtitleDownloadFailureFromForItem": "Neuspešen prenos podnapisov iz {0} za {1}", "Sync": "Sinhroniziraj", - "System": "System", + "System": "Sistem", "TvShows": "TV serije", - "User": "User", + "User": "Uporabnik", "UserCreatedWithName": "Uporabnik {0} je bil ustvarjen", "UserDeletedWithName": "Uporabnik {0} je bil izbrisan", "UserDownloadingItemWithValues": "{0} prenaša {1}", "UserLockedOutWithName": "Uporabnik {0} je bil zaklenjen", "UserOfflineFromDevice": "{0} je prekinil povezavo z {1}", - "UserOnlineFromDevice": "{0} je aktiven iz {1}", + "UserOnlineFromDevice": "{0} je aktiven na {1}", "UserPasswordChangedWithName": "Geslo za uporabnika {0} je bilo spremenjeno", "UserPolicyUpdatedWithName": "Pravilnik uporabe je bil posodobljen za uporabnika {0}", "UserStartedPlayingItemWithValues": "{0} predvaja {1} na {2}", "UserStoppedPlayingItemWithValues": "{0} je nehal predvajati {1} na {2}", "ValueHasBeenAddedToLibrary": "{0} je bil dodan vaši knjižnici", - "ValueSpecialEpisodeName": "Poseben - {0}", + "ValueSpecialEpisodeName": "Bonus - {0}", "VersionNumber": "Različica {0}", "TaskDownloadMissingSubtitles": "Prenesi manjkajoče podnapise", "TaskRefreshChannelsDescription": "Osveži podatke spletnih kanalov.", @@ -102,7 +102,7 @@ "TaskRefreshPeopleDescription": "Osveži metapodatke za igralce in režiserje v vaši knjižnici.", "TaskRefreshPeople": "Osveži osebe", "TaskCleanLogsDescription": "Izbriše dnevniške datoteke starejše od {0} dni.", - "TaskCleanLogs": "Počisti mapo dnevnika", + "TaskCleanLogs": "Počisti mapo dnevnikov", "TaskRefreshLibraryDescription": "Preišče vašo knjižnico za nove datoteke in osveži metapodatke.", "TaskRefreshLibrary": "Preišči knjižnico predstavnosti", "TaskRefreshChapterImagesDescription": "Ustvari sličice za poglavja videoposnetkov.", @@ -113,5 +113,12 @@ "TasksApplicationCategory": "Aplikacija", "TasksLibraryCategory": "Knjižnica", "TasksMaintenanceCategory": "Vzdrževanje", - "TaskDownloadMissingSubtitlesDescription": "Na podlagi nastavitev metapodatkov poišče manjkajoče podnapise na internetu." + "TaskDownloadMissingSubtitlesDescription": "Na podlagi nastavitev metapodatkov poišče manjkajoče podnapise na internetu.", + "TaskCleanActivityLogDescription": "Počisti zapise v dnevniku aktivnosti starejše od nastavljenega časa.", + "TaskCleanActivityLog": "Počisti dnevnik aktivnosti", + "Undefined": "Nedoločen", + "Forced": "Prisilno", + "Default": "Privzeto", + "TaskOptimizeDatabaseDescription": "Stisne bazo podatkov in uredi prazen prostor. Zagon tega opravila po iskanju predstavnosti ali drugih spremembah ki vplivajo na bazo podatkov lahko izboljša hitrost delovanja.", + "TaskOptimizeDatabase": "Optimiziraj bazo podatkov" } diff --git a/Emby.Server.Implementations/Localization/Core/sq.json b/Emby.Server.Implementations/Localization/Core/sq.json index ecca5af4cc..e36fdc43db 100644 --- a/Emby.Server.Implementations/Localization/Core/sq.json +++ b/Emby.Server.Implementations/Localization/Core/sq.json @@ -42,8 +42,8 @@ "Sync": "Sinkronizo", "SubtitleDownloadFailureFromForItem": "Titrat deshtuan të shkarkohen nga {0} për {1}", "StartupEmbyServerIsLoading": "Serveri Jellyfin po ngarkohet. Ju lutemi provoni përseri pas pak.", - "Songs": "Këngë", - "Shows": "Seriale", + "Songs": "Këngët", + "Shows": "Serialet", "ServerNameNeedsToBeRestarted": "{0} duhet të ristartoj", "ScheduledTaskStartedWithName": "{0} filloi", "ScheduledTaskFailedWithName": "{0} dështoi", @@ -74,9 +74,9 @@ "NameSeasonUnknown": "Sezon i panjohur", "NameSeasonNumber": "Sezoni {0}", "NameInstallFailed": "Instalimi i {0} dështoi", - "MusicVideos": "Video muzikore", + "MusicVideos": "Videot muzikore", "Music": "Muzikë", - "Movies": "Filma", + "Movies": "Filmat", "MixedContent": "Përmbajtje e përzier", "MessageServerConfigurationUpdated": "Konfigurimet e serverit u përditësuan", "MessageNamedServerConfigurationUpdatedWithValue": "Seksioni i konfigurimit të serverit {0} u përditësua", @@ -97,20 +97,27 @@ "HeaderFavoriteAlbums": "Albumet e preferuar", "HeaderContinueWatching": "Vazhdo të shikosh", "HeaderAlbumArtists": "Artistët e albumeve", - "Genres": "Zhanre", - "Folders": "Dosje", - "Favorites": "Të preferuara", + "Genres": "Zhanret", + "Folders": "Skedarët", + "Favorites": "Të preferuarat", "FailedLoginAttemptWithUserName": "Përpjekja për hyrje dështoi nga {0}", "DeviceOnlineWithName": "{0} u lidh", "DeviceOfflineWithName": "{0} u shkëput", - "Collections": "Koleksione", + "Collections": "Koleksionet", "ChapterNameValue": "Kapituj", - "Channels": "Kanale", + "Channels": "Kanalet", "CameraImageUploadedFrom": "Një foto e re nga kamera u ngarkua nga {0}", - "Books": "Libra", + "Books": "Librat", "AuthenticationSucceededWithUserName": "{0} u identifikua me sukses", - "Artists": "Artistë", + "Artists": "Artistët", "Application": "Aplikacioni", "AppDeviceValues": "Aplikacioni: {0}, Pajisja: {1}", - "Albums": "Albumet" + "Albums": "Albumet", + "TaskCleanActivityLogDescription": "Pastro të dhënat mbi aktivitetin më të vjetra sesa koha e përcaktuar.", + "TaskCleanActivityLog": "Pastro të dhënat mbi aktivitetin", + "Undefined": "I papërcaktuar", + "Forced": "I detyruar", + "Default": "Parazgjedhur", + "TaskOptimizeDatabaseDescription": "Kompakton bazën e të dhënave dhe shkurton hapësirën e lirë. Drejtimi i kësaj detyre pasi skanoni bibliotekën ose bëni ndryshime të tjera që nënkuptojnë modifikime të bazës së të dhënave mund të përmirësojë performancën.", + "TaskOptimizeDatabase": "Optimizo databazën" } diff --git a/Emby.Server.Implementations/Localization/Core/sr.json b/Emby.Server.Implementations/Localization/Core/sr.json index 2b1eccfaf3..15fb34186b 100644 --- a/Emby.Server.Implementations/Localization/Core/sr.json +++ b/Emby.Server.Implementations/Localization/Core/sr.json @@ -4,11 +4,11 @@ "VersionNumber": "Верзија {0}", "ValueSpecialEpisodeName": "Специјал - {0}", "ValueHasBeenAddedToLibrary": "{0} је додато у вашу медијску библиотеку", - "UserStoppedPlayingItemWithValues": "{0} заврши пуштање {1} на {2}", + "UserStoppedPlayingItemWithValues": "{0} завршио пуштање {1} на {2}", "UserStartedPlayingItemWithValues": "{0} пушта {1} на {2}", "UserPasswordChangedWithName": "Лозинка је промењена за корисника {0}", "UserOnlineFromDevice": "{0} је на вези од {1}", - "UserOfflineFromDevice": "{0} се одвезао са {1}", + "UserOfflineFromDevice": "{0} је прекинуо/а везу са {1}", "UserLockedOutWithName": "Корисник {0} је закључан", "UserDownloadingItemWithValues": "{0} преузима {1}", "UserDeletedWithName": "Корисник {0} је обрисан", @@ -41,7 +41,7 @@ "NotificationOptionPluginError": "Грешка прикључка", "NotificationOptionNewLibraryContent": "Додат нови садржај", "NotificationOptionInstallationFailed": "Неуспела инсталација", - "NotificationOptionCameraImageUploaded": "Слика са камере послата", + "NotificationOptionCameraImageUploaded": "Слика са камере отпремљена", "NotificationOptionAudioPlaybackStopped": "Заустављено пуштање звука", "NotificationOptionAudioPlayback": "Покренуто пуштање звука", "NotificationOptionApplicationUpdateInstalled": "Ажурирање инсталирано", @@ -66,7 +66,7 @@ "Inherit": "Наследи", "HomeVideos": "Кућни видео", "HeaderRecordingGroups": "Групе снимања", - "HeaderNextUp": "Следеће горе", + "HeaderNextUp": "Следи", "HeaderLiveTV": "ТВ уживо", "HeaderFavoriteSongs": "Омиљене песме", "HeaderFavoriteShows": "Омиљене серије", @@ -79,17 +79,17 @@ "Folders": "Фасцикле", "Favorites": "Омиљено", "FailedLoginAttemptWithUserName": "Неуспела пријава са {0}", - "DeviceOnlineWithName": "{0} се повезао", + "DeviceOnlineWithName": "{0} је повезан", "DeviceOfflineWithName": "{0} је прекинуо везу", "Collections": "Колекције", "ChapterNameValue": "Поглавље {0}", "Channels": "Канали", - "CameraImageUploadedFrom": "Нова фотографија је послата са {0}", + "CameraImageUploadedFrom": "Нова фотографија је учитана са {0}", "Books": "Књиге", - "AuthenticationSucceededWithUserName": "{0} успешно проверено", - "Artists": "Извођач", + "AuthenticationSucceededWithUserName": "{0} Успешна аутентикација", + "Artists": "Извођачи", "Application": "Апликација", - "AppDeviceValues": "Апл: {0}, уређај: {1}", + "AppDeviceValues": "Апликација: {0}, Уређај: {1}", "Albums": "Албуми", "TaskDownloadMissingSubtitlesDescription": "Претражује интернет за недостајуће титлове на основу конфигурације метаподатака.", "TaskDownloadMissingSubtitles": "Преузмите недостајуће титлове", @@ -100,11 +100,11 @@ "TaskUpdatePluginsDescription": "Преузима и инсталира исправке за додатке који су конфигурисани за аутоматско ажурирање.", "TaskUpdatePlugins": "Ажурирајте додатке", "TaskRefreshPeopleDescription": "Ажурира метаподатке за глумце и редитеље у вашој медијској библиотеци.", - "TaskRefreshPeople": "Освежите људе", + "TaskRefreshPeople": "Освежите кориснике", "TaskCleanLogsDescription": "Брише логове старије од {0} дана.", "TaskCleanLogs": "Очистите директоријум логова", "TaskRefreshLibraryDescription": "Скенира вашу медијску библиотеку за нове датотеке и освежава метаподатке.", - "TaskRefreshLibrary": "Скенирај Библиотеку Медија", + "TaskRefreshLibrary": "Скенирај библиотеку медија", "TaskRefreshChapterImagesDescription": "Ствара сличице за видео записе који имају поглавља.", "TaskRefreshChapterImages": "Издвоји слике из поглавља", "TaskCleanCacheDescription": "Брише Кеш фајлове који више нису потребни систему.", @@ -112,5 +112,10 @@ "TasksChannelsCategory": "Интернет канали", "TasksApplicationCategory": "Апликација", "TasksLibraryCategory": "Библиотека", - "TasksMaintenanceCategory": "Одржавање" + "TasksMaintenanceCategory": "Одржавање", + "TaskCleanActivityLogDescription": "Брише историју активности старију од конфигурисаног броја година.", + "TaskCleanActivityLog": "Очисти историју активности", + "Undefined": "Недефинисано", + "Forced": "Принудно", + "Default": "Подразумевано" } diff --git a/Emby.Server.Implementations/Localization/Core/sv.json b/Emby.Server.Implementations/Localization/Core/sv.json index bea294ba2e..88b182f8d9 100644 --- a/Emby.Server.Implementations/Localization/Core/sv.json +++ b/Emby.Server.Implementations/Localization/Core/sv.json @@ -39,7 +39,7 @@ "MixedContent": "Blandat innehåll", "Movies": "Filmer", "Music": "Musik", - "MusicVideos": "Musikvideos", + "MusicVideos": "Musikvideor", "NameInstallFailed": "{0} installationen misslyckades", "NameSeasonNumber": "Säsong {0}", "NameSeasonUnknown": "Okänd säsong", @@ -113,5 +113,11 @@ "TasksApplicationCategory": "Applikation", "TasksLibraryCategory": "Bibliotek", "TasksMaintenanceCategory": "Underhåll", - "TaskRefreshPeople": "Uppdatera Personer" + "TaskRefreshPeople": "Uppdatera Personer", + "TaskCleanActivityLogDescription": "Radera aktivitets logg inlägg som är äldre än definerad ålder.", + "TaskCleanActivityLog": "Rensa Aktivitets Logg", + "Undefined": "odefinierad", + "Forced": "Tvingad", + "Default": "Standard", + "TaskOptimizeDatabase": "Optimera databasen" } diff --git a/Emby.Server.Implementations/Localization/Core/ta.json b/Emby.Server.Implementations/Localization/Core/ta.json index ae38f45e1c..d6e9aa8e5a 100644 --- a/Emby.Server.Implementations/Localization/Core/ta.json +++ b/Emby.Server.Implementations/Localization/Core/ta.json @@ -21,7 +21,7 @@ "Inherit": "மரபுரிமையாகப் பெறு", "HeaderRecordingGroups": "பதிவு குழுக்கள்", "Folders": "கோப்புறைகள்", - "FailedLoginAttemptWithUserName": "{0} இலிருந்து உள்நுழைவு முயற்சி தோல்வியடைந்தது", + "FailedLoginAttemptWithUserName": "{0} இல் இருந்து உள்நுழைவு முயற்சி தோல்வியடைந்தது", "DeviceOnlineWithName": "{0} இணைக்கப்பட்டது", "DeviceOfflineWithName": "{0} துண்டிக்கப்பட்டது", "Collections": "தொகுப்புகள்", @@ -69,7 +69,7 @@ "NameSeasonUnknown": "அறியப்படாத பருவம்", "NameSeasonNumber": "பருவம் {0}", "NameInstallFailed": "{0} நிறுவல் தோல்வியடைந்தது", - "MusicVideos": "இசைப்படங்கள்", + "MusicVideos": "இசை கானொளி", "Music": "இசை", "Movies": "திரைப்படங்கள்", "Latest": "புதியவை", @@ -99,9 +99,9 @@ "MessageNamedServerConfigurationUpdatedWithValue": "சேவையக உள்ளமைவு பிரிவு {0} புதுப்பிக்கப்பட்டது", "TaskCleanCacheDescription": "கணினிக்கு இனி தேவைப்படாத தற்காலிக கோப்புகளை நீக்கு.", "UserOfflineFromDevice": "{0} இலிருந்து {1} துண்டிக்கப்பட்டுள்ளது", - "SubtitleDownloadFailureFromForItem": "வசன வரிகள் {0} இலிருந்து {1} க்கு பதிவிறக்கத் தவறிவிட்டன", + "SubtitleDownloadFailureFromForItem": "வசன வரிகள் {0} இல் இருந்து {1} க்கு பதிவிறக்கத் தவறிவிட்டன", "TaskDownloadMissingSubtitlesDescription": "மீத்தரவு உள்ளமைவின் அடிப்படையில் வசன வரிகள் காணாமல் போனதற்கு இணையத்தைத் தேடுகிறது.", - "TaskCleanTranscodeDescription": "டிரான்ஸ்கோட் கோப்புகளை ஒரு நாளுக்கு மேல் பழையதாக நீக்குகிறது.", + "TaskCleanTranscodeDescription": "ஒரு நாளைக்கு மேற்பட்ட பழைய டிரான்ஸ்கோட் கோப்புகளை நீக்குகிறது.", "TaskUpdatePluginsDescription": "தானாகவே புதுப்பிக்க கட்டமைக்கப்பட்ட உட்செருகிகளுக்கான புதுப்பிப்புகளை பதிவிறக்குகிறது மற்றும் நிறுவுகிறது.", "TaskRefreshPeopleDescription": "உங்கள் ஊடக நூலகத்தில் உள்ள நடிகர்கள் மற்றும் இயக்குனர்களுக்கான மீத்தரவை புதுப்பிக்கும்.", "TaskCleanLogsDescription": "{0} நாட்களுக்கு மேல் இருக்கும் பதிவு கோப்புகளை நீக்கும்.", @@ -112,5 +112,12 @@ "UserOnlineFromDevice": "{1} இருந்து {0} ஆன்லைன்", "HomeVideos": "முகப்பு வீடியோக்கள்", "UserStoppedPlayingItemWithValues": "{0} {2} இல் {1} முடித்துவிட்டது", - "UserStartedPlayingItemWithValues": "{0} {2}இல் {1} ஐ இயக்குகிறது" + "UserStartedPlayingItemWithValues": "{0} {2}இல் {1} ஐ இயக்குகிறது", + "TaskCleanActivityLogDescription": "உள்ளமைக்கப்பட்ட வயதை விட பழைய செயல்பாட்டு பதிவு உள்ளீடுகளை நீக்குகிறது.", + "TaskCleanActivityLog": "செயல்பாட்டு பதிவை அழி", + "Undefined": "வரையறுக்கப்படாத", + "Forced": "கட்டாயப்படுத்தப்பட்டது", + "Default": "இயல்புநிலை", + "TaskOptimizeDatabaseDescription": "தரவுத்தளத்தை சுருக்கி, இலவச இடத்தை குறைக்கிறது. நூலகத்தை ஸ்கேன் செய்தபின் அல்லது தரவுத்தள மாற்றங்களைக் குறிக்கும் பிற மாற்றங்களைச் செய்தபின் இந்த பணியை இயக்குவது செயல்திறனை மேம்படுத்தக்கூடும்.", + "TaskOptimizeDatabase": "தரவுத்தளத்தை மேம்படுத்தவும்" } diff --git a/Emby.Server.Implementations/Localization/Core/th.json b/Emby.Server.Implementations/Localization/Core/th.json index 71dd2c7a30..e260104236 100644 --- a/Emby.Server.Implementations/Localization/Core/th.json +++ b/Emby.Server.Implementations/Localization/Core/th.json @@ -50,7 +50,7 @@ "HeaderFavoriteArtists": "ศิลปินที่ชื่นชอบ", "HeaderFavoriteAlbums": "อัมบั้มที่ชื่นชอบ", "HeaderContinueWatching": "ดูต่อ", - "HeaderAlbumArtists": "อัลบั้มศิลปิน", + "HeaderAlbumArtists": "ศิลปินอัลบั้ม", "Genres": "ประเภท", "Folders": "โฟลเดอร์", "Favorites": "รายการโปรด", @@ -112,5 +112,10 @@ "System": "ระบบ", "Sync": "ซิงค์", "SubtitleDownloadFailureFromForItem": "ไม่สามารถดาวน์โหลดคำบรรยายจาก {0} สำหรับ {1} ได้", - "StartupEmbyServerIsLoading": "กำลังโหลดเซิร์ฟเวอร์ Jellyfin โปรดลองอีกครั้งในอีกสักครู่" + "StartupEmbyServerIsLoading": "กำลังโหลดเซิร์ฟเวอร์ Jellyfin โปรดลองอีกครั้งในอีกสักครู่", + "Default": "ค่าเริ่มต้น", + "TaskCleanActivityLogDescription": "ลบบันทึกกิจกรรมที่เก่ากว่าค่าที่กำหนดไว้", + "TaskCleanActivityLog": "ล้างบันทึกกิจกรรม", + "Undefined": "ไม่ได้กำหนด", + "Forced": "บังคับใช้" } diff --git a/Emby.Server.Implementations/Localization/Core/tr.json b/Emby.Server.Implementations/Localization/Core/tr.json index 1e5f2cf198..771c91d59f 100644 --- a/Emby.Server.Implementations/Localization/Core/tr.json +++ b/Emby.Server.Implementations/Localization/Core/tr.json @@ -8,7 +8,7 @@ "CameraImageUploadedFrom": "{0} 'den yeni bir kamera resmi yüklendi", "Channels": "Kanallar", "ChapterNameValue": "Bölüm {0}", - "Collections": "Koleksiyonlar", + "Collections": "Koleksiyon", "DeviceOfflineWithName": "{0} bağlantısı kesildi", "DeviceOnlineWithName": "{0} bağlı", "FailedLoginAttemptWithUserName": "{0} adresinden giriş başarısız oldu", @@ -23,7 +23,7 @@ "HeaderFavoriteShows": "Favori Diziler", "HeaderFavoriteSongs": "Favori Şarkılar", "HeaderLiveTV": "Canlı TV", - "HeaderNextUp": "Sonraki hafta", + "HeaderNextUp": "Gelecek Hafta", "HeaderRecordingGroups": "Kayıt Grupları", "HomeVideos": "Ev videoları", "Inherit": "Devral", @@ -43,7 +43,7 @@ "NameInstallFailed": "{0} kurulumu başarısız", "NameSeasonNumber": "Sezon {0}", "NameSeasonUnknown": "Bilinmeyen Sezon", - "NewVersionIsAvailable": "Jellyfin Sunucusunun yeni bir versiyonu indirmek için hazır.", + "NewVersionIsAvailable": "Jellyfin Sunucusunun yeni bir sürümü indirmek için hazır.", "NotificationOptionApplicationUpdateAvailable": "Uygulama güncellemesi mevcut", "NotificationOptionApplicationUpdateInstalled": "Uygulama güncellemesi yüklendi", "NotificationOptionAudioPlayback": "Ses çalma başladı", @@ -75,7 +75,7 @@ "StartupEmbyServerIsLoading": "Jellyfin Sunucusu yükleniyor. Lütfen kısa süre sonra tekrar deneyin.", "SubtitleDownloadFailureForItem": "Subtitles failed to download for {0}", "SubtitleDownloadFailureFromForItem": "{1} için alt yazılar {0} 'dan indirilemedi", - "Sync": "Eşitle", + "Sync": "Eşzamanlama", "System": "Sistem", "TvShows": "Diziler", "User": "Kullanıcı", @@ -89,29 +89,36 @@ "UserPolicyUpdatedWithName": "Kullanıcı politikası {0} için güncellendi", "UserStartedPlayingItemWithValues": "{0}, {2} cihazında {1} izliyor", "UserStoppedPlayingItemWithValues": "{0}, {2} cihazında {1} izlemeyi bitirdi", - "ValueHasBeenAddedToLibrary": "Medya kitaplığınıza {0} eklendi", + "ValueHasBeenAddedToLibrary": "Medya kütüphanenize {0} eklendi", "ValueSpecialEpisodeName": "Özel - {0}", - "VersionNumber": "Versiyon {0}", + "VersionNumber": "Sürüm {0}", "TaskCleanCache": "Geçici dosya klasörünü temizle", "TasksChannelsCategory": "İnternet kanalları", "TasksApplicationCategory": "Uygulama", "TasksLibraryCategory": "Kütüphane", - "TasksMaintenanceCategory": "Onarım", + "TasksMaintenanceCategory": "Bakım", "TaskRefreshPeopleDescription": "Medya kütüphanenizdeki videoların oyuncu ve yönetmen bilgilerini günceller.", "TaskDownloadMissingSubtitlesDescription": "Metadata ayarlarını baz alarak eksik altyazıları internette arar.", "TaskDownloadMissingSubtitles": "Eksik altyazıları indir", "TaskRefreshChannelsDescription": "Internet kanal bilgilerini yenile.", "TaskRefreshChannels": "Kanalları Yenile", - "TaskCleanTranscodeDescription": "Bir günü dolmuş dönüştürme bilgisi içeren dosyaları siler.", + "TaskCleanTranscodeDescription": "Bir günden daha eski dönüştürme dosyalarını siler.", "TaskCleanTranscode": "Dönüşüm Dizinini Temizle", "TaskUpdatePluginsDescription": "Otomatik güncellenmeye ayarlanmış eklentilerin güncellemelerini indirir ve kurar.", "TaskUpdatePlugins": "Eklentileri Güncelle", "TaskRefreshPeople": "Kullanıcıları Yenile", - "TaskCleanLogsDescription": "{0} günden eski log dosyalarını siler.", - "TaskCleanLogs": "Log Dizinini Temizle", - "TaskRefreshLibraryDescription": "Medya kütüphanenize eklenen yeni dosyaları arar ve bilgileri yeniler.", + "TaskCleanLogsDescription": "{0} günden eski günlük dosyalarını siler.", + "TaskCleanLogs": "Günlük Dizinini Temizle", + "TaskRefreshLibraryDescription": "Medya kütüphanenize eklenen yeni dosyaları arar ve ortam bilgilerini yeniler.", "TaskRefreshLibrary": "Medya Kütüphanesini Tara", "TaskRefreshChapterImagesDescription": "Sahnelere ayrılmış videolar için küçük resimler oluştur.", "TaskRefreshChapterImages": "Bölüm Resimlerini Çıkar", - "TaskCleanCacheDescription": "Sistem tarafından artık ihtiyaç duyulmayan önbellek dosyalarını siler." + "TaskCleanCacheDescription": "Sistem tarafından artık ihtiyaç duyulmayan önbellek dosyalarını siler.", + "TaskCleanActivityLog": "Etkinlik Günlüğünü Temizle", + "TaskCleanActivityLogDescription": "Yapılandırılan tarihten daha eski olan etkinlik günlüğü girişlerini siler.", + "Undefined": "Bilinmeyen", + "Default": "Varsayılan", + "Forced": "Zorla", + "TaskOptimizeDatabaseDescription": "Veritabanını sıkıştırır ve boş alanı keser. Kitaplığı taradıktan sonra veya veritabanında değişiklik anlamına gelen diğer işlemleri yaptıktan sonra bu görevi çalıştırmak performansı artırabilir.", + "TaskOptimizeDatabase": "Veritabanını optimize et" } diff --git a/Emby.Server.Implementations/Localization/Core/uk.json b/Emby.Server.Implementations/Localization/Core/uk.json index 06cc5f633e..5a2069df56 100644 --- a/Emby.Server.Implementations/Localization/Core/uk.json +++ b/Emby.Server.Implementations/Localization/Core/uk.json @@ -1,5 +1,5 @@ { - "MusicVideos": "Музичні кліпи", + "MusicVideos": "Музичні відеокліпи", "Music": "Музика", "Movies": "Фільми", "MessageApplicationUpdatedTo": "Jellyfin Server оновлено до версії {0}", @@ -27,7 +27,7 @@ "Channels": "Канали", "CameraImageUploadedFrom": "Нова фотографія завантажена з {0}", "Books": "Книги", - "AuthenticationSucceededWithUserName": "{0} успішно авторизований", + "AuthenticationSucceededWithUserName": "{0} успішно автентифіковано", "Artists": "Виконавці", "Application": "Додаток", "AppDeviceValues": "Додаток: {0}, Пристрій: {1}", @@ -112,5 +112,10 @@ "MessageServerConfigurationUpdated": "Конфігурація сервера оновлена", "MessageNamedServerConfigurationUpdatedWithValue": "Розділ конфігурації сервера {0} оновлено", "Inherit": "Успадкувати", - "HeaderRecordingGroups": "Групи запису" + "HeaderRecordingGroups": "Групи запису", + "Forced": "Форсовані", + "TaskCleanActivityLogDescription": "Видаляє старші за встановлений термін записи з журналу активності.", + "TaskCleanActivityLog": "Очистити журнал активності", + "Undefined": "Не визначено", + "Default": "За замовчуванням" } diff --git a/Emby.Server.Implementations/Localization/Core/ur_PK.json b/Emby.Server.Implementations/Localization/Core/ur_PK.json index fa7b2d4d0c..5d6d0775c4 100644 --- a/Emby.Server.Implementations/Localization/Core/ur_PK.json +++ b/Emby.Server.Implementations/Localization/Core/ur_PK.json @@ -8,7 +8,7 @@ "Collections": "مجموعہ", "Folders": "فولڈرز", "HeaderLiveTV": "براہ راست ٹی وی", - "Channels": "چینل", + "Channels": "چینلز", "HeaderContinueWatching": "دیکھنا جاری رکھیں", "Playlists": "پلے لسٹس", "ValueSpecialEpisodeName": "خاص - {0}", @@ -17,7 +17,7 @@ "Artists": "فنکار", "Sync": "مطابقت", "Photos": "تصوریں", - "Albums": "البم", + "Albums": "البمز", "Favorites": "پسندیدہ", "Songs": "گانے", "Books": "کتابیں", diff --git a/Emby.Server.Implementations/Localization/Core/vi.json b/Emby.Server.Implementations/Localization/Core/vi.json index ac74deff82..3d69e418b9 100644 --- a/Emby.Server.Implementations/Localization/Core/vi.json +++ b/Emby.Server.Implementations/Localization/Core/vi.json @@ -3,7 +3,7 @@ "Favorites": "Yêu Thích", "Folders": "Thư Mục", "Genres": "Thể Loại", - "HeaderAlbumArtists": "Bộ Sưu Tập Nghệ sĩ", + "HeaderAlbumArtists": "Album Nghệ sĩ", "HeaderContinueWatching": "Xem Tiếp", "HeaderLiveTV": "TV Trực Tiếp", "Movies": "Phim", @@ -13,10 +13,10 @@ "Songs": "Các Bài Hát", "Sync": "Đồng Bộ", "ValueSpecialEpisodeName": "Đặc Biệt - {0}", - "Albums": "Albums", + "Albums": "Tuyển Tập", "Artists": "Các Nghệ Sĩ", "TaskDownloadMissingSubtitlesDescription": "Tìm kiếm phụ đề bị thiếu trên Internet dựa trên cấu hình dữ liệu mô tả.", - "TaskDownloadMissingSubtitles": "Tải xuống phụ đề bị thiếu", + "TaskDownloadMissingSubtitles": "Tải Xuống Phụ Đề Bị Thiếu", "TaskRefreshChannelsDescription": "Làm mới thông tin kênh internet.", "TaskRefreshChannels": "Làm Mới Kênh", "TaskCleanTranscodeDescription": "Xóa các tệp chuyển mã cũ hơn một ngày.", @@ -24,11 +24,11 @@ "TaskUpdatePluginsDescription": "Tải xuống và cài đặt các bản cập nhật cho các plugin được định cấu hình để cập nhật tự động.", "TaskUpdatePlugins": "Cập Nhật Plugins", "TaskRefreshPeopleDescription": "Cập nhật thông tin chi tiết cho diễn viên và đạo diễn trong thư viện phương tiện của bạn.", - "TaskRefreshPeople": "Làm mới Người dùng", + "TaskRefreshPeople": "Làm Mới Người Dùng", "TaskCleanLogsDescription": "Xóa tập tin nhật ký cũ hơn {0} ngày.", - "TaskCleanLogs": "Làm sạch nhật ký", - "TaskRefreshLibraryDescription": "Quét thư viện phương tiện của bạn để tìm các tệp mới và làm mới thông tin chi tiết.", - "TaskRefreshLibrary": "Quét Thư viện Phương tiện", + "TaskCleanLogs": "Làm Sạch Thư Mục Nhật Ký", + "TaskRefreshLibraryDescription": "Quét thư viện phương tiện của bạn để tìm tệp mới và làm mới dữ liệu mô tả.", + "TaskRefreshLibrary": "Quét Thư Viện Phương Tiện", "TaskRefreshChapterImagesDescription": "Tạo hình thu nhỏ cho video có các phân cảnh.", "TaskRefreshChapterImages": "Trích Xuất Ảnh Phân Cảnh", "TaskCleanCacheDescription": "Xóa các tệp cache không còn cần thiết của hệ thống.", @@ -80,9 +80,9 @@ "NotificationOptionApplicationUpdateAvailable": "Bản cập nhật ứng dụng hiện sẵn có", "NewVersionIsAvailable": "Một phiên bản mới của Jellyfin Server sẵn có để tải.", "NameSeasonUnknown": "Không Rõ Mùa", - "NameSeasonNumber": "Mùa {0}", + "NameSeasonNumber": "Phần {0}", "NameInstallFailed": "{0} cài đặt thất bại", - "MusicVideos": "Video Nhạc", + "MusicVideos": "Videos Nhạc", "Music": "Nhạc", "MixedContent": "Nội dung hỗn hợp", "MessageServerConfigurationUpdated": "Cấu hình máy chủ đã được cập nhật", @@ -112,5 +112,12 @@ "Books": "Sách", "AuthenticationSucceededWithUserName": "{0} xác thực thành công", "Application": "Ứng Dụng", - "AppDeviceValues": "Ứng Dụng: {0}, Thiết Bị: {1}" + "AppDeviceValues": "Ứng Dụng: {0}, Thiết Bị: {1}", + "TaskCleanActivityLogDescription": "Xóa các mục nhật ký hoạt động cũ hơn độ tuổi đã cài đặt.", + "TaskCleanActivityLog": "Xóa Nhật Ký Hoạt Động", + "Undefined": "Không Xác Định", + "Forced": "Bắt Buộc", + "Default": "Mặc Định", + "TaskOptimizeDatabaseDescription": "Thu gọn cơ sở dữ liệu và cắt bớt dung lượng trống. Chạy tác vụ này sau khi quét thư viện hoặc thực hiện các thay đổi khác ngụ ý sửa đổi cơ sở dữ liệu có thể cải thiện hiệu suất.", + "TaskOptimizeDatabase": "Tối ưu hóa cơ sở dữ liệu" } diff --git a/Emby.Server.Implementations/Localization/Core/zh-CN.json b/Emby.Server.Implementations/Localization/Core/zh-CN.json index 53a902de28..faa9c40e27 100644 --- a/Emby.Server.Implementations/Localization/Core/zh-CN.json +++ b/Emby.Server.Implementations/Localization/Core/zh-CN.json @@ -113,5 +113,12 @@ "TaskCleanCacheDescription": "删除系统不再需要的缓存文件。", "TaskCleanCache": "清理缓存目录", "TasksApplicationCategory": "应用程序", - "TasksMaintenanceCategory": "维护" + "TasksMaintenanceCategory": "维护", + "TaskCleanActivityLog": "清理程序日志", + "TaskCleanActivityLogDescription": "删除早于设置时间的活动日志条目。", + "Undefined": "未定义", + "Forced": "强制的", + "Default": "默认", + "TaskOptimizeDatabaseDescription": "压缩数据库并优化可用空间,在扫描库或执行其他数据库修改后运行此任务可能会提高性能。", + "TaskOptimizeDatabase": "优化数据库" } diff --git a/Emby.Server.Implementations/Localization/Core/zh-HK.json b/Emby.Server.Implementations/Localization/Core/zh-HK.json index 435e294ef2..3dad21dcbc 100644 --- a/Emby.Server.Implementations/Localization/Core/zh-HK.json +++ b/Emby.Server.Implementations/Localization/Core/zh-HK.json @@ -113,5 +113,9 @@ "TaskCleanCache": "清理緩存目錄", "TasksChannelsCategory": "互聯網頻道", "TasksLibraryCategory": "庫", - "TaskRefreshPeople": "刷新人物" + "TaskRefreshPeople": "刷新人物", + "TaskCleanActivityLog": "清理活動記錄", + "Undefined": "未定義", + "Forced": "強制", + "Default": "預設" } diff --git a/Emby.Server.Implementations/Localization/Core/zh-TW.json b/Emby.Server.Implementations/Localization/Core/zh-TW.json index 30f7266300..c3b223f631 100644 --- a/Emby.Server.Implementations/Localization/Core/zh-TW.json +++ b/Emby.Server.Implementations/Localization/Core/zh-TW.json @@ -1,6 +1,6 @@ { "Albums": "專輯", - "AppDeviceValues": "軟體:{0},裝置:{1}", + "AppDeviceValues": "App:{0},裝置:{1}", "Application": "應用程式", "Artists": "演出者", "AuthenticationSucceededWithUserName": "{0} 成功授權", @@ -37,7 +37,7 @@ "MixedContent": "混合內容", "Movies": "電影", "Music": "音樂", - "MusicVideos": "音樂MV", + "MusicVideos": "音樂錄影帶", "NameInstallFailed": "{0} 安裝失敗", "NameSeasonNumber": "第 {0} 季", "NameSeasonUnknown": "未知季數", @@ -112,5 +112,10 @@ "TaskRefreshChapterImagesDescription": "為有章節的影片建立縮圖。", "TasksChannelsCategory": "網路頻道", "TasksApplicationCategory": "應用程式", - "TasksMaintenanceCategory": "維修" + "TasksMaintenanceCategory": "維護", + "TaskCleanActivityLogDescription": "刪除超過所設時間的活動紀錄。", + "TaskCleanActivityLog": "清除活動紀錄", + "Undefined": "未定義的", + "Forced": "強制", + "Default": "原本" } diff --git a/Emby.Server.Implementations/Localization/LocalizationManager.cs b/Emby.Server.Implementations/Localization/LocalizationManager.cs index 30aaf3a058..03919197e2 100644 --- a/Emby.Server.Implementations/Localization/LocalizationManager.cs +++ b/Emby.Server.Implementations/Localization/LocalizationManager.cs @@ -3,13 +3,14 @@ using System.Collections.Concurrent; using System.Collections.Generic; using System.Globalization; using System.IO; -using System.Linq; using System.Reflection; +using System.Text.Json; using System.Threading.Tasks; +using Jellyfin.Extensions; +using Jellyfin.Extensions.Json; using MediaBrowser.Controller.Configuration; using MediaBrowser.Model.Entities; using MediaBrowser.Model.Globalization; -using MediaBrowser.Model.Serialization; using Microsoft.Extensions.Logging; namespace Emby.Server.Implementations.Localization @@ -20,11 +21,13 @@ namespace Emby.Server.Implementations.Localization public class LocalizationManager : ILocalizationManager { private const string DefaultCulture = "en-US"; + private const string RatingsPath = "Emby.Server.Implementations.Localization.Ratings."; + private const string CulturesPath = "Emby.Server.Implementations.Localization.iso6392.txt"; + private const string CountriesPath = "Emby.Server.Implementations.Localization.countries.json"; private static readonly Assembly _assembly = typeof(LocalizationManager).Assembly; private static readonly string[] _unratedValues = { "n/a", "unrated", "not rated" }; private readonly IServerConfigurationManager _configurationManager; - private readonly IJsonSerializer _jsonSerializer; private readonly ILogger<LocalizationManager> _logger; private readonly Dictionary<string, Dictionary<string, ParentalRating>> _allParentalRatings = @@ -33,21 +36,20 @@ namespace Emby.Server.Implementations.Localization private readonly ConcurrentDictionary<string, Dictionary<string, string>> _dictionaries = new ConcurrentDictionary<string, Dictionary<string, string>>(StringComparer.OrdinalIgnoreCase); - private List<CultureDto> _cultures; + private readonly JsonSerializerOptions _jsonOptions = JsonDefaults.Options; + + private List<CultureDto> _cultures = new List<CultureDto>(); /// <summary> /// Initializes a new instance of the <see cref="LocalizationManager" /> class. /// </summary> /// <param name="configurationManager">The configuration manager.</param> - /// <param name="jsonSerializer">The json serializer.</param> /// <param name="logger">The logger.</param> public LocalizationManager( IServerConfigurationManager configurationManager, - IJsonSerializer jsonSerializer, ILogger<LocalizationManager> logger) { _configurationManager = configurationManager; - _jsonSerializer = jsonSerializer; _logger = logger; } @@ -57,44 +59,39 @@ namespace Emby.Server.Implementations.Localization /// <returns><see cref="Task" />.</returns> public async Task LoadAll() { - const string RatingsResource = "Emby.Server.Implementations.Localization.Ratings."; - // Extract from the assembly foreach (var resource in _assembly.GetManifestResourceNames()) { - if (!resource.StartsWith(RatingsResource, StringComparison.Ordinal)) + if (!resource.StartsWith(RatingsPath, StringComparison.Ordinal)) { continue; } - string countryCode = resource.Substring(RatingsResource.Length, 2); + string countryCode = resource.Substring(RatingsPath.Length, 2); var dict = new Dictionary<string, ParentalRating>(StringComparer.OrdinalIgnoreCase); - using (var str = _assembly.GetManifestResourceStream(resource)) - using (var reader = new StreamReader(str)) + await using var stream = _assembly.GetManifestResourceStream(resource); + using var reader = new StreamReader(stream!); // shouldn't be null here, we just got the resource path from Assembly.GetManifestResourceNames() + await foreach (var line in reader.ReadAllLinesAsync().ConfigureAwait(false)) { - string line; - while ((line = await reader.ReadLineAsync().ConfigureAwait(false)) != null) + if (string.IsNullOrWhiteSpace(line)) + { + continue; + } + + string[] parts = line.Split(','); + if (parts.Length == 2 + && int.TryParse(parts[1], NumberStyles.Integer, CultureInfo.InvariantCulture, out var value)) { - if (string.IsNullOrWhiteSpace(line)) - { - continue; - } - - string[] parts = line.Split(','); - if (parts.Length == 2 - && int.TryParse(parts[1], NumberStyles.Integer, CultureInfo.InvariantCulture, out var value)) - { - var name = parts[0]; - dict.Add(name, new ParentalRating(name, value)); - } + var name = parts[0]; + dict.Add(name, new ParentalRating(name, value)); + } #if DEBUG - else - { - _logger.LogWarning("Malformed line in ratings file for country {CountryCode}", countryCode); - } -#endif + else + { + _logger.LogWarning("Malformed line in ratings file for country {CountryCode}", countryCode); } +#endif } _allParentalRatings[countryCode] = dict; @@ -114,54 +111,49 @@ namespace Emby.Server.Implementations.Localization { List<CultureDto> list = new List<CultureDto>(); - const string ResourcePath = "Emby.Server.Implementations.Localization.iso6392.txt"; - - using (var stream = _assembly.GetManifestResourceStream(ResourcePath)) - using (var reader = new StreamReader(stream)) + await using var stream = _assembly.GetManifestResourceStream(CulturesPath) + ?? throw new InvalidOperationException($"Invalid resource path: '{CulturesPath}'"); + using var reader = new StreamReader(stream); + await foreach (var line in reader.ReadAllLinesAsync().ConfigureAwait(false)) { - while (!reader.EndOfStream) + if (string.IsNullOrWhiteSpace(line)) { - var line = await reader.ReadLineAsync().ConfigureAwait(false); + continue; + } - if (string.IsNullOrWhiteSpace(line)) + var parts = line.Split('|'); + + if (parts.Length == 5) + { + string name = parts[3]; + if (string.IsNullOrWhiteSpace(name)) { continue; } - var parts = line.Split('|'); + string twoCharName = parts[2]; + if (string.IsNullOrWhiteSpace(twoCharName)) + { + continue; + } - if (parts.Length == 5) + string[] threeletterNames; + if (string.IsNullOrWhiteSpace(parts[1])) { - string name = parts[3]; - if (string.IsNullOrWhiteSpace(name)) - { - continue; - } - - string twoCharName = parts[2]; - if (string.IsNullOrWhiteSpace(twoCharName)) - { - continue; - } - - string[] threeletterNames; - if (string.IsNullOrWhiteSpace(parts[1])) - { - threeletterNames = new[] { parts[0] }; - } - else - { - threeletterNames = new[] { parts[0], parts[1] }; - } - - list.Add(new CultureDto - { - DisplayName = name, - Name = name, - ThreeLetterISOLanguageNames = threeletterNames, - TwoLetterISOLanguageName = twoCharName - }); + threeletterNames = new[] { parts[0] }; } + else + { + threeletterNames = new[] { parts[0], parts[1] }; + } + + list.Add(new CultureDto + { + DisplayName = name, + Name = name, + ThreeLetterISOLanguageNames = threeletterNames, + TwoLetterISOLanguageName = twoCharName + }); } } @@ -169,18 +161,32 @@ namespace Emby.Server.Implementations.Localization } /// <inheritdoc /> - public CultureDto FindLanguageInfo(string language) - => GetCultures() - .FirstOrDefault(i => - string.Equals(i.DisplayName, language, StringComparison.OrdinalIgnoreCase) - || string.Equals(i.Name, language, StringComparison.OrdinalIgnoreCase) - || i.ThreeLetterISOLanguageNames.Contains(language, StringComparer.OrdinalIgnoreCase) - || string.Equals(i.TwoLetterISOLanguageName, language, StringComparison.OrdinalIgnoreCase)); + public CultureDto? FindLanguageInfo(string language) + { + // TODO language should ideally be a ReadOnlySpan but moq cannot mock ref structs + for (var i = 0; i < _cultures.Count; i++) + { + var culture = _cultures[i]; + if (language.Equals(culture.DisplayName, StringComparison.OrdinalIgnoreCase) + || language.Equals(culture.Name, StringComparison.OrdinalIgnoreCase) + || culture.ThreeLetterISOLanguageNames.Contains(language, StringComparison.OrdinalIgnoreCase) + || language.Equals(culture.TwoLetterISOLanguageName, StringComparison.OrdinalIgnoreCase)) + { + return culture; + } + } + + return default; + } /// <inheritdoc /> public IEnumerable<CountryInfo> GetCountries() - => _jsonSerializer.DeserializeFromStream<IEnumerable<CountryInfo>>( - _assembly.GetManifestResourceStream("Emby.Server.Implementations.Localization.countries.json")); + { + using StreamReader reader = new StreamReader( + _assembly.GetManifestResourceStream(CountriesPath) ?? throw new InvalidOperationException($"Invalid resource path: '{CountriesPath}'")); + return JsonSerializer.Deserialize<IEnumerable<CountryInfo>>(reader.ReadToEnd(), _jsonOptions) + ?? throw new InvalidOperationException($"Resource contains invalid data: '{CountriesPath}'"); + } /// <inheritdoc /> public IEnumerable<ParentalRating> GetParentalRatings() @@ -199,7 +205,9 @@ namespace Emby.Server.Implementations.Localization countryCode = "us"; } - return GetRatings(countryCode) ?? GetRatings("us"); + return GetRatings(countryCode) + ?? GetRatings("us") + ?? throw new InvalidOperationException($"Invalid resource path: '{CountriesPath}'"); } /// <summary> @@ -207,7 +215,7 @@ namespace Emby.Server.Implementations.Localization /// </summary> /// <param name="countryCode">The country code.</param> /// <returns>The ratings.</returns> - private Dictionary<string, ParentalRating> GetRatings(string countryCode) + private Dictionary<string, ParentalRating>? GetRatings(string countryCode) { _allParentalRatings.TryGetValue(countryCode, out var value); @@ -222,7 +230,7 @@ namespace Emby.Server.Implementations.Localization throw new ArgumentNullException(nameof(rating)); } - if (_unratedValues.Contains(rating, StringComparer.OrdinalIgnoreCase)) + if (_unratedValues.Contains(rating.AsSpan(), StringComparison.OrdinalIgnoreCase)) { return null; } @@ -232,7 +240,7 @@ namespace Emby.Server.Implementations.Localization var ratingsDictionary = GetParentalRatingsDictionary(); - if (ratingsDictionary.TryGetValue(rating, out ParentalRating value)) + if (ratingsDictionary.TryGetValue(rating, out ParentalRating? value)) { return value.Value; } @@ -250,11 +258,11 @@ namespace Emby.Server.Implementations.Localization var index = rating.IndexOf(':', StringComparison.Ordinal); if (index != -1) { - rating = rating.Substring(index).TrimStart(':').Trim(); + var trimmedRating = rating.AsSpan(index).TrimStart(':').Trim(); - if (!string.IsNullOrWhiteSpace(rating)) + if (!trimmedRating.IsEmpty) { - return GetRatingLevel(rating); + return GetRatingLevel(trimmedRating.ToString()); } } @@ -262,20 +270,6 @@ namespace Emby.Server.Implementations.Localization return null; } - /// <inheritdoc /> - public bool HasUnicodeCategory(string value, UnicodeCategory category) - { - foreach (var chr in value) - { - if (char.GetUnicodeCategory(chr) == category) - { - return true; - } - } - - return false; - } - /// <inheritdoc /> public string GetLocalizedString(string phrase) { @@ -313,11 +307,11 @@ namespace Emby.Server.Implementations.Localization } const string Prefix = "Core"; - var key = Prefix + culture; return _dictionaries.GetOrAdd( - key, - f => GetDictionary(Prefix, culture, DefaultCulture + ".json").GetAwaiter().GetResult()); + culture, + (key, localizationManager) => localizationManager.GetDictionary(Prefix, key, DefaultCulture + ".json").GetAwaiter().GetResult(), + this); } private async Task<Dictionary<string, string>> GetDictionary(string prefix, string culture, string baseFilename) @@ -339,22 +333,23 @@ namespace Emby.Server.Implementations.Localization private async Task CopyInto(IDictionary<string, string> dictionary, string resourcePath) { - using (var stream = _assembly.GetManifestResourceStream(resourcePath)) + await using var stream = _assembly.GetManifestResourceStream(resourcePath); + // If a Culture doesn't have a translation the stream will be null and it defaults to en-us further up the chain + if (stream == null) { - // If a Culture doesn't have a translation the stream will be null and it defaults to en-us further up the chain - if (stream != null) - { - var dict = await _jsonSerializer.DeserializeFromStreamAsync<Dictionary<string, string>>(stream).ConfigureAwait(false); + _logger.LogError("Missing translation/culture resource: {ResourcePath}", resourcePath); + return; + } - foreach (var key in dict.Keys) - { - dictionary[key] = dict[key]; - } - } - else - { - _logger.LogError("Missing translation/culture resource: {ResourcePath}", resourcePath); - } + var dict = await JsonSerializer.DeserializeAsync<Dictionary<string, string>>(stream, _jsonOptions).ConfigureAwait(false); + if (dict == null) + { + throw new InvalidOperationException($"Resource contains invalid data: '{stream}'"); + } + + foreach (var key in dict.Keys) + { + dictionary[key] = dict[key]; } } diff --git a/Emby.Server.Implementations/Localization/countries.json b/Emby.Server.Implementations/Localization/countries.json index 581e9f8357..b08a3ae798 100644 --- a/Emby.Server.Implementations/Localization/countries.json +++ b/Emby.Server.Implementations/Localization/countries.json @@ -557,6 +557,12 @@ "ThreeLetterISORegionName": "OMN", "TwoLetterISORegionName": "OM" }, + { + "DisplayName": "Palestine", + "Name": "PS", + "ThreeLetterISORegionName": "PSE", + "TwoLetterISORegionName": "PS" + }, { "DisplayName": "Panama", "Name": "PA", diff --git a/Emby.Server.Implementations/Localization/iso6392.txt b/Emby.Server.Implementations/Localization/iso6392.txt index 40f8614f1b..488901822d 100644 --- a/Emby.Server.Implementations/Localization/iso6392.txt +++ b/Emby.Server.Implementations/Localization/iso6392.txt @@ -77,6 +77,8 @@ chb|||Chibcha|chibcha che||ce|Chechen|tchétchène chg|||Chagatai|djaghataï chi|zho|zh|Chinese|chinois +chi|zho|zh-tw|Chinese; Traditional|chinois +chi|zho|zh-hk|Chinese; Hong Kong|chinois chk|||Chuukese|chuuk chm|||Mari|mari chn|||Chinook jargon|chinook, jargon diff --git a/Emby.Server.Implementations/MediaEncoder/EncodingManager.cs b/Emby.Server.Implementations/MediaEncoder/EncodingManager.cs index 438bbe24a5..8aaa1f7bbe 100644 --- a/Emby.Server.Implementations/MediaEncoder/EncodingManager.cs +++ b/Emby.Server.Implementations/MediaEncoder/EncodingManager.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System; @@ -12,9 +14,9 @@ using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.MediaEncoding; using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.Dto; using MediaBrowser.Model.Entities; using MediaBrowser.Model.IO; -using MediaBrowser.Model.MediaInfo; using Microsoft.Extensions.Logging; namespace Emby.Server.Implementations.MediaEncoder @@ -81,16 +83,6 @@ namespace Emby.Server.Implementations.MediaEncoder return false; } - if (video.VideoType == VideoType.Iso) - { - return false; - } - - if (video.VideoType == VideoType.BluRay || video.VideoType == VideoType.Dvd) - { - return false; - } - if (video.IsShortcut) { return false; @@ -140,15 +132,19 @@ namespace Emby.Server.Implementations.MediaEncoder // Add some time for the first chapter to make sure we don't end up with a black image var time = chapter.StartPositionTicks == 0 ? TimeSpan.FromTicks(Math.Min(_firstChapterTicks, video.RunTimeTicks ?? 0)) : TimeSpan.FromTicks(chapter.StartPositionTicks); - var protocol = MediaProtocol.File; - - var inputPath = MediaEncoderHelpers.GetInputArgument(_fileSystem, video.Path, null, Array.Empty<string>()); + var inputPath = video.Path; Directory.CreateDirectory(Path.GetDirectoryName(path)); var container = video.Container; + var mediaSource = new MediaSourceInfo + { + VideoType = video.VideoType, + IsoType = video.IsoType, + Protocol = video.PathProtocol.Value, + }; - var tempFile = await _encoder.ExtractVideoImage(inputPath, container, protocol, video.GetDefaultVideoStream(), video.Video3DFormat, time, cancellationToken).ConfigureAwait(false); + var tempFile = await _encoder.ExtractVideoImage(inputPath, container, mediaSource, video.GetDefaultVideoStream(), video.Video3DFormat, time, cancellationToken).ConfigureAwait(false); File.Copy(tempFile, path, true); try @@ -166,7 +162,7 @@ namespace Emby.Server.Implementations.MediaEncoder } catch (Exception ex) { - _logger.LogError(ex, "Error extracting chapter images for {0}", string.Join(",", video.Path)); + _logger.LogError(ex, "Error extracting chapter images for {0}", string.Join(',', video.Path)); success = false; break; } diff --git a/Emby.Server.Implementations/Net/SocketFactory.cs b/Emby.Server.Implementations/Net/SocketFactory.cs index 0781a0e333..1377286168 100644 --- a/Emby.Server.Implementations/Net/SocketFactory.cs +++ b/Emby.Server.Implementations/Net/SocketFactory.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System; diff --git a/Emby.Server.Implementations/Net/UdpSocket.cs b/Emby.Server.Implementations/Net/UdpSocket.cs index 4e25768cf6..a8b18d2925 100644 --- a/Emby.Server.Implementations/Net/UdpSocket.cs +++ b/Emby.Server.Implementations/Net/UdpSocket.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System; diff --git a/Emby.Server.Implementations/Networking/NetworkManager.cs b/Emby.Server.Implementations/Networking/NetworkManager.cs deleted file mode 100644 index 089ec30e6b..0000000000 --- a/Emby.Server.Implementations/Networking/NetworkManager.cs +++ /dev/null @@ -1,556 +0,0 @@ -#pragma warning disable CS1591 - -using System; -using System.Collections.Generic; -using System.Linq; -using System.Net; -using System.Net.NetworkInformation; -using System.Net.Sockets; -using System.Threading.Tasks; -using MediaBrowser.Common.Net; -using Microsoft.Extensions.Logging; - -namespace Emby.Server.Implementations.Networking -{ - /// <summary> - /// Class to take care of network interface management. - /// </summary> - public class NetworkManager : INetworkManager - { - private readonly ILogger<NetworkManager> _logger; - - private IPAddress[] _localIpAddresses; - private readonly object _localIpAddressSyncLock = new object(); - - private readonly object _subnetLookupLock = new object(); - private readonly Dictionary<string, List<string>> _subnetLookup = new Dictionary<string, List<string>>(StringComparer.Ordinal); - - private List<PhysicalAddress> _macAddresses; - - /// <summary> - /// Initializes a new instance of the <see cref="NetworkManager"/> class. - /// </summary> - /// <param name="logger">Logger to use for messages.</param> - public NetworkManager(ILogger<NetworkManager> logger) - { - _logger = logger; - - NetworkChange.NetworkAddressChanged += OnNetworkAddressChanged; - NetworkChange.NetworkAvailabilityChanged += OnNetworkAvailabilityChanged; - } - - /// <inheritdoc/> - public event EventHandler NetworkChanged; - - /// <inheritdoc/> - public Func<string[]> LocalSubnetsFn { get; set; } - - private void OnNetworkAvailabilityChanged(object sender, NetworkAvailabilityEventArgs e) - { - _logger.LogDebug("NetworkAvailabilityChanged"); - OnNetworkChanged(); - } - - private void OnNetworkAddressChanged(object sender, EventArgs e) - { - _logger.LogDebug("NetworkAddressChanged"); - OnNetworkChanged(); - } - - private void OnNetworkChanged() - { - lock (_localIpAddressSyncLock) - { - _localIpAddresses = null; - _macAddresses = null; - } - - NetworkChanged?.Invoke(this, EventArgs.Empty); - } - - /// <inheritdoc/> - public IPAddress[] GetLocalIpAddresses() - { - lock (_localIpAddressSyncLock) - { - if (_localIpAddresses == null) - { - var addresses = GetLocalIpAddressesInternal().ToArray(); - - _localIpAddresses = addresses; - } - - return _localIpAddresses; - } - } - - private List<IPAddress> GetLocalIpAddressesInternal() - { - var list = GetIPsDefault().ToList(); - - if (list.Count == 0) - { - list = GetLocalIpAddressesFallback().GetAwaiter().GetResult().ToList(); - } - - var listClone = new List<IPAddress>(); - - var subnets = LocalSubnetsFn(); - - foreach (var i in list) - { - if (i.IsIPv6LinkLocal || i.ToString().StartsWith("169.254.", StringComparison.OrdinalIgnoreCase)) - { - continue; - } - - if (Array.IndexOf(subnets, $"[{i}]") == -1) - { - listClone.Add(i); - } - } - - return listClone - .OrderBy(i => i.AddressFamily == AddressFamily.InterNetwork ? 0 : 1) - // .ThenBy(i => listClone.IndexOf(i)) - .GroupBy(i => i.ToString()) - .Select(x => x.First()) - .ToList(); - } - - /// <inheritdoc/> - public bool IsInPrivateAddressSpace(string endpoint) - { - return IsInPrivateAddressSpace(endpoint, true); - } - - // Checks if the address in endpoint is an RFC1918, RFC1122, or RFC3927 address - private bool IsInPrivateAddressSpace(string endpoint, bool checkSubnets) - { - if (string.Equals(endpoint, "::1", StringComparison.OrdinalIgnoreCase)) - { - return true; - } - - // IPV6 - if (endpoint.Split('.').Length > 4) - { - // Handle ipv4 mapped to ipv6 - var originalEndpoint = endpoint; - endpoint = endpoint.Replace("::ffff:", string.Empty, StringComparison.OrdinalIgnoreCase); - - if (string.Equals(endpoint, originalEndpoint, StringComparison.OrdinalIgnoreCase)) - { - return false; - } - } - - // Private address space: - - if (string.Equals(endpoint, "localhost", StringComparison.OrdinalIgnoreCase)) - { - return true; - } - - if (!IPAddress.TryParse(endpoint, out var ipAddress)) - { - return false; - } - - byte[] octet = ipAddress.GetAddressBytes(); - - if ((octet[0] == 10) || - (octet[0] == 172 && (octet[1] >= 16 && octet[1] <= 31)) || // RFC1918 - (octet[0] == 192 && octet[1] == 168) || // RFC1918 - (octet[0] == 127) || // RFC1122 - (octet[0] == 169 && octet[1] == 254)) // RFC3927 - { - return true; - } - - if (checkSubnets && IsInPrivateAddressSpaceAndLocalSubnet(endpoint)) - { - return true; - } - - return false; - } - - /// <inheritdoc/> - public bool IsInPrivateAddressSpaceAndLocalSubnet(string endpoint) - { - if (endpoint.StartsWith("10.", StringComparison.OrdinalIgnoreCase)) - { - var endpointFirstPart = endpoint.Split('.')[0]; - - var subnets = GetSubnets(endpointFirstPart); - - foreach (var subnet_Match in subnets) - { - // logger.LogDebug("subnet_Match:" + subnet_Match); - - if (endpoint.StartsWith(subnet_Match + ".", StringComparison.OrdinalIgnoreCase)) - { - return true; - } - } - } - - return false; - } - - // Gives a list of possible subnets from the system whose interface ip starts with endpointFirstPart - private List<string> GetSubnets(string endpointFirstPart) - { - lock (_subnetLookupLock) - { - if (_subnetLookup.TryGetValue(endpointFirstPart, out var subnets)) - { - return subnets; - } - - subnets = new List<string>(); - - foreach (var adapter in NetworkInterface.GetAllNetworkInterfaces()) - { - foreach (var unicastIPAddressInformation in adapter.GetIPProperties().UnicastAddresses) - { - if (unicastIPAddressInformation.Address.AddressFamily == AddressFamily.InterNetwork && endpointFirstPart == unicastIPAddressInformation.Address.ToString().Split('.')[0]) - { - int subnet_Test = 0; - foreach (string part in unicastIPAddressInformation.IPv4Mask.ToString().Split('.')) - { - if (part.Equals("0", StringComparison.Ordinal)) - { - break; - } - - subnet_Test++; - } - - var subnet_Match = string.Join(".", unicastIPAddressInformation.Address.ToString().Split('.').Take(subnet_Test).ToArray()); - - // TODO: Is this check necessary? - if (adapter.OperationalStatus == OperationalStatus.Up) - { - subnets.Add(subnet_Match); - } - } - } - } - - _subnetLookup[endpointFirstPart] = subnets; - - return subnets; - } - } - - /// <inheritdoc/> - public bool IsInLocalNetwork(string endpoint) - { - return IsInLocalNetworkInternal(endpoint, true); - } - - /// <inheritdoc/> - public bool IsAddressInSubnets(string addressString, string[] subnets) - { - return IsAddressInSubnets(IPAddress.Parse(addressString), addressString, subnets); - } - - /// <inheritdoc/> - public bool IsAddressInSubnets(IPAddress address, bool excludeInterfaces, bool excludeRFC) - { - byte[] octet = address.GetAddressBytes(); - - if ((octet[0] == 127) || // RFC1122 - (octet[0] == 169 && octet[1] == 254)) // RFC3927 - { - // don't use on loopback or 169 interfaces - return false; - } - - string addressString = address.ToString(); - string excludeAddress = "[" + addressString + "]"; - var subnets = LocalSubnetsFn(); - - // Include any address if LAN subnets aren't specified - if (subnets.Length == 0) - { - return true; - } - - // Exclude any addresses if they appear in the LAN list in [ ] - if (Array.IndexOf(subnets, excludeAddress) != -1) - { - return false; - } - - return IsAddressInSubnets(address, addressString, subnets); - } - - /// <summary> - /// Checks if the give address falls within the ranges given in [subnets]. The addresses in subnets can be hosts or subnets in the CIDR format. - /// </summary> - /// <param name="address">IPAddress version of the address.</param> - /// <param name="addressString">The address to check.</param> - /// <param name="subnets">If true, check against addresses in the LAN settings which have [] arroud and return true if it matches the address give in address.</param> - /// <returns><c>false</c>if the address isn't in the subnets, <c>true</c> otherwise.</returns> - private static bool IsAddressInSubnets(IPAddress address, string addressString, string[] subnets) - { - foreach (var subnet in subnets) - { - var normalizedSubnet = subnet.Trim(); - // Is the subnet a host address and does it match the address being passes? - if (string.Equals(normalizedSubnet, addressString, StringComparison.OrdinalIgnoreCase)) - { - return true; - } - - // Parse CIDR subnets and see if address falls within it. - if (normalizedSubnet.Contains('/', StringComparison.Ordinal)) - { - try - { - var ipNetwork = IPNetwork.Parse(normalizedSubnet); - if (ipNetwork.Contains(address)) - { - return true; - } - } - catch - { - // Ignoring - invalid subnet passed encountered. - } - } - } - - return false; - } - - private bool IsInLocalNetworkInternal(string endpoint, bool resolveHost) - { - if (string.IsNullOrEmpty(endpoint)) - { - throw new ArgumentNullException(nameof(endpoint)); - } - - if (IPAddress.TryParse(endpoint, out var address)) - { - var addressString = address.ToString(); - - var localSubnetsFn = LocalSubnetsFn; - if (localSubnetsFn != null) - { - var localSubnets = localSubnetsFn(); - foreach (var subnet in localSubnets) - { - // Only validate if there's at least one valid entry. - if (!string.IsNullOrWhiteSpace(subnet)) - { - return IsAddressInSubnets(address, addressString, localSubnets) || IsInPrivateAddressSpace(addressString, false); - } - } - } - - int lengthMatch = 100; - if (address.AddressFamily == AddressFamily.InterNetwork) - { - lengthMatch = 4; - if (IsInPrivateAddressSpace(addressString, true)) - { - return true; - } - } - else if (address.AddressFamily == AddressFamily.InterNetworkV6) - { - lengthMatch = 9; - if (IsInPrivateAddressSpace(endpoint, true)) - { - return true; - } - } - - // Should be even be doing this with ipv6? - if (addressString.Length >= lengthMatch) - { - var prefix = addressString.Substring(0, lengthMatch); - - if (GetLocalIpAddresses().Any(i => i.ToString().StartsWith(prefix, StringComparison.OrdinalIgnoreCase))) - { - return true; - } - } - } - else if (resolveHost) - { - if (Uri.TryCreate(endpoint, UriKind.RelativeOrAbsolute, out var uri)) - { - try - { - var host = uri.DnsSafeHost; - _logger.LogDebug("Resolving host {0}", host); - - address = GetIpAddresses(host).GetAwaiter().GetResult().FirstOrDefault(); - - if (address != null) - { - _logger.LogDebug("{0} resolved to {1}", host, address); - - return IsInLocalNetworkInternal(address.ToString(), false); - } - } - catch (InvalidOperationException) - { - // Can happen with reverse proxy or IIS url rewriting? - } - catch (Exception ex) - { - _logger.LogError(ex, "Error resolving hostname"); - } - } - } - - return false; - } - - private static Task<IPAddress[]> GetIpAddresses(string hostName) - { - return Dns.GetHostAddressesAsync(hostName); - } - - private IEnumerable<IPAddress> GetIPsDefault() - { - IEnumerable<NetworkInterface> interfaces; - - try - { - interfaces = NetworkInterface.GetAllNetworkInterfaces() - .Where(x => x.OperationalStatus == OperationalStatus.Up - || x.OperationalStatus == OperationalStatus.Unknown); - } - catch (NetworkInformationException ex) - { - _logger.LogError(ex, "Error in GetAllNetworkInterfaces"); - return Enumerable.Empty<IPAddress>(); - } - - return interfaces.SelectMany(network => - { - var ipProperties = network.GetIPProperties(); - - // Exclude any addresses if they appear in the LAN list in [ ] - - return ipProperties.UnicastAddresses - .Select(i => i.Address) - .Where(i => i.AddressFamily == AddressFamily.InterNetwork || i.AddressFamily == AddressFamily.InterNetworkV6); - }).GroupBy(i => i.ToString()) - .Select(x => x.First()); - } - - private static async Task<IEnumerable<IPAddress>> GetLocalIpAddressesFallback() - { - var host = await Dns.GetHostEntryAsync(Dns.GetHostName()).ConfigureAwait(false); - - // Reverse them because the last one is usually the correct one - // It's not fool-proof so ultimately the consumer will have to examine them and decide - return host.AddressList - .Where(i => i.AddressFamily == AddressFamily.InterNetwork || i.AddressFamily == AddressFamily.InterNetworkV6) - .Reverse(); - } - - /// <summary> - /// Gets a random port number that is currently available. - /// </summary> - /// <returns>System.Int32.</returns> - public int GetRandomUnusedTcpPort() - { - var listener = new TcpListener(IPAddress.Any, 0); - listener.Start(); - var port = ((IPEndPoint)listener.LocalEndpoint).Port; - listener.Stop(); - return port; - } - - /// <inheritdoc/> - public int GetRandomUnusedUdpPort() - { - var localEndPoint = new IPEndPoint(IPAddress.Any, 0); - using (var udpClient = new UdpClient(localEndPoint)) - { - return ((IPEndPoint)udpClient.Client.LocalEndPoint).Port; - } - } - - /// <inheritdoc/> - public List<PhysicalAddress> GetMacAddresses() - { - return _macAddresses ??= GetMacAddressesInternal().ToList(); - } - - private static IEnumerable<PhysicalAddress> GetMacAddressesInternal() - => NetworkInterface.GetAllNetworkInterfaces() - .Where(i => i.NetworkInterfaceType != NetworkInterfaceType.Loopback) - .Select(x => x.GetPhysicalAddress()) - .Where(x => !x.Equals(PhysicalAddress.None)); - - /// <inheritdoc/> - public bool IsInSameSubnet(IPAddress address1, IPAddress address2, IPAddress subnetMask) - { - IPAddress network1 = GetNetworkAddress(address1, subnetMask); - IPAddress network2 = GetNetworkAddress(address2, subnetMask); - return network1.Equals(network2); - } - - private IPAddress GetNetworkAddress(IPAddress address, IPAddress subnetMask) - { - byte[] ipAdressBytes = address.GetAddressBytes(); - byte[] subnetMaskBytes = subnetMask.GetAddressBytes(); - - if (ipAdressBytes.Length != subnetMaskBytes.Length) - { - throw new ArgumentException("Lengths of IP address and subnet mask do not match."); - } - - byte[] broadcastAddress = new byte[ipAdressBytes.Length]; - for (int i = 0; i < broadcastAddress.Length; i++) - { - broadcastAddress[i] = (byte)(ipAdressBytes[i] & subnetMaskBytes[i]); - } - - return new IPAddress(broadcastAddress); - } - - /// <inheritdoc/> - public IPAddress GetLocalIpSubnetMask(IPAddress address) - { - NetworkInterface[] interfaces; - - try - { - var validStatuses = new[] { OperationalStatus.Up, OperationalStatus.Unknown }; - - interfaces = NetworkInterface.GetAllNetworkInterfaces() - .Where(i => validStatuses.Contains(i.OperationalStatus)) - .ToArray(); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error in GetAllNetworkInterfaces"); - return null; - } - - foreach (NetworkInterface ni in interfaces) - { - foreach (UnicastIPAddressInformation ip in ni.GetIPProperties().UnicastAddresses) - { - if (ip.Address.Equals(address) && ip.IPv4Mask != null) - { - return ip.IPv4Mask; - } - } - } - - return null; - } - } -} diff --git a/Emby.Server.Implementations/Playlists/PlaylistManager.cs b/Emby.Server.Implementations/Playlists/PlaylistManager.cs index d3b64fb318..8cafde38ee 100644 --- a/Emby.Server.Implementations/Playlists/PlaylistManager.cs +++ b/Emby.Server.Implementations/Playlists/PlaylistManager.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System; @@ -145,12 +147,12 @@ namespace Emby.Server.Implementations.Playlists playlist.SetMediaType(options.MediaType); - parentFolder.AddChild(playlist, CancellationToken.None); + parentFolder.AddChild(playlist); await playlist.RefreshMetadata(new MetadataRefreshOptions(new DirectoryService(_fileSystem)) { ForceSave = true }, CancellationToken.None) .ConfigureAwait(false); - if (options.ItemIdList.Length > 0) + if (options.ItemIdList.Count > 0) { await AddToPlaylistInternal(playlist.Id, options.ItemIdList, user, new DtoOptions(false) { @@ -184,7 +186,7 @@ namespace Emby.Server.Implementations.Playlists return Playlist.GetPlaylistItems(playlistMediaType, items, user, options); } - public Task AddToPlaylistAsync(Guid playlistId, ICollection<Guid> itemIds, Guid userId) + public Task AddToPlaylistAsync(Guid playlistId, IReadOnlyCollection<Guid> itemIds, Guid userId) { var user = userId.Equals(Guid.Empty) ? null : _userManager.GetUserById(userId); @@ -194,7 +196,7 @@ namespace Emby.Server.Implementations.Playlists }); } - private async Task AddToPlaylistInternal(Guid playlistId, ICollection<Guid> newItemIds, User user, DtoOptions options) + private async Task AddToPlaylistInternal(Guid playlistId, IReadOnlyCollection<Guid> newItemIds, User user, DtoOptions options) { // Retrieve the existing playlist var playlist = _libraryManager.GetItemById(playlistId) as Playlist @@ -215,7 +217,7 @@ namespace Emby.Server.Implementations.Playlists // Create a list of the new linked children to add to the playlist var childrenToAdd = newItems - .Select(i => LinkedChild.Create(i)) + .Select(LinkedChild.Create) .ToList(); // Log duplicates that have been ignored, if any diff --git a/Emby.Server.Implementations/Plugins/PluginManager.cs b/Emby.Server.Implementations/Plugins/PluginManager.cs new file mode 100644 index 0000000000..fc0920edfa --- /dev/null +++ b/Emby.Server.Implementations/Plugins/PluginManager.cs @@ -0,0 +1,756 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Net.Http; +using System.Reflection; +using System.Text; +using System.Text.Json; +using System.Threading.Tasks; +using MediaBrowser.Common; +using MediaBrowser.Common.Extensions; +using Jellyfin.Extensions.Json; +using Jellyfin.Extensions.Json.Converters; +using MediaBrowser.Common.Net; +using MediaBrowser.Common.Plugins; +using MediaBrowser.Model.Configuration; +using MediaBrowser.Model.Plugins; +using MediaBrowser.Model.Updates; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace Emby.Server.Implementations.Plugins +{ + /// <summary> + /// Defines the <see cref="PluginManager" />. + /// </summary> + public class PluginManager : IPluginManager + { + private readonly string _pluginsPath; + private readonly Version _appVersion; + private readonly JsonSerializerOptions _jsonOptions; + private readonly ILogger<PluginManager> _logger; + private readonly IApplicationHost _appHost; + private readonly ServerConfiguration _config; + private readonly List<LocalPlugin> _plugins; + private readonly Version _minimumVersion; + + private IHttpClientFactory? _httpClientFactory; + + private IHttpClientFactory HttpClientFactory + { + get + { + return _httpClientFactory ?? (_httpClientFactory = _appHost.Resolve<IHttpClientFactory>()); + } + } + + /// <summary> + /// Initializes a new instance of the <see cref="PluginManager"/> class. + /// </summary> + /// <param name="logger">The <see cref="ILogger"/>.</param> + /// <param name="appHost">The <see cref="IApplicationHost"/>.</param> + /// <param name="config">The <see cref="ServerConfiguration"/>.</param> + /// <param name="pluginsPath">The plugin path.</param> + /// <param name="appVersion">The application version.</param> + public PluginManager( + ILogger<PluginManager> logger, + IApplicationHost appHost, + ServerConfiguration config, + string pluginsPath, + Version appVersion) + { + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _pluginsPath = pluginsPath; + _appVersion = appVersion ?? throw new ArgumentNullException(nameof(appVersion)); + _jsonOptions = new JsonSerializerOptions(JsonDefaults.Options) + { + WriteIndented = true + }; + + // We need to use the default GUID converter, so we need to remove any custom ones. + for (int a = _jsonOptions.Converters.Count - 1; a >= 0; a--) + { + if (_jsonOptions.Converters[a] is JsonGuidConverter convertor) + { + _jsonOptions.Converters.Remove(convertor); + break; + } + } + + _config = config; + _appHost = appHost; + _minimumVersion = new Version(0, 0, 0, 1); + _plugins = Directory.Exists(_pluginsPath) ? DiscoverPlugins().ToList() : new List<LocalPlugin>(); + } + + /// <summary> + /// Gets the Plugins. + /// </summary> + public IReadOnlyList<LocalPlugin> Plugins => _plugins; + + /// <summary> + /// Returns all the assemblies. + /// </summary> + /// <returns>An IEnumerable{Assembly}.</returns> + public IEnumerable<Assembly> LoadAssemblies() + { + // Attempt to remove any deleted plugins and change any successors to be active. + for (int i = _plugins.Count - 1; i >= 0; i--) + { + var plugin = _plugins[i]; + if (plugin.Manifest.Status == PluginStatus.Deleted && DeletePlugin(plugin)) + { + // See if there is another version, and if so make that active. + ProcessAlternative(plugin); + } + } + + // Now load the assemblies.. + foreach (var plugin in _plugins) + { + UpdatePluginSuperceedStatus(plugin); + + if (plugin.IsEnabledAndSupported == false) + { + _logger.LogInformation("Skipping disabled plugin {Version} of {Name} ", plugin.Version, plugin.Name); + continue; + } + + foreach (var file in plugin.DllFiles) + { + Assembly assembly; + try + { + assembly = Assembly.LoadFrom(file); + + assembly.GetExportedTypes(); + } + catch (FileLoadException ex) + { + _logger.LogError(ex, "Failed to load assembly {Path}. Disabling plugin.", file); + ChangePluginState(plugin, PluginStatus.Malfunctioned); + continue; + } + catch (TypeLoadException ex) // 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; + } +#pragma warning disable CA1031 // Do not catch general exception types + catch (Exception ex) +#pragma warning restore CA1031 // Do not catch general exception types + { + _logger.LogError(ex, "Failed to load assembly {Path}. Unknown exception was thrown. Disabling plugin.", file); + ChangePluginState(plugin, PluginStatus.Malfunctioned); + continue; + } + + _logger.LogInformation("Loaded assembly {Assembly} from {Path}", assembly.FullName, file); + yield return assembly; + } + } + } + + /// <summary> + /// Creates all the plugin instances. + /// </summary> + public void CreatePlugins() + { + _ = _appHost.GetExports<IPlugin>(CreatePluginInstance); + } + + /// <summary> + /// Registers the plugin's services with the DI. + /// Note: DI is not yet instantiated yet. + /// </summary> + /// <param name="serviceCollection">A <see cref="ServiceCollection"/> instance.</param> + public void RegisterServices(IServiceCollection serviceCollection) + { + foreach (var pluginServiceRegistrator in _appHost.GetExportTypes<IPluginServiceRegistrator>()) + { + var plugin = GetPluginByAssembly(pluginServiceRegistrator.Assembly); + if (plugin == null) + { + _logger.LogError("Unable to find plugin in assembly {Assembly}", pluginServiceRegistrator.Assembly.FullName); + continue; + } + + UpdatePluginSuperceedStatus(plugin); + if (!plugin.IsEnabledAndSupported) + { + continue; + } + + try + { + var instance = (IPluginServiceRegistrator?)Activator.CreateInstance(pluginServiceRegistrator); + instance?.RegisterServices(serviceCollection); + } +#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, "Error registering plugin services from {Assembly}.", pluginServiceRegistrator.Assembly.FullName); + if (ChangePluginState(plugin, PluginStatus.Malfunctioned)) + { + _logger.LogInformation("Disabling plugin {Path}", plugin.Path); + } + } + } + } + + /// <summary> + /// Imports a plugin manifest from <paramref name="folder"/>. + /// </summary> + /// <param name="folder">Folder of the plugin.</param> + public void ImportPluginFrom(string folder) + { + if (string.IsNullOrEmpty(folder)) + { + throw new ArgumentNullException(nameof(folder)); + } + + // Load the plugin. + var plugin = LoadManifest(folder); + // Make sure we haven't already loaded this. + if (_plugins.Any(p => p.Manifest.Equals(plugin.Manifest))) + { + return; + } + + _plugins.Add(plugin); + EnablePlugin(plugin); + } + + /// <summary> + /// Removes the plugin reference '<paramref name="plugin"/>. + /// </summary> + /// <param name="plugin">The plugin.</param> + /// <returns>Outcome of the operation.</returns> + public bool RemovePlugin(LocalPlugin plugin) + { + if (plugin == null) + { + throw new ArgumentNullException(nameof(plugin)); + } + + if (DeletePlugin(plugin)) + { + ProcessAlternative(plugin); + return true; + } + + _logger.LogWarning("Unable to delete {Path}, so marking as deleteOnStartup.", plugin.Path); + // Unable to delete, so disable. + if (ChangePluginState(plugin, PluginStatus.Deleted)) + { + ProcessAlternative(plugin); + return true; + } + + return false; + } + + /// <summary> + /// Attempts to find the plugin with and id of <paramref name="id"/>. + /// </summary> + /// <param name="id">The <see cref="Guid"/> of plugin.</param> + /// <param name="version">Optional <see cref="Version"/> of the plugin to locate.</param> + /// <returns>A <see cref="LocalPlugin"/> if located, or null if not.</returns> + public LocalPlugin? GetPlugin(Guid id, Version? version = null) + { + LocalPlugin? plugin; + + if (version == null) + { + // 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 != null) ?? plugins.OrderByDescending(p => p.Version).FirstOrDefault(); + } + else + { + // Match id and version number. + plugin = _plugins.FirstOrDefault(p => p.Id.Equals(id) && p.Version.Equals(version)); + } + + return plugin; + } + + /// <summary> + /// Enables the plugin, disabling all other versions. + /// </summary> + /// <param name="plugin">The <see cref="LocalPlugin"/> of the plug to disable.</param> + public void EnablePlugin(LocalPlugin plugin) + { + if (plugin == null) + { + throw new ArgumentNullException(nameof(plugin)); + } + + if (ChangePluginState(plugin, PluginStatus.Active)) + { + // See if there is another version, and if so, supercede it. + ProcessAlternative(plugin); + } + } + + /// <summary> + /// Disable the plugin. + /// </summary> + /// <param name="plugin">The <see cref="LocalPlugin"/> of the plug to disable.</param> + public void DisablePlugin(LocalPlugin plugin) + { + if (plugin == null) + { + throw new ArgumentNullException(nameof(plugin)); + } + + // Update the manifest on disk + if (ChangePluginState(plugin, PluginStatus.Disabled)) + { + // If there is another version, activate it. + ProcessAlternative(plugin); + } + } + + /// <summary> + /// Disable the plugin. + /// </summary> + /// <param name="assembly">The <see cref="Assembly"/> of the plug to disable.</param> + public void FailPlugin(Assembly assembly) + { + // Only save if disabled. + if (assembly == null) + { + throw new ArgumentNullException(nameof(assembly)); + } + + var plugin = _plugins.FirstOrDefault(p => p.DllFiles.Contains(assembly.Location)); + if (plugin == null) + { + // A plugin's assembly didn't cause this issue, so ignore it. + return; + } + + ChangePluginState(plugin, PluginStatus.Malfunctioned); + } + + /// <inheritdoc/> + public bool SaveManifest(PluginManifest manifest, string path) + { + try + { + var data = JsonSerializer.Serialize(manifest, _jsonOptions); + File.WriteAllText(Path.Combine(path, "meta.json"), data); + return true; + } + catch (ArgumentException e) + { + _logger.LogWarning(e, "Unable to save plugin manifest due to invalid value. {Path}", path); + return false; + } + } + + /// <inheritdoc/> + public async Task<bool> GenerateManifest(PackageInfo packageInfo, Version version, string path, PluginStatus status) + { + if (packageInfo == null) + { + return false; + } + + var versionInfo = packageInfo.Versions.First(v => v.Version == version.ToString()); + var imagePath = string.Empty; + + if (!string.IsNullOrEmpty(packageInfo.ImageUrl)) + { + var url = new Uri(packageInfo.ImageUrl); + imagePath = Path.Join(path, url.Segments[^1]); + + await using var fileStream = File.OpenWrite(imagePath); + + try + { + await using var downloadStream = await HttpClientFactory + .CreateClient(NamedClient.Default) + .GetStreamAsync(url) + .ConfigureAwait(false); + + await downloadStream.CopyToAsync(fileStream).ConfigureAwait(false); + } + catch (HttpRequestException ex) + { + _logger.LogError(ex, "Failed to download image to path {Path} on disk.", imagePath); + imagePath = string.Empty; + } + } + + var manifest = new PluginManifest + { + Category = packageInfo.Category, + Changelog = versionInfo.Changelog ?? string.Empty, + Description = packageInfo.Description, + Id = packageInfo.Id, + Name = packageInfo.Name, + Overview = packageInfo.Overview, + Owner = packageInfo.Owner, + TargetAbi = versionInfo.TargetAbi ?? string.Empty, + Timestamp = string.IsNullOrEmpty(versionInfo.Timestamp) ? DateTime.MinValue : DateTime.Parse(versionInfo.Timestamp, CultureInfo.InvariantCulture), + Version = versionInfo.Version, + Status = status == PluginStatus.Disabled ? PluginStatus.Disabled : PluginStatus.Active, // Keep disabled state. + AutoUpdate = true, + ImagePath = imagePath + }; + + return SaveManifest(manifest, path); + } + + /// <summary> + /// Changes a plugin's load status. + /// </summary> + /// <param name="plugin">The <see cref="LocalPlugin"/> instance.</param> + /// <param name="state">The <see cref="PluginStatus"/> of the plugin.</param> + /// <returns>Success of the task.</returns> + private bool ChangePluginState(LocalPlugin plugin, PluginStatus state) + { + if (plugin.Manifest.Status == state || string.IsNullOrEmpty(plugin.Path)) + { + // No need to save as the state hasn't changed. + return true; + } + + plugin.Manifest.Status = state; + return SaveManifest(plugin.Manifest, plugin.Path); + } + + /// <summary> + /// Finds the plugin record using the assembly. + /// </summary> + /// <param name="assembly">The <see cref="Assembly"/> being sought.</param> + /// <returns>The matching record, or null if not found.</returns> + private LocalPlugin? GetPluginByAssembly(Assembly assembly) + { + // Find which plugin it is by the path. + return _plugins.FirstOrDefault(p => p.DllFiles.Contains(assembly.Location, StringComparer.Ordinal)); + } + + /// <summary> + /// Creates the instance safe. + /// </summary> + /// <param name="type">The type.</param> + /// <returns>System.Object.</returns> + private IPlugin? CreatePluginInstance(Type type) + { + // Find the record for this plugin. + var plugin = GetPluginByAssembly(type.Assembly); + if (plugin?.Manifest.Status < PluginStatus.Active) + { + return null; + } + + try + { + _logger.LogDebug("Creating instance of {Type}", type); + // _appHost.ServiceProvider is already assigned when we create the plugins + var instance = (IPlugin)ActivatorUtilities.CreateInstance(_appHost.ServiceProvider!, type); + if (plugin == null) + { + // Create a dummy record for the providers. + // TODO: remove this code once all provided have been released as separate plugins. + plugin = new LocalPlugin( + instance.AssemblyFilePath, + true, + new PluginManifest + { + Id = instance.Id, + Status = PluginStatus.Active, + Name = instance.Name, + Version = instance.Version.ToString() + }) + { + Instance = instance + }; + + _plugins.Add(plugin); + + plugin.Manifest.Status = PluginStatus.Active; + } + else + { + plugin.Instance = instance; + var manifest = plugin.Manifest; + var pluginStr = instance.Version.ToString(); + bool changed = false; + if (string.Equals(manifest.Version, pluginStr, StringComparison.Ordinal) + || manifest.Id != instance.Id) + { + // If a plugin without a manifest failed to load due to an external issue (eg config), + // this updates the manifest to the actual plugin values. + manifest.Version = pluginStr; + manifest.Name = plugin.Instance.Name; + manifest.Description = plugin.Instance.Description; + manifest.Id = plugin.Instance.Id; + changed = true; + } + + changed = changed || manifest.Status != PluginStatus.Active; + manifest.Status = PluginStatus.Active; + + if (changed) + { + SaveManifest(manifest, plugin.Path); + } + } + + _logger.LogInformation("Loaded plugin: {PluginName} {PluginVersion}", plugin.Name, plugin.Version); + + return instance; + } +#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, "Error creating {Type}", type.FullName); + if (plugin != null) + { + if (ChangePluginState(plugin, PluginStatus.Malfunctioned)) + { + _logger.LogInformation("Plugin {Path} has been disabled.", plugin.Path); + return null; + } + } + + _logger.LogDebug("Unable to auto-disable."); + return null; + } + } + + private void UpdatePluginSuperceedStatus(LocalPlugin plugin) + { + if (plugin.Manifest.Status != PluginStatus.Superceded) + { + return; + } + + var predecessor = _plugins.OrderByDescending(p => p.Version) + .FirstOrDefault(p => p.Id.Equals(plugin.Id) && p.IsEnabledAndSupported && p.Version != plugin.Version); + if (predecessor != null) + { + return; + } + + plugin.Manifest.Status = PluginStatus.Active; + } + + /// <summary> + /// Attempts to delete a plugin. + /// </summary> + /// <param name="plugin">A <see cref="LocalPlugin"/> instance to delete.</param> + /// <returns>True if successful.</returns> + private bool DeletePlugin(LocalPlugin plugin) + { + // Attempt a cleanup of old folders. + try + { + Directory.Delete(plugin.Path, true); + _logger.LogDebug("Deleted {Path}", plugin.Path); + } +#pragma warning disable CA1031 // Do not catch general exception types + catch +#pragma warning restore CA1031 // Do not catch general exception types + { + return false; + } + + return _plugins.Remove(plugin); + } + + internal LocalPlugin LoadManifest(string dir) + { + Version? version; + PluginManifest? manifest = null; + var metafile = Path.Combine(dir, "meta.json"); + if (File.Exists(metafile)) + { + // Only path where this stays null is when File.ReadAllBytes throws an IOException + byte[] data = null!; + try + { + data = File.ReadAllBytes(metafile); + manifest = JsonSerializer.Deserialize<PluginManifest>(data, _jsonOptions); + } + catch (IOException ex) + { + _logger.LogError(ex, "Error reading file {Path}.", dir); + } + catch (JsonException ex) + { + _logger.LogError(ex, "Error deserializing {Json}.", Encoding.UTF8.GetString(data!)); + } + + if (manifest != null) + { + if (!Version.TryParse(manifest.TargetAbi, out var targetAbi)) + { + targetAbi = _minimumVersion; + } + + if (!Version.TryParse(manifest.Version, out version)) + { + manifest.Version = _minimumVersion.ToString(); + } + + return new LocalPlugin(dir, _appVersion >= targetAbi, manifest); + } + } + + // No metafile, so lets see if the folder is versioned. + // TODO: Phase this support out in future versions. + metafile = dir.Split(Path.DirectorySeparatorChar, StringSplitOptions.RemoveEmptyEntries)[^1]; + int versionIndex = dir.LastIndexOf('_'); + if (versionIndex != -1) + { + // Get the version number from the filename if possible. + metafile = Path.GetFileName(dir[..versionIndex]) ?? dir[..versionIndex]; + version = Version.TryParse(dir.AsSpan()[(versionIndex + 1)..], out Version? parsedVersion) ? parsedVersion : _appVersion; + } + else + { + // Un-versioned folder - Add it under the path name and version it suitable for this instance. + version = _appVersion; + } + + // Auto-create a plugin manifest, so we can disable it, if it fails to load. + manifest = new PluginManifest + { + Status = PluginStatus.Active, + Name = metafile, + AutoUpdate = false, + Id = metafile.GetMD5(), + TargetAbi = _appVersion.ToString(), + Version = version.ToString() + }; + + return new LocalPlugin(dir, true, manifest); + } + + /// <summary> + /// Gets the list of local plugins. + /// </summary> + /// <returns>Enumerable of local plugins.</returns> + private IEnumerable<LocalPlugin> DiscoverPlugins() + { + var versions = new List<LocalPlugin>(); + + if (!Directory.Exists(_pluginsPath)) + { + // Plugin path doesn't exist, don't try to enumerate sub-folders. + return Enumerable.Empty<LocalPlugin>(); + } + + var directories = Directory.EnumerateDirectories(_pluginsPath, "*.*", SearchOption.TopDirectoryOnly); + foreach (var dir in directories) + { + versions.Add(LoadManifest(dir)); + } + + string lastName = string.Empty; + versions.Sort(LocalPlugin.Compare); + // Traverse backwards through the list. + // The first item will be the latest version. + for (int x = versions.Count - 1; x >= 0; x--) + { + var entry = versions[x]; + if (!string.Equals(lastName, entry.Name, StringComparison.OrdinalIgnoreCase)) + { + entry.DllFiles = Directory.GetFiles(entry.Path, "*.dll", SearchOption.AllDirectories); + if (entry.IsEnabledAndSupported) + { + lastName = entry.Name; + continue; + } + } + + if (string.IsNullOrEmpty(lastName)) + { + continue; + } + + var manifest = entry.Manifest; + var cleaned = false; + var path = entry.Path; + if (_config.RemoveOldPlugins) + { + // Attempt a cleanup of old folders. + try + { + _logger.LogDebug("Deleting {Path}", path); + Directory.Delete(path, true); + cleaned = true; + } +#pragma warning disable CA1031 // Do not catch general exception types + catch (Exception e) +#pragma warning restore CA1031 // Do not catch general exception types + { + _logger.LogWarning(e, "Unable to delete {Path}", path); + } + + if (cleaned) + { + versions.RemoveAt(x); + } + else + { + if (manifest == null) + { + _logger.LogWarning("Unable to disable plugin {Path}", entry.Path); + continue; + } + + ChangePluginState(entry, PluginStatus.Deleted); + } + } + } + + // Only want plugin folders which have files. + return versions.Where(p => p.DllFiles.Count != 0); + } + + /// <summary> + /// Changes the status of the other versions of the plugin to "Superceded". + /// </summary> + /// <param name="plugin">The <see cref="LocalPlugin"/> that's master.</param> + private void ProcessAlternative(LocalPlugin plugin) + { + // Detect whether there is another version of this plugin that needs disabling. + var previousVersion = _plugins.OrderByDescending(p => p.Version) + .FirstOrDefault( + p => p.Id.Equals(plugin.Id) + && p.IsEnabledAndSupported + && p.Version != plugin.Version); + + if (previousVersion == null) + { + // This value is memory only - so that the web will show restart required. + plugin.Manifest.Status = PluginStatus.Restart; + return; + } + + if (plugin.Manifest.Status == PluginStatus.Active && !ChangePluginState(previousVersion, PluginStatus.Superceded)) + { + _logger.LogError("Unable to enable version {Version} of {Name}", previousVersion.Version, previousVersion.Name); + } + else if (plugin.Manifest.Status == PluginStatus.Superceded && !ChangePluginState(previousVersion, PluginStatus.Active)) + { + _logger.LogError("Unable to supercede version {Version} of {Name}", previousVersion.Version, previousVersion.Name); + } + + // This value is memory only - so that the web will show restart required. + plugin.Manifest.Status = PluginStatus.Restart; + } + } +} diff --git a/Emby.Server.Implementations/Plugins/PluginManifest.cs b/Emby.Server.Implementations/Plugins/PluginManifest.cs deleted file mode 100644 index 33762791bc..0000000000 --- a/Emby.Server.Implementations/Plugins/PluginManifest.cs +++ /dev/null @@ -1,60 +0,0 @@ -using System; - -namespace Emby.Server.Implementations.Plugins -{ - /// <summary> - /// Defines a Plugin manifest file. - /// </summary> - public class PluginManifest - { - /// <summary> - /// Gets or sets the category of the plugin. - /// </summary> - public string Category { get; set; } - - /// <summary> - /// Gets or sets the changelog information. - /// </summary> - public string Changelog { get; set; } - - /// <summary> - /// Gets or sets the description of the plugin. - /// </summary> - public string Description { get; set; } - - /// <summary> - /// Gets or sets the Global Unique Identifier for the plugin. - /// </summary> - public Guid Guid { get; set; } - - /// <summary> - /// Gets or sets the Name of the plugin. - /// </summary> - public string Name { get; set; } - - /// <summary> - /// Gets or sets an overview of the plugin. - /// </summary> - public string Overview { get; set; } - - /// <summary> - /// Gets or sets the owner of the plugin. - /// </summary> - public string Owner { get; set; } - - /// <summary> - /// Gets or sets the compatibility version for the plugin. - /// </summary> - public string TargetAbi { get; set; } - - /// <summary> - /// Gets or sets the timestamp of the plugin. - /// </summary> - public DateTime Timestamp { get; set; } - - /// <summary> - /// Gets or sets the Version number of the plugin. - /// </summary> - public string Version { get; set; } - } -} diff --git a/Emby.Server.Implementations/Properties/AssemblyInfo.cs b/Emby.Server.Implementations/Properties/AssemblyInfo.cs index a1933f66ef..cb7972173e 100644 --- a/Emby.Server.Implementations/Properties/AssemblyInfo.cs +++ b/Emby.Server.Implementations/Properties/AssemblyInfo.cs @@ -1,5 +1,6 @@ using System.Reflection; using System.Resources; +using System.Runtime.CompilerServices; using System.Runtime.InteropServices; // General Information about an assembly is controlled through the following @@ -14,6 +15,7 @@ using System.Runtime.InteropServices; [assembly: AssemblyTrademark("")] [assembly: AssemblyCulture("")] [assembly: NeutralResourcesLanguage("en")] +[assembly: InternalsVisibleTo("Jellyfin.Server.Implementations.Tests")] // Setting ComVisible to false makes the types in this assembly not visible // to COM components. If you need to access a type in this assembly from diff --git a/Emby.Server.Implementations/QuickConnect/QuickConnectManager.cs b/Emby.Server.Implementations/QuickConnect/QuickConnectManager.cs index 140a675414..898cbedbb6 100644 --- a/Emby.Server.Implementations/QuickConnect/QuickConnectManager.cs +++ b/Emby.Server.Implementations/QuickConnect/QuickConnectManager.cs @@ -3,12 +3,10 @@ using System.Collections.Concurrent; using System.Globalization; using System.Linq; using System.Security.Cryptography; -using MediaBrowser.Common; using MediaBrowser.Common.Extensions; using MediaBrowser.Controller; using MediaBrowser.Controller.Authentication; using MediaBrowser.Controller.Configuration; -using MediaBrowser.Controller.Net; using MediaBrowser.Controller.QuickConnect; using MediaBrowser.Controller.Security; using MediaBrowser.Model.QuickConnect; @@ -21,14 +19,28 @@ namespace Emby.Server.Implementations.QuickConnect /// </summary> public class QuickConnectManager : IQuickConnect, IDisposable { - private readonly RNGCryptoServiceProvider _rng = new RNGCryptoServiceProvider(); - private readonly ConcurrentDictionary<string, QuickConnectResult> _currentRequests = new ConcurrentDictionary<string, QuickConnectResult>(); + /// <summary> + /// The name of internal access tokens. + /// </summary> + private const string TokenName = "QuickConnect"; + + /// <summary> + /// The length of user facing codes. + /// </summary> + private const int CodeLength = 6; + + /// <summary> + /// The time (in minutes) that the quick connect token is valid. + /// </summary> + private const int Timeout = 10; + + private readonly RNGCryptoServiceProvider _rng = new(); + private readonly ConcurrentDictionary<string, QuickConnectResult> _currentRequests = new(); private readonly IServerConfigurationManager _config; private readonly ILogger<QuickConnectManager> _logger; - private readonly IAuthenticationRepository _authenticationRepository; - private readonly IAuthorizationContext _authContext; private readonly IServerApplicationHost _appHost; + private readonly IAuthenticationRepository _authenticationRepository; /// <summary> /// Initializes a new instance of the <see cref="QuickConnectManager"/> class. @@ -37,86 +49,42 @@ namespace Emby.Server.Implementations.QuickConnect /// <param name="config">Configuration.</param> /// <param name="logger">Logger.</param> /// <param name="appHost">Application host.</param> - /// <param name="authContext">Authentication context.</param> /// <param name="authenticationRepository">Authentication repository.</param> public QuickConnectManager( IServerConfigurationManager config, ILogger<QuickConnectManager> logger, IServerApplicationHost appHost, - IAuthorizationContext authContext, IAuthenticationRepository authenticationRepository) { _config = config; _logger = logger; _appHost = appHost; - _authContext = authContext; _authenticationRepository = authenticationRepository; - - ReloadConfiguration(); } - /// <inheritdoc/> - public int CodeLength { get; set; } = 6; + /// <inheritdoc /> + public bool IsEnabled => _config.Configuration.QuickConnectAvailable; - /// <inheritdoc/> - public string TokenName { get; set; } = "QuickConnect"; - - /// <inheritdoc/> - public QuickConnectState State { get; private set; } = QuickConnectState.Unavailable; - - /// <inheritdoc/> - public int Timeout { get; set; } = 5; - - private DateTime DateActivated { get; set; } - - /// <inheritdoc/> - public void AssertActive() + /// <summary> + /// Assert that quick connect is currently active and throws an exception if it is not. + /// </summary> + private void AssertActive() { - if (State != QuickConnectState.Active) + if (!IsEnabled) { - throw new ArgumentException("Quick connect is not active on this server"); + throw new AuthenticationException("Quick connect is not active on this server"); } } - /// <inheritdoc/> - public void Activate() - { - DateActivated = DateTime.UtcNow; - SetState(QuickConnectState.Active); - } - - /// <inheritdoc/> - public void SetState(QuickConnectState newState) - { - _logger.LogDebug("Changed quick connect state from {State} to {newState}", State, newState); - - ExpireRequests(true); - - State = newState; - _config.Configuration.QuickConnectAvailable = newState == QuickConnectState.Available || newState == QuickConnectState.Active; - _config.SaveConfiguration(); - - _logger.LogDebug("Configuration saved"); - } - /// <inheritdoc/> public QuickConnectResult TryConnect() { + AssertActive(); ExpireRequests(); - if (State != QuickConnectState.Active) - { - _logger.LogDebug("Refusing quick connect initiation request, current state is {State}", State); - throw new AuthenticationException("Quick connect is not active on this server"); - } - + var secret = GenerateSecureRandom(); var code = GenerateCode(); - var result = new QuickConnectResult() - { - Secret = GenerateSecureRandom(), - DateAdded = DateTime.UtcNow, - Code = code - }; + var result = new QuickConnectResult(secret, code, DateTime.UtcNow); _currentRequests[code] = result; return result; @@ -125,12 +93,12 @@ namespace Emby.Server.Implementations.QuickConnect /// <inheritdoc/> public QuickConnectResult CheckRequestStatus(string secret) { - ExpireRequests(); AssertActive(); + ExpireRequests(); string code = _currentRequests.Where(x => x.Value.Secret == secret).Select(x => x.Value.Code).DefaultIfEmpty(string.Empty).First(); - if (!_currentRequests.TryGetValue(code, out QuickConnectResult result)) + if (!_currentRequests.TryGetValue(code, out QuickConnectResult? result)) { throw new ResourceNotFoundException("Unable to find request with provided secret"); } @@ -138,8 +106,11 @@ namespace Emby.Server.Implementations.QuickConnect return result; } - /// <inheritdoc/> - public string GenerateCode() + /// <summary> + /// Generates a short code to display to the user to uniquely identify this request. + /// </summary> + /// <returns>A short, unique alphanumeric string.</returns> + private string GenerateCode() { Span<byte> raw = stackalloc byte[4]; @@ -160,10 +131,10 @@ namespace Emby.Server.Implementations.QuickConnect /// <inheritdoc/> public bool AuthorizeRequest(Guid userId, string code) { - ExpireRequests(); AssertActive(); + ExpireRequests(); - if (!_currentRequests.TryGetValue(code, out QuickConnectResult result)) + if (!_currentRequests.TryGetValue(code, out QuickConnectResult? result)) { throw new ResourceNotFoundException("Unable to find request"); } @@ -173,16 +144,16 @@ namespace Emby.Server.Implementations.QuickConnect throw new InvalidOperationException("Request is already authorized"); } - result.Authentication = Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture); + var token = Guid.NewGuid(); + result.Authentication = token; // Change the time on the request so it expires one minute into the future. It can't expire immediately as otherwise some clients wouldn't ever see that they have been authenticated. - var added = result.DateAdded ?? DateTime.UtcNow.Subtract(TimeSpan.FromMinutes(Timeout)); - result.DateAdded = added.Subtract(TimeSpan.FromMinutes(Timeout - 1)); + result.DateAdded = DateTime.Now.Add(TimeSpan.FromMinutes(1)); _authenticationRepository.Create(new AuthenticationInfo { AppName = TokenName, - AccessToken = result.Authentication, + AccessToken = token.ToString("N", CultureInfo.InvariantCulture), DateCreated = DateTime.UtcNow, DeviceId = _appHost.SystemId, DeviceName = _appHost.FriendlyName, @@ -195,28 +166,6 @@ namespace Emby.Server.Implementations.QuickConnect return true; } - /// <inheritdoc/> - public int DeleteAllDevices(Guid user) - { - var raw = _authenticationRepository.Get(new AuthenticationInfoQuery() - { - DeviceId = _appHost.SystemId, - UserId = user - }); - - var tokens = raw.Items.Where(x => x.AppName.StartsWith(TokenName, StringComparison.Ordinal)); - - var removed = 0; - foreach (var token in tokens) - { - _authenticationRepository.Delete(token); - _logger.LogDebug("Deleted token {AccessToken}", token.AccessToken); - removed++; - } - - return removed; - } - /// <summary> /// Dispose. /// </summary> @@ -234,7 +183,7 @@ namespace Emby.Server.Implementations.QuickConnect { if (disposing) { - _rng?.Dispose(); + _rng.Dispose(); } } @@ -243,43 +192,32 @@ namespace Emby.Server.Implementations.QuickConnect Span<byte> bytes = stackalloc byte[length]; _rng.GetBytes(bytes); - return Hex.Encode(bytes); + return Convert.ToHexString(bytes); } - /// <inheritdoc/> - public void ExpireRequests(bool expireAll = false) + /// <summary> + /// Expire quick connect requests that are over the time limit. If <paramref name="expireAll"/> is true, all requests are unconditionally expired. + /// </summary> + /// <param name="expireAll">If true, all requests will be expired.</param> + private void ExpireRequests(bool expireAll = false) { - // Check if quick connect should be deactivated - if (State == QuickConnectState.Active && DateTime.UtcNow > DateActivated.AddMinutes(Timeout) && !expireAll) - { - _logger.LogDebug("Quick connect time expired, deactivating"); - SetState(QuickConnectState.Available); - expireAll = true; - } + // All requests before this timestamp have expired + var minTime = DateTime.UtcNow.AddMinutes(-Timeout); // Expire stale connection requests - var code = string.Empty; - var values = _currentRequests.Values.ToList(); - - for (int i = 0; i < values.Count; i++) + foreach (var (_, currentRequest) in _currentRequests) { - var added = values[i].DateAdded ?? DateTime.UnixEpoch; - if (DateTime.UtcNow > added.AddMinutes(Timeout) || expireAll) + if (expireAll || currentRequest.DateAdded < minTime) { - code = values[i].Code; - _logger.LogDebug("Removing expired request {code}", code); + var code = currentRequest.Code; + _logger.LogDebug("Removing expired request {Code}", code); if (!_currentRequests.TryRemove(code, out _)) { - _logger.LogWarning("Request {code} already expired", code); + _logger.LogWarning("Request {Code} already expired", code); } } } } - - private void ReloadConfiguration() - { - State = _config.Configuration.QuickConnectAvailable ? QuickConnectState.Available : QuickConnectState.Unavailable; - } } } diff --git a/Emby.Server.Implementations/ResourceFileManager.cs b/Emby.Server.Implementations/ResourceFileManager.cs deleted file mode 100644 index 22fc62293a..0000000000 --- a/Emby.Server.Implementations/ResourceFileManager.cs +++ /dev/null @@ -1,45 +0,0 @@ -#pragma warning disable CS1591 - -using System; -using System.IO; -using MediaBrowser.Controller; -using MediaBrowser.Controller.Net; -using MediaBrowser.Model.IO; -using Microsoft.Extensions.Logging; - -namespace Emby.Server.Implementations -{ - public class ResourceFileManager : IResourceFileManager - { - private readonly IFileSystem _fileSystem; - private readonly ILogger<ResourceFileManager> _logger; - - public ResourceFileManager(ILogger<ResourceFileManager> logger, IFileSystem fileSystem) - { - _logger = logger; - _fileSystem = fileSystem; - } - - public string GetResourcePath(string basePath, string virtualPath) - { - var fullPath = Path.Combine(basePath, virtualPath.Replace('/', Path.DirectorySeparatorChar)); - - try - { - fullPath = Path.GetFullPath(fullPath); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error retrieving full path"); - } - - // Don't allow file system access outside of the source folder - if (!_fileSystem.ContainsSubPath(basePath, fullPath)) - { - throw new SecurityException("Access denied"); - } - - return fullPath; - } - } -} diff --git a/Emby.Server.Implementations/ScheduledTasks/ScheduledTaskWorker.cs b/Emby.Server.Implementations/ScheduledTasks/ScheduledTaskWorker.cs index bc01f95438..b343250419 100644 --- a/Emby.Server.Implementations/ScheduledTasks/ScheduledTaskWorker.cs +++ b/Emby.Server.Implementations/ScheduledTasks/ScheduledTaskWorker.cs @@ -1,16 +1,19 @@ +#nullable disable + #pragma warning disable CS1591 using System; using System.Globalization; using System.IO; using System.Linq; +using System.Text.Json; using System.Threading; using System.Threading.Tasks; using Jellyfin.Data.Events; using MediaBrowser.Common.Configuration; using MediaBrowser.Common.Extensions; +using Jellyfin.Extensions.Json; using MediaBrowser.Common.Progress; -using MediaBrowser.Model.Serialization; using MediaBrowser.Model.Tasks; using Microsoft.Extensions.Logging; @@ -21,11 +24,6 @@ namespace Emby.Server.Implementations.ScheduledTasks /// </summary> public class ScheduledTaskWorker : IScheduledTaskWorker { - /// <summary> - /// Gets or sets the json serializer. - /// </summary> - /// <value>The json serializer.</value> - private readonly IJsonSerializer _jsonSerializer; /// <summary> /// Gets or sets the application paths. @@ -69,13 +67,17 @@ namespace Emby.Server.Implementations.ScheduledTasks /// </summary> private string _id; + /// <summary> + /// The options for the json Serializer. + /// </summary> + private readonly JsonSerializerOptions _jsonOptions = JsonDefaults.Options; + /// <summary> /// Initializes a new instance of the <see cref="ScheduledTaskWorker" /> class. /// </summary> /// <param name="scheduledTask">The scheduled task.</param> /// <param name="applicationPaths">The application paths.</param> /// <param name="taskManager">The task manager.</param> - /// <param name="jsonSerializer">The json serializer.</param> /// <param name="logger">The logger.</param> /// <exception cref="ArgumentNullException"> /// scheduledTask @@ -88,7 +90,7 @@ namespace Emby.Server.Implementations.ScheduledTasks /// or /// logger. /// </exception> - public ScheduledTaskWorker(IScheduledTask scheduledTask, IApplicationPaths applicationPaths, ITaskManager taskManager, IJsonSerializer jsonSerializer, ILogger logger) + public ScheduledTaskWorker(IScheduledTask scheduledTask, IApplicationPaths applicationPaths, ITaskManager taskManager, ILogger logger) { if (scheduledTask == null) { @@ -105,11 +107,6 @@ namespace Emby.Server.Implementations.ScheduledTasks throw new ArgumentNullException(nameof(taskManager)); } - if (jsonSerializer == null) - { - throw new ArgumentNullException(nameof(jsonSerializer)); - } - if (logger == null) { throw new ArgumentNullException(nameof(logger)); @@ -118,7 +115,6 @@ namespace Emby.Server.Implementations.ScheduledTasks ScheduledTask = scheduledTask; _applicationPaths = applicationPaths; _taskManager = taskManager; - _jsonSerializer = jsonSerializer; _logger = logger; InitTriggerEvents(); @@ -148,13 +144,21 @@ namespace Emby.Server.Implementations.ScheduledTasks { if (File.Exists(path)) { - try + var bytes = File.ReadAllBytes(path); + if (bytes.Length > 0) { - _lastExecutionResult = _jsonSerializer.DeserializeFromFile<TaskResult>(path); + try + { + _lastExecutionResult = JsonSerializer.Deserialize<TaskResult>(bytes, _jsonOptions); + } + catch (JsonException ex) + { + _logger.LogError(ex, "Error deserializing {File}", path); + } } - catch (Exception ex) + else { - _logger.LogError(ex, "Error deserializing {File}", path); + _logger.LogDebug("Scheduled Task history file {Path} is empty. Skipping deserialization.", path); } } @@ -174,7 +178,9 @@ namespace Emby.Server.Implementations.ScheduledTasks lock (_lastExecutionResultSyncLock) { - _jsonSerializer.SerializeToFile(value, path); + using FileStream createStream = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.None); + using Utf8JsonWriter jsonStream = new Utf8JsonWriter(createStream); + JsonSerializer.Serialize(jsonStream, value, _jsonOptions); } } } @@ -297,12 +303,7 @@ namespace Emby.Server.Implementations.ScheduledTasks { get { - if (_id == null) - { - _id = ScheduledTask.GetType().FullName.GetMD5().ToString("N", CultureInfo.InvariantCulture); - } - - return _id; + return _id ??= ScheduledTask.GetType().FullName.GetMD5().ToString("N", CultureInfo.InvariantCulture); } } @@ -344,9 +345,7 @@ namespace Emby.Server.Implementations.ScheduledTasks { var trigger = (ITaskTrigger)sender; - var configurableTask = ScheduledTask as IConfigurableScheduledTask; - - if (configurableTask != null && !configurableTask.IsEnabled) + if (ScheduledTask is IConfigurableScheduledTask configurableTask && !configurableTask.IsEnabled) { return; } @@ -537,7 +536,8 @@ namespace Emby.Server.Implementations.ScheduledTasks TaskTriggerInfo[] list = null; if (File.Exists(path)) { - list = _jsonSerializer.DeserializeFromFile<TaskTriggerInfo[]>(path); + var bytes = File.ReadAllBytes(path); + list = JsonSerializer.Deserialize<TaskTriggerInfo[]>(bytes, _jsonOptions); } // Return defaults if file doesn't exist. @@ -572,8 +572,9 @@ namespace Emby.Server.Implementations.ScheduledTasks var path = GetConfigurationFilePath(); Directory.CreateDirectory(Path.GetDirectoryName(path)); - - _jsonSerializer.SerializeToFile(triggers, path); + using FileStream createStream = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.None); + using Utf8JsonWriter jsonWriter = new Utf8JsonWriter(createStream); + JsonSerializer.Serialize(jsonWriter, triggers, _jsonOptions); } /// <summary> @@ -653,7 +654,7 @@ namespace Emby.Server.Implementations.ScheduledTasks try { _logger.LogInformation(Name + ": Waiting on Task"); - var exited = Task.WaitAll(new[] { task }, 2000); + var exited = task.Wait(2000); if (exited) { @@ -703,21 +704,17 @@ namespace Emby.Server.Implementations.ScheduledTasks MaxRuntimeTicks = info.MaxRuntimeTicks }; - if (info.Type.Equals(typeof(DailyTrigger).Name, StringComparison.OrdinalIgnoreCase)) + if (info.Type.Equals(nameof(DailyTrigger), StringComparison.OrdinalIgnoreCase)) { if (!info.TimeOfDayTicks.HasValue) { throw new ArgumentException("Info did not contain a TimeOfDayTicks.", nameof(info)); } - return new DailyTrigger - { - TimeOfDay = TimeSpan.FromTicks(info.TimeOfDayTicks.Value), - TaskOptions = options - }; + return new DailyTrigger(TimeSpan.FromTicks(info.TimeOfDayTicks.Value), options); } - if (info.Type.Equals(typeof(WeeklyTrigger).Name, StringComparison.OrdinalIgnoreCase)) + if (info.Type.Equals(nameof(WeeklyTrigger), StringComparison.OrdinalIgnoreCase)) { if (!info.TimeOfDayTicks.HasValue) { @@ -729,31 +726,22 @@ namespace Emby.Server.Implementations.ScheduledTasks throw new ArgumentException("Info did not contain a DayOfWeek.", nameof(info)); } - return new WeeklyTrigger - { - TimeOfDay = TimeSpan.FromTicks(info.TimeOfDayTicks.Value), - DayOfWeek = info.DayOfWeek.Value, - TaskOptions = options - }; + return new WeeklyTrigger(TimeSpan.FromTicks(info.TimeOfDayTicks.Value), info.DayOfWeek.Value, options); } - if (info.Type.Equals(typeof(IntervalTrigger).Name, StringComparison.OrdinalIgnoreCase)) + if (info.Type.Equals(nameof(IntervalTrigger), StringComparison.OrdinalIgnoreCase)) { if (!info.IntervalTicks.HasValue) { throw new ArgumentException("Info did not contain a IntervalTicks.", nameof(info)); } - return new IntervalTrigger - { - Interval = TimeSpan.FromTicks(info.IntervalTicks.Value), - TaskOptions = options - }; + return new IntervalTrigger(TimeSpan.FromTicks(info.IntervalTicks.Value), options); } - if (info.Type.Equals(typeof(StartupTrigger).Name, StringComparison.OrdinalIgnoreCase)) + if (info.Type.Equals(nameof(StartupTrigger), StringComparison.OrdinalIgnoreCase)) { - return new StartupTrigger(); + return new StartupTrigger(options); } throw new ArgumentException("Unrecognized trigger type: " + info.Type); diff --git a/Emby.Server.Implementations/ScheduledTasks/TaskManager.cs b/Emby.Server.Implementations/ScheduledTasks/TaskManager.cs index 6f81bf49bb..4f0df75bf8 100644 --- a/Emby.Server.Implementations/ScheduledTasks/TaskManager.cs +++ b/Emby.Server.Implementations/ScheduledTasks/TaskManager.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System; @@ -7,7 +9,6 @@ using System.Linq; using System.Threading.Tasks; using Jellyfin.Data.Events; using MediaBrowser.Common.Configuration; -using MediaBrowser.Model.Serialization; using MediaBrowser.Model.Tasks; using Microsoft.Extensions.Logging; @@ -19,6 +20,7 @@ namespace Emby.Server.Implementations.ScheduledTasks public class TaskManager : ITaskManager { public event EventHandler<GenericEventArgs<IScheduledTaskWorker>> TaskExecuting; + public event EventHandler<TaskCompletionEventArgs> TaskCompleted; /// <summary> @@ -33,7 +35,6 @@ namespace Emby.Server.Implementations.ScheduledTasks private readonly ConcurrentQueue<Tuple<Type, TaskOptions>> _taskQueue = new ConcurrentQueue<Tuple<Type, TaskOptions>>(); - private readonly IJsonSerializer _jsonSerializer; private readonly IApplicationPaths _applicationPaths; private readonly ILogger<TaskManager> _logger; @@ -41,15 +42,12 @@ namespace Emby.Server.Implementations.ScheduledTasks /// Initializes a new instance of the <see cref="TaskManager" /> class. /// </summary> /// <param name="applicationPaths">The application paths.</param> - /// <param name="jsonSerializer">The json serializer.</param> /// <param name="logger">The logger.</param> public TaskManager( IApplicationPaths applicationPaths, - IJsonSerializer jsonSerializer, ILogger<TaskManager> logger) { _applicationPaths = applicationPaths; - _jsonSerializer = jsonSerializer; _logger = logger; ScheduledTasks = Array.Empty<IScheduledTaskWorker>(); @@ -136,7 +134,7 @@ namespace Emby.Server.Implementations.ScheduledTasks { var type = scheduledTask.ScheduledTask.GetType(); - _logger.LogInformation("Queueing task {0}", type.Name); + _logger.LogInformation("Queuing task {0}", type.Name); lock (_taskQueue) { @@ -176,7 +174,7 @@ namespace Emby.Server.Implementations.ScheduledTasks { var type = task.ScheduledTask.GetType(); - _logger.LogInformation("Queueing task {0}", type.Name); + _logger.LogInformation("Queuing task {0}", type.Name); lock (_taskQueue) { @@ -196,7 +194,7 @@ namespace Emby.Server.Implementations.ScheduledTasks /// <param name="tasks">The tasks.</param> public void AddTasks(IEnumerable<IScheduledTask> tasks) { - var list = tasks.Select(t => new ScheduledTaskWorker(t, _applicationPaths, this, _jsonSerializer, _logger)); + var list = tasks.Select(t => new ScheduledTaskWorker(t, _applicationPaths, this, _logger)); ScheduledTasks = ScheduledTasks.Concat(list).ToArray(); } diff --git a/Emby.Server.Implementations/ScheduledTasks/Tasks/ChapterImagesTask.cs b/Emby.Server.Implementations/ScheduledTasks/Tasks/ChapterImagesTask.cs index 8439f8a99d..b764a139cb 100644 --- a/Emby.Server.Implementations/ScheduledTasks/Tasks/ChapterImagesTask.cs +++ b/Emby.Server.Implementations/ScheduledTasks/Tasks/ChapterImagesTask.cs @@ -12,9 +12,9 @@ using MediaBrowser.Controller.MediaEncoding; using MediaBrowser.Controller.Persistence; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Entities; +using MediaBrowser.Model.Globalization; using MediaBrowser.Model.IO; using MediaBrowser.Model.Tasks; -using MediaBrowser.Model.Globalization; namespace Emby.Server.Implementations.ScheduledTasks { @@ -55,9 +55,19 @@ namespace Emby.Server.Implementations.ScheduledTasks _localization = localization; } - /// <summary> - /// Creates the triggers that define when the task will run. - /// </summary> + /// <inheritdoc /> + public string Name => _localization.GetLocalizedString("TaskRefreshChapterImages"); + + /// <inheritdoc /> + public string Description => _localization.GetLocalizedString("TaskRefreshChapterImagesDescription"); + + /// <inheritdoc /> + public string Category => _localization.GetLocalizedString("TasksLibraryCategory"); + + /// <inheritdoc /> + public string Key => "RefreshChapterImages"; + + /// <inheritdoc /> public IEnumerable<TaskTriggerInfo> GetDefaultTriggers() { return new[] @@ -106,7 +116,7 @@ namespace Emby.Server.Implementations.ScheduledTasks try { previouslyFailedImages = File.ReadAllText(failHistoryPath) - .Split(new[] { '|' }, StringSplitOptions.RemoveEmptyEntries) + .Split('|', StringSplitOptions.RemoveEmptyEntries) .ToList(); } catch (IOException) @@ -140,10 +150,12 @@ namespace Emby.Server.Implementations.ScheduledTasks previouslyFailedImages.Add(key); var parentPath = Path.GetDirectoryName(failHistoryPath); + if (parentPath != null) + { + Directory.CreateDirectory(parentPath); + } - Directory.CreateDirectory(parentPath); - - string text = string.Join("|", previouslyFailedImages); + string text = string.Join('|', previouslyFailedImages); File.WriteAllText(failHistoryPath, text); } @@ -160,26 +172,5 @@ namespace Emby.Server.Implementations.ScheduledTasks } } } - - /// <inheritdoc /> - public string Name => _localization.GetLocalizedString("TaskRefreshChapterImages"); - - /// <inheritdoc /> - public string Description => _localization.GetLocalizedString("TaskRefreshChapterImagesDescription"); - - /// <inheritdoc /> - public string Category => _localization.GetLocalizedString("TasksLibraryCategory"); - - /// <inheritdoc /> - public string Key => "RefreshChapterImages"; - - /// <inheritdoc /> - public bool IsHidden => false; - - /// <inheritdoc /> - public bool IsEnabled => true; - - /// <inheritdoc /> - public bool IsLogged => true; } } diff --git a/Emby.Server.Implementations/ScheduledTasks/Tasks/CleanActivityLogTask.cs b/Emby.Server.Implementations/ScheduledTasks/Tasks/CleanActivityLogTask.cs new file mode 100644 index 0000000000..19600b1e64 --- /dev/null +++ b/Emby.Server.Implementations/ScheduledTasks/Tasks/CleanActivityLogTask.cs @@ -0,0 +1,78 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Model.Activity; +using MediaBrowser.Model.Globalization; +using MediaBrowser.Model.Tasks; + +namespace Emby.Server.Implementations.ScheduledTasks.Tasks +{ + /// <summary> + /// Deletes old activity log entries. + /// </summary> + public class CleanActivityLogTask : IScheduledTask, IConfigurableScheduledTask + { + private readonly ILocalizationManager _localization; + private readonly IActivityManager _activityManager; + private readonly IServerConfigurationManager _serverConfigurationManager; + + /// <summary> + /// Initializes a new instance of the <see cref="CleanActivityLogTask"/> class. + /// </summary> + /// <param name="localization">Instance of the <see cref="ILocalizationManager"/> interface.</param> + /// <param name="activityManager">Instance of the <see cref="IActivityManager"/> interface.</param> + /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param> + public CleanActivityLogTask( + ILocalizationManager localization, + IActivityManager activityManager, + IServerConfigurationManager serverConfigurationManager) + { + _localization = localization; + _activityManager = activityManager; + _serverConfigurationManager = serverConfigurationManager; + } + + /// <inheritdoc /> + public string Name => _localization.GetLocalizedString("TaskCleanActivityLog"); + + /// <inheritdoc /> + public string Key => "CleanActivityLog"; + + /// <inheritdoc /> + public string Description => _localization.GetLocalizedString("TaskCleanActivityLogDescription"); + + /// <inheritdoc /> + public string Category => _localization.GetLocalizedString("TasksMaintenanceCategory"); + + /// <inheritdoc /> + public bool IsHidden => false; + + /// <inheritdoc /> + public bool IsEnabled => true; + + /// <inheritdoc /> + public bool IsLogged => true; + + /// <inheritdoc /> + public Task Execute(CancellationToken cancellationToken, IProgress<double> progress) + { + var retentionDays = _serverConfigurationManager.Configuration.ActivityLogRetentionDays; + if (!retentionDays.HasValue || retentionDays <= 0) + { + throw new Exception($"Activity Log Retention days must be at least 0. Currently: {retentionDays}"); + } + + var startDate = DateTime.UtcNow.AddDays(-retentionDays.Value); + return _activityManager.CleanAsync(startDate); + } + + /// <inheritdoc /> + public IEnumerable<TaskTriggerInfo> GetDefaultTriggers() + { + return Enumerable.Empty<TaskTriggerInfo>(); + } + } +} diff --git a/Emby.Server.Implementations/ScheduledTasks/Tasks/DeleteLogFileTask.cs b/Emby.Server.Implementations/ScheduledTasks/Tasks/DeleteLogFileTask.cs index 184d155d44..fedb5deb01 100644 --- a/Emby.Server.Implementations/ScheduledTasks/Tasks/DeleteLogFileTask.cs +++ b/Emby.Server.Implementations/ScheduledTasks/Tasks/DeleteLogFileTask.cs @@ -80,10 +80,11 @@ namespace Emby.Server.Implementations.ScheduledTasks.Tasks // Delete log files more than n days old var minDateModified = DateTime.UtcNow.AddDays(-_configurationManager.CommonConfiguration.LogFileRetentionDays); - // Only delete the .txt log files, the *.log files created by serilog get managed by itself - var filesToDelete = _fileSystem.GetFiles(_configurationManager.CommonApplicationPaths.LogDirectoryPath, new[] { ".txt" }, true, true) - .Where(f => _fileSystem.GetLastWriteTimeUtc(f) < minDateModified) - .ToList(); + // Only delete files that serilog doesn't manage (anything that doesn't start with 'log_' + var filesToDelete = _fileSystem.GetFiles(_configurationManager.CommonApplicationPaths.LogDirectoryPath, true) + .Where(f => !f.Name.StartsWith("log_", StringComparison.Ordinal) + && _fileSystem.GetLastWriteTimeUtc(f) < minDateModified) + .ToList(); var index = 0; diff --git a/Emby.Server.Implementations/ScheduledTasks/Tasks/DeleteTranscodeFileTask.cs b/Emby.Server.Implementations/ScheduledTasks/Tasks/DeleteTranscodeFileTask.cs index 26ef193542..b13fc7fc68 100644 --- a/Emby.Server.Implementations/ScheduledTasks/Tasks/DeleteTranscodeFileTask.cs +++ b/Emby.Server.Implementations/ScheduledTasks/Tasks/DeleteTranscodeFileTask.cs @@ -5,10 +5,10 @@ using System.Linq; using System.Threading; using System.Threading.Tasks; using MediaBrowser.Common.Configuration; +using MediaBrowser.Model.Globalization; using MediaBrowser.Model.IO; using MediaBrowser.Model.Tasks; using Microsoft.Extensions.Logging; -using MediaBrowser.Model.Globalization; namespace Emby.Server.Implementations.ScheduledTasks.Tasks { @@ -23,8 +23,12 @@ namespace Emby.Server.Implementations.ScheduledTasks.Tasks private readonly ILocalizationManager _localization; /// <summary> - /// Initializes a new instance of the <see cref="DeleteTranscodeFileTask" /> class. + /// Initializes a new instance of the <see cref="DeleteTranscodeFileTask"/> class. /// </summary> + /// <param name="logger">Instance of the <see cref="ILogger{DeleteTranscodeFileTask}"/> interface.</param> + /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param> + /// <param name="configurationManager">Instance of the <see cref="IConfigurationManager"/> interface.</param> + /// <param name="localization">Instance of the <see cref="ILocalizationManager"/> interface.</param> public DeleteTranscodeFileTask( ILogger<DeleteTranscodeFileTask> logger, IFileSystem fileSystem, @@ -37,11 +41,42 @@ namespace Emby.Server.Implementations.ScheduledTasks.Tasks _localization = localization; } + /// <inheritdoc /> + public string Name => _localization.GetLocalizedString("TaskCleanTranscode"); + + /// <inheritdoc /> + public string Description => _localization.GetLocalizedString("TaskCleanTranscodeDescription"); + + /// <inheritdoc /> + public string Category => _localization.GetLocalizedString("TasksMaintenanceCategory"); + + /// <inheritdoc /> + public string Key => "DeleteTranscodeFiles"; + + /// <inheritdoc /> + public bool IsHidden => false; + + /// <inheritdoc /> + public bool IsEnabled => true; + + /// <inheritdoc /> + public bool IsLogged => true; + /// <summary> /// Creates the triggers that define when the task will run. /// </summary> /// <returns>IEnumerable{BaseTaskTrigger}.</returns> - public IEnumerable<TaskTriggerInfo> GetDefaultTriggers() => new List<TaskTriggerInfo>(); + public IEnumerable<TaskTriggerInfo> GetDefaultTriggers() + { + return new[] + { + new TaskTriggerInfo + { + Type = TaskTriggerInfo.TriggerInterval, + IntervalTicks = TimeSpan.FromHours(24).Ticks + } + }; + } /// <summary> /// Returns the task to be executed. @@ -131,26 +166,5 @@ namespace Emby.Server.Implementations.ScheduledTasks.Tasks _logger.LogError(ex, "Error deleting file {path}", path); } } - - /// <inheritdoc /> - public string Name => _localization.GetLocalizedString("TaskCleanTranscode"); - - /// <inheritdoc /> - public string Description => _localization.GetLocalizedString("TaskCleanTranscodeDescription"); - - /// <inheritdoc /> - public string Category => _localization.GetLocalizedString("TasksMaintenanceCategory"); - - /// <inheritdoc /> - public string Key => "DeleteTranscodeFiles"; - - /// <inheritdoc /> - public bool IsHidden => false; - - /// <inheritdoc /> - public bool IsEnabled => true; - - /// <inheritdoc /> - public bool IsLogged => true; } } diff --git a/Emby.Server.Implementations/ScheduledTasks/Tasks/OptimizeDatabaseTask.cs b/Emby.Server.Implementations/ScheduledTasks/Tasks/OptimizeDatabaseTask.cs new file mode 100644 index 0000000000..1ad1d0f50a --- /dev/null +++ b/Emby.Server.Implementations/ScheduledTasks/Tasks/OptimizeDatabaseTask.cs @@ -0,0 +1,101 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Server.Implementations; +using MediaBrowser.Model.Globalization; +using MediaBrowser.Model.Tasks; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace Emby.Server.Implementations.ScheduledTasks.Tasks +{ + /// <summary> + /// Optimizes Jellyfin's database by issuing a VACUUM command. + /// </summary> + public class OptimizeDatabaseTask : IScheduledTask, IConfigurableScheduledTask + { + private readonly ILogger<OptimizeDatabaseTask> _logger; + private readonly ILocalizationManager _localization; + private readonly JellyfinDbProvider _provider; + + /// <summary> + /// Initializes a new instance of the <see cref="OptimizeDatabaseTask" /> class. + /// </summary> + public OptimizeDatabaseTask( + ILogger<OptimizeDatabaseTask> logger, + ILocalizationManager localization, + JellyfinDbProvider provider) + { + _logger = logger; + _localization = localization; + _provider = provider; + } + + /// <inheritdoc /> + public string Name => _localization.GetLocalizedString("TaskOptimizeDatabase"); + + /// <inheritdoc /> + public string Description => _localization.GetLocalizedString("TaskOptimizeDatabaseDescription"); + + /// <inheritdoc /> + public string Category => _localization.GetLocalizedString("TasksMaintenanceCategory"); + + /// <inheritdoc /> + public string Key => "OptimizeDatabaseTask"; + + /// <inheritdoc /> + public bool IsHidden => false; + + /// <inheritdoc /> + public bool IsEnabled => true; + + /// <inheritdoc /> + public bool IsLogged => true; + + /// <summary> + /// Creates the triggers that define when the task will run. + /// </summary> + /// <returns>IEnumerable{BaseTaskTrigger}.</returns> + public IEnumerable<TaskTriggerInfo> GetDefaultTriggers() + { + return new[] + { + // Every so often + new TaskTriggerInfo { Type = TaskTriggerInfo.TriggerInterval, IntervalTicks = TimeSpan.FromHours(24).Ticks } + }; + } + + /// <summary> + /// Returns the task to be executed. + /// </summary> + /// <param name="cancellationToken">The cancellation token.</param> + /// <param name="progress">The progress.</param> + /// <returns>Task.</returns> + public Task Execute(CancellationToken cancellationToken, IProgress<double> progress) + { + _logger.LogInformation("Optimizing and vacuuming jellyfin.db..."); + + try + { + using var context = _provider.CreateContext(); + if (context.Database.IsSqlite()) + { + context.Database.ExecuteSqlRaw("PRAGMA optimize"); + context.Database.ExecuteSqlRaw("VACUUM"); + _logger.LogInformation("jellyfin.db optimized successfully!"); + } + else + { + _logger.LogInformation("This database doesn't support optimization"); + } + } + catch (Exception e) + { + _logger.LogError(e, "Error while optimizing jellyfin.db"); + } + + return Task.CompletedTask; + } + } +} diff --git a/Emby.Server.Implementations/ScheduledTasks/Tasks/PeopleValidationTask.cs b/Emby.Server.Implementations/ScheduledTasks/Tasks/PeopleValidationTask.cs index c384cf4bbe..57d294a408 100644 --- a/Emby.Server.Implementations/ScheduledTasks/Tasks/PeopleValidationTask.cs +++ b/Emby.Server.Implementations/ScheduledTasks/Tasks/PeopleValidationTask.cs @@ -5,8 +5,8 @@ using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; using MediaBrowser.Controller.Library; -using MediaBrowser.Model.Tasks; using MediaBrowser.Model.Globalization; +using MediaBrowser.Model.Tasks; namespace Emby.Server.Implementations.ScheduledTasks { diff --git a/Emby.Server.Implementations/ScheduledTasks/Tasks/PluginUpdateTask.cs b/Emby.Server.Implementations/ScheduledTasks/Tasks/PluginUpdateTask.cs index c5af68bcec..11a5fb79f4 100644 --- a/Emby.Server.Implementations/ScheduledTasks/Tasks/PluginUpdateTask.cs +++ b/Emby.Server.Implementations/ScheduledTasks/Tasks/PluginUpdateTask.cs @@ -4,13 +4,13 @@ using System; using System.Collections.Generic; using System.IO; using System.Linq; +using System.Net.Http; using System.Threading; using System.Threading.Tasks; using MediaBrowser.Common.Updates; -using MediaBrowser.Model.Net; +using MediaBrowser.Model.Globalization; using MediaBrowser.Model.Tasks; using Microsoft.Extensions.Logging; -using MediaBrowser.Model.Globalization; namespace Emby.Server.Implementations.ScheduledTasks { @@ -101,7 +101,7 @@ namespace Emby.Server.Implementations.ScheduledTasks throw; } } - catch (HttpException ex) + catch (HttpRequestException ex) { _logger.LogError(ex, "Error downloading {0}", package.Name); } diff --git a/Emby.Server.Implementations/ScheduledTasks/Tasks/RefreshMediaLibraryTask.cs b/Emby.Server.Implementations/ScheduledTasks/Tasks/RefreshMediaLibraryTask.cs index e470adcf48..51b620404b 100644 --- a/Emby.Server.Implementations/ScheduledTasks/Tasks/RefreshMediaLibraryTask.cs +++ b/Emby.Server.Implementations/ScheduledTasks/Tasks/RefreshMediaLibraryTask.cs @@ -6,8 +6,8 @@ using System.Threading; using System.Threading.Tasks; using Emby.Server.Implementations.Library; using MediaBrowser.Controller.Library; -using MediaBrowser.Model.Tasks; using MediaBrowser.Model.Globalization; +using MediaBrowser.Model.Tasks; namespace Emby.Server.Implementations.ScheduledTasks { diff --git a/Emby.Server.Implementations/ScheduledTasks/Triggers/DailyTrigger.cs b/Emby.Server.Implementations/ScheduledTasks/Triggers/DailyTrigger.cs index 8b67d37d7a..29ab6a73dc 100644 --- a/Emby.Server.Implementations/ScheduledTasks/Triggers/DailyTrigger.cs +++ b/Emby.Server.Implementations/ScheduledTasks/Triggers/DailyTrigger.cs @@ -8,29 +8,31 @@ namespace Emby.Server.Implementations.ScheduledTasks /// <summary> /// Represents a task trigger that fires everyday. /// </summary> - public class DailyTrigger : ITaskTrigger + public sealed class DailyTrigger : ITaskTrigger { - /// <summary> - /// Occurs when [triggered]. - /// </summary> - public event EventHandler<EventArgs> Triggered; + private readonly TimeSpan _timeOfDay; + private Timer? _timer; /// <summary> - /// Gets or sets the time of day to trigger the task to run. + /// Initializes a new instance of the <see cref="DailyTrigger"/> class. /// </summary> - /// <value>The time of day.</value> - public TimeSpan TimeOfDay { get; set; } + /// <param name="timeofDay">The time of day to trigger the task to run.</param> + /// <param name="taskOptions">The options of this task.</param> + public DailyTrigger(TimeSpan timeofDay, TaskOptions taskOptions) + { + _timeOfDay = timeofDay; + TaskOptions = taskOptions; + } /// <summary> - /// Gets or sets the options of this task. + /// Occurs when [triggered]. /// </summary> - public TaskOptions TaskOptions { get; set; } + public event EventHandler<EventArgs>? Triggered; /// <summary> - /// Gets or sets the timer. + /// Gets the options of this task. /// </summary> - /// <value>The timer.</value> - private Timer Timer { get; set; } + public TaskOptions TaskOptions { get; } /// <summary> /// Stars waiting for the trigger action. @@ -45,14 +47,14 @@ namespace Emby.Server.Implementations.ScheduledTasks var now = DateTime.Now; - var triggerDate = now.TimeOfDay > TimeOfDay ? now.Date.AddDays(1) : now.Date; - triggerDate = triggerDate.Add(TimeOfDay); + var triggerDate = now.TimeOfDay > _timeOfDay ? now.Date.AddDays(1) : now.Date; + triggerDate = triggerDate.Add(_timeOfDay); var dueTime = triggerDate - now; - logger.LogInformation("Daily trigger for {Task} set to fire at {TriggerDate:g}, which is {DueTime:g} from now.", taskName, triggerDate, dueTime); + logger.LogInformation("Daily trigger for {Task} set to fire at {TriggerDate:yyyy-MM-dd HH:mm:ss.fff zzz}, which is {DueTime:c} from now.", taskName, triggerDate, dueTime); - Timer = new Timer(state => OnTriggered(), null, dueTime, TimeSpan.FromMilliseconds(-1)); + _timer = new Timer(state => OnTriggered(), null, dueTime, TimeSpan.FromMilliseconds(-1)); } /// <summary> @@ -68,10 +70,7 @@ namespace Emby.Server.Implementations.ScheduledTasks /// </summary> private void DisposeTimer() { - if (Timer != null) - { - Timer.Dispose(); - } + _timer?.Dispose(); } /// <summary> diff --git a/Emby.Server.Implementations/ScheduledTasks/Triggers/IntervalTrigger.cs b/Emby.Server.Implementations/ScheduledTasks/Triggers/IntervalTrigger.cs index b04fd7c7e2..30568e8092 100644 --- a/Emby.Server.Implementations/ScheduledTasks/Triggers/IntervalTrigger.cs +++ b/Emby.Server.Implementations/ScheduledTasks/Triggers/IntervalTrigger.cs @@ -9,31 +9,32 @@ namespace Emby.Server.Implementations.ScheduledTasks /// <summary> /// Represents a task trigger that runs repeatedly on an interval. /// </summary> - public class IntervalTrigger : ITaskTrigger + public sealed class IntervalTrigger : ITaskTrigger { + private readonly TimeSpan _interval; private DateTime _lastStartDate; + private Timer? _timer; /// <summary> - /// Occurs when [triggered]. + /// Initializes a new instance of the <see cref="IntervalTrigger"/> class. /// </summary> - public event EventHandler<EventArgs> Triggered; - - /// <summary> - /// Gets or sets the interval. - /// </summary> - /// <value>The interval.</value> - public TimeSpan Interval { get; set; } + /// <param name="interval">The interval.</param> + /// <param name="taskOptions">The options of this task.</param> + public IntervalTrigger(TimeSpan interval, TaskOptions taskOptions) + { + _interval = interval; + TaskOptions = taskOptions; + } /// <summary> - /// Gets or sets the options of this task. + /// Occurs when [triggered]. /// </summary> - public TaskOptions TaskOptions { get; set; } + public event EventHandler<EventArgs>? Triggered; /// <summary> - /// Gets or sets the timer. + /// Gets the options of this task. /// </summary> - /// <value>The timer.</value> - private Timer Timer { get; set; } + public TaskOptions TaskOptions { get; } /// <summary> /// Stars waiting for the trigger action. @@ -55,7 +56,7 @@ namespace Emby.Server.Implementations.ScheduledTasks } else { - triggerDate = new[] { lastResult.EndTimeUtc, _lastStartDate }.Max().Add(Interval); + triggerDate = new[] { lastResult.EndTimeUtc, _lastStartDate }.Max().Add(_interval); } if (DateTime.UtcNow > triggerDate) @@ -71,7 +72,7 @@ namespace Emby.Server.Implementations.ScheduledTasks dueTime = maxDueTime; } - Timer = new Timer(state => OnTriggered(), null, dueTime, TimeSpan.FromMilliseconds(-1)); + _timer = new Timer(state => OnTriggered(), null, dueTime, TimeSpan.FromMilliseconds(-1)); } /// <summary> @@ -87,10 +88,7 @@ namespace Emby.Server.Implementations.ScheduledTasks /// </summary> private void DisposeTimer() { - if (Timer != null) - { - Timer.Dispose(); - } + _timer?.Dispose(); } /// <summary> diff --git a/Emby.Server.Implementations/ScheduledTasks/Triggers/StartupTrigger.cs b/Emby.Server.Implementations/ScheduledTasks/Triggers/StartupTrigger.cs index 7cd5493da8..18b9a8b75a 100644 --- a/Emby.Server.Implementations/ScheduledTasks/Triggers/StartupTrigger.cs +++ b/Emby.Server.Implementations/ScheduledTasks/Triggers/StartupTrigger.cs @@ -10,24 +10,28 @@ namespace Emby.Server.Implementations.ScheduledTasks /// <summary> /// Class StartupTaskTrigger. /// </summary> - public class StartupTrigger : ITaskTrigger + public sealed class StartupTrigger : ITaskTrigger { + public const int DelayMs = 3000; + /// <summary> - /// Occurs when [triggered]. + /// Initializes a new instance of the <see cref="StartupTrigger"/> class. /// </summary> - public event EventHandler<EventArgs> Triggered; - - public int DelayMs { get; set; } + /// <param name="taskOptions">The options of this task.</param> + public StartupTrigger(TaskOptions taskOptions) + { + TaskOptions = taskOptions; + } /// <summary> - /// Gets or sets the options of this task. + /// Occurs when [triggered]. /// </summary> - public TaskOptions TaskOptions { get; set; } + public event EventHandler<EventArgs>? Triggered; - public StartupTrigger() - { - DelayMs = 3000; - } + /// <summary> + /// Gets the options of this task. + /// </summary> + public TaskOptions TaskOptions { get; } /// <summary> /// Stars waiting for the trigger action. diff --git a/Emby.Server.Implementations/ScheduledTasks/Triggers/WeeklyTrigger.cs b/Emby.Server.Implementations/ScheduledTasks/Triggers/WeeklyTrigger.cs index 0c0ebec082..36ae190b05 100644 --- a/Emby.Server.Implementations/ScheduledTasks/Triggers/WeeklyTrigger.cs +++ b/Emby.Server.Implementations/ScheduledTasks/Triggers/WeeklyTrigger.cs @@ -8,35 +8,34 @@ namespace Emby.Server.Implementations.ScheduledTasks /// <summary> /// Represents a task trigger that fires on a weekly basis. /// </summary> - public class WeeklyTrigger : ITaskTrigger + public sealed class WeeklyTrigger : ITaskTrigger { - /// <summary> - /// Occurs when [triggered]. - /// </summary> - public event EventHandler<EventArgs> Triggered; - - /// <summary> - /// Gets or sets the time of day to trigger the task to run. - /// </summary> - /// <value>The time of day.</value> - public TimeSpan TimeOfDay { get; set; } + private readonly TimeSpan _timeOfDay; + private readonly DayOfWeek _dayOfWeek; + private Timer? _timer; /// <summary> - /// Gets or sets the day of week. + /// Initializes a new instance of the <see cref="WeeklyTrigger"/> class. /// </summary> - /// <value>The day of week.</value> - public DayOfWeek DayOfWeek { get; set; } + /// <param name="timeofDay">The time of day to trigger the task to run.</param> + /// <param name="dayOfWeek">The day of week.</param> + /// <param name="taskOptions">The options of this task.</param> + public WeeklyTrigger(TimeSpan timeofDay, DayOfWeek dayOfWeek, TaskOptions taskOptions) + { + _timeOfDay = timeofDay; + _dayOfWeek = dayOfWeek; + TaskOptions = taskOptions; + } /// <summary> - /// Gets or sets the options of this task. + /// Occurs when [triggered]. /// </summary> - public TaskOptions TaskOptions { get; set; } + public event EventHandler<EventArgs>? Triggered; /// <summary> - /// Gets or sets the timer. + /// Gets the options of this task. /// </summary> - /// <value>The timer.</value> - private Timer Timer { get; set; } + public TaskOptions TaskOptions { get; } /// <summary> /// Stars waiting for the trigger action. @@ -51,7 +50,7 @@ namespace Emby.Server.Implementations.ScheduledTasks var triggerDate = GetNextTriggerDateTime(); - Timer = new Timer(state => OnTriggered(), null, triggerDate - DateTime.Now, TimeSpan.FromMilliseconds(-1)); + _timer = new Timer(state => OnTriggered(), null, triggerDate - DateTime.Now, TimeSpan.FromMilliseconds(-1)); } /// <summary> @@ -63,22 +62,22 @@ namespace Emby.Server.Implementations.ScheduledTasks var now = DateTime.Now; // If it's on the same day - if (now.DayOfWeek == DayOfWeek) + if (now.DayOfWeek == _dayOfWeek) { // It's either later today, or a week from now - return now.TimeOfDay < TimeOfDay ? now.Date.Add(TimeOfDay) : now.Date.AddDays(7).Add(TimeOfDay); + return now.TimeOfDay < _timeOfDay ? now.Date.Add(_timeOfDay) : now.Date.AddDays(7).Add(_timeOfDay); } var triggerDate = now.Date; // Walk the date forward until we get to the trigger day - while (triggerDate.DayOfWeek != DayOfWeek) + while (triggerDate.DayOfWeek != _dayOfWeek) { triggerDate = triggerDate.AddDays(1); } // Return the trigger date plus the time offset - return triggerDate.Add(TimeOfDay); + return triggerDate.Add(_timeOfDay); } /// <summary> @@ -94,10 +93,7 @@ namespace Emby.Server.Implementations.ScheduledTasks /// </summary> private void DisposeTimer() { - if (Timer != null) - { - Timer.Dispose(); - } + _timer?.Dispose(); } /// <summary> diff --git a/Emby.Server.Implementations/Security/AuthenticationRepository.cs b/Emby.Server.Implementations/Security/AuthenticationRepository.cs index 4bc12f44a4..e8eac315b6 100644 --- a/Emby.Server.Implementations/Security/AuthenticationRepository.cs +++ b/Emby.Server.Implementations/Security/AuthenticationRepository.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System; @@ -289,7 +291,7 @@ namespace Emby.Server.Implementations.Security return result; } - private static AuthenticationInfo Get(IReadOnlyList<IResultSetValue> reader) + private static AuthenticationInfo Get(IReadOnlyList<ResultSetValue> reader) { var info = new AuthenticationInfo { @@ -297,50 +299,49 @@ namespace Emby.Server.Implementations.Security AccessToken = reader[1].ToString() }; - if (reader[2].SQLiteType != SQLiteType.Null) - { - info.DeviceId = reader[2].ToString(); - } - - if (reader[3].SQLiteType != SQLiteType.Null) + if (reader.TryGetString(2, out var deviceId)) { - info.AppName = reader[3].ToString(); + info.DeviceId = deviceId; } - if (reader[4].SQLiteType != SQLiteType.Null) + if (reader.TryGetString(3, out var appName)) { - info.AppVersion = reader[4].ToString(); + info.AppName = appName; } - if (reader[5].SQLiteType != SQLiteType.Null) + if (reader.TryGetString(4, out var appVersion)) { - info.DeviceName = reader[5].ToString(); + info.AppVersion = appVersion; } - if (reader[6].SQLiteType != SQLiteType.Null) + if (reader.TryGetString(6, out var userId)) { - info.UserId = new Guid(reader[6].ToString()); + info.UserId = new Guid(userId); } - if (reader[7].SQLiteType != SQLiteType.Null) + if (reader.TryGetString(7, out var userName)) { - info.UserName = reader[7].ToString(); + info.UserName = userName; } info.DateCreated = reader[8].ReadDateTime(); - if (reader[9].SQLiteType != SQLiteType.Null) + if (reader.TryReadDateTime(9, out var dateLastActivity)) { - info.DateLastActivity = reader[9].ReadDateTime(); + info.DateLastActivity = dateLastActivity; } else { info.DateLastActivity = info.DateCreated; } - if (reader[10].SQLiteType != SQLiteType.Null) + if (reader.TryGetString(10, out var customName)) + { + info.DeviceName = customName; + } + else if (reader.TryGetString(5, out var deviceName)) { - info.DeviceName = reader[10].ToString(); + info.DeviceName = deviceName; } return info; @@ -361,9 +362,9 @@ namespace Emby.Server.Implementations.Security foreach (var row in statement.ExecuteQuery()) { - if (row[0].SQLiteType != SQLiteType.Null) + if (row.TryGetString(0, out var customName)) { - result.CustomName = row[0].ToString(); + result.CustomName = customName; } } diff --git a/Emby.Server.Implementations/Serialization/JsonSerializer.cs b/Emby.Server.Implementations/Serialization/JsonSerializer.cs deleted file mode 100644 index 5ec3a735a6..0000000000 --- a/Emby.Server.Implementations/Serialization/JsonSerializer.cs +++ /dev/null @@ -1,281 +0,0 @@ -#pragma warning disable CS1591 - -using System; -using System.Globalization; -using System.IO; -using System.Threading.Tasks; -using MediaBrowser.Model.Serialization; - -namespace Emby.Server.Implementations.Serialization -{ - /// <summary> - /// Provides a wrapper around third party json serialization. - /// </summary> - public class JsonSerializer : IJsonSerializer - { - /// <summary> - /// Initializes a new instance of the <see cref="JsonSerializer" /> class. - /// </summary> - public JsonSerializer() - { - ServiceStack.Text.JsConfig.DateHandler = ServiceStack.Text.DateHandler.ISO8601; - ServiceStack.Text.JsConfig.ExcludeTypeInfo = true; - ServiceStack.Text.JsConfig.IncludeNullValues = false; - ServiceStack.Text.JsConfig.AlwaysUseUtc = true; - ServiceStack.Text.JsConfig.AssumeUtc = true; - - ServiceStack.Text.JsConfig<Guid>.SerializeFn = SerializeGuid; - } - - /// <summary> - /// Serializes to stream. - /// </summary> - /// <param name="obj">The obj.</param> - /// <param name="stream">The stream.</param> - /// <exception cref="ArgumentNullException">obj</exception> - public void SerializeToStream(object obj, Stream stream) - { - if (obj == null) - { - throw new ArgumentNullException(nameof(obj)); - } - - if (stream == null) - { - throw new ArgumentNullException(nameof(stream)); - } - - ServiceStack.Text.JsonSerializer.SerializeToStream(obj, obj.GetType(), stream); - } - - /// <summary> - /// Serializes to stream. - /// </summary> - /// <param name="obj">The obj.</param> - /// <param name="stream">The stream.</param> - /// <exception cref="ArgumentNullException">obj</exception> - public void SerializeToStream<T>(T obj, Stream stream) - { - if (obj == null) - { - throw new ArgumentNullException(nameof(obj)); - } - - if (stream == null) - { - throw new ArgumentNullException(nameof(stream)); - } - - ServiceStack.Text.JsonSerializer.SerializeToStream<T>(obj, stream); - } - - /// <summary> - /// Serializes to file. - /// </summary> - /// <param name="obj">The obj.</param> - /// <param name="file">The file.</param> - /// <exception cref="ArgumentNullException">obj</exception> - public void SerializeToFile(object obj, string file) - { - if (obj == null) - { - throw new ArgumentNullException(nameof(obj)); - } - - if (string.IsNullOrEmpty(file)) - { - throw new ArgumentNullException(nameof(file)); - } - - using (var stream = new FileStream(file, FileMode.Create, FileAccess.Write, FileShare.Read)) - { - SerializeToStream(obj, stream); - } - } - - private static Stream OpenFile(string path) - { - return new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read, 131072); - } - - /// <summary> - /// Deserializes from file. - /// </summary> - /// <param name="type">The type.</param> - /// <param name="file">The file.</param> - /// <returns>System.Object.</returns> - /// <exception cref="ArgumentNullException">type</exception> - public object DeserializeFromFile(Type type, string file) - { - if (type == null) - { - throw new ArgumentNullException(nameof(type)); - } - - if (string.IsNullOrEmpty(file)) - { - throw new ArgumentNullException(nameof(file)); - } - - using (var stream = OpenFile(file)) - { - return DeserializeFromStream(stream, type); - } - } - - /// <summary> - /// Deserializes from file. - /// </summary> - /// <typeparam name="T"></typeparam> - /// <param name="file">The file.</param> - /// <returns>``0.</returns> - /// <exception cref="ArgumentNullException">file</exception> - public T DeserializeFromFile<T>(string file) - where T : class - { - if (string.IsNullOrEmpty(file)) - { - throw new ArgumentNullException(nameof(file)); - } - - using (var stream = OpenFile(file)) - { - return DeserializeFromStream<T>(stream); - } - } - - /// <summary> - /// Deserializes from stream. - /// </summary> - /// <typeparam name="T"></typeparam> - /// <param name="stream">The stream.</param> - /// <returns>``0.</returns> - /// <exception cref="ArgumentNullException">stream</exception> - public T DeserializeFromStream<T>(Stream stream) - { - if (stream == null) - { - throw new ArgumentNullException(nameof(stream)); - } - - return ServiceStack.Text.JsonSerializer.DeserializeFromStream<T>(stream); - } - - public Task<T> DeserializeFromStreamAsync<T>(Stream stream) - { - if (stream == null) - { - throw new ArgumentNullException(nameof(stream)); - } - - return ServiceStack.Text.JsonSerializer.DeserializeFromStreamAsync<T>(stream); - } - - /// <summary> - /// Deserializes from string. - /// </summary> - /// <typeparam name="T"></typeparam> - /// <param name="text">The text.</param> - /// <returns>``0.</returns> - /// <exception cref="ArgumentNullException">text</exception> - public T DeserializeFromString<T>(string text) - { - if (string.IsNullOrEmpty(text)) - { - throw new ArgumentNullException(nameof(text)); - } - - return ServiceStack.Text.JsonSerializer.DeserializeFromString<T>(text); - } - - /// <summary> - /// Deserializes from stream. - /// </summary> - /// <param name="stream">The stream.</param> - /// <param name="type">The type.</param> - /// <returns>System.Object.</returns> - /// <exception cref="ArgumentNullException">stream</exception> - public object DeserializeFromStream(Stream stream, Type type) - { - if (stream == null) - { - throw new ArgumentNullException(nameof(stream)); - } - - if (type == null) - { - throw new ArgumentNullException(nameof(type)); - } - - return ServiceStack.Text.JsonSerializer.DeserializeFromStream(type, stream); - } - - public async Task<object> DeserializeFromStreamAsync(Stream stream, Type type) - { - if (stream == null) - { - throw new ArgumentNullException(nameof(stream)); - } - - if (type == null) - { - throw new ArgumentNullException(nameof(type)); - } - - using (var reader = new StreamReader(stream)) - { - var json = await reader.ReadToEndAsync().ConfigureAwait(false); - - return ServiceStack.Text.JsonSerializer.DeserializeFromString(json, type); - } - } - - private static string SerializeGuid(Guid guid) - { - if (guid.Equals(Guid.Empty)) - { - return null; - } - - return guid.ToString("N", CultureInfo.InvariantCulture); - } - - /// <summary> - /// Deserializes from string. - /// </summary> - /// <param name="json">The json.</param> - /// <param name="type">The type.</param> - /// <returns>System.Object.</returns> - /// <exception cref="ArgumentNullException">json</exception> - public object DeserializeFromString(string json, Type type) - { - if (string.IsNullOrEmpty(json)) - { - throw new ArgumentNullException(nameof(json)); - } - - if (type == null) - { - throw new ArgumentNullException(nameof(type)); - } - - return ServiceStack.Text.JsonSerializer.DeserializeFromString(json, type); - } - - /// <summary> - /// Serializes to string. - /// </summary> - /// <param name="obj">The obj.</param> - /// <returns>System.String.</returns> - /// <exception cref="ArgumentNullException">obj</exception> - public string SerializeToString(object obj) - { - if (obj == null) - { - throw new ArgumentNullException(nameof(obj)); - } - - return ServiceStack.Text.JsonSerializer.SerializeToString(obj, obj.GetType()); - } - } -} diff --git a/Emby.Server.Implementations/Serialization/MyXmlSerializer.cs b/Emby.Server.Implementations/Serialization/MyXmlSerializer.cs index 27024e4e1c..5ff73de819 100644 --- a/Emby.Server.Implementations/Serialization/MyXmlSerializer.cs +++ b/Emby.Server.Implementations/Serialization/MyXmlSerializer.cs @@ -19,7 +19,10 @@ namespace Emby.Server.Implementations.Serialization new ConcurrentDictionary<string, XmlSerializer>(); private static XmlSerializer GetSerializer(Type type) - => _serializers.GetOrAdd(type.FullName, _ => new XmlSerializer(type)); + => _serializers.GetOrAdd( + type.FullName ?? throw new ArgumentException($"Invalid type {type}."), + (_, t) => new XmlSerializer(t), + type); /// <summary> /// Serializes to writer. @@ -38,7 +41,7 @@ namespace Emby.Server.Implementations.Serialization /// <param name="type">The type.</param> /// <param name="stream">The stream.</param> /// <returns>System.Object.</returns> - public object DeserializeFromStream(Type type, Stream stream) + public object? DeserializeFromStream(Type type, Stream stream) { using (var reader = XmlReader.Create(stream)) { @@ -81,7 +84,7 @@ namespace Emby.Server.Implementations.Serialization /// <param name="type">The type.</param> /// <param name="file">The file.</param> /// <returns>System.Object.</returns> - public object DeserializeFromFile(Type type, string file) + public object? DeserializeFromFile(Type type, string file) { using (var stream = File.OpenRead(file)) { @@ -95,7 +98,7 @@ namespace Emby.Server.Implementations.Serialization /// <param name="type">The type.</param> /// <param name="buffer">The buffer.</param> /// <returns>System.Object.</returns> - public object DeserializeFromBytes(Type type, byte[] buffer) + public object? DeserializeFromBytes(Type type, byte[] buffer) { using (var stream = new MemoryStream(buffer, 0, buffer.Length, false, true)) { diff --git a/Emby.Server.Implementations/ServerApplicationPaths.cs b/Emby.Server.Implementations/ServerApplicationPaths.cs index dfdd4200e0..6cf9a8f71e 100644 --- a/Emby.Server.Implementations/ServerApplicationPaths.cs +++ b/Emby.Server.Implementations/ServerApplicationPaths.cs @@ -25,6 +25,10 @@ namespace Emby.Server.Implementations cacheDirectoryPath, webDirectoryPath) { + // ProgramDataPath cannot change when the server is running, so cache these to avoid allocations. + RootFolderPath = Path.Join(ProgramDataPath, "root"); + DefaultUserViewsPath = Path.Combine(RootFolderPath, "default"); + DefaultInternalMetadataPath = Path.Combine(ProgramDataPath, "metadata"); InternalMetadataPath = DefaultInternalMetadataPath; } @@ -32,13 +36,13 @@ namespace Emby.Server.Implementations /// Gets the path to the base root media directory. /// </summary> /// <value>The root folder path.</value> - public string RootFolderPath => Path.Combine(ProgramDataPath, "root"); + public string RootFolderPath { get; } /// <summary> /// Gets the path to the default user view directory. Used if no specific user view is defined. /// </summary> /// <value>The default user views path.</value> - public string DefaultUserViewsPath => Path.Combine(RootFolderPath, "default"); + public string DefaultUserViewsPath { get; } /// <summary> /// Gets the path to the People directory. @@ -98,12 +102,12 @@ namespace Emby.Server.Implementations public string UserConfigurationDirectoryPath => Path.Combine(ConfigurationDirectoryPath, "users"); /// <inheritdoc/> - public string DefaultInternalMetadataPath => Path.Combine(ProgramDataPath, "metadata"); + public string DefaultInternalMetadataPath { get; } /// <inheritdoc /> public string InternalMetadataPath { get; set; } /// <inheritdoc /> - public string VirtualInternalMetadataPath { get; } = "%MetadataPath%"; + public string VirtualInternalMetadataPath => "%MetadataPath%"; } } diff --git a/Emby.Server.Implementations/Session/SessionManager.cs b/Emby.Server.Implementations/Session/SessionManager.cs index 607b322f2e..c4b19f4171 100644 --- a/Emby.Server.Implementations/Session/SessionManager.cs +++ b/Emby.Server.Implementations/Session/SessionManager.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System; @@ -10,6 +12,7 @@ using System.Threading.Tasks; using Jellyfin.Data.Entities; using Jellyfin.Data.Enums; using Jellyfin.Data.Events; +using Jellyfin.Extensions; using MediaBrowser.Common.Events; using MediaBrowser.Common.Extensions; using MediaBrowser.Controller; @@ -58,8 +61,7 @@ namespace Emby.Server.Implementations.Session /// <summary> /// The active connections. /// </summary> - private readonly ConcurrentDictionary<string, SessionInfo> _activeConnections = - new ConcurrentDictionary<string, SessionInfo>(StringComparer.OrdinalIgnoreCase); + private readonly ConcurrentDictionary<string, SessionInfo> _activeConnections = new (StringComparer.OrdinalIgnoreCase); private Timer _idleTimer; @@ -129,6 +131,9 @@ namespace Emby.Server.Implementations.Session /// <inheritdoc /> public event EventHandler<SessionEventArgs> SessionActivity; + /// <inheritdoc /> + public event EventHandler<SessionEventArgs> SessionControllerConnected; + /// <summary> /// Gets all connections. /// </summary> @@ -196,7 +201,7 @@ namespace Emby.Server.Implementations.Session { if (!string.IsNullOrEmpty(info.DeviceId)) { - var capabilities = GetSavedCapabilities(info.DeviceId); + var capabilities = _deviceManager.GetCapabilities(info.DeviceId); if (capabilities != null) { @@ -313,6 +318,19 @@ namespace Emby.Server.Implementations.Session return session; } + /// <inheritdoc /> + public void OnSessionControllerConnected(SessionInfo info) + { + EventHelper.QueueEventIfNotNull( + SessionControllerConnected, + this, + new SessionEventArgs + { + SessionInfo = info + }, + _logger); + } + /// <inheritdoc /> public void CloseIfNeeded(SessionInfo session) { @@ -1182,18 +1200,16 @@ namespace Emby.Server.Implementations.Session } /// <inheritdoc /> - public async Task SendSyncPlayCommand(string sessionId, SendCommand command, CancellationToken cancellationToken) + public async Task SendSyncPlayCommand(SessionInfo session, SendCommand command, CancellationToken cancellationToken) { CheckDisposed(); - var session = GetSessionToRemoteControl(sessionId); await SendMessageToSession(session, SessionMessageType.SyncPlayCommand, command, cancellationToken).ConfigureAwait(false); } /// <inheritdoc /> - public async Task SendSyncPlayGroupUpdate<T>(string sessionId, GroupUpdate<T> command, CancellationToken cancellationToken) + public async Task SendSyncPlayGroupUpdate<T>(SessionInfo session, GroupUpdate<T> command, CancellationToken cancellationToken) { CheckDisposed(); - var session = GetSessionToRemoteControl(sessionId); await SendMessageToSession(session, SessionMessageType.SyncPlayGroupUpdate, command, cancellationToken).ConfigureAwait(false); } @@ -1297,7 +1313,7 @@ namespace Emby.Server.Implementations.Session } } - return SendMessageToSession(session, SessionMessageType.PlayState, command, cancellationToken); + return SendMessageToSession(session, SessionMessageType.Playstate, command, cancellationToken); } private static void AssertCanControl(SessionInfo session, SessionInfo controllingSession) @@ -1443,7 +1459,12 @@ namespace Emby.Server.Implementations.Session throw new SecurityException("Unknown quick connect token"); } - request.UserId = result.Items[0].UserId; + var info = result.Items[0]; + request.UserId = info.UserId; + + // There's no need to keep the quick connect token in the database, as AuthenticateNewSessionInternal() issues a long lived token. + _authRepo.Delete(info); + return AuthenticateNewSessionInternal(request, false); } @@ -1457,17 +1478,14 @@ namespace Emby.Server.Implementations.Session user = _userManager.GetUserById(request.UserId); } - if (user == null) - { - user = _userManager.GetUserByName(request.Username); - } + user ??= _userManager.GetUserByName(request.Username); if (enforcePassword) { user = await _userManager.AuthenticateUser( request.Username, request.Password, - request.PasswordSha1, + null, request.RemoteEndPoint, true).ConfigureAwait(false); } @@ -1525,23 +1543,26 @@ namespace Emby.Server.Implementations.Session Limit = 1 }).Items.FirstOrDefault(); - var allExistingForDevice = _authRepo.Get( - new AuthenticationInfoQuery - { - DeviceId = deviceId - }).Items; - - foreach (var auth in allExistingForDevice) + if (!string.IsNullOrEmpty(deviceId)) { - if (existing == null || !string.Equals(auth.AccessToken, existing.AccessToken, StringComparison.Ordinal)) - { - try + var allExistingForDevice = _authRepo.Get( + new AuthenticationInfoQuery { - Logout(auth); - } - catch (Exception ex) + DeviceId = deviceId + }).Items; + + foreach (var auth in allExistingForDevice) + { + if (existing == null || !string.Equals(auth.AccessToken, existing.AccessToken, StringComparison.Ordinal)) { - _logger.LogError(ex, "Error while logging out."); + try + { + Logout(auth); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error while logging out."); + } } } } @@ -1677,27 +1698,10 @@ namespace Emby.Server.Implementations.Session SessionInfo = session }); - try - { - SaveCapabilities(session.DeviceId, capabilities); - } - catch (Exception ex) - { - _logger.LogError("Error saving device capabilities", ex); - } + _deviceManager.SaveCapabilities(session.DeviceId, capabilities); } } - private ClientCapabilities GetSavedCapabilities(string deviceId) - { - return _deviceManager.GetCapabilities(deviceId); - } - - private void SaveCapabilities(string deviceId, ClientCapabilities capabilities) - { - _deviceManager.SaveCapabilities(deviceId, capabilities); - } - /// <summary> /// Converts a BaseItem to a BaseItemInfo. /// </summary> diff --git a/Emby.Server.Implementations/Session/SessionWebSocketListener.cs b/Emby.Server.Implementations/Session/SessionWebSocketListener.cs index a5f8479537..e9e3ca7f4b 100644 --- a/Emby.Server.Implementations/Session/SessionWebSocketListener.cs +++ b/Emby.Server.Implementations/Session/SessionWebSocketListener.cs @@ -1,10 +1,11 @@ +#nullable disable + using System; using System.Collections.Generic; using System.Linq; using System.Net.WebSockets; using System.Threading; using System.Threading.Tasks; -using Jellyfin.Data.Events; using MediaBrowser.Controller.Net; using MediaBrowser.Controller.Session; using MediaBrowser.Model.Net; @@ -22,50 +23,48 @@ namespace Emby.Server.Implementations.Session /// <summary> /// The timeout in seconds after which a WebSocket is considered to be lost. /// </summary> - public const int WebSocketLostTimeout = 60; + private const int WebSocketLostTimeout = 60; /// <summary> /// The keep-alive interval factor; controls how often the watcher will check on the status of the WebSockets. /// </summary> - public const float IntervalFactor = 0.2f; + private const float IntervalFactor = 0.2f; /// <summary> /// The ForceKeepAlive factor; controls when a ForceKeepAlive is sent. /// </summary> - public const float ForceKeepAliveFactor = 0.75f; + private const float ForceKeepAliveFactor = 0.75f; /// <summary> - /// The _session manager. + /// Lock used for accesing the KeepAlive cancellation token. /// </summary> - private readonly ISessionManager _sessionManager; + private readonly object _keepAliveLock = new object(); /// <summary> - /// The _logger. + /// The WebSocket watchlist. /// </summary> - private readonly ILogger<SessionWebSocketListener> _logger; - private readonly ILoggerFactory _loggerFactory; - - private readonly IWebSocketManager _webSocketManager; + private readonly HashSet<IWebSocketConnection> _webSockets = new HashSet<IWebSocketConnection>(); /// <summary> - /// The KeepAlive cancellation token. + /// Lock used for accessing the WebSockets watchlist. /// </summary> - private CancellationTokenSource _keepAliveCancellationToken; + private readonly object _webSocketsLock = new object(); /// <summary> - /// Lock used for accesing the KeepAlive cancellation token. + /// The _session manager. /// </summary> - private readonly object _keepAliveLock = new object(); + private readonly ISessionManager _sessionManager; /// <summary> - /// The WebSocket watchlist. + /// The _logger. /// </summary> - private readonly HashSet<IWebSocketConnection> _webSockets = new HashSet<IWebSocketConnection>(); + private readonly ILogger<SessionWebSocketListener> _logger; + private readonly ILoggerFactory _loggerFactory; /// <summary> - /// Lock used for accesing the WebSockets watchlist. + /// The KeepAlive cancellation token. /// </summary> - private readonly object _webSocketsLock = new object(); + private CancellationTokenSource _keepAliveCancellationToken; /// <summary> /// Initializes a new instance of the <see cref="SessionWebSocketListener" /> class. @@ -73,32 +72,42 @@ namespace Emby.Server.Implementations.Session /// <param name="logger">The logger.</param> /// <param name="sessionManager">The session manager.</param> /// <param name="loggerFactory">The logger factory.</param> - /// <param name="webSocketManager">The HTTP server.</param> public SessionWebSocketListener( ILogger<SessionWebSocketListener> logger, ISessionManager sessionManager, - ILoggerFactory loggerFactory, - IWebSocketManager webSocketManager) + ILoggerFactory loggerFactory) { _logger = logger; _sessionManager = sessionManager; _loggerFactory = loggerFactory; - _webSocketManager = webSocketManager; + } - webSocketManager.WebSocketConnected += OnServerManagerWebSocketConnected; + /// <inheritdoc /> + public void Dispose() + { + StopKeepAlive(); } - private async void OnServerManagerWebSocketConnected(object sender, GenericEventArgs<IWebSocketConnection> e) + /// <summary> + /// Processes the message. + /// </summary> + /// <param name="message">The message.</param> + /// <returns>Task.</returns> + public Task ProcessMessageAsync(WebSocketMessageInfo message) + => Task.CompletedTask; + + /// <inheritdoc /> + public async Task ProcessWebSocketConnectedAsync(IWebSocketConnection connection) { - var session = GetSession(e.Argument.QueryString, e.Argument.RemoteEndPoint.ToString()); + var session = GetSession(connection.QueryString, connection.RemoteEndPoint.ToString()); if (session != null) { - EnsureController(session, e.Argument); - await KeepAliveWebSocket(e.Argument).ConfigureAwait(false); + EnsureController(session, connection); + await KeepAliveWebSocket(connection).ConfigureAwait(false); } else { - _logger.LogWarning("Unable to determine session based on query string: {0}", e.Argument.QueryString); + _logger.LogWarning("Unable to determine session based on query string: {0}", connection.QueryString); } } @@ -119,21 +128,6 @@ namespace Emby.Server.Implementations.Session return _sessionManager.GetSessionByAuthenticationToken(token, deviceId, remoteEndpoint); } - /// <inheritdoc /> - public void Dispose() - { - _webSocketManager.WebSocketConnected -= OnServerManagerWebSocketConnected; - StopKeepAlive(); - } - - /// <summary> - /// Processes the message. - /// </summary> - /// <param name="message">The message.</param> - /// <returns>Task.</returns> - public Task ProcessMessageAsync(WebSocketMessageInfo message) - => Task.CompletedTask; - private void EnsureController(SessionInfo session, IWebSocketConnection connection) { var controllerInfo = session.EnsureController<WebSocketController>( @@ -141,6 +135,8 @@ namespace Emby.Server.Implementations.Session var controller = (WebSocketController)controllerInfo.Item1; controller.AddWebSocket(connection); + + _sessionManager.OnSessionControllerConnected(session); } /// <summary> diff --git a/Emby.Server.Implementations/Session/WebSocketController.cs b/Emby.Server.Implementations/Session/WebSocketController.cs index b986ffa1cd..9fa92a53a1 100644 --- a/Emby.Server.Implementations/Session/WebSocketController.cs +++ b/Emby.Server.Implementations/Session/WebSocketController.cs @@ -1,6 +1,4 @@ #pragma warning disable CS1591 -#pragma warning disable SA1600 -#nullable enable using System; using System.Collections.Generic; @@ -55,9 +53,9 @@ namespace Emby.Server.Implementations.Session connection.Closed += OnConnectionClosed; } - private void OnConnectionClosed(object sender, EventArgs e) + private void OnConnectionClosed(object? sender, EventArgs e) { - var connection = (IWebSocketConnection)sender; + var connection = sender as IWebSocketConnection ?? throw new ArgumentException($"{nameof(sender)} is not of type {nameof(IWebSocketConnection)}", nameof(sender)); _logger.LogDebug("Removing websocket from session {Session}", _session.Id); _sockets.Remove(connection); connection.Closed -= OnConnectionClosed; diff --git a/Emby.Server.Implementations/Sorting/AiredEpisodeOrderComparer.cs b/Emby.Server.Implementations/Sorting/AiredEpisodeOrderComparer.cs index 1f68a9c810..2b0ab536f9 100644 --- a/Emby.Server.Implementations/Sorting/AiredEpisodeOrderComparer.cs +++ b/Emby.Server.Implementations/Sorting/AiredEpisodeOrderComparer.cs @@ -16,7 +16,7 @@ namespace Emby.Server.Implementations.Sorting /// <param name="x">The x.</param> /// <param name="y">The y.</param> /// <returns>System.Int32.</returns> - public int Compare(BaseItem x, BaseItem y) + public int Compare(BaseItem? x, BaseItem? y) { if (x == null) { @@ -131,11 +131,11 @@ namespace Emby.Server.Implementations.Sorting return GetSpecialCompareValue(x).CompareTo(GetSpecialCompareValue(y)); } - private static int GetSpecialCompareValue(Episode item) + private static long GetSpecialCompareValue(Episode item) { // First sort by season number // Since there are three sort orders, pad with 9 digits (3 for each, figure 1000 episode buffer should be enough) - var val = (item.AirsAfterSeasonNumber ?? item.AirsBeforeSeasonNumber ?? 0) * 1000000000; + var val = (item.AirsAfterSeasonNumber ?? item.AirsBeforeSeasonNumber ?? 0) * 1000000000L; // Second sort order is if it airs after the season if (item.AirsAfterSeasonNumber.HasValue) diff --git a/Emby.Server.Implementations/Sorting/AlbumArtistComparer.cs b/Emby.Server.Implementations/Sorting/AlbumArtistComparer.cs index 7657cc74e1..42e644970c 100644 --- a/Emby.Server.Implementations/Sorting/AlbumArtistComparer.cs +++ b/Emby.Server.Implementations/Sorting/AlbumArtistComparer.cs @@ -18,7 +18,7 @@ namespace Emby.Server.Implementations.Sorting /// <param name="x">The x.</param> /// <param name="y">The y.</param> /// <returns>System.Int32.</returns> - public int Compare(BaseItem x, BaseItem y) + public int Compare(BaseItem? x, BaseItem? y) { return string.Compare(GetValue(x), GetValue(y), StringComparison.CurrentCultureIgnoreCase); } @@ -28,7 +28,7 @@ namespace Emby.Server.Implementations.Sorting /// </summary> /// <param name="x">The x.</param> /// <returns>System.String.</returns> - private static string GetValue(BaseItem x) + private static string? GetValue(BaseItem? x) { var audio = x as IHasAlbumArtist; diff --git a/Emby.Server.Implementations/Sorting/AlbumComparer.cs b/Emby.Server.Implementations/Sorting/AlbumComparer.cs index 7dfdd9ecff..1db3f5e9ca 100644 --- a/Emby.Server.Implementations/Sorting/AlbumComparer.cs +++ b/Emby.Server.Implementations/Sorting/AlbumComparer.cs @@ -17,7 +17,7 @@ namespace Emby.Server.Implementations.Sorting /// <param name="x">The x.</param> /// <param name="y">The y.</param> /// <returns>System.Int32.</returns> - public int Compare(BaseItem x, BaseItem y) + public int Compare(BaseItem? x, BaseItem? y) { return string.Compare(GetValue(x), GetValue(y), StringComparison.CurrentCultureIgnoreCase); } @@ -27,7 +27,7 @@ namespace Emby.Server.Implementations.Sorting /// </summary> /// <param name="x">The x.</param> /// <returns>System.String.</returns> - private static string GetValue(BaseItem x) + private static string? GetValue(BaseItem? x) { var audio = x as Audio; diff --git a/Emby.Server.Implementations/Sorting/ArtistComparer.cs b/Emby.Server.Implementations/Sorting/ArtistComparer.cs index 756d3c5b6c..98bee3fd9f 100644 --- a/Emby.Server.Implementations/Sorting/ArtistComparer.cs +++ b/Emby.Server.Implementations/Sorting/ArtistComparer.cs @@ -15,7 +15,7 @@ namespace Emby.Server.Implementations.Sorting public string Name => ItemSortBy.Artist; /// <inheritdoc /> - public int Compare(BaseItem x, BaseItem y) + public int Compare(BaseItem? x, BaseItem? y) { return string.Compare(GetValue(x), GetValue(y), StringComparison.CurrentCultureIgnoreCase); } @@ -25,7 +25,7 @@ namespace Emby.Server.Implementations.Sorting /// </summary> /// <param name="x">The x.</param> /// <returns>System.String.</returns> - private static string GetValue(BaseItem x) + private static string? GetValue(BaseItem? x) { if (!(x is Audio audio)) { diff --git a/Emby.Server.Implementations/Sorting/CommunityRatingComparer.cs b/Emby.Server.Implementations/Sorting/CommunityRatingComparer.cs index 980954ba03..5f142fa4bb 100644 --- a/Emby.Server.Implementations/Sorting/CommunityRatingComparer.cs +++ b/Emby.Server.Implementations/Sorting/CommunityRatingComparer.cs @@ -21,7 +21,7 @@ namespace Emby.Server.Implementations.Sorting /// <param name="x">The x.</param> /// <param name="y">The y.</param> /// <returns>System.Int32.</returns> - public int Compare(BaseItem x, BaseItem y) + public int Compare(BaseItem? x, BaseItem? y) { if (x == null) { diff --git a/Emby.Server.Implementations/Sorting/CriticRatingComparer.cs b/Emby.Server.Implementations/Sorting/CriticRatingComparer.cs index fa136c36d0..d20dedc2d4 100644 --- a/Emby.Server.Implementations/Sorting/CriticRatingComparer.cs +++ b/Emby.Server.Implementations/Sorting/CriticRatingComparer.cs @@ -15,14 +15,14 @@ namespace Emby.Server.Implementations.Sorting /// <param name="x">The x.</param> /// <param name="y">The y.</param> /// <returns>System.Int32.</returns> - public int Compare(BaseItem x, BaseItem y) + public int Compare(BaseItem? x, BaseItem? y) { return GetValue(x).CompareTo(GetValue(y)); } - private static float GetValue(BaseItem x) + private static float GetValue(BaseItem? x) { - return x.CriticRating ?? 0; + return x?.CriticRating ?? 0; } /// <summary> diff --git a/Emby.Server.Implementations/Sorting/DateCreatedComparer.cs b/Emby.Server.Implementations/Sorting/DateCreatedComparer.cs index cbca300d2f..d3f10f78cb 100644 --- a/Emby.Server.Implementations/Sorting/DateCreatedComparer.cs +++ b/Emby.Server.Implementations/Sorting/DateCreatedComparer.cs @@ -16,7 +16,7 @@ namespace Emby.Server.Implementations.Sorting /// <param name="x">The x.</param> /// <param name="y">The y.</param> /// <returns>System.Int32.</returns> - public int Compare(BaseItem x, BaseItem y) + public int Compare(BaseItem? x, BaseItem? y) { if (x == null) { diff --git a/Emby.Server.Implementations/Sorting/DateLastMediaAddedComparer.cs b/Emby.Server.Implementations/Sorting/DateLastMediaAddedComparer.cs index 03ff19d21c..b1cb123ce1 100644 --- a/Emby.Server.Implementations/Sorting/DateLastMediaAddedComparer.cs +++ b/Emby.Server.Implementations/Sorting/DateLastMediaAddedComparer.cs @@ -1,3 +1,4 @@ +#nullable disable #pragma warning disable CS1591 using System; diff --git a/Emby.Server.Implementations/Sorting/DatePlayedComparer.cs b/Emby.Server.Implementations/Sorting/DatePlayedComparer.cs index 16bd2aff86..08a44319f2 100644 --- a/Emby.Server.Implementations/Sorting/DatePlayedComparer.cs +++ b/Emby.Server.Implementations/Sorting/DatePlayedComparer.cs @@ -1,3 +1,5 @@ +#nullable disable + using System; using Jellyfin.Data.Entities; using MediaBrowser.Controller.Entities; diff --git a/Emby.Server.Implementations/Sorting/IsFavoriteOrLikeComparer.cs b/Emby.Server.Implementations/Sorting/IsFavoriteOrLikeComparer.cs index 0c4e82d011..73e628cf75 100644 --- a/Emby.Server.Implementations/Sorting/IsFavoriteOrLikeComparer.cs +++ b/Emby.Server.Implementations/Sorting/IsFavoriteOrLikeComparer.cs @@ -1,3 +1,4 @@ +#nullable disable #pragma warning disable CS1591 using Jellyfin.Data.Entities; diff --git a/Emby.Server.Implementations/Sorting/IsFolderComparer.cs b/Emby.Server.Implementations/Sorting/IsFolderComparer.cs index a35192eff8..3c5ddeefaa 100644 --- a/Emby.Server.Implementations/Sorting/IsFolderComparer.cs +++ b/Emby.Server.Implementations/Sorting/IsFolderComparer.cs @@ -20,7 +20,7 @@ namespace Emby.Server.Implementations.Sorting /// <param name="x">The x.</param> /// <param name="y">The y.</param> /// <returns>System.Int32.</returns> - public int Compare(BaseItem x, BaseItem y) + public int Compare(BaseItem? x, BaseItem? y) { return GetValue(x).CompareTo(GetValue(y)); } @@ -30,9 +30,9 @@ namespace Emby.Server.Implementations.Sorting /// </summary> /// <param name="x">The x.</param> /// <returns>System.String.</returns> - private static int GetValue(BaseItem x) + private static int GetValue(BaseItem? x) { - return x.IsFolder ? 0 : 1; + return x?.IsFolder ?? true ? 0 : 1; } } } diff --git a/Emby.Server.Implementations/Sorting/IsPlayedComparer.cs b/Emby.Server.Implementations/Sorting/IsPlayedComparer.cs index d95948406f..7d77a8bc5a 100644 --- a/Emby.Server.Implementations/Sorting/IsPlayedComparer.cs +++ b/Emby.Server.Implementations/Sorting/IsPlayedComparer.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using Jellyfin.Data.Entities; diff --git a/Emby.Server.Implementations/Sorting/IsUnplayedComparer.cs b/Emby.Server.Implementations/Sorting/IsUnplayedComparer.cs index 1632c5a7a9..926835f906 100644 --- a/Emby.Server.Implementations/Sorting/IsUnplayedComparer.cs +++ b/Emby.Server.Implementations/Sorting/IsUnplayedComparer.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using Jellyfin.Data.Entities; diff --git a/Emby.Server.Implementations/Sorting/NameComparer.cs b/Emby.Server.Implementations/Sorting/NameComparer.cs index da020d8d8e..4de81a69e3 100644 --- a/Emby.Server.Implementations/Sorting/NameComparer.cs +++ b/Emby.Server.Implementations/Sorting/NameComparer.cs @@ -16,7 +16,7 @@ namespace Emby.Server.Implementations.Sorting /// <param name="x">The x.</param> /// <param name="y">The y.</param> /// <returns>System.Int32.</returns> - public int Compare(BaseItem x, BaseItem y) + public int Compare(BaseItem? x, BaseItem? y) { if (x == null) { diff --git a/Emby.Server.Implementations/Sorting/OfficialRatingComparer.cs b/Emby.Server.Implementations/Sorting/OfficialRatingComparer.cs index 76bb798b5f..a81f78ebfc 100644 --- a/Emby.Server.Implementations/Sorting/OfficialRatingComparer.cs +++ b/Emby.Server.Implementations/Sorting/OfficialRatingComparer.cs @@ -29,7 +29,7 @@ namespace Emby.Server.Implementations.Sorting /// <param name="x">The x.</param> /// <param name="y">The y.</param> /// <returns>System.Int32.</returns> - public int Compare(BaseItem x, BaseItem y) + public int Compare(BaseItem? x, BaseItem? y) { if (x == null) { diff --git a/Emby.Server.Implementations/Sorting/PlayCountComparer.cs b/Emby.Server.Implementations/Sorting/PlayCountComparer.cs index 5c28303229..04e4865cbf 100644 --- a/Emby.Server.Implementations/Sorting/PlayCountComparer.cs +++ b/Emby.Server.Implementations/Sorting/PlayCountComparer.cs @@ -1,3 +1,5 @@ +#nullable disable + using Jellyfin.Data.Entities; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; diff --git a/Emby.Server.Implementations/Sorting/PremiereDateComparer.cs b/Emby.Server.Implementations/Sorting/PremiereDateComparer.cs index 92ac04dc66..c98f97bf1e 100644 --- a/Emby.Server.Implementations/Sorting/PremiereDateComparer.cs +++ b/Emby.Server.Implementations/Sorting/PremiereDateComparer.cs @@ -16,7 +16,7 @@ namespace Emby.Server.Implementations.Sorting /// <param name="x">The x.</param> /// <param name="y">The y.</param> /// <returns>System.Int32.</returns> - public int Compare(BaseItem x, BaseItem y) + public int Compare(BaseItem? x, BaseItem? y) { return GetDate(x).CompareTo(GetDate(y)); } @@ -26,8 +26,13 @@ namespace Emby.Server.Implementations.Sorting /// </summary> /// <param name="x">The x.</param> /// <returns>DateTime.</returns> - private static DateTime GetDate(BaseItem x) + private static DateTime GetDate(BaseItem? x) { + if (x == null) + { + return DateTime.MinValue; + } + if (x.PremiereDate.HasValue) { return x.PremiereDate.Value; diff --git a/Emby.Server.Implementations/Sorting/ProductionYearComparer.cs b/Emby.Server.Implementations/Sorting/ProductionYearComparer.cs index e2857df0b9..df9f9957d6 100644 --- a/Emby.Server.Implementations/Sorting/ProductionYearComparer.cs +++ b/Emby.Server.Implementations/Sorting/ProductionYearComparer.cs @@ -15,7 +15,7 @@ namespace Emby.Server.Implementations.Sorting /// <param name="x">The x.</param> /// <param name="y">The y.</param> /// <returns>System.Int32.</returns> - public int Compare(BaseItem x, BaseItem y) + public int Compare(BaseItem? x, BaseItem? y) { return GetValue(x).CompareTo(GetValue(y)); } @@ -25,8 +25,13 @@ namespace Emby.Server.Implementations.Sorting /// </summary> /// <param name="x">The x.</param> /// <returns>DateTime.</returns> - private static int GetValue(BaseItem x) + private static int GetValue(BaseItem? x) { + if (x == null) + { + return 0; + } + if (x.ProductionYear.HasValue) { return x.ProductionYear.Value; diff --git a/Emby.Server.Implementations/Sorting/RandomComparer.cs b/Emby.Server.Implementations/Sorting/RandomComparer.cs index 7739d04182..af3bc27508 100644 --- a/Emby.Server.Implementations/Sorting/RandomComparer.cs +++ b/Emby.Server.Implementations/Sorting/RandomComparer.cs @@ -16,7 +16,7 @@ namespace Emby.Server.Implementations.Sorting /// <param name="x">The x.</param> /// <param name="y">The y.</param> /// <returns>System.Int32.</returns> - public int Compare(BaseItem x, BaseItem y) + public int Compare(BaseItem? x, BaseItem? y) { return Guid.NewGuid().CompareTo(Guid.NewGuid()); } diff --git a/Emby.Server.Implementations/Sorting/RuntimeComparer.cs b/Emby.Server.Implementations/Sorting/RuntimeComparer.cs index dde44333d5..1293153031 100644 --- a/Emby.Server.Implementations/Sorting/RuntimeComparer.cs +++ b/Emby.Server.Implementations/Sorting/RuntimeComparer.cs @@ -1,3 +1,5 @@ +#nullable disable + using System; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Sorting; diff --git a/Emby.Server.Implementations/Sorting/SeriesSortNameComparer.cs b/Emby.Server.Implementations/Sorting/SeriesSortNameComparer.cs index b9205ee076..4123a59f8b 100644 --- a/Emby.Server.Implementations/Sorting/SeriesSortNameComparer.cs +++ b/Emby.Server.Implementations/Sorting/SeriesSortNameComparer.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System; diff --git a/Emby.Server.Implementations/Sorting/SortNameComparer.cs b/Emby.Server.Implementations/Sorting/SortNameComparer.cs index f745e193b4..8d30716d39 100644 --- a/Emby.Server.Implementations/Sorting/SortNameComparer.cs +++ b/Emby.Server.Implementations/Sorting/SortNameComparer.cs @@ -1,3 +1,5 @@ +#nullable disable + using System; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Sorting; diff --git a/Emby.Server.Implementations/Sorting/StartDateComparer.cs b/Emby.Server.Implementations/Sorting/StartDateComparer.cs index 558a3d3513..c3df7c47e6 100644 --- a/Emby.Server.Implementations/Sorting/StartDateComparer.cs +++ b/Emby.Server.Implementations/Sorting/StartDateComparer.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System; diff --git a/Emby.Server.Implementations/Sorting/StudioComparer.cs b/Emby.Server.Implementations/Sorting/StudioComparer.cs index 5766dc542b..6826aee3b1 100644 --- a/Emby.Server.Implementations/Sorting/StudioComparer.cs +++ b/Emby.Server.Implementations/Sorting/StudioComparer.cs @@ -1,7 +1,10 @@ +#nullable disable + #pragma warning disable CS1591 using System; using System.Linq; +using Jellyfin.Extensions; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Sorting; using MediaBrowser.Model.Querying; @@ -28,7 +31,7 @@ namespace Emby.Server.Implementations.Sorting throw new ArgumentNullException(nameof(y)); } - return AlphanumComparator.CompareValues(x.Studios.FirstOrDefault() ?? string.Empty, y.Studios.FirstOrDefault() ?? string.Empty); + return AlphanumericComparator.CompareValues(x.Studios.FirstOrDefault(), y.Studios.FirstOrDefault()); } /// <summary> diff --git a/Emby.Server.Implementations/SyncPlay/Group.cs b/Emby.Server.Implementations/SyncPlay/Group.cs new file mode 100644 index 0000000000..12efff261b --- /dev/null +++ b/Emby.Server.Implementations/SyncPlay/Group.cs @@ -0,0 +1,676 @@ +#nullable disable + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Data.Entities; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Session; +using MediaBrowser.Controller.SyncPlay; +using MediaBrowser.Controller.SyncPlay.GroupStates; +using MediaBrowser.Controller.SyncPlay.Queue; +using MediaBrowser.Controller.SyncPlay.Requests; +using MediaBrowser.Model.SyncPlay; +using Microsoft.Extensions.Logging; + +namespace Emby.Server.Implementations.SyncPlay +{ + /// <summary> + /// Class Group. + /// </summary> + /// <remarks> + /// Class is not thread-safe, external locking is required when accessing methods. + /// </remarks> + public class Group : IGroupStateContext + { + /// <summary> + /// The logger. + /// </summary> + private readonly ILogger<Group> _logger; + + /// <summary> + /// The logger factory. + /// </summary> + private readonly ILoggerFactory _loggerFactory; + + /// <summary> + /// The user manager. + /// </summary> + private readonly IUserManager _userManager; + + /// <summary> + /// The session manager. + /// </summary> + private readonly ISessionManager _sessionManager; + + /// <summary> + /// The library manager. + /// </summary> + private readonly ILibraryManager _libraryManager; + + /// <summary> + /// The participants, or members of the group. + /// </summary> + private readonly Dictionary<string, GroupMember> _participants = + new Dictionary<string, GroupMember>(StringComparer.OrdinalIgnoreCase); + + /// <summary> + /// The internal group state. + /// </summary> + private IGroupState _state; + + /// <summary> + /// Initializes a new instance of the <see cref="Group" /> class. + /// </summary> + /// <param name="loggerFactory">The logger factory.</param> + /// <param name="userManager">The user manager.</param> + /// <param name="sessionManager">The session manager.</param> + /// <param name="libraryManager">The library manager.</param> + public Group( + ILoggerFactory loggerFactory, + IUserManager userManager, + ISessionManager sessionManager, + ILibraryManager libraryManager) + { + _loggerFactory = loggerFactory; + _userManager = userManager; + _sessionManager = sessionManager; + _libraryManager = libraryManager; + _logger = loggerFactory.CreateLogger<Group>(); + + _state = new IdleGroupState(loggerFactory); + } + + /// <summary> + /// Gets the default ping value used for sessions. + /// </summary> + /// <value>The default ping.</value> + public long DefaultPing { get; } = 500; + + /// <summary> + /// Gets the maximum time offset error accepted for dates reported by clients, in milliseconds. + /// </summary> + /// <value>The maximum time offset error.</value> + public long TimeSyncOffset { get; } = 2000; + + /// <summary> + /// Gets the maximum offset error accepted for position reported by clients, in milliseconds. + /// </summary> + /// <value>The maximum offset error.</value> + public long MaxPlaybackOffset { get; } = 500; + + /// <summary> + /// Gets the group identifier. + /// </summary> + /// <value>The group identifier.</value> + public Guid GroupId { get; } = Guid.NewGuid(); + + /// <summary> + /// Gets the group name. + /// </summary> + /// <value>The group name.</value> + public string GroupName { get; private set; } + + /// <summary> + /// Gets the group identifier. + /// </summary> + /// <value>The group identifier.</value> + public PlayQueueManager PlayQueue { get; } = new PlayQueueManager(); + + /// <summary> + /// Gets the runtime ticks of current playing item. + /// </summary> + /// <value>The runtime ticks of current playing item.</value> + public long RunTimeTicks { get; private set; } + + /// <summary> + /// Gets or sets the position ticks. + /// </summary> + /// <value>The position ticks.</value> + public long PositionTicks { get; set; } + + /// <summary> + /// Gets or sets the last activity. + /// </summary> + /// <value>The last activity.</value> + public DateTime LastActivity { get; set; } + + /// <summary> + /// Adds the session to the group. + /// </summary> + /// <param name="session">The session.</param> + private void AddSession(SessionInfo session) + { + _participants.TryAdd( + session.Id, + new GroupMember(session) + { + Ping = DefaultPing, + IsBuffering = false + }); + } + + /// <summary> + /// Removes the session from the group. + /// </summary> + /// <param name="session">The session.</param> + private void RemoveSession(SessionInfo session) + { + _participants.Remove(session.Id); + } + + /// <summary> + /// Filters sessions of this group. + /// </summary> + /// <param name="from">The current session.</param> + /// <param name="type">The filtering type.</param> + /// <returns>The list of sessions matching the filter.</returns> + private IEnumerable<SessionInfo> FilterSessions(SessionInfo from, SyncPlayBroadcastType type) + { + return type switch + { + SyncPlayBroadcastType.CurrentSession => new SessionInfo[] { from }, + SyncPlayBroadcastType.AllGroup => _participants + .Values + .Select(session => session.Session), + SyncPlayBroadcastType.AllExceptCurrentSession => _participants + .Values + .Select(session => session.Session) + .Where(session => !session.Id.Equals(from.Id, StringComparison.OrdinalIgnoreCase)), + SyncPlayBroadcastType.AllReady => _participants + .Values + .Where(session => !session.IsBuffering) + .Select(session => session.Session), + _ => Enumerable.Empty<SessionInfo>() + }; + } + + /// <summary> + /// Checks if a given user can access all items of a given queue, that is, + /// the user has the required minimum parental access and has access to all required folders. + /// </summary> + /// <param name="user">The user.</param> + /// <param name="queue">The queue.</param> + /// <returns><c>true</c> if the user can access all the items in the queue, <c>false</c> otherwise.</returns> + private bool HasAccessToQueue(User user, IReadOnlyList<Guid> queue) + { + // Check if queue is empty. + if (queue == null || queue.Count == 0) + { + return true; + } + + foreach (var itemId in queue) + { + var item = _libraryManager.GetItemById(itemId); + if (!item.IsVisibleStandalone(user)) + { + return false; + } + } + + return true; + } + + private bool AllUsersHaveAccessToQueue(IReadOnlyList<Guid> queue) + { + // Check if queue is empty. + if (queue == null || queue.Count == 0) + { + return true; + } + + // Get list of users. + var users = _participants + .Values + .Select(participant => _userManager.GetUserById(participant.Session.UserId)); + + // Find problematic users. + var usersWithNoAccess = users.Where(user => !HasAccessToQueue(user, queue)); + + // All users must be able to access the queue. + return !usersWithNoAccess.Any(); + } + + /// <summary> + /// Checks if the group is empty. + /// </summary> + /// <returns><c>true</c> if the group is empty, <c>false</c> otherwise.</returns> + public bool IsGroupEmpty() => _participants.Count == 0; + + /// <summary> + /// Initializes the group with the session's info. + /// </summary> + /// <param name="session">The session.</param> + /// <param name="request">The request.</param> + /// <param name="cancellationToken">The cancellation token.</param> + public void CreateGroup(SessionInfo session, NewGroupRequest request, CancellationToken cancellationToken) + { + GroupName = request.GroupName; + AddSession(session); + + var sessionIsPlayingAnItem = session.FullNowPlayingItem != null; + + RestartCurrentItem(); + + if (sessionIsPlayingAnItem) + { + var playlist = session.NowPlayingQueue.Select(item => item.Id).ToList(); + PlayQueue.Reset(); + PlayQueue.SetPlaylist(playlist); + PlayQueue.SetPlayingItemById(session.FullNowPlayingItem.Id); + RunTimeTicks = session.FullNowPlayingItem.RunTimeTicks ?? 0; + PositionTicks = session.PlayState.PositionTicks ?? 0; + + // Maintain playstate. + var waitingState = new WaitingGroupState(_loggerFactory) + { + ResumePlaying = !session.PlayState.IsPaused + }; + SetState(waitingState); + } + + var updateSession = NewSyncPlayGroupUpdate(GroupUpdateType.GroupJoined, GetInfo()); + SendGroupUpdate(session, SyncPlayBroadcastType.CurrentSession, updateSession, cancellationToken); + + _state.SessionJoined(this, _state.Type, session, cancellationToken); + + _logger.LogInformation("Session {SessionId} created group {GroupId}.", session.Id, GroupId.ToString()); + } + + /// <summary> + /// Adds the session to the group. + /// </summary> + /// <param name="session">The session.</param> + /// <param name="request">The request.</param> + /// <param name="cancellationToken">The cancellation token.</param> + public void SessionJoin(SessionInfo session, JoinGroupRequest request, CancellationToken cancellationToken) + { + AddSession(session); + + var updateSession = NewSyncPlayGroupUpdate(GroupUpdateType.GroupJoined, GetInfo()); + SendGroupUpdate(session, SyncPlayBroadcastType.CurrentSession, updateSession, cancellationToken); + + var updateOthers = NewSyncPlayGroupUpdate(GroupUpdateType.UserJoined, session.UserName); + SendGroupUpdate(session, SyncPlayBroadcastType.AllExceptCurrentSession, updateOthers, cancellationToken); + + _state.SessionJoined(this, _state.Type, session, cancellationToken); + + _logger.LogInformation("Session {SessionId} joined group {GroupId}.", session.Id, GroupId.ToString()); + } + + /// <summary> + /// Removes the session from the group. + /// </summary> + /// <param name="session">The session.</param> + /// <param name="request">The request.</param> + /// <param name="cancellationToken">The cancellation token.</param> + public void SessionLeave(SessionInfo session, LeaveGroupRequest request, CancellationToken cancellationToken) + { + _state.SessionLeaving(this, _state.Type, session, cancellationToken); + + RemoveSession(session); + + var updateSession = NewSyncPlayGroupUpdate(GroupUpdateType.GroupLeft, GroupId.ToString()); + SendGroupUpdate(session, SyncPlayBroadcastType.CurrentSession, updateSession, cancellationToken); + + var updateOthers = NewSyncPlayGroupUpdate(GroupUpdateType.UserLeft, session.UserName); + SendGroupUpdate(session, SyncPlayBroadcastType.AllExceptCurrentSession, updateOthers, cancellationToken); + + _logger.LogInformation("Session {SessionId} left group {GroupId}.", session.Id, GroupId.ToString()); + } + + /// <summary> + /// Handles the requested action by the session. + /// </summary> + /// <param name="session">The session.</param> + /// <param name="request">The requested action.</param> + /// <param name="cancellationToken">The cancellation token.</param> + public void HandleRequest(SessionInfo session, IGroupPlaybackRequest request, CancellationToken cancellationToken) + { + // The server's job is to maintain a consistent state for clients to reference + // and notify clients of state changes. The actual syncing of media playback + // happens client side. Clients are aware of the server's time and use it to sync. + _logger.LogInformation("Session {SessionId} requested {RequestType} in group {GroupId} that is {StateType}.", session.Id, request.Action, GroupId.ToString(), _state.Type); + + // Apply requested changes to this group given its current state. + // Every request has a slightly different outcome depending on the group's state. + // There are currently four different group states that accomplish different goals: + // - Idle: in this state no media is playing and clients should be idle (playback is stopped). + // - Waiting: in this state the group is waiting for all the clients to be ready to start the playback, + // that is, they've either finished loading the media for the first time or they've finished buffering. + // Once all clients report to be ready the group's state can change to Playing or Paused. + // - Playing: clients have some media loaded and playback is unpaused. + // - Paused: clients have some media loaded but playback is currently paused. + request.Apply(this, _state, session, cancellationToken); + } + + /// <summary> + /// Gets the info about the group for the clients. + /// </summary> + /// <returns>The group info for the clients.</returns> + public GroupInfoDto GetInfo() + { + var participants = _participants.Values.Select(session => session.Session.UserName).Distinct().ToList(); + return new GroupInfoDto(GroupId, GroupName, _state.Type, participants, DateTime.UtcNow); + } + + /// <summary> + /// Checks if a user has access to all content in the play queue. + /// </summary> + /// <param name="user">The user.</param> + /// <returns><c>true</c> if the user can access the play queue; <c>false</c> otherwise.</returns> + public bool HasAccessToPlayQueue(User user) + { + var items = PlayQueue.GetPlaylist().Select(item => item.ItemId).ToList(); + return HasAccessToQueue(user, items); + } + + /// <inheritdoc /> + public void SetIgnoreGroupWait(SessionInfo session, bool ignoreGroupWait) + { + if (_participants.TryGetValue(session.Id, out GroupMember value)) + { + value.IgnoreGroupWait = ignoreGroupWait; + } + } + + /// <inheritdoc /> + public void SetState(IGroupState state) + { + _logger.LogInformation("Group {GroupId} switching from {FromStateType} to {ToStateType}.", GroupId.ToString(), _state.Type, state.Type); + this._state = state; + } + + /// <inheritdoc /> + public Task SendGroupUpdate<T>(SessionInfo from, SyncPlayBroadcastType type, GroupUpdate<T> message, CancellationToken cancellationToken) + { + IEnumerable<Task> GetTasks() + { + foreach (var session in FilterSessions(from, type)) + { + yield return _sessionManager.SendSyncPlayGroupUpdate(session, message, cancellationToken); + } + } + + return Task.WhenAll(GetTasks()); + } + + /// <inheritdoc /> + public Task SendCommand(SessionInfo from, SyncPlayBroadcastType type, SendCommand message, CancellationToken cancellationToken) + { + IEnumerable<Task> GetTasks() + { + foreach (var session in FilterSessions(from, type)) + { + yield return _sessionManager.SendSyncPlayCommand(session, message, cancellationToken); + } + } + + return Task.WhenAll(GetTasks()); + } + + /// <inheritdoc /> + public SendCommand NewSyncPlayCommand(SendCommandType type) + { + return new SendCommand( + GroupId, + PlayQueue.GetPlayingItemPlaylistId(), + LastActivity, + type, + PositionTicks, + DateTime.UtcNow); + } + + /// <inheritdoc /> + public GroupUpdate<T> NewSyncPlayGroupUpdate<T>(GroupUpdateType type, T data) + { + return new GroupUpdate<T>(GroupId, type, data); + } + + /// <inheritdoc /> + public long SanitizePositionTicks(long? positionTicks) + { + var ticks = positionTicks ?? 0; + return Math.Clamp(ticks, 0, RunTimeTicks); + } + + /// <inheritdoc /> + public void UpdatePing(SessionInfo session, long ping) + { + if (_participants.TryGetValue(session.Id, out GroupMember value)) + { + value.Ping = ping; + } + } + + /// <inheritdoc /> + public long GetHighestPing() + { + long max = long.MinValue; + foreach (var session in _participants.Values) + { + max = Math.Max(max, session.Ping); + } + + return max; + } + + /// <inheritdoc /> + public void SetBuffering(SessionInfo session, bool isBuffering) + { + if (_participants.TryGetValue(session.Id, out GroupMember value)) + { + value.IsBuffering = isBuffering; + } + } + + /// <inheritdoc /> + public void SetAllBuffering(bool isBuffering) + { + foreach (var session in _participants.Values) + { + session.IsBuffering = isBuffering; + } + } + + /// <inheritdoc /> + public bool IsBuffering() + { + foreach (var session in _participants.Values) + { + if (session.IsBuffering && !session.IgnoreGroupWait) + { + return true; + } + } + + return false; + } + + /// <inheritdoc /> + public bool SetPlayQueue(IReadOnlyList<Guid> playQueue, int playingItemPosition, long startPositionTicks) + { + // Ignore on empty queue or invalid item position. + if (playQueue.Count == 0 || playingItemPosition >= playQueue.Count || playingItemPosition < 0) + { + return false; + } + + // Check if participants can access the new playing queue. + if (!AllUsersHaveAccessToQueue(playQueue)) + { + return false; + } + + PlayQueue.Reset(); + PlayQueue.SetPlaylist(playQueue); + PlayQueue.SetPlayingItemByIndex(playingItemPosition); + var item = _libraryManager.GetItemById(PlayQueue.GetPlayingItemId()); + RunTimeTicks = item.RunTimeTicks ?? 0; + PositionTicks = startPositionTicks; + LastActivity = DateTime.UtcNow; + + return true; + } + + /// <inheritdoc /> + public bool SetPlayingItem(Guid playlistItemId) + { + var itemFound = PlayQueue.SetPlayingItemByPlaylistId(playlistItemId); + + if (itemFound) + { + var item = _libraryManager.GetItemById(PlayQueue.GetPlayingItemId()); + RunTimeTicks = item.RunTimeTicks ?? 0; + } + else + { + RunTimeTicks = 0; + } + + RestartCurrentItem(); + + return itemFound; + } + + /// <inheritdoc /> + public bool RemoveFromPlayQueue(IReadOnlyList<Guid> playlistItemIds) + { + var playingItemRemoved = PlayQueue.RemoveFromPlaylist(playlistItemIds); + if (playingItemRemoved) + { + var itemId = PlayQueue.GetPlayingItemId(); + if (!itemId.Equals(Guid.Empty)) + { + var item = _libraryManager.GetItemById(itemId); + RunTimeTicks = item.RunTimeTicks ?? 0; + } + else + { + RunTimeTicks = 0; + } + + RestartCurrentItem(); + } + + return playingItemRemoved; + } + + /// <inheritdoc /> + public bool MoveItemInPlayQueue(Guid playlistItemId, int newIndex) + { + return PlayQueue.MovePlaylistItem(playlistItemId, newIndex); + } + + /// <inheritdoc /> + public bool AddToPlayQueue(IReadOnlyList<Guid> newItems, GroupQueueMode mode) + { + // Ignore on empty list. + if (newItems.Count == 0) + { + return false; + } + + // Check if participants can access the new playing queue. + if (!AllUsersHaveAccessToQueue(newItems)) + { + return false; + } + + if (mode.Equals(GroupQueueMode.QueueNext)) + { + PlayQueue.QueueNext(newItems); + } + else + { + PlayQueue.Queue(newItems); + } + + return true; + } + + /// <inheritdoc /> + public void RestartCurrentItem() + { + PositionTicks = 0; + LastActivity = DateTime.UtcNow; + } + + /// <inheritdoc /> + public bool NextItemInQueue() + { + var update = PlayQueue.Next(); + if (update) + { + var item = _libraryManager.GetItemById(PlayQueue.GetPlayingItemId()); + RunTimeTicks = item.RunTimeTicks ?? 0; + RestartCurrentItem(); + return true; + } + else + { + return false; + } + } + + /// <inheritdoc /> + public bool PreviousItemInQueue() + { + var update = PlayQueue.Previous(); + if (update) + { + var item = _libraryManager.GetItemById(PlayQueue.GetPlayingItemId()); + RunTimeTicks = item.RunTimeTicks ?? 0; + RestartCurrentItem(); + return true; + } + else + { + return false; + } + } + + /// <inheritdoc /> + public void SetRepeatMode(GroupRepeatMode mode) + { + PlayQueue.SetRepeatMode(mode); + } + + /// <inheritdoc /> + public void SetShuffleMode(GroupShuffleMode mode) + { + PlayQueue.SetShuffleMode(mode); + } + + /// <inheritdoc /> + public PlayQueueUpdate GetPlayQueueUpdate(PlayQueueUpdateReason reason) + { + var startPositionTicks = PositionTicks; + + if (_state.Type.Equals(GroupStateType.Playing)) + { + var currentTime = DateTime.UtcNow; + var elapsedTime = currentTime - LastActivity; + // Elapsed time is negative if event happens + // during the delay added to account for latency. + // In this phase clients haven't started the playback yet. + // In other words, LastActivity is in the future, + // when playback unpause is supposed to happen. + // Adjust ticks only if playback actually started. + startPositionTicks += Math.Max(elapsedTime.Ticks, 0); + } + + return new PlayQueueUpdate( + reason, + PlayQueue.LastChange, + PlayQueue.GetPlaylist(), + PlayQueue.PlayingItemIndex, + startPositionTicks, + PlayQueue.ShuffleMode, + PlayQueue.RepeatMode); + } + } +} diff --git a/Emby.Server.Implementations/SyncPlay/SyncPlayController.cs b/Emby.Server.Implementations/SyncPlay/SyncPlayController.cs deleted file mode 100644 index 5384795122..0000000000 --- a/Emby.Server.Implementations/SyncPlay/SyncPlayController.cs +++ /dev/null @@ -1,514 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Globalization; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using MediaBrowser.Controller.Session; -using MediaBrowser.Controller.SyncPlay; -using MediaBrowser.Model.Session; -using MediaBrowser.Model.SyncPlay; - -namespace Emby.Server.Implementations.SyncPlay -{ - /// <summary> - /// Class SyncPlayController. - /// </summary> - /// <remarks> - /// Class is not thread-safe, external locking is required when accessing methods. - /// </remarks> - public class SyncPlayController : ISyncPlayController - { - /// <summary> - /// Used to filter the sessions of a group. - /// </summary> - private enum BroadcastType - { - /// <summary> - /// All sessions will receive the message. - /// </summary> - AllGroup = 0, - - /// <summary> - /// Only the specified session will receive the message. - /// </summary> - CurrentSession = 1, - - /// <summary> - /// All sessions, except the current one, will receive the message. - /// </summary> - AllExceptCurrentSession = 2, - - /// <summary> - /// Only sessions that are not buffering will receive the message. - /// </summary> - AllReady = 3 - } - - /// <summary> - /// The session manager. - /// </summary> - private readonly ISessionManager _sessionManager; - - /// <summary> - /// The SyncPlay manager. - /// </summary> - private readonly ISyncPlayManager _syncPlayManager; - - /// <summary> - /// The group to manage. - /// </summary> - private readonly GroupInfo _group = new GroupInfo(); - - /// <summary> - /// Initializes a new instance of the <see cref="SyncPlayController" /> class. - /// </summary> - /// <param name="sessionManager">The session manager.</param> - /// <param name="syncPlayManager">The SyncPlay manager.</param> - public SyncPlayController( - ISessionManager sessionManager, - ISyncPlayManager syncPlayManager) - { - _sessionManager = sessionManager; - _syncPlayManager = syncPlayManager; - } - - /// <inheritdoc /> - public Guid GetGroupId() => _group.GroupId; - - /// <inheritdoc /> - public Guid GetPlayingItemId() => _group.PlayingItem.Id; - - /// <inheritdoc /> - public bool IsGroupEmpty() => _group.IsEmpty(); - - /// <summary> - /// Converts DateTime to UTC string. - /// </summary> - /// <param name="date">The date to convert.</param> - /// <value>The UTC string.</value> - private string DateToUTCString(DateTime date) - { - return date.ToUniversalTime().ToString("o", CultureInfo.InvariantCulture); - } - - /// <summary> - /// Filters sessions of this group. - /// </summary> - /// <param name="from">The current session.</param> - /// <param name="type">The filtering type.</param> - /// <value>The array of sessions matching the filter.</value> - private IEnumerable<SessionInfo> FilterSessions(SessionInfo from, BroadcastType type) - { - switch (type) - { - case BroadcastType.CurrentSession: - return new SessionInfo[] { from }; - case BroadcastType.AllGroup: - return _group.Participants.Values - .Select(session => session.Session); - case BroadcastType.AllExceptCurrentSession: - return _group.Participants.Values - .Select(session => session.Session) - .Where(session => !session.Id.Equals(from.Id, StringComparison.Ordinal)); - case BroadcastType.AllReady: - return _group.Participants.Values - .Where(session => !session.IsBuffering) - .Select(session => session.Session); - default: - return Array.Empty<SessionInfo>(); - } - } - - /// <summary> - /// Sends a GroupUpdate message to the interested sessions. - /// </summary> - /// <param name="from">The current session.</param> - /// <param name="type">The filtering type.</param> - /// <param name="message">The message to send.</param> - /// <param name="cancellationToken">The cancellation token.</param> - /// <value>The task.</value> - private Task SendGroupUpdate<T>(SessionInfo from, BroadcastType type, GroupUpdate<T> message, CancellationToken cancellationToken) - { - IEnumerable<Task> GetTasks() - { - foreach (var session in FilterSessions(from, type)) - { - yield return _sessionManager.SendSyncPlayGroupUpdate(session.Id, message, cancellationToken); - } - } - - return Task.WhenAll(GetTasks()); - } - - /// <summary> - /// Sends a playback command to the interested sessions. - /// </summary> - /// <param name="from">The current session.</param> - /// <param name="type">The filtering type.</param> - /// <param name="message">The message to send.</param> - /// <param name="cancellationToken">The cancellation token.</param> - /// <value>The task.</value> - private Task SendCommand(SessionInfo from, BroadcastType type, SendCommand message, CancellationToken cancellationToken) - { - IEnumerable<Task> GetTasks() - { - foreach (var session in FilterSessions(from, type)) - { - yield return _sessionManager.SendSyncPlayCommand(session.Id, message, cancellationToken); - } - } - - return Task.WhenAll(GetTasks()); - } - - /// <summary> - /// Builds a new playback command with some default values. - /// </summary> - /// <param name="type">The command type.</param> - /// <value>The SendCommand.</value> - private SendCommand NewSyncPlayCommand(SendCommandType type) - { - return new SendCommand() - { - GroupId = _group.GroupId.ToString(), - Command = type, - PositionTicks = _group.PositionTicks, - When = DateToUTCString(_group.LastActivity), - EmittedAt = DateToUTCString(DateTime.UtcNow) - }; - } - - /// <summary> - /// Builds a new group update message. - /// </summary> - /// <param name="type">The update type.</param> - /// <param name="data">The data to send.</param> - /// <value>The GroupUpdate.</value> - private GroupUpdate<T> NewSyncPlayGroupUpdate<T>(GroupUpdateType type, T data) - { - return new GroupUpdate<T>() - { - GroupId = _group.GroupId.ToString(), - Type = type, - Data = data - }; - } - - /// <inheritdoc /> - public void CreateGroup(SessionInfo session, CancellationToken cancellationToken) - { - _group.AddSession(session); - _syncPlayManager.AddSessionToGroup(session, this); - - _group.PlayingItem = session.FullNowPlayingItem; - _group.IsPaused = session.PlayState.IsPaused; - _group.PositionTicks = session.PlayState.PositionTicks ?? 0; - _group.LastActivity = DateTime.UtcNow; - - var updateSession = NewSyncPlayGroupUpdate(GroupUpdateType.GroupJoined, DateToUTCString(DateTime.UtcNow)); - SendGroupUpdate(session, BroadcastType.CurrentSession, updateSession, cancellationToken); - } - - /// <inheritdoc /> - public void SessionJoin(SessionInfo session, JoinGroupRequest request, CancellationToken cancellationToken) - { - if (session.NowPlayingItem?.Id == _group.PlayingItem.Id) - { - _group.AddSession(session); - _syncPlayManager.AddSessionToGroup(session, this); - - var updateSession = NewSyncPlayGroupUpdate(GroupUpdateType.GroupJoined, DateToUTCString(DateTime.UtcNow)); - SendGroupUpdate(session, BroadcastType.CurrentSession, updateSession, cancellationToken); - - var updateOthers = NewSyncPlayGroupUpdate(GroupUpdateType.UserJoined, session.UserName); - SendGroupUpdate(session, BroadcastType.AllExceptCurrentSession, updateOthers, cancellationToken); - - // Syncing will happen client-side - if (!_group.IsPaused) - { - var playCommand = NewSyncPlayCommand(SendCommandType.Play); - SendCommand(session, BroadcastType.CurrentSession, playCommand, cancellationToken); - } - else - { - var pauseCommand = NewSyncPlayCommand(SendCommandType.Pause); - SendCommand(session, BroadcastType.CurrentSession, pauseCommand, cancellationToken); - } - } - else - { - var playRequest = new PlayRequest - { - ItemIds = new Guid[] { _group.PlayingItem.Id }, - StartPositionTicks = _group.PositionTicks - }; - var update = NewSyncPlayGroupUpdate(GroupUpdateType.PrepareSession, playRequest); - SendGroupUpdate(session, BroadcastType.CurrentSession, update, cancellationToken); - } - } - - /// <inheritdoc /> - public void SessionLeave(SessionInfo session, CancellationToken cancellationToken) - { - _group.RemoveSession(session); - _syncPlayManager.RemoveSessionFromGroup(session, this); - - var updateSession = NewSyncPlayGroupUpdate(GroupUpdateType.GroupLeft, _group.PositionTicks); - SendGroupUpdate(session, BroadcastType.CurrentSession, updateSession, cancellationToken); - - var updateOthers = NewSyncPlayGroupUpdate(GroupUpdateType.UserLeft, session.UserName); - SendGroupUpdate(session, BroadcastType.AllExceptCurrentSession, updateOthers, cancellationToken); - } - - /// <inheritdoc /> - public void HandleRequest(SessionInfo session, PlaybackRequest request, CancellationToken cancellationToken) - { - // The server's job is to maintain a consistent state for clients to reference - // and notify clients of state changes. The actual syncing of media playback - // happens client side. Clients are aware of the server's time and use it to sync. - switch (request.Type) - { - case PlaybackRequestType.Play: - HandlePlayRequest(session, request, cancellationToken); - break; - case PlaybackRequestType.Pause: - HandlePauseRequest(session, request, cancellationToken); - break; - case PlaybackRequestType.Seek: - HandleSeekRequest(session, request, cancellationToken); - break; - case PlaybackRequestType.Buffer: - HandleBufferingRequest(session, request, cancellationToken); - break; - case PlaybackRequestType.Ready: - HandleBufferingDoneRequest(session, request, cancellationToken); - break; - case PlaybackRequestType.Ping: - HandlePingUpdateRequest(session, request); - break; - } - } - - /// <summary> - /// Handles a play action requested by a session. - /// </summary> - /// <param name="session">The session.</param> - /// <param name="request">The play action.</param> - /// <param name="cancellationToken">The cancellation token.</param> - private void HandlePlayRequest(SessionInfo session, PlaybackRequest request, CancellationToken cancellationToken) - { - if (_group.IsPaused) - { - // Pick a suitable time that accounts for latency - var delay = Math.Max(_group.GetHighestPing() * 2, GroupInfo.DefaultPing); - - // Unpause group and set starting point in future - // Clients will start playback at LastActivity (datetime) from PositionTicks (playback position) - // The added delay does not guarantee, of course, that the command will be received in time - // Playback synchronization will mainly happen client side - _group.IsPaused = false; - _group.LastActivity = DateTime.UtcNow.AddMilliseconds( - delay); - - var command = NewSyncPlayCommand(SendCommandType.Play); - SendCommand(session, BroadcastType.AllGroup, command, cancellationToken); - } - else - { - // Client got lost, sending current state - var command = NewSyncPlayCommand(SendCommandType.Play); - SendCommand(session, BroadcastType.CurrentSession, command, cancellationToken); - } - } - - /// <summary> - /// Handles a pause action requested by a session. - /// </summary> - /// <param name="session">The session.</param> - /// <param name="request">The pause action.</param> - /// <param name="cancellationToken">The cancellation token.</param> - private void HandlePauseRequest(SessionInfo session, PlaybackRequest request, CancellationToken cancellationToken) - { - if (!_group.IsPaused) - { - // Pause group and compute the media playback position - _group.IsPaused = true; - var currentTime = DateTime.UtcNow; - var elapsedTime = currentTime - _group.LastActivity; - _group.LastActivity = currentTime; - - // Seek only if playback actually started - // Pause request may be issued during the delay added to account for latency - _group.PositionTicks += elapsedTime.Ticks > 0 ? elapsedTime.Ticks : 0; - - var command = NewSyncPlayCommand(SendCommandType.Pause); - SendCommand(session, BroadcastType.AllGroup, command, cancellationToken); - } - else - { - // Client got lost, sending current state - var command = NewSyncPlayCommand(SendCommandType.Pause); - SendCommand(session, BroadcastType.CurrentSession, command, cancellationToken); - } - } - - /// <summary> - /// Handles a seek action requested by a session. - /// </summary> - /// <param name="session">The session.</param> - /// <param name="request">The seek action.</param> - /// <param name="cancellationToken">The cancellation token.</param> - private void HandleSeekRequest(SessionInfo session, PlaybackRequest request, CancellationToken cancellationToken) - { - // Sanitize PositionTicks - var ticks = SanitizePositionTicks(request.PositionTicks); - - // Pause and seek - _group.IsPaused = true; - _group.PositionTicks = ticks; - _group.LastActivity = DateTime.UtcNow; - - var command = NewSyncPlayCommand(SendCommandType.Seek); - SendCommand(session, BroadcastType.AllGroup, command, cancellationToken); - } - - /// <summary> - /// Handles a buffering action requested by a session. - /// </summary> - /// <param name="session">The session.</param> - /// <param name="request">The buffering action.</param> - /// <param name="cancellationToken">The cancellation token.</param> - private void HandleBufferingRequest(SessionInfo session, PlaybackRequest request, CancellationToken cancellationToken) - { - if (!_group.IsPaused) - { - // Pause group and compute the media playback position - _group.IsPaused = true; - var currentTime = DateTime.UtcNow; - var elapsedTime = currentTime - _group.LastActivity; - _group.LastActivity = currentTime; - _group.PositionTicks += elapsedTime.Ticks > 0 ? elapsedTime.Ticks : 0; - - _group.SetBuffering(session, true); - - // Send pause command to all non-buffering sessions - var command = NewSyncPlayCommand(SendCommandType.Pause); - SendCommand(session, BroadcastType.AllReady, command, cancellationToken); - - var updateOthers = NewSyncPlayGroupUpdate(GroupUpdateType.GroupWait, session.UserName); - SendGroupUpdate(session, BroadcastType.AllExceptCurrentSession, updateOthers, cancellationToken); - } - else - { - // Client got lost, sending current state - var command = NewSyncPlayCommand(SendCommandType.Pause); - SendCommand(session, BroadcastType.CurrentSession, command, cancellationToken); - } - } - - /// <summary> - /// Handles a buffering-done action requested by a session. - /// </summary> - /// <param name="session">The session.</param> - /// <param name="request">The buffering-done action.</param> - /// <param name="cancellationToken">The cancellation token.</param> - private void HandleBufferingDoneRequest(SessionInfo session, PlaybackRequest request, CancellationToken cancellationToken) - { - if (_group.IsPaused) - { - _group.SetBuffering(session, false); - - var requestTicks = SanitizePositionTicks(request.PositionTicks); - - var when = request.When ?? DateTime.UtcNow; - var currentTime = DateTime.UtcNow; - var elapsedTime = currentTime - when; - var clientPosition = TimeSpan.FromTicks(requestTicks) + elapsedTime; - var delay = _group.PositionTicks - clientPosition.Ticks; - - if (_group.IsBuffering()) - { - // Others are still buffering, tell this client to pause when ready - var command = NewSyncPlayCommand(SendCommandType.Pause); - var pauseAtTime = currentTime.AddMilliseconds(delay); - command.When = DateToUTCString(pauseAtTime); - SendCommand(session, BroadcastType.CurrentSession, command, cancellationToken); - } - else - { - // Let other clients resume as soon as the buffering client catches up - _group.IsPaused = false; - - if (delay > _group.GetHighestPing() * 2) - { - // Client that was buffering is recovering, notifying others to resume - _group.LastActivity = currentTime.AddMilliseconds( - delay); - var command = NewSyncPlayCommand(SendCommandType.Play); - SendCommand(session, BroadcastType.AllExceptCurrentSession, command, cancellationToken); - } - else - { - // Client, that was buffering, resumed playback but did not update others in time - delay = Math.Max(_group.GetHighestPing() * 2, GroupInfo.DefaultPing); - - _group.LastActivity = currentTime.AddMilliseconds( - delay); - - var command = NewSyncPlayCommand(SendCommandType.Play); - SendCommand(session, BroadcastType.AllGroup, command, cancellationToken); - } - } - } - else - { - // Group was not waiting, make sure client has latest state - var command = NewSyncPlayCommand(SendCommandType.Play); - SendCommand(session, BroadcastType.CurrentSession, command, cancellationToken); - } - } - - /// <summary> - /// Sanitizes the PositionTicks, considers the current playing item when available. - /// </summary> - /// <param name="positionTicks">The PositionTicks.</param> - /// <value>The sanitized PositionTicks.</value> - private long SanitizePositionTicks(long? positionTicks) - { - var ticks = positionTicks ?? 0; - ticks = ticks >= 0 ? ticks : 0; - if (_group.PlayingItem != null) - { - var runTimeTicks = _group.PlayingItem.RunTimeTicks ?? 0; - ticks = ticks > runTimeTicks ? runTimeTicks : ticks; - } - - return ticks; - } - - /// <summary> - /// Updates ping of a session. - /// </summary> - /// <param name="session">The session.</param> - /// <param name="request">The update.</param> - private void HandlePingUpdateRequest(SessionInfo session, PlaybackRequest request) - { - // Collected pings are used to account for network latency when unpausing playback - _group.UpdatePing(session, request.Ping ?? GroupInfo.DefaultPing); - } - - /// <inheritdoc /> - public GroupInfoView GetInfo() - { - return new GroupInfoView() - { - GroupId = GetGroupId().ToString(), - PlayingItemName = _group.PlayingItem.Name, - PlayingItemId = _group.PlayingItem.Id.ToString(), - PositionTicks = _group.PositionTicks, - Participants = _group.Participants.Values.Select(session => session.Session.UserName).Distinct().ToList() - }; - } - } -} diff --git a/Emby.Server.Implementations/SyncPlay/SyncPlayManager.cs b/Emby.Server.Implementations/SyncPlay/SyncPlayManager.cs index 966ed5024e..993456196d 100644 --- a/Emby.Server.Implementations/SyncPlay/SyncPlayManager.cs +++ b/Emby.Server.Implementations/SyncPlay/SyncPlayManager.cs @@ -1,13 +1,13 @@ +#nullable disable + using System; +using System.Collections.Concurrent; using System.Collections.Generic; -using System.Globalization; -using System.Linq; using System.Threading; -using Jellyfin.Data.Entities; -using Jellyfin.Data.Enums; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Session; using MediaBrowser.Controller.SyncPlay; +using MediaBrowser.Controller.SyncPlay.Requests; using MediaBrowser.Model.SyncPlay; using Microsoft.Extensions.Logging; @@ -23,6 +23,11 @@ namespace Emby.Server.Implementations.SyncPlay /// </summary> private readonly ILogger<SyncPlayManager> _logger; + /// <summary> + /// The logger factory. + /// </summary> + private readonly ILoggerFactory _loggerFactory; + /// <summary> /// The user manager. /// </summary> @@ -38,21 +43,30 @@ namespace Emby.Server.Implementations.SyncPlay /// </summary> private readonly ILibraryManager _libraryManager; + /// <summary> + /// The map between users and counter of active sessions. + /// </summary> + private readonly ConcurrentDictionary<Guid, int> _activeUsers = + new ConcurrentDictionary<Guid, int>(); + /// <summary> /// The map between sessions and groups. /// </summary> - private readonly Dictionary<string, ISyncPlayController> _sessionToGroupMap = - new Dictionary<string, ISyncPlayController>(StringComparer.OrdinalIgnoreCase); + private readonly ConcurrentDictionary<string, Group> _sessionToGroupMap = + new ConcurrentDictionary<string, Group>(StringComparer.OrdinalIgnoreCase); /// <summary> /// The groups. /// </summary> - private readonly Dictionary<Guid, ISyncPlayController> _groups = - new Dictionary<Guid, ISyncPlayController>(); + private readonly ConcurrentDictionary<Guid, Group> _groups = + new ConcurrentDictionary<Guid, Group>(); /// <summary> - /// Lock used for accesing any group. + /// Lock used for accessing multiple groups at once. /// </summary> + /// <remarks> + /// This lock has priority on locks made on <see cref="Group"/>. + /// </remarks> private readonly object _groupsLock = new object(); private bool _disposed = false; @@ -60,31 +74,24 @@ namespace Emby.Server.Implementations.SyncPlay /// <summary> /// Initializes a new instance of the <see cref="SyncPlayManager" /> class. /// </summary> - /// <param name="logger">The logger.</param> + /// <param name="loggerFactory">The logger factory.</param> /// <param name="userManager">The user manager.</param> /// <param name="sessionManager">The session manager.</param> /// <param name="libraryManager">The library manager.</param> public SyncPlayManager( - ILogger<SyncPlayManager> logger, + ILoggerFactory loggerFactory, IUserManager userManager, ISessionManager sessionManager, ILibraryManager libraryManager) { - _logger = logger; + _loggerFactory = loggerFactory; _userManager = userManager; _sessionManager = sessionManager; _libraryManager = libraryManager; - - _sessionManager.SessionEnded += OnSessionManagerSessionEnded; - _sessionManager.PlaybackStopped += OnSessionManagerPlaybackStopped; + _logger = loggerFactory.CreateLogger<SyncPlayManager>(); + _sessionManager.SessionEnded += OnSessionEnded; } - /// <summary> - /// Gets all groups. - /// </summary> - /// <value>All groups.</value> - public IEnumerable<ISyncPlayController> Groups => _groups.Values; - /// <inheritdoc /> public void Dispose() { @@ -92,286 +99,297 @@ namespace Emby.Server.Implementations.SyncPlay GC.SuppressFinalize(this); } - /// <summary> - /// Releases unmanaged and optionally managed resources. - /// </summary> - /// <param name="disposing"><c>true</c> to release both managed and unmanaged resources; <c>false</c> to release only unmanaged resources.</param> - protected virtual void Dispose(bool disposing) + /// <inheritdoc /> + public void NewGroup(SessionInfo session, NewGroupRequest request, CancellationToken cancellationToken) { - if (_disposed) + if (session == null) { - return; + throw new InvalidOperationException("Session is null!"); } - _sessionManager.SessionEnded -= OnSessionManagerSessionEnded; - _sessionManager.PlaybackStopped -= OnSessionManagerPlaybackStopped; - - _disposed = true; - } - - private void OnSessionManagerSessionEnded(object sender, SessionEventArgs e) - { - var session = e.SessionInfo; - if (!IsSessionInGroup(session)) + if (request == null) { - return; + throw new InvalidOperationException("Request is null!"); } - LeaveGroup(session, CancellationToken.None); - } - - private void OnSessionManagerPlaybackStopped(object sender, PlaybackStopEventArgs e) - { - var session = e.Session; - if (!IsSessionInGroup(session)) + // Locking required to access list of groups. + lock (_groupsLock) { - return; - } - - LeaveGroup(session, CancellationToken.None); - } - - private bool IsSessionInGroup(SessionInfo session) - { - return _sessionToGroupMap.ContainsKey(session.Id); - } - - private bool HasAccessToItem(User user, Guid itemId) - { - var item = _libraryManager.GetItemById(itemId); + // Make sure that session has not joined another group. + if (_sessionToGroupMap.ContainsKey(session.Id)) + { + var leaveGroupRequest = new LeaveGroupRequest(); + LeaveGroup(session, leaveGroupRequest, cancellationToken); + } - // Check ParentalRating access - var hasParentalRatingAccess = !user.MaxParentalAgeRating.HasValue - || item.InheritedParentalRatingValue <= user.MaxParentalAgeRating; + var group = new Group(_loggerFactory, _userManager, _sessionManager, _libraryManager); + _groups[group.GroupId] = group; - if (!user.HasPermission(PermissionKind.EnableAllFolders) && hasParentalRatingAccess) - { - var collections = _libraryManager.GetCollectionFolders(item).Select( - folder => folder.Id.ToString("N", CultureInfo.InvariantCulture)); + if (!_sessionToGroupMap.TryAdd(session.Id, group)) + { + throw new InvalidOperationException("Could not add session to group!"); + } - return collections.Intersect(user.GetPreference(PreferenceKind.EnabledFolders)).Any(); + UpdateSessionsCounter(session.UserId, 1); + group.CreateGroup(session, request, cancellationToken); } - - return hasParentalRatingAccess; - } - - private Guid? GetSessionGroup(SessionInfo session) - { - _sessionToGroupMap.TryGetValue(session.Id, out var group); - return group?.GetGroupId(); } /// <inheritdoc /> - public void NewGroup(SessionInfo session, CancellationToken cancellationToken) + public void JoinGroup(SessionInfo session, JoinGroupRequest request, CancellationToken cancellationToken) { - var user = _userManager.GetUserById(session.UserId); - - if (user.SyncPlayAccess != SyncPlayAccess.CreateAndJoinGroups) + if (session == null) { - _logger.LogWarning("NewGroup: {0} does not have permission to create groups.", session.Id); - - var error = new GroupUpdate<string> - { - Type = GroupUpdateType.CreateGroupDenied - }; - - _sessionManager.SendSyncPlayGroupUpdate(session.Id, error, CancellationToken.None); - return; + throw new InvalidOperationException("Session is null!"); } - lock (_groupsLock) + if (request == null) { - if (IsSessionInGroup(session)) - { - LeaveGroup(session, cancellationToken); - } - - var group = new SyncPlayController(_sessionManager, this); - _groups[group.GetGroupId()] = group; - - group.CreateGroup(session, cancellationToken); + throw new InvalidOperationException("Request is null!"); } - } - /// <inheritdoc /> - public void JoinGroup(SessionInfo session, Guid groupId, JoinGroupRequest request, CancellationToken cancellationToken) - { var user = _userManager.GetUserById(session.UserId); - if (user.SyncPlayAccess == SyncPlayAccess.None) - { - _logger.LogWarning("JoinGroup: {0} does not have access to SyncPlay.", session.Id); - - var error = new GroupUpdate<string>() - { - Type = GroupUpdateType.JoinGroupDenied - }; - - _sessionManager.SendSyncPlayGroupUpdate(session.Id, error, CancellationToken.None); - return; - } - + // Locking required to access list of groups. lock (_groupsLock) { - ISyncPlayController group; - _groups.TryGetValue(groupId, out group); + _groups.TryGetValue(request.GroupId, out Group group); if (group == null) { - _logger.LogWarning("JoinGroup: {0} tried to join group {0} that does not exist.", session.Id, groupId); + _logger.LogWarning("Session {SessionId} tried to join group {GroupId} that does not exist.", session.Id, request.GroupId); - var error = new GroupUpdate<string>() - { - Type = GroupUpdateType.GroupDoesNotExist - }; - _sessionManager.SendSyncPlayGroupUpdate(session.Id, error, CancellationToken.None); + var error = new GroupUpdate<string>(Guid.Empty, GroupUpdateType.GroupDoesNotExist, string.Empty); + _sessionManager.SendSyncPlayGroupUpdate(session, error, CancellationToken.None); return; } - if (!HasAccessToItem(user, group.GetPlayingItemId())) + // Group lock required to let other requests end first. + lock (group) { - _logger.LogWarning("JoinGroup: {0} does not have access to {1}.", session.Id, group.GetPlayingItemId()); + if (!group.HasAccessToPlayQueue(user)) + { + _logger.LogWarning("Session {SessionId} tried to join group {GroupId} but does not have access to some content of the playing queue.", session.Id, group.GroupId.ToString()); - var error = new GroupUpdate<string>() + var error = new GroupUpdate<string>(group.GroupId, GroupUpdateType.LibraryAccessDenied, string.Empty); + _sessionManager.SendSyncPlayGroupUpdate(session, error, CancellationToken.None); + return; + } + + if (_sessionToGroupMap.TryGetValue(session.Id, out var existingGroup)) { - GroupId = group.GetGroupId().ToString(), - Type = GroupUpdateType.LibraryAccessDenied - }; - _sessionManager.SendSyncPlayGroupUpdate(session.Id, error, CancellationToken.None); - return; - } + if (existingGroup.GroupId.Equals(request.GroupId)) + { + // Restore session. + UpdateSessionsCounter(session.UserId, 1); + group.SessionJoin(session, request, cancellationToken); + return; + } + + var leaveGroupRequest = new LeaveGroupRequest(); + LeaveGroup(session, leaveGroupRequest, cancellationToken); + } - if (IsSessionInGroup(session)) - { - if (GetSessionGroup(session).Equals(groupId)) + if (!_sessionToGroupMap.TryAdd(session.Id, group)) { - return; + throw new InvalidOperationException("Could not add session to group!"); } - LeaveGroup(session, cancellationToken); + UpdateSessionsCounter(session.UserId, 1); + group.SessionJoin(session, request, cancellationToken); } - - group.SessionJoin(session, request, cancellationToken); } } /// <inheritdoc /> - public void LeaveGroup(SessionInfo session, CancellationToken cancellationToken) + public void LeaveGroup(SessionInfo session, LeaveGroupRequest request, CancellationToken cancellationToken) { - // TODO: determine what happens to users that are in a group and get their permissions revoked - lock (_groupsLock) + if (session == null) { - _sessionToGroupMap.TryGetValue(session.Id, out var group); + throw new InvalidOperationException("Session is null!"); + } - if (group == null) - { - _logger.LogWarning("LeaveGroup: {0} does not belong to any group.", session.Id); + if (request == null) + { + throw new InvalidOperationException("Request is null!"); + } - var error = new GroupUpdate<string>() + // Locking required to access list of groups. + lock (_groupsLock) + { + if (_sessionToGroupMap.TryGetValue(session.Id, out var group)) + { + // Group lock required to let other requests end first. + lock (group) { - Type = GroupUpdateType.NotInGroup - }; - _sessionManager.SendSyncPlayGroupUpdate(session.Id, error, CancellationToken.None); - return; + if (_sessionToGroupMap.TryRemove(session.Id, out var tempGroup)) + { + if (!tempGroup.GroupId.Equals(group.GroupId)) + { + throw new InvalidOperationException("Session was in wrong group!"); + } + } + else + { + throw new InvalidOperationException("Could not remove session from group!"); + } + + UpdateSessionsCounter(session.UserId, -1); + group.SessionLeave(session, request, cancellationToken); + + if (group.IsGroupEmpty()) + { + _logger.LogInformation("Group {GroupId} is empty, removing it.", group.GroupId); + _groups.Remove(group.GroupId, out _); + } + } } - - group.SessionLeave(session, cancellationToken); - - if (group.IsGroupEmpty()) + else { - _logger.LogInformation("LeaveGroup: removing empty group {0}.", group.GetGroupId()); - _groups.Remove(group.GetGroupId(), out _); + _logger.LogWarning("Session {SessionId} does not belong to any group.", session.Id); + + var error = new GroupUpdate<string>(Guid.Empty, GroupUpdateType.NotInGroup, string.Empty); + _sessionManager.SendSyncPlayGroupUpdate(session, error, CancellationToken.None); + return; } } } /// <inheritdoc /> - public List<GroupInfoView> ListGroups(SessionInfo session, Guid filterItemId) + public List<GroupInfoDto> ListGroups(SessionInfo session, ListGroupsRequest request) { - var user = _userManager.GetUserById(session.UserId); - - if (user.SyncPlayAccess == SyncPlayAccess.None) + if (session == null) { - return new List<GroupInfoView>(); + throw new InvalidOperationException("Session is null!"); } - // Filter by item if requested - if (!filterItemId.Equals(Guid.Empty)) + if (request == null) { - return _groups.Values.Where( - group => group.GetPlayingItemId().Equals(filterItemId) && HasAccessToItem(user, group.GetPlayingItemId())).Select( - group => group.GetInfo()).ToList(); + throw new InvalidOperationException("Request is null!"); } - else + + var user = _userManager.GetUserById(session.UserId); + List<GroupInfoDto> list = new List<GroupInfoDto>(); + + lock (_groupsLock) { - // Otherwise show all available groups - return _groups.Values.Where( - group => HasAccessToItem(user, group.GetPlayingItemId())).Select( - group => group.GetInfo()).ToList(); + foreach (var (_, group) in _groups) + { + // Locking required as group is not thread-safe. + lock (group) + { + if (group.HasAccessToPlayQueue(user)) + { + list.Add(group.GetInfo()); + } + } + } } + + return list; } /// <inheritdoc /> - public void HandleRequest(SessionInfo session, PlaybackRequest request, CancellationToken cancellationToken) + public void HandleRequest(SessionInfo session, IGroupPlaybackRequest request, CancellationToken cancellationToken) { - var user = _userManager.GetUserById(session.UserId); - - if (user.SyncPlayAccess == SyncPlayAccess.None) + if (session == null) { - _logger.LogWarning("HandleRequest: {0} does not have access to SyncPlay.", session.Id); - - var error = new GroupUpdate<string>() - { - Type = GroupUpdateType.JoinGroupDenied - }; - - _sessionManager.SendSyncPlayGroupUpdate(session.Id, error, CancellationToken.None); - return; + throw new InvalidOperationException("Session is null!"); } - lock (_groupsLock) + if (request == null) { - _sessionToGroupMap.TryGetValue(session.Id, out var group); + throw new InvalidOperationException("Request is null!"); + } - if (group == null) + if (_sessionToGroupMap.TryGetValue(session.Id, out var group)) + { + // Group lock required as Group is not thread-safe. + lock (group) { - _logger.LogWarning("HandleRequest: {0} does not belong to any group.", session.Id); + // Make sure that session still belongs to this group. + if (_sessionToGroupMap.TryGetValue(session.Id, out var checkGroup) && !checkGroup.GroupId.Equals(group.GroupId)) + { + // Drop request. + return; + } - var error = new GroupUpdate<string>() + // Drop request if group is empty. + if (group.IsGroupEmpty()) { - Type = GroupUpdateType.NotInGroup - }; - _sessionManager.SendSyncPlayGroupUpdate(session.Id, error, CancellationToken.None); - return; + return; + } + + // Apply requested changes to group. + group.HandleRequest(session, request, cancellationToken); } + } + else + { + _logger.LogWarning("Session {SessionId} does not belong to any group.", session.Id); - group.HandleRequest(session, request, cancellationToken); + var error = new GroupUpdate<string>(Guid.Empty, GroupUpdateType.NotInGroup, string.Empty); + _sessionManager.SendSyncPlayGroupUpdate(session, error, CancellationToken.None); } } /// <inheritdoc /> - public void AddSessionToGroup(SessionInfo session, ISyncPlayController group) + public bool IsUserActive(Guid userId) { - if (IsSessionInGroup(session)) + if (_activeUsers.TryGetValue(userId, out var sessionsCounter)) { - throw new InvalidOperationException("Session in other group already!"); + return sessionsCounter > 0; } + else + { + return false; + } + } - _sessionToGroupMap[session.Id] = group; + /// <summary> + /// Releases unmanaged and optionally managed resources. + /// </summary> + /// <param name="disposing"><c>true</c> to release both managed and unmanaged resources; <c>false</c> to release only unmanaged resources.</param> + protected virtual void Dispose(bool disposing) + { + if (_disposed) + { + return; + } + + _sessionManager.SessionEnded -= OnSessionEnded; + _disposed = true; } - /// <inheritdoc /> - public void RemoveSessionFromGroup(SessionInfo session, ISyncPlayController group) + private void OnSessionEnded(object sender, SessionEventArgs e) + { + var session = e.SessionInfo; + + if (_sessionToGroupMap.TryGetValue(session.Id, out var group)) + { + var leaveGroupRequest = new LeaveGroupRequest(); + LeaveGroup(session, leaveGroupRequest, CancellationToken.None); + } + } + + private void UpdateSessionsCounter(Guid userId, int toAdd) { - if (!IsSessionInGroup(session)) + // Update sessions counter. + var newSessionsCounter = _activeUsers.AddOrUpdate( + userId, + 1, + (key, sessionsCounter) => sessionsCounter + toAdd); + + // Should never happen. + if (newSessionsCounter < 0) { - throw new InvalidOperationException("Session not in any group!"); + throw new InvalidOperationException("Sessions counter is negative!"); } - _sessionToGroupMap.Remove(session.Id, out var tempGroup); - if (!tempGroup.GetGroupId().Equals(group.GetGroupId())) + // Clean record if user has no more active sessions. + if (newSessionsCounter == 0) { - throw new InvalidOperationException("Session was in wrong group!"); + _activeUsers.TryRemove(new KeyValuePair<Guid, int>(userId, newSessionsCounter)); } } } diff --git a/Emby.Server.Implementations/TV/TVSeriesManager.cs b/Emby.Server.Implementations/TV/TVSeriesManager.cs index d1818deff4..af453d1480 100644 --- a/Emby.Server.Implementations/TV/TVSeriesManager.cs +++ b/Emby.Server.Implementations/TV/TVSeriesManager.cs @@ -1,8 +1,9 @@ +#nullable disable + #pragma warning disable CS1591 using System; using System.Collections.Generic; -using System.Globalization; using System.Linq; using Jellyfin.Data.Entities; using Jellyfin.Data.Enums; @@ -11,7 +12,6 @@ using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.TV; -using MediaBrowser.Model.Entities; using MediaBrowser.Model.Querying; using Episode = MediaBrowser.Controller.Entities.TV.Episode; using Series = MediaBrowser.Controller.Entities.TV.Series; @@ -23,12 +23,14 @@ namespace Emby.Server.Implementations.TV private readonly IUserManager _userManager; private readonly IUserDataManager _userDataManager; private readonly ILibraryManager _libraryManager; + private readonly IServerConfigurationManager _configurationManager; - public TVSeriesManager(IUserManager userManager, IUserDataManager userDataManager, ILibraryManager libraryManager) + public TVSeriesManager(IUserManager userManager, IUserDataManager userDataManager, ILibraryManager libraryManager, IServerConfigurationManager configurationManager) { _userManager = userManager; _userDataManager = userDataManager; _libraryManager = libraryManager; + _configurationManager = configurationManager; } public QueryResult<BaseItem> GetNextUp(NextUpQuery request, DtoOptions dtoOptions) @@ -43,9 +45,7 @@ namespace Emby.Server.Implementations.TV string presentationUniqueKey = null; if (!string.IsNullOrEmpty(request.SeriesId)) { - var series = _libraryManager.GetItemById(request.SeriesId) as Series; - - if (series != null) + if (_libraryManager.GetItemById(request.SeriesId) is Series series) { presentationUniqueKey = GetUniqueSeriesKey(series); } @@ -56,13 +56,11 @@ namespace Emby.Server.Implementations.TV return GetResult(GetNextUpEpisodes(request, user, new[] { presentationUniqueKey }, dtoOptions), request); } - var parentIdGuid = string.IsNullOrEmpty(request.ParentId) ? (Guid?)null : new Guid(request.ParentId); - BaseItem[] parents; - if (parentIdGuid.HasValue) + if (request.ParentId.HasValue) { - var parent = _libraryManager.GetItemById(parentIdGuid.Value); + var parent = _libraryManager.GetItemById(request.ParentId.Value); if (parent != null) { @@ -77,8 +75,7 @@ namespace Emby.Server.Implementations.TV { parents = _libraryManager.GetUserRootFolder().GetChildren(user, true) .Where(i => i is Folder) - .Where(i => !user.GetPreference(PreferenceKind.LatestItemExcludes) - .Contains(i.Id.ToString("N", CultureInfo.InvariantCulture))) + .Where(i => !user.GetPreferenceValues<Guid>(PreferenceKind.LatestItemExcludes).Contains(i.Id)) .ToArray(); } @@ -98,9 +95,7 @@ namespace Emby.Server.Implementations.TV int? limit = null; if (!string.IsNullOrEmpty(request.SeriesId)) { - var series = _libraryManager.GetItemById(request.SeriesId) as Series; - - if (series != null) + if (_libraryManager.GetItemById(request.SeriesId) is Series series) { presentationUniqueKey = GetUniqueSeriesKey(series); limit = 1; @@ -121,7 +116,7 @@ namespace Emby.Server.Implementations.TV .GetItemList( new InternalItemsQuery(user) { - IncludeItemTypes = new[] { typeof(Episode).Name }, + IncludeItemTypes = new[] { nameof(Episode) }, OrderBy = new[] { new ValueTuple<string, SortOrder>(ItemSortBy.DatePlayed, SortOrder.Descending) }, SeriesPresentationUniqueKey = presentationUniqueKey, Limit = limit, @@ -146,8 +141,6 @@ namespace Emby.Server.Implementations.TV var allNextUp = seriesKeys .Select(i => GetNextUp(i, currentUser, dtoOptions)); - // allNextUp = allNextUp.OrderByDescending(i => i.Item1); - // If viewing all next up for all series, remove first episodes // But if that returns empty, keep those first episodes (avoid completely empty view) var alwaysEnableFirstEpisode = !string.IsNullOrEmpty(request.SeriesId); @@ -156,7 +149,12 @@ namespace Emby.Server.Implementations.TV return allNextUp .Where(i => { - if (alwaysEnableFirstEpisode || i.Item1 != DateTime.MinValue) + if (request.DisableFirstEpisode) + { + return i.Item1 != DateTime.MinValue; + } + + if (alwaysEnableFirstEpisode || (i.Item1 != DateTime.MinValue && i.Item1.Date >= request.NextUpDateCutoff)) { anyFound = true; return true; @@ -200,21 +198,18 @@ namespace Emby.Server.Implementations.TV ParentIndexNumberNotEquals = 0, DtoOptions = new DtoOptions { - Fields = new ItemFields[] - { - ItemFields.SortName - }, + Fields = new[] { ItemFields.SortName }, EnableImages = false } - }).FirstOrDefault(); + }).Cast<Episode>().FirstOrDefault(); Func<Episode> getEpisode = () => { - return _libraryManager.GetItemList(new InternalItemsQuery(user) + var nextEpisode = _libraryManager.GetItemList(new InternalItemsQuery(user) { AncestorWithPresentationUniqueKey = null, SeriesPresentationUniqueKey = seriesKey, - IncludeItemTypes = new[] { typeof(Episode).Name }, + IncludeItemTypes = new[] { nameof(Episode) }, OrderBy = new[] { new ValueTuple<string, SortOrder>(ItemSortBy.SortName, SortOrder.Ascending) }, Limit = 1, IsPlayed = false, @@ -223,6 +218,55 @@ namespace Emby.Server.Implementations.TV MinSortName = lastWatchedEpisode?.SortName, DtoOptions = dtoOptions }).Cast<Episode>().FirstOrDefault(); + + if (_configurationManager.Configuration.DisplaySpecialsWithinSeasons) + { + var consideredEpisodes = _libraryManager.GetItemList(new InternalItemsQuery(user) + { + AncestorWithPresentationUniqueKey = null, + SeriesPresentationUniqueKey = seriesKey, + ParentIndexNumber = 0, + IncludeItemTypes = new[] { nameof(Episode) }, + IsPlayed = false, + IsVirtualItem = false, + DtoOptions = dtoOptions + }) + .Cast<Episode>() + .Where(episode => episode.AirsBeforeSeasonNumber != null || episode.AirsAfterSeasonNumber != null) + .ToList(); + + if (lastWatchedEpisode != null) + { + // Last watched episode is added, because there could be specials that aired before the last watched episode + consideredEpisodes.Add(lastWatchedEpisode); + } + + if (nextEpisode != null) + { + consideredEpisodes.Add(nextEpisode); + } + + var sortedConsideredEpisodes = _libraryManager.Sort(consideredEpisodes, user, new[] { (ItemSortBy.AiredEpisodeOrder, SortOrder.Ascending) }) + .Cast<Episode>(); + if (lastWatchedEpisode != null) + { + sortedConsideredEpisodes = sortedConsideredEpisodes.SkipWhile(episode => episode.Id != lastWatchedEpisode.Id).Skip(1); + } + + nextEpisode = sortedConsideredEpisodes.FirstOrDefault(); + } + + if (nextEpisode != null) + { + var userData = _userDataManager.GetUserData(user, nextEpisode); + + if (userData.PlaybackPositionTicks > 0) + { + return null; + } + } + + return nextEpisode; }; if (lastWatchedEpisode != null) diff --git a/Emby.Server.Implementations/Udp/UdpServer.cs b/Emby.Server.Implementations/Udp/UdpServer.cs index b7a59cee2d..8179e26c5e 100644 --- a/Emby.Server.Implementations/Udp/UdpServer.cs +++ b/Emby.Server.Implementations/Udp/UdpServer.cs @@ -17,6 +17,11 @@ namespace Emby.Server.Implementations.Udp /// </summary> public sealed class UdpServer : IDisposable { + /// <summary> + /// Address Override Configuration Key. + /// </summary> + public const string AddressOverrideConfigKey = "PublishedServerUrl"; + /// <summary> /// The _logger. /// </summary> @@ -24,11 +29,6 @@ namespace Emby.Server.Implementations.Udp private readonly IServerApplicationHost _appHost; private readonly IConfiguration _config; - /// <summary> - /// Address Override Configuration Key. - /// </summary> - public const string AddressOverrideConfigKey = "PublishedServerUrl"; - private Socket _udpSocket; private IPEndPoint _endpoint; private readonly byte[] _receiveBuffer = new byte[8192]; @@ -38,54 +38,58 @@ namespace Emby.Server.Implementations.Udp /// <summary> /// Initializes a new instance of the <see cref="UdpServer" /> class. /// </summary> - public UdpServer(ILogger logger, IServerApplicationHost appHost, IConfiguration configuration) + /// <param name="logger">The logger.</param> + /// <param name="appHost">The application host.</param> + /// <param name="configuration">The configuration manager.</param> + /// <param name="port">The port.</param> + public UdpServer( + ILogger logger, + IServerApplicationHost appHost, + IConfiguration configuration, + int port) { _logger = logger; _appHost = appHost; _config = configuration; + + _endpoint = new IPEndPoint(IPAddress.Any, port); + + _udpSocket = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp); + _udpSocket.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true); } private async Task RespondToV2Message(string messageText, EndPoint endpoint, CancellationToken cancellationToken) { - string localUrl = !string.IsNullOrEmpty(_config[AddressOverrideConfigKey]) - ? _config[AddressOverrideConfigKey] - : await _appHost.GetLocalApiUrl(cancellationToken).ConfigureAwait(false); + string? localUrl = _config[AddressOverrideConfigKey]; + if (string.IsNullOrEmpty(localUrl)) + { + localUrl = _appHost.GetSmartApiUrl(((IPEndPoint)endpoint).Address); + } - if (!string.IsNullOrEmpty(localUrl)) + if (string.IsNullOrEmpty(localUrl)) { - var response = new ServerDiscoveryInfo - { - Address = localUrl, - Id = _appHost.SystemId, - Name = _appHost.FriendlyName - }; + _logger.LogWarning("Unable to respond to udp request because the local ip address could not be determined."); + return; + } - try - { - await _udpSocket.SendToAsync(JsonSerializer.SerializeToUtf8Bytes(response), SocketFlags.None, endpoint).ConfigureAwait(false); - } - catch (SocketException ex) - { - _logger.LogError(ex, "Error sending response message"); - } + var response = new ServerDiscoveryInfo(localUrl, _appHost.SystemId, _appHost.FriendlyName); + + try + { + await _udpSocket.SendToAsync(JsonSerializer.SerializeToUtf8Bytes(response), SocketFlags.None, endpoint).ConfigureAwait(false); } - else + catch (SocketException ex) { - _logger.LogWarning("Unable to respond to udp request because the local ip address could not be determined."); + _logger.LogError(ex, "Error sending response message"); } } /// <summary> /// Starts the specified port. /// </summary> - /// <param name="port">The port.</param> - /// <param name="cancellationToken"></param> - public void Start(int port, CancellationToken cancellationToken) + /// <param name="cancellationToken">The cancellation token to cancel operation.</param> + public void Start(CancellationToken cancellationToken) { - _endpoint = new IPEndPoint(IPAddress.Any, port); - - _udpSocket = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp); - _udpSocket.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true); _udpSocket.Bind(_endpoint); _ = Task.Run(async () => await BeginReceiveAsync(cancellationToken).ConfigureAwait(false), cancellationToken).ConfigureAwait(false); @@ -93,9 +97,9 @@ namespace Emby.Server.Implementations.Udp private async Task BeginReceiveAsync(CancellationToken cancellationToken) { + var infiniteTask = Task.Delay(-1, cancellationToken); while (!cancellationToken.IsCancellationRequested) { - var infiniteTask = Task.Delay(-1, cancellationToken); try { var task = _udpSocket.ReceiveFromAsync(_receiveBuffer, SocketFlags.None, _endpoint); diff --git a/Emby.Server.Implementations/Updates/InstallationManager.cs b/Emby.Server.Implementations/Updates/InstallationManager.cs index 003cf3c740..7b0afa4e24 100644 --- a/Emby.Server.Implementations/Updates/InstallationManager.cs +++ b/Emby.Server.Implementations/Updates/InstallationManager.cs @@ -1,28 +1,28 @@ -#pragma warning disable CS1591 - using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.IO; using System.Linq; using System.Net.Http; -using System.Runtime.Serialization; +using System.Net.Http.Json; using System.Security.Cryptography; +using System.Text.Json; using System.Threading; using System.Threading.Tasks; -using MediaBrowser.Common; +using Jellyfin.Data.Events; using MediaBrowser.Common.Configuration; +using Jellyfin.Extensions.Json; using MediaBrowser.Common.Net; using MediaBrowser.Common.Plugins; using MediaBrowser.Common.Updates; -using MediaBrowser.Common.System; +using MediaBrowser.Controller; using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.Events; +using MediaBrowser.Controller.Events.Updates; using MediaBrowser.Model.IO; -using MediaBrowser.Model.Net; -using MediaBrowser.Model.Serialization; +using MediaBrowser.Model.Plugins; using MediaBrowser.Model.Updates; using Microsoft.Extensions.Logging; -using MediaBrowser.Model.System; namespace Emby.Server.Implementations.Updates { @@ -36,19 +36,18 @@ namespace Emby.Server.Implementations.Updates /// </summary> private readonly ILogger<InstallationManager> _logger; private readonly IApplicationPaths _appPaths; + private readonly IEventManager _eventManager; private readonly IHttpClientFactory _httpClientFactory; - private readonly IJsonSerializer _jsonSerializer; private readonly IServerConfigurationManager _config; - private readonly IFileSystem _fileSystem; + private readonly JsonSerializerOptions _jsonSerializerOptions; + private readonly IPluginManager _pluginManager; /// <summary> /// Gets the application host. /// </summary> /// <value>The application host.</value> - private readonly IApplicationHost _applicationHost; - + private readonly IServerApplicationHost _applicationHost; private readonly IZipClient _zipClient; - private readonly object _currentInstallationsLock = new object(); /// <summary> @@ -61,85 +60,103 @@ namespace Emby.Server.Implementations.Updates /// </summary> private readonly ConcurrentBag<InstallationInfo> _completedInstallationsInternal; + /// <summary> + /// Initializes a new instance of the <see cref="InstallationManager"/> class. + /// </summary> + /// <param name="logger">The <see cref="ILogger{InstallationManager}"/>.</param> + /// <param name="appHost">The <see cref="IServerApplicationHost"/>.</param> + /// <param name="appPaths">The <see cref="IApplicationPaths"/>.</param> + /// <param name="eventManager">The <see cref="IEventManager"/>.</param> + /// <param name="httpClientFactory">The <see cref="IHttpClientFactory"/>.</param> + /// <param name="config">The <see cref="IServerConfigurationManager"/>.</param> + /// <param name="zipClient">The <see cref="IZipClient"/>.</param> + /// <param name="pluginManager">The <see cref="IPluginManager"/>.</param> public InstallationManager( ILogger<InstallationManager> logger, - IApplicationHost appHost, + IServerApplicationHost appHost, IApplicationPaths appPaths, + IEventManager eventManager, IHttpClientFactory httpClientFactory, - IJsonSerializer jsonSerializer, IServerConfigurationManager config, - IFileSystem fileSystem, - IZipClient zipClient) + IZipClient zipClient, + IPluginManager pluginManager) { - if (logger == null) - { - throw new ArgumentNullException(nameof(logger)); - } - _currentInstallations = new List<(InstallationInfo, CancellationTokenSource)>(); _completedInstallationsInternal = new ConcurrentBag<InstallationInfo>(); _logger = logger; _applicationHost = appHost; _appPaths = appPaths; + _eventManager = eventManager; _httpClientFactory = httpClientFactory; - _jsonSerializer = jsonSerializer; _config = config; - _fileSystem = fileSystem; _zipClient = zipClient; + _jsonSerializerOptions = JsonDefaults.Options; + _pluginManager = pluginManager; } - /// <inheritdoc /> - public event EventHandler<InstallationInfo> PackageInstalling; - - /// <inheritdoc /> - public event EventHandler<InstallationInfo> PackageInstallationCompleted; - - /// <inheritdoc /> - public event EventHandler<InstallationFailedEventArgs> PackageInstallationFailed; - - /// <inheritdoc /> - public event EventHandler<InstallationInfo> PackageInstallationCancelled; - - /// <inheritdoc /> - public event EventHandler<IPlugin> PluginUninstalled; - - /// <inheritdoc /> - public event EventHandler<InstallationInfo> PluginUpdated; - - /// <inheritdoc /> - public event EventHandler<InstallationInfo> PluginInstalled; - /// <inheritdoc /> public IEnumerable<InstallationInfo> CompletedInstallations => _completedInstallationsInternal; /// <inheritdoc /> - public async Task<IReadOnlyList<PackageInfo>> GetPackages(string manifest, CancellationToken cancellationToken = default) + public async Task<PackageInfo[]> GetPackages(string manifestName, string manifest, bool filterIncompatible, CancellationToken cancellationToken = default) { try { - using var response = await _httpClientFactory.CreateClient(NamedClient.Default) - .GetAsync(manifest, cancellationToken).ConfigureAwait(false); - await using var stream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false); + PackageInfo[]? packages = await _httpClientFactory.CreateClient(NamedClient.Default) + .GetFromJsonAsync<PackageInfo[]>(new Uri(manifest), _jsonSerializerOptions, cancellationToken).ConfigureAwait(false); - try + if (packages == null) { - return await _jsonSerializer.DeserializeFromStreamAsync<IReadOnlyList<PackageInfo>>(stream).ConfigureAwait(false); + return Array.Empty<PackageInfo>(); } - catch (SerializationException ex) + + var minimumVersion = new Version(0, 0, 0, 1); + // Store the repository and repository url with each version, as they may be spread apart. + foreach (var entry in packages) { - _logger.LogError(ex, "Failed to deserialize the plugin manifest retrieved from {Manifest}", manifest); - return Array.Empty<PackageInfo>(); + for (int a = entry.Versions.Count - 1; a >= 0; a--) + { + var ver = entry.Versions[a]; + ver.RepositoryName = manifestName; + ver.RepositoryUrl = manifest; + + if (!filterIncompatible) + { + continue; + } + + if (!Version.TryParse(ver.TargetAbi, out var targetAbi)) + { + targetAbi = minimumVersion; + } + + // Only show plugins that are greater than or equal to targetAbi. + if (_applicationHost.ApplicationVersion >= targetAbi) + { + continue; + } + + // Not compatible with this version so remove it. + entry.Versions.Remove(ver); + } } + + return packages; } - catch (UriFormatException ex) + catch (IOException ex) { - _logger.LogError(ex, "The URL configured for the plugin repository manifest URL is not valid: {Manifest}", manifest); + _logger.LogError(ex, "Cannot locate the plugin manifest {Manifest}", manifest); return Array.Empty<PackageInfo>(); } - catch (HttpException ex) + catch (JsonException ex) { - _logger.LogError(ex, "An error occurred while accessing the plugin manifest: {Manifest}", manifest); + _logger.LogError(ex, "Failed to deserialize the plugin manifest retrieved from {Manifest}", manifest); + return Array.Empty<PackageInfo>(); + } + catch (UriFormatException ex) + { + _logger.LogError(ex, "The URL configured for the plugin repository manifest URL is not valid: {Manifest}", manifest); return Array.Empty<PackageInfo>(); } catch (HttpRequestException ex) @@ -155,11 +172,47 @@ namespace Emby.Server.Implementations.Updates var result = new List<PackageInfo>(); foreach (RepositoryInfo repository in _config.Configuration.PluginRepositories) { - foreach (var package in await GetPackages(repository.Url, cancellationToken).ConfigureAwait(true)) + if (repository.Enabled && repository.Url != null) { - package.repositoryName = repository.Name; - package.repositoryUrl = repository.Url; - result.Add(package); + // Where repositories have the same content, the details from the first is taken. + foreach (var package in await GetPackages(repository.Name ?? "Unnamed Repo", repository.Url, true, cancellationToken).ConfigureAwait(true)) + { + var existing = FilterPackages(result, package.Name, package.Id).FirstOrDefault(); + + // Remove invalid versions from the valid package. + for (var i = package.Versions.Count - 1; i >= 0; i--) + { + var version = package.Versions[i]; + + var plugin = _pluginManager.GetPlugin(package.Id, version.VersionNumber); + if (plugin != null) + { + await _pluginManager.GenerateManifest(package, version.VersionNumber, plugin.Path, plugin.Manifest.Status).ConfigureAwait(false); + } + + // Remove versions with a target ABI greater then the current application version. + if (Version.TryParse(version.TargetAbi, out var targetAbi) && _applicationHost.ApplicationVersion < targetAbi) + { + package.Versions.RemoveAt(i); + } + } + + // Don't add a package that doesn't have any compatible versions. + if (package.Versions.Count == 0) + { + continue; + } + + if (existing != null) + { + // Assumption is both lists are ordered, so slot these into the correct place. + MergeSortedList(existing.Versions, package.Versions); + } + else + { + result.Add(package); + } + } } } @@ -169,17 +222,23 @@ namespace Emby.Server.Implementations.Updates /// <inheritdoc /> public IEnumerable<PackageInfo> FilterPackages( IEnumerable<PackageInfo> availablePackages, - string name = null, - Guid guid = default) + string? name = null, + Guid id = default, + Version? specificVersion = null) { if (name != null) { - availablePackages = availablePackages.Where(x => x.name.Equals(name, StringComparison.OrdinalIgnoreCase)); + availablePackages = availablePackages.Where(x => x.Name.Equals(name, StringComparison.OrdinalIgnoreCase)); + } + + if (id != default) + { + availablePackages = availablePackages.Where(x => x.Id == id); } - if (guid != Guid.Empty) + if (specificVersion != null) { - availablePackages = availablePackages.Where(x => Guid.Parse(x.guid) == guid); + availablePackages = availablePackages.Where(x => x.Versions.Any(y => y.VersionNumber.Equals(specificVersion))); } return availablePackages; @@ -188,12 +247,12 @@ namespace Emby.Server.Implementations.Updates /// <inheritdoc /> public IEnumerable<InstallationInfo> GetCompatibleVersions( IEnumerable<PackageInfo> availablePackages, - string name = null, - Guid guid = default, - Version minVersion = null, - Version specificVersion = null) + string? name = null, + Guid id = default, + Version? minVersion = null, + Version? specificVersion = null) { - var package = FilterPackages(availablePackages, name, guid).FirstOrDefault(); + var package = FilterPackages(availablePackages, name, id, specificVersion).FirstOrDefault(); // Package not found in repository if (package == null) @@ -202,28 +261,29 @@ namespace Emby.Server.Implementations.Updates } var appVer = _applicationHost.ApplicationVersion; - var availableVersions = package.versions - .Where(x => Version.Parse(x.targetAbi) <= appVer); + var availableVersions = package.Versions + .Where(x => string.IsNullOrEmpty(x.TargetAbi) || Version.Parse(x.TargetAbi) <= appVer); if (specificVersion != null) { - availableVersions = availableVersions.Where(x => new Version(x.version) == specificVersion); + availableVersions = availableVersions.Where(x => x.VersionNumber.Equals(specificVersion)); } else if (minVersion != null) { - availableVersions = availableVersions.Where(x => new Version(x.version) >= minVersion); + availableVersions = availableVersions.Where(x => x.VersionNumber >= minVersion); } - foreach (var v in availableVersions.OrderByDescending(x => x.version)) + foreach (var v in availableVersions.OrderByDescending(x => x.VersionNumber)) { yield return new InstallationInfo { - Changelog = v.changelog, - Guid = new Guid(package.guid), - Name = package.name, - Version = new Version(v.version), - SourceUrl = v.sourceUrl, - Checksum = v.checksum + Changelog = v.Changelog, + Id = package.Id, + Name = package.Name, + Version = v.VersionNumber, + SourceUrl = v.SourceUrl, + Checksum = v.Checksum, + PackageInfo = package }; } } @@ -235,19 +295,6 @@ namespace Emby.Server.Implementations.Updates return GetAvailablePluginUpdates(catalog); } - private IEnumerable<InstallationInfo> GetAvailablePluginUpdates(IReadOnlyList<PackageInfo> pluginCatalog) - { - foreach (var plugin in _applicationHost.Plugins) - { - var compatibleVersions = GetCompatibleVersions(pluginCatalog, plugin.Name, plugin.Id, minVersion: plugin.Version); - var version = compatibleVersions.FirstOrDefault(y => y.Version > plugin.Version); - if (version != null && CompletedInstallations.All(x => x.Guid != version.Guid)) - { - yield return version; - } - } - } - /// <inheritdoc /> public async Task InstallPackage(InstallationInfo package, CancellationToken cancellationToken) { @@ -266,13 +313,14 @@ namespace Emby.Server.Implementations.Updates _currentInstallations.Add(tuple); } - var linkedToken = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, innerCancellationTokenSource.Token).Token; + using var linkedTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, innerCancellationTokenSource.Token); + var linkedToken = linkedTokenSource.Token; - PackageInstalling?.Invoke(this, package); + await _eventManager.PublishAsync(new PluginInstallingEventArgs(package)).ConfigureAwait(false); try { - await InstallPackageInternal(package, linkedToken).ConfigureAwait(false); + var isUpdate = await InstallPackageInternal(package, linkedToken).ConfigureAwait(false); lock (_currentInstallationsLock) { @@ -280,8 +328,11 @@ namespace Emby.Server.Implementations.Updates } _completedInstallationsInternal.Add(package); + await _eventManager.PublishAsync(isUpdate + ? (GenericEventArgs<InstallationInfo>)new PluginUpdatedEventArgs(package) + : new PluginInstalledEventArgs(package)).ConfigureAwait(false); - PackageInstallationCompleted?.Invoke(this, package); + _applicationHost.NotifyPendingRestart(); } catch (OperationCanceledException) { @@ -292,7 +343,7 @@ namespace Emby.Server.Implementations.Updates _logger.LogInformation("Package installation cancelled: {0} {1}", package.Name, package.Version); - PackageInstallationCancelled?.Invoke(this, package); + await _eventManager.PublishAsync(new PluginInstallationCancelledEventArgs(package)).ConfigureAwait(false); throw; } @@ -305,11 +356,11 @@ namespace Emby.Server.Implementations.Updates _currentInstallations.Remove(tuple); } - PackageInstallationFailed?.Invoke(this, new InstallationFailedEventArgs + await _eventManager.PublishAsync(new InstallationFailedEventArgs { InstallationInfo = package, Exception = ex - }); + }).ConfigureAwait(false); throw; } @@ -321,38 +372,144 @@ namespace Emby.Server.Implementations.Updates } /// <summary> - /// Installs the package internal. + /// Uninstalls a plugin. /// </summary> - /// <param name="package">The package.</param> - /// <param name="cancellationToken">The cancellation token.</param> - /// <returns><see cref="Task" />.</returns> - private async Task InstallPackageInternal(InstallationInfo package, CancellationToken cancellationToken) + /// <param name="plugin">The <see cref="LocalPlugin"/> to uninstall.</param> + public void UninstallPlugin(LocalPlugin plugin) { - // Set last update time if we were installed before - IPlugin plugin = _applicationHost.Plugins.FirstOrDefault(p => p.Id == package.Guid) - ?? _applicationHost.Plugins.FirstOrDefault(p => p.Name.Equals(package.Name, StringComparison.OrdinalIgnoreCase)); + if (plugin == null) + { + return; + } + + if (plugin.Instance?.CanUninstall == false) + { + _logger.LogWarning("Attempt to delete non removable plugin {PluginName}, ignoring request", plugin.Name); + return; + } - // Do the install - await PerformPackageInstallation(package, cancellationToken).ConfigureAwait(false); + plugin.Instance?.OnUninstalling(); - // Do plugin-specific processing - if (plugin == null) + // Remove it the quick way for now + _pluginManager.RemovePlugin(plugin); + + _eventManager.Publish(new PluginUninstalledEventArgs(plugin.GetPluginInfo())); + + _applicationHost.NotifyPendingRestart(); + } + + /// <inheritdoc/> + public bool CancelInstallation(Guid id) + { + lock (_currentInstallationsLock) { - _logger.LogInformation("New plugin installed: {0} {1}", package.Name, package.Version); + var install = _currentInstallations.Find(x => x.info.Id == id); + if (install == default((InstallationInfo, CancellationTokenSource))) + { + return false; + } - PluginInstalled?.Invoke(this, package); + install.token.Cancel(); + _currentInstallations.Remove(install); + return true; } - else + } + + /// <inheritdoc /> + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + /// <summary> + /// Releases unmanaged and optionally managed resources. + /// </summary> + /// <param name="dispose"><c>true</c> to release both managed and unmanaged resources or <c>false</c> to release only unmanaged resources.</param> + protected virtual void Dispose(bool dispose) + { + if (dispose) { - _logger.LogInformation("Plugin updated: {0} {1}", package.Name, package.Version); + lock (_currentInstallationsLock) + { + foreach (var (info, token) in _currentInstallations) + { + token.Dispose(); + } - PluginUpdated?.Invoke(this, package); + _currentInstallations.Clear(); + } } + } - _applicationHost.NotifyPendingRestart(); + /// <summary> + /// Merges two sorted lists. + /// </summary> + /// <param name="source">The source <see cref="IList{VersionInfo}"/> instance to merge.</param> + /// <param name="dest">The destination <see cref="IList{VersionInfo}"/> instance to merge with.</param> + private static void MergeSortedList(IList<VersionInfo> source, IList<VersionInfo> dest) + { + int sLength = source.Count - 1; + int dLength = dest.Count; + int s = 0, d = 0; + var sourceVersion = source[0].VersionNumber; + var destVersion = dest[0].VersionNumber; + + while (d < dLength) + { + if (sourceVersion.CompareTo(destVersion) >= 0) + { + if (s < sLength) + { + sourceVersion = source[++s].VersionNumber; + } + else + { + // Append all of destination to the end of source. + while (d < dLength) + { + source.Add(dest[d++]); + } + + break; + } + } + else + { + source.Insert(s++, dest[d++]); + if (d >= dLength) + { + break; + } + + sLength++; + destVersion = dest[d].VersionNumber; + } + } } - private async Task PerformPackageInstallation(InstallationInfo package, CancellationToken cancellationToken) + private IEnumerable<InstallationInfo> GetAvailablePluginUpdates(IReadOnlyList<PackageInfo> pluginCatalog) + { + var plugins = _pluginManager.Plugins; + foreach (var plugin in plugins) + { + // Don't auto update when plugin marked not to, or when it's disabled. + if (plugin.Manifest?.AutoUpdate == false || plugin.Manifest?.Status == PluginStatus.Disabled) + { + continue; + } + + var compatibleVersions = GetCompatibleVersions(pluginCatalog, plugin.Name, plugin.Id, minVersion: plugin.Version); + var version = compatibleVersions.FirstOrDefault(y => y.Version > plugin.Version); + + if (version != null && CompletedInstallations.All(x => x.Id != version.Id)) + { + yield return version; + } + } + } + + private async Task PerformPackageInstallation(InstallationInfo package, PluginStatus status, CancellationToken cancellationToken) { var extension = Path.GetExtension(package.SourceUrl); if (!string.Equals(extension, ".zip", StringComparison.OrdinalIgnoreCase)) @@ -365,15 +522,16 @@ namespace Emby.Server.Implementations.Updates string targetDir = Path.Combine(_appPaths.PluginsPath, package.Name); using var response = await _httpClientFactory.CreateClient(NamedClient.Default) - .GetAsync(package.SourceUrl, cancellationToken).ConfigureAwait(false); - await using var stream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false); + .GetAsync(new Uri(package.SourceUrl), cancellationToken).ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); // CA5351: Do Not Use Broken Cryptographic Algorithms #pragma warning disable CA5351 using var md5 = MD5.Create(); cancellationToken.ThrowIfCancellationRequested(); - var hash = Hex.Encode(md5.ComputeHash(stream)); + var hash = Convert.ToHexString(md5.ComputeHash(stream)); if (!string.Equals(package.Checksum, hash, StringComparison.OrdinalIgnoreCase)) { _logger.LogError( @@ -393,7 +551,9 @@ namespace Emby.Server.Implementations.Updates { Directory.Delete(targetDir, true); } +#pragma warning disable CA1031 // Do not catch general exception types catch +#pragma warning restore CA1031 // Do not catch general exception types { // Ignore any exceptions. } @@ -401,119 +561,19 @@ namespace Emby.Server.Implementations.Updates stream.Position = 0; _zipClient.ExtractAllFromZip(stream, targetDir, true); - -#pragma warning restore CA5351 - } - - /// <summary> - /// Uninstalls a plugin. - /// </summary> - /// <param name="plugin">The plugin.</param> - public void UninstallPlugin(IPlugin plugin) - { - if (!plugin.CanUninstall) - { - _logger.LogWarning("Attempt to delete non removable plugin {0}, ignoring request", plugin.Name); - return; - } - - plugin.OnUninstalling(); - - // Remove it the quick way for now - _applicationHost.RemovePlugin(plugin); - - var path = plugin.AssemblyFilePath; - bool isDirectory = false; - // Check if we have a plugin directory we should remove too - if (Path.GetDirectoryName(plugin.AssemblyFilePath) != _appPaths.PluginsPath) - { - path = Path.GetDirectoryName(plugin.AssemblyFilePath); - isDirectory = true; - } - - // Make this case-insensitive to account for possible incorrect assembly naming - var file = _fileSystem.GetFilePaths(Path.GetDirectoryName(path)) - .FirstOrDefault(i => string.Equals(i, path, StringComparison.OrdinalIgnoreCase)); - - if (!string.IsNullOrWhiteSpace(file)) - { - path = file; - } - - try - { - if (isDirectory) - { - _logger.LogInformation("Deleting plugin directory {0}", path); - Directory.Delete(path, true); - } - else - { - _logger.LogInformation("Deleting plugin file {0}", path); - _fileSystem.DeleteFile(path); - } - } - catch - { - // Ignore file errors. - } - - var list = _config.Configuration.UninstalledPlugins.ToList(); - var filename = Path.GetFileName(path); - if (!list.Contains(filename, StringComparer.OrdinalIgnoreCase)) - { - list.Add(filename); - _config.Configuration.UninstalledPlugins = list.ToArray(); - _config.SaveConfiguration(); - } - - PluginUninstalled?.Invoke(this, plugin); - - _applicationHost.NotifyPendingRestart(); + await _pluginManager.GenerateManifest(package.PackageInfo, package.Version, targetDir, status).ConfigureAwait(false); + _pluginManager.ImportPluginFrom(targetDir); } - /// <inheritdoc/> - public bool CancelInstallation(Guid id) + private async Task<bool> InstallPackageInternal(InstallationInfo package, CancellationToken cancellationToken) { - lock (_currentInstallationsLock) - { - var install = _currentInstallations.Find(x => x.info.Guid == id); - if (install == default((InstallationInfo, CancellationTokenSource))) - { - return false; - } + LocalPlugin? plugin = _pluginManager.Plugins.FirstOrDefault(p => p.Id.Equals(package.Id) && p.Version.Equals(package.Version)) + ?? _pluginManager.Plugins.FirstOrDefault(p => p.Name.Equals(package.Name, StringComparison.OrdinalIgnoreCase) && p.Version.Equals(package.Version)); - install.token.Cancel(); - _currentInstallations.Remove(install); - return true; - } - } - - /// <inheritdoc /> - public void Dispose() - { - Dispose(true); - GC.SuppressFinalize(this); - } - - /// <summary> - /// Releases unmanaged and optionally managed resources. - /// </summary> - /// <param name="dispose"><c>true</c> to release both managed and unmanaged resources or <c>false</c> to release only unmanaged resources.</param> - protected virtual void Dispose(bool dispose) - { - if (dispose) - { - lock (_currentInstallationsLock) - { - foreach (var tuple in _currentInstallations) - { - tuple.token.Dispose(); - } + await PerformPackageInstallation(package, plugin?.Manifest.Status ?? PluginStatus.Active, cancellationToken).ConfigureAwait(false); + _logger.LogInformation(plugin == null ? "New plugin installed: {PluginName} {PluginVersion}" : "Plugin updated: {PluginName} {PluginVersion}", package.Name, package.Version); - _currentInstallations.Clear(); - } - } + return plugin != null; } } } diff --git a/Jellyfin.Api/Attributes/AcceptsFileAttribute.cs b/Jellyfin.Api/Attributes/AcceptsFileAttribute.cs new file mode 100644 index 0000000000..49b6689cde --- /dev/null +++ b/Jellyfin.Api/Attributes/AcceptsFileAttribute.cs @@ -0,0 +1,28 @@ +using System; + +namespace Jellyfin.Api.Attributes +{ + /// <summary> + /// Internal produces image attribute. + /// </summary> + [AttributeUsage(AttributeTargets.Method)] + public class AcceptsFileAttribute : Attribute + { + private readonly string[] _contentTypes; + + /// <summary> + /// Initializes a new instance of the <see cref="AcceptsFileAttribute"/> class. + /// </summary> + /// <param name="contentTypes">Content types this endpoint produces.</param> + public AcceptsFileAttribute(params string[] contentTypes) + { + _contentTypes = contentTypes; + } + + /// <summary> + /// Gets the configured content types. + /// </summary> + /// <returns>the configured content types.</returns> + public string[] GetContentTypes() => _contentTypes; + } +} diff --git a/Jellyfin.Api/Attributes/AcceptsImageFileAttribute.cs b/Jellyfin.Api/Attributes/AcceptsImageFileAttribute.cs new file mode 100644 index 0000000000..001f27409e --- /dev/null +++ b/Jellyfin.Api/Attributes/AcceptsImageFileAttribute.cs @@ -0,0 +1,18 @@ +namespace Jellyfin.Api.Attributes +{ + /// <summary> + /// Produces file attribute of "image/*". + /// </summary> + public class AcceptsImageFileAttribute : AcceptsFileAttribute + { + private const string ContentType = "image/*"; + + /// <summary> + /// Initializes a new instance of the <see cref="AcceptsImageFileAttribute"/> class. + /// </summary> + public AcceptsImageFileAttribute() + : base(ContentType) + { + } + } +} diff --git a/Jellyfin.Api/Attributes/ParameterObsoleteAttribute.cs b/Jellyfin.Api/Attributes/ParameterObsoleteAttribute.cs new file mode 100644 index 0000000000..56c9772b6d --- /dev/null +++ b/Jellyfin.Api/Attributes/ParameterObsoleteAttribute.cs @@ -0,0 +1,12 @@ +using System; + +namespace Jellyfin.Api.Attributes +{ + /// <summary> + /// Attribute to mark a parameter as obsolete. + /// </summary> + [AttributeUsage(AttributeTargets.Parameter)] + public class ParameterObsoleteAttribute : Attribute + { + } +} diff --git a/Jellyfin.Api/Auth/BaseAuthorizationHandler.cs b/Jellyfin.Api/Auth/BaseAuthorizationHandler.cs index d732b6bc6a..392498c530 100644 --- a/Jellyfin.Api/Auth/BaseAuthorizationHandler.cs +++ b/Jellyfin.Api/Auth/BaseAuthorizationHandler.cs @@ -50,6 +50,13 @@ namespace Jellyfin.Api.Auth bool localAccessOnly = false, bool requiredDownloadPermission = false) { + // ApiKey is currently global admin, always allow. + var isApiKey = ClaimHelpers.GetIsApiKey(claimsPrincipal); + if (isApiKey) + { + return true; + } + // Ensure claim has userId. var userId = ClaimHelpers.GetUserId(claimsPrincipal); if (!userId.HasValue) @@ -70,8 +77,9 @@ namespace Jellyfin.Api.Auth return false; } - var ip = _httpContextAccessor.HttpContext.GetNormalizedRemoteIp(); - var isInLocalNetwork = _networkManager.IsInLocalNetwork(ip); + var isInLocalNetwork = _httpContextAccessor.HttpContext != null + && _networkManager.IsInLocalNetwork(_httpContextAccessor.HttpContext.GetNormalizedRemoteIp()); + // User cannot access remotely and user is remote if (!user.HasPermission(PermissionKind.EnableRemoteAccess) && !isInLocalNetwork) { diff --git a/Jellyfin.Api/Auth/CustomAuthenticationHandler.cs b/Jellyfin.Api/Auth/CustomAuthenticationHandler.cs index 733c6959eb..c56233794a 100644 --- a/Jellyfin.Api/Auth/CustomAuthenticationHandler.cs +++ b/Jellyfin.Api/Auth/CustomAuthenticationHandler.cs @@ -1,10 +1,10 @@ using System.Globalization; -using System.Security.Authentication; using System.Security.Claims; using System.Text.Encodings.Web; using System.Threading.Tasks; using Jellyfin.Api.Constants; using Jellyfin.Data.Enums; +using MediaBrowser.Controller.Authentication; using MediaBrowser.Controller.Net; using Microsoft.AspNetCore.Authentication; using Microsoft.Extensions.Logging; @@ -18,6 +18,7 @@ namespace Jellyfin.Api.Auth public class CustomAuthenticationHandler : AuthenticationHandler<AuthenticationSchemeOptions> { private readonly IAuthService _authService; + private readonly ILogger<CustomAuthenticationHandler> _logger; /// <summary> /// Initializes a new instance of the <see cref="CustomAuthenticationHandler" /> class. @@ -35,6 +36,7 @@ namespace Jellyfin.Api.Auth ISystemClock clock) : base(options, logger, encoder, clock) { _authService = authService; + _logger = logger.CreateLogger<CustomAuthenticationHandler>(); } /// <inheritdoc /> @@ -43,24 +45,23 @@ namespace Jellyfin.Api.Auth try { var authorizationInfo = _authService.Authenticate(Request); - if (authorizationInfo == null) + var role = UserRoles.User; + if (authorizationInfo.IsApiKey || authorizationInfo.User.HasPermission(PermissionKind.IsAdministrator)) { - return Task.FromResult(AuthenticateResult.NoResult()); - // TODO return when legacy API is removed. - // Don't spam the log with "Invalid User" - // return Task.FromResult(AuthenticateResult.Fail("Invalid user")); + role = UserRoles.Administrator; } var claims = new[] { - new Claim(ClaimTypes.Name, authorizationInfo.User.Username), - new Claim(ClaimTypes.Role, authorizationInfo.User.HasPermission(PermissionKind.IsAdministrator) ? UserRoles.Administrator : UserRoles.User), + new Claim(ClaimTypes.Name, authorizationInfo.User?.Username ?? string.Empty), + new Claim(ClaimTypes.Role, role), new Claim(InternalClaimTypes.UserId, authorizationInfo.UserId.ToString("N", CultureInfo.InvariantCulture)), new Claim(InternalClaimTypes.DeviceId, authorizationInfo.DeviceId), new Claim(InternalClaimTypes.Device, authorizationInfo.Device), new Claim(InternalClaimTypes.Client, authorizationInfo.Client), new Claim(InternalClaimTypes.Version, authorizationInfo.Version), new Claim(InternalClaimTypes.Token, authorizationInfo.Token), + new Claim(InternalClaimTypes.IsApiKey, authorizationInfo.IsApiKey.ToString(CultureInfo.InvariantCulture)) }; var identity = new ClaimsIdentity(claims, Scheme.Name); @@ -71,7 +72,8 @@ namespace Jellyfin.Api.Auth } catch (AuthenticationException ex) { - return Task.FromResult(AuthenticateResult.Fail(ex)); + _logger.LogDebug(ex, "Error authenticating with {Handler}", nameof(CustomAuthenticationHandler)); + return Task.FromResult(AuthenticateResult.NoResult()); } catch (SecurityException ex) { diff --git a/Jellyfin.Api/Auth/DefaultAuthorizationPolicy/DefaultAuthorizationHandler.cs b/Jellyfin.Api/Auth/DefaultAuthorizationPolicy/DefaultAuthorizationHandler.cs index b5913daab9..be77b7a4e4 100644 --- a/Jellyfin.Api/Auth/DefaultAuthorizationPolicy/DefaultAuthorizationHandler.cs +++ b/Jellyfin.Api/Auth/DefaultAuthorizationPolicy/DefaultAuthorizationHandler.cs @@ -29,13 +29,15 @@ namespace Jellyfin.Api.Auth.DefaultAuthorizationPolicy protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, DefaultAuthorizationRequirement requirement) { var validated = ValidateClaims(context.User); - if (!validated) + if (validated) + { + context.Succeed(requirement); + } + else { context.Fail(); - return Task.CompletedTask; } - context.Succeed(requirement); return Task.CompletedTask; } } diff --git a/Jellyfin.Api/Auth/IgnoreParentalControlPolicy/IgnoreParentalControlHandler.cs b/Jellyfin.Api/Auth/IgnoreParentalControlPolicy/IgnoreParentalControlHandler.cs index 5213bc4cb7..a7623556a9 100644 --- a/Jellyfin.Api/Auth/IgnoreParentalControlPolicy/IgnoreParentalControlHandler.cs +++ b/Jellyfin.Api/Auth/IgnoreParentalControlPolicy/IgnoreParentalControlHandler.cs @@ -29,13 +29,15 @@ namespace Jellyfin.Api.Auth.IgnoreParentalControlPolicy protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, IgnoreParentalControlRequirement requirement) { var validated = ValidateClaims(context.User, ignoreSchedule: true); - if (!validated) + if (validated) + { + context.Succeed(requirement); + } + else { context.Fail(); - return Task.CompletedTask; } - context.Succeed(requirement); return Task.CompletedTask; } } diff --git a/Jellyfin.Api/Auth/LocalAccessPolicy/LocalAccessHandler.cs b/Jellyfin.Api/Auth/LocalAccessPolicy/LocalAccessHandler.cs index af73352bcc..d772ec5542 100644 --- a/Jellyfin.Api/Auth/LocalAccessPolicy/LocalAccessHandler.cs +++ b/Jellyfin.Api/Auth/LocalAccessPolicy/LocalAccessHandler.cs @@ -29,13 +29,13 @@ namespace Jellyfin.Api.Auth.LocalAccessPolicy protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, LocalAccessRequirement requirement) { var validated = ValidateClaims(context.User, localAccessOnly: true); - if (!validated) + if (validated) { - context.Fail(); + context.Succeed(requirement); } else { - context.Succeed(requirement); + context.Fail(); } return Task.CompletedTask; diff --git a/Jellyfin.Api/Auth/SyncPlayAccessPolicy/SyncPlayAccessHandler.cs b/Jellyfin.Api/Auth/SyncPlayAccessPolicy/SyncPlayAccessHandler.cs new file mode 100644 index 0000000000..b898ac76c8 --- /dev/null +++ b/Jellyfin.Api/Auth/SyncPlayAccessPolicy/SyncPlayAccessHandler.cs @@ -0,0 +1,105 @@ +using System.Threading.Tasks; +using Jellyfin.Api.Helpers; +using Jellyfin.Data.Enums; +using MediaBrowser.Common.Net; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.SyncPlay; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; + +namespace Jellyfin.Api.Auth.SyncPlayAccessPolicy +{ + /// <summary> + /// Default authorization handler. + /// </summary> + public class SyncPlayAccessHandler : BaseAuthorizationHandler<SyncPlayAccessRequirement> + { + private readonly ISyncPlayManager _syncPlayManager; + private readonly IUserManager _userManager; + + /// <summary> + /// Initializes a new instance of the <see cref="SyncPlayAccessHandler"/> class. + /// </summary> + /// <param name="syncPlayManager">Instance of the <see cref="ISyncPlayManager"/> interface.</param> + /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> + /// <param name="networkManager">Instance of the <see cref="INetworkManager"/> interface.</param> + /// <param name="httpContextAccessor">Instance of the <see cref="IHttpContextAccessor"/> interface.</param> + public SyncPlayAccessHandler( + ISyncPlayManager syncPlayManager, + IUserManager userManager, + INetworkManager networkManager, + IHttpContextAccessor httpContextAccessor) + : base(userManager, networkManager, httpContextAccessor) + { + _syncPlayManager = syncPlayManager; + _userManager = userManager; + } + + /// <inheritdoc /> + protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, SyncPlayAccessRequirement requirement) + { + if (!ValidateClaims(context.User)) + { + context.Fail(); + return Task.CompletedTask; + } + + var userId = ClaimHelpers.GetUserId(context.User); + var user = _userManager.GetUserById(userId!.Value); + + if (requirement.RequiredAccess == SyncPlayAccessRequirementType.HasAccess) + { + if (user.SyncPlayAccess == SyncPlayUserAccessType.CreateAndJoinGroups + || user.SyncPlayAccess == SyncPlayUserAccessType.JoinGroups + || _syncPlayManager.IsUserActive(userId!.Value)) + { + context.Succeed(requirement); + } + else + { + context.Fail(); + } + } + else if (requirement.RequiredAccess == SyncPlayAccessRequirementType.CreateGroup) + { + if (user.SyncPlayAccess == SyncPlayUserAccessType.CreateAndJoinGroups) + { + context.Succeed(requirement); + } + else + { + context.Fail(); + } + } + else if (requirement.RequiredAccess == SyncPlayAccessRequirementType.JoinGroup) + { + if (user.SyncPlayAccess == SyncPlayUserAccessType.CreateAndJoinGroups + || user.SyncPlayAccess == SyncPlayUserAccessType.JoinGroups) + { + context.Succeed(requirement); + } + else + { + context.Fail(); + } + } + else if (requirement.RequiredAccess == SyncPlayAccessRequirementType.IsInGroup) + { + if (_syncPlayManager.IsUserActive(userId!.Value)) + { + 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 new file mode 100644 index 0000000000..6fab4c0ad8 --- /dev/null +++ b/Jellyfin.Api/Auth/SyncPlayAccessPolicy/SyncPlayAccessRequirement.cs @@ -0,0 +1,25 @@ +using Jellyfin.Data.Enums; +using Microsoft.AspNetCore.Authorization; + +namespace Jellyfin.Api.Auth.SyncPlayAccessPolicy +{ + /// <summary> + /// The default authorization requirement. + /// </summary> + public class SyncPlayAccessRequirement : IAuthorizationRequirement + { + /// <summary> + /// Initializes a new instance of the <see cref="SyncPlayAccessRequirement"/> class. + /// </summary> + /// <param name="requiredAccess">A value of <see cref="SyncPlayAccessRequirementType"/>.</param> + public SyncPlayAccessRequirement(SyncPlayAccessRequirementType requiredAccess) + { + RequiredAccess = requiredAccess; + } + + /// <summary> + /// Gets the required SyncPlay access. + /// </summary> + public SyncPlayAccessRequirementType RequiredAccess { get; } + } +} diff --git a/Jellyfin.Api/BaseJellyfinApiController.cs b/Jellyfin.Api/BaseJellyfinApiController.cs index 1c1fc71d79..59d6b75137 100644 --- a/Jellyfin.Api/BaseJellyfinApiController.cs +++ b/Jellyfin.Api/BaseJellyfinApiController.cs @@ -1,5 +1,5 @@ using System.Net.Mime; -using MediaBrowser.Common.Json; +using Jellyfin.Extensions.Json; using Microsoft.AspNetCore.Mvc; namespace Jellyfin.Api diff --git a/Jellyfin.Api/Constants/InternalClaimTypes.cs b/Jellyfin.Api/Constants/InternalClaimTypes.cs index 4d7c7135d5..8323312e51 100644 --- a/Jellyfin.Api/Constants/InternalClaimTypes.cs +++ b/Jellyfin.Api/Constants/InternalClaimTypes.cs @@ -34,5 +34,10 @@ /// Token. /// </summary> public const string Token = "Jellyfin-Token"; + + /// <summary> + /// Is Api Key. + /// </summary> + public const string IsApiKey = "Jellyfin-IsApiKey"; } } diff --git a/Jellyfin.Api/Constants/Policies.cs b/Jellyfin.Api/Constants/Policies.cs index 7d77674700..632dedb3cd 100644 --- a/Jellyfin.Api/Constants/Policies.cs +++ b/Jellyfin.Api/Constants/Policies.cs @@ -49,5 +49,25 @@ namespace Jellyfin.Api.Constants /// Policy name for escaping schedule controls or requiring first time setup. /// </summary> public const string FirstTimeSetupOrIgnoreParentalControl = "FirstTimeSetupOrIgnoreParentalControl"; + + /// <summary> + /// Policy name for accessing SyncPlay. + /// </summary> + public const string SyncPlayHasAccess = "SyncPlayHasAccess"; + + /// <summary> + /// Policy name for creating a SyncPlay group. + /// </summary> + public const string SyncPlayCreateGroup = "SyncPlayCreateGroup"; + + /// <summary> + /// Policy name for joining a SyncPlay group. + /// </summary> + public const string SyncPlayJoinGroup = "SyncPlayJoinGroup"; + + /// <summary> + /// Policy name for accessing a SyncPlay group. + /// </summary> + public const string SyncPlayIsInGroup = "SyncPlayIsInGroup"; } } diff --git a/Jellyfin.Api/Controllers/AlbumsController.cs b/Jellyfin.Api/Controllers/AlbumsController.cs deleted file mode 100644 index 357f646a2b..0000000000 --- a/Jellyfin.Api/Controllers/AlbumsController.cs +++ /dev/null @@ -1,135 +0,0 @@ -using System; -using System.Collections.Generic; -using System.ComponentModel.DataAnnotations; -using System.Linq; -using Jellyfin.Api.Extensions; -using Jellyfin.Api.Helpers; -using MediaBrowser.Controller.Dto; -using MediaBrowser.Controller.Entities; -using MediaBrowser.Controller.Entities.Audio; -using MediaBrowser.Controller.Library; -using MediaBrowser.Model.Dto; -using MediaBrowser.Model.Querying; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc; - -namespace Jellyfin.Api.Controllers -{ - /// <summary> - /// The albums controller. - /// </summary> - [Route("")] - public class AlbumsController : BaseJellyfinApiController - { - private readonly IUserManager _userManager; - private readonly ILibraryManager _libraryManager; - private readonly IDtoService _dtoService; - - /// <summary> - /// Initializes a new instance of the <see cref="AlbumsController"/> class. - /// </summary> - /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> - /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> - /// <param name="dtoService">Instance of the <see cref="IDtoService"/> interface.</param> - public AlbumsController( - IUserManager userManager, - ILibraryManager libraryManager, - IDtoService dtoService) - { - _userManager = userManager; - _libraryManager = libraryManager; - _dtoService = dtoService; - } - - /// <summary> - /// Finds albums similar to a given album. - /// </summary> - /// <param name="albumId">The album id.</param> - /// <param name="userId">Optional. Filter by user id, and attach user data.</param> - /// <param name="excludeArtistIds">Optional. Ids of artists to exclude.</param> - /// <param name="limit">Optional. The maximum number of records to return.</param> - /// <response code="200">Similar albums returned.</response> - /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with similar albums.</returns> - [HttpGet("Albums/{albumId}/Similar")] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult<QueryResult<BaseItemDto>> GetSimilarAlbums( - [FromRoute, Required] string albumId, - [FromQuery] Guid? userId, - [FromQuery] string? excludeArtistIds, - [FromQuery] int? limit) - { - var dtoOptions = new DtoOptions().AddClientFields(Request); - - return SimilarItemsHelper.GetSimilarItemsResult( - dtoOptions, - _userManager, - _libraryManager, - _dtoService, - userId, - albumId, - excludeArtistIds, - limit, - new[] { typeof(MusicAlbum) }, - GetAlbumSimilarityScore); - } - - /// <summary> - /// Finds artists similar to a given artist. - /// </summary> - /// <param name="artistId">The artist id.</param> - /// <param name="userId">Optional. Filter by user id, and attach user data.</param> - /// <param name="excludeArtistIds">Optional. Ids of artists to exclude.</param> - /// <param name="limit">Optional. The maximum number of records to return.</param> - /// <response code="200">Similar artists returned.</response> - /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with similar artists.</returns> - [HttpGet("Artists/{artistId}/Similar")] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult<QueryResult<BaseItemDto>> GetSimilarArtists( - [FromRoute, Required] string artistId, - [FromQuery] Guid? userId, - [FromQuery] string? excludeArtistIds, - [FromQuery] int? limit) - { - var dtoOptions = new DtoOptions().AddClientFields(Request); - - return SimilarItemsHelper.GetSimilarItemsResult( - dtoOptions, - _userManager, - _libraryManager, - _dtoService, - userId, - artistId, - excludeArtistIds, - limit, - new[] { typeof(MusicArtist) }, - SimilarItemsHelper.GetSimiliarityScore); - } - - /// <summary> - /// Gets a similairty score of two albums. - /// </summary> - /// <param name="item1">The first item.</param> - /// <param name="item1People">The item1 people.</param> - /// <param name="allPeople">All people.</param> - /// <param name="item2">The second item.</param> - /// <returns>System.Int32.</returns> - private int GetAlbumSimilarityScore(BaseItem item1, List<PersonInfo> item1People, List<PersonInfo> allPeople, BaseItem item2) - { - var points = SimilarItemsHelper.GetSimiliarityScore(item1, item1People, allPeople, item2); - - var album1 = (MusicAlbum)item1; - var album2 = (MusicAlbum)item2; - - var artists1 = album1 - .GetAllArtists() - .DistinctNames() - .ToList(); - - var artists2 = new HashSet<string>( - album2.GetAllArtists().DistinctNames(), - StringComparer.OrdinalIgnoreCase); - - return points + artists1.Where(artists2.Contains).Sum(i => 5); - } - } -} diff --git a/Jellyfin.Api/Controllers/ApiKeyController.cs b/Jellyfin.Api/Controllers/ApiKeyController.cs index e8d6ccdf27..8c43d786a7 100644 --- a/Jellyfin.Api/Controllers/ApiKeyController.cs +++ b/Jellyfin.Api/Controllers/ApiKeyController.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.ComponentModel.DataAnnotations; using System.Globalization; using Jellyfin.Api.Constants; diff --git a/Jellyfin.Api/Controllers/ArtistsController.cs b/Jellyfin.Api/Controllers/ArtistsController.cs index 784ecaeb32..154a567020 100644 --- a/Jellyfin.Api/Controllers/ArtistsController.cs +++ b/Jellyfin.Api/Controllers/ArtistsController.cs @@ -1,14 +1,17 @@ -using System; +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 Jellyfin.Data.Enums; using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; using MediaBrowser.Model.Dto; +using MediaBrowser.Model.Entities; using MediaBrowser.Model.Querying; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; @@ -51,7 +54,7 @@ namespace Jellyfin.Api.Controllers /// <param name="limit">Optional. The maximum number of records to return.</param> /// <param name="searchTerm">Optional. Search term.</param> /// <param name="parentId">Specify this to localize the search to a specific item or folder. Omit to use the root.</param> - /// <param name="fields">Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimited. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines.</param> + /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param> /// <param name="excludeItemTypes">Optional. If specified, results will be filtered out based on item type. This allows multiple, comma delimited.</param> /// <param name="includeItemTypes">Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimited.</param> /// <param name="filters">Optional. Specify additional filters to apply.</param> @@ -74,6 +77,8 @@ namespace Jellyfin.Api.Controllers /// <param name="nameStartsWithOrGreater">Optional filter by items whose name is sorted equally or greater than a given input string.</param> /// <param name="nameStartsWith">Optional filter by items whose name is sorted equally than a given input string.</param> /// <param name="nameLessThan">Optional filter by items whose name is equally or lesser than a given input string.</param> + /// <param name="sortBy">Optional. Specify one or more sort orders, comma delimited.</param> + /// <param name="sortOrder">Sort Order - Ascending,Descending.</param> /// <param name="enableImages">Optional, include image information in output.</param> /// <param name="enableTotalRecordCount">Total record count.</param> /// <response code="200">Artists returned.</response> @@ -85,97 +90,90 @@ namespace Jellyfin.Api.Controllers [FromQuery] int? startIndex, [FromQuery] int? limit, [FromQuery] string? searchTerm, - [FromQuery] string? parentId, - [FromQuery] string? fields, - [FromQuery] string? excludeItemTypes, - [FromQuery] string? includeItemTypes, - [FromQuery] ItemFilter[] filters, + [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] string? mediaTypes, - [FromQuery] string? genres, - [FromQuery] string? genreIds, - [FromQuery] string? officialRatings, - [FromQuery] string? tags, - [FromQuery] string? years, + [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] string? enableImageTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes, [FromQuery] string? person, - [FromQuery] string? personIds, - [FromQuery] string? personTypes, - [FromQuery] string? studios, - [FromQuery] string? studioIds, + [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 dtoOptions = new DtoOptions() - .AddItemFields(fields) + var dtoOptions = new DtoOptions { Fields = fields } .AddClientFields(Request) .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); User? user = null; - BaseItem parentItem; + BaseItem parentItem = _libraryManager.GetParentItem(parentId, userId); if (userId.HasValue && !userId.Equals(Guid.Empty)) { user = _userManager.GetUserById(userId.Value); - parentItem = string.IsNullOrEmpty(parentId) ? _libraryManager.GetUserRootFolder() : _libraryManager.GetItemById(parentId); } - else - { - parentItem = string.IsNullOrEmpty(parentId) ? _libraryManager.RootFolder : _libraryManager.GetItemById(parentId); - } - - var excludeItemTypesArr = RequestHelpers.Split(excludeItemTypes, ',', true); - var includeItemTypesArr = RequestHelpers.Split(includeItemTypes, ',', true); - var mediaTypesArr = RequestHelpers.Split(mediaTypes, ',', true); var query = new InternalItemsQuery(user) { - ExcludeItemTypes = excludeItemTypesArr, - IncludeItemTypes = includeItemTypesArr, - MediaTypes = mediaTypesArr, + ExcludeItemTypes = RequestHelpers.GetItemTypeStrings(excludeItemTypes), + IncludeItemTypes = RequestHelpers.GetItemTypeStrings(includeItemTypes), + MediaTypes = mediaTypes, StartIndex = startIndex, Limit = limit, IsFavorite = isFavorite, NameLessThan = nameLessThan, NameStartsWith = nameStartsWith, NameStartsWithOrGreater = nameStartsWithOrGreater, - Tags = RequestHelpers.Split(tags, ',', true), - OfficialRatings = RequestHelpers.Split(officialRatings, ',', true), - Genres = RequestHelpers.Split(genres, ',', true), - GenreIds = RequestHelpers.GetGuids(genreIds), - StudioIds = RequestHelpers.GetGuids(studioIds), + Tags = tags, + OfficialRatings = officialRatings, + Genres = genres, + GenreIds = genreIds, + StudioIds = studioIds, Person = person, - PersonIds = RequestHelpers.GetGuids(personIds), - PersonTypes = RequestHelpers.Split(personTypes, ',', true), - Years = RequestHelpers.Split(years, ',', true).Select(int.Parse).ToArray(), + PersonIds = personIds, + PersonTypes = personTypes, + Years = years, MinCommunityRating = minCommunityRating, DtoOptions = dtoOptions, SearchTerm = searchTerm, - EnableTotalRecordCount = enableTotalRecordCount + EnableTotalRecordCount = enableTotalRecordCount, + OrderBy = RequestHelpers.GetOrderBy(sortBy, sortOrder) }; - if (!string.IsNullOrWhiteSpace(parentId)) + if (parentId.HasValue) { if (parentItem is Folder) { - query.AncestorIds = new[] { new Guid(parentId) }; + query.AncestorIds = new[] { parentId.Value }; } else { - query.ItemIds = new[] { new Guid(parentId) }; + query.ItemIds = new[] { parentId.Value }; } } // Studios - if (!string.IsNullOrEmpty(studios)) + if (studios.Length != 0) { - query.StudioIds = studios.Split('|').Select(i => + query.StudioIds = studios.Select(i => { try { @@ -229,7 +227,7 @@ namespace Jellyfin.Api.Controllers var (baseItem, itemCounts) = i; var dto = _dtoService.GetItemByNameDto(baseItem, dtoOptions, null, user); - if (!string.IsNullOrWhiteSpace(includeItemTypes)) + if (includeItemTypes.Length != 0) { dto.ChildCount = itemCounts.ItemCount; dto.ProgramCount = itemCounts.ProgramCount; @@ -260,7 +258,7 @@ namespace Jellyfin.Api.Controllers /// <param name="limit">Optional. The maximum number of records to return.</param> /// <param name="searchTerm">Optional. Search term.</param> /// <param name="parentId">Specify this to localize the search to a specific item or folder. Omit to use the root.</param> - /// <param name="fields">Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimited. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines.</param> + /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param> /// <param name="excludeItemTypes">Optional. If specified, results will be filtered out based on item type. This allows multiple, comma delimited.</param> /// <param name="includeItemTypes">Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimited.</param> /// <param name="filters">Optional. Specify additional filters to apply.</param> @@ -283,6 +281,8 @@ namespace Jellyfin.Api.Controllers /// <param name="nameStartsWithOrGreater">Optional filter by items whose name is sorted equally or greater than a given input string.</param> /// <param name="nameStartsWith">Optional filter by items whose name is sorted equally than a given input string.</param> /// <param name="nameLessThan">Optional filter by items whose name is equally or lesser than a given input string.</param> + /// <param name="sortBy">Optional. Specify one or more sort orders, comma delimited.</param> + /// <param name="sortOrder">Sort Order - Ascending,Descending.</param> /// <param name="enableImages">Optional, include image information in output.</param> /// <param name="enableTotalRecordCount">Total record count.</param> /// <response code="200">Album artists returned.</response> @@ -294,97 +294,90 @@ namespace Jellyfin.Api.Controllers [FromQuery] int? startIndex, [FromQuery] int? limit, [FromQuery] string? searchTerm, - [FromQuery] string? parentId, - [FromQuery] string? fields, - [FromQuery] string? excludeItemTypes, - [FromQuery] string? includeItemTypes, - [FromQuery] ItemFilter[] filters, + [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] string? mediaTypes, - [FromQuery] string? genres, - [FromQuery] string? genreIds, - [FromQuery] string? officialRatings, - [FromQuery] string? tags, - [FromQuery] string? years, + [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] string? enableImageTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes, [FromQuery] string? person, - [FromQuery] string? personIds, - [FromQuery] string? personTypes, - [FromQuery] string? studios, - [FromQuery] string? studioIds, + [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 dtoOptions = new DtoOptions() - .AddItemFields(fields) + var dtoOptions = new DtoOptions { Fields = fields } .AddClientFields(Request) .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); User? user = null; - BaseItem parentItem; + BaseItem parentItem = _libraryManager.GetParentItem(parentId, userId); if (userId.HasValue && !userId.Equals(Guid.Empty)) { user = _userManager.GetUserById(userId.Value); - parentItem = string.IsNullOrEmpty(parentId) ? _libraryManager.GetUserRootFolder() : _libraryManager.GetItemById(parentId); } - else - { - parentItem = string.IsNullOrEmpty(parentId) ? _libraryManager.RootFolder : _libraryManager.GetItemById(parentId); - } - - var excludeItemTypesArr = RequestHelpers.Split(excludeItemTypes, ',', true); - var includeItemTypesArr = RequestHelpers.Split(includeItemTypes, ',', true); - var mediaTypesArr = RequestHelpers.Split(mediaTypes, ',', true); var query = new InternalItemsQuery(user) { - ExcludeItemTypes = excludeItemTypesArr, - IncludeItemTypes = includeItemTypesArr, - MediaTypes = mediaTypesArr, + ExcludeItemTypes = RequestHelpers.GetItemTypeStrings(excludeItemTypes), + IncludeItemTypes = RequestHelpers.GetItemTypeStrings(includeItemTypes), + MediaTypes = mediaTypes, StartIndex = startIndex, Limit = limit, IsFavorite = isFavorite, NameLessThan = nameLessThan, NameStartsWith = nameStartsWith, NameStartsWithOrGreater = nameStartsWithOrGreater, - Tags = RequestHelpers.Split(tags, ',', true), - OfficialRatings = RequestHelpers.Split(officialRatings, ',', true), - Genres = RequestHelpers.Split(genres, ',', true), - GenreIds = RequestHelpers.GetGuids(genreIds), - StudioIds = RequestHelpers.GetGuids(studioIds), + Tags = tags, + OfficialRatings = officialRatings, + Genres = genres, + GenreIds = genreIds, + StudioIds = studioIds, Person = person, - PersonIds = RequestHelpers.GetGuids(personIds), - PersonTypes = RequestHelpers.Split(personTypes, ',', true), - Years = RequestHelpers.Split(years, ',', true).Select(int.Parse).ToArray(), + PersonIds = personIds, + PersonTypes = personTypes, + Years = years, MinCommunityRating = minCommunityRating, DtoOptions = dtoOptions, SearchTerm = searchTerm, - EnableTotalRecordCount = enableTotalRecordCount + EnableTotalRecordCount = enableTotalRecordCount, + OrderBy = RequestHelpers.GetOrderBy(sortBy, sortOrder) }; - if (!string.IsNullOrWhiteSpace(parentId)) + if (parentId.HasValue) { if (parentItem is Folder) { - query.AncestorIds = new[] { new Guid(parentId) }; + query.AncestorIds = new[] { parentId.Value }; } else { - query.ItemIds = new[] { new Guid(parentId) }; + query.ItemIds = new[] { parentId.Value }; } } // Studios - if (!string.IsNullOrEmpty(studios)) + if (studios.Length != 0) { - query.StudioIds = studios.Split('|').Select(i => + query.StudioIds = studios.Select(i => { try { @@ -438,7 +431,7 @@ namespace Jellyfin.Api.Controllers var (baseItem, itemCounts) = i; var dto = _dtoService.GetItemByNameDto(baseItem, dtoOptions, null, user); - if (!string.IsNullOrWhiteSpace(includeItemTypes)) + if (includeItemTypes.Length != 0) { dto.ChildCount = itemCounts.ItemCount; dto.ProgramCount = itemCounts.ProgramCount; diff --git a/Jellyfin.Api/Controllers/AudioController.cs b/Jellyfin.Api/Controllers/AudioController.cs index d4c6e4af94..a6e70e72d3 100644 --- a/Jellyfin.Api/Controllers/AudioController.cs +++ b/Jellyfin.Api/Controllers/AudioController.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.Threading.Tasks; @@ -42,7 +42,7 @@ namespace Jellyfin.Api.Controllers /// <param name="deviceProfileId">Optional. The dlna device profile id to utilize.</param> /// <param name="playSessionId">The play session id.</param> /// <param name="segmentContainer">The segment container.</param> - /// <param name="segmentLength">The segment lenght.</param> + /// <param name="segmentLength">The segment length.</param> /// <param name="minSegments">The minimum number of segments.</param> /// <param name="mediaSourceId">The media version id, if playing an alternate version.</param> /// <param name="deviceId">The device id of the client requesting. Used to stop encoding processes when needed.</param> @@ -71,29 +71,192 @@ namespace Jellyfin.Api.Controllers /// <param name="maxVideoBitDepth">Optional. The maximum video bit depth.</param> /// <param name="requireAvc">Optional. Whether to require avc.</param> /// <param name="deInterlace">Optional. Whether to deinterlace the video.</param> - /// <param name="requireNonAnamorphic">Optional. Whether to require a non anamporphic stream.</param> + /// <param name="requireNonAnamorphic">Optional. Whether to require a non anamorphic stream.</param> /// <param name="transcodingMaxAudioChannels">Optional. The maximum number of audio channels to transcode.</param> /// <param name="cpuCoreLimit">Optional. The limit of how many cpu cores to use.</param> /// <param name="liveStreamId">The live stream id.</param> /// <param name="enableMpegtsM2TsMode">Optional. Whether to enable the MpegtsM2Ts mode.</param> /// <param name="videoCodec">Optional. Specify a video codec to encode to, e.g. h264. If omitted the server will auto-select using the url's extension. Options: h265, h264, mpeg4, theora, vpx, wmv.</param> /// <param name="subtitleCodec">Optional. Specify a subtitle codec to encode to.</param> - /// <param name="transcodingReasons">Optional. The transcoding reason.</param> + /// <param name="transcodeReasons">Optional. The transcoding reason.</param> /// <param name="audioStreamIndex">Optional. The index of the audio stream to use. If omitted the first audio stream will be used.</param> /// <param name="videoStreamIndex">Optional. The index of the video stream to use. If omitted the first video stream will be used.</param> /// <param name="context">Optional. The <see cref="EncodingContext"/>.</param> /// <param name="streamOptions">Optional. The streaming options.</param> /// <response code="200">Audio stream returned.</response> /// <returns>A <see cref="FileResult"/> containing the audio file.</returns> - [HttpGet("{itemId}/stream.{container:required}", Name = "GetAudioStreamByContainer")] [HttpGet("{itemId}/stream", Name = "GetAudioStream")] - [HttpHead("{itemId}/stream.{container:required}", Name = "HeadAudioStreamByContainer")] [HttpHead("{itemId}/stream", Name = "HeadAudioStream")] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesAudioFile] public async Task<ActionResult> GetAudioStream( [FromRoute, Required] Guid itemId, - [FromRoute] string? container, + [FromQuery] string? container, + [FromQuery] bool? @static, + [FromQuery] string? @params, + [FromQuery] string? tag, + [FromQuery] string? deviceProfileId, + [FromQuery] string? playSessionId, + [FromQuery] string? segmentContainer, + [FromQuery] int? segmentLength, + [FromQuery] int? minSegments, + [FromQuery] string? mediaSourceId, + [FromQuery] string? deviceId, + [FromQuery] string? audioCodec, + [FromQuery] bool? enableAutoStreamCopy, + [FromQuery] bool? allowVideoStreamCopy, + [FromQuery] bool? allowAudioStreamCopy, + [FromQuery] bool? breakOnNonKeyFrames, + [FromQuery] int? audioSampleRate, + [FromQuery] int? maxAudioBitDepth, + [FromQuery] int? audioBitRate, + [FromQuery] int? audioChannels, + [FromQuery] int? maxAudioChannels, + [FromQuery] string? profile, + [FromQuery] string? level, + [FromQuery] float? framerate, + [FromQuery] float? maxFramerate, + [FromQuery] bool? copyTimestamps, + [FromQuery] long? startTimeTicks, + [FromQuery] int? width, + [FromQuery] int? height, + [FromQuery] int? videoBitRate, + [FromQuery] int? subtitleStreamIndex, + [FromQuery] SubtitleDeliveryMethod? subtitleMethod, + [FromQuery] int? maxRefFrames, + [FromQuery] int? maxVideoBitDepth, + [FromQuery] bool? requireAvc, + [FromQuery] bool? deInterlace, + [FromQuery] bool? requireNonAnamorphic, + [FromQuery] int? transcodingMaxAudioChannels, + [FromQuery] int? cpuCoreLimit, + [FromQuery] string? liveStreamId, + [FromQuery] bool? enableMpegtsM2TsMode, + [FromQuery] string? videoCodec, + [FromQuery] string? subtitleCodec, + [FromQuery] string? transcodeReasons, + [FromQuery] int? audioStreamIndex, + [FromQuery] int? videoStreamIndex, + [FromQuery] EncodingContext? context, + [FromQuery] Dictionary<string, string>? streamOptions) + { + StreamingRequestDto streamingRequest = new StreamingRequestDto + { + 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); + } + + /// <summary> + /// Gets an audio stream. + /// </summary> + /// <param name="itemId">The item id.</param> + /// <param name="container">The audio container.</param> + /// <param name="static">Optional. If true, the original file will be streamed statically without any encoding. Use either no url extension or the original file extension. true/false.</param> + /// <param name="params">The streaming parameters.</param> + /// <param name="tag">The tag.</param> + /// <param name="deviceProfileId">Optional. The dlna device profile id to utilize.</param> + /// <param name="playSessionId">The play session id.</param> + /// <param name="segmentContainer">The segment container.</param> + /// <param name="segmentLength">The segment lenght.</param> + /// <param name="minSegments">The minimum number of segments.</param> + /// <param name="mediaSourceId">The media version id, if playing an alternate version.</param> + /// <param name="deviceId">The device id of the client requesting. Used to stop encoding processes when needed.</param> + /// <param name="audioCodec">Optional. Specify a audio codec to encode to, e.g. mp3. If omitted the server will auto-select using the url's extension. Options: aac, mp3, vorbis, wma.</param> + /// <param name="enableAutoStreamCopy">Whether or not to allow automatic stream copy if requested values match the original source. Defaults to true.</param> + /// <param name="allowVideoStreamCopy">Whether or not to allow copying of the video stream url.</param> + /// <param name="allowAudioStreamCopy">Whether or not to allow copying of the audio stream url.</param> + /// <param name="breakOnNonKeyFrames">Optional. Whether to break on non key frames.</param> + /// <param name="audioSampleRate">Optional. Specify a specific audio sample rate, e.g. 44100.</param> + /// <param name="maxAudioBitDepth">Optional. The maximum audio bit depth.</param> + /// <param name="audioBitRate">Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults.</param> + /// <param name="audioChannels">Optional. Specify a specific number of audio channels to encode to, e.g. 2.</param> + /// <param name="maxAudioChannels">Optional. Specify a maximum number of audio channels to encode to, e.g. 2.</param> + /// <param name="profile">Optional. Specify a specific an encoder profile (varies by encoder), e.g. main, baseline, high.</param> + /// <param name="level">Optional. Specify a level for the encoder profile (varies by encoder), e.g. 3, 3.1.</param> + /// <param name="framerate">Optional. A specific video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param> + /// <param name="maxFramerate">Optional. A specific maximum video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param> + /// <param name="copyTimestamps">Whether or not to copy timestamps when transcoding with an offset. Defaults to false.</param> + /// <param name="startTimeTicks">Optional. Specify a starting offset, in ticks. 1 tick = 10000 ms.</param> + /// <param name="width">Optional. The fixed horizontal resolution of the encoded video.</param> + /// <param name="height">Optional. The fixed vertical resolution of the encoded video.</param> + /// <param name="videoBitRate">Optional. Specify a video bitrate to encode to, e.g. 500000. If omitted this will be left to encoder defaults.</param> + /// <param name="subtitleStreamIndex">Optional. The index of the subtitle stream to use. If omitted no subtitles will be used.</param> + /// <param name="subtitleMethod">Optional. Specify the subtitle delivery method.</param> + /// <param name="maxRefFrames">Optional.</param> + /// <param name="maxVideoBitDepth">Optional. The maximum video bit depth.</param> + /// <param name="requireAvc">Optional. Whether to require avc.</param> + /// <param name="deInterlace">Optional. Whether to deinterlace the video.</param> + /// <param name="requireNonAnamorphic">Optional. Whether to require a non anamporphic stream.</param> + /// <param name="transcodingMaxAudioChannels">Optional. The maximum number of audio channels to transcode.</param> + /// <param name="cpuCoreLimit">Optional. The limit of how many cpu cores to use.</param> + /// <param name="liveStreamId">The live stream id.</param> + /// <param name="enableMpegtsM2TsMode">Optional. Whether to enable the MpegtsM2Ts mode.</param> + /// <param name="videoCodec">Optional. Specify a video codec to encode to, e.g. h264. If omitted the server will auto-select using the url's extension. Options: h265, h264, mpeg4, theora, vpx, wmv.</param> + /// <param name="subtitleCodec">Optional. Specify a subtitle codec to encode to.</param> + /// <param name="transcodeReasons">Optional. The transcoding reason.</param> + /// <param name="audioStreamIndex">Optional. The index of the audio stream to use. If omitted the first audio stream will be used.</param> + /// <param name="videoStreamIndex">Optional. The index of the video stream to use. If omitted the first video stream will be used.</param> + /// <param name="context">Optional. The <see cref="EncodingContext"/>.</param> + /// <param name="streamOptions">Optional. The streaming options.</param> + /// <response code="200">Audio stream returned.</response> + /// <returns>A <see cref="FileResult"/> containing the audio file.</returns> + [HttpGet("{itemId}/stream.{container}", Name = "GetAudioStreamByContainer")] + [HttpHead("{itemId}/stream.{container}", Name = "HeadAudioStreamByContainer")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesAudioFile] + public async Task<ActionResult> GetAudioStreamByContainer( + [FromRoute, Required] Guid itemId, + [FromRoute, Required] string container, [FromQuery] bool? @static, [FromQuery] string? @params, [FromQuery] string? tag, @@ -124,7 +287,7 @@ namespace Jellyfin.Api.Controllers [FromQuery] int? height, [FromQuery] int? videoBitRate, [FromQuery] int? subtitleStreamIndex, - [FromQuery] SubtitleDeliveryMethod subtitleMethod, + [FromQuery] SubtitleDeliveryMethod? subtitleMethod, [FromQuery] int? maxRefFrames, [FromQuery] int? maxVideoBitDepth, [FromQuery] bool? requireAvc, @@ -136,7 +299,7 @@ namespace Jellyfin.Api.Controllers [FromQuery] bool? enableMpegtsM2TsMode, [FromQuery] string? videoCodec, [FromQuery] string? subtitleCodec, - [FromQuery] string? transcodingReasons, + [FromQuery] string? transcodeReasons, [FromQuery] int? audioStreamIndex, [FromQuery] int? videoStreamIndex, [FromQuery] EncodingContext? context, @@ -146,7 +309,7 @@ namespace Jellyfin.Api.Controllers { Id = itemId, Container = container, - Static = @static ?? true, + Static = @static ?? false, Params = @params, Tag = tag, DeviceProfileId = deviceProfileId, @@ -170,25 +333,25 @@ namespace Jellyfin.Api.Controllers Level = level, Framerate = framerate, MaxFramerate = maxFramerate, - CopyTimestamps = copyTimestamps ?? true, + CopyTimestamps = copyTimestamps ?? false, StartTimeTicks = startTimeTicks, Width = width, Height = height, VideoBitRate = videoBitRate, SubtitleStreamIndex = subtitleStreamIndex, - SubtitleMethod = subtitleMethod, + SubtitleMethod = subtitleMethod ?? SubtitleDeliveryMethod.Encode, MaxRefFrames = maxRefFrames, MaxVideoBitDepth = maxVideoBitDepth, - RequireAvc = requireAvc ?? true, - DeInterlace = deInterlace ?? true, - RequireNonAnamorphic = requireNonAnamorphic ?? true, + RequireAvc = requireAvc ?? false, + DeInterlace = deInterlace ?? false, + RequireNonAnamorphic = requireNonAnamorphic ?? false, TranscodingMaxAudioChannels = transcodingMaxAudioChannels, CpuCoreLimit = cpuCoreLimit, LiveStreamId = liveStreamId, - EnableMpegtsM2TsMode = enableMpegtsM2TsMode ?? true, + EnableMpegtsM2TsMode = enableMpegtsM2TsMode ?? false, VideoCodec = videoCodec, SubtitleCodec = subtitleCodec, - TranscodeReasons = transcodingReasons, + TranscodeReasons = transcodeReasons, AudioStreamIndex = audioStreamIndex, VideoStreamIndex = videoStreamIndex, Context = context ?? EncodingContext.Static, diff --git a/Jellyfin.Api/Controllers/BrandingController.cs b/Jellyfin.Api/Controllers/BrandingController.cs index 1d4836f278..d3ea412015 100644 --- a/Jellyfin.Api/Controllers/BrandingController.cs +++ b/Jellyfin.Api/Controllers/BrandingController.cs @@ -1,4 +1,4 @@ -using MediaBrowser.Common.Configuration; +using MediaBrowser.Common.Configuration; using MediaBrowser.Controller.Configuration; using MediaBrowser.Model.Branding; using Microsoft.AspNetCore.Http; diff --git a/Jellyfin.Api/Controllers/ChannelsController.cs b/Jellyfin.Api/Controllers/ChannelsController.cs index fda00b8d46..54bd800951 100644 --- a/Jellyfin.Api/Controllers/ChannelsController.cs +++ b/Jellyfin.Api/Controllers/ChannelsController.cs @@ -1,12 +1,12 @@ using System; using System.Collections.Generic; 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.Helpers; +using Jellyfin.Api.ModelBinders; +using Jellyfin.Data.Enums; using MediaBrowser.Controller.Channels; using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; @@ -91,7 +91,7 @@ namespace Jellyfin.Api.Controllers /// <response code="200">Channel features returned.</response> /// <returns>An <see cref="OkResult"/> containing the channel features.</returns> [HttpGet("{channelId}/Features")] - public ActionResult<ChannelFeatures> GetChannelFeatures([FromRoute, Required] string channelId) + public ActionResult<ChannelFeatures> GetChannelFeatures([FromRoute, Required] Guid channelId) { return _channelManager.GetChannelFeatures(channelId); } @@ -107,7 +107,7 @@ namespace Jellyfin.Api.Controllers /// <param name="sortOrder">Optional. Sort Order - Ascending,Descending.</param> /// <param name="filters">Optional. Specify additional filters to apply.</param> /// <param name="sortBy">Optional. Specify one or more sort orders, comma delimited. Options: Album, AlbumArtist, Artist, Budget, CommunityRating, CriticRating, DateCreated, DatePlayed, PlayCount, PremiereDate, ProductionYear, SortName, Random, Revenue, Runtime.</param> - /// <param name="fields">Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimited. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines.</param> + /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param> /// <response code="200">Channel items returned.</response> /// <returns> /// A <see cref="Task"/> representing the request to get the channel items. @@ -120,10 +120,10 @@ namespace Jellyfin.Api.Controllers [FromQuery] Guid? userId, [FromQuery] int? startIndex, [FromQuery] int? limit, - [FromQuery] string? sortOrder, - [FromQuery] ItemFilter[] filters, - [FromQuery] string? sortBy, - [FromQuery] string? fields) + [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.HasValue && !userId.Equals(Guid.Empty) ? _userManager.GetUserById(userId.Value) @@ -136,8 +136,7 @@ namespace Jellyfin.Api.Controllers ChannelIds = new[] { channelId }, ParentId = folderId ?? Guid.Empty, OrderBy = RequestHelpers.GetOrderBy(sortBy, sortOrder), - DtoOptions = new DtoOptions() - .AddItemFields(fields) + DtoOptions = new DtoOptions { Fields = fields } }; foreach (var filter in filters) @@ -184,7 +183,7 @@ namespace Jellyfin.Api.Controllers /// <param name="startIndex">Optional. The record index to start at. All items with a lower index will be dropped from the results.</param> /// <param name="limit">Optional. The maximum number of records to return.</param> /// <param name="filters">Optional. Specify additional filters to apply.</param> - /// <param name="fields">Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimited. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines.</param> + /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param> /// <param name="channelIds">Optional. Specify one or more channel id's, comma delimited.</param> /// <response code="200">Latest channel items returned.</response> /// <returns> @@ -196,9 +195,9 @@ namespace Jellyfin.Api.Controllers [FromQuery] Guid? userId, [FromQuery] int? startIndex, [FromQuery] int? limit, - [FromQuery] ItemFilter[] filters, - [FromQuery] string? fields, - [FromQuery] string? channelIds) + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFilter[] filters, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] channelIds) { var user = userId.HasValue && !userId.Equals(Guid.Empty) ? _userManager.GetUserById(userId.Value) @@ -208,13 +207,8 @@ namespace Jellyfin.Api.Controllers { Limit = limit, StartIndex = startIndex, - ChannelIds = (channelIds ?? string.Empty) - .Split(',') - .Where(i => !string.IsNullOrWhiteSpace(i)) - .Select(i => new Guid(i)) - .ToArray(), - DtoOptions = new DtoOptions() - .AddItemFields(fields) + ChannelIds = channelIds, + DtoOptions = new DtoOptions { Fields = fields } }; foreach (var filter in filters) diff --git a/Jellyfin.Api/Controllers/CollectionController.cs b/Jellyfin.Api/Controllers/CollectionController.cs index 2fc697a6aa..852d1e9cbd 100644 --- a/Jellyfin.Api/Controllers/CollectionController.cs +++ b/Jellyfin.Api/Controllers/CollectionController.cs @@ -1,9 +1,9 @@ -using System; +using System; using System.ComponentModel.DataAnnotations; using System.Threading.Tasks; using Jellyfin.Api.Constants; using Jellyfin.Api.Extensions; -using Jellyfin.Api.Helpers; +using Jellyfin.Api.ModelBinders; using MediaBrowser.Controller.Collections; using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Net; @@ -54,7 +54,7 @@ namespace Jellyfin.Api.Controllers [ProducesResponseType(StatusCodes.Status200OK)] public async Task<ActionResult<CollectionCreationResult>> CreateCollection( [FromQuery] string? name, - [FromQuery] string? ids, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] ids, [FromQuery] Guid? parentId, [FromQuery] bool isLocked = false) { @@ -65,7 +65,7 @@ namespace Jellyfin.Api.Controllers IsLocked = isLocked, Name = name, ParentId = parentId, - ItemIdList = RequestHelpers.Split(ids, ',', true), + ItemIdList = ids, UserIds = new[] { userId } }).ConfigureAwait(false); @@ -83,14 +83,16 @@ namespace Jellyfin.Api.Controllers /// Adds items to a collection. /// </summary> /// <param name="collectionId">The collection id.</param> - /// <param name="itemIds">Item ids, comma delimited.</param> + /// <param name="ids">Item ids, comma delimited.</param> /// <response code="204">Items added to collection.</response> /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> [HttpPost("{collectionId}/Items")] [ProducesResponseType(StatusCodes.Status204NoContent)] - public async Task<ActionResult> AddToCollection([FromRoute, Required] Guid collectionId, [FromQuery, Required] string itemIds) + public async Task<ActionResult> AddToCollection( + [FromRoute, Required] Guid collectionId, + [FromQuery, Required, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] ids) { - await _collectionManager.AddToCollectionAsync(collectionId, RequestHelpers.GetGuids(itemIds)).ConfigureAwait(true); + await _collectionManager.AddToCollectionAsync(collectionId, ids).ConfigureAwait(true); return NoContent(); } @@ -98,14 +100,16 @@ namespace Jellyfin.Api.Controllers /// Removes items from a collection. /// </summary> /// <param name="collectionId">The collection id.</param> - /// <param name="itemIds">Item ids, comma delimited.</param> + /// <param name="ids">Item ids, comma delimited.</param> /// <response code="204">Items removed from collection.</response> /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> [HttpDelete("{collectionId}/Items")] [ProducesResponseType(StatusCodes.Status204NoContent)] - public async Task<ActionResult> RemoveFromCollection([FromRoute, Required] Guid collectionId, [FromQuery, Required] string itemIds) + public async Task<ActionResult> RemoveFromCollection( + [FromRoute, Required] Guid collectionId, + [FromQuery, Required, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] ids) { - await _collectionManager.RemoveFromCollectionAsync(collectionId, RequestHelpers.GetGuids(itemIds)).ConfigureAwait(false); + await _collectionManager.RemoveFromCollectionAsync(collectionId, ids).ConfigureAwait(false); return NoContent(); } } diff --git a/Jellyfin.Api/Controllers/ConfigurationController.cs b/Jellyfin.Api/Controllers/ConfigurationController.cs index e1c9f69f61..60529e9904 100644 --- a/Jellyfin.Api/Controllers/ConfigurationController.cs +++ b/Jellyfin.Api/Controllers/ConfigurationController.cs @@ -1,3 +1,4 @@ +using System; using System.ComponentModel.DataAnnotations; using System.Net.Mime; using System.Text.Json; @@ -5,7 +6,7 @@ using System.Threading.Tasks; using Jellyfin.Api.Attributes; using Jellyfin.Api.Constants; using Jellyfin.Api.Models.ConfigurationDtos; -using MediaBrowser.Common.Json; +using Jellyfin.Extensions.Json; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.MediaEncoding; using MediaBrowser.Model.Configuration; @@ -25,7 +26,7 @@ namespace Jellyfin.Api.Controllers private readonly IServerConfigurationManager _configurationManager; private readonly IMediaEncoder _mediaEncoder; - private readonly JsonSerializerOptions _serializerOptions = JsonDefaults.GetOptions(); + private readonly JsonSerializerOptions _serializerOptions = JsonDefaults.Options; /// <summary> /// Initializes a new instance of the <see cref="ConfigurationController"/> class. @@ -94,6 +95,11 @@ namespace Jellyfin.Api.Controllers { var configurationType = _configurationManager.GetConfigurationType(key); var configuration = await JsonSerializer.DeserializeAsync(Request.Body, configurationType, _serializerOptions).ConfigureAwait(false); + if (configuration == null) + { + throw new ArgumentException("Body doesn't contain a valid configuration"); + } + _configurationManager.SaveConfiguration(key, configuration); return NoContent(); } diff --git a/Jellyfin.Api/Controllers/DashboardController.cs b/Jellyfin.Api/Controllers/DashboardController.cs index a859ac114c..445733c24d 100644 --- a/Jellyfin.Api/Controllers/DashboardController.cs +++ b/Jellyfin.Api/Controllers/DashboardController.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.IO; using System.Linq; @@ -6,18 +6,11 @@ using System.Net.Mime; using Jellyfin.Api.Attributes; using Jellyfin.Api.Models; using MediaBrowser.Common.Plugins; -using MediaBrowser.Controller; -using MediaBrowser.Controller.Configuration; -using MediaBrowser.Controller.Extensions; -using MediaBrowser.Controller.Plugins; using MediaBrowser.Model.Net; using MediaBrowser.Model.Plugins; using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Http.Extensions; using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; -using Microsoft.Net.Http.Headers; namespace Jellyfin.Api.Controllers { @@ -28,67 +21,35 @@ namespace Jellyfin.Api.Controllers public class DashboardController : BaseJellyfinApiController { private readonly ILogger<DashboardController> _logger; - private readonly IServerApplicationHost _appHost; + private readonly IPluginManager _pluginManager; /// <summary> /// Initializes a new instance of the <see cref="DashboardController"/> class. /// </summary> /// <param name="logger">Instance of <see cref="ILogger{DashboardController}"/> interface.</param> - /// <param name="appHost">Instance of <see cref="IServerApplicationHost"/> interface.</param> + /// <param name="pluginManager">Instance of <see cref="IPluginManager"/> interface.</param> public DashboardController( ILogger<DashboardController> logger, - IServerApplicationHost appHost) + IPluginManager pluginManager) { _logger = logger; - _appHost = appHost; + _pluginManager = pluginManager; } /// <summary> /// Gets the configuration pages. /// </summary> /// <param name="enableInMainMenu">Whether to enable in the main menu.</param> - /// <param name="pageType">The <see cref="ConfigurationPageInfo"/>.</param> /// <response code="200">ConfigurationPages returned.</response> /// <response code="404">Server still loading.</response> /// <returns>An <see cref="IEnumerable{ConfigurationPageInfo}"/> with infos about the plugins.</returns> [HttpGet("web/ConfigurationPages")] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] - public ActionResult<IEnumerable<ConfigurationPageInfo?>> GetConfigurationPages( - [FromQuery] bool? enableInMainMenu, - [FromQuery] ConfigurationPageType? pageType) + public ActionResult<IEnumerable<ConfigurationPageInfo>> GetConfigurationPages( + [FromQuery] bool? enableInMainMenu) { - const string unavailableMessage = "The server is still loading. Please try again momentarily."; - - var pages = _appHost.GetExports<IPluginConfigurationPage>().ToList(); - - if (pages == null) - { - return NotFound(unavailableMessage); - } - - // Don't allow a failing plugin to fail them all - var configPages = pages.Select(p => - { - try - { - return new ConfigurationPageInfo(p); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error getting plugin information from {Plugin}", p.GetType().Name); - return null; - } - }) - .Where(i => i != null) - .ToList(); - - configPages.AddRange(_appHost.Plugins.SelectMany(GetConfigPages)); - - if (pageType.HasValue) - { - configPages = configPages.Where(p => p!.ConfigurationPageType == pageType).ToList(); - } + var configPages = _pluginManager.Plugins.SelectMany(GetConfigPages).ToList(); if (enableInMainMenu.HasValue) { @@ -111,68 +72,42 @@ namespace Jellyfin.Api.Controllers [ProducesFile(MediaTypeNames.Text.Html, "application/x-javascript")] public ActionResult GetDashboardConfigurationPage([FromQuery] string? name) { - IPlugin? plugin = null; - Stream? stream = null; - - var isJs = false; - var isTemplate = false; - - var page = _appHost.GetExports<IPluginConfigurationPage>().FirstOrDefault(p => string.Equals(p.Name, name, StringComparison.OrdinalIgnoreCase)); - if (page != null) - { - plugin = page.Plugin; - stream = page.GetHtmlStream(); - } - - if (plugin == null) + var altPage = GetPluginPages().FirstOrDefault(p => string.Equals(p.Item1.Name, name, StringComparison.OrdinalIgnoreCase)); + if (altPage == null) { - var altPage = GetPluginPages().FirstOrDefault(p => string.Equals(p.Item1.Name, name, StringComparison.OrdinalIgnoreCase)); - if (altPage != null) - { - plugin = altPage.Item2; - stream = plugin.GetType().Assembly.GetManifestResourceStream(altPage.Item1.EmbeddedResourcePath); - - isJs = string.Equals(Path.GetExtension(altPage.Item1.EmbeddedResourcePath), ".js", StringComparison.OrdinalIgnoreCase); - isTemplate = altPage.Item1.EmbeddedResourcePath.EndsWith(".template.html", StringComparison.Ordinal); - } + return NotFound(); } - if (plugin != null && stream != null) + IPlugin plugin = altPage.Item2; + string resourcePath = altPage.Item1.EmbeddedResourcePath; + Stream? stream = plugin.GetType().Assembly.GetManifestResourceStream(resourcePath); + if (stream == null) { - if (isJs) - { - return File(stream, MimeTypes.GetMimeType("page.js")); - } - - if (isTemplate) - { - return File(stream, MimeTypes.GetMimeType("page.html")); - } - - return File(stream, MimeTypes.GetMimeType("page.html")); + _logger.LogError("Failed to get resource {Resource} from plugin {Plugin}", resourcePath, plugin.Name); + return NotFound(); } - return NotFound(); + return File(stream, MimeTypes.GetMimeType(resourcePath)); } - private IEnumerable<ConfigurationPageInfo> GetConfigPages(IPlugin plugin) + private IEnumerable<ConfigurationPageInfo> GetConfigPages(LocalPlugin plugin) { - return GetPluginPages(plugin).Select(i => new ConfigurationPageInfo(plugin, i.Item1)); + return GetPluginPages(plugin).Select(i => new ConfigurationPageInfo(plugin.Instance, i.Item1)); } - private IEnumerable<Tuple<PluginPageInfo, IPlugin>> GetPluginPages(IPlugin plugin) + private IEnumerable<Tuple<PluginPageInfo, IPlugin>> GetPluginPages(LocalPlugin plugin) { - if (!(plugin is IHasWebPages hasWebPages)) + if (plugin.Instance is not IHasWebPages hasWebPages) { - return new List<Tuple<PluginPageInfo, IPlugin>>(); + return Enumerable.Empty<Tuple<PluginPageInfo, IPlugin>>(); } - return hasWebPages.GetPages().Select(i => new Tuple<PluginPageInfo, IPlugin>(i, plugin)); + return hasWebPages.GetPages().Select(i => new Tuple<PluginPageInfo, IPlugin>(i, plugin.Instance)); } private IEnumerable<Tuple<PluginPageInfo, IPlugin>> GetPluginPages() { - return _appHost.Plugins.SelectMany(GetPluginPages); + return _pluginManager.Plugins.SelectMany(GetPluginPages); } } } diff --git a/Jellyfin.Api/Controllers/DevicesController.cs b/Jellyfin.Api/Controllers/DevicesController.cs index 74380c2eff..b3e3490c2a 100644 --- a/Jellyfin.Api/Controllers/DevicesController.cs +++ b/Jellyfin.Api/Controllers/DevicesController.cs @@ -15,7 +15,7 @@ namespace Jellyfin.Api.Controllers /// <summary> /// Devices Controller. /// </summary> - [Authorize(Policy = Policies.DefaultAuthorization)] + [Authorize(Policy = Policies.RequiresElevation)] public class DevicesController : BaseJellyfinApiController { private readonly IDeviceManager _deviceManager; @@ -46,7 +46,6 @@ namespace Jellyfin.Api.Controllers /// <response code="200">Devices retrieved.</response> /// <returns>An <see cref="OkResult"/> containing the list of devices.</returns> [HttpGet] - [Authorize(Policy = Policies.RequiresElevation)] [ProducesResponseType(StatusCodes.Status200OK)] public ActionResult<QueryResult<DeviceInfo>> GetDevices([FromQuery] bool? supportsSync, [FromQuery] Guid? userId) { @@ -62,7 +61,6 @@ namespace Jellyfin.Api.Controllers /// <response code="404">Device not found.</response> /// <returns>An <see cref="OkResult"/> containing the device info on success, or a <see cref="NotFoundResult"/> if the device could not be found.</returns> [HttpGet("Info")] - [Authorize(Policy = Policies.RequiresElevation)] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] public ActionResult<DeviceInfo> GetDeviceInfo([FromQuery, Required] string id) @@ -84,7 +82,6 @@ namespace Jellyfin.Api.Controllers /// <response code="404">Device not found.</response> /// <returns>An <see cref="OkResult"/> containing the device info on success, or a <see cref="NotFoundResult"/> if the device could not be found.</returns> [HttpGet("Options")] - [Authorize(Policy = Policies.RequiresElevation)] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] public ActionResult<DeviceOptions> GetDeviceOptions([FromQuery, Required] string id) @@ -107,7 +104,6 @@ namespace Jellyfin.Api.Controllers /// <response code="404">Device not found.</response> /// <returns>A <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if the device could not be found.</returns> [HttpPost("Options")] - [Authorize(Policy = Policies.RequiresElevation)] [ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status404NotFound)] public ActionResult UpdateDeviceOptions( diff --git a/Jellyfin.Api/Controllers/DisplayPreferencesController.cs b/Jellyfin.Api/Controllers/DisplayPreferencesController.cs index 874467c75b..87b4577b62 100644 --- a/Jellyfin.Api/Controllers/DisplayPreferencesController.cs +++ b/Jellyfin.Api/Controllers/DisplayPreferencesController.cs @@ -6,11 +6,13 @@ using System.Linq; using Jellyfin.Api.Constants; using Jellyfin.Data.Entities; using Jellyfin.Data.Enums; +using MediaBrowser.Common.Extensions; using MediaBrowser.Controller; using MediaBrowser.Model.Entities; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; namespace Jellyfin.Api.Controllers { @@ -21,14 +23,17 @@ namespace Jellyfin.Api.Controllers public class DisplayPreferencesController : BaseJellyfinApiController { private readonly IDisplayPreferencesManager _displayPreferencesManager; + private readonly ILogger<DisplayPreferencesController> _logger; /// <summary> /// Initializes a new instance of the <see cref="DisplayPreferencesController"/> class. /// </summary> /// <param name="displayPreferencesManager">Instance of <see cref="IDisplayPreferencesManager"/> interface.</param> - public DisplayPreferencesController(IDisplayPreferencesManager displayPreferencesManager) + /// <param name="logger">Instance of <see cref="ILogger{DisplayPreferencesController}"/> interface.</param> + public DisplayPreferencesController(IDisplayPreferencesManager displayPreferencesManager, ILogger<DisplayPreferencesController> logger) { _displayPreferencesManager = displayPreferencesManager; + _logger = logger; } /// <summary> @@ -47,14 +52,19 @@ namespace Jellyfin.Api.Controllers [FromQuery, Required] Guid userId, [FromQuery, Required] string client) { - var displayPreferences = _displayPreferencesManager.GetDisplayPreferences(userId, client); - var itemPreferences = _displayPreferencesManager.GetItemDisplayPreferences(displayPreferences.UserId, Guid.Empty, displayPreferences.Client); + if (!Guid.TryParse(displayPreferencesId, out var itemId)) + { + itemId = displayPreferencesId.GetMD5(); + } + + var displayPreferences = _displayPreferencesManager.GetDisplayPreferences(userId, itemId, client); + var itemPreferences = _displayPreferencesManager.GetItemDisplayPreferences(displayPreferences.UserId, itemId, displayPreferences.Client); + itemPreferences.ItemId = itemId; var dto = new DisplayPreferencesDto { Client = displayPreferences.Client, - Id = displayPreferences.UserId.ToString(), - ViewType = itemPreferences.ViewType.ToString(), + Id = displayPreferences.ItemId.ToString(), SortBy = itemPreferences.SortBy, SortOrder = itemPreferences.SortOrder, IndexBy = displayPreferences.IndexBy?.ToString(), @@ -70,16 +80,25 @@ namespace Jellyfin.Api.Controllers dto.CustomPrefs["homesection" + homeSection.Order] = homeSection.Type.ToString().ToLowerInvariant(); } - foreach (var itemDisplayPreferences in _displayPreferencesManager.ListItemDisplayPreferences(displayPreferences.UserId, displayPreferences.Client)) - { - dto.CustomPrefs["landing-" + itemDisplayPreferences.ItemId] = itemDisplayPreferences.ViewType.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); + if (customDisplayPreferences != null) + { + 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; } @@ -112,7 +131,12 @@ namespace Jellyfin.Api.Controllers HomeSectionType.LatestMedia, HomeSectionType.None, }; - var existingDisplayPreferences = _displayPreferencesManager.GetDisplayPreferences(userId, client); + if (!Guid.TryParse(displayPreferencesId, out var itemId)) + { + itemId = displayPreferencesId.GetMD5(); + } + + var existingDisplayPreferences = _displayPreferencesManager.GetDisplayPreferences(userId, itemId, client); existingDisplayPreferences.IndexBy = Enum.TryParse<IndexingKind>(displayPreferences.IndexBy, true, out var indexBy) ? indexBy : (IndexingKind?)null; existingDisplayPreferences.ShowBackdrop = displayPreferences.ShowBackdrop; existingDisplayPreferences.ShowSidebar = displayPreferences.ShowSidebar; @@ -121,21 +145,33 @@ namespace Jellyfin.Api.Controllers existingDisplayPreferences.ChromecastVersion = displayPreferences.CustomPrefs.TryGetValue("chromecastVersion", out var chromecastVersion) ? Enum.Parse<ChromecastVersion>(chromecastVersion, true) : ChromecastVersion.Stable; + displayPreferences.CustomPrefs.Remove("chromecastVersion"); + existingDisplayPreferences.EnableNextVideoInfoOverlay = displayPreferences.CustomPrefs.TryGetValue("enableNextVideoInfoOverlay", out var enableNextVideoInfoOverlay) ? bool.Parse(enableNextVideoInfoOverlay) : true; + displayPreferences.CustomPrefs.Remove("enableNextVideoInfoOverlay"); + existingDisplayPreferences.SkipBackwardLength = displayPreferences.CustomPrefs.TryGetValue("skipBackLength", out var skipBackLength) ? int.Parse(skipBackLength, CultureInfo.InvariantCulture) : 10000; + displayPreferences.CustomPrefs.Remove("skipBackLength"); + existingDisplayPreferences.SkipForwardLength = displayPreferences.CustomPrefs.TryGetValue("skipForwardLength", out var 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))) @@ -146,26 +182,28 @@ namespace Jellyfin.Api.Controllers type = order < 7 ? defaults[order] : HomeSectionType.None; } + 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))) { - var itemPreferences = _displayPreferencesManager.GetItemDisplayPreferences(existingDisplayPreferences.UserId, Guid.Parse(key.Substring("landing-".Length)), existingDisplayPreferences.Client); - itemPreferences.ViewType = Enum.Parse<ViewType>(displayPreferences.ViewType); + if (!Enum.TryParse<ViewType>(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, Guid.Empty, existingDisplayPreferences.Client); + var itemPrefs = _displayPreferencesManager.GetItemDisplayPreferences(existingDisplayPreferences.UserId, itemId, existingDisplayPreferences.Client); itemPrefs.SortBy = displayPreferences.SortBy; itemPrefs.SortOrder = displayPreferences.SortOrder; itemPrefs.RememberIndexing = displayPreferences.RememberIndexing; itemPrefs.RememberSorting = displayPreferences.RememberSorting; + itemPrefs.ItemId = itemId; - if (Enum.TryParse<ViewType>(displayPreferences.ViewType, true, out var viewType)) - { - itemPrefs.ViewType = viewType; - } - + // Set all remaining custom preferences. + _displayPreferencesManager.SetCustomItemDisplayPreferences(userId, itemId, existingDisplayPreferences.Client, displayPreferences.CustomPrefs); _displayPreferencesManager.SaveChanges(); return NoContent(); diff --git a/Jellyfin.Api/Controllers/DlnaServerController.cs b/Jellyfin.Api/Controllers/DlnaServerController.cs index 271ae293b4..694d16ad97 100644 --- a/Jellyfin.Api/Controllers/DlnaServerController.cs +++ b/Jellyfin.Api/Controllers/DlnaServerController.cs @@ -41,18 +41,25 @@ namespace Jellyfin.Api.Controllers /// </summary> /// <param name="serverId">Server UUID.</param> /// <response code="200">Description xml returned.</response> + /// <response code="503">DLNA is disabled.</response> /// <returns>An <see cref="OkResult"/> containing the description xml.</returns> [HttpGet("{serverId}/description")] [HttpGet("{serverId}/description.xml", Name = "GetDescriptionXml_2")] [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)] [Produces(MediaTypeNames.Text.Xml)] [ProducesFile(MediaTypeNames.Text.Xml)] public ActionResult 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); + if (DlnaEntryPoint.Enabled) + { + 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 StatusCode(StatusCodes.Status503ServiceUnavailable); } /// <summary> @@ -60,126 +67,215 @@ namespace Jellyfin.Api.Controllers /// </summary> /// <param name="serverId">Server UUID.</param> /// <response code="200">Dlna content directory returned.</response> + /// <response code="503">DLNA is disabled.</response> /// <returns>An <see cref="OkResult"/> containing the dlna content directory xml.</returns> [HttpGet("{serverId}/ContentDirectory")] [HttpGet("{serverId}/ContentDirectory/ContentDirectory", Name = "GetContentDirectory_2")] [HttpGet("{serverId}/ContentDirectory/ContentDirectory.xml", Name = "GetContentDirectory_3")] [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)] [Produces(MediaTypeNames.Text.Xml)] [ProducesFile(MediaTypeNames.Text.Xml)] [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "serverId", Justification = "Required for DLNA")] public ActionResult GetContentDirectory([FromRoute, Required] string serverId) { - return Ok(_contentDirectory.GetServiceXml()); + if (DlnaEntryPoint.Enabled) + { + return Ok(_contentDirectory.GetServiceXml()); + } + + return StatusCode(StatusCodes.Status503ServiceUnavailable); } /// <summary> /// Gets Dlna media receiver registrar xml. /// </summary> /// <param name="serverId">Server UUID.</param> + /// <response code="200">Dlna media receiver registrar xml returned.</response> + /// <response code="503">DLNA is disabled.</response> /// <returns>Dlna media receiver registrar xml.</returns> [HttpGet("{serverId}/MediaReceiverRegistrar")] [HttpGet("{serverId}/MediaReceiverRegistrar/MediaReceiverRegistrar", Name = "GetMediaReceiverRegistrar_2")] [HttpGet("{serverId}/MediaReceiverRegistrar/MediaReceiverRegistrar.xml", Name = "GetMediaReceiverRegistrar_3")] [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)] [Produces(MediaTypeNames.Text.Xml)] [ProducesFile(MediaTypeNames.Text.Xml)] [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "serverId", Justification = "Required for DLNA")] public ActionResult GetMediaReceiverRegistrar([FromRoute, Required] string serverId) { - return Ok(_mediaReceiverRegistrar.GetServiceXml()); + if (DlnaEntryPoint.Enabled) + { + return Ok(_mediaReceiverRegistrar.GetServiceXml()); + } + + return StatusCode(StatusCodes.Status503ServiceUnavailable); } /// <summary> /// Gets Dlna media receiver registrar xml. /// </summary> /// <param name="serverId">Server UUID.</param> + /// <response code="200">Dlna media receiver registrar xml returned.</response> + /// <response code="503">DLNA is disabled.</response> /// <returns>Dlna media receiver registrar xml.</returns> [HttpGet("{serverId}/ConnectionManager")] [HttpGet("{serverId}/ConnectionManager/ConnectionManager", Name = "GetConnectionManager_2")] [HttpGet("{serverId}/ConnectionManager/ConnectionManager.xml", Name = "GetConnectionManager_3")] [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)] [Produces(MediaTypeNames.Text.Xml)] [ProducesFile(MediaTypeNames.Text.Xml)] [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "serverId", Justification = "Required for DLNA")] public ActionResult GetConnectionManager([FromRoute, Required] string serverId) { - return Ok(_connectionManager.GetServiceXml()); + if (DlnaEntryPoint.Enabled) + { + return Ok(_connectionManager.GetServiceXml()); + } + + return StatusCode(StatusCodes.Status503ServiceUnavailable); } /// <summary> /// Process a content directory control request. /// </summary> /// <param name="serverId">Server UUID.</param> + /// <response code="200">Request processed.</response> + /// <response code="503">DLNA is disabled.</response> /// <returns>Control response.</returns> [HttpPost("{serverId}/ContentDirectory/Control")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)] + [Produces(MediaTypeNames.Text.Xml)] + [ProducesFile(MediaTypeNames.Text.Xml)] public async Task<ActionResult<ControlResponse>> ProcessContentDirectoryControlRequest([FromRoute, Required] string serverId) { - return await ProcessControlRequestInternalAsync(serverId, Request.Body, _contentDirectory).ConfigureAwait(false); + if (DlnaEntryPoint.Enabled) + { + return await ProcessControlRequestInternalAsync(serverId, Request.Body, _contentDirectory).ConfigureAwait(false); + } + + return StatusCode(StatusCodes.Status503ServiceUnavailable); } /// <summary> /// Process a connection manager control request. /// </summary> /// <param name="serverId">Server UUID.</param> + /// <response code="200">Request processed.</response> + /// <response code="503">DLNA is disabled.</response> /// <returns>Control response.</returns> [HttpPost("{serverId}/ConnectionManager/Control")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)] + [Produces(MediaTypeNames.Text.Xml)] + [ProducesFile(MediaTypeNames.Text.Xml)] public async Task<ActionResult<ControlResponse>> ProcessConnectionManagerControlRequest([FromRoute, Required] string serverId) { - return await ProcessControlRequestInternalAsync(serverId, Request.Body, _connectionManager).ConfigureAwait(false); + if (DlnaEntryPoint.Enabled) + { + return await ProcessControlRequestInternalAsync(serverId, Request.Body, _connectionManager).ConfigureAwait(false); + } + + return StatusCode(StatusCodes.Status503ServiceUnavailable); } /// <summary> /// Process a media receiver registrar control request. /// </summary> /// <param name="serverId">Server UUID.</param> + /// <response code="200">Request processed.</response> + /// <response code="503">DLNA is disabled.</response> /// <returns>Control response.</returns> [HttpPost("{serverId}/MediaReceiverRegistrar/Control")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)] + [Produces(MediaTypeNames.Text.Xml)] + [ProducesFile(MediaTypeNames.Text.Xml)] public async Task<ActionResult<ControlResponse>> ProcessMediaReceiverRegistrarControlRequest([FromRoute, Required] string serverId) { - return await ProcessControlRequestInternalAsync(serverId, Request.Body, _mediaReceiverRegistrar).ConfigureAwait(false); + if (DlnaEntryPoint.Enabled) + { + return await ProcessControlRequestInternalAsync(serverId, Request.Body, _mediaReceiverRegistrar).ConfigureAwait(false); + } + + return StatusCode(StatusCodes.Status503ServiceUnavailable); } /// <summary> /// Processes an event subscription request. /// </summary> /// <param name="serverId">Server UUID.</param> + /// <response code="200">Request processed.</response> + /// <response code="503">DLNA is disabled.</response> /// <returns>Event subscription response.</returns> [HttpSubscribe("{serverId}/MediaReceiverRegistrar/Events")] [HttpUnsubscribe("{serverId}/MediaReceiverRegistrar/Events")] [ApiExplorerSettings(IgnoreApi = true)] // Ignore in openapi docs [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "serverId", Justification = "Required for DLNA")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)] + [Produces(MediaTypeNames.Text.Xml)] + [ProducesFile(MediaTypeNames.Text.Xml)] public ActionResult<EventSubscriptionResponse> ProcessMediaReceiverRegistrarEventRequest(string serverId) { - return ProcessEventRequest(_mediaReceiverRegistrar); + if (DlnaEntryPoint.Enabled) + { + return ProcessEventRequest(_mediaReceiverRegistrar); + } + + return StatusCode(StatusCodes.Status503ServiceUnavailable); } /// <summary> /// Processes an event subscription request. /// </summary> /// <param name="serverId">Server UUID.</param> + /// <response code="200">Request processed.</response> + /// <response code="503">DLNA is disabled.</response> /// <returns>Event subscription response.</returns> [HttpSubscribe("{serverId}/ContentDirectory/Events")] [HttpUnsubscribe("{serverId}/ContentDirectory/Events")] [ApiExplorerSettings(IgnoreApi = true)] // Ignore in openapi docs [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "serverId", Justification = "Required for DLNA")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)] + [Produces(MediaTypeNames.Text.Xml)] + [ProducesFile(MediaTypeNames.Text.Xml)] public ActionResult<EventSubscriptionResponse> ProcessContentDirectoryEventRequest(string serverId) { - return ProcessEventRequest(_contentDirectory); + if (DlnaEntryPoint.Enabled) + { + return ProcessEventRequest(_contentDirectory); + } + + return StatusCode(StatusCodes.Status503ServiceUnavailable); } /// <summary> /// Processes an event subscription request. /// </summary> /// <param name="serverId">Server UUID.</param> + /// <response code="200">Request processed.</response> + /// <response code="503">DLNA is disabled.</response> /// <returns>Event subscription response.</returns> [HttpSubscribe("{serverId}/ConnectionManager/Events")] [HttpUnsubscribe("{serverId}/ConnectionManager/Events")] [ApiExplorerSettings(IgnoreApi = true)] // Ignore in openapi docs [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "serverId", Justification = "Required for DLNA")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)] + [Produces(MediaTypeNames.Text.Xml)] + [ProducesFile(MediaTypeNames.Text.Xml)] public ActionResult<EventSubscriptionResponse> ProcessConnectionManagerEventRequest(string serverId) { - return ProcessEventRequest(_connectionManager); + if (DlnaEntryPoint.Enabled) + { + return ProcessEventRequest(_connectionManager); + } + + return StatusCode(StatusCodes.Status503ServiceUnavailable); } /// <summary> @@ -187,14 +283,24 @@ namespace Jellyfin.Api.Controllers /// </summary> /// <param name="serverId">Server UUID.</param> /// <param name="fileName">The icon filename.</param> + /// <response code="200">Request processed.</response> + /// <response code="404">Not Found.</response> + /// <response code="503">DLNA is disabled.</response> /// <returns>Icon stream.</returns> [HttpGet("{serverId}/icons/{fileName}")] [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "serverId", Justification = "Required for DLNA")] [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)] [ProducesImageFile] public ActionResult GetIconId([FromRoute, Required] string serverId, [FromRoute, Required] string fileName) { - return GetIconInternal(fileName); + if (DlnaEntryPoint.Enabled) + { + return GetIconInternal(fileName); + } + + return StatusCode(StatusCodes.Status503ServiceUnavailable); } /// <summary> @@ -202,11 +308,22 @@ namespace Jellyfin.Api.Controllers /// </summary> /// <param name="fileName">The icon filename.</param> /// <returns>Icon stream.</returns> + /// <response code="200">Request processed.</response> + /// <response code="404">Not Found.</response> + /// <response code="503">DLNA is disabled.</response> [HttpGet("icons/{fileName}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)] [ProducesImageFile] public ActionResult GetIcon([FromRoute, Required] string fileName) { - return GetIconInternal(fileName); + if (DlnaEntryPoint.Enabled) + { + return GetIconInternal(fileName); + } + + return StatusCode(StatusCodes.Status503ServiceUnavailable); } private ActionResult GetIconInternal(string fileName) @@ -226,7 +343,7 @@ namespace Jellyfin.Api.Controllers private string GetAbsoluteUri() { - return $"{Request.Scheme}://{Request.Host}{Request.Path}"; + return $"{Request.Scheme}://{Request.Host}{Request.PathBase}{Request.Path}"; } private Task<ControlResponse> ProcessControlRequestInternalAsync(string id, Stream requestStream, IUpnpService service) diff --git a/Jellyfin.Api/Controllers/DynamicHlsController.cs b/Jellyfin.Api/Controllers/DynamicHlsController.cs index 1153a601e7..35435b0071 100644 --- a/Jellyfin.Api/Controllers/DynamicHlsController.cs +++ b/Jellyfin.Api/Controllers/DynamicHlsController.cs @@ -5,6 +5,7 @@ using System.Diagnostics.CodeAnalysis; using System.Globalization; using System.IO; using System.Linq; +using System.Runtime.InteropServices; using System.Text; using System.Threading; using System.Threading.Tasks; @@ -27,7 +28,6 @@ using MediaBrowser.Model.Net; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; using Microsoft.Net.Http.Headers; @@ -40,6 +40,9 @@ namespace Jellyfin.Api.Controllers [Authorize(Policy = Policies.DefaultAuthorization)] public class DynamicHlsController : BaseJellyfinApiController { + private const string DefaultEncoderPreset = "veryfast"; + private const TranscodingJobType TranscodingJobType = MediaBrowser.Controller.MediaEncoding.TranscodingJobType.Hls; + private readonly ILibraryManager _libraryManager; private readonly IUserManager _userManager; private readonly IDlnaManager _dlnaManager; @@ -48,15 +51,12 @@ namespace Jellyfin.Api.Controllers private readonly IServerConfigurationManager _serverConfigurationManager; private readonly IMediaEncoder _mediaEncoder; private readonly IFileSystem _fileSystem; - private readonly ISubtitleEncoder _subtitleEncoder; - private readonly IConfiguration _configuration; private readonly IDeviceManager _deviceManager; private readonly TranscodingJobHelper _transcodingJobHelper; private readonly ILogger<DynamicHlsController> _logger; private readonly EncodingHelper _encodingHelper; private readonly DynamicHlsHelper _dynamicHlsHelper; - - private readonly TranscodingJobType _transcodingJobType = TranscodingJobType.Hls; + private readonly EncodingOptions _encodingOptions; /// <summary> /// Initializes a new instance of the <see cref="DynamicHlsController"/> class. @@ -69,12 +69,11 @@ namespace Jellyfin.Api.Controllers /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param> /// <param name="mediaEncoder">Instance of the <see cref="IMediaEncoder"/> interface.</param> /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param> - /// <param name="subtitleEncoder">Instance of the <see cref="ISubtitleEncoder"/> interface.</param> - /// <param name="configuration">Instance of the <see cref="IConfiguration"/> interface.</param> /// <param name="deviceManager">Instance of the <see cref="IDeviceManager"/> interface.</param> /// <param name="transcodingJobHelper">Instance of the <see cref="TranscodingJobHelper"/> class.</param> /// <param name="logger">Instance of the <see cref="ILogger{DynamicHlsController}"/> interface.</param> /// <param name="dynamicHlsHelper">Instance of <see cref="DynamicHlsHelper"/>.</param> + /// <param name="encodingHelper">Instance of <see cref="EncodingHelper"/>.</param> public DynamicHlsController( ILibraryManager libraryManager, IUserManager userManager, @@ -84,12 +83,11 @@ namespace Jellyfin.Api.Controllers IServerConfigurationManager serverConfigurationManager, IMediaEncoder mediaEncoder, IFileSystem fileSystem, - ISubtitleEncoder subtitleEncoder, - IConfiguration configuration, IDeviceManager deviceManager, TranscodingJobHelper transcodingJobHelper, ILogger<DynamicHlsController> logger, - DynamicHlsHelper dynamicHlsHelper) + DynamicHlsHelper dynamicHlsHelper, + EncodingHelper encodingHelper) { _libraryManager = libraryManager; _userManager = userManager; @@ -99,14 +97,13 @@ namespace Jellyfin.Api.Controllers _serverConfigurationManager = serverConfigurationManager; _mediaEncoder = mediaEncoder; _fileSystem = fileSystem; - _subtitleEncoder = subtitleEncoder; - _configuration = configuration; _deviceManager = deviceManager; _transcodingJobHelper = transcodingJobHelper; _logger = logger; _dynamicHlsHelper = dynamicHlsHelper; + _encodingHelper = encodingHelper; - _encodingHelper = new EncodingHelper(_mediaEncoder, _fileSystem, _subtitleEncoder, _configuration); + _encodingOptions = serverConfigurationManager.GetEncodingOptions(); } /// <summary> @@ -119,7 +116,7 @@ namespace Jellyfin.Api.Controllers /// <param name="deviceProfileId">Optional. The dlna device profile id to utilize.</param> /// <param name="playSessionId">The play session id.</param> /// <param name="segmentContainer">The segment container.</param> - /// <param name="segmentLength">The segment lenght.</param> + /// <param name="segmentLength">The segment length.</param> /// <param name="minSegments">The minimum number of segments.</param> /// <param name="mediaSourceId">The media version id, if playing an alternate version.</param> /// <param name="deviceId">The device id of the client requesting. Used to stop encoding processes when needed.</param> @@ -148,14 +145,14 @@ namespace Jellyfin.Api.Controllers /// <param name="maxVideoBitDepth">Optional. The maximum video bit depth.</param> /// <param name="requireAvc">Optional. Whether to require avc.</param> /// <param name="deInterlace">Optional. Whether to deinterlace the video.</param> - /// <param name="requireNonAnamorphic">Optional. Whether to require a non anamporphic stream.</param> + /// <param name="requireNonAnamorphic">Optional. Whether to require a non anamorphic stream.</param> /// <param name="transcodingMaxAudioChannels">Optional. The maximum number of audio channels to transcode.</param> /// <param name="cpuCoreLimit">Optional. The limit of how many cpu cores to use.</param> /// <param name="liveStreamId">The live stream id.</param> /// <param name="enableMpegtsM2TsMode">Optional. Whether to enable the MpegtsM2Ts mode.</param> /// <param name="videoCodec">Optional. Specify a video codec to encode to, e.g. h264. If omitted the server will auto-select using the url's extension. Options: h265, h264, mpeg4, theora, vpx, wmv.</param> /// <param name="subtitleCodec">Optional. Specify a subtitle codec to encode to.</param> - /// <param name="transcodingReasons">Optional. The transcoding reason.</param> + /// <param name="transcodeReasons">Optional. The transcoding reason.</param> /// <param name="audioStreamIndex">Optional. The index of the audio stream to use. If omitted the first audio stream will be used.</param> /// <param name="videoStreamIndex">Optional. The index of the video stream to use. If omitted the first video stream will be used.</param> /// <param name="context">Optional. The <see cref="EncodingContext"/>.</param> @@ -199,7 +196,7 @@ namespace Jellyfin.Api.Controllers [FromQuery] int? height, [FromQuery] int? videoBitRate, [FromQuery] int? subtitleStreamIndex, - [FromQuery] SubtitleDeliveryMethod subtitleMethod, + [FromQuery] SubtitleDeliveryMethod? subtitleMethod, [FromQuery] int? maxRefFrames, [FromQuery] int? maxVideoBitDepth, [FromQuery] bool? requireAvc, @@ -211,17 +208,17 @@ namespace Jellyfin.Api.Controllers [FromQuery] bool? enableMpegtsM2TsMode, [FromQuery] string? videoCodec, [FromQuery] string? subtitleCodec, - [FromQuery] string? transcodingReasons, + [FromQuery] string? transcodeReasons, [FromQuery] int? audioStreamIndex, [FromQuery] int? videoStreamIndex, - [FromQuery] EncodingContext context, + [FromQuery] EncodingContext? context, [FromQuery] Dictionary<string, string> streamOptions, [FromQuery] bool enableAdaptiveBitrateStreaming = true) { var streamingRequest = new HlsVideoRequestDto { Id = itemId, - Static = @static ?? true, + Static = @static ?? false, Params = @params, Tag = tag, DeviceProfileId = deviceProfileId, @@ -245,33 +242,33 @@ namespace Jellyfin.Api.Controllers Level = level, Framerate = framerate, MaxFramerate = maxFramerate, - CopyTimestamps = copyTimestamps ?? true, + CopyTimestamps = copyTimestamps ?? false, StartTimeTicks = startTimeTicks, Width = width, Height = height, VideoBitRate = videoBitRate, SubtitleStreamIndex = subtitleStreamIndex, - SubtitleMethod = subtitleMethod, + SubtitleMethod = subtitleMethod ?? SubtitleDeliveryMethod.Encode, MaxRefFrames = maxRefFrames, MaxVideoBitDepth = maxVideoBitDepth, - RequireAvc = requireAvc ?? true, - DeInterlace = deInterlace ?? true, - RequireNonAnamorphic = requireNonAnamorphic ?? true, + RequireAvc = requireAvc ?? false, + DeInterlace = deInterlace ?? false, + RequireNonAnamorphic = requireNonAnamorphic ?? false, TranscodingMaxAudioChannels = transcodingMaxAudioChannels, CpuCoreLimit = cpuCoreLimit, LiveStreamId = liveStreamId, - EnableMpegtsM2TsMode = enableMpegtsM2TsMode ?? true, + EnableMpegtsM2TsMode = enableMpegtsM2TsMode ?? false, VideoCodec = videoCodec, SubtitleCodec = subtitleCodec, - TranscodeReasons = transcodingReasons, + TranscodeReasons = transcodeReasons, AudioStreamIndex = audioStreamIndex, VideoStreamIndex = videoStreamIndex, - Context = context, + Context = context ?? EncodingContext.Streaming, StreamOptions = streamOptions, EnableAdaptiveBitrateStreaming = enableAdaptiveBitrateStreaming }; - return await _dynamicHlsHelper.GetMasterHlsPlaylist(_transcodingJobType, streamingRequest, enableAdaptiveBitrateStreaming).ConfigureAwait(false); + return await _dynamicHlsHelper.GetMasterHlsPlaylist(TranscodingJobType, streamingRequest, enableAdaptiveBitrateStreaming).ConfigureAwait(false); } /// <summary> @@ -284,7 +281,7 @@ namespace Jellyfin.Api.Controllers /// <param name="deviceProfileId">Optional. The dlna device profile id to utilize.</param> /// <param name="playSessionId">The play session id.</param> /// <param name="segmentContainer">The segment container.</param> - /// <param name="segmentLength">The segment lenght.</param> + /// <param name="segmentLength">The segment length.</param> /// <param name="minSegments">The minimum number of segments.</param> /// <param name="mediaSourceId">The media version id, if playing an alternate version.</param> /// <param name="deviceId">The device id of the client requesting. Used to stop encoding processes when needed.</param> @@ -295,6 +292,7 @@ namespace Jellyfin.Api.Controllers /// <param name="breakOnNonKeyFrames">Optional. Whether to break on non key frames.</param> /// <param name="audioSampleRate">Optional. Specify a specific audio sample rate, e.g. 44100.</param> /// <param name="maxAudioBitDepth">Optional. The maximum audio bit depth.</param> + /// <param name="maxStreamingBitrate">Optional. The maximum streaming bitrate.</param> /// <param name="audioBitRate">Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults.</param> /// <param name="audioChannels">Optional. Specify a specific number of audio channels to encode to, e.g. 2.</param> /// <param name="maxAudioChannels">Optional. Specify a maximum number of audio channels to encode to, e.g. 2.</param> @@ -313,14 +311,14 @@ namespace Jellyfin.Api.Controllers /// <param name="maxVideoBitDepth">Optional. The maximum video bit depth.</param> /// <param name="requireAvc">Optional. Whether to require avc.</param> /// <param name="deInterlace">Optional. Whether to deinterlace the video.</param> - /// <param name="requireNonAnamorphic">Optional. Whether to require a non anamporphic stream.</param> + /// <param name="requireNonAnamorphic">Optional. Whether to require a non anamorphic stream.</param> /// <param name="transcodingMaxAudioChannels">Optional. The maximum number of audio channels to transcode.</param> /// <param name="cpuCoreLimit">Optional. The limit of how many cpu cores to use.</param> /// <param name="liveStreamId">The live stream id.</param> /// <param name="enableMpegtsM2TsMode">Optional. Whether to enable the MpegtsM2Ts mode.</param> /// <param name="videoCodec">Optional. Specify a video codec to encode to, e.g. h264. If omitted the server will auto-select using the url's extension. Options: h265, h264, mpeg4, theora, vpx, wmv.</param> /// <param name="subtitleCodec">Optional. Specify a subtitle codec to encode to.</param> - /// <param name="transcodingReasons">Optional. The transcoding reason.</param> + /// <param name="transcodeReasons">Optional. The transcoding reason.</param> /// <param name="audioStreamIndex">Optional. The index of the audio stream to use. If omitted the first audio stream will be used.</param> /// <param name="videoStreamIndex">Optional. The index of the video stream to use. If omitted the first video stream will be used.</param> /// <param name="context">Optional. The <see cref="EncodingContext"/>.</param> @@ -351,6 +349,7 @@ namespace Jellyfin.Api.Controllers [FromQuery] bool? breakOnNonKeyFrames, [FromQuery] int? audioSampleRate, [FromQuery] int? maxAudioBitDepth, + [FromQuery] int? maxStreamingBitrate, [FromQuery] int? audioBitRate, [FromQuery] int? audioChannels, [FromQuery] int? maxAudioChannels, @@ -364,7 +363,7 @@ namespace Jellyfin.Api.Controllers [FromQuery] int? height, [FromQuery] int? videoBitRate, [FromQuery] int? subtitleStreamIndex, - [FromQuery] SubtitleDeliveryMethod subtitleMethod, + [FromQuery] SubtitleDeliveryMethod? subtitleMethod, [FromQuery] int? maxRefFrames, [FromQuery] int? maxVideoBitDepth, [FromQuery] bool? requireAvc, @@ -376,17 +375,17 @@ namespace Jellyfin.Api.Controllers [FromQuery] bool? enableMpegtsM2TsMode, [FromQuery] string? videoCodec, [FromQuery] string? subtitleCodec, - [FromQuery] string? transcodingReasons, + [FromQuery] string? transcodeReasons, [FromQuery] int? audioStreamIndex, [FromQuery] int? videoStreamIndex, - [FromQuery] EncodingContext context, + [FromQuery] EncodingContext? context, [FromQuery] Dictionary<string, string> streamOptions, [FromQuery] bool enableAdaptiveBitrateStreaming = true) { var streamingRequest = new HlsAudioRequestDto { Id = itemId, - Static = @static ?? true, + Static = @static ?? false, Params = @params, Tag = tag, DeviceProfileId = deviceProfileId, @@ -403,40 +402,40 @@ namespace Jellyfin.Api.Controllers BreakOnNonKeyFrames = breakOnNonKeyFrames ?? false, AudioSampleRate = audioSampleRate, MaxAudioChannels = maxAudioChannels, - AudioBitRate = audioBitRate, + AudioBitRate = audioBitRate ?? maxStreamingBitrate, MaxAudioBitDepth = maxAudioBitDepth, AudioChannels = audioChannels, Profile = profile, Level = level, Framerate = framerate, MaxFramerate = maxFramerate, - CopyTimestamps = copyTimestamps ?? true, + CopyTimestamps = copyTimestamps ?? false, StartTimeTicks = startTimeTicks, Width = width, Height = height, VideoBitRate = videoBitRate, SubtitleStreamIndex = subtitleStreamIndex, - SubtitleMethod = subtitleMethod, + SubtitleMethod = subtitleMethod ?? SubtitleDeliveryMethod.Encode, MaxRefFrames = maxRefFrames, MaxVideoBitDepth = maxVideoBitDepth, - RequireAvc = requireAvc ?? true, - DeInterlace = deInterlace ?? true, - RequireNonAnamorphic = requireNonAnamorphic ?? true, + RequireAvc = requireAvc ?? false, + DeInterlace = deInterlace ?? false, + RequireNonAnamorphic = requireNonAnamorphic ?? false, TranscodingMaxAudioChannels = transcodingMaxAudioChannels, CpuCoreLimit = cpuCoreLimit, LiveStreamId = liveStreamId, - EnableMpegtsM2TsMode = enableMpegtsM2TsMode ?? true, + EnableMpegtsM2TsMode = enableMpegtsM2TsMode ?? false, VideoCodec = videoCodec, SubtitleCodec = subtitleCodec, - TranscodeReasons = transcodingReasons, + TranscodeReasons = transcodeReasons, AudioStreamIndex = audioStreamIndex, VideoStreamIndex = videoStreamIndex, - Context = context, + Context = context ?? EncodingContext.Streaming, StreamOptions = streamOptions, EnableAdaptiveBitrateStreaming = enableAdaptiveBitrateStreaming }; - return await _dynamicHlsHelper.GetMasterHlsPlaylist(_transcodingJobType, streamingRequest, enableAdaptiveBitrateStreaming).ConfigureAwait(false); + return await _dynamicHlsHelper.GetMasterHlsPlaylist(TranscodingJobType, streamingRequest, enableAdaptiveBitrateStreaming).ConfigureAwait(false); } /// <summary> @@ -449,7 +448,7 @@ namespace Jellyfin.Api.Controllers /// <param name="deviceProfileId">Optional. The dlna device profile id to utilize.</param> /// <param name="playSessionId">The play session id.</param> /// <param name="segmentContainer">The segment container.</param> - /// <param name="segmentLength">The segment lenght.</param> + /// <param name="segmentLength">The segment length.</param> /// <param name="minSegments">The minimum number of segments.</param> /// <param name="mediaSourceId">The media version id, if playing an alternate version.</param> /// <param name="deviceId">The device id of the client requesting. Used to stop encoding processes when needed.</param> @@ -478,14 +477,14 @@ namespace Jellyfin.Api.Controllers /// <param name="maxVideoBitDepth">Optional. The maximum video bit depth.</param> /// <param name="requireAvc">Optional. Whether to require avc.</param> /// <param name="deInterlace">Optional. Whether to deinterlace the video.</param> - /// <param name="requireNonAnamorphic">Optional. Whether to require a non anamporphic stream.</param> + /// <param name="requireNonAnamorphic">Optional. Whether to require a non anamorphic stream.</param> /// <param name="transcodingMaxAudioChannels">Optional. The maximum number of audio channels to transcode.</param> /// <param name="cpuCoreLimit">Optional. The limit of how many cpu cores to use.</param> /// <param name="liveStreamId">The live stream id.</param> /// <param name="enableMpegtsM2TsMode">Optional. Whether to enable the MpegtsM2Ts mode.</param> /// <param name="videoCodec">Optional. Specify a video codec to encode to, e.g. h264. If omitted the server will auto-select using the url's extension. Options: h265, h264, mpeg4, theora, vpx, wmv.</param> /// <param name="subtitleCodec">Optional. Specify a subtitle codec to encode to.</param> - /// <param name="transcodingReasons">Optional. The transcoding reason.</param> + /// <param name="transcodeReasons">Optional. The transcoding reason.</param> /// <param name="audioStreamIndex">Optional. The index of the audio stream to use. If omitted the first audio stream will be used.</param> /// <param name="videoStreamIndex">Optional. The index of the video stream to use. If omitted the first video stream will be used.</param> /// <param name="context">Optional. The <see cref="EncodingContext"/>.</param> @@ -527,7 +526,7 @@ namespace Jellyfin.Api.Controllers [FromQuery] int? height, [FromQuery] int? videoBitRate, [FromQuery] int? subtitleStreamIndex, - [FromQuery] SubtitleDeliveryMethod subtitleMethod, + [FromQuery] SubtitleDeliveryMethod? subtitleMethod, [FromQuery] int? maxRefFrames, [FromQuery] int? maxVideoBitDepth, [FromQuery] bool? requireAvc, @@ -539,17 +538,17 @@ namespace Jellyfin.Api.Controllers [FromQuery] bool? enableMpegtsM2TsMode, [FromQuery] string? videoCodec, [FromQuery] string? subtitleCodec, - [FromQuery] string? transcodingReasons, + [FromQuery] string? transcodeReasons, [FromQuery] int? audioStreamIndex, [FromQuery] int? videoStreamIndex, - [FromQuery] EncodingContext context, + [FromQuery] EncodingContext? context, [FromQuery] Dictionary<string, string> streamOptions) { - var cancellationTokenSource = new CancellationTokenSource(); + using var cancellationTokenSource = new CancellationTokenSource(); var streamingRequest = new VideoRequestDto { Id = itemId, - Static = @static ?? true, + Static = @static ?? false, Params = @params, Tag = tag, DeviceProfileId = deviceProfileId, @@ -573,28 +572,28 @@ namespace Jellyfin.Api.Controllers Level = level, Framerate = framerate, MaxFramerate = maxFramerate, - CopyTimestamps = copyTimestamps ?? true, + CopyTimestamps = copyTimestamps ?? false, StartTimeTicks = startTimeTicks, Width = width, Height = height, VideoBitRate = videoBitRate, SubtitleStreamIndex = subtitleStreamIndex, - SubtitleMethod = subtitleMethod, + SubtitleMethod = subtitleMethod ?? SubtitleDeliveryMethod.Encode, MaxRefFrames = maxRefFrames, MaxVideoBitDepth = maxVideoBitDepth, - RequireAvc = requireAvc ?? true, - DeInterlace = deInterlace ?? true, - RequireNonAnamorphic = requireNonAnamorphic ?? true, + RequireAvc = requireAvc ?? false, + DeInterlace = deInterlace ?? false, + RequireNonAnamorphic = requireNonAnamorphic ?? false, TranscodingMaxAudioChannels = transcodingMaxAudioChannels, CpuCoreLimit = cpuCoreLimit, LiveStreamId = liveStreamId, - EnableMpegtsM2TsMode = enableMpegtsM2TsMode ?? true, + EnableMpegtsM2TsMode = enableMpegtsM2TsMode ?? false, VideoCodec = videoCodec, SubtitleCodec = subtitleCodec, - TranscodeReasons = transcodingReasons, + TranscodeReasons = transcodeReasons, AudioStreamIndex = audioStreamIndex, VideoStreamIndex = videoStreamIndex, - Context = context, + Context = context ?? EncodingContext.Streaming, StreamOptions = streamOptions }; @@ -612,7 +611,7 @@ namespace Jellyfin.Api.Controllers /// <param name="deviceProfileId">Optional. The dlna device profile id to utilize.</param> /// <param name="playSessionId">The play session id.</param> /// <param name="segmentContainer">The segment container.</param> - /// <param name="segmentLength">The segment lenght.</param> + /// <param name="segmentLength">The segment length.</param> /// <param name="minSegments">The minimum number of segments.</param> /// <param name="mediaSourceId">The media version id, if playing an alternate version.</param> /// <param name="deviceId">The device id of the client requesting. Used to stop encoding processes when needed.</param> @@ -623,6 +622,7 @@ namespace Jellyfin.Api.Controllers /// <param name="breakOnNonKeyFrames">Optional. Whether to break on non key frames.</param> /// <param name="audioSampleRate">Optional. Specify a specific audio sample rate, e.g. 44100.</param> /// <param name="maxAudioBitDepth">Optional. The maximum audio bit depth.</param> + /// <param name="maxStreamingBitrate">Optional. The maximum streaming bitrate.</param> /// <param name="audioBitRate">Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults.</param> /// <param name="audioChannels">Optional. Specify a specific number of audio channels to encode to, e.g. 2.</param> /// <param name="maxAudioChannels">Optional. Specify a maximum number of audio channels to encode to, e.g. 2.</param> @@ -641,14 +641,14 @@ namespace Jellyfin.Api.Controllers /// <param name="maxVideoBitDepth">Optional. The maximum video bit depth.</param> /// <param name="requireAvc">Optional. Whether to require avc.</param> /// <param name="deInterlace">Optional. Whether to deinterlace the video.</param> - /// <param name="requireNonAnamorphic">Optional. Whether to require a non anamporphic stream.</param> + /// <param name="requireNonAnamorphic">Optional. Whether to require a non anamorphic stream.</param> /// <param name="transcodingMaxAudioChannels">Optional. The maximum number of audio channels to transcode.</param> /// <param name="cpuCoreLimit">Optional. The limit of how many cpu cores to use.</param> /// <param name="liveStreamId">The live stream id.</param> /// <param name="enableMpegtsM2TsMode">Optional. Whether to enable the MpegtsM2Ts mode.</param> /// <param name="videoCodec">Optional. Specify a video codec to encode to, e.g. h264. If omitted the server will auto-select using the url's extension. Options: h265, h264, mpeg4, theora, vpx, wmv.</param> /// <param name="subtitleCodec">Optional. Specify a subtitle codec to encode to.</param> - /// <param name="transcodingReasons">Optional. The transcoding reason.</param> + /// <param name="transcodeReasons">Optional. The transcoding reason.</param> /// <param name="audioStreamIndex">Optional. The index of the audio stream to use. If omitted the first audio stream will be used.</param> /// <param name="videoStreamIndex">Optional. The index of the video stream to use. If omitted the first video stream will be used.</param> /// <param name="context">Optional. The <see cref="EncodingContext"/>.</param> @@ -677,6 +677,7 @@ namespace Jellyfin.Api.Controllers [FromQuery] bool? breakOnNonKeyFrames, [FromQuery] int? audioSampleRate, [FromQuery] int? maxAudioBitDepth, + [FromQuery] int? maxStreamingBitrate, [FromQuery] int? audioBitRate, [FromQuery] int? audioChannels, [FromQuery] int? maxAudioChannels, @@ -690,7 +691,7 @@ namespace Jellyfin.Api.Controllers [FromQuery] int? height, [FromQuery] int? videoBitRate, [FromQuery] int? subtitleStreamIndex, - [FromQuery] SubtitleDeliveryMethod subtitleMethod, + [FromQuery] SubtitleDeliveryMethod? subtitleMethod, [FromQuery] int? maxRefFrames, [FromQuery] int? maxVideoBitDepth, [FromQuery] bool? requireAvc, @@ -702,17 +703,17 @@ namespace Jellyfin.Api.Controllers [FromQuery] bool? enableMpegtsM2TsMode, [FromQuery] string? videoCodec, [FromQuery] string? subtitleCodec, - [FromQuery] string? transcodingReasons, + [FromQuery] string? transcodeReasons, [FromQuery] int? audioStreamIndex, [FromQuery] int? videoStreamIndex, - [FromQuery] EncodingContext context, + [FromQuery] EncodingContext? context, [FromQuery] Dictionary<string, string> streamOptions) { - var cancellationTokenSource = new CancellationTokenSource(); + using var cancellationTokenSource = new CancellationTokenSource(); var streamingRequest = new StreamingRequestDto { Id = itemId, - Static = @static ?? true, + Static = @static ?? false, Params = @params, Tag = tag, DeviceProfileId = deviceProfileId, @@ -729,35 +730,35 @@ namespace Jellyfin.Api.Controllers BreakOnNonKeyFrames = breakOnNonKeyFrames ?? false, AudioSampleRate = audioSampleRate, MaxAudioChannels = maxAudioChannels, - AudioBitRate = audioBitRate, + AudioBitRate = audioBitRate ?? maxStreamingBitrate, MaxAudioBitDepth = maxAudioBitDepth, AudioChannels = audioChannels, Profile = profile, Level = level, Framerate = framerate, MaxFramerate = maxFramerate, - CopyTimestamps = copyTimestamps ?? true, + CopyTimestamps = copyTimestamps ?? false, StartTimeTicks = startTimeTicks, Width = width, Height = height, VideoBitRate = videoBitRate, SubtitleStreamIndex = subtitleStreamIndex, - SubtitleMethod = subtitleMethod, + SubtitleMethod = subtitleMethod ?? SubtitleDeliveryMethod.Encode, MaxRefFrames = maxRefFrames, MaxVideoBitDepth = maxVideoBitDepth, - RequireAvc = requireAvc ?? true, - DeInterlace = deInterlace ?? true, - RequireNonAnamorphic = requireNonAnamorphic ?? true, + RequireAvc = requireAvc ?? false, + DeInterlace = deInterlace ?? false, + RequireNonAnamorphic = requireNonAnamorphic ?? false, TranscodingMaxAudioChannels = transcodingMaxAudioChannels, CpuCoreLimit = cpuCoreLimit, LiveStreamId = liveStreamId, - EnableMpegtsM2TsMode = enableMpegtsM2TsMode ?? true, + EnableMpegtsM2TsMode = enableMpegtsM2TsMode ?? false, VideoCodec = videoCodec, SubtitleCodec = subtitleCodec, - TranscodeReasons = transcodingReasons, + TranscodeReasons = transcodeReasons, AudioStreamIndex = audioStreamIndex, VideoStreamIndex = videoStreamIndex, - Context = context, + Context = context ?? EncodingContext.Streaming, StreamOptions = streamOptions }; @@ -807,14 +808,14 @@ namespace Jellyfin.Api.Controllers /// <param name="maxVideoBitDepth">Optional. The maximum video bit depth.</param> /// <param name="requireAvc">Optional. Whether to require avc.</param> /// <param name="deInterlace">Optional. Whether to deinterlace the video.</param> - /// <param name="requireNonAnamorphic">Optional. Whether to require a non anamporphic stream.</param> + /// <param name="requireNonAnamorphic">Optional. Whether to require a non anamorphic stream.</param> /// <param name="transcodingMaxAudioChannels">Optional. The maximum number of audio channels to transcode.</param> /// <param name="cpuCoreLimit">Optional. The limit of how many cpu cores to use.</param> /// <param name="liveStreamId">The live stream id.</param> /// <param name="enableMpegtsM2TsMode">Optional. Whether to enable the MpegtsM2Ts mode.</param> /// <param name="videoCodec">Optional. Specify a video codec to encode to, e.g. h264. If omitted the server will auto-select using the url's extension. Options: h265, h264, mpeg4, theora, vpx, wmv.</param> /// <param name="subtitleCodec">Optional. Specify a subtitle codec to encode to.</param> - /// <param name="transcodingReasons">Optional. The transcoding reason.</param> + /// <param name="transcodeReasons">Optional. The transcoding reason.</param> /// <param name="audioStreamIndex">Optional. The index of the audio stream to use. If omitted the first audio stream will be used.</param> /// <param name="videoStreamIndex">Optional. The index of the video stream to use. If omitted the first video stream will be used.</param> /// <param name="context">Optional. The <see cref="EncodingContext"/>.</param> @@ -829,7 +830,7 @@ namespace Jellyfin.Api.Controllers [FromRoute, Required] Guid itemId, [FromRoute, Required] string playlistId, [FromRoute, Required] int segmentId, - [FromRoute] string container, + [FromRoute, Required] string container, [FromQuery] bool? @static, [FromQuery] string? @params, [FromQuery] string? tag, @@ -860,7 +861,7 @@ namespace Jellyfin.Api.Controllers [FromQuery] int? height, [FromQuery] int? videoBitRate, [FromQuery] int? subtitleStreamIndex, - [FromQuery] SubtitleDeliveryMethod subtitleMethod, + [FromQuery] SubtitleDeliveryMethod? subtitleMethod, [FromQuery] int? maxRefFrames, [FromQuery] int? maxVideoBitDepth, [FromQuery] bool? requireAvc, @@ -872,17 +873,17 @@ namespace Jellyfin.Api.Controllers [FromQuery] bool? enableMpegtsM2TsMode, [FromQuery] string? videoCodec, [FromQuery] string? subtitleCodec, - [FromQuery] string? transcodingReasons, + [FromQuery] string? transcodeReasons, [FromQuery] int? audioStreamIndex, [FromQuery] int? videoStreamIndex, - [FromQuery] EncodingContext context, + [FromQuery] EncodingContext? context, [FromQuery] Dictionary<string, string> streamOptions) { var streamingRequest = new VideoRequestDto { Id = itemId, Container = container, - Static = @static ?? true, + Static = @static ?? false, Params = @params, Tag = tag, DeviceProfileId = deviceProfileId, @@ -906,28 +907,28 @@ namespace Jellyfin.Api.Controllers Level = level, Framerate = framerate, MaxFramerate = maxFramerate, - CopyTimestamps = copyTimestamps ?? true, + CopyTimestamps = copyTimestamps ?? false, StartTimeTicks = startTimeTicks, Width = width, Height = height, VideoBitRate = videoBitRate, SubtitleStreamIndex = subtitleStreamIndex, - SubtitleMethod = subtitleMethod, + SubtitleMethod = subtitleMethod ?? SubtitleDeliveryMethod.Encode, MaxRefFrames = maxRefFrames, MaxVideoBitDepth = maxVideoBitDepth, - RequireAvc = requireAvc ?? true, - DeInterlace = deInterlace ?? true, - RequireNonAnamorphic = requireNonAnamorphic ?? true, + RequireAvc = requireAvc ?? false, + DeInterlace = deInterlace ?? false, + RequireNonAnamorphic = requireNonAnamorphic ?? false, TranscodingMaxAudioChannels = transcodingMaxAudioChannels, CpuCoreLimit = cpuCoreLimit, LiveStreamId = liveStreamId, - EnableMpegtsM2TsMode = enableMpegtsM2TsMode ?? true, + EnableMpegtsM2TsMode = enableMpegtsM2TsMode ?? false, VideoCodec = videoCodec, SubtitleCodec = subtitleCodec, - TranscodeReasons = transcodingReasons, + TranscodeReasons = transcodeReasons, AudioStreamIndex = audioStreamIndex, VideoStreamIndex = videoStreamIndex, - Context = context, + Context = context ?? EncodingContext.Streaming, StreamOptions = streamOptions }; @@ -948,7 +949,7 @@ namespace Jellyfin.Api.Controllers /// <param name="deviceProfileId">Optional. The dlna device profile id to utilize.</param> /// <param name="playSessionId">The play session id.</param> /// <param name="segmentContainer">The segment container.</param> - /// <param name="segmentLength">The segment lenght.</param> + /// <param name="segmentLength">The segment length.</param> /// <param name="minSegments">The minimum number of segments.</param> /// <param name="mediaSourceId">The media version id, if playing an alternate version.</param> /// <param name="deviceId">The device id of the client requesting. Used to stop encoding processes when needed.</param> @@ -959,6 +960,7 @@ namespace Jellyfin.Api.Controllers /// <param name="breakOnNonKeyFrames">Optional. Whether to break on non key frames.</param> /// <param name="audioSampleRate">Optional. Specify a specific audio sample rate, e.g. 44100.</param> /// <param name="maxAudioBitDepth">Optional. The maximum audio bit depth.</param> + /// <param name="maxStreamingBitrate">Optional. The maximum streaming bitrate.</param> /// <param name="audioBitRate">Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults.</param> /// <param name="audioChannels">Optional. Specify a specific number of audio channels to encode to, e.g. 2.</param> /// <param name="maxAudioChannels">Optional. Specify a maximum number of audio channels to encode to, e.g. 2.</param> @@ -977,14 +979,14 @@ namespace Jellyfin.Api.Controllers /// <param name="maxVideoBitDepth">Optional. The maximum video bit depth.</param> /// <param name="requireAvc">Optional. Whether to require avc.</param> /// <param name="deInterlace">Optional. Whether to deinterlace the video.</param> - /// <param name="requireNonAnamorphic">Optional. Whether to require a non anamporphic stream.</param> + /// <param name="requireNonAnamorphic">Optional. Whether to require a non anamorphic stream.</param> /// <param name="transcodingMaxAudioChannels">Optional. The maximum number of audio channels to transcode.</param> /// <param name="cpuCoreLimit">Optional. The limit of how many cpu cores to use.</param> /// <param name="liveStreamId">The live stream id.</param> /// <param name="enableMpegtsM2TsMode">Optional. Whether to enable the MpegtsM2Ts mode.</param> /// <param name="videoCodec">Optional. Specify a video codec to encode to, e.g. h264. If omitted the server will auto-select using the url's extension. Options: h265, h264, mpeg4, theora, vpx, wmv.</param> /// <param name="subtitleCodec">Optional. Specify a subtitle codec to encode to.</param> - /// <param name="transcodingReasons">Optional. The transcoding reason.</param> + /// <param name="transcodeReasons">Optional. The transcoding reason.</param> /// <param name="audioStreamIndex">Optional. The index of the audio stream to use. If omitted the first audio stream will be used.</param> /// <param name="videoStreamIndex">Optional. The index of the video stream to use. If omitted the first video stream will be used.</param> /// <param name="context">Optional. The <see cref="EncodingContext"/>.</param> @@ -999,7 +1001,7 @@ namespace Jellyfin.Api.Controllers [FromRoute, Required] Guid itemId, [FromRoute, Required] string playlistId, [FromRoute, Required] int segmentId, - [FromRoute] string container, + [FromRoute, Required] string container, [FromQuery] bool? @static, [FromQuery] string? @params, [FromQuery] string? tag, @@ -1017,6 +1019,7 @@ namespace Jellyfin.Api.Controllers [FromQuery] bool? breakOnNonKeyFrames, [FromQuery] int? audioSampleRate, [FromQuery] int? maxAudioBitDepth, + [FromQuery] int? maxStreamingBitrate, [FromQuery] int? audioBitRate, [FromQuery] int? audioChannels, [FromQuery] int? maxAudioChannels, @@ -1030,7 +1033,7 @@ namespace Jellyfin.Api.Controllers [FromQuery] int? height, [FromQuery] int? videoBitRate, [FromQuery] int? subtitleStreamIndex, - [FromQuery] SubtitleDeliveryMethod subtitleMethod, + [FromQuery] SubtitleDeliveryMethod? subtitleMethod, [FromQuery] int? maxRefFrames, [FromQuery] int? maxVideoBitDepth, [FromQuery] bool? requireAvc, @@ -1042,17 +1045,17 @@ namespace Jellyfin.Api.Controllers [FromQuery] bool? enableMpegtsM2TsMode, [FromQuery] string? videoCodec, [FromQuery] string? subtitleCodec, - [FromQuery] string? transcodingReasons, + [FromQuery] string? transcodeReasons, [FromQuery] int? audioStreamIndex, [FromQuery] int? videoStreamIndex, - [FromQuery] EncodingContext context, + [FromQuery] EncodingContext? context, [FromQuery] Dictionary<string, string> streamOptions) { var streamingRequest = new StreamingRequestDto { Id = itemId, Container = container, - Static = @static ?? true, + Static = @static ?? false, Params = @params, Tag = tag, DeviceProfileId = deviceProfileId, @@ -1069,35 +1072,35 @@ namespace Jellyfin.Api.Controllers BreakOnNonKeyFrames = breakOnNonKeyFrames ?? false, AudioSampleRate = audioSampleRate, MaxAudioChannels = maxAudioChannels, - AudioBitRate = audioBitRate, + AudioBitRate = audioBitRate ?? maxStreamingBitrate, MaxAudioBitDepth = maxAudioBitDepth, AudioChannels = audioChannels, Profile = profile, Level = level, Framerate = framerate, MaxFramerate = maxFramerate, - CopyTimestamps = copyTimestamps ?? true, + CopyTimestamps = copyTimestamps ?? false, StartTimeTicks = startTimeTicks, Width = width, Height = height, VideoBitRate = videoBitRate, SubtitleStreamIndex = subtitleStreamIndex, - SubtitleMethod = subtitleMethod, + SubtitleMethod = subtitleMethod ?? SubtitleDeliveryMethod.Encode, MaxRefFrames = maxRefFrames, MaxVideoBitDepth = maxVideoBitDepth, - RequireAvc = requireAvc ?? true, - DeInterlace = deInterlace ?? true, - RequireNonAnamorphic = requireNonAnamorphic ?? true, + RequireAvc = requireAvc ?? false, + DeInterlace = deInterlace ?? false, + RequireNonAnamorphic = requireNonAnamorphic ?? false, TranscodingMaxAudioChannels = transcodingMaxAudioChannels, CpuCoreLimit = cpuCoreLimit, LiveStreamId = liveStreamId, - EnableMpegtsM2TsMode = enableMpegtsM2TsMode ?? true, + EnableMpegtsM2TsMode = enableMpegtsM2TsMode ?? false, VideoCodec = videoCodec, SubtitleCodec = subtitleCodec, - TranscodeReasons = transcodingReasons, + TranscodeReasons = transcodeReasons, AudioStreamIndex = audioStreamIndex, VideoStreamIndex = videoStreamIndex, - Context = context, + Context = context ?? EncodingContext.Streaming, StreamOptions = streamOptions }; @@ -1116,13 +1119,11 @@ namespace Jellyfin.Api.Controllers _libraryManager, _serverConfigurationManager, _mediaEncoder, - _fileSystem, - _subtitleEncoder, - _configuration, + _encodingHelper, _dlnaManager, _deviceManager, _transcodingJobHelper, - _transcodingJobType, + TranscodingJobType, cancellationTokenSource.Token) .ConfigureAwait(false); @@ -1130,11 +1131,19 @@ namespace Jellyfin.Api.Controllers var segmentLengths = GetSegmentLengths(state); - var builder = new StringBuilder(); + var segmentContainer = state.Request.SegmentContainer ?? "ts"; + + // http://ffmpeg.org/ffmpeg-all.html#toc-hls-2 + var isHlsInFmp4 = string.Equals(segmentContainer, "mp4", StringComparison.OrdinalIgnoreCase); + var hlsVersion = isHlsInFmp4 ? "7" : "3"; + + var builder = new StringBuilder(128); builder.AppendLine("#EXTM3U") .AppendLine("#EXT-X-PLAYLIST-TYPE:VOD") - .AppendLine("#EXT-X-VERSION:3") + .Append("#EXT-X-VERSION:") + .Append(hlsVersion) + .AppendLine() .Append("#EXT-X-TARGETDURATION:") .Append(Math.Ceiling(segmentLengths.Length > 0 ? segmentLengths.Max() : state.SegmentLength)) .AppendLine() @@ -1144,6 +1153,18 @@ namespace Jellyfin.Api.Controllers var segmentExtension = GetSegmentFileExtension(streamingRequest.SegmentContainer); var queryString = Request.QueryString; + if (isHlsInFmp4) + { + builder.Append("#EXT-X-MAP:URI=\"") + .Append("hls1/") + .Append(name) + .Append("/-1") + .Append(segmentExtension) + .Append(queryString) + .Append('"') + .AppendLine(); + } + foreach (var length in segmentLengths) { builder.Append("#EXTINF:") @@ -1169,6 +1190,7 @@ namespace Jellyfin.Api.Controllers throw new ArgumentException("StartTimeTicks is not allowed."); } + // CTS lifecycle is managed internally. var cancellationTokenSource = new CancellationTokenSource(); var cancellationToken = cancellationTokenSource.Token; @@ -1181,14 +1203,12 @@ namespace Jellyfin.Api.Controllers _libraryManager, _serverConfigurationManager, _mediaEncoder, - _fileSystem, - _subtitleEncoder, - _configuration, + _encodingHelper, _dlnaManager, _deviceManager, _transcodingJobHelper, - _transcodingJobType, - cancellationTokenSource.Token) + TranscodingJobType, + cancellationToken) .ConfigureAwait(false); var playlistPath = Path.ChangeExtension(state.OutputFilePath, ".m3u8"); @@ -1201,13 +1221,13 @@ namespace Jellyfin.Api.Controllers if (System.IO.File.Exists(segmentPath)) { - job = _transcodingJobHelper.OnTranscodeBeginRequest(playlistPath, _transcodingJobType); + 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(cancellationTokenSource.Token).ConfigureAwait(false); + await transcodingLock.WaitAsync(cancellationToken).ConfigureAwait(false); var released = false; var startTranscoding = false; @@ -1215,7 +1235,7 @@ namespace Jellyfin.Api.Controllers { if (System.IO.File.Exists(segmentPath)) { - job = _transcodingJobHelper.OnTranscodeBeginRequest(playlistPath, _transcodingJobType); + job = _transcodingJobHelper.OnTranscodeBeginRequest(playlistPath, TranscodingJobType); transcodingLock.Release(); released = true; _logger.LogDebug("returning {0} [it exists, try 2]", segmentPath); @@ -1226,7 +1246,13 @@ namespace Jellyfin.Api.Controllers var currentTranscodingIndex = GetCurrentTranscodingIndex(playlistPath, segmentExtension); var segmentGapRequiringTranscodingChange = 24 / state.SegmentLength; - if (currentTranscodingIndex == null) + if (segmentId == -1) + { + _logger.LogDebug("Starting transcoding because fmp4 init file is being requested"); + startTranscoding = true; + segmentId = 0; + } + else if (currentTranscodingIndex == null) { _logger.LogDebug("Starting transcoding because currentTranscodingIndex=null"); startTranscoding = true; @@ -1258,13 +1284,12 @@ namespace Jellyfin.Api.Controllers streamingRequest.StartTimeTicks = GetStartPositionTicks(state, segmentId); state.WaitForPath = segmentPath; - var encodingOptions = _serverConfigurationManager.GetEncodingOptions(); job = await _transcodingJobHelper.StartFfMpeg( state, playlistPath, - GetCommandLineArguments(playlistPath, encodingOptions, state, true, segmentId), + GetCommandLineArguments(playlistPath, state, true, segmentId), Request, - _transcodingJobType, + TranscodingJobType, cancellationTokenSource).ConfigureAwait(false); } catch @@ -1277,7 +1302,7 @@ namespace Jellyfin.Api.Controllers } else { - job = _transcodingJobHelper.OnTranscodeBeginRequest(playlistPath, _transcodingJobType); + job = _transcodingJobHelper.OnTranscodeBeginRequest(playlistPath, TranscodingJobType); if (job?.TranscodingThrottler != null) { await job.TranscodingThrottler.UnpauseTranscoding().ConfigureAwait(false); @@ -1294,35 +1319,38 @@ namespace Jellyfin.Api.Controllers } _logger.LogDebug("returning {0} [general case]", segmentPath); - job ??= _transcodingJobHelper.OnTranscodeBeginRequest(playlistPath, _transcodingJobType); + job ??= _transcodingJobHelper.OnTranscodeBeginRequest(playlistPath, TranscodingJobType); return await GetSegmentResult(state, playlistPath, segmentPath, segmentExtension, segmentId, job, cancellationToken).ConfigureAwait(false); } - private double[] GetSegmentLengths(StreamState state) - { - var result = new List<double>(); - - var ticks = state.RunTimeTicks ?? 0; + private static double[] GetSegmentLengths(StreamState state) + => GetSegmentLengthsInternal(state.RunTimeTicks ?? 0, state.SegmentLength); - var segmentLengthTicks = TimeSpan.FromSeconds(state.SegmentLength).Ticks; + internal static double[] GetSegmentLengthsInternal(long runtimeTicks, int segmentlength) + { + var segmentLengthTicks = TimeSpan.FromSeconds(segmentlength).Ticks; + var wholeSegments = runtimeTicks / segmentLengthTicks; + var remainingTicks = runtimeTicks % segmentLengthTicks; - while (ticks > 0) + var segmentsLen = wholeSegments + (remainingTicks == 0 ? 0 : 1); + var segments = new double[segmentsLen]; + for (int i = 0; i < wholeSegments; i++) { - var length = ticks >= segmentLengthTicks ? segmentLengthTicks : ticks; - - result.Add(TimeSpan.FromTicks(length).TotalSeconds); + segments[i] = segmentlength; + } - ticks -= length; + if (remainingTicks != 0) + { + segments[^1] = TimeSpan.FromTicks(remainingTicks).TotalSeconds; } - return result.ToArray(); + return segments; } - private string GetCommandLineArguments(string outputPath, EncodingOptions encodingOptions, StreamState state, bool isEncoding, int startNumber) + private string GetCommandLineArguments(string outputPath, StreamState state, bool isEncoding, int startNumber) { - var videoCodec = _encodingHelper.GetVideoEncoder(state, encodingOptions); - - var threads = _encodingHelper.GetNumberOfThreads(state, encodingOptions, videoCodec); + var videoCodec = _encodingHelper.GetVideoEncoder(state, _encodingOptions); + var threads = EncodingHelper.GetNumberOfThreads(state, _encodingOptions, videoCodec); if (state.BaseRequest.BreakOnNonKeyFrames) { @@ -1334,34 +1362,52 @@ namespace Jellyfin.Api.Controllers state.BaseRequest.BreakOnNonKeyFrames = false; } - var inputModifier = _encodingHelper.GetInputModifier(state, encodingOptions); - // If isEncoding is true we're actually starting ffmpeg var startNumberParam = isEncoding ? startNumber.ToString(CultureInfo.InvariantCulture) : "0"; - + var inputModifier = _encodingHelper.GetInputModifier(state, _encodingOptions); var mapArgs = state.IsOutputVideo ? _encodingHelper.GetMapArgs(state) : string.Empty; - var outputTsArg = Path.Combine(Path.GetDirectoryName(outputPath), Path.GetFileNameWithoutExtension(outputPath)) + "%d" + GetSegmentFileExtension(state.Request.SegmentContainer); + 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 = GetSegmentFileExtension(state.Request.SegmentContainer); + var outputTsArg = outputPrefix + "%d" + outputExtension; - var segmentFormat = GetSegmentFileExtension(state.Request.SegmentContainer).TrimStart('.'); + var segmentFormat = outputExtension.TrimStart('.'); if (string.Equals(segmentFormat, "ts", StringComparison.OrdinalIgnoreCase)) { segmentFormat = "mpegts"; } + else if (string.Equals(segmentFormat, "mp4", StringComparison.OrdinalIgnoreCase)) + { + var outputFmp4HeaderArg = OperatingSystem.IsWindows() switch + { + // on Windows, the path of fmp4 header file needs to be configured + true => " -hls_fmp4_init_filename \"" + outputPrefix + "-1" + outputExtension + "\"", + // on Linux/Unix, ffmpeg generate fmp4 header file to m3u8 output folder + false => " -hls_fmp4_init_filename \"" + outputFileNameWithoutExtension + "-1" + outputExtension + "\"" + }; - var maxMuxingQueueSize = encodingOptions.MaxMuxingQueueSize > 128 - ? encodingOptions.MaxMuxingQueueSize.ToString(CultureInfo.InvariantCulture) + segmentFormat = "fmp4" + outputFmp4HeaderArg; + } + else + { + _logger.LogError("Invalid HLS segment container: " + segmentFormat); + } + + var maxMuxingQueueSize = _encodingOptions.MaxMuxingQueueSize > 128 + ? _encodingOptions.MaxMuxingQueueSize.ToString(CultureInfo.InvariantCulture) : "128"; 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} -individual_header_trailer 0 -hls_segment_type {8} -start_number {9} -hls_segment_filename \"{10}\" -hls_playlist_type vod -hls_list_size 0 -y \"{11}\"", + "{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} -hls_segment_filename \"{10}\" -hls_playlist_type vod -hls_list_size 0 -y \"{11}\"", inputModifier, - _encodingHelper.GetInputArgument(state, encodingOptions), + _encodingHelper.GetInputArgument(state, _encodingOptions), threads, mapArgs, - GetVideoArguments(state, encodingOptions, startNumber), - GetAudioArguments(state, encodingOptions), + GetVideoArguments(state, startNumber), + GetAudioArguments(state), maxMuxingQueueSize, state.SegmentLength.ToString(CultureInfo.InvariantCulture), segmentFormat, @@ -1370,50 +1416,63 @@ namespace Jellyfin.Api.Controllers outputPath).Trim(); } - private string GetAudioArguments(StreamState state, EncodingOptions encodingOptions) + /// <summary> + /// Gets the audio arguments for transcoding. + /// </summary> + /// <param name="state">The <see cref="StreamState"/>.</param> + /// <returns>The command line arguments for audio transcoding.</returns> + private string GetAudioArguments(StreamState state) { + if (state.AudioStream == null) + { + return string.Empty; + } + var audioCodec = _encodingHelper.GetAudioEncoder(state); if (!state.IsOutputVideo) { if (EncodingHelper.IsCopyCodec(audioCodec)) { - return "-acodec copy"; + var bitStreamArgs = EncodingHelper.GetAudioBitStreamArguments(state, state.Request.SegmentContainer, state.MediaSource.Container); + + return "-acodec copy -strict -2" + bitStreamArgs; } - var audioTranscodeParams = new List<string>(); + var audioTranscodeParams = string.Empty; - audioTranscodeParams.Add("-acodec " + audioCodec); + audioTranscodeParams += "-acodec " + audioCodec; if (state.OutputAudioBitrate.HasValue) { - audioTranscodeParams.Add("-ab " + state.OutputAudioBitrate.Value.ToString(CultureInfo.InvariantCulture)); + audioTranscodeParams += " -ab " + state.OutputAudioBitrate.Value.ToString(CultureInfo.InvariantCulture); } if (state.OutputAudioChannels.HasValue) { - audioTranscodeParams.Add("-ac " + state.OutputAudioChannels.Value.ToString(CultureInfo.InvariantCulture)); + audioTranscodeParams += " -ac " + state.OutputAudioChannels.Value.ToString(CultureInfo.InvariantCulture); } if (state.OutputAudioSampleRate.HasValue) { - audioTranscodeParams.Add("-ar " + state.OutputAudioSampleRate.Value.ToString(CultureInfo.InvariantCulture)); + audioTranscodeParams += " -ar " + state.OutputAudioSampleRate.Value.ToString(CultureInfo.InvariantCulture); } - audioTranscodeParams.Add("-vn"); - return string.Join(' ', audioTranscodeParams); + audioTranscodeParams += " -vn"; + return audioTranscodeParams; } if (EncodingHelper.IsCopyCodec(audioCodec)) { - var videoCodec = _encodingHelper.GetVideoEncoder(state, encodingOptions); + var videoCodec = _encodingHelper.GetVideoEncoder(state, _encodingOptions); + var bitStreamArgs = EncodingHelper.GetAudioBitStreamArguments(state, state.Request.SegmentContainer, state.MediaSource.Container); if (EncodingHelper.IsCopyCodec(videoCodec) && state.EnableBreakOnNonKeyFrames(videoCodec)) { - return "-codec:a:0 copy -copypriorss:a:0 0"; + return "-codec:a:0 copy -strict -2 -copypriorss:a:0 0" + bitStreamArgs; } - return "-codec:a:0 copy"; + return "-codec:a:0 copy -strict -2" + bitStreamArgs; } var args = "-codec:a:0 " + audioCodec; @@ -1437,94 +1496,89 @@ namespace Jellyfin.Api.Controllers args += " -ar " + state.OutputAudioSampleRate.Value.ToString(CultureInfo.InvariantCulture); } - args += " " + _encodingHelper.GetAudioFilterParam(state, encodingOptions, true); + args += _encodingHelper.GetAudioFilterParam(state, _encodingOptions); return args; } - private string GetVideoArguments(StreamState state, EncodingOptions encodingOptions, int startNumber) + /// <summary> + /// Gets the video arguments for transcoding. + /// </summary> + /// <param name="state">The <see cref="StreamState"/>.</param> + /// <param name="startNumber">The first number in the hls sequence.</param> + /// <returns>The command line arguments for video transcoding.</returns> + private string GetVideoArguments(StreamState state, int startNumber) { + if (state.VideoStream == null) + { + return string.Empty; + } + if (!state.IsOutputVideo) { return string.Empty; } - var codec = _encodingHelper.GetVideoEncoder(state, encodingOptions); + var codec = _encodingHelper.GetVideoEncoder(state, _encodingOptions); var args = "-codec:v:0 " + codec; + // Prefer hvc1 to hev1. + 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)) + { + args += " -tag:v:0 hvc1"; + } + // if (state.EnableMpegtsM2TsMode) // { // args += " -mpegts_m2ts_mode 1"; // } - // See if we can save come cpu cycles by avoiding encoding + // See if we can save come cpu cycles by avoiding encoding. if (EncodingHelper.IsCopyCodec(codec)) { if (state.VideoStream != null && !string.Equals(state.VideoStream.NalLengthSize, "0", StringComparison.OrdinalIgnoreCase)) { - string bitStreamArgs = _encodingHelper.GetBitStreamArgs(state.VideoStream); + string bitStreamArgs = EncodingHelper.GetBitStreamArgs(state.VideoStream); if (!string.IsNullOrEmpty(bitStreamArgs)) { args += " " + bitStreamArgs; } } + args += " -start_at_zero"; + // args += " -flags -global_header"; } else { - var gopArg = string.Empty; - var keyFrameArg = string.Format( - CultureInfo.InvariantCulture, - " -force_key_frames:0 \"expr:gte(t,{0}+n_forced*{1})\"", - startNumber * state.SegmentLength, - state.SegmentLength); + args += _encodingHelper.GetVideoQualityParam(state, codec, _encodingOptions, DefaultEncoderPreset); - var framerate = state.VideoStream?.RealFrameRate; + // Set the key frame params for video encoding to match the hls segment time. + args += _encodingHelper.GetHlsVideoKeyFrameArguments(state, codec, state.SegmentLength, false, startNumber); - if (framerate.HasValue) + // Currenly b-frames in libx265 breaks the FMP4-HLS playback on iOS, disable it for now. + if (string.Equals(codec, "libx265", StringComparison.OrdinalIgnoreCase)) { - // This is to make sure keyframe interval is limited to our segment, - // as forcing keyframes is not enough. - // Example: we encoded half of desired length, then codec detected - // scene cut and inserted a keyframe; next forced keyframe would - // be created outside of segment, which breaks seeking - // -sc_threshold 0 is used to prevent the hardware encoder from post processing to break the set keyframe - gopArg = string.Format( - CultureInfo.InvariantCulture, - " -g {0} -keyint_min {0} -sc_threshold 0", - Math.Ceiling(state.SegmentLength * framerate.Value)); - } - - args += " " + _encodingHelper.GetVideoQualityParam(state, codec, encodingOptions, "veryfast"); - - // Unable to force key frames using these hw encoders, set key frames by GOP - if (string.Equals(codec, "h264_qsv", StringComparison.OrdinalIgnoreCase) - || string.Equals(codec, "h264_nvenc", StringComparison.OrdinalIgnoreCase) - || string.Equals(codec, "h264_amf", StringComparison.OrdinalIgnoreCase)) - { - args += " " + gopArg; - } - else - { - args += " " + keyFrameArg + gopArg; + args += " -bf 0"; } // args += " -mixed-refs 0 -refs 3 -x264opts b_pyramid=0:weightb=0:weightp=0"; var hasGraphicalSubs = state.SubtitleStream != null && !state.SubtitleStream.IsTextSubtitleStream && state.SubtitleDeliveryMethod == SubtitleDeliveryMethod.Encode; - // This is for graphical subs if (hasGraphicalSubs) { - args += _encodingHelper.GetGraphicalSubtitleParam(state, encodingOptions, codec); + // Graphical subs overlay and resolution params. + args += _encodingHelper.GetGraphicalSubtitleParam(state, _encodingOptions, codec); } - - // Add resolution params, if specified else { - args += _encodingHelper.GetOutputSizeParam(state, encodingOptions, codec); + // Resolution params. + args += _encodingHelper.GetOutputSizeParam(state, _encodingOptions, codec); } // -start_at_zero is necessary to use with -ss when seeking, @@ -1559,8 +1613,7 @@ namespace Jellyfin.Api.Controllers private string GetSegmentPath(StreamState state, string playlist, int index) { - var folder = Path.GetDirectoryName(playlist); - + 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) + GetSegmentFileExtension(state.Request.SegmentContainer)); @@ -1685,7 +1738,7 @@ namespace Jellyfin.Api.Controllers private int? GetCurrentTranscodingIndex(string playlist, string segmentExtension) { - var job = _transcodingJobHelper.GetTranscodingJob(playlist, _transcodingJobType); + var job = _transcodingJobHelper.GetTranscodingJob(playlist, TranscodingJobType); if (job == null || job.HasExited) { @@ -1708,9 +1761,9 @@ namespace Jellyfin.Api.Controllers private static FileSystemMetadata? GetLastTranscodingFile(string playlist, string segmentExtension, IFileSystem fileSystem) { - var folder = Path.GetDirectoryName(playlist); + var folder = Path.GetDirectoryName(playlist) ?? throw new ArgumentException("Path can't be a root directory.", nameof(playlist)); - var filePrefix = Path.GetFileNameWithoutExtension(playlist) ?? string.Empty; + var filePrefix = Path.GetFileNameWithoutExtension(playlist); try { diff --git a/Jellyfin.Api/Controllers/EnvironmentController.cs b/Jellyfin.Api/Controllers/EnvironmentController.cs index ce88b0b995..b0b4b5af51 100644 --- a/Jellyfin.Api/Controllers/EnvironmentController.cs +++ b/Jellyfin.Api/Controllers/EnvironmentController.cs @@ -5,6 +5,7 @@ using System.IO; using System.Linq; using Jellyfin.Api.Constants; using Jellyfin.Api.Models.EnvironmentDtos; +using MediaBrowser.Common.Extensions; using MediaBrowser.Model.IO; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; @@ -16,7 +17,7 @@ namespace Jellyfin.Api.Controllers /// <summary> /// Environment Controller. /// </summary> - [Authorize(Policy = Policies.RequiresElevation)] + [Authorize(Policy = Policies.FirstTimeSetupOrElevated)] public class EnvironmentController : BaseJellyfinApiController { private const char UncSeparator = '\\'; @@ -103,6 +104,11 @@ namespace Jellyfin.Api.Controllers if (validatePathDto.ValidateWritable) { + if (validatePathDto.Path == null) + { + throw new ResourceNotFoundException(nameof(validatePathDto.Path)); + } + var file = Path.Combine(validatePathDto.Path, Guid.NewGuid().ToString()); try { diff --git a/Jellyfin.Api/Controllers/FilterController.cs b/Jellyfin.Api/Controllers/FilterController.cs index 2a567c8461..223b2a2b66 100644 --- a/Jellyfin.Api/Controllers/FilterController.cs +++ b/Jellyfin.Api/Controllers/FilterController.cs @@ -1,12 +1,12 @@ -using System; +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; using MediaBrowser.Controller.Entities; -using MediaBrowser.Controller.Entities.Audio; -using MediaBrowser.Controller.Entities.Movies; using MediaBrowser.Controller.Library; -using MediaBrowser.Controller.Playlists; using MediaBrowser.Model.Dto; using MediaBrowser.Model.Querying; using Microsoft.AspNetCore.Authorization; @@ -49,37 +49,29 @@ namespace Jellyfin.Api.Controllers [ProducesResponseType(StatusCodes.Status200OK)] public ActionResult<QueryFiltersLegacy> GetQueryFiltersLegacy( [FromQuery] Guid? userId, - [FromQuery] string? parentId, - [FromQuery] string? includeItemTypes, - [FromQuery] string? mediaTypes) + [FromQuery] Guid? parentId, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] includeItemTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] mediaTypes) { - var parentItem = string.IsNullOrEmpty(parentId) - ? null - : _libraryManager.GetItemById(parentId); - var user = userId.HasValue && !userId.Equals(Guid.Empty) ? _userManager.GetUserById(userId.Value) : null; - if (string.Equals(includeItemTypes, nameof(BoxSet), StringComparison.OrdinalIgnoreCase) - || string.Equals(includeItemTypes, nameof(Playlist), StringComparison.OrdinalIgnoreCase) - || string.Equals(includeItemTypes, nameof(Trailer), StringComparison.OrdinalIgnoreCase) - || string.Equals(includeItemTypes, "Program", StringComparison.OrdinalIgnoreCase)) + BaseItem? item = null; + if (includeItemTypes.Length != 1 + || !(includeItemTypes[0] == BaseItemKind.BoxSet + || includeItemTypes[0] == BaseItemKind.Playlist + || includeItemTypes[0] == BaseItemKind.Trailer + || includeItemTypes[0] == BaseItemKind.Program)) { - parentItem = null; + item = _libraryManager.GetParentItem(parentId, user?.Id); } - var item = string.IsNullOrEmpty(parentId) - ? user == null - ? _libraryManager.RootFolder - : _libraryManager.GetUserRootFolder() - : parentItem; - var query = new InternalItemsQuery { User = user, - MediaTypes = (mediaTypes ?? string.Empty).Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries), - IncludeItemTypes = (includeItemTypes ?? string.Empty).Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries), + MediaTypes = mediaTypes, + IncludeItemTypes = RequestHelpers.GetItemTypeStrings(includeItemTypes), Recursive = true, EnableTotalRecordCount = false, DtoOptions = new DtoOptions @@ -90,7 +82,12 @@ namespace Jellyfin.Api.Controllers } }; - var itemList = ((Folder)item!).GetItemList(query); + if (item is not Folder folder) + { + return new QueryFiltersLegacy(); + } + + var itemList = folder.GetItemList(query); return new QueryFiltersLegacy { Years = itemList.Select(i => i.ProductionYear ?? -1) @@ -138,8 +135,8 @@ namespace Jellyfin.Api.Controllers [ProducesResponseType(StatusCodes.Status200OK)] public ActionResult<QueryFilters> GetQueryFilters( [FromQuery] Guid? userId, - [FromQuery] string? parentId, - [FromQuery] string? includeItemTypes, + [FromQuery] Guid? parentId, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] includeItemTypes, [FromQuery] bool? isAiring, [FromQuery] bool? isMovie, [FromQuery] bool? isSports, @@ -148,27 +145,28 @@ namespace Jellyfin.Api.Controllers [FromQuery] bool? isSeries, [FromQuery] bool? recursive) { - var parentItem = string.IsNullOrEmpty(parentId) - ? null - : _libraryManager.GetItemById(parentId); - var user = userId.HasValue && !userId.Equals(Guid.Empty) ? _userManager.GetUserById(userId.Value) : null; - if (string.Equals(includeItemTypes, nameof(BoxSet), StringComparison.OrdinalIgnoreCase) - || string.Equals(includeItemTypes, nameof(Playlist), StringComparison.OrdinalIgnoreCase) - || string.Equals(includeItemTypes, nameof(Trailer), StringComparison.OrdinalIgnoreCase) - || string.Equals(includeItemTypes, "Program", StringComparison.OrdinalIgnoreCase)) + 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 ?? string.Empty).Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries), + IncludeItemTypes = RequestHelpers.GetItemTypeStrings(includeItemTypes), DtoOptions = new DtoOptions { Fields = Array.Empty<ItemFields>(), @@ -192,10 +190,11 @@ namespace Jellyfin.Api.Controllers genreQuery.Parent = parentItem; } - if (string.Equals(includeItemTypes, nameof(MusicAlbum), StringComparison.OrdinalIgnoreCase) - || string.Equals(includeItemTypes, nameof(MusicVideo), StringComparison.OrdinalIgnoreCase) - || string.Equals(includeItemTypes, nameof(MusicArtist), StringComparison.OrdinalIgnoreCase) - || string.Equals(includeItemTypes, nameof(Audio), StringComparison.OrdinalIgnoreCase)) + 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 { diff --git a/Jellyfin.Api/Controllers/GenresController.cs b/Jellyfin.Api/Controllers/GenresController.cs index f4b69b312e..5aa457153c 100644 --- a/Jellyfin.Api/Controllers/GenresController.cs +++ b/Jellyfin.Api/Controllers/GenresController.cs @@ -1,15 +1,17 @@ -using System; +using System; using System.ComponentModel.DataAnnotations; -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; using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; using MediaBrowser.Model.Dto; +using MediaBrowser.Model.Entities; using MediaBrowser.Model.Querying; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; @@ -47,34 +49,22 @@ namespace Jellyfin.Api.Controllers /// <summary> /// Gets all genres from a given item, folder, or the entire library. /// </summary> - /// <param name="minCommunityRating">Optional filter by minimum community rating.</param> /// <param name="startIndex">Optional. The record index to start at. All items with a lower index will be dropped from the results.</param> /// <param name="limit">Optional. The maximum number of records to return.</param> /// <param name="searchTerm">The search term.</param> /// <param name="parentId">Specify this to localize the search to a specific item or folder. Omit to use the root.</param> - /// <param name="fields">Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimited. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines.</param> + /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param> /// <param name="excludeItemTypes">Optional. If specified, results will be filtered out based on item type. This allows multiple, comma delimited.</param> /// <param name="includeItemTypes">Optional. If specified, results will be filtered in based on item type. This allows multiple, comma delimited.</param> - /// <param name="filters">Optional. Specify additional filters to apply.</param> /// <param name="isFavorite">Optional filter by items that are marked as favorite, or not.</param> - /// <param name="mediaTypes">Optional filter by MediaType. Allows multiple, comma delimited.</param> - /// <param name="genres">Optional. If specified, results will be filtered based on genre. This allows multiple, pipe delimited.</param> - /// <param name="genreIds">Optional. If specified, results will be filtered based on genre id. This allows multiple, pipe delimited.</param> - /// <param name="officialRatings">Optional. If specified, results will be filtered based on OfficialRating. This allows multiple, pipe delimited.</param> - /// <param name="tags">Optional. If specified, results will be filtered based on tag. This allows multiple, pipe delimited.</param> - /// <param name="years">Optional. If specified, results will be filtered based on production year. This allows multiple, comma delimited.</param> - /// <param name="enableUserData">Optional, include user data.</param> /// <param name="imageTypeLimit">Optional, the max number of images to return, per image type.</param> /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> - /// <param name="person">Optional. If specified, results will be filtered to include only those containing the specified person.</param> - /// <param name="personIds">Optional. If specified, results will be filtered to include only those containing the specified person id.</param> - /// <param name="personTypes">Optional. If specified, along with Person, results will be filtered to include only those containing the specified person and PersonType. Allows multiple, comma-delimited.</param> - /// <param name="studios">Optional. If specified, results will be filtered based on studio. This allows multiple, pipe delimited.</param> - /// <param name="studioIds">Optional. If specified, results will be filtered based on studio id. This allows multiple, pipe delimited.</param> /// <param name="userId">User id.</param> /// <param name="nameStartsWithOrGreater">Optional filter by items whose name is sorted equally or greater than a given input string.</param> /// <param name="nameStartsWith">Optional filter by items whose name is sorted equally than a given input string.</param> /// <param name="nameLessThan">Optional filter by items whose name is equally or lesser than a given input string.</param> + /// <param name="sortBy">Optional. Specify one or more sort orders, comma delimited.</param> + /// <param name="sortOrder">Sort Order - Ascending,Descending.</param> /// <param name="enableImages">Optional, include image information in output.</param> /// <param name="enableTotalRecordCount">Optional. Include total record count.</param> /// <response code="200">Genres returned.</response> @@ -82,174 +72,75 @@ namespace Jellyfin.Api.Controllers [HttpGet] [ProducesResponseType(StatusCodes.Status200OK)] public ActionResult<QueryResult<BaseItemDto>> GetGenres( - [FromQuery] double? minCommunityRating, [FromQuery] int? startIndex, [FromQuery] int? limit, [FromQuery] string? searchTerm, - [FromQuery] string? parentId, - [FromQuery] string? fields, - [FromQuery] string? excludeItemTypes, - [FromQuery] string? includeItemTypes, - [FromQuery] ItemFilter[] filters, + [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] string? mediaTypes, - [FromQuery] string? genres, - [FromQuery] string? genreIds, - [FromQuery] string? officialRatings, - [FromQuery] string? tags, - [FromQuery] string? years, - [FromQuery] bool? enableUserData, [FromQuery] int? imageTypeLimit, - [FromQuery] string? enableImageTypes, - [FromQuery] string? person, - [FromQuery] string? personIds, - [FromQuery] string? personTypes, - [FromQuery] string? studios, - [FromQuery] string? studioIds, + [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() - .AddItemFields(fields) + var dtoOptions = new DtoOptions { Fields = fields } .AddClientFields(Request) - .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); + .AddAdditionalDtoOptions(enableImages, false, imageTypeLimit, enableImageTypes); - User? user = null; - BaseItem parentItem; + User? user = userId.HasValue && userId != Guid.Empty ? _userManager.GetUserById(userId.Value) : null; - if (userId.HasValue && !userId.Equals(Guid.Empty)) - { - user = _userManager.GetUserById(userId.Value); - parentItem = string.IsNullOrEmpty(parentId) ? _libraryManager.GetUserRootFolder() : _libraryManager.GetItemById(parentId); - } - else - { - parentItem = string.IsNullOrEmpty(parentId) ? _libraryManager.RootFolder : _libraryManager.GetItemById(parentId); - } + var parentItem = _libraryManager.GetParentItem(parentId, userId); var query = new InternalItemsQuery(user) { - ExcludeItemTypes = RequestHelpers.Split(excludeItemTypes, ',', true), - IncludeItemTypes = RequestHelpers.Split(includeItemTypes, ',', true), - MediaTypes = RequestHelpers.Split(mediaTypes, ',', true), + ExcludeItemTypes = RequestHelpers.GetItemTypeStrings(excludeItemTypes), + IncludeItemTypes = RequestHelpers.GetItemTypeStrings(includeItemTypes), StartIndex = startIndex, Limit = limit, IsFavorite = isFavorite, NameLessThan = nameLessThan, NameStartsWith = nameStartsWith, NameStartsWithOrGreater = nameStartsWithOrGreater, - Tags = RequestHelpers.Split(tags, '|', true), - OfficialRatings = RequestHelpers.Split(officialRatings, '|', true), - Genres = RequestHelpers.Split(genres, '|', true), - GenreIds = RequestHelpers.GetGuids(genreIds), - StudioIds = RequestHelpers.GetGuids(studioIds), - Person = person, - PersonIds = RequestHelpers.GetGuids(personIds), - PersonTypes = RequestHelpers.Split(personTypes, ',', true), - Years = RequestHelpers.Split(years, ',', true).Select(y => Convert.ToInt32(y, CultureInfo.InvariantCulture)).ToArray(), - MinCommunityRating = minCommunityRating, DtoOptions = dtoOptions, SearchTerm = searchTerm, - EnableTotalRecordCount = enableTotalRecordCount + EnableTotalRecordCount = enableTotalRecordCount, + OrderBy = RequestHelpers.GetOrderBy(sortBy, sortOrder) }; - if (!string.IsNullOrWhiteSpace(parentId)) + if (parentId.HasValue) { if (parentItem is Folder) { - query.AncestorIds = new[] { new Guid(parentId) }; + query.AncestorIds = new[] { parentId.Value }; } else { - query.ItemIds = new[] { new Guid(parentId) }; + query.ItemIds = new[] { parentId.Value }; } } - // Studios - if (!string.IsNullOrEmpty(studios)) + 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))) { - query.StudioIds = studios.Split('|') - .Select(i => - { - try - { - return _libraryManager.GetStudio(i); - } - catch - { - return null; - } - }).Where(i => i != null) - .Select(i => i!.Id) - .ToArray(); + result = _libraryManager.GetMusicGenres(query); } - - foreach (var filter in filters) + else { - 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; - } + result = _libraryManager.GetGenres(query); } - var result = new QueryResult<(BaseItem, ItemCounts)>(); - - var dtos = result.Items.Select(i => - { - var (baseItem, counts) = i; - var dto = _dtoService.GetItemByNameDto(baseItem, dtoOptions, null, user); - - if (!string.IsNullOrWhiteSpace(includeItemTypes)) - { - dto.ChildCount = counts.ItemCount; - dto.ProgramCount = counts.ProgramCount; - dto.SeriesCount = counts.SeriesCount; - dto.EpisodeCount = counts.EpisodeCount; - dto.MovieCount = counts.MovieCount; - dto.TrailerCount = counts.TrailerCount; - dto.AlbumCount = counts.AlbumCount; - dto.SongCount = counts.SongCount; - dto.ArtistCount = counts.ArtistCount; - } - - return dto; - }); - - return new QueryResult<BaseItemDto> - { - Items = dtos.ToArray(), - TotalRecordCount = result.TotalRecordCount - }; + var shouldIncludeItemTypes = includeItemTypes.Length != 0; + return RequestHelpers.CreateQueryResult(result, dtoOptions, _dtoService, shouldIncludeItemTypes, user); } /// <summary> @@ -291,7 +182,7 @@ namespace Jellyfin.Api.Controllers return _dtoService.GetBaseItemDto(item, dtoOptions); } - private T GetItemFromSlugName<T>(ILibraryManager libraryManager, string name, DtoOptions dtoOptions) + private T? GetItemFromSlugName<T>(ILibraryManager libraryManager, string name, DtoOptions dtoOptions) where T : BaseItem, new() { var result = libraryManager.GetItemList(new InternalItemsQuery diff --git a/Jellyfin.Api/Controllers/HlsSegmentController.cs b/Jellyfin.Api/Controllers/HlsSegmentController.cs index 054e586ce3..473bdc523c 100644 --- a/Jellyfin.Api/Controllers/HlsSegmentController.cs +++ b/Jellyfin.Api/Controllers/HlsSegmentController.cs @@ -1,8 +1,7 @@ -using System; +using System; using System.ComponentModel.DataAnnotations; using System.Diagnostics.CodeAnalysis; using System.IO; -using System.Linq; using System.Threading.Tasks; using Jellyfin.Api.Attributes; using Jellyfin.Api.Constants; @@ -62,7 +61,13 @@ namespace Jellyfin.Api.Controllers { // TODO: Deprecate with new iOS app var file = segmentId + Path.GetExtension(Request.Path); - file = Path.Combine(_serverConfigurationManager.GetTranscodePath(), file); + var transcodePath = _serverConfigurationManager.GetTranscodePath(); + file = Path.GetFullPath(Path.Combine(transcodePath, file)); + var fileDir = Path.GetDirectoryName(file); + if (string.IsNullOrEmpty(fileDir) || !fileDir.StartsWith(transcodePath)) + { + return BadRequest("Invalid segment."); + } return FileStreamResponseHelpers.GetStaticFileResult(file, MimeTypes.GetMimeType(file)!, false, HttpContext); } @@ -82,7 +87,13 @@ namespace Jellyfin.Api.Controllers public ActionResult GetHlsPlaylistLegacy([FromRoute, Required] string itemId, [FromRoute, Required] string playlistId) { var file = playlistId + Path.GetExtension(Request.Path); - file = Path.Combine(_serverConfigurationManager.GetTranscodePath(), file); + var transcodePath = _serverConfigurationManager.GetTranscodePath(); + file = Path.GetFullPath(Path.Combine(transcodePath, file)); + var fileDir = Path.GetDirectoryName(file); + if (string.IsNullOrEmpty(fileDir) || !fileDir.StartsWith(transcodePath) || Path.GetExtension(file) != ".m3u8") + { + return BadRequest("Invalid segment."); + } return GetFileResult(file, file); } @@ -97,7 +108,9 @@ namespace Jellyfin.Api.Controllers [HttpDelete("Videos/ActiveEncodings")] [Authorize(Policy = Policies.DefaultAuthorization)] [ProducesResponseType(StatusCodes.Status204NoContent)] - public ActionResult StopEncodingProcess([FromQuery] string deviceId, [FromQuery] string playSessionId) + public ActionResult StopEncodingProcess( + [FromQuery, Required] string deviceId, + [FromQuery, Required] string playSessionId) { _transcodingJobHelper.KillTranscodingJobs(deviceId, playSessionId, path => true); return NoContent(); @@ -111,11 +124,13 @@ namespace Jellyfin.Api.Controllers /// <param name="segmentId">The segment id.</param> /// <param name="segmentContainer">The segment container.</param> /// <response code="200">Hls video segment returned.</response> + /// <response code="404">Hls segment not found.</response> /// <returns>A <see cref="FileStreamResult"/> containing the video segment.</returns> // Can't require authentication just yet due to seeing some requests come from Chrome without full query string // [Authenticated] [HttpGet("Videos/{itemId}/hls/{playlistId}/{segmentId}.{segmentContainer}")] [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesVideoFile] [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "itemId", Justification = "Required for ServiceStack")] public ActionResult GetHlsVideoSegmentLegacy( @@ -127,16 +142,34 @@ namespace Jellyfin.Api.Controllers var file = segmentId + Path.GetExtension(Request.Path); var transcodeFolderPath = _serverConfigurationManager.GetTranscodePath(); - file = Path.Combine(transcodeFolderPath, file); + file = Path.GetFullPath(Path.Combine(transcodeFolderPath, file)); + var fileDir = Path.GetDirectoryName(file); + if (string.IsNullOrEmpty(fileDir) || !fileDir.StartsWith(transcodeFolderPath)) + { + return BadRequest("Invalid segment."); + } var normalizedPlaylistId = playlistId; - var playlistPath = _fileSystem.GetFilePaths(transcodeFolderPath) - .FirstOrDefault(i => - string.Equals(Path.GetExtension(i), ".m3u8", StringComparison.OrdinalIgnoreCase) - && i.IndexOf(normalizedPlaylistId, StringComparison.OrdinalIgnoreCase) != -1); + 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 GetFileResult(file, playlistPath); + return playlistPath == null + ? NotFound("Hls segment not found.") + : GetFileResult(file, playlistPath); } private ActionResult GetFileResult(string path, string playlistPath) diff --git a/Jellyfin.Api/Controllers/ImageByNameController.cs b/Jellyfin.Api/Controllers/ImageByNameController.cs index 980c3273dd..e1b8080984 100644 --- a/Jellyfin.Api/Controllers/ImageByNameController.cs +++ b/Jellyfin.Api/Controllers/ImageByNameController.cs @@ -74,7 +74,7 @@ namespace Jellyfin.Api.Controllers : type; var path = BaseItem.SupportedImageExtensions - .Select(i => Path.Combine(_applicationPaths.GeneralPath, name, filename + i)) + .Select(i => Path.GetFullPath(Path.Combine(_applicationPaths.GeneralPath, name, filename + i))) .FirstOrDefault(System.IO.File.Exists); if (path == null) @@ -82,6 +82,11 @@ namespace Jellyfin.Api.Controllers return NotFound(); } + if (!path.StartsWith(_applicationPaths.GeneralPath)) + { + return BadRequest("Invalid image path."); + } + var contentType = MimeTypes.GetMimeType(path); return File(System.IO.File.OpenRead(path), contentType); } @@ -161,9 +166,10 @@ namespace Jellyfin.Api.Controllers /// <param name="theme">Theme to search.</param> /// <param name="name">File name to search for.</param> /// <returns>A <see cref="FileStreamResult"/> containing the image contents on success, or a <see cref="NotFoundResult"/> if the image could not be found.</returns> - private ActionResult GetImageFile(string basePath, string? theme, string? name) + private ActionResult GetImageFile(string basePath, string theme, string? name) { - var themeFolder = Path.Combine(basePath, theme); + var themeFolder = Path.GetFullPath(Path.Combine(basePath, theme)); + if (Directory.Exists(themeFolder)) { var path = BaseItem.SupportedImageExtensions.Select(i => Path.Combine(themeFolder, name + i)) @@ -171,12 +177,18 @@ namespace Jellyfin.Api.Controllers if (!string.IsNullOrEmpty(path) && System.IO.File.Exists(path)) { + if (!path.StartsWith(basePath)) + { + return BadRequest("Invalid image path."); + } + var contentType = MimeTypes.GetMimeType(path); + return PhysicalFile(path, contentType); } } - var allFolder = Path.Combine(basePath, "all"); + var allFolder = Path.GetFullPath(Path.Combine(basePath, "all")); if (Directory.Exists(allFolder)) { var path = BaseItem.SupportedImageExtensions.Select(i => Path.Combine(allFolder, name + i)) @@ -184,6 +196,11 @@ namespace Jellyfin.Api.Controllers if (!string.IsNullOrEmpty(path) && System.IO.File.Exists(path)) { + if (!path.StartsWith(basePath)) + { + return BadRequest("Invalid image path."); + } + var contentType = MimeTypes.GetMimeType(path); return PhysicalFile(path, contentType); } diff --git a/Jellyfin.Api/Controllers/ImageController.cs b/Jellyfin.Api/Controllers/ImageController.cs index 05efe23553..8f7500ac69 100644 --- a/Jellyfin.Api/Controllers/ImageController.cs +++ b/Jellyfin.Api/Controllers/ImageController.cs @@ -1,10 +1,11 @@ -using System; +using System; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.Diagnostics.CodeAnalysis; using System.Globalization; using System.IO; using System.Linq; +using System.Net.Mime; using System.Threading; using System.Threading.Tasks; using Jellyfin.Api.Attributes; @@ -85,8 +86,8 @@ namespace Jellyfin.Api.Controllers /// <response code="403">User does not have permission to delete the image.</response> /// <returns>A <see cref="NoContentResult"/>.</returns> [HttpPost("Users/{userId}/Images/{imageType}")] - [HttpPost("Users/{userId}/Images/{imageType}/{index?}", Name = "PostUserImage_2")] [Authorize(Policy = Policies.DefaultAuthorization)] + [AcceptsImageFile] [ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status403Forbidden)] [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "imageType", Justification = "Imported from ServiceStack")] @@ -94,11 +95,11 @@ namespace Jellyfin.Api.Controllers public async Task<ActionResult> PostUserImage( [FromRoute, Required] Guid userId, [FromRoute, Required] ImageType imageType, - [FromRoute] int? index = null) + [FromQuery] int? index = null) { if (!RequestHelpers.AssertCanUpdateUser(_authContext, HttpContext.Request, userId, true)) { - return Forbid("User is not allowed to update the image."); + return StatusCode(StatusCodes.Status403Forbidden, "User is not allowed to update the image."); } var user = _userManager.GetUserById(userId); @@ -109,10 +110,10 @@ namespace Jellyfin.Api.Controllers var userDataPath = Path.Combine(_serverConfigurationManager.ApplicationPaths.UserConfigurationDirectoryPath, user.Username); if (user.ProfileImage != null) { - _userManager.ClearProfileImage(user); + await _userManager.ClearProfileImageAsync(user).ConfigureAwait(false); } - user.ProfileImage = new Data.Entities.ImageInfo(Path.Combine(userDataPath, "profile" + MimeTypes.ToExtension(mimeType))); + user.ProfileImage = new Data.Entities.ImageInfo(Path.Combine(userDataPath, "profile" + MimeTypes.ToExtension(mimeType ?? string.Empty))); await _providerManager .SaveImage(memoryStream, mimeType, user.ProfileImage.Path) @@ -122,6 +123,97 @@ namespace Jellyfin.Api.Controllers return NoContent(); } + /// <summary> + /// Sets the user image. + /// </summary> + /// <param name="userId">User Id.</param> + /// <param name="imageType">(Unused) Image type.</param> + /// <param name="index">(Unused) Image index.</param> + /// <response code="204">Image updated.</response> + /// <response code="403">User does not have permission to delete the image.</response> + /// <returns>A <see cref="NoContentResult"/>.</returns> + [HttpPost("Users/{userId}/Images/{imageType}/{index}")] + [Authorize(Policy = Policies.DefaultAuthorization)] + [AcceptsImageFile] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "imageType", Justification = "Imported from ServiceStack")] + [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "index", Justification = "Imported from ServiceStack")] + public async Task<ActionResult> PostUserImageByIndex( + [FromRoute, Required] Guid userId, + [FromRoute, Required] ImageType imageType, + [FromRoute] int index) + { + if (!RequestHelpers.AssertCanUpdateUser(_authContext, HttpContext.Request, userId, true)) + { + return StatusCode(StatusCodes.Status403Forbidden, "User is not allowed to update the image."); + } + + var user = _userManager.GetUserById(userId); + await using var memoryStream = await GetMemoryStream(Request.Body).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 != 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(); + } + + /// <summary> + /// Delete the user's image. + /// </summary> + /// <param name="userId">User Id.</param> + /// <param name="imageType">(Unused) Image type.</param> + /// <param name="index">(Unused) Image index.</param> + /// <response code="204">Image deleted.</response> + /// <response code="403">User does not have permission to delete the image.</response> + /// <returns>A <see cref="NoContentResult"/>.</returns> + [HttpDelete("Users/{userId}/Images/{imageType}")] + [Authorize(Policy = Policies.DefaultAuthorization)] + [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "imageType", Justification = "Imported from ServiceStack")] + [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "index", Justification = "Imported from ServiceStack")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public async Task<ActionResult> DeleteUserImage( + [FromRoute, Required] Guid userId, + [FromRoute, Required] ImageType imageType, + [FromQuery] int? index = null) + { + if (!RequestHelpers.AssertCanUpdateUser(_authContext, HttpContext.Request, userId, true)) + { + return StatusCode(StatusCodes.Status403Forbidden, "User is not allowed to delete the image."); + } + + var user = _userManager.GetUserById(userId); + if (user?.ProfileImage == null) + { + return NoContent(); + } + + try + { + System.IO.File.Delete(user.ProfileImage.Path); + } + catch (IOException e) + { + _logger.LogError(e, "Error deleting user profile image:"); + } + + await _userManager.ClearProfileImageAsync(user).ConfigureAwait(false); + return NoContent(); + } + /// <summary> /// Delete the user's image. /// </summary> @@ -131,24 +223,28 @@ namespace Jellyfin.Api.Controllers /// <response code="204">Image deleted.</response> /// <response code="403">User does not have permission to delete the image.</response> /// <returns>A <see cref="NoContentResult"/>.</returns> - [HttpDelete("Users/{userId}/Images/{itemType}")] - [HttpDelete("Users/{userId}/Images/{itemType}/{index?}", Name = "DeleteUserImage_2")] + [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 ActionResult DeleteUserImage( + public async Task<ActionResult> DeleteUserImageByIndex( [FromRoute, Required] Guid userId, [FromRoute, Required] ImageType imageType, - [FromRoute] int? index = null) + [FromRoute] int index) { if (!RequestHelpers.AssertCanUpdateUser(_authContext, HttpContext.Request, userId, true)) { - return Forbid("User is not allowed to delete the image."); + return StatusCode(StatusCodes.Status403Forbidden, "User is not allowed to delete the image."); } var user = _userManager.GetUserById(userId); + if (user?.ProfileImage == null) + { + return NoContent(); + } + try { System.IO.File.Delete(user.ProfileImage.Path); @@ -158,7 +254,7 @@ namespace Jellyfin.Api.Controllers _logger.LogError(e, "Error deleting user profile image:"); } - _userManager.ClearProfileImage(user); + await _userManager.ClearProfileImageAsync(user).ConfigureAwait(false); return NoContent(); } @@ -172,14 +268,13 @@ namespace Jellyfin.Api.Controllers /// <response code="404">Item not found.</response> /// <returns>A <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if item not found.</returns> [HttpDelete("Items/{itemId}/Images/{imageType}")] - [HttpDelete("Items/{itemId}/Images/{imageType}/{imageIndex?}", Name = "DeleteItemImage_2")] [Authorize(Policy = Policies.RequiresElevation)] [ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task<ActionResult> DeleteItemImage( [FromRoute, Required] Guid itemId, [FromRoute, Required] ImageType imageType, - [FromRoute] int? imageIndex = null) + [FromQuery] int? imageIndex) { var item = _libraryManager.GetItemById(itemId); if (item == null) @@ -191,25 +286,87 @@ namespace Jellyfin.Api.Controllers return NoContent(); } + /// <summary> + /// Delete an item's image. + /// </summary> + /// <param name="itemId">Item id.</param> + /// <param name="imageType">Image type.</param> + /// <param name="imageIndex">The image index.</param> + /// <response code="204">Image deleted.</response> + /// <response code="404">Item not found.</response> + /// <returns>A <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if item not found.</returns> + [HttpDelete("Items/{itemId}/Images/{imageType}/{imageIndex}")] + [Authorize(Policy = Policies.RequiresElevation)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task<ActionResult> DeleteItemImageByIndex( + [FromRoute, Required] Guid itemId, + [FromRoute, Required] ImageType imageType, + [FromRoute] int imageIndex) + { + var item = _libraryManager.GetItemById(itemId); + if (item == null) + { + return NotFound(); + } + + await item.DeleteImageAsync(imageType, imageIndex).ConfigureAwait(false); + return NoContent(); + } + /// <summary> /// Set item image. /// </summary> /// <param name="itemId">Item id.</param> /// <param name="imageType">Image type.</param> - /// <param name="imageIndex">(Unused) Image index.</param> /// <response code="204">Image saved.</response> /// <response code="404">Item not found.</response> /// <returns>A <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if item not found.</returns> [HttpPost("Items/{itemId}/Images/{imageType}")] - [HttpPost("Items/{itemId}/Images/{imageType}/{imageIndex?}", Name = "SetItemImage_2")] [Authorize(Policy = Policies.RequiresElevation)] + [AcceptsImageFile] [ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status404NotFound)] [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "index", Justification = "Imported from ServiceStack")] public async Task<ActionResult> SetItemImage( + [FromRoute, Required] Guid itemId, + [FromRoute, Required] ImageType imageType) + { + var item = _libraryManager.GetItemById(itemId); + if (item == null) + { + return NotFound(); + } + + await using var memoryStream = await GetMemoryStream(Request.Body).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(); + } + + /// <summary> + /// Set item image. + /// </summary> + /// <param name="itemId">Item id.</param> + /// <param name="imageType">Image type.</param> + /// <param name="imageIndex">(Unused) Image index.</param> + /// <response code="204">Image saved.</response> + /// <response code="404">Item not found.</response> + /// <returns>A <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if item not found.</returns> + [HttpPost("Items/{itemId}/Images/{imageType}/{imageIndex}")] + [Authorize(Policy = Policies.RequiresElevation)] + [AcceptsImageFile] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "index", Justification = "Imported from ServiceStack")] + public async Task<ActionResult> SetItemImageByIndex( [FromRoute, Required] Guid itemId, [FromRoute, Required] ImageType imageType, - [FromRoute] int? imageIndex = null) + [FromRoute] int imageIndex) { var item = _libraryManager.GetItemById(itemId); if (item == null) @@ -217,9 +374,11 @@ namespace Jellyfin.Api.Controllers return NotFound(); } + await using var memoryStream = await GetMemoryStream(Request.Body).ConfigureAwait(false); + // Handle image/png; charset=utf-8 var mimeType = Request.ContentType.Split(';').FirstOrDefault(); - await _providerManager.SaveImage(item, Request.Body, mimeType, imageType, null, CancellationToken.None).ConfigureAwait(false); + await _providerManager.SaveImage(item, memoryStream, mimeType, imageType, null, CancellationToken.None).ConfigureAwait(false); await item.UpdateToRepositoryAsync(ItemUpdateType.ImageUpdate, CancellationToken.None).ConfigureAwait(false); return NoContent(); @@ -243,7 +402,7 @@ namespace Jellyfin.Api.Controllers [FromRoute, Required] Guid itemId, [FromRoute, Required] ImageType imageType, [FromRoute, Required] int imageIndex, - [FromQuery] int newIndex) + [FromQuery, Required] int newIndex) { var item = _libraryManager.GetItemById(itemId); if (item == null) @@ -318,69 +477,491 @@ namespace Jellyfin.Api.Controllers } } - return list; + return list; + } + + /// <summary> + /// Gets the item's image. + /// </summary> + /// <param name="itemId">Item id.</param> + /// <param name="imageType">Image type.</param> + /// <param name="maxWidth">The maximum image width to return.</param> + /// <param name="maxHeight">The maximum image height to return.</param> + /// <param name="width">The fixed image width to return.</param> + /// <param name="height">The fixed image height to return.</param> + /// <param name="quality">Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.</param> + /// <param name="fillWidth">Width of box to fill.</param> + /// <param name="fillHeight">Height of box to fill.</param> + /// <param name="tag">Optional. Supply the cache tag from the item object to receive strong caching headers.</param> + /// <param name="cropWhitespace">Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art.</param> + /// <param name="format">Optional. The <see cref="ImageFormat"/> of the returned image.</param> + /// <param name="addPlayedIndicator">Optional. Add a played indicator.</param> + /// <param name="percentPlayed">Optional. Percent to render for the percent played overlay.</param> + /// <param name="unplayedCount">Optional. Unplayed count overlay to render.</param> + /// <param name="blur">Optional. Blur image.</param> + /// <param name="backgroundColor">Optional. Apply a background color for transparent images.</param> + /// <param name="foregroundLayer">Optional. Apply a foreground layer on top of the image.</param> + /// <param name="imageIndex">Image index.</param> + /// <response code="200">Image stream returned.</response> + /// <response code="404">Item not found.</response> + /// <returns> + /// A <see cref="FileStreamResult"/> containing the file stream on success, + /// or a <see cref="NotFoundResult"/> if item not found. + /// </returns> + [HttpGet("Items/{itemId}/Images/{imageType}")] + [HttpHead("Items/{itemId}/Images/{imageType}", Name = "HeadItemImage")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesImageFile] + public async Task<ActionResult> GetItemImage( + [FromRoute, Required] Guid itemId, + [FromRoute, Required] ImageType imageType, + [FromQuery] int? maxWidth, + [FromQuery] int? maxHeight, + [FromQuery] int? width, + [FromQuery] int? height, + [FromQuery] int? quality, + [FromQuery] int? fillWidth, + [FromQuery] int? fillHeight, + [FromQuery] string? tag, + [FromQuery, ParameterObsolete] bool? cropWhitespace, + [FromQuery] ImageFormat? format, + [FromQuery] bool? addPlayedIndicator, + [FromQuery] double? percentPlayed, + [FromQuery] int? unplayedCount, + [FromQuery] int? blur, + [FromQuery] string? backgroundColor, + [FromQuery] string? foregroundLayer, + [FromQuery] int? imageIndex) + { + var item = _libraryManager.GetItemById(itemId); + if (item == null) + { + return NotFound(); + } + + return await GetImageInternal( + itemId, + imageType, + imageIndex, + tag, + format, + maxWidth, + maxHeight, + percentPlayed, + unplayedCount, + width, + height, + quality, + fillWidth, + fillHeight, + addPlayedIndicator, + blur, + backgroundColor, + foregroundLayer, + item, + Request.Method.Equals(HttpMethods.Head, StringComparison.OrdinalIgnoreCase)) + .ConfigureAwait(false); + } + + /// <summary> + /// Gets the item's image. + /// </summary> + /// <param name="itemId">Item id.</param> + /// <param name="imageType">Image type.</param> + /// <param name="imageIndex">Image index.</param> + /// <param name="maxWidth">The maximum image width to return.</param> + /// <param name="maxHeight">The maximum image height to return.</param> + /// <param name="width">The fixed image width to return.</param> + /// <param name="height">The fixed image height to return.</param> + /// <param name="quality">Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.</param> + /// <param name="fillWidth">Width of box to fill.</param> + /// <param name="fillHeight">Height of box to fill.</param> + /// <param name="tag">Optional. Supply the cache tag from the item object to receive strong caching headers.</param> + /// <param name="cropWhitespace">Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art.</param> + /// <param name="format">Optional. The <see cref="ImageFormat"/> of the returned image.</param> + /// <param name="addPlayedIndicator">Optional. Add a played indicator.</param> + /// <param name="percentPlayed">Optional. Percent to render for the percent played overlay.</param> + /// <param name="unplayedCount">Optional. Unplayed count overlay to render.</param> + /// <param name="blur">Optional. Blur image.</param> + /// <param name="backgroundColor">Optional. Apply a background color for transparent images.</param> + /// <param name="foregroundLayer">Optional. Apply a foreground layer on top of the image.</param> + /// <response code="200">Image stream returned.</response> + /// <response code="404">Item not found.</response> + /// <returns> + /// A <see cref="FileStreamResult"/> containing the file stream on success, + /// or a <see cref="NotFoundResult"/> if item not found. + /// </returns> + [HttpGet("Items/{itemId}/Images/{imageType}/{imageIndex}")] + [HttpHead("Items/{itemId}/Images/{imageType}/{imageIndex}", Name = "HeadItemImageByIndex")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesImageFile] + public async Task<ActionResult> GetItemImageByIndex( + [FromRoute, Required] Guid itemId, + [FromRoute, Required] ImageType imageType, + [FromRoute] int imageIndex, + [FromQuery] int? maxWidth, + [FromQuery] int? maxHeight, + [FromQuery] int? width, + [FromQuery] int? height, + [FromQuery] int? quality, + [FromQuery] int? fillWidth, + [FromQuery] int? fillHeight, + [FromQuery] string? tag, + [FromQuery, ParameterObsolete] bool? cropWhitespace, + [FromQuery] ImageFormat? format, + [FromQuery] bool? addPlayedIndicator, + [FromQuery] double? percentPlayed, + [FromQuery] int? unplayedCount, + [FromQuery] int? blur, + [FromQuery] string? backgroundColor, + [FromQuery] string? foregroundLayer) + { + var item = _libraryManager.GetItemById(itemId); + if (item == null) + { + return NotFound(); + } + + return await GetImageInternal( + itemId, + imageType, + imageIndex, + tag, + format, + maxWidth, + maxHeight, + percentPlayed, + unplayedCount, + width, + height, + quality, + fillWidth, + fillHeight, + addPlayedIndicator, + blur, + backgroundColor, + foregroundLayer, + item, + Request.Method.Equals(HttpMethods.Head, StringComparison.OrdinalIgnoreCase)) + .ConfigureAwait(false); + } + + /// <summary> + /// Gets the item's image. + /// </summary> + /// <param name="itemId">Item id.</param> + /// <param name="imageType">Image type.</param> + /// <param name="maxWidth">The maximum image width to return.</param> + /// <param name="maxHeight">The maximum image height to return.</param> + /// <param name="width">The fixed image width to return.</param> + /// <param name="height">The fixed image height to return.</param> + /// <param name="quality">Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.</param> + /// <param name="fillWidth">Width of box to fill.</param> + /// <param name="fillHeight">Height of box to fill.</param> + /// <param name="tag">Optional. Supply the cache tag from the item object to receive strong caching headers.</param> + /// <param name="cropWhitespace">Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art.</param> + /// <param name="format">Determines the output format of the image - original,gif,jpg,png.</param> + /// <param name="addPlayedIndicator">Optional. Add a played indicator.</param> + /// <param name="percentPlayed">Optional. Percent to render for the percent played overlay.</param> + /// <param name="unplayedCount">Optional. Unplayed count overlay to render.</param> + /// <param name="blur">Optional. Blur image.</param> + /// <param name="backgroundColor">Optional. Apply a background color for transparent images.</param> + /// <param name="foregroundLayer">Optional. Apply a foreground layer on top of the image.</param> + /// <param name="imageIndex">Image index.</param> + /// <response code="200">Image stream returned.</response> + /// <response code="404">Item not found.</response> + /// <returns> + /// A <see cref="FileStreamResult"/> containing the file stream on success, + /// or a <see cref="NotFoundResult"/> if item not found. + /// </returns> + [HttpGet("Items/{itemId}/Images/{imageType}/{imageIndex}/{tag}/{format}/{maxWidth}/{maxHeight}/{percentPlayed}/{unplayedCount}")] + [HttpHead("Items/{itemId}/Images/{imageType}/{imageIndex}/{tag}/{format}/{maxWidth}/{maxHeight}/{percentPlayed}/{unplayedCount}", Name = "HeadItemImage2")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesImageFile] + public async Task<ActionResult> GetItemImage2( + [FromRoute, Required] Guid itemId, + [FromRoute, Required] ImageType imageType, + [FromRoute, Required] int maxWidth, + [FromRoute, Required] int maxHeight, + [FromQuery] int? width, + [FromQuery] int? height, + [FromQuery] int? quality, + [FromQuery] int? fillWidth, + [FromQuery] int? fillHeight, + [FromRoute, Required] string tag, + [FromQuery, ParameterObsolete] bool? cropWhitespace, + [FromRoute, Required] ImageFormat format, + [FromQuery] bool? addPlayedIndicator, + [FromRoute, Required] double percentPlayed, + [FromRoute, Required] int unplayedCount, + [FromQuery] int? blur, + [FromQuery] string? backgroundColor, + [FromQuery] string? foregroundLayer, + [FromRoute, Required] int imageIndex) + { + var item = _libraryManager.GetItemById(itemId); + if (item == null) + { + return NotFound(); + } + + return await GetImageInternal( + itemId, + imageType, + imageIndex, + tag, + format, + maxWidth, + maxHeight, + percentPlayed, + unplayedCount, + width, + height, + quality, + fillWidth, + fillHeight, + addPlayedIndicator, + blur, + backgroundColor, + foregroundLayer, + item, + Request.Method.Equals(HttpMethods.Head, StringComparison.OrdinalIgnoreCase)) + .ConfigureAwait(false); + } + + /// <summary> + /// Get artist image by name. + /// </summary> + /// <param name="name">Artist name.</param> + /// <param name="imageType">Image type.</param> + /// <param name="tag">Optional. Supply the cache tag from the item object to receive strong caching headers.</param> + /// <param name="format">Determines the output format of the image - original,gif,jpg,png.</param> + /// <param name="maxWidth">The maximum image width to return.</param> + /// <param name="maxHeight">The maximum image height to return.</param> + /// <param name="percentPlayed">Optional. Percent to render for the percent played overlay.</param> + /// <param name="unplayedCount">Optional. Unplayed count overlay to render.</param> + /// <param name="width">The fixed image width to return.</param> + /// <param name="height">The fixed image height to return.</param> + /// <param name="quality">Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.</param> + /// <param name="fillWidth">Width of box to fill.</param> + /// <param name="fillHeight">Height of box to fill.</param> + /// <param name="cropWhitespace">Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art.</param> + /// <param name="addPlayedIndicator">Optional. Add a played indicator.</param> + /// <param name="blur">Optional. Blur image.</param> + /// <param name="backgroundColor">Optional. Apply a background color for transparent images.</param> + /// <param name="foregroundLayer">Optional. Apply a foreground layer on top of the image.</param> + /// <param name="imageIndex">Image index.</param> + /// <response code="200">Image stream returned.</response> + /// <response code="404">Item not found.</response> + /// <returns> + /// A <see cref="FileStreamResult"/> containing the file stream on success, + /// or a <see cref="NotFoundResult"/> if item not found. + /// </returns> + [HttpGet("Artists/{name}/Images/{imageType}/{imageIndex}")] + [HttpHead("Artists/{name}/Images/{imageType}/{imageIndex}", Name = "HeadArtistImage")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesImageFile] + public async Task<ActionResult> GetArtistImage( + [FromRoute, Required] string name, + [FromRoute, Required] ImageType imageType, + [FromQuery] string? tag, + [FromQuery] ImageFormat? format, + [FromQuery] int? maxWidth, + [FromQuery] int? maxHeight, + [FromQuery] double? percentPlayed, + [FromQuery] int? unplayedCount, + [FromQuery] int? width, + [FromQuery] int? height, + [FromQuery] int? quality, + [FromQuery] int? fillWidth, + [FromQuery] int? fillHeight, + [FromQuery, ParameterObsolete] bool? cropWhitespace, + [FromQuery] bool? addPlayedIndicator, + [FromQuery] int? blur, + [FromQuery] string? backgroundColor, + [FromQuery] string? foregroundLayer, + [FromRoute, Required] int imageIndex) + { + var item = _libraryManager.GetArtist(name); + if (item == null) + { + return NotFound(); + } + + return await GetImageInternal( + item.Id, + imageType, + imageIndex, + tag, + format, + maxWidth, + maxHeight, + percentPlayed, + unplayedCount, + width, + height, + quality, + fillWidth, + fillHeight, + addPlayedIndicator, + blur, + backgroundColor, + foregroundLayer, + item, + Request.Method.Equals(HttpMethods.Head, StringComparison.OrdinalIgnoreCase)) + .ConfigureAwait(false); + } + + /// <summary> + /// Get genre image by name. + /// </summary> + /// <param name="name">Genre name.</param> + /// <param name="imageType">Image type.</param> + /// <param name="tag">Optional. Supply the cache tag from the item object to receive strong caching headers.</param> + /// <param name="format">Determines the output format of the image - original,gif,jpg,png.</param> + /// <param name="maxWidth">The maximum image width to return.</param> + /// <param name="maxHeight">The maximum image height to return.</param> + /// <param name="percentPlayed">Optional. Percent to render for the percent played overlay.</param> + /// <param name="unplayedCount">Optional. Unplayed count overlay to render.</param> + /// <param name="width">The fixed image width to return.</param> + /// <param name="height">The fixed image height to return.</param> + /// <param name="quality">Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.</param> + /// <param name="fillWidth">Width of box to fill.</param> + /// <param name="fillHeight">Height of box to fill.</param> + /// <param name="cropWhitespace">Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art.</param> + /// <param name="addPlayedIndicator">Optional. Add a played indicator.</param> + /// <param name="blur">Optional. Blur image.</param> + /// <param name="backgroundColor">Optional. Apply a background color for transparent images.</param> + /// <param name="foregroundLayer">Optional. Apply a foreground layer on top of the image.</param> + /// <param name="imageIndex">Image index.</param> + /// <response code="200">Image stream returned.</response> + /// <response code="404">Item not found.</response> + /// <returns> + /// A <see cref="FileStreamResult"/> containing the file stream on success, + /// or a <see cref="NotFoundResult"/> if item not found. + /// </returns> + [HttpGet("Genres/{name}/Images/{imageType}")] + [HttpHead("Genres/{name}/Images/{imageType}", Name = "HeadGenreImage")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesImageFile] + public async Task<ActionResult> GetGenreImage( + [FromRoute, Required] string name, + [FromRoute, Required] ImageType imageType, + [FromQuery] string? tag, + [FromQuery] ImageFormat? format, + [FromQuery] int? maxWidth, + [FromQuery] int? maxHeight, + [FromQuery] double? percentPlayed, + [FromQuery] int? unplayedCount, + [FromQuery] int? width, + [FromQuery] int? height, + [FromQuery] int? quality, + [FromQuery] int? fillWidth, + [FromQuery] int? fillHeight, + [FromQuery, ParameterObsolete] bool? cropWhitespace, + [FromQuery] bool? addPlayedIndicator, + [FromQuery] int? blur, + [FromQuery] string? backgroundColor, + [FromQuery] string? foregroundLayer, + [FromQuery] int? imageIndex) + { + var item = _libraryManager.GetGenre(name); + if (item == null) + { + return NotFound(); + } + + return await GetImageInternal( + item.Id, + imageType, + imageIndex, + tag, + format, + maxWidth, + maxHeight, + percentPlayed, + unplayedCount, + width, + height, + quality, + fillWidth, + fillHeight, + addPlayedIndicator, + blur, + backgroundColor, + foregroundLayer, + item, + Request.Method.Equals(HttpMethods.Head, StringComparison.OrdinalIgnoreCase)) + .ConfigureAwait(false); } /// <summary> - /// Gets the item's image. + /// Get genre image by name. /// </summary> - /// <param name="itemId">Item id.</param> + /// <param name="name">Genre name.</param> /// <param name="imageType">Image type.</param> + /// <param name="imageIndex">Image index.</param> + /// <param name="tag">Optional. Supply the cache tag from the item object to receive strong caching headers.</param> + /// <param name="format">Determines the output format of the image - original,gif,jpg,png.</param> /// <param name="maxWidth">The maximum image width to return.</param> /// <param name="maxHeight">The maximum image height to return.</param> + /// <param name="percentPlayed">Optional. Percent to render for the percent played overlay.</param> + /// <param name="unplayedCount">Optional. Unplayed count overlay to render.</param> /// <param name="width">The fixed image width to return.</param> /// <param name="height">The fixed image height to return.</param> /// <param name="quality">Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.</param> - /// <param name="tag">Optional. Supply the cache tag from the item object to receive strong caching headers.</param> + /// <param name="fillWidth">Width of box to fill.</param> + /// <param name="fillHeight">Height of box to fill.</param> /// <param name="cropWhitespace">Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art.</param> - /// <param name="format">Optional. The <see cref="ImageFormat"/> of the returned image.</param> /// <param name="addPlayedIndicator">Optional. Add a played indicator.</param> - /// <param name="percentPlayed">Optional. Percent to render for the percent played overlay.</param> - /// <param name="unplayedCount">Optional. Unplayed count overlay to render.</param> /// <param name="blur">Optional. Blur image.</param> /// <param name="backgroundColor">Optional. Apply a background color for transparent images.</param> /// <param name="foregroundLayer">Optional. Apply a foreground layer on top of the image.</param> - /// <param name="imageIndex">Image index.</param> /// <response code="200">Image stream returned.</response> /// <response code="404">Item not found.</response> /// <returns> /// A <see cref="FileStreamResult"/> containing the file stream on success, /// or a <see cref="NotFoundResult"/> if item not found. /// </returns> - [HttpGet("Items/{itemId}/Images/{imageType}")] - [HttpHead("Items/{itemId}/Images/{imageType}", Name = "HeadItemImage")] - [HttpGet("Items/{itemId}/Images/{imageType}/{imageIndex?}", Name = "GetItemImage_2")] - [HttpHead("Items/{itemId}/Images/{imageType}/{imageIndex?}", Name = "HeadItemImage_2")] + [HttpGet("Genres/{name}/Images/{imageType}/{imageIndex}")] + [HttpHead("Genres/{name}/Images/{imageType}/{imageIndex}", Name = "HeadGenreImageByIndex")] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesImageFile] - public async Task<ActionResult> GetItemImage( - [FromRoute, Required] Guid itemId, + public async Task<ActionResult> GetGenreImageByIndex( + [FromRoute, Required] string name, [FromRoute, Required] ImageType imageType, + [FromRoute, Required] int imageIndex, + [FromQuery] string? tag, + [FromQuery] ImageFormat? format, [FromQuery] int? maxWidth, [FromQuery] int? maxHeight, + [FromQuery] double? percentPlayed, + [FromQuery] int? unplayedCount, [FromQuery] int? width, [FromQuery] int? height, [FromQuery] int? quality, - [FromQuery] string? tag, - [FromQuery] bool? cropWhitespace, - [FromQuery] ImageFormat? format, + [FromQuery] int? fillWidth, + [FromQuery] int? fillHeight, + [FromQuery, ParameterObsolete] bool? cropWhitespace, [FromQuery] bool? addPlayedIndicator, - [FromQuery] double? percentPlayed, - [FromQuery] int? unplayedCount, [FromQuery] int? blur, [FromQuery] string? backgroundColor, - [FromQuery] string? foregroundLayer, - [FromRoute] int? imageIndex = null) + [FromQuery] string? foregroundLayer) { - var item = _libraryManager.GetItemById(itemId); + var item = _libraryManager.GetGenre(name); if (item == null) { return NotFound(); } return await GetImageInternal( - itemId, + item.Id, imageType, imageIndex, tag, @@ -392,7 +973,8 @@ namespace Jellyfin.Api.Controllers width, height, quality, - cropWhitespace, + fillWidth, + fillHeight, addPlayedIndicator, blur, backgroundColor, @@ -403,21 +985,23 @@ namespace Jellyfin.Api.Controllers } /// <summary> - /// Gets the item's image. + /// Get music genre image by name. /// </summary> - /// <param name="itemId">Item id.</param> + /// <param name="name">Music genre name.</param> /// <param name="imageType">Image type.</param> + /// <param name="tag">Optional. Supply the cache tag from the item object to receive strong caching headers.</param> + /// <param name="format">Determines the output format of the image - original,gif,jpg,png.</param> /// <param name="maxWidth">The maximum image width to return.</param> /// <param name="maxHeight">The maximum image height to return.</param> + /// <param name="percentPlayed">Optional. Percent to render for the percent played overlay.</param> + /// <param name="unplayedCount">Optional. Unplayed count overlay to render.</param> /// <param name="width">The fixed image width to return.</param> /// <param name="height">The fixed image height to return.</param> /// <param name="quality">Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.</param> - /// <param name="tag">Optional. Supply the cache tag from the item object to receive strong caching headers.</param> + /// <param name="fillWidth">Width of box to fill.</param> + /// <param name="fillHeight">Height of box to fill.</param> /// <param name="cropWhitespace">Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art.</param> - /// <param name="format">Determines the output format of the image - original,gif,jpg,png.</param> /// <param name="addPlayedIndicator">Optional. Add a played indicator.</param> - /// <param name="percentPlayed">Optional. Percent to render for the percent played overlay.</param> - /// <param name="unplayedCount">Optional. Unplayed count overlay to render.</param> /// <param name="blur">Optional. Blur image.</param> /// <param name="backgroundColor">Optional. Apply a background color for transparent images.</param> /// <param name="foregroundLayer">Optional. Apply a foreground layer on top of the image.</param> @@ -428,38 +1012,40 @@ namespace Jellyfin.Api.Controllers /// A <see cref="FileStreamResult"/> containing the file stream on success, /// or a <see cref="NotFoundResult"/> if item not found. /// </returns> - [HttpGet("Items/{itemId}/Images/{imageType}/{imageIndex}/{tag}/{format}/{maxWidth}/{maxHeight}/{percentPlayed}/{unplayedCount}")] - [HttpHead("Items/{itemId}/Images/{imageType}/{imageIndex}/{tag}/{format}/{maxWidth}/{maxHeight}/{percentPlayed}/{unplayedCount}", Name = "HeadItemImage2")] + [HttpGet("MusicGenres/{name}/Images/{imageType}")] + [HttpHead("MusicGenres/{name}/Images/{imageType}", Name = "HeadMusicGenreImage")] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesImageFile] - public async Task<ActionResult> GetItemImage2( - [FromRoute, Required] Guid itemId, + public async Task<ActionResult> GetMusicGenreImage( + [FromRoute, Required] string name, [FromRoute, Required] ImageType imageType, - [FromRoute, Required] int maxWidth, - [FromRoute, Required] int maxHeight, + [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, - [FromRoute, Required] string tag, - [FromQuery] bool? cropWhitespace, - [FromRoute, Required] ImageFormat format, + [FromQuery] int? fillWidth, + [FromQuery] int? fillHeight, + [FromQuery, ParameterObsolete] bool? cropWhitespace, [FromQuery] bool? addPlayedIndicator, - [FromRoute, Required] double percentPlayed, - [FromRoute, Required] int unplayedCount, [FromQuery] int? blur, [FromQuery] string? backgroundColor, [FromQuery] string? foregroundLayer, - [FromRoute, Required] int imageIndex) + [FromQuery] int? imageIndex) { - var item = _libraryManager.GetItemById(itemId); + var item = _libraryManager.GetMusicGenre(name); if (item == null) { return NotFound(); } return await GetImageInternal( - itemId, + item.Id, imageType, imageIndex, tag, @@ -471,7 +1057,8 @@ namespace Jellyfin.Api.Controllers width, height, quality, - cropWhitespace, + fillWidth, + fillHeight, addPlayedIndicator, blur, backgroundColor, @@ -482,10 +1069,11 @@ namespace Jellyfin.Api.Controllers } /// <summary> - /// Get artist image by name. + /// Get music genre image by name. /// </summary> - /// <param name="name">Artist name.</param> + /// <param name="name">Music genre name.</param> /// <param name="imageType">Image type.</param> + /// <param name="imageIndex">Image index.</param> /// <param name="tag">Optional. Supply the cache tag from the item object to receive strong caching headers.</param> /// <param name="format">Determines the output format of the image - original,gif,jpg,png.</param> /// <param name="maxWidth">The maximum image width to return.</param> @@ -495,27 +1083,29 @@ namespace Jellyfin.Api.Controllers /// <param name="width">The fixed image width to return.</param> /// <param name="height">The fixed image height to return.</param> /// <param name="quality">Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.</param> + /// <param name="fillWidth">Width of box to fill.</param> + /// <param name="fillHeight">Height of box to fill.</param> /// <param name="cropWhitespace">Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art.</param> /// <param name="addPlayedIndicator">Optional. Add a played indicator.</param> /// <param name="blur">Optional. Blur image.</param> /// <param name="backgroundColor">Optional. Apply a background color for transparent images.</param> /// <param name="foregroundLayer">Optional. Apply a foreground layer on top of the image.</param> - /// <param name="imageIndex">Image index.</param> /// <response code="200">Image stream returned.</response> /// <response code="404">Item not found.</response> /// <returns> /// A <see cref="FileStreamResult"/> containing the file stream on success, /// or a <see cref="NotFoundResult"/> if item not found. /// </returns> - [HttpGet("Artists/{name}/Images/{imageType}/{imageIndex?}")] - [HttpHead("Artists/{name}/Images/{imageType}/{imageIndex?}", Name = "HeadArtistImage")] + [HttpGet("MusicGenres/{name}/Images/{imageType}/{imageIndex}")] + [HttpHead("MusicGenres/{name}/Images/{imageType}/{imageIndex}", Name = "HeadMusicGenreImageByIndex")] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesImageFile] - public async Task<ActionResult> GetArtistImage( + public async Task<ActionResult> GetMusicGenreImageByIndex( [FromRoute, Required] string name, [FromRoute, Required] ImageType imageType, - [FromQuery] string tag, + [FromRoute, Required] int imageIndex, + [FromQuery] string? tag, [FromQuery] ImageFormat? format, [FromQuery] int? maxWidth, [FromQuery] int? maxHeight, @@ -524,14 +1114,15 @@ namespace Jellyfin.Api.Controllers [FromQuery] int? width, [FromQuery] int? height, [FromQuery] int? quality, - [FromQuery] bool? cropWhitespace, + [FromQuery] int? fillWidth, + [FromQuery] int? fillHeight, + [FromQuery, ParameterObsolete] bool? cropWhitespace, [FromQuery] bool? addPlayedIndicator, [FromQuery] int? blur, [FromQuery] string? backgroundColor, - [FromQuery] string? foregroundLayer, - [FromRoute, Required] int imageIndex) + [FromQuery] string? foregroundLayer) { - var item = _libraryManager.GetArtist(name); + var item = _libraryManager.GetMusicGenre(name); if (item == null) { return NotFound(); @@ -550,7 +1141,8 @@ namespace Jellyfin.Api.Controllers width, height, quality, - cropWhitespace, + fillWidth, + fillHeight, addPlayedIndicator, blur, backgroundColor, @@ -561,9 +1153,9 @@ namespace Jellyfin.Api.Controllers } /// <summary> - /// Get genre image by name. + /// Get person image by name. /// </summary> - /// <param name="name">Genre name.</param> + /// <param name="name">Person name.</param> /// <param name="imageType">Image type.</param> /// <param name="tag">Optional. Supply the cache tag from the item object to receive strong caching headers.</param> /// <param name="format">Determines the output format of the image - original,gif,jpg,png.</param> @@ -574,6 +1166,8 @@ namespace Jellyfin.Api.Controllers /// <param name="width">The fixed image width to return.</param> /// <param name="height">The fixed image height to return.</param> /// <param name="quality">Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.</param> + /// <param name="fillWidth">Width of box to fill.</param> + /// <param name="fillHeight">Height of box to fill.</param> /// <param name="cropWhitespace">Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art.</param> /// <param name="addPlayedIndicator">Optional. Add a played indicator.</param> /// <param name="blur">Optional. Blur image.</param> @@ -586,15 +1180,15 @@ namespace Jellyfin.Api.Controllers /// A <see cref="FileStreamResult"/> containing the file stream on success, /// or a <see cref="NotFoundResult"/> if item not found. /// </returns> - [HttpGet("Genres/{name}/Images/{imageType}/{imageIndex?}")] - [HttpHead("Genres/{name}/Images/{imageType}/{imageIndex?}", Name = "HeadGenreImage")] + [HttpGet("Persons/{name}/Images/{imageType}")] + [HttpHead("Persons/{name}/Images/{imageType}", Name = "HeadPersonImage")] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesImageFile] - public async Task<ActionResult> GetGenreImage( + public async Task<ActionResult> GetPersonImage( [FromRoute, Required] string name, [FromRoute, Required] ImageType imageType, - [FromQuery] string tag, + [FromQuery] string? tag, [FromQuery] ImageFormat? format, [FromQuery] int? maxWidth, [FromQuery] int? maxHeight, @@ -603,14 +1197,16 @@ namespace Jellyfin.Api.Controllers [FromQuery] int? width, [FromQuery] int? height, [FromQuery] int? quality, - [FromQuery] bool? cropWhitespace, + [FromQuery] int? fillWidth, + [FromQuery] int? fillHeight, + [FromQuery, ParameterObsolete] bool? cropWhitespace, [FromQuery] bool? addPlayedIndicator, [FromQuery] int? blur, [FromQuery] string? backgroundColor, [FromQuery] string? foregroundLayer, - [FromRoute] int? imageIndex = null) + [FromQuery] int? imageIndex) { - var item = _libraryManager.GetGenre(name); + var item = _libraryManager.GetPerson(name); if (item == null) { return NotFound(); @@ -629,7 +1225,8 @@ namespace Jellyfin.Api.Controllers width, height, quality, - cropWhitespace, + fillWidth, + fillHeight, addPlayedIndicator, blur, backgroundColor, @@ -640,10 +1237,11 @@ namespace Jellyfin.Api.Controllers } /// <summary> - /// Get music genre image by name. + /// Get person image by name. /// </summary> - /// <param name="name">Music genre name.</param> + /// <param name="name">Person name.</param> /// <param name="imageType">Image type.</param> + /// <param name="imageIndex">Image index.</param> /// <param name="tag">Optional. Supply the cache tag from the item object to receive strong caching headers.</param> /// <param name="format">Determines the output format of the image - original,gif,jpg,png.</param> /// <param name="maxWidth">The maximum image width to return.</param> @@ -653,27 +1251,29 @@ namespace Jellyfin.Api.Controllers /// <param name="width">The fixed image width to return.</param> /// <param name="height">The fixed image height to return.</param> /// <param name="quality">Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.</param> + /// <param name="fillWidth">Width of box to fill.</param> + /// <param name="fillHeight">Height of box to fill.</param> /// <param name="cropWhitespace">Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art.</param> /// <param name="addPlayedIndicator">Optional. Add a played indicator.</param> /// <param name="blur">Optional. Blur image.</param> /// <param name="backgroundColor">Optional. Apply a background color for transparent images.</param> /// <param name="foregroundLayer">Optional. Apply a foreground layer on top of the image.</param> - /// <param name="imageIndex">Image index.</param> /// <response code="200">Image stream returned.</response> /// <response code="404">Item not found.</response> /// <returns> /// A <see cref="FileStreamResult"/> containing the file stream on success, /// or a <see cref="NotFoundResult"/> if item not found. /// </returns> - [HttpGet("MusicGenres/{name}/Images/{imageType}/{imageIndex?}")] - [HttpHead("MusicGenres/{name}/Images/{imageType}/{imageIndex?}", Name = "HeadMusicGenreImage")] + [HttpGet("Persons/{name}/Images/{imageType}/{imageIndex}")] + [HttpHead("Persons/{name}/Images/{imageType}/{imageIndex}", Name = "HeadPersonImageByIndex")] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesImageFile] - public async Task<ActionResult> GetMusicGenreImage( + public async Task<ActionResult> GetPersonImageByIndex( [FromRoute, Required] string name, [FromRoute, Required] ImageType imageType, - [FromQuery] string tag, + [FromRoute, Required] int imageIndex, + [FromQuery] string? tag, [FromQuery] ImageFormat? format, [FromQuery] int? maxWidth, [FromQuery] int? maxHeight, @@ -682,14 +1282,15 @@ namespace Jellyfin.Api.Controllers [FromQuery] int? width, [FromQuery] int? height, [FromQuery] int? quality, - [FromQuery] bool? cropWhitespace, + [FromQuery] int? fillWidth, + [FromQuery] int? fillHeight, + [FromQuery, ParameterObsolete] bool? cropWhitespace, [FromQuery] bool? addPlayedIndicator, [FromQuery] int? blur, [FromQuery] string? backgroundColor, - [FromQuery] string? foregroundLayer, - [FromRoute] int? imageIndex = null) + [FromQuery] string? foregroundLayer) { - var item = _libraryManager.GetMusicGenre(name); + var item = _libraryManager.GetPerson(name); if (item == null) { return NotFound(); @@ -708,7 +1309,8 @@ namespace Jellyfin.Api.Controllers width, height, quality, - cropWhitespace, + fillWidth, + fillHeight, addPlayedIndicator, blur, backgroundColor, @@ -719,9 +1321,9 @@ namespace Jellyfin.Api.Controllers } /// <summary> - /// Get person image by name. + /// Get studio image by name. /// </summary> - /// <param name="name">Person name.</param> + /// <param name="name">Studio name.</param> /// <param name="imageType">Image type.</param> /// <param name="tag">Optional. Supply the cache tag from the item object to receive strong caching headers.</param> /// <param name="format">Determines the output format of the image - original,gif,jpg,png.</param> @@ -732,6 +1334,8 @@ namespace Jellyfin.Api.Controllers /// <param name="width">The fixed image width to return.</param> /// <param name="height">The fixed image height to return.</param> /// <param name="quality">Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.</param> + /// <param name="fillWidth">Width of box to fill.</param> + /// <param name="fillHeight">Height of box to fill.</param> /// <param name="cropWhitespace">Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art.</param> /// <param name="addPlayedIndicator">Optional. Add a played indicator.</param> /// <param name="blur">Optional. Blur image.</param> @@ -744,15 +1348,15 @@ namespace Jellyfin.Api.Controllers /// A <see cref="FileStreamResult"/> containing the file stream on success, /// or a <see cref="NotFoundResult"/> if item not found. /// </returns> - [HttpGet("Persons/{name}/Images/{imageType}/{imageIndex?}")] - [HttpHead("Persons/{name}/Images/{imageType}/{imageIndex?}", Name = "HeadPersonImage")] + [HttpGet("Studios/{name}/Images/{imageType}")] + [HttpHead("Studios/{name}/Images/{imageType}", Name = "HeadStudioImage")] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesImageFile] - public async Task<ActionResult> GetPersonImage( + public async Task<ActionResult> GetStudioImage( [FromRoute, Required] string name, [FromRoute, Required] ImageType imageType, - [FromQuery] string tag, + [FromQuery] string? tag, [FromQuery] ImageFormat? format, [FromQuery] int? maxWidth, [FromQuery] int? maxHeight, @@ -761,14 +1365,16 @@ namespace Jellyfin.Api.Controllers [FromQuery] int? width, [FromQuery] int? height, [FromQuery] int? quality, - [FromQuery] bool? cropWhitespace, + [FromQuery] int? fillWidth, + [FromQuery] int? fillHeight, + [FromQuery, ParameterObsolete] bool? cropWhitespace, [FromQuery] bool? addPlayedIndicator, [FromQuery] int? blur, [FromQuery] string? backgroundColor, [FromQuery] string? foregroundLayer, - [FromRoute] int? imageIndex = null) + [FromQuery] int? imageIndex) { - var item = _libraryManager.GetPerson(name); + var item = _libraryManager.GetStudio(name); if (item == null) { return NotFound(); @@ -787,7 +1393,8 @@ namespace Jellyfin.Api.Controllers width, height, quality, - cropWhitespace, + fillWidth, + fillHeight, addPlayedIndicator, blur, backgroundColor, @@ -802,6 +1409,7 @@ namespace Jellyfin.Api.Controllers /// </summary> /// <param name="name">Studio name.</param> /// <param name="imageType">Image type.</param> + /// <param name="imageIndex">Image index.</param> /// <param name="tag">Optional. Supply the cache tag from the item object to receive strong caching headers.</param> /// <param name="format">Determines the output format of the image - original,gif,jpg,png.</param> /// <param name="maxWidth">The maximum image width to return.</param> @@ -811,28 +1419,30 @@ namespace Jellyfin.Api.Controllers /// <param name="width">The fixed image width to return.</param> /// <param name="height">The fixed image height to return.</param> /// <param name="quality">Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.</param> + /// <param name="fillWidth">Width of box to fill.</param> + /// <param name="fillHeight">Height of box to fill.</param> /// <param name="cropWhitespace">Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art.</param> /// <param name="addPlayedIndicator">Optional. Add a played indicator.</param> /// <param name="blur">Optional. Blur image.</param> /// <param name="backgroundColor">Optional. Apply a background color for transparent images.</param> /// <param name="foregroundLayer">Optional. Apply a foreground layer on top of the image.</param> - /// <param name="imageIndex">Image index.</param> /// <response code="200">Image stream returned.</response> /// <response code="404">Item not found.</response> /// <returns> /// A <see cref="FileStreamResult"/> containing the file stream on success, /// or a <see cref="NotFoundResult"/> if item not found. /// </returns> - [HttpGet("Studios/{name}/Images/{imageType}/{imageIndex?}")] - [HttpHead("Studios/{name}/Images/{imageType}/{imageIndex?}", Name = "HeadStudioImage")] + [HttpGet("Studios/{name}/Images/{imageType}/{imageIndex}")] + [HttpHead("Studios/{name}/Images/{imageType}/{imageIndex}", Name = "HeadStudioImageByIndex")] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesImageFile] - public async Task<ActionResult> GetStudioImage( + public async Task<ActionResult> GetStudioImageByIndex( [FromRoute, Required] string name, [FromRoute, Required] ImageType imageType, - [FromRoute, Required] string tag, - [FromRoute, Required] ImageFormat format, + [FromRoute, Required] int imageIndex, + [FromQuery] string? tag, + [FromQuery] ImageFormat? format, [FromQuery] int? maxWidth, [FromQuery] int? maxHeight, [FromQuery] double? percentPlayed, @@ -840,12 +1450,13 @@ namespace Jellyfin.Api.Controllers [FromQuery] int? width, [FromQuery] int? height, [FromQuery] int? quality, - [FromQuery] bool? cropWhitespace, + [FromQuery] int? fillWidth, + [FromQuery] int? fillHeight, + [FromQuery, ParameterObsolete] bool? cropWhitespace, [FromQuery] bool? addPlayedIndicator, [FromQuery] int? blur, [FromQuery] string? backgroundColor, - [FromQuery] string? foregroundLayer, - [FromRoute] int? imageIndex = null) + [FromQuery] string? foregroundLayer) { var item = _libraryManager.GetStudio(name); if (item == null) @@ -866,7 +1477,8 @@ namespace Jellyfin.Api.Controllers width, height, quality, - cropWhitespace, + fillWidth, + fillHeight, addPlayedIndicator, blur, backgroundColor, @@ -890,6 +1502,8 @@ namespace Jellyfin.Api.Controllers /// <param name="width">The fixed image width to return.</param> /// <param name="height">The fixed image height to return.</param> /// <param name="quality">Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.</param> + /// <param name="fillWidth">Width of box to fill.</param> + /// <param name="fillHeight">Height of box to fill.</param> /// <param name="cropWhitespace">Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art.</param> /// <param name="addPlayedIndicator">Optional. Add a played indicator.</param> /// <param name="blur">Optional. Blur image.</param> @@ -902,8 +1516,8 @@ namespace Jellyfin.Api.Controllers /// A <see cref="FileStreamResult"/> containing the file stream on success, /// or a <see cref="NotFoundResult"/> if item not found. /// </returns> - [HttpGet("Users/{userId}/Images/{imageType}/{imageIndex?}")] - [HttpHead("Users/{userId}/Images/{imageType}/{imageIndex?}", Name = "HeadUserImage")] + [HttpGet("Users/{userId}/Images/{imageType}")] + [HttpHead("Users/{userId}/Images/{imageType}", Name = "HeadUserImage")] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesImageFile] @@ -919,15 +1533,119 @@ namespace Jellyfin.Api.Controllers [FromQuery] int? width, [FromQuery] int? height, [FromQuery] int? quality, - [FromQuery] bool? cropWhitespace, + [FromQuery] int? fillWidth, + [FromQuery] int? fillHeight, + [FromQuery, ParameterObsolete] bool? cropWhitespace, [FromQuery] bool? addPlayedIndicator, [FromQuery] int? blur, [FromQuery] string? backgroundColor, [FromQuery] string? foregroundLayer, - [FromRoute] int? imageIndex = null) + [FromQuery] int? imageIndex) + { + var user = _userManager.GetUserById(userId); + if (user?.ProfileImage == 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, + addPlayedIndicator, + blur, + backgroundColor, + foregroundLayer, + null, + Request.Method.Equals(HttpMethods.Head, StringComparison.OrdinalIgnoreCase), + info) + .ConfigureAwait(false); + } + + /// <summary> + /// Get user profile image. + /// </summary> + /// <param name="userId">User id.</param> + /// <param name="imageType">Image type.</param> + /// <param name="imageIndex">Image index.</param> + /// <param name="tag">Optional. Supply the cache tag from the item object to receive strong caching headers.</param> + /// <param name="format">Determines the output format of the image - original,gif,jpg,png.</param> + /// <param name="maxWidth">The maximum image width to return.</param> + /// <param name="maxHeight">The maximum image height to return.</param> + /// <param name="percentPlayed">Optional. Percent to render for the percent played overlay.</param> + /// <param name="unplayedCount">Optional. Unplayed count overlay to render.</param> + /// <param name="width">The fixed image width to return.</param> + /// <param name="height">The fixed image height to return.</param> + /// <param name="quality">Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.</param> + /// <param name="fillWidth">Width of box to fill.</param> + /// <param name="fillHeight">Height of box to fill.</param> + /// <param name="cropWhitespace">Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art.</param> + /// <param name="addPlayedIndicator">Optional. Add a played indicator.</param> + /// <param name="blur">Optional. Blur image.</param> + /// <param name="backgroundColor">Optional. Apply a background color for transparent images.</param> + /// <param name="foregroundLayer">Optional. Apply a foreground layer on top of the image.</param> + /// <response code="200">Image stream returned.</response> + /// <response code="404">Item not found.</response> + /// <returns> + /// A <see cref="FileStreamResult"/> containing the file stream on success, + /// or a <see cref="NotFoundResult"/> if item not found. + /// </returns> + [HttpGet("Users/{userId}/Images/{imageType}/{imageIndex}")] + [HttpHead("Users/{userId}/Images/{imageType}/{imageIndex}", Name = "HeadUserImageByIndex")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesImageFile] + public async Task<ActionResult> GetUserImageByIndex( + [FromRoute, Required] Guid userId, + [FromRoute, Required] ImageType imageType, + [FromRoute, Required] int imageIndex, + [FromQuery] string? tag, + [FromQuery] ImageFormat? format, + [FromQuery] int? maxWidth, + [FromQuery] int? maxHeight, + [FromQuery] double? percentPlayed, + [FromQuery] int? unplayedCount, + [FromQuery] int? width, + [FromQuery] int? height, + [FromQuery] int? quality, + [FromQuery] int? fillWidth, + [FromQuery] int? fillHeight, + [FromQuery, ParameterObsolete] bool? cropWhitespace, + [FromQuery] bool? addPlayedIndicator, + [FromQuery] int? blur, + [FromQuery] string? backgroundColor, + [FromQuery] string? foregroundLayer) { var user = _userManager.GetUserById(userId); - if (user == null) + if (user?.ProfileImage == null) { return NotFound(); } @@ -962,7 +1680,8 @@ namespace Jellyfin.Api.Controllers width, height, quality, - cropWhitespace, + fillWidth, + fillHeight, addPlayedIndicator, blur, backgroundColor, @@ -1046,7 +1765,8 @@ namespace Jellyfin.Api.Controllers int? width, int? height, int? quality, - bool? cropWhitespace, + int? fillWidth, + int? fillHeight, bool? addPlayedIndicator, int? blur, string? backgroundColor, @@ -1088,8 +1808,6 @@ namespace Jellyfin.Api.Controllers } } - cropWhitespace ??= imageType == ImageType.Logo || imageType == ImageType.Art; - var outputFormats = GetOutputFormats(format); TimeSpan? cacheDuration = null; @@ -1109,11 +1827,13 @@ namespace Jellyfin.Api.Controllers item, itemId, imageIndex, + width, height, - maxHeight, maxWidth, + maxHeight, + fillWidth, + fillHeight, quality, - width, addPlayedIndicator, percentPlayed, unplayedCount, @@ -1121,7 +1841,6 @@ namespace Jellyfin.Api.Controllers backgroundColor, foregroundLayer, imageInfo, - cropWhitespace.Value, outputFormats, cacheDuration, responseHeaders, @@ -1140,17 +1859,15 @@ namespace Jellyfin.Api.Controllers private ImageFormat[] GetClientSupportedFormats() { - var acceptTypes = Request.Headers[HeaderNames.Accept]; - var supportedFormats = new List<string>(); - if (acceptTypes.Count > 0) + var supportedFormats = Request.Headers.GetCommaSeparatedValues(HeaderNames.Accept); + for (var i = 0; i < supportedFormats.Length; i++) { - foreach (var type in acceptTypes) + // Remove charsets etc. (anything after semi-colon) + var type = supportedFormats[i]; + int index = type.IndexOf(';', StringComparison.Ordinal); + if (index != -1) { - int index = type.IndexOf(';', StringComparison.Ordinal); - if (index != -1) - { - supportedFormats.Add(type.Substring(0, index)); - } + supportedFormats[i] = type.Substring(0, index); } } @@ -1208,11 +1925,13 @@ namespace Jellyfin.Api.Controllers BaseItem? item, Guid itemId, int? index, + int? width, int? height, - int? maxHeight, int? maxWidth, + int? maxHeight, + int? fillWidth, + int? fillHeight, int? quality, - int? width, bool? addPlayedIndicator, double? percentPlayed, int? unplayedCount, @@ -1220,7 +1939,6 @@ namespace Jellyfin.Api.Controllers string? backgroundColor, string? foregroundLayer, ItemImageInfo imageInfo, - bool cropWhitespace, IReadOnlyCollection<ImageFormat> supportedFormats, TimeSpan? cacheDuration, IDictionary<string, string> headers, @@ -1233,7 +1951,6 @@ namespace Jellyfin.Api.Controllers var options = new ImageProcessingOptions { - CropWhiteSpace = cropWhitespace, Height = height, ImageIndex = index ?? 0, Image = imageInfo, @@ -1241,6 +1958,8 @@ namespace Jellyfin.Api.Controllers ItemId = itemId, MaxHeight = maxHeight, MaxWidth = maxWidth, + FillHeight = fillHeight, + FillWidth = fillWidth, Quality = quality ?? 100, Width = width, AddPlayedIndicator = addPlayedIndicator ?? false, @@ -1268,7 +1987,7 @@ namespace Jellyfin.Api.Controllers Response.Headers.Add(key, value); } - Response.ContentType = imageContentType; + 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); diff --git a/Jellyfin.Api/Controllers/InstantMixController.cs b/Jellyfin.Api/Controllers/InstantMixController.cs index 07fed97642..4774ed4efa 100644 --- a/Jellyfin.Api/Controllers/InstantMixController.cs +++ b/Jellyfin.Api/Controllers/InstantMixController.cs @@ -1,15 +1,16 @@ -using System; +using System; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; -using System.Linq; using Jellyfin.Api.Constants; using Jellyfin.Api.Extensions; +using Jellyfin.Api.ModelBinders; using Jellyfin.Data.Entities; using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Playlists; using MediaBrowser.Model.Dto; +using MediaBrowser.Model.Entities; using MediaBrowser.Model.Querying; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; @@ -54,7 +55,7 @@ namespace Jellyfin.Api.Controllers /// <param name="id">The item id.</param> /// <param name="userId">Optional. Filter by user id, and attach user data.</param> /// <param name="limit">Optional. The maximum number of records to return.</param> - /// <param name="fields">Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimeted. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines, TrailerUrls.</param> + /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param> /// <param name="enableImages">Optional. Include image information in output.</param> /// <param name="enableUserData">Optional. Include user data.</param> /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param> @@ -67,18 +68,17 @@ namespace Jellyfin.Api.Controllers [FromRoute, Required] Guid id, [FromQuery] Guid? userId, [FromQuery] int? limit, - [FromQuery] string? fields, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, [FromQuery] bool? enableImages, [FromQuery] bool? enableUserData, [FromQuery] int? imageTypeLimit, - [FromQuery] string? enableImageTypes) + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes) { var item = _libraryManager.GetItemById(id); var user = userId.HasValue && !userId.Equals(Guid.Empty) ? _userManager.GetUserById(userId.Value) : null; - var dtoOptions = new DtoOptions() - .AddItemFields(fields) + var dtoOptions = new DtoOptions { Fields = fields } .AddClientFields(Request) .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes!); var items = _musicManager.GetInstantMixFromItem(item, user, dtoOptions); @@ -86,12 +86,12 @@ namespace Jellyfin.Api.Controllers } /// <summary> - /// Creates an instant playlist based on a given song. + /// Creates an instant playlist based on a given album. /// </summary> /// <param name="id">The item id.</param> /// <param name="userId">Optional. Filter by user id, and attach user data.</param> /// <param name="limit">Optional. The maximum number of records to return.</param> - /// <param name="fields">Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimeted. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines, TrailerUrls.</param> + /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param> /// <param name="enableImages">Optional. Include image information in output.</param> /// <param name="enableUserData">Optional. Include user data.</param> /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param> @@ -104,18 +104,17 @@ namespace Jellyfin.Api.Controllers [FromRoute, Required] Guid id, [FromQuery] Guid? userId, [FromQuery] int? limit, - [FromQuery] string? fields, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, [FromQuery] bool? enableImages, [FromQuery] bool? enableUserData, [FromQuery] int? imageTypeLimit, - [FromQuery] string? enableImageTypes) + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes) { var album = _libraryManager.GetItemById(id); var user = userId.HasValue && !userId.Equals(Guid.Empty) ? _userManager.GetUserById(userId.Value) : null; - var dtoOptions = new DtoOptions() - .AddItemFields(fields) + var dtoOptions = new DtoOptions { Fields = fields } .AddClientFields(Request) .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes!); var items = _musicManager.GetInstantMixFromItem(album, user, dtoOptions); @@ -123,12 +122,12 @@ namespace Jellyfin.Api.Controllers } /// <summary> - /// Creates an instant playlist based on a given song. + /// Creates an instant playlist based on a given playlist. /// </summary> /// <param name="id">The item id.</param> /// <param name="userId">Optional. Filter by user id, and attach user data.</param> /// <param name="limit">Optional. The maximum number of records to return.</param> - /// <param name="fields">Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimeted. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines, TrailerUrls.</param> + /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param> /// <param name="enableImages">Optional. Include image information in output.</param> /// <param name="enableUserData">Optional. Include user data.</param> /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param> @@ -141,18 +140,17 @@ namespace Jellyfin.Api.Controllers [FromRoute, Required] Guid id, [FromQuery] Guid? userId, [FromQuery] int? limit, - [FromQuery] string? fields, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, [FromQuery] bool? enableImages, [FromQuery] bool? enableUserData, [FromQuery] int? imageTypeLimit, - [FromQuery] string? enableImageTypes) + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes) { var playlist = (Playlist)_libraryManager.GetItemById(id); var user = userId.HasValue && !userId.Equals(Guid.Empty) ? _userManager.GetUserById(userId.Value) : null; - var dtoOptions = new DtoOptions() - .AddItemFields(fields) + var dtoOptions = new DtoOptions { Fields = fields } .AddClientFields(Request) .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes!); var items = _musicManager.GetInstantMixFromItem(playlist, user, dtoOptions); @@ -160,12 +158,12 @@ namespace Jellyfin.Api.Controllers } /// <summary> - /// Creates an instant playlist based on a given song. + /// Creates an instant playlist based on a given genre. /// </summary> /// <param name="name">The genre name.</param> /// <param name="userId">Optional. Filter by user id, and attach user data.</param> /// <param name="limit">Optional. The maximum number of records to return.</param> - /// <param name="fields">Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimeted. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines, TrailerUrls.</param> + /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param> /// <param name="enableImages">Optional. Include image information in output.</param> /// <param name="enableUserData">Optional. Include user data.</param> /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param> @@ -174,21 +172,20 @@ namespace Jellyfin.Api.Controllers /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the playlist items.</returns> [HttpGet("MusicGenres/{name}/InstantMix")] [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult<QueryResult<BaseItemDto>> GetInstantMixFromMusicGenre( + public ActionResult<QueryResult<BaseItemDto>> GetInstantMixFromMusicGenreByName( [FromRoute, Required] string name, [FromQuery] Guid? userId, [FromQuery] int? limit, - [FromQuery] string? fields, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, [FromQuery] bool? enableImages, [FromQuery] bool? enableUserData, [FromQuery] int? imageTypeLimit, - [FromQuery] string? enableImageTypes) + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes) { var user = userId.HasValue && !userId.Equals(Guid.Empty) ? _userManager.GetUserById(userId.Value) : null; - var dtoOptions = new DtoOptions() - .AddItemFields(fields) + var dtoOptions = new DtoOptions { Fields = fields } .AddClientFields(Request) .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes!); var items = _musicManager.GetInstantMixFromGenres(new[] { name }, user, dtoOptions); @@ -196,36 +193,35 @@ namespace Jellyfin.Api.Controllers } /// <summary> - /// Creates an instant playlist based on a given song. + /// Creates an instant playlist based on a given artist. /// </summary> /// <param name="id">The item id.</param> /// <param name="userId">Optional. Filter by user id, and attach user data.</param> /// <param name="limit">Optional. The maximum number of records to return.</param> - /// <param name="fields">Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimeted. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines, TrailerUrls.</param> + /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param> /// <param name="enableImages">Optional. Include image information in output.</param> /// <param name="enableUserData">Optional. Include user data.</param> /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param> /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> /// <response code="200">Instant playlist returned.</response> /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the playlist items.</returns> - [HttpGet("Artists/InstantMix")] + [HttpGet("Artists/{id}/InstantMix")] [ProducesResponseType(StatusCodes.Status200OK)] public ActionResult<QueryResult<BaseItemDto>> GetInstantMixFromArtists( [FromRoute, Required] Guid id, [FromQuery] Guid? userId, [FromQuery] int? limit, - [FromQuery] string? fields, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, [FromQuery] bool? enableImages, [FromQuery] bool? enableUserData, [FromQuery] int? imageTypeLimit, - [FromQuery] string? enableImageTypes) + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes) { var item = _libraryManager.GetItemById(id); var user = userId.HasValue && !userId.Equals(Guid.Empty) ? _userManager.GetUserById(userId.Value) : null; - var dtoOptions = new DtoOptions() - .AddItemFields(fields) + var dtoOptions = new DtoOptions { Fields = fields } .AddClientFields(Request) .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes!); var items = _musicManager.GetInstantMixFromItem(item, user, dtoOptions); @@ -233,36 +229,35 @@ namespace Jellyfin.Api.Controllers } /// <summary> - /// Creates an instant playlist based on a given song. + /// Creates an instant playlist based on a given item. /// </summary> /// <param name="id">The item id.</param> /// <param name="userId">Optional. Filter by user id, and attach user data.</param> /// <param name="limit">Optional. The maximum number of records to return.</param> - /// <param name="fields">Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimeted. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines, TrailerUrls.</param> + /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param> /// <param name="enableImages">Optional. Include image information in output.</param> /// <param name="enableUserData">Optional. Include user data.</param> /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param> /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> /// <response code="200">Instant playlist returned.</response> /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the playlist items.</returns> - [HttpGet("MusicGenres/InstantMix")] + [HttpGet("Items/{id}/InstantMix")] [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult<QueryResult<BaseItemDto>> GetInstantMixFromMusicGenres( + public ActionResult<QueryResult<BaseItemDto>> GetInstantMixFromItem( [FromRoute, Required] Guid id, [FromQuery] Guid? userId, [FromQuery] int? limit, - [FromQuery] string? fields, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, [FromQuery] bool? enableImages, [FromQuery] bool? enableUserData, [FromQuery] int? imageTypeLimit, - [FromQuery] string? enableImageTypes) + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes) { var item = _libraryManager.GetItemById(id); var user = userId.HasValue && !userId.Equals(Guid.Empty) ? _userManager.GetUserById(userId.Value) : null; - var dtoOptions = new DtoOptions() - .AddItemFields(fields) + var dtoOptions = new DtoOptions { Fields = fields } .AddClientFields(Request) .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes!); var items = _musicManager.GetInstantMixFromItem(item, user, dtoOptions); @@ -270,36 +265,72 @@ namespace Jellyfin.Api.Controllers } /// <summary> - /// Creates an instant playlist based on a given song. + /// Creates an instant playlist based on a given artist. /// </summary> /// <param name="id">The item id.</param> /// <param name="userId">Optional. Filter by user id, and attach user data.</param> /// <param name="limit">Optional. The maximum number of records to return.</param> - /// <param name="fields">Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimeted. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines, TrailerUrls.</param> + /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param> /// <param name="enableImages">Optional. Include image information in output.</param> /// <param name="enableUserData">Optional. Include user data.</param> /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param> /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> /// <response code="200">Instant playlist returned.</response> /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the playlist items.</returns> - [HttpGet("Items/{id}/InstantMix")] + [HttpGet("Artists/InstantMix")] [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult<QueryResult<BaseItemDto>> GetInstantMixFromItem( - [FromRoute, Required] Guid id, + [Obsolete("Use GetInstantMixFromArtists")] + public ActionResult<QueryResult<BaseItemDto>> GetInstantMixFromArtists2( + [FromQuery, Required] Guid id, + [FromQuery] Guid? userId, + [FromQuery] int? limit, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, + [FromQuery] bool? enableImages, + [FromQuery] bool? enableUserData, + [FromQuery] int? imageTypeLimit, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes) + { + return GetInstantMixFromArtists( + id, + userId, + limit, + fields, + enableImages, + enableUserData, + imageTypeLimit, + enableImageTypes); + } + + /// <summary> + /// Creates an instant playlist based on a given genre. + /// </summary> + /// <param name="id">The item id.</param> + /// <param name="userId">Optional. Filter by user id, and attach user data.</param> + /// <param name="limit">Optional. The maximum number of records to return.</param> + /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param> + /// <param name="enableImages">Optional. Include image information in output.</param> + /// <param name="enableUserData">Optional. Include user data.</param> + /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param> + /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> + /// <response code="200">Instant playlist returned.</response> + /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the playlist items.</returns> + [HttpGet("MusicGenres/InstantMix")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<QueryResult<BaseItemDto>> GetInstantMixFromMusicGenreById( + [FromQuery, Required] Guid id, [FromQuery] Guid? userId, [FromQuery] int? limit, - [FromQuery] string? fields, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, [FromQuery] bool? enableImages, [FromQuery] bool? enableUserData, [FromQuery] int? imageTypeLimit, - [FromQuery] string? enableImageTypes) + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes) { var item = _libraryManager.GetItemById(id); var user = userId.HasValue && !userId.Equals(Guid.Empty) ? _userManager.GetUserById(userId.Value) : null; - var dtoOptions = new DtoOptions() - .AddItemFields(fields) + var dtoOptions = new DtoOptions { Fields = fields } .AddClientFields(Request) .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes!); var items = _musicManager.GetInstantMixFromItem(item, user, dtoOptions); @@ -315,9 +346,9 @@ namespace Jellyfin.Api.Controllers TotalRecordCount = list.Count }; - if (limit.HasValue) + if (limit.HasValue && limit < list.Count) { - list = list.Take(limit.Value).ToList(); + list = list.GetRange(0, limit.Value); } var returnList = _dtoService.GetBaseItemDtos(list, dtoOptions, user); diff --git a/Jellyfin.Api/Controllers/ItemLookupController.cs b/Jellyfin.Api/Controllers/ItemLookupController.cs index ab73aa4286..9fa307858f 100644 --- a/Jellyfin.Api/Controllers/ItemLookupController.cs +++ b/Jellyfin.Api/Controllers/ItemLookupController.cs @@ -1,9 +1,7 @@ -using System; +using System; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.IO; -using System.Linq; -using System.Net.Mime; using System.Text.Json; using System.Threading; using System.Threading.Tasks; @@ -239,48 +237,6 @@ namespace Jellyfin.Api.Controllers return Ok(results); } - /// <summary> - /// Gets a remote image. - /// </summary> - /// <param name="imageUrl">The image url.</param> - /// <param name="providerName">The provider name.</param> - /// <response code="200">Remote image retrieved.</response> - /// <returns> - /// A <see cref="Task" /> that represents the asynchronous operation to get the remote search results. - /// The task result contains an <see cref="FileStreamResult"/> containing the images file stream. - /// </returns> - [HttpGet("Items/RemoteSearch/Image")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesImageFile] - public async Task<ActionResult> GetRemoteSearchImage( - [FromQuery, Required] string imageUrl, - [FromQuery, Required] string providerName) - { - var urlHash = imageUrl.GetMD5(); - var pointerCachePath = GetFullCachePath(urlHash.ToString()); - - try - { - var contentPath = await System.IO.File.ReadAllTextAsync(pointerCachePath).ConfigureAwait(false); - if (System.IO.File.Exists(contentPath)) - { - return PhysicalFile(contentPath, MimeTypes.GetMimeType(contentPath)); - } - } - catch (FileNotFoundException) - { - // Means the file isn't cached yet - } - catch (IOException) - { - // Means the file isn't cached yet - } - - await DownloadImage(providerName, imageUrl, urlHash, pointerCachePath).ConfigureAwait(false); - var updatedContentPath = await System.IO.File.ReadAllTextAsync(pointerCachePath).ConfigureAwait(false); - return PhysicalFile(updatedContentPath, MimeTypes.GetMimeType(updatedContentPath)); - } - /// <summary> /// Applies search criteria to an item and refreshes metadata. /// </summary> @@ -322,45 +278,5 @@ namespace Jellyfin.Api.Controllers return NoContent(); } - - /// <summary> - /// Downloads the image. - /// </summary> - /// <param name="providerName">Name of the provider.</param> - /// <param name="url">The URL.</param> - /// <param name="urlHash">The URL hash.</param> - /// <param name="pointerCachePath">The pointer cache path.</param> - /// <returns>Task.</returns> - private async Task DownloadImage(string providerName, string url, Guid urlHash, string pointerCachePath) - { - using var result = await _providerManager.GetSearchImage(providerName, url, CancellationToken.None).ConfigureAwait(false); - var ext = result.Content.Headers.ContentType.MediaType.Split('/')[^1]; - var fullCachePath = GetFullCachePath(urlHash + "." + ext); - - Directory.CreateDirectory(Path.GetDirectoryName(fullCachePath)); - using (var stream = result.Content) - { - await using var fileStream = new FileStream( - fullCachePath, - FileMode.Create, - FileAccess.Write, - FileShare.Read, - IODefaults.FileStreamBufferSize, - true); - - await stream.CopyToAsync(fileStream).ConfigureAwait(false); - } - - Directory.CreateDirectory(Path.GetDirectoryName(pointerCachePath)); - await System.IO.File.WriteAllTextAsync(pointerCachePath, fullCachePath).ConfigureAwait(false); - } - - /// <summary> - /// Gets the full cache path. - /// </summary> - /// <param name="filename">The filename.</param> - /// <returns>System.String.</returns> - private string GetFullCachePath(string filename) - => Path.Combine(_appPaths.CachePath, "remote-images", filename.Substring(0, 1), filename); } } diff --git a/Jellyfin.Api/Controllers/ItemUpdateController.cs b/Jellyfin.Api/Controllers/ItemUpdateController.cs index 0a6ed31ae3..a9f4a5a58c 100644 --- a/Jellyfin.Api/Controllers/ItemUpdateController.cs +++ b/Jellyfin.Api/Controllers/ItemUpdateController.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.Linq; @@ -195,7 +195,7 @@ namespace Jellyfin.Api.Controllers [HttpPost("Items/{itemId}/ContentType")] [ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status404NotFound)] - public ActionResult UpdateItemContentType([FromRoute, Required] Guid itemId, [FromQuery] string contentType) + public ActionResult UpdateItemContentType([FromRoute, Required] Guid itemId, [FromQuery] string? contentType) { var item = _libraryManager.GetItemById(itemId); if (item == null) diff --git a/Jellyfin.Api/Controllers/ItemsController.cs b/Jellyfin.Api/Controllers/ItemsController.cs index b50b1e836b..35c27dd0e9 100644 --- a/Jellyfin.Api/Controllers/ItemsController.cs +++ b/Jellyfin.Api/Controllers/ItemsController.cs @@ -1,10 +1,10 @@ -using System; +using System; using System.ComponentModel.DataAnnotations; -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.Enums; using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; @@ -59,7 +59,6 @@ namespace Jellyfin.Api.Controllers /// <summary> /// Gets items based on a query. /// </summary> - /// <param name="uId">The user id supplied in the /Users/{uid}/Items.</param> /// <param name="userId">The user id supplied as query parameter.</param> /// <param name="maxOfficialRating">Optional filter by maximum official rating (PG, PG-13, TV-MA, etc).</param> /// <param name="hasThemeSong">Optional filter by items with theme songs.</param> @@ -72,8 +71,8 @@ namespace Jellyfin.Api.Controllers /// <param name="hasParentalRating">Optional filter by items that have or do not have a parental rating.</param> /// <param name="isHd">Optional filter by items that are HD or not.</param> /// <param name="is4K">Optional filter by items that are 4K or not.</param> - /// <param name="locationTypes">Optional. If specified, results will be filtered based on LocationType. This allows multiple, comma delimeted.</param> - /// <param name="excludeLocationTypes">Optional. If specified, results will be filtered based on the LocationType. This allows multiple, comma delimeted.</param> + /// <param name="locationTypes">Optional. If specified, results will be filtered based on LocationType. This allows multiple, comma delimited.</param> + /// <param name="excludeLocationTypes">Optional. If specified, results will be filtered based on the LocationType. This allows multiple, comma delimited.</param> /// <param name="isMissing">Optional filter by items that are missing episodes or not.</param> /// <param name="isUnaired">Optional filter by items that are unaired episodes or not.</param> /// <param name="minCommunityRating">Optional filter by minimum community rating.</param> @@ -86,42 +85,42 @@ namespace Jellyfin.Api.Controllers /// <param name="hasImdbId">Optional filter by items that have an imdb id or not.</param> /// <param name="hasTmdbId">Optional filter by items that have a tmdb id or not.</param> /// <param name="hasTvdbId">Optional filter by items that have a tvdb id or not.</param> - /// <param name="excludeItemIds">Optional. If specified, results will be filtered by exxcluding item ids. This allows multiple, comma delimeted.</param> + /// <param name="excludeItemIds">Optional. If specified, results will be filtered by excluding item ids. This allows multiple, comma delimited.</param> /// <param name="startIndex">Optional. The record index to start at. All items with a lower index will be dropped from the results.</param> /// <param name="limit">Optional. The maximum number of records to return.</param> /// <param name="recursive">When searching within folders, this determines whether or not the search will be recursive. true/false.</param> /// <param name="searchTerm">Optional. Filter based on a search term.</param> /// <param name="sortOrder">Sort Order - Ascending,Descending.</param> /// <param name="parentId">Specify this to localize the search to a specific item or folder. Omit to use the root.</param> - /// <param name="fields">Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimeted. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines.</param> - /// <param name="excludeItemTypes">Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimeted.</param> - /// <param name="includeItemTypes">Optional. If specified, results will be filtered based on the item type. This allows multiple, comma delimeted.</param> - /// <param name="filters">Optional. Specify additional filters to apply. This allows multiple, comma delimeted. Options: IsFolder, IsNotFolder, IsUnplayed, IsPlayed, IsFavorite, IsResumable, Likes, Dislikes.</param> + /// <param name="fields">Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimited. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines.</param> + /// <param name="excludeItemTypes">Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimited.</param> + /// <param name="includeItemTypes">Optional. If specified, results will be filtered based on the item type. This allows multiple, comma delimited.</param> + /// <param name="filters">Optional. Specify additional filters to apply. This allows multiple, comma delimited. Options: IsFolder, IsNotFolder, IsUnplayed, IsPlayed, IsFavorite, IsResumable, Likes, Dislikes.</param> /// <param name="isFavorite">Optional filter by items that are marked as favorite, or not.</param> /// <param name="mediaTypes">Optional filter by MediaType. Allows multiple, comma delimited.</param> /// <param name="imageTypes">Optional. If specified, results will be filtered based on those containing image types. This allows multiple, comma delimited.</param> - /// <param name="sortBy">Optional. Specify one or more sort orders, comma delimeted. Options: Album, AlbumArtist, Artist, Budget, CommunityRating, CriticRating, DateCreated, DatePlayed, PlayCount, PremiereDate, ProductionYear, SortName, Random, Revenue, Runtime.</param> + /// <param name="sortBy">Optional. Specify one or more sort orders, comma delimited. Options: Album, AlbumArtist, Artist, Budget, CommunityRating, CriticRating, DateCreated, DatePlayed, PlayCount, PremiereDate, ProductionYear, SortName, Random, Revenue, Runtime.</param> /// <param name="isPlayed">Optional filter by items that are played, or not.</param> - /// <param name="genres">Optional. If specified, results will be filtered based on genre. This allows multiple, pipe delimeted.</param> - /// <param name="officialRatings">Optional. If specified, results will be filtered based on OfficialRating. This allows multiple, pipe delimeted.</param> - /// <param name="tags">Optional. If specified, results will be filtered based on tag. This allows multiple, pipe delimeted.</param> - /// <param name="years">Optional. If specified, results will be filtered based on production year. This allows multiple, comma delimeted.</param> + /// <param name="genres">Optional. If specified, results will be filtered based on genre. This allows multiple, pipe delimited.</param> + /// <param name="officialRatings">Optional. If specified, results will be filtered based on OfficialRating. This allows multiple, pipe delimited.</param> + /// <param name="tags">Optional. If specified, results will be filtered based on tag. This allows multiple, pipe delimited.</param> + /// <param name="years">Optional. If specified, results will be filtered based on production year. This allows multiple, comma delimited.</param> /// <param name="enableUserData">Optional, include user data.</param> /// <param name="imageTypeLimit">Optional, the max number of images to return, per image type.</param> /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> /// <param name="person">Optional. If specified, results will be filtered to include only those containing the specified person.</param> /// <param name="personIds">Optional. If specified, results will be filtered to include only those containing the specified person id.</param> /// <param name="personTypes">Optional. If specified, along with Person, results will be filtered to include only those containing the specified person and PersonType. Allows multiple, comma-delimited.</param> - /// <param name="studios">Optional. If specified, results will be filtered based on studio. This allows multiple, pipe delimeted.</param> - /// <param name="artists">Optional. If specified, results will be filtered based on artists. This allows multiple, pipe delimeted.</param> - /// <param name="excludeArtistIds">Optional. If specified, results will be filtered based on artist id. This allows multiple, pipe delimeted.</param> + /// <param name="studios">Optional. If specified, results will be filtered based on studio. This allows multiple, pipe delimited.</param> + /// <param name="artists">Optional. If specified, results will be filtered based on artists. This allows multiple, pipe delimited.</param> + /// <param name="excludeArtistIds">Optional. If specified, results will be filtered based on artist id. This allows multiple, pipe delimited.</param> /// <param name="artistIds">Optional. If specified, results will be filtered to include only those containing the specified artist id.</param> /// <param name="albumArtistIds">Optional. If specified, results will be filtered to include only those containing the specified album artist id.</param> /// <param name="contributingArtistIds">Optional. If specified, results will be filtered to include only those containing the specified contributing artist id.</param> - /// <param name="albums">Optional. If specified, results will be filtered based on album. This allows multiple, pipe delimeted.</param> - /// <param name="albumIds">Optional. If specified, results will be filtered based on album id. This allows multiple, pipe delimeted.</param> + /// <param name="albums">Optional. If specified, results will be filtered based on album. This allows multiple, pipe delimited.</param> + /// <param name="albumIds">Optional. If specified, results will be filtered based on album id. This allows multiple, pipe delimited.</param> /// <param name="ids">Optional. If specific items are needed, specify a list of item id's to retrieve. This allows multiple, comma delimited.</param> - /// <param name="videoTypes">Optional filter by VideoType (videofile, dvd, bluray, iso). Allows multiple, comma delimeted.</param> + /// <param name="videoTypes">Optional filter by VideoType (videofile, dvd, bluray, iso). Allows multiple, comma delimited.</param> /// <param name="minOfficialRating">Optional filter by minimum official rating (PG, PG-13, TV-MA, etc).</param> /// <param name="isLocked">Optional filter by items that are locked.</param> /// <param name="isPlaceHolder">Optional filter by items that are placeholders.</param> @@ -132,21 +131,19 @@ namespace Jellyfin.Api.Controllers /// <param name="maxWidth">Optional. Filter by the maximum width of the item.</param> /// <param name="maxHeight">Optional. Filter by the maximum height of the item.</param> /// <param name="is3D">Optional filter by items that are 3D, or not.</param> - /// <param name="seriesStatus">Optional filter by Series Status. Allows multiple, comma delimeted.</param> + /// <param name="seriesStatus">Optional filter by Series Status. Allows multiple, comma delimited.</param> /// <param name="nameStartsWithOrGreater">Optional filter by items whose name is sorted equally or greater than a given input string.</param> /// <param name="nameStartsWith">Optional filter by items whose name is sorted equally than a given input string.</param> /// <param name="nameLessThan">Optional filter by items whose name is equally or lesser than a given input string.</param> - /// <param name="studioIds">Optional. If specified, results will be filtered based on studio id. This allows multiple, pipe delimeted.</param> - /// <param name="genreIds">Optional. If specified, results will be filtered based on genre id. This allows multiple, pipe delimeted.</param> + /// <param name="studioIds">Optional. If specified, results will be filtered based on studio id. This allows multiple, pipe delimited.</param> + /// <param name="genreIds">Optional. If specified, results will be filtered based on genre id. This allows multiple, pipe delimited.</param> /// <param name="enableTotalRecordCount">Optional. Enable the total record count.</param> /// <param name="enableImages">Optional, include image information in output.</param> /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the items.</returns> [HttpGet("Items")] - [HttpGet("Users/{uId}/Items", Name = "GetItems_2")] [ProducesResponseType(StatusCodes.Status200OK)] public ActionResult<QueryResult<BaseItemDto>> GetItems( - [FromRoute] Guid? uId, - [FromQuery] Guid? userId, + [FromQuery] Guid userId, [FromQuery] string? maxOfficialRating, [FromQuery] bool? hasThemeSong, [FromQuery] bool? hasThemeVideo, @@ -158,8 +155,8 @@ namespace Jellyfin.Api.Controllers [FromQuery] bool? hasParentalRating, [FromQuery] bool? isHd, [FromQuery] bool? is4K, - [FromQuery] string? locationTypes, - [FromQuery] LocationType[] excludeLocationTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] LocationType[] locationTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] LocationType[] excludeLocationTypes, [FromQuery] bool? isMissing, [FromQuery] bool? isUnaired, [FromQuery] double? minCommunityRating, @@ -172,42 +169,42 @@ namespace Jellyfin.Api.Controllers [FromQuery] bool? hasImdbId, [FromQuery] bool? hasTmdbId, [FromQuery] bool? hasTvdbId, - [FromQuery] string? excludeItemIds, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] excludeItemIds, [FromQuery] int? startIndex, [FromQuery] int? limit, [FromQuery] bool? recursive, [FromQuery] string? searchTerm, - [FromQuery] string? sortOrder, - [FromQuery] string? parentId, - [FromQuery] string? fields, - [FromQuery] string? excludeItemTypes, - [FromQuery] string? includeItemTypes, - [FromQuery] ItemFilter[] filters, + [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] string? mediaTypes, - [FromQuery] string? imageTypes, - [FromQuery] string? sortBy, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] mediaTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] imageTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] sortBy, [FromQuery] bool? isPlayed, - [FromQuery] string? genres, - [FromQuery] string? officialRatings, - [FromQuery] string? tags, - [FromQuery] string? years, + [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] string? enableImageTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes, [FromQuery] string? person, - [FromQuery] string? personIds, - [FromQuery] string? personTypes, - [FromQuery] string? studios, - [FromQuery] string? artists, - [FromQuery] string? excludeArtistIds, - [FromQuery] string? artistIds, - [FromQuery] string? albumArtistIds, - [FromQuery] string? contributingArtistIds, - [FromQuery] string? albums, - [FromQuery] string? albumIds, - [FromQuery] string? ids, - [FromQuery] string? videoTypes, + [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, @@ -218,87 +215,84 @@ namespace Jellyfin.Api.Controllers [FromQuery] int? maxWidth, [FromQuery] int? maxHeight, [FromQuery] bool? is3D, - [FromQuery] string? seriesStatus, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] SeriesStatus[] seriesStatus, [FromQuery] string? nameStartsWithOrGreater, [FromQuery] string? nameStartsWith, [FromQuery] string? nameLessThan, - [FromQuery] string? studioIds, - [FromQuery] string? genreIds, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] studioIds, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] genreIds, [FromQuery] bool enableTotalRecordCount = true, [FromQuery] bool? enableImages = true) { - // use user id route parameter over query parameter - userId = uId ?? userId; - - var user = userId.HasValue && !userId.Equals(Guid.Empty) - ? _userManager.GetUserById(userId.Value) + var user = !userId.Equals(Guid.Empty) + ? _userManager.GetUserById(userId) : null; - var dtoOptions = new DtoOptions() - .AddItemFields(fields) + var dtoOptions = new DtoOptions { Fields = fields } .AddClientFields(Request) .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); - if (string.Equals(includeItemTypes, "Playlist", StringComparison.OrdinalIgnoreCase) - || string.Equals(includeItemTypes, "BoxSet", StringComparison.OrdinalIgnoreCase)) + if (includeItemTypes.Length == 1 + && (includeItemTypes[0] == BaseItemKind.Playlist + || includeItemTypes[0] == BaseItemKind.BoxSet)) { parentId = null; } - BaseItem? item = null; + var item = _libraryManager.GetParentItem(parentId, userId); QueryResult<BaseItem> result; - if (!string.IsNullOrEmpty(parentId)) - { - item = _libraryManager.GetItemById(parentId); - } - - item ??= _libraryManager.GetUserRootFolder(); if (!(item is Folder folder)) { folder = _libraryManager.GetUserRootFolder(); } - if (folder is IHasCollectionType hasCollectionType - && string.Equals(hasCollectionType.CollectionType, CollectionType.Playlists, StringComparison.OrdinalIgnoreCase)) + string? collectionType = null; + if (folder is IHasCollectionType hasCollectionType) + { + collectionType = hasCollectionType.CollectionType; + } + + if (string.Equals(collectionType, CollectionType.Playlists, StringComparison.OrdinalIgnoreCase)) { recursive = true; - includeItemTypes = "Playlist"; + includeItemTypes = new[] { BaseItemKind.Playlist }; } - bool isInEnabledFolder = user!.GetPreference(PreferenceKind.EnabledFolders).Any(i => new Guid(i) == item.Id) + var enabledChannels = user!.GetPreferenceValues<Guid>(PreferenceKind.EnabledChannels); + + bool isInEnabledFolder = Array.IndexOf(user.GetPreferenceValues<Guid>(PreferenceKind.EnabledFolders), item.Id) != -1 // Assume all folders inside an EnabledChannel are enabled - || user.GetPreference(PreferenceKind.EnabledChannels).Any(i => new Guid(i) == item.Id) + || Array.IndexOf(enabledChannels, item.Id) != -1 // Assume all items inside an EnabledChannel are enabled - || user.GetPreference(PreferenceKind.EnabledChannels).Any(i => new Guid(i) == item.ChannelId); + || Array.IndexOf(enabledChannels, item.ChannelId) != -1; var collectionFolders = _libraryManager.GetCollectionFolders(item); foreach (var collectionFolder in collectionFolders) { - if (user.GetPreference(PreferenceKind.EnabledFolders).Contains( - collectionFolder.Id.ToString("N", CultureInfo.InvariantCulture), - StringComparer.OrdinalIgnoreCase)) + if (user.GetPreferenceValues<Guid>(PreferenceKind.EnabledFolders).Contains(collectionFolder.Id)) { isInEnabledFolder = true; } } - if (!(item is UserRootFolder) + if (item is not UserRootFolder && !isInEnabledFolder && !user.HasPermission(PermissionKind.EnableAllFolders) - && !user.HasPermission(PermissionKind.EnableAllChannels)) + && !user.HasPermission(PermissionKind.EnableAllChannels) + && !string.Equals(collectionType, CollectionType.Folders, StringComparison.OrdinalIgnoreCase)) { _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) || !string.IsNullOrEmpty(ids) || !(item is UserRootFolder)) + if ((recursive.HasValue && recursive.Value) || ids.Length != 0 || !(item is UserRootFolder)) { var query = new InternalItemsQuery(user!) { IsPlayed = isPlayed, - MediaTypes = RequestHelpers.Split(mediaTypes, ',', true), - IncludeItemTypes = RequestHelpers.Split(includeItemTypes, ',', true), - ExcludeItemTypes = RequestHelpers.Split(excludeItemTypes, ',', true), + MediaTypes = mediaTypes, + IncludeItemTypes = RequestHelpers.GetItemTypeStrings(includeItemTypes), + ExcludeItemTypes = RequestHelpers.GetItemTypeStrings(excludeItemTypes), Recursive = recursive ?? false, OrderBy = RequestHelpers.GetOrderBy(sortBy, sortOrder), IsFavorite = isFavorite, @@ -330,28 +324,28 @@ namespace Jellyfin.Api.Controllers HasTrailer = hasTrailer, IsHD = isHd, Is4K = is4K, - Tags = RequestHelpers.Split(tags, '|', true), - OfficialRatings = RequestHelpers.Split(officialRatings, '|', true), - Genres = RequestHelpers.Split(genres, '|', true), - ArtistIds = RequestHelpers.GetGuids(artistIds), - AlbumArtistIds = RequestHelpers.GetGuids(albumArtistIds), - ContributingArtistIds = RequestHelpers.GetGuids(contributingArtistIds), - GenreIds = RequestHelpers.GetGuids(genreIds), - StudioIds = RequestHelpers.GetGuids(studioIds), + Tags = tags, + OfficialRatings = officialRatings, + Genres = genres, + ArtistIds = artistIds, + AlbumArtistIds = albumArtistIds, + ContributingArtistIds = contributingArtistIds, + GenreIds = genreIds, + StudioIds = studioIds, Person = person, - PersonIds = RequestHelpers.GetGuids(personIds), - PersonTypes = RequestHelpers.Split(personTypes, ',', true), - Years = RequestHelpers.Split(years, ',', true).Select(int.Parse).ToArray(), - ImageTypes = RequestHelpers.Split(imageTypes, ',', true).Select(v => Enum.Parse<ImageType>(v, true)).ToArray(), - VideoTypes = RequestHelpers.Split(videoTypes, ',', true).Select(v => Enum.Parse<VideoType>(v, true)).ToArray(), + PersonIds = personIds, + PersonTypes = personTypes, + Years = years, + ImageTypes = imageTypes, + VideoTypes = videoTypes, AdjacentTo = adjacentTo, - ItemIds = RequestHelpers.GetGuids(ids), + ItemIds = ids, MinCommunityRating = minCommunityRating, MinCriticRating = minCriticRating, - ParentId = string.IsNullOrWhiteSpace(parentId) ? Guid.Empty : new Guid(parentId), + ParentId = parentId ?? Guid.Empty, ParentIndexNumber = parentIndexNumber, EnableTotalRecordCount = enableTotalRecordCount, - ExcludeItemIds = RequestHelpers.GetGuids(excludeItemIds), + ExcludeItemIds = excludeItemIds, DtoOptions = dtoOptions, SearchTerm = searchTerm, MinDateLastSaved = minDateLastSaved?.ToUniversalTime(), @@ -360,7 +354,7 @@ namespace Jellyfin.Api.Controllers MaxPremiereDate = maxPremiereDate?.ToUniversalTime(), }; - if (!string.IsNullOrWhiteSpace(ids) || !string.IsNullOrWhiteSpace(searchTerm)) + if (ids.Length != 0 || !string.IsNullOrWhiteSpace(searchTerm)) { query.CollapseBoxSetItems = false; } @@ -400,9 +394,9 @@ namespace Jellyfin.Api.Controllers } // Filter by Series Status - if (!string.IsNullOrEmpty(seriesStatus)) + if (seriesStatus.Length != 0) { - query.SeriesStatuses = seriesStatus.Split(',').Select(d => (SeriesStatus)Enum.Parse(typeof(SeriesStatus), d, true)).ToArray(); + query.SeriesStatuses = seriesStatus; } // ExcludeLocationTypes @@ -411,13 +405,9 @@ namespace Jellyfin.Api.Controllers query.IsVirtualItem = false; } - if (!string.IsNullOrEmpty(locationTypes)) + if (locationTypes.Length > 0 && locationTypes.Length < 4) { - var requestedLocationTypes = locationTypes.Split(','); - if (requestedLocationTypes.Length > 0 && requestedLocationTypes.Length < 4) - { - query.IsVirtualItem = requestedLocationTypes.Contains(LocationType.Virtual.ToString()); - } + query.IsVirtualItem = locationTypes.Contains(LocationType.Virtual); } // Min official rating @@ -433,9 +423,9 @@ namespace Jellyfin.Api.Controllers } // Artists - if (!string.IsNullOrEmpty(artists)) + if (artists.Length != 0) { - query.ArtistIds = artists.Split('|').Select(i => + query.ArtistIds = artists.Select(i => { try { @@ -449,29 +439,29 @@ namespace Jellyfin.Api.Controllers } // ExcludeArtistIds - if (!string.IsNullOrWhiteSpace(excludeArtistIds)) + if (excludeArtistIds.Length != 0) { - query.ExcludeArtistIds = RequestHelpers.GetGuids(excludeArtistIds); + query.ExcludeArtistIds = excludeArtistIds; } - if (!string.IsNullOrWhiteSpace(albumIds)) + if (albumIds.Length != 0) { - query.AlbumIds = RequestHelpers.GetGuids(albumIds); + query.AlbumIds = albumIds; } // Albums - if (!string.IsNullOrEmpty(albums)) + if (albums.Length != 0) { - query.AlbumIds = albums.Split('|').SelectMany(i => + query.AlbumIds = albums.SelectMany(i => { return _libraryManager.GetItemIds(new InternalItemsQuery { IncludeItemTypes = new[] { nameof(MusicAlbum) }, Name = i, Limit = 1 }); }).ToArray(); } // Studios - if (!string.IsNullOrEmpty(studios)) + if (studios.Length != 0) { - query.StudioIds = studios.Split('|').Select(i => + query.StudioIds = studios.Select(i => { try { @@ -505,6 +495,257 @@ namespace Jellyfin.Api.Controllers return new QueryResult<BaseItemDto> { StartIndex = startIndex.GetValueOrDefault(), TotalRecordCount = result.TotalRecordCount, Items = _dtoService.GetBaseItemDtos(result.Items, dtoOptions, user) }; } + /// <summary> + /// Gets items based on a query. + /// </summary> + /// <param name="userId">The user id supplied as query parameter.</param> + /// <param name="maxOfficialRating">Optional filter by maximum official rating (PG, PG-13, TV-MA, etc).</param> + /// <param name="hasThemeSong">Optional filter by items with theme songs.</param> + /// <param name="hasThemeVideo">Optional filter by items with theme videos.</param> + /// <param name="hasSubtitles">Optional filter by items with subtitles.</param> + /// <param name="hasSpecialFeature">Optional filter by items with special features.</param> + /// <param name="hasTrailer">Optional filter by items with trailers.</param> + /// <param name="adjacentTo">Optional. Return items that are siblings of a supplied item.</param> + /// <param name="parentIndexNumber">Optional filter by parent index number.</param> + /// <param name="hasParentalRating">Optional filter by items that have or do not have a parental rating.</param> + /// <param name="isHd">Optional filter by items that are HD or not.</param> + /// <param name="is4K">Optional filter by items that are 4K or not.</param> + /// <param name="locationTypes">Optional. If specified, results will be filtered based on LocationType. This allows multiple, comma delimeted.</param> + /// <param name="excludeLocationTypes">Optional. If specified, results will be filtered based on the LocationType. This allows multiple, comma delimeted.</param> + /// <param name="isMissing">Optional filter by items that are missing episodes or not.</param> + /// <param name="isUnaired">Optional filter by items that are unaired episodes or not.</param> + /// <param name="minCommunityRating">Optional filter by minimum community rating.</param> + /// <param name="minCriticRating">Optional filter by minimum critic rating.</param> + /// <param name="minPremiereDate">Optional. The minimum premiere date. Format = ISO.</param> + /// <param name="minDateLastSaved">Optional. The minimum last saved date. Format = ISO.</param> + /// <param name="minDateLastSavedForUser">Optional. The minimum last saved date for the current user. Format = ISO.</param> + /// <param name="maxPremiereDate">Optional. The maximum premiere date. Format = ISO.</param> + /// <param name="hasOverview">Optional filter by items that have an overview or not.</param> + /// <param name="hasImdbId">Optional filter by items that have an imdb id or not.</param> + /// <param name="hasTmdbId">Optional filter by items that have a tmdb id or not.</param> + /// <param name="hasTvdbId">Optional filter by items that have a tvdb id or not.</param> + /// <param name="excludeItemIds">Optional. If specified, results will be filtered by exxcluding item ids. This allows multiple, comma delimeted.</param> + /// <param name="startIndex">Optional. The record index to start at. All items with a lower index will be dropped from the results.</param> + /// <param name="limit">Optional. The maximum number of records to return.</param> + /// <param name="recursive">When searching within folders, this determines whether or not the search will be recursive. true/false.</param> + /// <param name="searchTerm">Optional. Filter based on a search term.</param> + /// <param name="sortOrder">Sort Order - Ascending,Descending.</param> + /// <param name="parentId">Specify this to localize the search to a specific item or folder. Omit to use the root.</param> + /// <param name="fields">Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimeted. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines.</param> + /// <param name="excludeItemTypes">Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimeted.</param> + /// <param name="includeItemTypes">Optional. If specified, results will be filtered based on the item type. This allows multiple, comma delimeted.</param> + /// <param name="filters">Optional. Specify additional filters to apply. This allows multiple, comma delimeted. Options: IsFolder, IsNotFolder, IsUnplayed, IsPlayed, IsFavorite, IsResumable, Likes, Dislikes.</param> + /// <param name="isFavorite">Optional filter by items that are marked as favorite, or not.</param> + /// <param name="mediaTypes">Optional filter by MediaType. Allows multiple, comma delimited.</param> + /// <param name="imageTypes">Optional. If specified, results will be filtered based on those containing image types. This allows multiple, comma delimited.</param> + /// <param name="sortBy">Optional. Specify one or more sort orders, comma delimeted. Options: Album, AlbumArtist, Artist, Budget, CommunityRating, CriticRating, DateCreated, DatePlayed, PlayCount, PremiereDate, ProductionYear, SortName, Random, Revenue, Runtime.</param> + /// <param name="isPlayed">Optional filter by items that are played, or not.</param> + /// <param name="genres">Optional. If specified, results will be filtered based on genre. This allows multiple, pipe delimeted.</param> + /// <param name="officialRatings">Optional. If specified, results will be filtered based on OfficialRating. This allows multiple, pipe delimeted.</param> + /// <param name="tags">Optional. If specified, results will be filtered based on tag. This allows multiple, pipe delimeted.</param> + /// <param name="years">Optional. If specified, results will be filtered based on production year. This allows multiple, comma delimeted.</param> + /// <param name="enableUserData">Optional, include user data.</param> + /// <param name="imageTypeLimit">Optional, the max number of images to return, per image type.</param> + /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> + /// <param name="person">Optional. If specified, results will be filtered to include only those containing the specified person.</param> + /// <param name="personIds">Optional. If specified, results will be filtered to include only those containing the specified person id.</param> + /// <param name="personTypes">Optional. If specified, along with Person, results will be filtered to include only those containing the specified person and PersonType. Allows multiple, comma-delimited.</param> + /// <param name="studios">Optional. If specified, results will be filtered based on studio. This allows multiple, pipe delimeted.</param> + /// <param name="artists">Optional. If specified, results will be filtered based on artists. This allows multiple, pipe delimeted.</param> + /// <param name="excludeArtistIds">Optional. If specified, results will be filtered based on artist id. This allows multiple, pipe delimeted.</param> + /// <param name="artistIds">Optional. If specified, results will be filtered to include only those containing the specified artist id.</param> + /// <param name="albumArtistIds">Optional. If specified, results will be filtered to include only those containing the specified album artist id.</param> + /// <param name="contributingArtistIds">Optional. If specified, results will be filtered to include only those containing the specified contributing artist id.</param> + /// <param name="albums">Optional. If specified, results will be filtered based on album. This allows multiple, pipe delimeted.</param> + /// <param name="albumIds">Optional. If specified, results will be filtered based on album id. This allows multiple, pipe delimeted.</param> + /// <param name="ids">Optional. If specific items are needed, specify a list of item id's to retrieve. This allows multiple, comma delimited.</param> + /// <param name="videoTypes">Optional filter by VideoType (videofile, dvd, bluray, iso). Allows multiple, comma delimeted.</param> + /// <param name="minOfficialRating">Optional filter by minimum official rating (PG, PG-13, TV-MA, etc).</param> + /// <param name="isLocked">Optional filter by items that are locked.</param> + /// <param name="isPlaceHolder">Optional filter by items that are placeholders.</param> + /// <param name="hasOfficialRating">Optional filter by items that have official ratings.</param> + /// <param name="collapseBoxSetItems">Whether or not to hide items behind their boxsets.</param> + /// <param name="minWidth">Optional. Filter by the minimum width of the item.</param> + /// <param name="minHeight">Optional. Filter by the minimum height of the item.</param> + /// <param name="maxWidth">Optional. Filter by the maximum width of the item.</param> + /// <param name="maxHeight">Optional. Filter by the maximum height of the item.</param> + /// <param name="is3D">Optional filter by items that are 3D, or not.</param> + /// <param name="seriesStatus">Optional filter by Series Status. Allows multiple, comma delimeted.</param> + /// <param name="nameStartsWithOrGreater">Optional filter by items whose name is sorted equally or greater than a given input string.</param> + /// <param name="nameStartsWith">Optional filter by items whose name is sorted equally than a given input string.</param> + /// <param name="nameLessThan">Optional filter by items whose name is equally or lesser than a given input string.</param> + /// <param name="studioIds">Optional. If specified, results will be filtered based on studio id. This allows multiple, pipe delimeted.</param> + /// <param name="genreIds">Optional. If specified, results will be filtered based on genre id. This allows multiple, pipe delimeted.</param> + /// <param name="enableTotalRecordCount">Optional. Enable the total record count.</param> + /// <param name="enableImages">Optional, include image information in output.</param> + /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the items.</returns> + [HttpGet("Users/{userId}/Items")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<QueryResult<BaseItemDto>> GetItemsByUserId( + [FromRoute] Guid userId, + [FromQuery] string? maxOfficialRating, + [FromQuery] bool? hasThemeSong, + [FromQuery] bool? hasThemeVideo, + [FromQuery] bool? hasSubtitles, + [FromQuery] bool? hasSpecialFeature, + [FromQuery] bool? hasTrailer, + [FromQuery] string? 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, 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, + 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); + } + /// <summary> /// Gets items based on a query. /// </summary> @@ -513,13 +754,13 @@ namespace Jellyfin.Api.Controllers /// <param name="limit">The item limit.</param> /// <param name="searchTerm">The search term.</param> /// <param name="parentId">Specify this to localize the search to a specific item or folder. Omit to use the root.</param> - /// <param name="fields">Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimeted. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines.</param> + /// <param name="fields">Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimited. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines.</param> /// <param name="mediaTypes">Optional. Filter by MediaType. Allows multiple, comma delimited.</param> /// <param name="enableUserData">Optional. Include user data.</param> /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param> /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> - /// <param name="excludeItemTypes">Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimeted.</param> - /// <param name="includeItemTypes">Optional. If specified, results will be filtered based on the item type. This allows multiple, comma delimeted.</param> + /// <param name="excludeItemTypes">Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimited.</param> + /// <param name="includeItemTypes">Optional. If specified, results will be filtered based on the item type. This allows multiple, comma delimited.</param> /// <param name="enableTotalRecordCount">Optional. Enable the total record count.</param> /// <param name="enableImages">Optional. Include image information in output.</param> /// <response code="200">Items returned.</response> @@ -531,32 +772,31 @@ namespace Jellyfin.Api.Controllers [FromQuery] int? startIndex, [FromQuery] int? limit, [FromQuery] string? searchTerm, - [FromQuery] string? parentId, - [FromQuery] string? fields, - [FromQuery] string? mediaTypes, + [FromQuery] Guid? parentId, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] mediaTypes, [FromQuery] bool? enableUserData, [FromQuery] int? imageTypeLimit, - [FromQuery] string? enableImageTypes, - [FromQuery] string? excludeItemTypes, - [FromQuery] string? includeItemTypes, + [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) { var user = _userManager.GetUserById(userId); - var parentIdGuid = string.IsNullOrWhiteSpace(parentId) ? Guid.Empty : new Guid(parentId); - var dtoOptions = new DtoOptions() - .AddItemFields(fields) + var parentIdGuid = parentId ?? Guid.Empty; + var dtoOptions = new DtoOptions { Fields = fields } .AddClientFields(Request) .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); var ancestorIds = Array.Empty<Guid>(); - var excludeFolderIds = user.GetPreference(PreferenceKind.LatestItemExcludes); + var excludeFolderIds = user.GetPreferenceValues<Guid>(PreferenceKind.LatestItemExcludes); if (parentIdGuid.Equals(Guid.Empty) && excludeFolderIds.Length > 0) { ancestorIds = _libraryManager.GetUserRootFolder().GetChildren(user, true) .Where(i => i is Folder) - .Where(i => !excludeFolderIds.Contains(i.Id.ToString("N", CultureInfo.InvariantCulture))) + .Where(i => !excludeFolderIds.Contains(i.Id)) .Select(i => i.Id) .ToArray(); } @@ -570,13 +810,13 @@ namespace Jellyfin.Api.Controllers ParentId = parentIdGuid, Recursive = true, DtoOptions = dtoOptions, - MediaTypes = RequestHelpers.Split(mediaTypes, ',', true), + MediaTypes = mediaTypes, IsVirtualItem = false, CollapseBoxSetItems = false, EnableTotalRecordCount = enableTotalRecordCount, AncestorIds = ancestorIds, - IncludeItemTypes = RequestHelpers.Split(includeItemTypes, ',', true), - ExcludeItemTypes = RequestHelpers.Split(excludeItemTypes, ',', true), + IncludeItemTypes = RequestHelpers.GetItemTypeStrings(includeItemTypes), + ExcludeItemTypes = RequestHelpers.GetItemTypeStrings(excludeItemTypes), SearchTerm = searchTerm }); diff --git a/Jellyfin.Api/Controllers/LibraryController.cs b/Jellyfin.Api/Controllers/LibraryController.cs index 8a872ae133..4ed15e1d51 100644 --- a/Jellyfin.Api/Controllers/LibraryController.cs +++ b/Jellyfin.Api/Controllers/LibraryController.cs @@ -11,7 +11,7 @@ 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 MediaBrowser.Common.Progress; @@ -114,7 +114,7 @@ namespace Jellyfin.Api.Controllers return NotFound(); } - return PhysicalFile(item.Path, MimeTypes.GetMimeType(item.Path)); + return PhysicalFile(item.Path, MimeTypes.GetMimeType(item.Path), true); } /// <summary> @@ -303,7 +303,7 @@ namespace Jellyfin.Api.Controllers /// </summary> /// <response code="204">Library scan started.</response> /// <returns>A <see cref="NoContentResult"/>.</returns> - [HttpGet("Library/Refresh")] + [HttpPost("Library/Refresh")] [Authorize(Policy = Policies.RequiresElevation)] [ProducesResponseType(StatusCodes.Status204NoContent)] public async Task<ActionResult> RefreshLibrary() @@ -361,15 +361,14 @@ namespace Jellyfin.Api.Controllers [Authorize(Policy = Policies.DefaultAuthorization)] [ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status401Unauthorized)] - public ActionResult DeleteItems([FromQuery] string? ids) + public ActionResult DeleteItems([FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] ids) { - if (string.IsNullOrEmpty(ids)) + if (ids.Length == 0) { return NoContent(); } - var itemIds = RequestHelpers.Split(ids, ',', true); - foreach (var i in itemIds) + foreach (var i in ids) { var item = _libraryManager.GetItemById(i); var auth = _authContext.GetAuthorizationInfo(Request); @@ -455,7 +454,7 @@ namespace Jellyfin.Api.Controllers : null; var dtoOptions = new DtoOptions().AddClientFields(Request); - BaseItem parent = item.GetParent(); + BaseItem? parent = item.GetParent(); while (parent != null) { @@ -466,7 +465,7 @@ namespace Jellyfin.Api.Controllers baseItemDtos.Add(_dtoService.GetBaseItemDto(parent, dtoOptions, user)); - parent = parent.GetParent(); + parent = parent?.GetParent(); } return baseItemDtos; @@ -591,17 +590,17 @@ namespace Jellyfin.Api.Controllers /// <summary> /// Reports that new movies have been added by an external source. /// </summary> - /// <param name="updates">A list of updated media paths.</param> + /// <param name="dto">The update paths.</param> /// <response code="204">Report success.</response> /// <returns>A <see cref="NoContentResult"/>.</returns> [HttpPost("Library/Media/Updated")] [Authorize(Policy = Policies.DefaultAuthorization)] [ProducesResponseType(StatusCodes.Status204NoContent)] - public ActionResult PostUpdatedMedia([FromBody, Required] MediaUpdateInfoDto[] updates) + public ActionResult PostUpdatedMedia([FromBody, Required] MediaUpdateInfoDto dto) { - foreach (var item in updates) + foreach (var item in dto.Updates) { - _libraryMonitor.ReportFileSystemChanged(item.Path); + _libraryMonitor.ReportFileSystemChanged(item.Path ?? throw new ArgumentException("Item path can't be null.")); } return NoContent(); @@ -667,7 +666,7 @@ namespace Jellyfin.Api.Controllers } // TODO determine non-ASCII validity. - return PhysicalFile(path, MimeTypes.GetMimeType(path)); + return PhysicalFile(path, MimeTypes.GetMimeType(path), filename, true); } /// <summary> @@ -680,20 +679,20 @@ namespace Jellyfin.Api.Controllers /// <param name="fields">Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimited. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines, TrailerUrls.</param> /// <response code="200">Similar items returned.</response> /// <returns>A <see cref="QueryResult{BaseItemDto}"/> containing the similar items.</returns> - [HttpGet("Artists/{itemId}/Similar", Name = "GetSimilarArtists2")] + [HttpGet("Artists/{itemId}/Similar", Name = "GetSimilarArtists")] [HttpGet("Items/{itemId}/Similar")] - [HttpGet("Albums/{itemId}/Similar", Name = "GetSimilarAlbums2")] - [HttpGet("Shows/{itemId}/Similar", Name = "GetSimilarShows2")] - [HttpGet("Movies/{itemId}/Similar", Name = "GetSimilarMovies2")] - [HttpGet("Trailers/{itemId}/Similar", Name = "GetSimilarTrailers2")] + [HttpGet("Albums/{itemId}/Similar", Name = "GetSimilarAlbums")] + [HttpGet("Shows/{itemId}/Similar", Name = "GetSimilarShows")] + [HttpGet("Movies/{itemId}/Similar", Name = "GetSimilarMovies")] + [HttpGet("Trailers/{itemId}/Similar", Name = "GetSimilarTrailers")] [Authorize(Policy = Policies.DefaultAuthorization)] [ProducesResponseType(StatusCodes.Status200OK)] public ActionResult<QueryResult<BaseItemDto>> GetSimilarItems( [FromRoute, Required] Guid itemId, - [FromQuery] string? excludeArtistIds, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] excludeArtistIds, [FromQuery] Guid? userId, [FromQuery] int? limit, - [FromQuery] string? fields) + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields) { var item = itemId.Equals(Guid.Empty) ? (!userId.Equals(Guid.Empty) @@ -701,33 +700,69 @@ namespace Jellyfin.Api.Controllers : _libraryManager.RootFolder) : _libraryManager.GetItemById(itemId); + if (item is Episode || (item is IItemByName && !(item is MusicArtist))) + { + return new QueryResult<BaseItemDto>(); + } + + var user = userId.HasValue && !userId.Equals(Guid.Empty) + ? _userManager.GetUserById(userId.Value) + : null; + var dtoOptions = new DtoOptions { Fields = fields } + .AddClientFields(Request); + var program = item as IHasProgramAttributes; - var isMovie = item is MediaBrowser.Controller.Entities.Movies.Movie || (program != null && program.IsMovie) || item is Trailer; - if (program != null && program.IsSeries) + bool? isMovie = item is Movie || (program != null && program.IsMovie) || item is Trailer; + bool? isSeries = item is Series || (program != null && program.IsSeries); + + var includeItemTypes = new List<string>(); + if (isMovie.Value) { - return GetSimilarItemsResult( - item, - excludeArtistIds, - userId, - limit, - fields, - new[] { nameof(Series) }, - false); + includeItemTypes.Add(nameof(Movie)); + if (_serverConfigurationManager.Configuration.EnableExternalContentInSuggestions) + { + includeItemTypes.Add(nameof(Trailer)); + includeItemTypes.Add(nameof(LiveTvProgram)); + } + } + else if (isSeries.Value) + { + includeItemTypes.Add(nameof(Series)); + } + else + { + // For non series and movie types these columns are typically null + isSeries = null; + isMovie = null; + includeItemTypes.Add(item.GetType().Name); } - if (item is MediaBrowser.Controller.Entities.TV.Episode || (item is IItemByName && !(item is MusicArtist))) + var query = new InternalItemsQuery(user) { - return new QueryResult<BaseItemDto>(); + 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; } - return GetSimilarItemsResult( - item, - excludeArtistIds, - userId, - limit, - fields, - new[] { item.GetType().Name }, - isMovie); + List<BaseItem> itemsResult = _libraryManager.GetItemList(query); + + var returnList = _dtoService.GetBaseItemDtos(itemsResult, dtoOptions, user); + + return new QueryResult<BaseItemDto> + { + Items = returnList, + TotalRecordCount = itemsResult.Count + }; } /// <summary> @@ -742,7 +777,7 @@ namespace Jellyfin.Api.Controllers [ProducesResponseType(StatusCodes.Status200OK)] public ActionResult<LibraryOptionsResultDto> GetLibraryOptionsInfo( [FromQuery] string? libraryContentType, - [FromQuery] bool isNewLibrary) + [FromQuery] bool isNewLibrary = false) { var result = new LibraryOptionsResultDto(); @@ -854,7 +889,7 @@ namespace Jellyfin.Api.Controllers return _libraryManager.GetItemsResult(query).TotalRecordCount; } - private BaseItem TranslateParentItem(BaseItem item, User user) + private BaseItem? TranslateParentItem(BaseItem item, User user) { return item.GetParent() is AggregateFolder ? _libraryManager.GetUserRootFolder().GetChildren(user, true) @@ -880,75 +915,6 @@ namespace Jellyfin.Api.Controllers } } - private QueryResult<BaseItemDto> GetSimilarItemsResult( - BaseItem item, - string? excludeArtistIds, - Guid? userId, - int? limit, - string? fields, - string[] includeItemTypes, - bool isMovie) - { - var user = userId.HasValue && !userId.Equals(Guid.Empty) - ? _userManager.GetUserById(userId.Value) - : null; - var dtoOptions = new DtoOptions() - .AddItemFields(fields) - .AddClientFields(Request); - - var query = new InternalItemsQuery(user) - { - Limit = limit, - IncludeItemTypes = includeItemTypes, - IsMovie = isMovie, - SimilarTo = item, - DtoOptions = dtoOptions, - EnableTotalRecordCount = !isMovie, - EnableGroupByMetadataKey = isMovie - }; - - // ExcludeArtistIds - if (!string.IsNullOrEmpty(excludeArtistIds)) - { - query.ExcludeArtistIds = RequestHelpers.GetGuids(excludeArtistIds); - } - - List<BaseItem> itemsResult; - - if (isMovie) - { - var itemTypes = new List<string> { nameof(MediaBrowser.Controller.Entities.Movies.Movie) }; - if (_serverConfigurationManager.Configuration.EnableExternalContentInSuggestions) - { - itemTypes.Add(nameof(Trailer)); - itemTypes.Add(nameof(LiveTvProgram)); - } - - query.IncludeItemTypes = itemTypes.ToArray(); - itemsResult = _libraryManager.GetArtists(query).Items.Select(i => i.Item1).ToList(); - } - else if (item is MusicArtist) - { - query.IncludeItemTypes = Array.Empty<string>(); - - itemsResult = _libraryManager.GetArtists(query).Items.Select(i => i.Item1).ToList(); - } - else - { - itemsResult = _libraryManager.GetItemList(query); - } - - var returnList = _dtoService.GetBaseItemDtos(itemsResult, dtoOptions, user); - - var result = new QueryResult<BaseItemDto> - { - Items = returnList, - TotalRecordCount = itemsResult.Count - }; - - return result; - } - private static string[] GetRepresentativeItemTypes(string? contentType) { return contentType switch diff --git a/Jellyfin.Api/Controllers/LibraryStructureController.cs b/Jellyfin.Api/Controllers/LibraryStructureController.cs index d290e3c5bd..be9127dd39 100644 --- a/Jellyfin.Api/Controllers/LibraryStructureController.cs +++ b/Jellyfin.Api/Controllers/LibraryStructureController.cs @@ -7,6 +7,7 @@ using System.Linq; using System.Threading; using System.Threading.Tasks; using Jellyfin.Api.Constants; +using Jellyfin.Api.ModelBinders; using Jellyfin.Api.Models.LibraryStructureDto; using MediaBrowser.Common.Progress; using MediaBrowser.Controller; @@ -74,8 +75,8 @@ namespace Jellyfin.Api.Controllers [ProducesResponseType(StatusCodes.Status204NoContent)] public async Task<ActionResult> AddVirtualFolder( [FromQuery] string? name, - [FromQuery] string? collectionType, - [FromQuery] string[] paths, + [FromQuery] CollectionTypeOptions? collectionType, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] paths, [FromBody] AddVirtualFolderDto? libraryOptionsDto, [FromQuery] bool refreshLibrary = false) { @@ -240,23 +241,20 @@ namespace Jellyfin.Api.Controllers /// <summary> /// Updates a media path. /// </summary> - /// <param name="name">The name of the library.</param> - /// <param name="pathInfo">The path info.</param> + /// <param name="mediaPathRequestDto">The name of the library and path infos.</param> /// <returns>A <see cref="NoContentResult"/>.</returns> /// <response code="204">Media path updated.</response> /// <exception cref="ArgumentNullException">The name of the library may not be empty.</exception> [HttpPost("Paths/Update")] [ProducesResponseType(StatusCodes.Status204NoContent)] - public ActionResult UpdateMediaPath( - [FromQuery] string? name, - [FromBody] MediaPathInfo? pathInfo) + public ActionResult UpdateMediaPath([FromBody, Required] UpdateMediaPathRequestDto mediaPathRequestDto) { - if (string.IsNullOrWhiteSpace(name)) + if (string.IsNullOrWhiteSpace(mediaPathRequestDto.Name)) { - throw new ArgumentNullException(nameof(name)); + throw new ArgumentNullException(nameof(mediaPathRequestDto), "Name must not be null or empty"); } - _libraryManager.UpdateMediaPath(name, pathInfo); + _libraryManager.UpdateMediaPath(mediaPathRequestDto.Name, mediaPathRequestDto.PathInfo); return NoContent(); } diff --git a/Jellyfin.Api/Controllers/LiveTvController.cs b/Jellyfin.Api/Controllers/LiveTvController.cs index 3557e63047..24ee833ef7 100644 --- a/Jellyfin.Api/Controllers/LiveTvController.cs +++ b/Jellyfin.Api/Controllers/LiveTvController.cs @@ -14,9 +14,9 @@ using Jellyfin.Api.Attributes; using Jellyfin.Api.Constants; using Jellyfin.Api.Extensions; using Jellyfin.Api.Helpers; +using Jellyfin.Api.ModelBinders; using Jellyfin.Api.Models.LiveTvDtos; using Jellyfin.Data.Enums; -using MediaBrowser.Common; using MediaBrowser.Common.Configuration; using MediaBrowser.Common.Net; using MediaBrowser.Controller.Dto; @@ -26,6 +26,7 @@ using MediaBrowser.Controller.Library; using MediaBrowser.Controller.LiveTv; using MediaBrowser.Controller.Net; using MediaBrowser.Model.Dto; +using MediaBrowser.Model.Entities; using MediaBrowser.Model.LiveTv; using MediaBrowser.Model.Net; using MediaBrowser.Model.Querying; @@ -117,7 +118,7 @@ namespace Jellyfin.Api.Controllers /// <param name="enableImages">Optional. Include image information in output.</param> /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param> /// <param name="enableImageTypes">"Optional. The image types to include in the output.</param> - /// <param name="fields">Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimited. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines.</param> + /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param> /// <param name="enableUserData">Optional. Include user data.</param> /// <param name="sortBy">Optional. Key to sort by.</param> /// <param name="sortOrder">Optional. Sort order.</param> @@ -145,16 +146,15 @@ namespace Jellyfin.Api.Controllers [FromQuery] bool? isDisliked, [FromQuery] bool? enableImages, [FromQuery] int? imageTypeLimit, - [FromQuery] string? enableImageTypes, - [FromQuery] string? fields, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, [FromQuery] bool? enableUserData, - [FromQuery] string? sortBy, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] sortBy, [FromQuery] SortOrder? sortOrder, [FromQuery] bool enableFavoriteSorting = false, [FromQuery] bool addCurrentProgram = true) { - var dtoOptions = new DtoOptions() - .AddItemFields(fields) + var dtoOptions = new DtoOptions { Fields = fields } .AddClientFields(Request) .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); @@ -174,7 +174,7 @@ namespace Jellyfin.Api.Controllers IsNews = isNews, IsKids = isKids, IsSports = isSports, - SortBy = RequestHelpers.Split(sortBy, ',', true), + SortBy = sortBy, SortOrder = sortOrder ?? SortOrder.Ascending, AddCurrentProgram = addCurrentProgram }, @@ -238,7 +238,7 @@ namespace Jellyfin.Api.Controllers /// <param name="enableImages">Optional. Include image information in output.</param> /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param> /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> - /// <param name="fields">Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimited. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines.</param> + /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param> /// <param name="enableUserData">Optional. Include user data.</param> /// <param name="isMovie">Optional. Filter for movies.</param> /// <param name="isSeries">Optional. Filter for series.</param> @@ -262,8 +262,8 @@ namespace Jellyfin.Api.Controllers [FromQuery] string? seriesTimerId, [FromQuery] bool? enableImages, [FromQuery] int? imageTypeLimit, - [FromQuery] string? enableImageTypes, - [FromQuery] string? fields, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, [FromQuery] bool? enableUserData, [FromQuery] bool? isMovie, [FromQuery] bool? isSeries, @@ -273,8 +273,7 @@ namespace Jellyfin.Api.Controllers [FromQuery] bool? isLibraryItem, [FromQuery] bool enableTotalRecordCount = true) { - var dtoOptions = new DtoOptions() - .AddItemFields(fields) + var dtoOptions = new DtoOptions { Fields = fields } .AddClientFields(Request) .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); @@ -295,7 +294,7 @@ namespace Jellyfin.Api.Controllers IsKids = isKids, IsSports = isSports, IsLibraryItem = isLibraryItem, - Fields = RequestHelpers.GetItemFields(fields), + Fields = fields, ImageTypeLimit = imageTypeLimit, EnableImages = enableImages }, dtoOptions); @@ -315,7 +314,7 @@ namespace Jellyfin.Api.Controllers /// <param name="enableImages">Optional. Include image information in output.</param> /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param> /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> - /// <param name="fields">Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimited. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines.</param> + /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param> /// <param name="enableUserData">Optional. Include user data.</param> /// <param name="enableTotalRecordCount">Optional. Return total record count.</param> /// <response code="200">Live tv recordings returned.</response> @@ -349,8 +348,8 @@ namespace Jellyfin.Api.Controllers [FromQuery] string? seriesTimerId, [FromQuery] bool? enableImages, [FromQuery] int? imageTypeLimit, - [FromQuery] string? enableImageTypes, - [FromQuery] string? fields, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, [FromQuery] bool? enableUserData, [FromQuery] bool enableTotalRecordCount = true) { @@ -529,7 +528,7 @@ namespace Jellyfin.Api.Controllers /// <param name="enableUserData">Optional. Include user data.</param> /// <param name="seriesTimerId">Optional. Filter by series timer id.</param> /// <param name="librarySeriesId">Optional. Filter by library series id.</param> - /// <param name="fields">Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimited. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines.</param> + /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param> /// <param name="enableTotalRecordCount">Retrieve total record count.</param> /// <response code="200">Live tv epgs returned.</response> /// <returns> @@ -539,7 +538,7 @@ namespace Jellyfin.Api.Controllers [ProducesResponseType(StatusCodes.Status200OK)] [Authorize(Policy = Policies.DefaultAuthorization)] public async Task<ActionResult<QueryResult<BaseItemDto>>> GetLiveTvPrograms( - [FromQuery] string? channelIds, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] channelIds, [FromQuery] Guid? userId, [FromQuery] DateTime? minStartDate, [FromQuery] bool? hasAired, @@ -554,17 +553,17 @@ namespace Jellyfin.Api.Controllers [FromQuery] bool? isSports, [FromQuery] int? startIndex, [FromQuery] int? limit, - [FromQuery] string? sortBy, - [FromQuery] string? sortOrder, - [FromQuery] string? genres, - [FromQuery] string? genreIds, + [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] string? enableImageTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes, [FromQuery] bool? enableUserData, [FromQuery] string? seriesTimerId, [FromQuery] Guid? librarySeriesId, - [FromQuery] string? fields, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, [FromQuery] bool enableTotalRecordCount = true) { var user = userId.HasValue && !userId.Equals(Guid.Empty) @@ -573,8 +572,7 @@ namespace Jellyfin.Api.Controllers var query = new InternalItemsQuery(user) { - ChannelIds = RequestHelpers.Split(channelIds, ',', true) - .Select(i => new Guid(i)).ToArray(), + ChannelIds = channelIds, HasAired = hasAired, IsAiring = isAiring, EnableTotalRecordCount = enableTotalRecordCount, @@ -591,8 +589,8 @@ namespace Jellyfin.Api.Controllers IsKids = isKids, IsSports = isSports, SeriesTimerId = seriesTimerId, - Genres = RequestHelpers.Split(genres, ',', true), - GenreIds = RequestHelpers.GetGuids(genreIds) + Genres = genres, + GenreIds = genreIds }; if (librarySeriesId != null && !librarySeriesId.Equals(Guid.Empty)) @@ -605,8 +603,7 @@ namespace Jellyfin.Api.Controllers } } - var dtoOptions = new DtoOptions() - .AddItemFields(fields) + var dtoOptions = new DtoOptions { Fields = fields } .AddClientFields(Request) .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); return await _liveTvManager.GetPrograms(query, dtoOptions, CancellationToken.None).ConfigureAwait(false); @@ -629,8 +626,7 @@ namespace Jellyfin.Api.Controllers var query = new InternalItemsQuery(user) { - ChannelIds = RequestHelpers.Split(body.ChannelIds, ',', true) - .Select(i => new Guid(i)).ToArray(), + ChannelIds = body.ChannelIds, HasAired = body.HasAired, IsAiring = body.IsAiring, EnableTotalRecordCount = body.EnableTotalRecordCount, @@ -647,8 +643,8 @@ namespace Jellyfin.Api.Controllers IsKids = body.IsKids, IsSports = body.IsSports, SeriesTimerId = body.SeriesTimerId, - Genres = RequestHelpers.Split(body.Genres, ',', true), - GenreIds = RequestHelpers.GetGuids(body.GenreIds) + Genres = body.Genres, + GenreIds = body.GenreIds }; if (!body.LibrarySeriesId.Equals(Guid.Empty)) @@ -661,8 +657,7 @@ namespace Jellyfin.Api.Controllers } } - var dtoOptions = new DtoOptions() - .AddItemFields(body.Fields) + var dtoOptions = new DtoOptions { Fields = body.Fields } .AddClientFields(Request) .AddAdditionalDtoOptions(body.EnableImages, body.EnableUserData, body.ImageTypeLimit, body.EnableImageTypes); return await _liveTvManager.GetPrograms(query, dtoOptions, CancellationToken.None).ConfigureAwait(false); @@ -684,7 +679,7 @@ namespace Jellyfin.Api.Controllers /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param> /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> /// <param name="genreIds">The genres to return guide information for.</param> - /// <param name="fields">Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimited. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines.</param> + /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param> /// <param name="enableUserData">Optional. include user data.</param> /// <param name="enableTotalRecordCount">Retrieve total record count.</param> /// <response code="200">Recommended epgs returned.</response> @@ -704,9 +699,9 @@ namespace Jellyfin.Api.Controllers [FromQuery] bool? isSports, [FromQuery] bool? enableImages, [FromQuery] int? imageTypeLimit, - [FromQuery] string? enableImageTypes, - [FromQuery] string? genreIds, - [FromQuery] string? fields, + [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) { @@ -725,11 +720,10 @@ namespace Jellyfin.Api.Controllers IsNews = isNews, IsSports = isSports, EnableTotalRecordCount = enableTotalRecordCount, - GenreIds = RequestHelpers.GetGuids(genreIds) + GenreIds = genreIds }; - var dtoOptions = new DtoOptions() - .AddItemFields(fields) + var dtoOptions = new DtoOptions { Fields = fields } .AddClientFields(Request) .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); return _liveTvManager.GetRecommendedPrograms(query, dtoOptions, CancellationToken.None); @@ -1020,7 +1014,9 @@ namespace Jellyfin.Api.Controllers if (!string.IsNullOrEmpty(pw)) { using var sha = SHA1.Create(); - listingsProviderInfo.Password = Hex.Encode(sha.ComputeHash(Encoding.UTF8.GetBytes(pw))); + // TODO: remove ToLower when Convert.ToHexString supports lowercase + // Schedules Direct requires the hex to be lowercase + listingsProviderInfo.Password = Convert.ToHexString(sha.ComputeHash(Encoding.UTF8.GetBytes(pw))).ToLowerInvariant(); } return await _liveTvManager.SaveListingProvider(listingsProviderInfo, validateLogin, validateListings).ConfigureAwait(false); @@ -1076,7 +1072,7 @@ namespace Jellyfin.Api.Controllers 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("https://json.schedulesdirect.org/20141201/available/countries") + 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); @@ -1123,20 +1119,15 @@ namespace Jellyfin.Api.Controllers /// <summary> /// Set channel mappings. /// </summary> - /// <param name="providerId">Provider id.</param> - /// <param name="tunerChannelId">Tuner channel id.</param> - /// <param name="providerChannelId">Provider channel id.</param> + /// <param name="setChannelMappingDto">The set channel mapping dto.</param> /// <response code="200">Created channel mapping returned.</response> /// <returns>An <see cref="OkResult"/> containing the created channel mapping.</returns> [HttpPost("ChannelMappings")] [Authorize(Policy = Policies.DefaultAuthorization)] [ProducesResponseType(StatusCodes.Status200OK)] - public async Task<ActionResult<TunerChannelMapping>> SetChannelMapping( - [FromQuery] string? providerId, - [FromQuery] string? tunerChannelId, - [FromQuery] string? providerChannelId) + public async Task<ActionResult<TunerChannelMapping>> SetChannelMapping([FromBody, Required] SetChannelMappingDto setChannelMappingDto) { - return await _liveTvManager.SetChannelMapping(providerId, tunerChannelId, providerChannelId).ConfigureAwait(false); + return await _liveTvManager.SetChannelMapping(setChannelMappingDto.ProviderId, setChannelMappingDto.TunerChannelId, setChannelMappingDto.ProviderChannelId).ConfigureAwait(false); } /// <summary> @@ -1158,7 +1149,8 @@ namespace Jellyfin.Api.Controllers /// <param name="newDevicesOnly">Only discover new tuners.</param> /// <response code="200">Tuners returned.</response> /// <returns>An <see cref="OkResult"/> containing the tuners.</returns> - [HttpGet("Tuners/Discvover")] + [HttpGet("Tuners/Discvover", Name = "DiscvoverTuners")] + [HttpGet("Tuners/Discover")] [Authorize(Policy = Policies.DefaultAuthorization)] [ProducesResponseType(StatusCodes.Status200OK)] public async Task<ActionResult<IEnumerable<TunerHostInfo>>> DiscoverTuners([FromQuery] bool newDevicesOnly = false) @@ -1219,11 +1211,8 @@ namespace Jellyfin.Api.Controllers return NotFound(); } - await using var memoryStream = new MemoryStream(); - await new ProgressiveFileCopier(liveStreamInfo, null, _transcodingJobHelper, CancellationToken.None) - .WriteToAsync(memoryStream, CancellationToken.None) - .ConfigureAwait(false); - return File(memoryStream, MimeTypes.GetMimeType("file." + container)); + var liveStream = new ProgressiveFileStream(liveStreamInfo.GetFilePath(), null, _transcodingJobHelper); + return new FileStreamResult(liveStream, MimeTypes.GetMimeType("file." + container)); } private void AssertUserCanManageLiveTv() diff --git a/Jellyfin.Api/Controllers/LocalizationController.cs b/Jellyfin.Api/Controllers/LocalizationController.cs index ef2e7e8b15..3d8b9e0cac 100644 --- a/Jellyfin.Api/Controllers/LocalizationController.cs +++ b/Jellyfin.Api/Controllers/LocalizationController.cs @@ -1,4 +1,4 @@ -using System.Collections.Generic; +using System.Collections.Generic; using Jellyfin.Api.Constants; using MediaBrowser.Model.Entities; using MediaBrowser.Model.Globalization; diff --git a/Jellyfin.Api/Controllers/MediaInfoController.cs b/Jellyfin.Api/Controllers/MediaInfoController.cs index 4c21999b1b..8903e0ce89 100644 --- a/Jellyfin.Api/Controllers/MediaInfoController.cs +++ b/Jellyfin.Api/Controllers/MediaInfoController.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Buffers; using System.ComponentModel.DataAnnotations; using System.Linq; @@ -8,7 +8,6 @@ using Jellyfin.Api.Attributes; using Jellyfin.Api.Constants; using Jellyfin.Api.Helpers; using Jellyfin.Api.Models.MediaInfoDtos; -using Jellyfin.Api.Models.VideoDtos; using MediaBrowser.Common.Extensions; using MediaBrowser.Controller.Devices; using MediaBrowser.Controller.Library; @@ -18,6 +17,7 @@ using MediaBrowser.Model.MediaInfo; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.ModelBinding; using Microsoft.Extensions.Logging; namespace Jellyfin.Api.Controllers @@ -81,6 +81,10 @@ namespace Jellyfin.Api.Controllers /// <summary> /// Gets live playback media info for an item. /// </summary> + /// <remarks> + /// For backwards compatibility parameters can be sent via Query or Body, with Query having higher precedence. + /// Query parameters are obsolete. + /// </remarks> /// <param name="itemId">The item id.</param> /// <param name="userId">The user id.</param> /// <param name="maxStreamingBitrate">The maximum streaming bitrate.</param> @@ -90,39 +94,38 @@ namespace Jellyfin.Api.Controllers /// <param name="maxAudioChannels">The maximum number of audio channels.</param> /// <param name="mediaSourceId">The media source id.</param> /// <param name="liveStreamId">The livestream id.</param> - /// <param name="deviceProfile">The device profile.</param> /// <param name="autoOpenLiveStream">Whether to auto open the livestream.</param> /// <param name="enableDirectPlay">Whether to enable direct play. Default: true.</param> /// <param name="enableDirectStream">Whether to enable direct stream. Default: true.</param> /// <param name="enableTranscoding">Whether to enable transcoding. Default: true.</param> /// <param name="allowVideoStreamCopy">Whether to allow to copy the video stream. Default: true.</param> /// <param name="allowAudioStreamCopy">Whether to allow to copy the audio stream. Default: true.</param> + /// <param name="playbackInfoDto">The playback info.</param> /// <response code="200">Playback info returned.</response> /// <returns>A <see cref="Task"/> containing a <see cref="PlaybackInfoResponse"/> with the playback info.</returns> [HttpPost("Items/{itemId}/PlaybackInfo")] [ProducesResponseType(StatusCodes.Status200OK)] public async Task<ActionResult<PlaybackInfoResponse>> GetPostedPlaybackInfo( [FromRoute, Required] Guid itemId, - [FromQuery] Guid? userId, - [FromQuery] long? maxStreamingBitrate, - [FromQuery] long? startTimeTicks, - [FromQuery] int? audioStreamIndex, - [FromQuery] int? subtitleStreamIndex, - [FromQuery] int? maxAudioChannels, - [FromQuery] string? mediaSourceId, - [FromQuery] string? liveStreamId, - [FromBody] DeviceProfileDto? deviceProfile, - [FromQuery] bool autoOpenLiveStream = false, - [FromQuery] bool enableDirectPlay = true, - [FromQuery] bool enableDirectStream = true, - [FromQuery] bool enableTranscoding = true, - [FromQuery] bool allowVideoStreamCopy = true, - [FromQuery] bool allowAudioStreamCopy = true) + [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 authInfo = _authContext.GetAuthorizationInfo(Request); - var profile = deviceProfile?.DeviceProfile; - + var profile = playbackInfoDto?.DeviceProfile; _logger.LogInformation("GetPostedPlaybackInfo profile: {@Profile}", profile); if (profile == null) @@ -134,6 +137,23 @@ namespace Jellyfin.Api.Controllers } } + // 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, @@ -141,6 +161,11 @@ namespace Jellyfin.Api.Controllers liveStreamId) .ConfigureAwait(false); + if (info.ErrorCode != null) + { + return info; + } + if (profile != null) { // set device specific data @@ -161,18 +186,18 @@ namespace Jellyfin.Api.Controllers maxAudioChannels, info!.PlaySessionId!, userId ?? Guid.Empty, - enableDirectPlay, - enableDirectStream, - enableTranscoding, - allowVideoStreamCopy, - allowAudioStreamCopy, + enableDirectPlay.Value, + enableDirectStream.Value, + enableTranscoding.Value, + allowVideoStreamCopy.Value, + allowAudioStreamCopy.Value, Request.HttpContext.GetNormalizedRemoteIp()); } _mediaInfoHelper.SortMediaSources(info, maxStreamingBitrate); } - if (autoOpenLiveStream) + if (autoOpenLiveStream.Value) { var mediaSource = string.IsNullOrWhiteSpace(mediaSourceId) ? info.MediaSources[0] : info.MediaSources.FirstOrDefault(i => string.Equals(i.Id, mediaSourceId, StringComparison.Ordinal)); @@ -183,9 +208,9 @@ namespace Jellyfin.Api.Controllers new LiveStreamRequest { AudioStreamIndex = audioStreamIndex, - DeviceProfile = deviceProfile?.DeviceProfile, - EnableDirectPlay = enableDirectPlay, - EnableDirectStream = enableDirectStream, + DeviceProfile = playbackInfoDto?.DeviceProfile, + EnableDirectPlay = enableDirectPlay.Value, + EnableDirectStream = enableDirectStream.Value, ItemId = itemId, MaxAudioChannels = maxAudioChannels, MaxStreamingBitrate = maxStreamingBitrate, @@ -234,30 +259,30 @@ namespace Jellyfin.Api.Controllers [FromQuery] string? openToken, [FromQuery] Guid? userId, [FromQuery] string? playSessionId, - [FromQuery] long? maxStreamingBitrate, + [FromQuery] int? maxStreamingBitrate, [FromQuery] long? startTimeTicks, [FromQuery] int? audioStreamIndex, [FromQuery] int? subtitleStreamIndex, [FromQuery] int? maxAudioChannels, [FromQuery] Guid? itemId, - [FromBody] OpenLiveStreamDto openLiveStreamDto, - [FromQuery] bool enableDirectPlay = true, - [FromQuery] bool enableDirectStream = true) + [FromBody] OpenLiveStreamDto? openLiveStreamDto, + [FromQuery] bool? enableDirectPlay, + [FromQuery] bool? enableDirectStream) { var request = new LiveStreamRequest { - OpenToken = openToken, - UserId = userId ?? Guid.Empty, - PlaySessionId = playSessionId, - MaxStreamingBitrate = maxStreamingBitrate, - StartTimeTicks = startTimeTicks, - AudioStreamIndex = audioStreamIndex, - SubtitleStreamIndex = subtitleStreamIndex, - MaxAudioChannels = maxAudioChannels, - ItemId = itemId ?? Guid.Empty, + 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, - EnableDirectStream = enableDirectStream, + EnableDirectPlay = enableDirectPlay ?? openLiveStreamDto?.EnableDirectPlay ?? true, + EnableDirectStream = enableDirectStream ?? openLiveStreamDto?.EnableDirectStream ?? true, DirectPlayProtocols = openLiveStreamDto?.DirectPlayProtocols ?? new[] { MediaProtocol.Http } }; return await _mediaInfoHelper.OpenMediaSource(Request, request).ConfigureAwait(false); @@ -282,27 +307,12 @@ namespace Jellyfin.Api.Controllers /// </summary> /// <param name="size">The bitrate. Defaults to 102400.</param> /// <response code="200">Test buffer returned.</response> - /// <response code="400">Size has to be a numer between 0 and 10,000,000.</response> /// <returns>A <see cref="FileResult"/> with specified bitrate.</returns> [HttpGet("Playback/BitrateTest")] [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status400BadRequest)] - [Produces(MediaTypeNames.Application.Octet)] [ProducesFile(MediaTypeNames.Application.Octet)] - public ActionResult GetBitrateTestBytes([FromQuery] int size = 102400) + 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) { - const int MaxSize = 10_000_000; - - if (size <= 0) - { - return BadRequest($"The requested size ({size}) is equal to or smaller than 0."); - } - - if (size > MaxSize) - { - return BadRequest($"The requested size ({size}) is larger than the max allowed value ({MaxSize})."); - } - byte[] buffer = ArrayPool<byte>.Shared.Rent(size); try { diff --git a/Jellyfin.Api/Controllers/MoviesController.cs b/Jellyfin.Api/Controllers/MoviesController.cs index 7fcfc749de..010a3b19a7 100644 --- a/Jellyfin.Api/Controllers/MoviesController.cs +++ b/Jellyfin.Api/Controllers/MoviesController.cs @@ -1,9 +1,10 @@ -using System; +using System; using System.Collections.Generic; using System.Globalization; using System.Linq; using Jellyfin.Api.Constants; using Jellyfin.Api.Extensions; +using Jellyfin.Api.ModelBinders; using Jellyfin.Data.Entities; using Jellyfin.Data.Enums; using MediaBrowser.Common.Extensions; @@ -17,6 +18,7 @@ using MediaBrowser.Model.Dto; using MediaBrowser.Model.Entities; using MediaBrowser.Model.Querying; using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; namespace Jellyfin.Api.Controllers @@ -64,32 +66,31 @@ namespace Jellyfin.Api.Controllers [HttpGet("Recommendations")] public ActionResult<IEnumerable<RecommendationDto>> GetMovieRecommendations( [FromQuery] Guid? userId, - [FromQuery] string? parentId, - [FromQuery] string? fields, + [FromQuery] Guid? parentId, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, [FromQuery] int categoryLimit = 5, [FromQuery] int itemLimit = 8) { var user = userId.HasValue && !userId.Equals(Guid.Empty) ? _userManager.GetUserById(userId.Value) : null; - var dtoOptions = new DtoOptions() - .AddItemFields(fields) + var dtoOptions = new DtoOptions { Fields = fields } .AddClientFields(Request); var categories = new List<RecommendationDto>(); - var parentIdGuid = string.IsNullOrWhiteSpace(parentId) ? Guid.Empty : new Guid(parentId); + var parentIdGuid = parentId ?? Guid.Empty; var query = new InternalItemsQuery(user) { IncludeItemTypes = new[] { nameof(Movie), - // typeof(Trailer).Name, - // typeof(LiveTvProgram).Name + // nameof(Trailer), + // nameof(LiveTvProgram) }, // IsMovie = true - OrderBy = new[] { ItemSortBy.DatePlayed, ItemSortBy.Random }.Select(i => new ValueTuple<string, SortOrder>(i, SortOrder.Descending)).ToArray(), + OrderBy = new[] { (ItemSortBy.DatePlayed, SortOrder.Descending), (ItemSortBy.Random, SortOrder.Descending) }, Limit = 7, ParentId = parentIdGuid, Recursive = true, @@ -110,7 +111,7 @@ namespace Jellyfin.Api.Controllers { IncludeItemTypes = itemTypes.ToArray(), IsMovie = true, - OrderBy = new[] { ItemSortBy.Random }.Select(i => new ValueTuple<string, SortOrder>(i, SortOrder.Descending)).ToArray(), + OrderBy = new[] { (ItemSortBy.Random, SortOrder.Descending) }, Limit = 10, IsFavoriteOrLiked = true, ExcludeItemIds = recentlyPlayedMovies.Select(i => i.Id).ToArray(), @@ -120,7 +121,7 @@ namespace Jellyfin.Api.Controllers DtoOptions = dtoOptions }); - var mostRecentMovies = recentlyPlayedMovies.Take(6).ToList(); + var mostRecentMovies = recentlyPlayedMovies.GetRange(0, Math.Min(recentlyPlayedMovies.Count, 6)); // Get recently played directors var recentDirectors = GetDirectors(mostRecentMovies) .ToList(); @@ -191,7 +192,8 @@ namespace Jellyfin.Api.Controllers foreach (var name in names) { - var items = _libraryManager.GetItemList(new InternalItemsQuery(user) + var items = _libraryManager.GetItemList( + new InternalItemsQuery(user) { Person = name, // Account for duplicates by imdb id, since the database doesn't support this yet @@ -299,9 +301,8 @@ namespace Jellyfin.Api.Controllers private IEnumerable<string> GetActors(IEnumerable<BaseItem> items) { - var people = _libraryManager.GetPeople(new InternalPeopleQuery + var people = _libraryManager.GetPeople(new InternalPeopleQuery(Array.Empty<string>(), new[] { PersonType.Director }) { - ExcludePersonTypes = new[] { PersonType.Director }, MaxListOrder = 3 }); @@ -315,10 +316,9 @@ namespace Jellyfin.Api.Controllers private IEnumerable<string> GetDirectors(IEnumerable<BaseItem> items) { - var people = _libraryManager.GetPeople(new InternalPeopleQuery - { - PersonTypes = new[] { PersonType.Director } - }); + var people = _libraryManager.GetPeople(new InternalPeopleQuery( + new[] { PersonType.Director }, + Array.Empty<string>())); var itemIds = items.Select(i => i.Id).ToList(); diff --git a/Jellyfin.Api/Controllers/MusicGenresController.cs b/Jellyfin.Api/Controllers/MusicGenresController.cs index d216b7f728..27eec2b9a8 100644 --- a/Jellyfin.Api/Controllers/MusicGenresController.cs +++ b/Jellyfin.Api/Controllers/MusicGenresController.cs @@ -1,16 +1,18 @@ -using System; +using System; using System.ComponentModel.DataAnnotations; -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; using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.Audio; using MediaBrowser.Controller.Library; using MediaBrowser.Model.Dto; +using MediaBrowser.Model.Entities; using MediaBrowser.Model.Querying; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; @@ -47,208 +49,88 @@ namespace Jellyfin.Api.Controllers /// <summary> /// Gets all music genres from a given item, folder, or the entire library. /// </summary> - /// <param name="minCommunityRating">Optional filter by minimum community rating.</param> /// <param name="startIndex">Optional. The record index to start at. All items with a lower index will be dropped from the results.</param> /// <param name="limit">Optional. The maximum number of records to return.</param> /// <param name="searchTerm">The search term.</param> /// <param name="parentId">Specify this to localize the search to a specific item or folder. Omit to use the root.</param> - /// <param name="fields">Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimited. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines.</param> + /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param> /// <param name="excludeItemTypes">Optional. If specified, results will be filtered out based on item type. This allows multiple, comma delimited.</param> /// <param name="includeItemTypes">Optional. If specified, results will be filtered in based on item type. This allows multiple, comma delimited.</param> - /// <param name="filters">Optional. Specify additional filters to apply.</param> /// <param name="isFavorite">Optional filter by items that are marked as favorite, or not.</param> - /// <param name="mediaTypes">Optional filter by MediaType. Allows multiple, comma delimited.</param> - /// <param name="genres">Optional. If specified, results will be filtered based on genre. This allows multiple, pipe delimited.</param> - /// <param name="genreIds">Optional. If specified, results will be filtered based on genre id. This allows multiple, pipe delimited.</param> - /// <param name="officialRatings">Optional. If specified, results will be filtered based on OfficialRating. This allows multiple, pipe delimited.</param> - /// <param name="tags">Optional. If specified, results will be filtered based on tag. This allows multiple, pipe delimited.</param> - /// <param name="years">Optional. If specified, results will be filtered based on production year. This allows multiple, comma delimited.</param> - /// <param name="enableUserData">Optional, include user data.</param> /// <param name="imageTypeLimit">Optional, the max number of images to return, per image type.</param> /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> - /// <param name="person">Optional. If specified, results will be filtered to include only those containing the specified person.</param> - /// <param name="personIds">Optional. If specified, results will be filtered to include only those containing the specified person id.</param> - /// <param name="personTypes">Optional. If specified, along with Person, results will be filtered to include only those containing the specified person and PersonType. Allows multiple, comma-delimited.</param> - /// <param name="studios">Optional. If specified, results will be filtered based on studio. This allows multiple, pipe delimited.</param> - /// <param name="studioIds">Optional. If specified, results will be filtered based on studio id. This allows multiple, pipe delimited.</param> /// <param name="userId">User id.</param> /// <param name="nameStartsWithOrGreater">Optional filter by items whose name is sorted equally or greater than a given input string.</param> /// <param name="nameStartsWith">Optional filter by items whose name is sorted equally than a given input string.</param> /// <param name="nameLessThan">Optional filter by items whose name is equally or lesser than a given input string.</param> + /// <param name="sortBy">Optional. Specify one or more sort orders, comma delimited.</param> + /// <param name="sortOrder">Sort Order - Ascending,Descending.</param> /// <param name="enableImages">Optional, include image information in output.</param> /// <param name="enableTotalRecordCount">Optional. Include total record count.</param> /// <response code="200">Music genres returned.</response> /// <returns>An <see cref="OkResult"/> containing the queryresult of music genres.</returns> [HttpGet] + [Obsolete("Use GetGenres instead")] public ActionResult<QueryResult<BaseItemDto>> GetMusicGenres( - [FromQuery] double? minCommunityRating, [FromQuery] int? startIndex, [FromQuery] int? limit, [FromQuery] string? searchTerm, - [FromQuery] string? parentId, - [FromQuery] string? fields, - [FromQuery] string? excludeItemTypes, - [FromQuery] string? includeItemTypes, - [FromQuery] ItemFilter[] filters, + [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] string? mediaTypes, - [FromQuery] string? genres, - [FromQuery] string? genreIds, - [FromQuery] string? officialRatings, - [FromQuery] string? tags, - [FromQuery] string? years, - [FromQuery] bool? enableUserData, [FromQuery] int? imageTypeLimit, - [FromQuery] string? enableImageTypes, - [FromQuery] string? person, - [FromQuery] string? personIds, - [FromQuery] string? personTypes, - [FromQuery] string? studios, - [FromQuery] string? studioIds, + [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() - .AddItemFields(fields) + var dtoOptions = new DtoOptions { Fields = fields } .AddClientFields(Request) - .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); + .AddAdditionalDtoOptions(enableImages, false, imageTypeLimit, enableImageTypes); - User? user = null; - BaseItem parentItem; + User? user = userId.HasValue && userId != Guid.Empty ? _userManager.GetUserById(userId.Value) : null; - if (userId.HasValue && !userId.Equals(Guid.Empty)) - { - user = _userManager.GetUserById(userId.Value); - parentItem = string.IsNullOrEmpty(parentId) ? _libraryManager.GetUserRootFolder() : _libraryManager.GetItemById(parentId); - } - else - { - parentItem = string.IsNullOrEmpty(parentId) ? _libraryManager.RootFolder : _libraryManager.GetItemById(parentId); - } + var parentItem = _libraryManager.GetParentItem(parentId, userId); var query = new InternalItemsQuery(user) { - ExcludeItemTypes = RequestHelpers.Split(excludeItemTypes, ',', true), - IncludeItemTypes = RequestHelpers.Split(includeItemTypes, ',', true), - MediaTypes = RequestHelpers.Split(mediaTypes, ',', true), + ExcludeItemTypes = RequestHelpers.GetItemTypeStrings(excludeItemTypes), + IncludeItemTypes = RequestHelpers.GetItemTypeStrings(includeItemTypes), StartIndex = startIndex, Limit = limit, IsFavorite = isFavorite, NameLessThan = nameLessThan, NameStartsWith = nameStartsWith, NameStartsWithOrGreater = nameStartsWithOrGreater, - Tags = RequestHelpers.Split(tags, '|', true), - OfficialRatings = RequestHelpers.Split(officialRatings, '|', true), - Genres = RequestHelpers.Split(genres, '|', true), - GenreIds = RequestHelpers.GetGuids(genreIds), - StudioIds = RequestHelpers.GetGuids(studioIds), - Person = person, - PersonIds = RequestHelpers.GetGuids(personIds), - PersonTypes = RequestHelpers.Split(personTypes, ',', true), - Years = RequestHelpers.Split(years, ',', true).Select(y => Convert.ToInt32(y, CultureInfo.InvariantCulture)).ToArray(), - MinCommunityRating = minCommunityRating, DtoOptions = dtoOptions, SearchTerm = searchTerm, - EnableTotalRecordCount = enableTotalRecordCount + EnableTotalRecordCount = enableTotalRecordCount, + OrderBy = RequestHelpers.GetOrderBy(sortBy, sortOrder) }; - if (!string.IsNullOrWhiteSpace(parentId)) + if (parentId.HasValue) { if (parentItem is Folder) { - query.AncestorIds = new[] { new Guid(parentId) }; + query.AncestorIds = new[] { parentId.Value }; } else { - query.ItemIds = new[] { new Guid(parentId) }; - } - } - - // Studios - if (!string.IsNullOrEmpty(studios)) - { - query.StudioIds = studios.Split('|') - .Select(i => - { - try - { - return _libraryManager.GetStudio(i); - } - catch - { - return null; - } - }).Where(i => i != 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; + query.ItemIds = new[] { parentId.Value }; } } var result = _libraryManager.GetMusicGenres(query); - var dtos = result.Items.Select(i => - { - var (baseItem, counts) = i; - var dto = _dtoService.GetItemByNameDto(baseItem, dtoOptions, null, user); - - if (!string.IsNullOrWhiteSpace(includeItemTypes)) - { - dto.ChildCount = counts.ItemCount; - dto.ProgramCount = counts.ProgramCount; - dto.SeriesCount = counts.SeriesCount; - dto.EpisodeCount = counts.EpisodeCount; - dto.MovieCount = counts.MovieCount; - dto.TrailerCount = counts.TrailerCount; - dto.AlbumCount = counts.AlbumCount; - dto.SongCount = counts.SongCount; - dto.ArtistCount = counts.ArtistCount; - } - - return dto; - }); - - return new QueryResult<BaseItemDto> - { - Items = dtos.ToArray(), - TotalRecordCount = result.TotalRecordCount - }; + var shouldIncludeItemTypes = includeItemTypes.Length != 0; + return RequestHelpers.CreateQueryResult(result, dtoOptions, _dtoService, shouldIncludeItemTypes, user); } /// <summary> @@ -263,7 +145,7 @@ namespace Jellyfin.Api.Controllers { var dtoOptions = new DtoOptions().AddClientFields(Request); - MusicGenre item; + MusicGenre? item; if (genreName.IndexOf(BaseItem.SlugChar, StringComparison.OrdinalIgnoreCase) != -1) { @@ -284,7 +166,7 @@ namespace Jellyfin.Api.Controllers return _dtoService.GetBaseItemDto(item, dtoOptions); } - private T GetItemFromSlugName<T>(ILibraryManager libraryManager, string name, DtoOptions dtoOptions) + private T? GetItemFromSlugName<T>(ILibraryManager libraryManager, string name, DtoOptions dtoOptions) where T : BaseItem, new() { var result = libraryManager.GetItemList(new InternalItemsQuery diff --git a/Jellyfin.Api/Controllers/NotificationsController.cs b/Jellyfin.Api/Controllers/NotificationsController.cs index 0ceda6815c..420630cdf4 100644 --- a/Jellyfin.Api/Controllers/NotificationsController.cs +++ b/Jellyfin.Api/Controllers/NotificationsController.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; using System.Linq; using System.Threading; using Jellyfin.Api.Constants; @@ -86,26 +87,19 @@ namespace Jellyfin.Api.Controllers /// <summary> /// Sends a notification to all admins. /// </summary> - /// <param name="url">The URL of the notification.</param> - /// <param name="level">The level of the notification.</param> - /// <param name="name">The name of the notification.</param> - /// <param name="description">The description of the notification.</param> + /// <param name="notificationDto">The notification request.</param> /// <response code="204">Notification sent.</response> /// <returns>A <cref see="NoContentResult"/>.</returns> [HttpPost("Admin")] [ProducesResponseType(StatusCodes.Status204NoContent)] - public ActionResult CreateAdminNotification( - [FromQuery] string? url, - [FromQuery] NotificationLevel? level, - [FromQuery] string name = "", - [FromQuery] string description = "") + public ActionResult CreateAdminNotification([FromBody, Required] AdminNotificationDto notificationDto) { var notification = new NotificationRequest { - Name = name, - Description = description, - Url = url, - Level = level ?? NotificationLevel.Normal, + Name = notificationDto.Name, + Description = notificationDto.Description, + Url = notificationDto.Url, + Level = notificationDto.NotificationLevel ?? NotificationLevel.Normal, UserIds = _userManager.Users .Where(user => user.HasPermission(PermissionKind.IsAdministrator)) .Select(user => user.Id) @@ -114,7 +108,6 @@ namespace Jellyfin.Api.Controllers }; _notificationManager.SendNotification(notification, CancellationToken.None); - return NoContent(); } diff --git a/Jellyfin.Api/Controllers/PackageController.cs b/Jellyfin.Api/Controllers/PackageController.cs index 1d9de14d2d..5dd49ef2fd 100644 --- a/Jellyfin.Api/Controllers/PackageController.cs +++ b/Jellyfin.Api/Controllers/PackageController.cs @@ -45,15 +45,20 @@ namespace Jellyfin.Api.Controllers [ProducesResponseType(StatusCodes.Status200OK)] public async Task<ActionResult<PackageInfo>> GetPackageInfo( [FromRoute, Required] string name, - [FromQuery] string? assemblyGuid) + [FromQuery] Guid? assemblyGuid) { var packages = await _installationManager.GetAvailablePackages().ConfigureAwait(false); var result = _installationManager.FilterPackages( packages, name, - string.IsNullOrEmpty(assemblyGuid) ? default : Guid.Parse(assemblyGuid)) + assemblyGuid ?? default) .FirstOrDefault(); + if (result == null) + { + return NotFound(); + } + return result; } @@ -87,21 +92,21 @@ namespace Jellyfin.Api.Controllers [Authorize(Policy = Policies.RequiresElevation)] public async Task<ActionResult> InstallPackage( [FromRoute, Required] string name, - [FromQuery] string? assemblyGuid, + [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.repositoryUrl.Equals(repositoryUrl, StringComparison.OrdinalIgnoreCase)) + packages = packages.Where(p => p.Versions.Any(q => q.RepositoryUrl.Equals(repositoryUrl, StringComparison.OrdinalIgnoreCase))) .ToList(); } var package = _installationManager.GetCompatibleVersions( packages, name, - string.IsNullOrEmpty(assemblyGuid) ? Guid.Empty : Guid.Parse(assemblyGuid), + assemblyGuid ?? Guid.Empty, specificVersion: string.IsNullOrEmpty(version) ? null : Version.Parse(version)) .FirstOrDefault(); @@ -149,12 +154,13 @@ namespace Jellyfin.Api.Controllers /// <param name="repositoryInfos">The list of package repositories.</param> /// <response code="204">Package repositories saved.</response> /// <returns>A <see cref="NoContentResult"/>.</returns> - [HttpOptions("Repositories")] + [HttpPost("Repositories")] [Authorize(Policy = Policies.DefaultAuthorization)] [ProducesResponseType(StatusCodes.Status204NoContent)] - public ActionResult SetRepositories([FromBody] List<RepositoryInfo> repositoryInfos) + public ActionResult SetRepositories([FromBody, Required] List<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 bbef1ff9a1..b98307f879 100644 --- a/Jellyfin.Api/Controllers/PersonsController.cs +++ b/Jellyfin.Api/Controllers/PersonsController.cs @@ -1,15 +1,15 @@ -using System; +using System; using System.ComponentModel.DataAnnotations; -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 MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; using MediaBrowser.Model.Dto; +using MediaBrowser.Model.Entities; using MediaBrowser.Model.Querying; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; @@ -26,6 +26,7 @@ namespace Jellyfin.Api.Controllers private readonly ILibraryManager _libraryManager; private readonly IDtoService _dtoService; private readonly IUserManager _userManager; + private readonly IUserDataManager _userDataManager; /// <summary> /// Initializes a new instance of the <see cref="PersonsController"/> class. @@ -33,221 +34,81 @@ namespace Jellyfin.Api.Controllers /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> /// <param name="dtoService">Instance of the <see cref="IDtoService"/> interface.</param> /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> + /// <param name="userDataManager">Instance of the <see cref="IUserDataManager"/> interface.</param> public PersonsController( ILibraryManager libraryManager, IDtoService dtoService, - IUserManager userManager) + IUserManager userManager, + IUserDataManager userDataManager) { _libraryManager = libraryManager; _dtoService = dtoService; _userManager = userManager; + _userDataManager = userDataManager; } /// <summary> - /// Gets all persons from a given item, folder, or the entire library. + /// Gets all persons. /// </summary> - /// <param name="minCommunityRating">Optional filter by minimum community rating.</param> - /// <param name="startIndex">Optional. The record index to start at. All items with a lower index will be dropped from the results.</param> /// <param name="limit">Optional. The maximum number of records to return.</param> /// <param name="searchTerm">The search term.</param> - /// <param name="parentId">Specify this to localize the search to a specific item or folder. Omit to use the root.</param> - /// <param name="fields">Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimited. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines.</param> - /// <param name="excludeItemTypes">Optional. If specified, results will be filtered out based on item type. This allows multiple, comma delimited.</param> - /// <param name="includeItemTypes">Optional. If specified, results will be filtered in based on item type. This allows multiple, comma delimited.</param> - /// <param name="filters">Optional. Specify additional filters to apply. This allows multiple, comma delimited. Options: IsFolder, IsNotFolder, IsUnplayed, IsPlayed, IsFavorite, IsResumable, Likes, Dislikes.</param> - /// <param name="isFavorite">Optional filter by items that are marked as favorite, or not.</param> - /// <param name="mediaTypes">Optional filter by MediaType. Allows multiple, comma delimited.</param> - /// <param name="genres">Optional. If specified, results will be filtered based on genre. This allows multiple, pipe delimited.</param> - /// <param name="genreIds">Optional. If specified, results will be filtered based on genre id. This allows multiple, pipe delimited.</param> - /// <param name="officialRatings">Optional. If specified, results will be filtered based on OfficialRating. This allows multiple, pipe delimited.</param> - /// <param name="tags">Optional. If specified, results will be filtered based on tag. This allows multiple, pipe delimited.</param> - /// <param name="years">Optional. If specified, results will be filtered based on production year. This allows multiple, comma delimited.</param> + /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param> + /// <param name="filters">Optional. Specify additional filters to apply.</param> + /// <param name="isFavorite">Optional filter by items that are marked as favorite, or not. userId is required.</param> /// <param name="enableUserData">Optional, include user data.</param> /// <param name="imageTypeLimit">Optional, the max number of images to return, per image type.</param> /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> - /// <param name="person">Optional. If specified, results will be filtered to include only those containing the specified person.</param> - /// <param name="personIds">Optional. If specified, results will be filtered to include only those containing the specified person id.</param> - /// <param name="personTypes">Optional. If specified, along with Person, results will be filtered to include only those containing the specified person and PersonType. Allows multiple, comma-delimited.</param> - /// <param name="studios">Optional. If specified, results will be filtered based on studio. This allows multiple, pipe delimited.</param> - /// <param name="studioIds">Optional. If specified, results will be filtered based on studio id. This allows multiple, pipe delimited.</param> + /// <param name="excludePersonTypes">Optional. If specified results will be filtered to exclude those containing the specified PersonType. Allows multiple, comma-delimited.</param> + /// <param name="personTypes">Optional. If specified results will be filtered to include only those containing the specified PersonType. Allows multiple, comma-delimited.</param> + /// <param name="appearsInItemId">Optional. If specified, person results will be filtered on items related to said persons.</param> /// <param name="userId">User id.</param> - /// <param name="nameStartsWithOrGreater">Optional filter by items whose name is sorted equally or greater than a given input string.</param> - /// <param name="nameStartsWith">Optional filter by items whose name is sorted equally than a given input string.</param> - /// <param name="nameLessThan">Optional filter by items whose name is equally or lesser than a given input string.</param> /// <param name="enableImages">Optional, include image information in output.</param> - /// <param name="enableTotalRecordCount">Optional. Include total record count.</param> /// <response code="200">Persons returned.</response> /// <returns>An <see cref="OkResult"/> containing the queryresult of persons.</returns> [HttpGet] [ProducesResponseType(StatusCodes.Status200OK)] public ActionResult<QueryResult<BaseItemDto>> GetPersons( - [FromQuery] double? minCommunityRating, - [FromQuery] int? startIndex, [FromQuery] int? limit, [FromQuery] string? searchTerm, - [FromQuery] string? parentId, - [FromQuery] string? fields, - [FromQuery] string? excludeItemTypes, - [FromQuery] string? includeItemTypes, - [FromQuery] ItemFilter[] filters, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFilter[] filters, [FromQuery] bool? isFavorite, - [FromQuery] string? mediaTypes, - [FromQuery] string? genres, - [FromQuery] string? genreIds, - [FromQuery] string? officialRatings, - [FromQuery] string? tags, - [FromQuery] string? years, [FromQuery] bool? enableUserData, [FromQuery] int? imageTypeLimit, - [FromQuery] string? enableImageTypes, - [FromQuery] string? person, - [FromQuery] string? personIds, - [FromQuery] string? personTypes, - [FromQuery] string? studios, - [FromQuery] string? studioIds, + [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] string? nameStartsWithOrGreater, - [FromQuery] string? nameStartsWith, - [FromQuery] string? nameLessThan, - [FromQuery] bool? enableImages = true, - [FromQuery] bool enableTotalRecordCount = true) + [FromQuery] bool? enableImages = true) { - var dtoOptions = new DtoOptions() - .AddItemFields(fields) + var dtoOptions = new DtoOptions { Fields = fields } .AddClientFields(Request) .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); User? user = null; - BaseItem parentItem; if (userId.HasValue && !userId.Equals(Guid.Empty)) { user = _userManager.GetUserById(userId.Value); - parentItem = string.IsNullOrEmpty(parentId) ? _libraryManager.GetUserRootFolder() : _libraryManager.GetItemById(parentId); - } - else - { - parentItem = string.IsNullOrEmpty(parentId) ? _libraryManager.RootFolder : _libraryManager.GetItemById(parentId); - } - - var query = new InternalItemsQuery(user) - { - ExcludeItemTypes = RequestHelpers.Split(excludeItemTypes, ',', true), - IncludeItemTypes = RequestHelpers.Split(includeItemTypes, ',', true), - MediaTypes = RequestHelpers.Split(mediaTypes, ',', true), - StartIndex = startIndex, - Limit = limit, - IsFavorite = isFavorite, - NameLessThan = nameLessThan, - NameStartsWith = nameStartsWith, - NameStartsWithOrGreater = nameStartsWithOrGreater, - Tags = RequestHelpers.Split(tags, '|', true), - OfficialRatings = RequestHelpers.Split(officialRatings, '|', true), - Genres = RequestHelpers.Split(genres, '|', true), - GenreIds = RequestHelpers.GetGuids(genreIds), - StudioIds = RequestHelpers.GetGuids(studioIds), - Person = person, - PersonIds = RequestHelpers.GetGuids(personIds), - PersonTypes = RequestHelpers.Split(personTypes, ',', true), - Years = RequestHelpers.Split(years, ',', true).Select(y => Convert.ToInt32(y, CultureInfo.InvariantCulture)).ToArray(), - MinCommunityRating = minCommunityRating, - DtoOptions = dtoOptions, - SearchTerm = searchTerm, - EnableTotalRecordCount = enableTotalRecordCount - }; - - if (!string.IsNullOrWhiteSpace(parentId)) - { - if (parentItem is Folder) - { - query.AncestorIds = new[] { new Guid(parentId) }; - } - else - { - query.ItemIds = new[] { new Guid(parentId) }; - } } - // Studios - if (!string.IsNullOrEmpty(studios)) + var isFavoriteInFilters = filters.Any(f => f == ItemFilter.IsFavorite); + var peopleItems = _libraryManager.GetPeopleItems(new InternalPeopleQuery( + personTypes, + excludePersonTypes) { - query.StudioIds = studios.Split('|') - .Select(i => - { - try - { - return _libraryManager.GetStudio(i); - } - catch - { - return null; - } - }).Where(i => i != 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 = new QueryResult<(BaseItem, ItemCounts)>(); - - var dtos = result.Items.Select(i => - { - var (baseItem, counts) = i; - var dto = _dtoService.GetItemByNameDto(baseItem, dtoOptions, null, user); - - if (!string.IsNullOrWhiteSpace(includeItemTypes)) - { - dto.ChildCount = counts.ItemCount; - dto.ProgramCount = counts.ProgramCount; - dto.SeriesCount = counts.SeriesCount; - dto.EpisodeCount = counts.EpisodeCount; - dto.MovieCount = counts.MovieCount; - dto.TrailerCount = counts.TrailerCount; - dto.AlbumCount = counts.AlbumCount; - dto.SongCount = counts.SongCount; - dto.ArtistCount = counts.ArtistCount; - } - - return dto; + NameContains = searchTerm, + User = user, + IsFavorite = !isFavorite.HasValue && isFavoriteInFilters ? true : isFavorite, + AppearsInItemId = appearsInItemId ?? Guid.Empty, + Limit = limit ?? 0 }); return new QueryResult<BaseItemDto> { - Items = dtos.ToArray(), - TotalRecordCount = result.TotalRecordCount + Items = peopleItems.Select(person => _dtoService.GetItemByNameDto(person, dtoOptions, null, user)).ToArray(), + TotalRecordCount = peopleItems.Count }; } diff --git a/Jellyfin.Api/Controllers/PlaylistsController.cs b/Jellyfin.Api/Controllers/PlaylistsController.cs index 1e95bd2b38..1667d6edee 100644 --- a/Jellyfin.Api/Controllers/PlaylistsController.cs +++ b/Jellyfin.Api/Controllers/PlaylistsController.cs @@ -1,20 +1,24 @@ -using System; +using System; +using System.Collections.Generic; 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; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Playlists; using MediaBrowser.Model.Dto; +using MediaBrowser.Model.Entities; using MediaBrowser.Model.Playlists; using MediaBrowser.Model.Querying; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.ModelBinding; namespace Jellyfin.Api.Controllers { @@ -51,6 +55,14 @@ namespace Jellyfin.Api.Controllers /// <summary> /// Creates a new playlist. /// </summary> + /// <remarks> + /// For backwards compatibility parameters can be sent via Query or Body, with Query having higher precedence. + /// Query parameters are obsolete. + /// </remarks> + /// <param name="name">The playlist name.</param> + /// <param name="ids">The item ids.</param> + /// <param name="userId">The user id.</param> + /// <param name="mediaType">The media type.</param> /// <param name="createPlaylistRequest">The create playlist payload.</param> /// <returns> /// A <see cref="Task" /> that represents the asynchronous operation to create a playlist. @@ -59,15 +71,23 @@ namespace Jellyfin.Api.Controllers [HttpPost] [ProducesResponseType(StatusCodes.Status200OK)] public async Task<ActionResult<PlaylistCreationResult>> CreatePlaylist( - [FromBody, Required] CreatePlaylistDto createPlaylistRequest) + [FromQuery, ParameterObsolete] string? name, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder)), ParameterObsolete] IReadOnlyList<Guid> ids, + [FromQuery, ParameterObsolete] Guid? userId, + [FromQuery, ParameterObsolete] string? mediaType, + [FromBody(EmptyBodyBehavior = EmptyBodyBehavior.Allow)] CreatePlaylistDto? createPlaylistRequest) { - Guid[] idGuidArray = RequestHelpers.GetGuids(createPlaylistRequest.Ids); + if (ids.Count == 0) + { + ids = createPlaylistRequest?.Ids ?? Array.Empty<Guid>(); + } + var result = await _playlistManager.CreatePlaylist(new PlaylistCreationRequest { - Name = createPlaylistRequest.Name, - ItemIdList = idGuidArray, - UserId = createPlaylistRequest.UserId, - MediaType = createPlaylistRequest.MediaType + Name = name ?? createPlaylistRequest?.Name, + ItemIdList = ids, + UserId = userId ?? createPlaylistRequest?.UserId ?? default, + MediaType = mediaType ?? createPlaylistRequest?.MediaType }).ConfigureAwait(false); return result; @@ -85,10 +105,10 @@ namespace Jellyfin.Api.Controllers [ProducesResponseType(StatusCodes.Status204NoContent)] public async Task<ActionResult> AddToPlaylist( [FromRoute, Required] Guid playlistId, - [FromQuery] string? ids, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] ids, [FromQuery] Guid? userId) { - await _playlistManager.AddToPlaylistAsync(playlistId, RequestHelpers.GetGuids(ids), userId ?? Guid.Empty).ConfigureAwait(false); + await _playlistManager.AddToPlaylistAsync(playlistId, ids, userId ?? Guid.Empty).ConfigureAwait(false); return NoContent(); } @@ -120,9 +140,11 @@ namespace Jellyfin.Api.Controllers /// <returns>An <see cref="NoContentResult"/> on success.</returns> [HttpDelete("{playlistId}/Items")] [ProducesResponseType(StatusCodes.Status204NoContent)] - public async Task<ActionResult> RemoveFromPlaylist([FromRoute, Required] string playlistId, [FromQuery] string? entryIds) + public async Task<ActionResult> RemoveFromPlaylist( + [FromRoute, Required] string playlistId, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] entryIds) { - await _playlistManager.RemoveFromPlaylistAsync(playlistId, RequestHelpers.Split(entryIds, ',', true)).ConfigureAwait(false); + await _playlistManager.RemoveFromPlaylistAsync(playlistId, entryIds).ConfigureAwait(false); return NoContent(); } @@ -133,7 +155,7 @@ namespace Jellyfin.Api.Controllers /// <param name="userId">User id.</param> /// <param name="startIndex">Optional. The record index to start at. All items with a lower index will be dropped from the results.</param> /// <param name="limit">Optional. The maximum number of records to return.</param> - /// <param name="fields">Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimited. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines.</param> + /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param> /// <param name="enableImages">Optional. Include image information in output.</param> /// <param name="enableUserData">Optional. Include user data.</param> /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param> @@ -147,11 +169,11 @@ namespace Jellyfin.Api.Controllers [FromQuery, Required] Guid userId, [FromQuery] int? startIndex, [FromQuery] int? limit, - [FromQuery] string? fields, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, [FromQuery] bool? enableImages, [FromQuery] bool? enableUserData, [FromQuery] int? imageTypeLimit, - [FromQuery] string? enableImageTypes) + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes) { var playlist = (Playlist)_libraryManager.GetItemById(playlistId); if (playlist == null) @@ -175,8 +197,7 @@ namespace Jellyfin.Api.Controllers items = items.Take(limit.Value).ToArray(); } - var dtoOptions = new DtoOptions() - .AddItemFields(fields) + var dtoOptions = new DtoOptions { Fields = fields } .AddClientFields(Request) .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); diff --git a/Jellyfin.Api/Controllers/PlaystateController.cs b/Jellyfin.Api/Controllers/PlaystateController.cs index 5c15e9a0d7..f256c8c25c 100644 --- a/Jellyfin.Api/Controllers/PlaystateController.cs +++ b/Jellyfin.Api/Controllers/PlaystateController.cs @@ -1,9 +1,10 @@ -using System; +using System; using System.ComponentModel.DataAnnotations; using System.Diagnostics.CodeAnalysis; using System.Threading.Tasks; using Jellyfin.Api.Constants; using Jellyfin.Api.Helpers; +using Jellyfin.Api.ModelBinders; using Jellyfin.Data.Entities; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Net; @@ -74,7 +75,7 @@ namespace Jellyfin.Api.Controllers public ActionResult<UserItemDataDto> MarkPlayedItem( [FromRoute, Required] Guid userId, [FromRoute, Required] Guid itemId, - [FromQuery] DateTime? datePlayed) + [FromQuery, ModelBinder(typeof(LegacyDateTimeModelBinder))] DateTime? datePlayed) { var user = _userManager.GetUserById(userId); var session = RequestHelpers.GetSession(_sessionManager, _authContext, Request); @@ -151,7 +152,7 @@ namespace Jellyfin.Api.Controllers /// <returns>A <see cref="NoContentResult"/>.</returns> [HttpPost("Sessions/Playing/Ping")] [ProducesResponseType(StatusCodes.Status204NoContent)] - public ActionResult PingPlaybackSession([FromQuery] string playSessionId) + public ActionResult PingPlaybackSession([FromQuery, Required] string playSessionId) { _transcodingJobHelper.PingTranscodingJob(playSessionId, null); return NoContent(); @@ -201,9 +202,9 @@ namespace Jellyfin.Api.Controllers [FromQuery] string? mediaSourceId, [FromQuery] int? audioStreamIndex, [FromQuery] int? subtitleStreamIndex, - [FromQuery] PlayMethod playMethod, + [FromQuery] PlayMethod? playMethod, [FromQuery] string? liveStreamId, - [FromQuery] string playSessionId, + [FromQuery] string? playSessionId, [FromQuery] bool canSeek = false) { var playbackStartInfo = new PlaybackStartInfo @@ -213,7 +214,7 @@ namespace Jellyfin.Api.Controllers MediaSourceId = mediaSourceId, AudioStreamIndex = audioStreamIndex, SubtitleStreamIndex = subtitleStreamIndex, - PlayMethod = playMethod, + PlayMethod = playMethod ?? PlayMethod.Transcode, PlaySessionId = playSessionId, LiveStreamId = liveStreamId }; @@ -253,10 +254,10 @@ namespace Jellyfin.Api.Controllers [FromQuery] int? audioStreamIndex, [FromQuery] int? subtitleStreamIndex, [FromQuery] int? volumeLevel, - [FromQuery] PlayMethod playMethod, + [FromQuery] PlayMethod? playMethod, [FromQuery] string? liveStreamId, - [FromQuery] string playSessionId, - [FromQuery] RepeatMode repeatMode, + [FromQuery] string? playSessionId, + [FromQuery] RepeatMode? repeatMode, [FromQuery] bool isPaused = false, [FromQuery] bool isMuted = false) { @@ -270,10 +271,10 @@ namespace Jellyfin.Api.Controllers AudioStreamIndex = audioStreamIndex, SubtitleStreamIndex = subtitleStreamIndex, VolumeLevel = volumeLevel, - PlayMethod = playMethod, + PlayMethod = playMethod ?? PlayMethod.Transcode, PlaySessionId = playSessionId, LiveStreamId = liveStreamId, - RepeatMode = repeatMode + RepeatMode = repeatMode ?? RepeatMode.RepeatNone }; playbackProgressInfo.PlayMethod = ValidatePlayMethod(playbackProgressInfo.PlayMethod, playbackProgressInfo.PlaySessionId); @@ -351,7 +352,7 @@ namespace Jellyfin.Api.Controllers return _userDataRepository.GetUserDataDto(item, user); } - private PlayMethod ValidatePlayMethod(PlayMethod method, string playSessionId) + private PlayMethod ValidatePlayMethod(PlayMethod method, string? playSessionId) { if (method == PlayMethod.Transcode) { diff --git a/Jellyfin.Api/Controllers/PluginsController.cs b/Jellyfin.Api/Controllers/PluginsController.cs index 0f8ceba291..0ae6109bcc 100644 --- a/Jellyfin.Api/Controllers/PluginsController.cs +++ b/Jellyfin.Api/Controllers/PluginsController.cs @@ -1,15 +1,18 @@ -using System; +using System; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; +using System.IO; using System.Linq; using System.Text.Json; using System.Threading.Tasks; +using Jellyfin.Api.Attributes; using Jellyfin.Api.Constants; using Jellyfin.Api.Models.PluginDtos; -using MediaBrowser.Common; -using MediaBrowser.Common.Json; +using Jellyfin.Extensions.Json; +using MediaBrowser.Common.Configuration; using MediaBrowser.Common.Plugins; using MediaBrowser.Common.Updates; +using MediaBrowser.Model.Net; using MediaBrowser.Model.Plugins; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; @@ -23,22 +26,81 @@ namespace Jellyfin.Api.Controllers [Authorize(Policy = Policies.DefaultAuthorization)] public class PluginsController : BaseJellyfinApiController { - private readonly IApplicationHost _appHost; private readonly IInstallationManager _installationManager; - - private readonly JsonSerializerOptions _serializerOptions = JsonDefaults.GetOptions(); + private readonly IPluginManager _pluginManager; + private readonly IConfigurationManager _config; + private readonly JsonSerializerOptions _serializerOptions; /// <summary> /// Initializes a new instance of the <see cref="PluginsController"/> class. /// </summary> - /// <param name="appHost">Instance of the <see cref="IApplicationHost"/> interface.</param> /// <param name="installationManager">Instance of the <see cref="IInstallationManager"/> interface.</param> + /// <param name="pluginManager">Instance of the <see cref="IPluginManager"/> interface.</param> + /// <param name="config">Instance of the <see cref="IConfigurationManager"/> interface.</param> public PluginsController( - IApplicationHost appHost, - IInstallationManager installationManager) + IInstallationManager installationManager, + IPluginManager pluginManager, + IConfigurationManager config) { - _appHost = appHost; _installationManager = installationManager; + _pluginManager = pluginManager; + _serializerOptions = JsonDefaults.Options; + _config = config; + } + + /// <summary> + /// Get plugin security info. + /// </summary> + /// <response code="200">Plugin security info returned.</response> + /// <returns>Plugin security info.</returns> + [Obsolete("This endpoint should not be used.")] + [HttpGet("SecurityInfo")] + [ProducesResponseType(StatusCodes.Status200OK)] + public static ActionResult<PluginSecurityInfo> GetPluginSecurityInfo() + { + return new PluginSecurityInfo + { + IsMbSupporter = true, + SupporterKey = "IAmTotallyLegit" + }; + } + + /// <summary> + /// Gets registration status for a feature. + /// </summary> + /// <param name="name">Feature name.</param> + /// <response code="200">Registration status returned.</response> + /// <returns>Mb registration record.</returns> + [Obsolete("This endpoint should not be used.")] + [HttpPost("RegistrationRecords/{name}")] + [ProducesResponseType(StatusCodes.Status200OK)] + public static ActionResult<MBRegistrationRecord> GetRegistrationStatus([FromRoute, Required] string name) + { + return new MBRegistrationRecord + { + IsRegistered = true, + RegChecked = true, + TrialVersion = false, + IsValid = true, + RegError = false + }; + } + + /// <summary> + /// Gets registration status for a feature. + /// </summary> + /// <param name="name">Feature name.</param> + /// <response code="501">Not implemented.</response> + /// <returns>Not Implemented.</returns> + /// <exception cref="NotImplementedException">This endpoint is not implemented.</exception> + [Obsolete("Paid plugins are not supported")] + [HttpGet("Registrations/{name}")] + [ProducesResponseType(StatusCodes.Status501NotImplemented)] + public static ActionResult GetRegistration([FromRoute, Required] string name) + { + // TODO Once we have proper apps and plugins and decide to break compatibility with paid plugins, + // delete all these registration endpoints. They are only kept for compatibility. + throw new NotImplementedException(); } /// <summary> @@ -50,23 +112,74 @@ namespace Jellyfin.Api.Controllers [ProducesResponseType(StatusCodes.Status200OK)] public ActionResult<IEnumerable<PluginInfo>> GetPlugins() { - return Ok(_appHost.Plugins.OrderBy(p => p.Name).Select(p => p.GetPluginInfo())); + return Ok(_pluginManager.Plugins + .OrderBy(p => p.Name) + .Select(p => p.GetPluginInfo())); } /// <summary> - /// Uninstalls a plugin. + /// Enables a disabled plugin. + /// </summary> + /// <param name="pluginId">Plugin id.</param> + /// <param name="version">Plugin version.</param> + /// <response code="204">Plugin enabled.</response> + /// <response code="404">Plugin not found.</response> + /// <returns>An <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if the plugin could not be found.</returns> + [HttpPost("{pluginId}/{version}/Enable")] + [Authorize(Policy = Policies.RequiresElevation)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public ActionResult EnablePlugin([FromRoute, Required] Guid pluginId, [FromRoute, Required] Version version) + { + var plugin = _pluginManager.GetPlugin(pluginId, version); + if (plugin == null) + { + return NotFound(); + } + + _pluginManager.EnablePlugin(plugin); + return NoContent(); + } + + /// <summary> + /// Disable a plugin. + /// </summary> + /// <param name="pluginId">Plugin id.</param> + /// <param name="version">Plugin version.</param> + /// <response code="204">Plugin disabled.</response> + /// <response code="404">Plugin not found.</response> + /// <returns>An <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if the plugin could not be found.</returns> + [HttpPost("{pluginId}/{version}/Disable")] + [Authorize(Policy = Policies.RequiresElevation)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public ActionResult DisablePlugin([FromRoute, Required] Guid pluginId, [FromRoute, Required] Version version) + { + var plugin = _pluginManager.GetPlugin(pluginId, version); + if (plugin == null) + { + return NotFound(); + } + + _pluginManager.DisablePlugin(plugin); + return NoContent(); + } + + /// <summary> + /// Uninstalls a plugin by version. /// </summary> /// <param name="pluginId">Plugin id.</param> + /// <param name="version">Plugin version.</param> /// <response code="204">Plugin uninstalled.</response> /// <response code="404">Plugin not found.</response> - /// <returns>An <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if the file could not be found.</returns> - [HttpDelete("{pluginId}")] + /// <returns>An <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if the plugin could not be found.</returns> + [HttpDelete("{pluginId}/{version}")] [Authorize(Policy = Policies.RequiresElevation)] [ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status404NotFound)] - public ActionResult UninstallPlugin([FromRoute, Required] Guid pluginId) + public ActionResult UninstallPluginByVersion([FromRoute, Required] Guid pluginId, [FromRoute, Required] Version version) { - var plugin = _appHost.Plugins.FirstOrDefault(p => p.Id == pluginId); + var plugin = _pluginManager.GetPlugin(pluginId, version); if (plugin == null) { return NotFound(); @@ -76,6 +189,35 @@ namespace Jellyfin.Api.Controllers return NoContent(); } + /// <summary> + /// Uninstalls a plugin. + /// </summary> + /// <param name="pluginId">Plugin id.</param> + /// <response code="204">Plugin uninstalled.</response> + /// <response code="404">Plugin not found.</response> + /// <returns>An <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if the plugin could not be found.</returns> + [HttpDelete("{pluginId}")] + [Authorize(Policy = Policies.RequiresElevation)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [Obsolete("Please use the UninstallPluginByVersion API.")] + public ActionResult UninstallPlugin([FromRoute, Required] Guid pluginId) + { + // If no version is given, return the current instance. + var plugins = _pluginManager.Plugins.Where(p => p.Id.Equals(pluginId)); + + // Select the un-instanced one first. + var plugin = plugins.FirstOrDefault(p => p.Instance == null) ?? plugins.OrderBy(p => p.Manifest.Status).FirstOrDefault(); + + if (plugin != null) + { + _installationManager.UninstallPlugin(plugin); + return NoContent(); + } + + return NotFound(); + } + /// <summary> /// Gets plugin configuration. /// </summary> @@ -88,12 +230,13 @@ namespace Jellyfin.Api.Controllers [ProducesResponseType(StatusCodes.Status404NotFound)] public ActionResult<BasePluginConfiguration> GetPluginConfiguration([FromRoute, Required] Guid pluginId) { - if (!(_appHost.Plugins.FirstOrDefault(p => p.Id == pluginId) is IHasPluginConfiguration plugin)) + var plugin = _pluginManager.GetPlugin(pluginId); + if (plugin?.Instance is IHasPluginConfiguration configPlugin) { - return NotFound(); + return configPlugin.Configuration; } - return plugin.Configuration; + return NotFound(); } /// <summary> @@ -105,47 +248,79 @@ namespace Jellyfin.Api.Controllers /// <param name="pluginId">Plugin id.</param> /// <response code="204">Plugin configuration updated.</response> /// <response code="404">Plugin not found or plugin does not have configuration.</response> - /// <returns> - /// A <see cref="Task" /> that represents the asynchronous operation to update plugin configuration. - /// The task result contains an <see cref="NoContentResult"/> indicating success, or <see cref="NotFoundResult"/> - /// when plugin not found or plugin doesn't have configuration. - /// </returns> + /// <returns>An <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if the plugin could not be found.</returns> [HttpPost("{pluginId}/Configuration")] [ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task<ActionResult> UpdatePluginConfiguration([FromRoute, Required] Guid pluginId) { - if (!(_appHost.Plugins.FirstOrDefault(p => p.Id == pluginId) is IHasPluginConfiguration plugin)) + var plugin = _pluginManager.GetPlugin(pluginId); + if (plugin?.Instance is not IHasPluginConfiguration configPlugin) { return NotFound(); } - var configuration = (BasePluginConfiguration?)await JsonSerializer.DeserializeAsync(Request.Body, plugin.ConfigurationType, _serializerOptions) + var configuration = (BasePluginConfiguration?)await JsonSerializer.DeserializeAsync(Request.Body, configPlugin.ConfigurationType, _serializerOptions) .ConfigureAwait(false); if (configuration != null) { - plugin.UpdateConfiguration(configuration); + configPlugin.UpdateConfiguration(configuration); } return NoContent(); } /// <summary> - /// Get plugin security info. + /// Gets a plugin's image. /// </summary> - /// <response code="200">Plugin security info returned.</response> - /// <returns>Plugin security info.</returns> - [Obsolete("This endpoint should not be used.")] - [HttpGet("SecurityInfo")] + /// <param name="pluginId">Plugin id.</param> + /// <param name="version">Plugin version.</param> + /// <response code="200">Plugin image returned.</response> + /// <returns>Plugin's image.</returns> + [HttpGet("{pluginId}/{version}/Image")] [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult<PluginSecurityInfo> GetPluginSecurityInfo() + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesImageFile] + [AllowAnonymous] + public ActionResult GetPluginImage([FromRoute, Required] Guid pluginId, [FromRoute, Required] Version version) { - return new PluginSecurityInfo + var plugin = _pluginManager.GetPlugin(pluginId, version); + if (plugin == null) { - IsMbSupporter = true, - SupporterKey = "IAmTotallyLegit" - }; + return NotFound(); + } + + var imagePath = Path.Combine(plugin.Path, plugin.Manifest.ImagePath ?? string.Empty); + if (plugin.Manifest.ImagePath == null || !System.IO.File.Exists(imagePath)) + { + return NotFound(); + } + + imagePath = Path.Combine(plugin.Path, plugin.Manifest.ImagePath); + return PhysicalFile(imagePath, MimeTypes.GetMimeType(imagePath)); + } + + /// <summary> + /// Gets a plugin's manifest. + /// </summary> + /// <param name="pluginId">Plugin id.</param> + /// <response code="204">Plugin manifest returned.</response> + /// <response code="404">Plugin not found.</response> + /// <returns>A <see cref="PluginManifest"/> on success, or a <see cref="NotFoundResult"/> if the plugin could not be found.</returns> + [HttpPost("{pluginId}/Manifest")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public ActionResult<PluginManifest> GetPluginManifest([FromRoute, Required] Guid pluginId) + { + var plugin = _pluginManager.GetPlugin(pluginId); + + if (plugin != null) + { + return plugin.Manifest; + } + + return NotFound(); } /// <summary> @@ -162,43 +337,5 @@ namespace Jellyfin.Api.Controllers { return NoContent(); } - - /// <summary> - /// Gets registration status for a feature. - /// </summary> - /// <param name="name">Feature name.</param> - /// <response code="200">Registration status returned.</response> - /// <returns>Mb registration record.</returns> - [Obsolete("This endpoint should not be used.")] - [HttpPost("RegistrationRecords/{name}")] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult<MBRegistrationRecord> GetRegistrationStatus([FromRoute, Required] string name) - { - return new MBRegistrationRecord - { - IsRegistered = true, - RegChecked = true, - TrialVersion = false, - IsValid = true, - RegError = false - }; - } - - /// <summary> - /// Gets registration status for a feature. - /// </summary> - /// <param name="name">Feature name.</param> - /// <response code="501">Not implemented.</response> - /// <returns>Not Implemented.</returns> - /// <exception cref="NotImplementedException">This endpoint is not implemented.</exception> - [Obsolete("Paid plugins are not supported")] - [HttpGet("Registrations/{name}")] - [ProducesResponseType(StatusCodes.Status501NotImplemented)] - public ActionResult GetRegistration([FromRoute, Required] string name) - { - // TODO Once we have proper apps and plugins and decide to break compatibility with paid plugins, - // delete all these registration endpoints. They are only kept for compatibility. - throw new NotImplementedException(); - } } } diff --git a/Jellyfin.Api/Controllers/QuickConnectController.cs b/Jellyfin.Api/Controllers/QuickConnectController.cs index 73da2f906a..3cd1bc6d4c 100644 --- a/Jellyfin.Api/Controllers/QuickConnectController.cs +++ b/Jellyfin.Api/Controllers/QuickConnectController.cs @@ -2,6 +2,7 @@ using System.ComponentModel.DataAnnotations; using Jellyfin.Api.Constants; using Jellyfin.Api.Helpers; using MediaBrowser.Common.Extensions; +using MediaBrowser.Controller.Authentication; using MediaBrowser.Controller.QuickConnect; using MediaBrowser.Model.QuickConnect; using Microsoft.AspNetCore.Authorization; @@ -30,13 +31,12 @@ namespace Jellyfin.Api.Controllers /// Gets the current quick connect state. /// </summary> /// <response code="200">Quick connect state returned.</response> - /// <returns>The current <see cref="QuickConnectState"/>.</returns> - [HttpGet("Status")] + /// <returns>Whether Quick Connect is enabled on the server or not.</returns> + [HttpGet("Enabled")] [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult<QuickConnectState> GetStatus() + public ActionResult<bool> GetEnabled() { - _quickConnect.ExpireRequests(); - return _quickConnect.State; + return _quickConnect.IsEnabled; } /// <summary> @@ -49,7 +49,14 @@ namespace Jellyfin.Api.Controllers [ProducesResponseType(StatusCodes.Status200OK)] public ActionResult<QuickConnectResult> Initiate() { - return _quickConnect.TryConnect(); + try + { + return _quickConnect.TryConnect(); + } + catch (AuthenticationException) + { + return Unauthorized("Quick connect is disabled"); + } } /// <summary> @@ -72,42 +79,10 @@ namespace Jellyfin.Api.Controllers { return NotFound("Unknown secret"); } - } - - /// <summary> - /// Temporarily activates quick connect for five minutes. - /// </summary> - /// <response code="204">Quick connect has been temporarily activated.</response> - /// <response code="403">Quick connect is unavailable on this server.</response> - /// <returns>An <see cref="NoContentResult"/> on success.</returns> - [HttpPost("Activate")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [ProducesResponseType(StatusCodes.Status403Forbidden)] - public ActionResult Activate() - { - if (_quickConnect.State == QuickConnectState.Unavailable) + catch (AuthenticationException) { - return Forbid("Quick connect is unavailable"); + return Unauthorized("Quick connect is disabled"); } - - _quickConnect.Activate(); - return NoContent(); - } - - /// <summary> - /// Enables or disables quick connect. - /// </summary> - /// <param name="status">New <see cref="QuickConnectState"/>.</param> - /// <response code="204">Quick connect state set successfully.</response> - /// <returns>An <see cref="NoContentResult"/> on success.</returns> - [HttpPost("Available")] - [Authorize(Policy = Policies.RequiresElevation)] - [ProducesResponseType(StatusCodes.Status204NoContent)] - public ActionResult Available([FromQuery] QuickConnectState status = QuickConnectState.Available) - { - _quickConnect.SetState(status); - return NoContent(); } /// <summary> @@ -126,29 +101,17 @@ namespace Jellyfin.Api.Controllers var userId = ClaimHelpers.GetUserId(Request.HttpContext.User); if (!userId.HasValue) { - return Forbid("Unknown user id"); + return StatusCode(StatusCodes.Status403Forbidden, "Unknown user id"); } - return _quickConnect.AuthorizeRequest(userId.Value, code); - } - - /// <summary> - /// Deauthorize all quick connect devices for the current user. - /// </summary> - /// <response code="200">All quick connect devices were deleted.</response> - /// <returns>The number of devices that were deleted.</returns> - [HttpPost("Deauthorize")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult<int> Deauthorize() - { - var userId = ClaimHelpers.GetUserId(Request.HttpContext.User); - if (!userId.HasValue) + try { - return 0; + return _quickConnect.AuthorizeRequest(userId.Value, code); + } + catch (AuthenticationException) + { + return Unauthorized("Quick connect is disabled"); } - - return _quickConnect.DeleteAllDevices(userId.Value); } } } diff --git a/Jellyfin.Api/Controllers/RemoteImageController.cs b/Jellyfin.Api/Controllers/RemoteImageController.cs index 5f095443b9..ec836f43e3 100644 --- a/Jellyfin.Api/Controllers/RemoteImageController.cs +++ b/Jellyfin.Api/Controllers/RemoteImageController.cs @@ -145,58 +145,6 @@ namespace Jellyfin.Api.Controllers return Ok(_providerManager.GetRemoteImageProviderInfo(item)); } - /// <summary> - /// Gets a remote image. - /// </summary> - /// <param name="imageUrl">The image url.</param> - /// <response code="200">Remote image returned.</response> - /// <response code="404">Remote image not found.</response> - /// <returns>Image Stream.</returns> - [HttpGet("Images/Remote")] - [Produces(MediaTypeNames.Application.Octet)] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - [ProducesImageFile] - public async Task<ActionResult> GetRemoteImage([FromQuery, Required] string imageUrl) - { - var urlHash = imageUrl.GetMD5(); - var pointerCachePath = GetFullCachePath(urlHash.ToString()); - - string? contentPath = null; - var hasFile = false; - - try - { - contentPath = await System.IO.File.ReadAllTextAsync(pointerCachePath).ConfigureAwait(false); - if (System.IO.File.Exists(contentPath)) - { - hasFile = true; - } - } - catch (FileNotFoundException) - { - // The file isn't cached yet - } - catch (IOException) - { - // The file isn't cached yet - } - - if (!hasFile) - { - await DownloadImage(imageUrl, urlHash, pointerCachePath).ConfigureAwait(false); - contentPath = await System.IO.File.ReadAllTextAsync(pointerCachePath).ConfigureAwait(false); - } - - if (string.IsNullOrEmpty(contentPath)) - { - return NotFound(); - } - - var contentType = MimeTypes.GetMimeType(contentPath); - return PhysicalFile(contentPath, contentType); - } - /// <summary> /// Downloads a remote image for an item. /// </summary> @@ -245,17 +193,26 @@ namespace Jellyfin.Api.Controllers /// <param name="urlHash">The URL hash.</param> /// <param name="pointerCachePath">The pointer cache path.</param> /// <returns>Task.</returns> - private async Task DownloadImage(string url, Guid urlHash, string pointerCachePath) + private async Task DownloadImage(Uri url, Guid urlHash, string pointerCachePath) { var httpClient = _httpClientFactory.CreateClient(NamedClient.Default); using var response = await httpClient.GetAsync(url).ConfigureAwait(false); - var ext = response.Content.Headers.ContentType.MediaType.Split('/').Last(); + if (response.Content.Headers.ContentType?.MediaType == null) + { + throw new ResourceNotFoundException(nameof(response.Content.Headers.ContentType)); + } + + var ext = response.Content.Headers.ContentType.MediaType.Split('/')[^1]; var fullCachePath = GetFullCachePath(urlHash + "." + ext); - Directory.CreateDirectory(Path.GetDirectoryName(fullCachePath)); - await using var fileStream = new FileStream(fullCachePath, FileMode.Create, FileAccess.Write, FileShare.Read, IODefaults.FileStreamBufferSize, true); + var fullCacheDirectory = Path.GetDirectoryName(fullCachePath) ?? throw new ResourceNotFoundException($"Provided path ({fullCachePath}) is not valid."); + Directory.CreateDirectory(fullCacheDirectory); + // use FileShare.None as this bypasses dotnet bug dotnet/runtime#42790 . + await using var fileStream = new FileStream(fullCachePath, FileMode.Create, FileAccess.Write, FileShare.None, IODefaults.FileStreamBufferSize, true); await response.Content.CopyToAsync(fileStream).ConfigureAwait(false); - Directory.CreateDirectory(Path.GetDirectoryName(pointerCachePath)); + + var pointerCacheDirectory = Path.GetDirectoryName(pointerCachePath) ?? throw new ArgumentException($"Provided path ({pointerCachePath}) is not valid.", nameof(pointerCachePath)); + Directory.CreateDirectory(pointerCacheDirectory); await System.IO.File.WriteAllTextAsync(pointerCachePath, fullCachePath, CancellationToken.None) .ConfigureAwait(false); } diff --git a/Jellyfin.Api/Controllers/ScheduledTasksController.cs b/Jellyfin.Api/Controllers/ScheduledTasksController.cs index ab7920895b..68e4f0586f 100644 --- a/Jellyfin.Api/Controllers/ScheduledTasksController.cs +++ b/Jellyfin.Api/Controllers/ScheduledTasksController.cs @@ -36,7 +36,7 @@ namespace Jellyfin.Api.Controllers /// <returns>The list of scheduled tasks.</returns> [HttpGet] [ProducesResponseType(StatusCodes.Status200OK)] - public IEnumerable<IScheduledTaskWorker> GetTasks( + public IEnumerable<TaskInfo> GetTasks( [FromQuery] bool? isHidden, [FromQuery] bool? isEnabled) { @@ -57,7 +57,7 @@ namespace Jellyfin.Api.Controllers } } - yield return task; + yield return ScheduledTaskHelpers.GetTaskInfo(task); } } diff --git a/Jellyfin.Api/Controllers/SearchController.cs b/Jellyfin.Api/Controllers/SearchController.cs index 62c870cb1f..73bdf90182 100644 --- a/Jellyfin.Api/Controllers/SearchController.cs +++ b/Jellyfin.Api/Controllers/SearchController.cs @@ -5,6 +5,8 @@ using System.Globalization; using System.Linq; using Jellyfin.Api.Constants; using Jellyfin.Api.Helpers; +using Jellyfin.Api.ModelBinders; +using Jellyfin.Data.Enums; using MediaBrowser.Controller.Drawing; using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; @@ -82,10 +84,10 @@ namespace Jellyfin.Api.Controllers [FromQuery] int? limit, [FromQuery] Guid? userId, [FromQuery, Required] string searchTerm, - [FromQuery] string? includeItemTypes, - [FromQuery] string? excludeItemTypes, - [FromQuery] string? mediaTypes, - [FromQuery] string? parentId, + [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, @@ -108,9 +110,9 @@ namespace Jellyfin.Api.Controllers IncludeStudios = includeStudios, StartIndex = startIndex, UserId = userId ?? Guid.Empty, - IncludeItemTypes = RequestHelpers.Split(includeItemTypes, ',', true), - ExcludeItemTypes = RequestHelpers.Split(excludeItemTypes, ',', true), - MediaTypes = RequestHelpers.Split(mediaTypes, ',', true), + IncludeItemTypes = RequestHelpers.GetItemTypeStrings(includeItemTypes), + ExcludeItemTypes = RequestHelpers.GetItemTypeStrings(excludeItemTypes), + MediaTypes = mediaTypes, ParentId = parentId, IsKids = isKids, @@ -226,10 +228,7 @@ namespace Jellyfin.Api.Controllers itemWithImage = GetParentWithImage<Series>(item, ImageType.Thumb); } - if (itemWithImage == null) - { - itemWithImage = GetParentWithImage<BaseItem>(item, ImageType.Thumb); - } + itemWithImage ??= GetParentWithImage<BaseItem>(item, ImageType.Thumb); if (itemWithImage != null) { @@ -260,7 +259,7 @@ namespace Jellyfin.Api.Controllers } } - private T GetParentWithImage<T>(BaseItem item, ImageType type) + private T? GetParentWithImage<T>(BaseItem item, ImageType type) where T : BaseItem { return item.GetParents().OfType<T>().FirstOrDefault(i => i.HasImage(type)); diff --git a/Jellyfin.Api/Controllers/SessionController.cs b/Jellyfin.Api/Controllers/SessionController.cs index 5656709629..7bd0b6918f 100644 --- a/Jellyfin.Api/Controllers/SessionController.cs +++ b/Jellyfin.Api/Controllers/SessionController.cs @@ -6,6 +6,7 @@ using System.Threading; using Jellyfin.Api.Constants; using Jellyfin.Api.Helpers; using Jellyfin.Api.ModelBinders; +using Jellyfin.Api.Models.SessionDtos; using Jellyfin.Data.Enums; using MediaBrowser.Controller.Devices; using MediaBrowser.Controller.Library; @@ -152,6 +153,10 @@ namespace Jellyfin.Api.Controllers /// <param name="playCommand">The type of play command to issue (PlayNow, PlayNext, PlayLast). Clients who have not yet implemented play next and play last may play now.</param> /// <param name="itemIds">The ids of the items to play, comma delimited.</param> /// <param name="startPositionTicks">The starting position of the first item.</param> + /// <param name="mediaSourceId">Optional. The media source id.</param> + /// <param name="audioStreamIndex">Optional. The index of the audio stream to play.</param> + /// <param name="subtitleStreamIndex">Optional. The index of the subtitle stream to play.</param> + /// <param name="startIndex">Optional. The start index.</param> /// <response code="204">Instruction sent to session.</response> /// <returns>A <see cref="NoContentResult"/>.</returns> [HttpPost("Sessions/{sessionId}/Playing")] @@ -160,14 +165,22 @@ namespace Jellyfin.Api.Controllers public ActionResult Play( [FromRoute, Required] string sessionId, [FromQuery, Required] PlayCommand playCommand, - [FromQuery, Required] string itemIds, - [FromQuery] long? startPositionTicks) + [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 = RequestHelpers.GetGuids(itemIds), + ItemIds = itemIds, StartPositionTicks = startPositionTicks, - PlayCommand = playCommand + PlayCommand = playCommand, + MediaSourceId = mediaSourceId, + AudioStreamIndex = audioStreamIndex, + SubtitleStreamIndex = subtitleStreamIndex, + StartIndex = startIndex }; _sessionManager.SendPlayCommand( @@ -300,9 +313,7 @@ namespace Jellyfin.Api.Controllers /// Issues a command to a client to display a message to the user. /// </summary> /// <param name="sessionId">The session id.</param> - /// <param name="text">The message test.</param> - /// <param name="header">The message header.</param> - /// <param name="timeoutMs">The message timeout. If omitted the user will have to confirm viewing the message.</param> + /// <param name="command">The <see cref="MessageCommand" /> object containing Header, Message Text, and TimeoutMs.</param> /// <response code="204">Message sent.</response> /// <returns>A <see cref="NoContentResult"/>.</returns> [HttpPost("Sessions/{sessionId}/Message")] @@ -310,16 +321,12 @@ namespace Jellyfin.Api.Controllers [ProducesResponseType(StatusCodes.Status204NoContent)] public ActionResult SendMessageCommand( [FromRoute, Required] string sessionId, - [FromQuery, Required] string text, - [FromQuery] string? header, - [FromQuery] long? timeoutMs) + [FromBody, Required] MessageCommand command) { - var command = new MessageCommand + if (string.IsNullOrWhiteSpace(command.Header)) { - Header = string.IsNullOrEmpty(header) ? "Message from Server" : header, - TimeoutMs = timeoutMs, - Text = text - }; + command.Header = "Message from Server"; + } _sessionManager.SendMessageCommand(RequestHelpers.GetSession(_sessionManager, _authContext, Request).Id, sessionId, command, CancellationToken.None); @@ -378,8 +385,8 @@ namespace Jellyfin.Api.Controllers [ProducesResponseType(StatusCodes.Status204NoContent)] public ActionResult PostCapabilities( [FromQuery] string? id, - [FromQuery] string? playableMediaTypes, - [FromQuery] GeneralCommandType[] supportedCommands, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] playableMediaTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] GeneralCommandType[] supportedCommands, [FromQuery] bool supportsMediaControl = false, [FromQuery] bool supportsSync = false, [FromQuery] bool supportsPersistentIdentifier = true) @@ -391,7 +398,7 @@ namespace Jellyfin.Api.Controllers _sessionManager.ReportCapabilities(id, new ClientCapabilities { - PlayableMediaTypes = RequestHelpers.Split(playableMediaTypes, ',', true), + PlayableMediaTypes = playableMediaTypes, SupportedCommands = supportedCommands, SupportsMediaControl = supportsMediaControl, SupportsSync = supportsSync, @@ -412,14 +419,14 @@ namespace Jellyfin.Api.Controllers [ProducesResponseType(StatusCodes.Status204NoContent)] public ActionResult PostFullCapabilities( [FromQuery] string? id, - [FromBody, Required] ClientCapabilities capabilities) + [FromBody, Required] ClientCapabilitiesDto capabilities) { if (string.IsNullOrWhiteSpace(id)) { id = RequestHelpers.GetSession(_sessionManager, _authContext, Request).Id; } - _sessionManager.ReportCapabilities(id, capabilities); + _sessionManager.ReportCapabilities(id, capabilities.ToClientCapabilities()); return NoContent(); } diff --git a/Jellyfin.Api/Controllers/StartupController.cs b/Jellyfin.Api/Controllers/StartupController.cs index 9c259cc198..a01a617fc0 100644 --- a/Jellyfin.Api/Controllers/StartupController.cs +++ b/Jellyfin.Api/Controllers/StartupController.cs @@ -3,6 +3,7 @@ using System.Linq; using System.Threading.Tasks; using Jellyfin.Api.Constants; using Jellyfin.Api.Models.StartupDtos; +using Jellyfin.Networking.Configuration; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Library; using Microsoft.AspNetCore.Authorization; @@ -72,9 +73,9 @@ namespace Jellyfin.Api.Controllers [ProducesResponseType(StatusCodes.Status204NoContent)] public ActionResult UpdateInitialConfiguration([FromBody, Required] StartupConfigurationDto startupConfiguration) { - _config.Configuration.UICulture = startupConfiguration.UICulture; - _config.Configuration.MetadataCountryCode = startupConfiguration.MetadataCountryCode; - _config.Configuration.PreferredMetadataLanguage = startupConfiguration.PreferredMetadataLanguage; + _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(); } @@ -89,9 +90,10 @@ namespace Jellyfin.Api.Controllers [ProducesResponseType(StatusCodes.Status204NoContent)] public ActionResult SetRemoteAccess([FromBody, Required] StartupRemoteAccessDto startupRemoteAccessDto) { - _config.Configuration.EnableRemoteAccess = startupRemoteAccessDto.EnableRemoteAccess; - _config.Configuration.EnableUPnP = startupRemoteAccessDto.EnableAutomaticPortMapping; - _config.SaveConfiguration(); + NetworkConfiguration settings = _config.GetNetworkConfiguration(); + settings.EnableRemoteAccess = startupRemoteAccessDto.EnableRemoteAccess; + settings.EnableUPnP = startupRemoteAccessDto.EnableAutomaticPortMapping; + _config.SaveConfiguration("network", settings); return NoContent(); } @@ -130,7 +132,10 @@ namespace Jellyfin.Api.Controllers { var user = _userManager.Users.First(); - user.Username = startupUserDto.Name; + if (startupUserDto.Name != null) + { + user.Username = startupUserDto.Name; + } await _userManager.UpdateUserAsync(user).ConfigureAwait(false); diff --git a/Jellyfin.Api/Controllers/StudiosController.cs b/Jellyfin.Api/Controllers/StudiosController.cs index 3a8ac1b243..da8f8b1995 100644 --- a/Jellyfin.Api/Controllers/StudiosController.cs +++ b/Jellyfin.Api/Controllers/StudiosController.cs @@ -1,14 +1,16 @@ -using System; +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 Jellyfin.Data.Enums; using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; using MediaBrowser.Model.Dto; +using MediaBrowser.Model.Entities; using MediaBrowser.Model.Querying; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; @@ -45,30 +47,17 @@ namespace Jellyfin.Api.Controllers /// <summary> /// Gets all studios from a given item, folder, or the entire library. /// </summary> - /// <param name="minCommunityRating">Optional filter by minimum community rating.</param> /// <param name="startIndex">Optional. The record index to start at. All items with a lower index will be dropped from the results.</param> /// <param name="limit">Optional. The maximum number of records to return.</param> /// <param name="searchTerm">Optional. Search term.</param> /// <param name="parentId">Specify this to localize the search to a specific item or folder. Omit to use the root.</param> - /// <param name="fields">Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimited. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines.</param> + /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param> /// <param name="excludeItemTypes">Optional. If specified, results will be filtered out based on item type. This allows multiple, comma delimited.</param> /// <param name="includeItemTypes">Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimited.</param> - /// <param name="filters">Optional. Specify additional filters to apply.</param> /// <param name="isFavorite">Optional filter by items that are marked as favorite, or not.</param> - /// <param name="mediaTypes">Optional filter by MediaType. Allows multiple, comma delimited.</param> - /// <param name="genres">Optional. If specified, results will be filtered based on genre. This allows multiple, pipe delimited.</param> - /// <param name="genreIds">Optional. If specified, results will be filtered based on genre id. This allows multiple, pipe delimited.</param> - /// <param name="officialRatings">Optional. If specified, results will be filtered based on OfficialRating. This allows multiple, pipe delimited.</param> - /// <param name="tags">Optional. If specified, results will be filtered based on tag. This allows multiple, pipe delimited.</param> - /// <param name="years">Optional. If specified, results will be filtered based on production year. This allows multiple, comma delimited.</param> /// <param name="enableUserData">Optional, include user data.</param> /// <param name="imageTypeLimit">Optional, the max number of images to return, per image type.</param> /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> - /// <param name="person">Optional. If specified, results will be filtered to include only those containing the specified person.</param> - /// <param name="personIds">Optional. If specified, results will be filtered to include only those containing the specified person ids.</param> - /// <param name="personTypes">Optional. If specified, along with Person, results will be filtered to include only those containing the specified person and PersonType. Allows multiple, comma-delimited.</param> - /// <param name="studios">Optional. If specified, results will be filtered based on studio. This allows multiple, pipe delimited.</param> - /// <param name="studioIds">Optional. If specified, results will be filtered based on studio id. This allows multiple, pipe delimited.</param> /// <param name="userId">User id.</param> /// <param name="nameStartsWithOrGreater">Optional filter by items whose name is sorted equally or greater than a given input string.</param> /// <param name="nameStartsWith">Optional filter by items whose name is sorted equally than a given input string.</param> @@ -80,30 +69,17 @@ namespace Jellyfin.Api.Controllers [HttpGet] [ProducesResponseType(StatusCodes.Status200OK)] public ActionResult<QueryResult<BaseItemDto>> GetStudios( - [FromQuery] double? minCommunityRating, [FromQuery] int? startIndex, [FromQuery] int? limit, [FromQuery] string? searchTerm, - [FromQuery] string? parentId, - [FromQuery] string? fields, - [FromQuery] string? excludeItemTypes, - [FromQuery] string? includeItemTypes, - [FromQuery] ItemFilter[] filters, + [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] string? mediaTypes, - [FromQuery] string? genres, - [FromQuery] string? genreIds, - [FromQuery] string? officialRatings, - [FromQuery] string? tags, - [FromQuery] string? years, [FromQuery] bool? enableUserData, [FromQuery] int? imageTypeLimit, - [FromQuery] string? enableImageTypes, - [FromQuery] string? person, - [FromQuery] string? personIds, - [FromQuery] string? personTypes, - [FromQuery] string? studios, - [FromQuery] string? studioIds, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes, [FromQuery] Guid? userId, [FromQuery] string? nameStartsWithOrGreater, [FromQuery] string? nameStartsWith, @@ -111,144 +87,44 @@ namespace Jellyfin.Api.Controllers [FromQuery] bool? enableImages = true, [FromQuery] bool enableTotalRecordCount = true) { - var dtoOptions = new DtoOptions() - .AddItemFields(fields) + var dtoOptions = new DtoOptions { Fields = fields } .AddClientFields(Request) .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); - User? user = null; - BaseItem parentItem; + User? user = userId.HasValue && userId != Guid.Empty ? _userManager.GetUserById(userId.Value) : null; - if (userId.HasValue && !userId.Equals(Guid.Empty)) - { - user = _userManager.GetUserById(userId.Value); - parentItem = string.IsNullOrEmpty(parentId) ? _libraryManager.GetUserRootFolder() : _libraryManager.GetItemById(parentId); - } - else - { - parentItem = string.IsNullOrEmpty(parentId) ? _libraryManager.RootFolder : _libraryManager.GetItemById(parentId); - } - - var excludeItemTypesArr = RequestHelpers.Split(excludeItemTypes, ',', true); - var includeItemTypesArr = RequestHelpers.Split(includeItemTypes, ',', true); - var mediaTypesArr = RequestHelpers.Split(mediaTypes, ',', true); + var parentItem = _libraryManager.GetParentItem(parentId, userId); var query = new InternalItemsQuery(user) { - ExcludeItemTypes = excludeItemTypesArr, - IncludeItemTypes = includeItemTypesArr, - MediaTypes = mediaTypesArr, + ExcludeItemTypes = RequestHelpers.GetItemTypeStrings(excludeItemTypes), + IncludeItemTypes = RequestHelpers.GetItemTypeStrings(includeItemTypes), StartIndex = startIndex, Limit = limit, IsFavorite = isFavorite, NameLessThan = nameLessThan, NameStartsWith = nameStartsWith, NameStartsWithOrGreater = nameStartsWithOrGreater, - Tags = RequestHelpers.Split(tags, ',', true), - OfficialRatings = RequestHelpers.Split(officialRatings, ',', true), - Genres = RequestHelpers.Split(genres, ',', true), - GenreIds = RequestHelpers.GetGuids(genreIds), - StudioIds = RequestHelpers.GetGuids(studioIds), - Person = person, - PersonIds = RequestHelpers.GetGuids(personIds), - PersonTypes = RequestHelpers.Split(personTypes, ',', true), - Years = RequestHelpers.Split(years, ',', true).Select(int.Parse).ToArray(), - MinCommunityRating = minCommunityRating, DtoOptions = dtoOptions, SearchTerm = searchTerm, EnableTotalRecordCount = enableTotalRecordCount }; - if (!string.IsNullOrWhiteSpace(parentId)) + if (parentId.HasValue) { if (parentItem is Folder) { - query.AncestorIds = new[] { new Guid(parentId) }; + query.AncestorIds = new[] { parentId.Value }; } else { - query.ItemIds = new[] { new Guid(parentId) }; + query.ItemIds = new[] { parentId.Value }; } } - // Studios - if (!string.IsNullOrEmpty(studios)) - { - query.StudioIds = studios.Split('|').Select(i => - { - try - { - return _libraryManager.GetStudio(i); - } - catch - { - return null; - } - }).Where(i => i != 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 = new QueryResult<(BaseItem, ItemCounts)>(); - var dtos = result.Items.Select(i => - { - var (baseItem, itemCounts) = i; - var dto = _dtoService.GetItemByNameDto(baseItem, dtoOptions, null, user); - - if (!string.IsNullOrWhiteSpace(includeItemTypes)) - { - 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<BaseItemDto> - { - Items = dtos.ToArray(), - TotalRecordCount = result.TotalRecordCount - }; + var result = _libraryManager.GetStudios(query); + var shouldIncludeItemTypes = includeItemTypes.Length != 0; + return RequestHelpers.CreateQueryResult(result, dtoOptions, _dtoService, shouldIncludeItemTypes, user); } /// <summary> diff --git a/Jellyfin.Api/Controllers/SubtitleController.cs b/Jellyfin.Api/Controllers/SubtitleController.cs index cc682ed542..b473574e0a 100644 --- a/Jellyfin.Api/Controllers/SubtitleController.cs +++ b/Jellyfin.Api/Controllers/SubtitleController.cs @@ -11,6 +11,9 @@ using System.Threading; using System.Threading.Tasks; using Jellyfin.Api.Attributes; using Jellyfin.Api.Constants; +using Jellyfin.Api.Models.SubtitleDtos; +using MediaBrowser.Common.Configuration; +using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.MediaEncoding; @@ -21,6 +24,7 @@ using MediaBrowser.Model.Entities; using MediaBrowser.Model.IO; using MediaBrowser.Model.Net; using MediaBrowser.Model.Providers; +using MediaBrowser.Model.Subtitles; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; @@ -34,6 +38,7 @@ namespace Jellyfin.Api.Controllers [Route("")] public class SubtitleController : BaseJellyfinApiController { + private readonly IServerConfigurationManager _serverConfigurationManager; private readonly ILibraryManager _libraryManager; private readonly ISubtitleManager _subtitleManager; private readonly ISubtitleEncoder _subtitleEncoder; @@ -46,6 +51,7 @@ namespace Jellyfin.Api.Controllers /// <summary> /// Initializes a new instance of the <see cref="SubtitleController"/> class. /// </summary> + /// <param name="serverConfigurationManager">Instance of <see cref="IServerConfigurationManager"/> interface.</param> /// <param name="libraryManager">Instance of <see cref="ILibraryManager"/> interface.</param> /// <param name="subtitleManager">Instance of <see cref="ISubtitleManager"/> interface.</param> /// <param name="subtitleEncoder">Instance of <see cref="ISubtitleEncoder"/> interface.</param> @@ -55,6 +61,7 @@ namespace Jellyfin.Api.Controllers /// <param name="authContext">Instance of <see cref="IAuthorizationContext"/> interface.</param> /// <param name="logger">Instance of <see cref="ILogger{SubtitleController}"/> interface.</param> public SubtitleController( + IServerConfigurationManager serverConfigurationManager, ILibraryManager libraryManager, ISubtitleManager subtitleManager, ISubtitleEncoder subtitleEncoder, @@ -64,6 +71,7 @@ namespace Jellyfin.Api.Controllers IAuthorizationContext authContext, ILogger<SubtitleController> logger) { + _serverConfigurationManager = serverConfigurationManager; _libraryManager = libraryManager; _subtitleManager = subtitleManager; _subtitleEncoder = subtitleEncoder; @@ -174,6 +182,10 @@ namespace Jellyfin.Api.Controllers /// <summary> /// Gets subtitles in a specified format. /// </summary> + /// <param name="routeItemId">The (route) item id.</param> + /// <param name="routeMediaSourceId">The (route) media source id.</param> + /// <param name="routeIndex">The (route) subtitle stream index.</param> + /// <param name="routeFormat">The (route) format of the returned subtitle.</param> /// <param name="itemId">The item id.</param> /// <param name="mediaSourceId">The media source id.</param> /// <param name="index">The subtitle stream index.</param> @@ -181,23 +193,32 @@ namespace Jellyfin.Api.Controllers /// <param name="endPositionTicks">Optional. The end position of the subtitle in ticks.</param> /// <param name="copyTimestamps">Optional. Whether to copy the timestamps.</param> /// <param name="addVttTimeMap">Optional. Whether to add a VTT time map.</param> - /// <param name="startPositionTicks">Optional. The start position of the subtitle in ticks.</param> + /// <param name="startPositionTicks">The start position of the subtitle in ticks.</param> /// <response code="200">File returned.</response> /// <returns>A <see cref="FileContentResult"/> with the subtitle file.</returns> - [HttpGet("Videos/{itemId}/{mediaSourceId}/Subtitles/{index}/Stream.{format}")] - [HttpGet("Videos/{itemId}/{mediaSourceId}/Subtitles/{index}/{startPositionTicks?}/Stream.{format}", Name = "GetSubtitle_2")] + [HttpGet("Videos/{routeItemId}/{routeMediaSourceId}/Subtitles/{routeIndex}/Stream.{routeFormat}")] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesFile("text/*")] public async Task<ActionResult> GetSubtitle( - [FromRoute, Required] Guid itemId, - [FromRoute, Required] string mediaSourceId, - [FromRoute, Required] int index, - [FromRoute, Required] string format, + [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, - [FromRoute] long startPositionTicks = 0) + [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"; @@ -205,9 +226,9 @@ namespace Jellyfin.Api.Controllers if (string.IsNullOrEmpty(format)) { - var item = (Video)_libraryManager.GetItemById(itemId); + var item = (Video)_libraryManager.GetItemById(itemId.Value); - var idString = itemId.ToString("N", CultureInfo.InvariantCulture); + var idString = itemId.Value.ToString("N", CultureInfo.InvariantCulture); var mediaSource = _mediaSourceManager.GetStaticMediaSources(item, false) .First(i => string.Equals(i.Id, mediaSourceId ?? idString, StringComparison.Ordinal)); @@ -219,7 +240,7 @@ namespace Jellyfin.Api.Controllers if (string.Equals(format, "vtt", StringComparison.OrdinalIgnoreCase) && addVttTimeMap) { - await using Stream stream = await EncodeSubtitles(itemId, mediaSourceId, index, format, startPositionTicks, endPositionTicks, copyTimestamps).ConfigureAwait(false); + await using Stream stream = await EncodeSubtitles(itemId.Value, mediaSourceId, index.Value, format, startPositionTicks, endPositionTicks, copyTimestamps).ConfigureAwait(false); using var reader = new StreamReader(stream); var text = await reader.ReadToEndAsync().ConfigureAwait(false); @@ -231,9 +252,9 @@ namespace Jellyfin.Api.Controllers return File( await EncodeSubtitles( - itemId, + itemId.Value, mediaSourceId, - index, + index.Value, format, startPositionTicks, endPositionTicks, @@ -241,6 +262,57 @@ namespace Jellyfin.Api.Controllers MimeTypes.GetMimeType("file." + format)); } + /// <summary> + /// Gets subtitles in a specified format. + /// </summary> + /// <param name="routeItemId">The (route) item id.</param> + /// <param name="routeMediaSourceId">The (route) media source id.</param> + /// <param name="routeIndex">The (route) subtitle stream index.</param> + /// <param name="routeStartPositionTicks">The (route) start position of the subtitle in ticks.</param> + /// <param name="routeFormat">The (route) format of the returned subtitle.</param> + /// <param name="itemId">The item id.</param> + /// <param name="mediaSourceId">The media source id.</param> + /// <param name="index">The subtitle stream index.</param> + /// <param name="startPositionTicks">The start position of the subtitle in ticks.</param> + /// <param name="format">The format of the returned subtitle.</param> + /// <param name="endPositionTicks">Optional. The end position of the subtitle in ticks.</param> + /// <param name="copyTimestamps">Optional. Whether to copy the timestamps.</param> + /// <param name="addVttTimeMap">Optional. Whether to add a VTT time map.</param> + /// <response code="200">File returned.</response> + /// <returns>A <see cref="FileContentResult"/> with the subtitle file.</returns> + [HttpGet("Videos/{routeItemId}/{routeMediaSourceId}/Subtitles/{routeIndex}/{routeStartPositionTicks}/Stream.{routeFormat}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesFile("text/*")] + public Task<ActionResult> GetSubtitleWithTicks( + [FromRoute, Required] Guid routeItemId, + [FromRoute, Required] string routeMediaSourceId, + [FromRoute, Required] int routeIndex, + [FromRoute, Required] long routeStartPositionTicks, + [FromRoute, Required] string routeFormat, + [FromQuery, ParameterObsolete] Guid? itemId, + [FromQuery, ParameterObsolete] string? mediaSourceId, + [FromQuery, ParameterObsolete] int? index, + [FromQuery, ParameterObsolete] long? startPositionTicks, + [FromQuery, ParameterObsolete] string? format, + [FromQuery] long? endPositionTicks, + [FromQuery] bool copyTimestamps = false, + [FromQuery] bool addVttTimeMap = false) + { + return GetSubtitle( + routeItemId, + routeMediaSourceId, + routeIndex, + routeFormat, + itemId, + mediaSourceId, + index, + format, + endPositionTicks, + copyTimestamps, + addVttTimeMap, + startPositionTicks ?? routeStartPositionTicks); + } + /// <summary> /// Gets an HLS subtitle playlist. /// </summary> @@ -319,6 +391,35 @@ namespace Jellyfin.Api.Controllers return File(Encoding.UTF8.GetBytes(builder.ToString()), MimeTypes.GetMimeType("playlist.m3u8")); } + /// <summary> + /// Upload an external subtitle file. + /// </summary> + /// <param name="itemId">The item the subtitle belongs to.</param> + /// <param name="body">The request body.</param> + /// <response code="204">Subtitle uploaded.</response> + /// <returns>A <see cref="NoContentResult"/>.</returns> + [HttpPost("Videos/{itemId}/Subtitles")] + [Authorize(Policy = Policies.RequiresElevation)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public async Task<ActionResult> UploadSubtitle( + [FromRoute, Required] Guid itemId, + [FromBody, Required] UploadSubtitleDto body) + { + var video = (Video)_libraryManager.GetItemById(itemId); + var data = Convert.FromBase64String(body.Data); + await using var memoryStream = new MemoryStream(data); + await _subtitleManager.UploadSubtitle( + video, + new SubtitleResponse + { + Format = body.Format, + Language = body.Language, + IsForced = body.IsForced, + Stream = memoryStream + }).ConfigureAwait(false); + return NoContent(); + } + /// <summary> /// Encodes a subtitle in the specified format. /// </summary> @@ -351,5 +452,96 @@ namespace Jellyfin.Api.Controllers copyTimestamps, CancellationToken.None); } + + /// <summary> + /// Gets a list of available fallback font files. + /// </summary> + /// <response code="200">Information retrieved.</response> + /// <returns>An array of <see cref="FontFile"/> with the available font files.</returns> + [HttpGet("FallbackFont/Fonts")] + [Authorize(Policy = Policies.DefaultAuthorization)] + [ProducesResponseType(StatusCodes.Status200OK)] + public IEnumerable<FontFile> 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; + } + } + + /// <summary> + /// Gets a fallback font file. + /// </summary> + /// <param name="name">The name of the fallback font file to get.</param> + /// <response code="200">Fallback font file retrieved.</response> + /// <returns>The fallback font file.</returns> + [HttpGet("FallbackFont/Fonts/{name}")] + [Authorize(Policy = Policies.DefaultAuthorization)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesFile("font/*")] + public ActionResult GetFallbackFont([FromRoute, Required] string name) + { + 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 != null && fileSize != null && fileSize > 0) + { + _logger.LogDebug("Fallback font size is {fileSize} Bytes", fileSize); + return PhysicalFile(fontFile.FullName, MimeTypes.GetMimeType(fontFile.FullName)); + } + else + { + _logger.LogWarning("The selected font is null or empty"); + } + } + else + { + _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 d7c81a3ab6..a811a29c31 100644 --- a/Jellyfin.Api/Controllers/SuggestionsController.cs +++ b/Jellyfin.Api/Controllers/SuggestionsController.cs @@ -1,14 +1,16 @@ -using System; +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.Enums; using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; using MediaBrowser.Model.Dto; using MediaBrowser.Model.Querying; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; @@ -18,6 +20,7 @@ namespace Jellyfin.Api.Controllers /// The suggestions controller. /// </summary> [Route("")] + [Authorize(Policy = Policies.DefaultAuthorization)] public class SuggestionsController : BaseJellyfinApiController { private readonly IDtoService _dtoService; @@ -55,8 +58,8 @@ namespace Jellyfin.Api.Controllers [ProducesResponseType(StatusCodes.Status200OK)] public ActionResult<QueryResult<BaseItemDto>> GetSuggestions( [FromRoute, Required] Guid userId, - [FromQuery] string? mediaType, - [FromQuery] string? type, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] mediaType, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] type, [FromQuery] int? startIndex, [FromQuery] int? limit, [FromQuery] bool enableTotalRecordCount = false) @@ -66,9 +69,9 @@ namespace Jellyfin.Api.Controllers var dtoOptions = new DtoOptions().AddClientFields(Request); var result = _libraryManager.GetItemsResult(new InternalItemsQuery(user) { - OrderBy = new[] { ItemSortBy.Random }.Select(i => new ValueTuple<string, SortOrder>(i, SortOrder.Descending)).ToArray(), - MediaTypes = RequestHelpers.Split(mediaType!, ',', true), - IncludeItemTypes = RequestHelpers.Split(type!, ',', true), + OrderBy = new[] { (ItemSortBy.Random, SortOrder.Descending) }, + MediaTypes = mediaType, + IncludeItemTypes = type, IsVirtualItem = false, StartIndex = startIndex, Limit = limit, diff --git a/Jellyfin.Api/Controllers/SyncPlayController.cs b/Jellyfin.Api/Controllers/SyncPlayController.cs index e16a10ba4d..f878f2329c 100644 --- a/Jellyfin.Api/Controllers/SyncPlayController.cs +++ b/Jellyfin.Api/Controllers/SyncPlayController.cs @@ -1,12 +1,14 @@ -using System; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.Threading; using Jellyfin.Api.Constants; using Jellyfin.Api.Helpers; +using Jellyfin.Api.Models.SyncPlayDtos; using MediaBrowser.Controller.Net; using MediaBrowser.Controller.Session; using MediaBrowser.Controller.SyncPlay; +using MediaBrowser.Controller.SyncPlay.PlaybackRequests; +using MediaBrowser.Controller.SyncPlay.Requests; using MediaBrowser.Model.SyncPlay; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; @@ -17,7 +19,7 @@ namespace Jellyfin.Api.Controllers /// <summary> /// The sync play controller. /// </summary> - [Authorize(Policy = Policies.DefaultAuthorization)] + [Authorize(Policy = Policies.SyncPlayHasAccess)] public class SyncPlayController : BaseJellyfinApiController { private readonly ISessionManager _sessionManager; @@ -43,35 +45,36 @@ namespace Jellyfin.Api.Controllers /// <summary> /// Create a new SyncPlay group. /// </summary> + /// <param name="requestData">The settings of the new group.</param> /// <response code="204">New group created.</response> /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> [HttpPost("New")] [ProducesResponseType(StatusCodes.Status204NoContent)] - public ActionResult SyncPlayCreateGroup() + [Authorize(Policy = Policies.SyncPlayCreateGroup)] + public ActionResult SyncPlayCreateGroup( + [FromBody, Required] NewGroupRequestDto requestData) { var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request); - _syncPlayManager.NewGroup(currentSession, CancellationToken.None); + var syncPlayRequest = new NewGroupRequest(requestData.GroupName); + _syncPlayManager.NewGroup(currentSession, syncPlayRequest, CancellationToken.None); return NoContent(); } /// <summary> /// Join an existing SyncPlay group. /// </summary> - /// <param name="groupId">The sync play group id.</param> + /// <param name="requestData">The group to join.</param> /// <response code="204">Group join successful.</response> /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> [HttpPost("Join")] [ProducesResponseType(StatusCodes.Status204NoContent)] - public ActionResult SyncPlayJoinGroup([FromQuery, Required] Guid groupId) + [Authorize(Policy = Policies.SyncPlayJoinGroup)] + public ActionResult SyncPlayJoinGroup( + [FromBody, Required] JoinGroupRequestDto requestData) { var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request); - - var joinRequest = new JoinGroupRequest() - { - GroupId = groupId - }; - - _syncPlayManager.JoinGroup(currentSession, groupId, joinRequest, CancellationToken.None); + var syncPlayRequest = new JoinGroupRequest(requestData.GroupId); + _syncPlayManager.JoinGroup(currentSession, syncPlayRequest, CancellationToken.None); return NoContent(); } @@ -82,41 +85,135 @@ namespace Jellyfin.Api.Controllers /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> [HttpPost("Leave")] [ProducesResponseType(StatusCodes.Status204NoContent)] + [Authorize(Policy = Policies.SyncPlayIsInGroup)] public ActionResult SyncPlayLeaveGroup() { var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request); - _syncPlayManager.LeaveGroup(currentSession, CancellationToken.None); + var syncPlayRequest = new LeaveGroupRequest(); + _syncPlayManager.LeaveGroup(currentSession, syncPlayRequest, CancellationToken.None); return NoContent(); } /// <summary> /// Gets all SyncPlay groups. /// </summary> - /// <param name="filterItemId">Optional. Filter by item id.</param> /// <response code="200">Groups returned.</response> /// <returns>An <see cref="IEnumerable{GroupInfoView}"/> containing the available SyncPlay groups.</returns> [HttpGet("List")] [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult<IEnumerable<GroupInfoView>> SyncPlayGetGroups([FromQuery] Guid? filterItemId) + [Authorize(Policy = Policies.SyncPlayJoinGroup)] + public ActionResult<IEnumerable<GroupInfoDto>> SyncPlayGetGroups() + { + var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request); + var syncPlayRequest = new ListGroupsRequest(); + return Ok(_syncPlayManager.ListGroups(currentSession, syncPlayRequest)); + } + + /// <summary> + /// Request to set new playlist in SyncPlay group. + /// </summary> + /// <param name="requestData">The new playlist to play in the group.</param> + /// <response code="204">Queue update sent to all group members.</response> + /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> + [HttpPost("SetNewQueue")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [Authorize(Policy = Policies.SyncPlayIsInGroup)] + public ActionResult SyncPlaySetNewQueue( + [FromBody, Required] PlayRequestDto requestData) { var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request); - return Ok(_syncPlayManager.ListGroups(currentSession, filterItemId.HasValue ? filterItemId.Value : Guid.Empty)); + var syncPlayRequest = new PlayGroupRequest( + requestData.PlayingQueue, + requestData.PlayingItemPosition, + requestData.StartPositionTicks); + _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None); + return NoContent(); } /// <summary> - /// Request play in SyncPlay group. + /// Request to change playlist item in SyncPlay group. /// </summary> - /// <response code="204">Play request sent to all group members.</response> + /// <param name="requestData">The new item to play.</param> + /// <response code="204">Queue update sent to all group members.</response> /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> - [HttpPost("Play")] + [HttpPost("SetPlaylistItem")] [ProducesResponseType(StatusCodes.Status204NoContent)] - public ActionResult SyncPlayPlay() + [Authorize(Policy = Policies.SyncPlayIsInGroup)] + public ActionResult SyncPlaySetPlaylistItem( + [FromBody, Required] SetPlaylistItemRequestDto requestData) { var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request); - var syncPlayRequest = new PlaybackRequest() - { - Type = PlaybackRequestType.Play - }; + var syncPlayRequest = new SetPlaylistItemGroupRequest(requestData.PlaylistItemId); + _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None); + return NoContent(); + } + + /// <summary> + /// Request to remove items from the playlist in SyncPlay group. + /// </summary> + /// <param name="requestData">The items to remove.</param> + /// <response code="204">Queue update sent to all group members.</response> + /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> + [HttpPost("RemoveFromPlaylist")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [Authorize(Policy = Policies.SyncPlayIsInGroup)] + public ActionResult SyncPlayRemoveFromPlaylist( + [FromBody, Required] RemoveFromPlaylistRequestDto requestData) + { + var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request); + var syncPlayRequest = new RemoveFromPlaylistGroupRequest(requestData.PlaylistItemIds); + _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None); + return NoContent(); + } + + /// <summary> + /// Request to move an item in the playlist in SyncPlay group. + /// </summary> + /// <param name="requestData">The new position for the item.</param> + /// <response code="204">Queue update sent to all group members.</response> + /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> + [HttpPost("MovePlaylistItem")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [Authorize(Policy = Policies.SyncPlayIsInGroup)] + public ActionResult SyncPlayMovePlaylistItem( + [FromBody, Required] MovePlaylistItemRequestDto requestData) + { + var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request); + var syncPlayRequest = new MovePlaylistItemGroupRequest(requestData.PlaylistItemId, requestData.NewIndex); + _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None); + return NoContent(); + } + + /// <summary> + /// Request to queue items to the playlist of a SyncPlay group. + /// </summary> + /// <param name="requestData">The items to add.</param> + /// <response code="204">Queue update sent to all group members.</response> + /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> + [HttpPost("Queue")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [Authorize(Policy = Policies.SyncPlayIsInGroup)] + public ActionResult SyncPlayQueue( + [FromBody, Required] QueueRequestDto requestData) + { + var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request); + var syncPlayRequest = new QueueGroupRequest(requestData.ItemIds, requestData.Mode); + _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None); + return NoContent(); + } + + /// <summary> + /// Request unpause in SyncPlay group. + /// </summary> + /// <response code="204">Unpause update sent to all group members.</response> + /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> + [HttpPost("Unpause")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [Authorize(Policy = Policies.SyncPlayIsInGroup)] + public ActionResult SyncPlayUnpause() + { + var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request); + var syncPlayRequest = new UnpauseGroupRequest(); _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None); return NoContent(); } @@ -124,17 +221,31 @@ namespace Jellyfin.Api.Controllers /// <summary> /// Request pause in SyncPlay group. /// </summary> - /// <response code="204">Pause request sent to all group members.</response> + /// <response code="204">Pause update sent to all group members.</response> /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> [HttpPost("Pause")] [ProducesResponseType(StatusCodes.Status204NoContent)] + [Authorize(Policy = Policies.SyncPlayIsInGroup)] public ActionResult SyncPlayPause() { var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request); - var syncPlayRequest = new PlaybackRequest() - { - Type = PlaybackRequestType.Pause - }; + var syncPlayRequest = new PauseGroupRequest(); + _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None); + return NoContent(); + } + + /// <summary> + /// Request stop in SyncPlay group. + /// </summary> + /// <response code="204">Stop update sent to all group members.</response> + /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> + [HttpPost("Stop")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [Authorize(Policy = Policies.SyncPlayIsInGroup)] + public ActionResult SyncPlayStop() + { + var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request); + var syncPlayRequest = new StopGroupRequest(); _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None); return NoContent(); } @@ -142,42 +253,151 @@ namespace Jellyfin.Api.Controllers /// <summary> /// Request seek in SyncPlay group. /// </summary> - /// <param name="positionTicks">The playback position in ticks.</param> - /// <response code="204">Seek request sent to all group members.</response> + /// <param name="requestData">The new playback position.</param> + /// <response code="204">Seek update sent to all group members.</response> /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> [HttpPost("Seek")] [ProducesResponseType(StatusCodes.Status204NoContent)] - public ActionResult SyncPlaySeek([FromQuery] long positionTicks) + [Authorize(Policy = Policies.SyncPlayIsInGroup)] + public ActionResult SyncPlaySeek( + [FromBody, Required] SeekRequestDto requestData) { var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request); - var syncPlayRequest = new PlaybackRequest() - { - Type = PlaybackRequestType.Seek, - PositionTicks = positionTicks - }; + var syncPlayRequest = new SeekGroupRequest(requestData.PositionTicks); _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None); return NoContent(); } /// <summary> - /// Request group wait in SyncPlay group while buffering. + /// Notify SyncPlay group that member is buffering. /// </summary> - /// <param name="when">When the request has been made by the client.</param> - /// <param name="positionTicks">The playback position in ticks.</param> - /// <param name="bufferingDone">Whether the buffering is done.</param> - /// <response code="204">Buffering request sent to all group members.</response> + /// <param name="requestData">The player status.</param> + /// <response code="204">Group state update sent to all group members.</response> /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> [HttpPost("Buffering")] [ProducesResponseType(StatusCodes.Status204NoContent)] - public ActionResult SyncPlayBuffering([FromQuery] DateTime when, [FromQuery] long positionTicks, [FromQuery] bool bufferingDone) + [Authorize(Policy = Policies.SyncPlayIsInGroup)] + public ActionResult SyncPlayBuffering( + [FromBody, Required] BufferRequestDto requestData) + { + var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request); + var syncPlayRequest = new BufferGroupRequest( + requestData.When, + requestData.PositionTicks, + requestData.IsPlaying, + requestData.PlaylistItemId); + _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None); + return NoContent(); + } + + /// <summary> + /// Notify SyncPlay group that member is ready for playback. + /// </summary> + /// <param name="requestData">The player status.</param> + /// <response code="204">Group state update sent to all group members.</response> + /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> + [HttpPost("Ready")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [Authorize(Policy = Policies.SyncPlayIsInGroup)] + public ActionResult SyncPlayReady( + [FromBody, Required] ReadyRequestDto requestData) + { + var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request); + var syncPlayRequest = new ReadyGroupRequest( + requestData.When, + requestData.PositionTicks, + requestData.IsPlaying, + requestData.PlaylistItemId); + _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None); + return NoContent(); + } + + /// <summary> + /// Request SyncPlay group to ignore member during group-wait. + /// </summary> + /// <param name="requestData">The settings to set.</param> + /// <response code="204">Member state updated.</response> + /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> + [HttpPost("SetIgnoreWait")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [Authorize(Policy = Policies.SyncPlayIsInGroup)] + public ActionResult SyncPlaySetIgnoreWait( + [FromBody, Required] IgnoreWaitRequestDto requestData) + { + var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request); + var syncPlayRequest = new IgnoreWaitGroupRequest(requestData.IgnoreWait); + _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None); + return NoContent(); + } + + /// <summary> + /// Request next item in SyncPlay group. + /// </summary> + /// <param name="requestData">The current item information.</param> + /// <response code="204">Next item update sent to all group members.</response> + /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> + [HttpPost("NextItem")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [Authorize(Policy = Policies.SyncPlayIsInGroup)] + public ActionResult SyncPlayNextItem( + [FromBody, Required] NextItemRequestDto requestData) + { + var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request); + var syncPlayRequest = new NextItemGroupRequest(requestData.PlaylistItemId); + _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None); + return NoContent(); + } + + /// <summary> + /// Request previous item in SyncPlay group. + /// </summary> + /// <param name="requestData">The current item information.</param> + /// <response code="204">Previous item update sent to all group members.</response> + /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> + [HttpPost("PreviousItem")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [Authorize(Policy = Policies.SyncPlayIsInGroup)] + public ActionResult SyncPlayPreviousItem( + [FromBody, Required] PreviousItemRequestDto requestData) + { + var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request); + var syncPlayRequest = new PreviousItemGroupRequest(requestData.PlaylistItemId); + _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None); + return NoContent(); + } + + /// <summary> + /// Request to set repeat mode in SyncPlay group. + /// </summary> + /// <param name="requestData">The new repeat mode.</param> + /// <response code="204">Play queue update sent to all group members.</response> + /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> + [HttpPost("SetRepeatMode")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [Authorize(Policy = Policies.SyncPlayIsInGroup)] + public ActionResult SyncPlaySetRepeatMode( + [FromBody, Required] SetRepeatModeRequestDto requestData) + { + var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request); + var syncPlayRequest = new SetRepeatModeGroupRequest(requestData.Mode); + _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None); + return NoContent(); + } + + /// <summary> + /// Request to set shuffle mode in SyncPlay group. + /// </summary> + /// <param name="requestData">The new shuffle mode.</param> + /// <response code="204">Play queue update sent to all group members.</response> + /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> + [HttpPost("SetShuffleMode")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [Authorize(Policy = Policies.SyncPlayIsInGroup)] + public ActionResult SyncPlaySetShuffleMode( + [FromBody, Required] SetShuffleModeRequestDto requestData) { var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request); - var syncPlayRequest = new PlaybackRequest() - { - Type = bufferingDone ? PlaybackRequestType.Ready : PlaybackRequestType.Buffer, - When = when, - PositionTicks = positionTicks - }; + var syncPlayRequest = new SetShuffleModeGroupRequest(requestData.Mode); _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None); return NoContent(); } @@ -185,19 +405,16 @@ namespace Jellyfin.Api.Controllers /// <summary> /// Update session ping. /// </summary> - /// <param name="ping">The ping.</param> + /// <param name="requestData">The new ping.</param> /// <response code="204">Ping updated.</response> /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> [HttpPost("Ping")] [ProducesResponseType(StatusCodes.Status204NoContent)] - public ActionResult SyncPlayPing([FromQuery] double ping) + public ActionResult SyncPlayPing( + [FromBody, Required] PingRequestDto requestData) { var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request); - var syncPlayRequest = new PlaybackRequest() - { - Type = PlaybackRequestType.Ping, - Ping = Convert.ToInt64(ping) - }; + 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 4cb1984a2f..bbbe5fb8da 100644 --- a/Jellyfin.Api/Controllers/SystemController.cs +++ b/Jellyfin.Api/Controllers/SystemController.cs @@ -1,10 +1,10 @@ -using System; +using System; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.IO; using System.Linq; +using System.Net; using System.Net.Mime; -using System.Threading; using System.Threading.Tasks; using Jellyfin.Api.Attributes; using Jellyfin.Api.Constants; @@ -64,9 +64,9 @@ namespace Jellyfin.Api.Controllers [HttpGet("Info")] [Authorize(Policy = Policies.FirstTimeSetupOrIgnoreParentalControl)] [ProducesResponseType(StatusCodes.Status200OK)] - public async Task<ActionResult<SystemInfo>> GetSystemInfo() + public ActionResult<SystemInfo> GetSystemInfo() { - return await _appHost.GetSystemInfo(CancellationToken.None).ConfigureAwait(false); + return _appHost.GetSystemInfo(Request.HttpContext.Connection.RemoteIpAddress ?? IPAddress.Loopback); } /// <summary> @@ -76,9 +76,9 @@ namespace Jellyfin.Api.Controllers /// <returns>A <see cref="PublicSystemInfo"/> with public info about the system.</returns> [HttpGet("Info/Public")] [ProducesResponseType(StatusCodes.Status200OK)] - public async Task<ActionResult<PublicSystemInfo>> GetPublicSystemInfo() + public ActionResult<PublicSystemInfo> GetPublicSystemInfo() { - return await _appHost.GetPublicSystemInfo(CancellationToken.None).ConfigureAwait(false); + return _appHost.GetPublicSystemInfo(Request.HttpContext.Connection.RemoteIpAddress ?? IPAddress.Loopback); } /// <summary> @@ -202,7 +202,7 @@ namespace Jellyfin.Api.Controllers // 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); - return File(stream, "text/plain"); + return File(stream, "text/plain; charset=utf-8"); } /// <summary> diff --git a/Jellyfin.Api/Controllers/TimeSyncController.cs b/Jellyfin.Api/Controllers/TimeSyncController.cs index 2dc744e7ca..7df51c7afb 100644 --- a/Jellyfin.Api/Controllers/TimeSyncController.cs +++ b/Jellyfin.Api/Controllers/TimeSyncController.cs @@ -1,5 +1,4 @@ -using System; -using System.Globalization; +using System; using MediaBrowser.Model.SyncPlay; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; @@ -13,7 +12,7 @@ namespace Jellyfin.Api.Controllers public class TimeSyncController : BaseJellyfinApiController { /// <summary> - /// Gets the current utc time. + /// Gets the current UTC time. /// </summary> /// <response code="200">Time returned.</response> /// <returns>An <see cref="UtcTimeResponse"/> to sync the client and server time.</returns> @@ -22,18 +21,14 @@ namespace Jellyfin.Api.Controllers public ActionResult<UtcTimeResponse> GetUtcTime() { // Important to keep the following line at the beginning - var requestReceptionTime = DateTime.UtcNow.ToUniversalTime().ToString("o", DateTimeFormatInfo.InvariantInfo); + var requestReceptionTime = DateTime.UtcNow.ToUniversalTime(); - var response = new UtcTimeResponse(); - response.RequestReceptionTime = requestReceptionTime; - - // Important to keep the following two lines at the end - var responseTransmissionTime = DateTime.UtcNow.ToUniversalTime().ToString("o", DateTimeFormatInfo.InvariantInfo); - response.ResponseTransmissionTime = responseTransmissionTime; + // Important to keep the following line at the end + var responseTransmissionTime = DateTime.UtcNow.ToUniversalTime(); // Implementing NTP on such a high level results in this useless // information being sent. On the other hand it enables future additions. - return response; + return new UtcTimeResponse(requestReceptionTime, responseTransmissionTime); } } } diff --git a/Jellyfin.Api/Controllers/TrailersController.cs b/Jellyfin.Api/Controllers/TrailersController.cs index dec4498575..5cb7468b24 100644 --- a/Jellyfin.Api/Controllers/TrailersController.cs +++ b/Jellyfin.Api/Controllers/TrailersController.cs @@ -1,5 +1,7 @@ -using System; +using System; using Jellyfin.Api.Constants; +using Jellyfin.Api.ModelBinders; +using Jellyfin.Data.Enums; using MediaBrowser.Model.Dto; using MediaBrowser.Model.Entities; using MediaBrowser.Model.Querying; @@ -41,8 +43,8 @@ namespace Jellyfin.Api.Controllers /// <param name="hasParentalRating">Optional filter by items that have or do not have a parental rating.</param> /// <param name="isHd">Optional filter by items that are HD or not.</param> /// <param name="is4K">Optional filter by items that are 4K or not.</param> - /// <param name="locationTypes">Optional. If specified, results will be filtered based on LocationType. This allows multiple, comma delimeted.</param> - /// <param name="excludeLocationTypes">Optional. If specified, results will be filtered based on the LocationType. This allows multiple, comma delimeted.</param> + /// <param name="locationTypes">Optional. If specified, results will be filtered based on LocationType. This allows multiple, comma delimited.</param> + /// <param name="excludeLocationTypes">Optional. If specified, results will be filtered based on the LocationType. This allows multiple, comma delimited.</param> /// <param name="isMissing">Optional filter by items that are missing episodes or not.</param> /// <param name="isUnaired">Optional filter by items that are unaired episodes or not.</param> /// <param name="minCommunityRating">Optional filter by minimum community rating.</param> @@ -55,41 +57,41 @@ namespace Jellyfin.Api.Controllers /// <param name="hasImdbId">Optional filter by items that have an imdb id or not.</param> /// <param name="hasTmdbId">Optional filter by items that have a tmdb id or not.</param> /// <param name="hasTvdbId">Optional filter by items that have a tvdb id or not.</param> - /// <param name="excludeItemIds">Optional. If specified, results will be filtered by exxcluding item ids. This allows multiple, comma delimeted.</param> + /// <param name="excludeItemIds">Optional. If specified, results will be filtered by excluding item ids. This allows multiple, comma delimited.</param> /// <param name="startIndex">Optional. The record index to start at. All items with a lower index will be dropped from the results.</param> /// <param name="limit">Optional. The maximum number of records to return.</param> /// <param name="recursive">When searching within folders, this determines whether or not the search will be recursive. true/false.</param> /// <param name="searchTerm">Optional. Filter based on a search term.</param> /// <param name="sortOrder">Sort Order - Ascending,Descending.</param> /// <param name="parentId">Specify this to localize the search to a specific item or folder. Omit to use the root.</param> - /// <param name="fields">Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimeted. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines.</param> - /// <param name="excludeItemTypes">Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimeted.</param> - /// <param name="filters">Optional. Specify additional filters to apply. This allows multiple, comma delimeted. Options: IsFolder, IsNotFolder, IsUnplayed, IsPlayed, IsFavorite, IsResumable, Likes, Dislikes.</param> + /// <param name="fields">Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimited. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines.</param> + /// <param name="excludeItemTypes">Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimited.</param> + /// <param name="filters">Optional. Specify additional filters to apply. This allows multiple, comma delimited. Options: IsFolder, IsNotFolder, IsUnplayed, IsPlayed, IsFavorite, IsResumable, Likes, Dislikes.</param> /// <param name="isFavorite">Optional filter by items that are marked as favorite, or not.</param> /// <param name="mediaTypes">Optional filter by MediaType. Allows multiple, comma delimited.</param> /// <param name="imageTypes">Optional. If specified, results will be filtered based on those containing image types. This allows multiple, comma delimited.</param> - /// <param name="sortBy">Optional. Specify one or more sort orders, comma delimeted. Options: Album, AlbumArtist, Artist, Budget, CommunityRating, CriticRating, DateCreated, DatePlayed, PlayCount, PremiereDate, ProductionYear, SortName, Random, Revenue, Runtime.</param> + /// <param name="sortBy">Optional. Specify one or more sort orders, comma delimited. Options: Album, AlbumArtist, Artist, Budget, CommunityRating, CriticRating, DateCreated, DatePlayed, PlayCount, PremiereDate, ProductionYear, SortName, Random, Revenue, Runtime.</param> /// <param name="isPlayed">Optional filter by items that are played, or not.</param> - /// <param name="genres">Optional. If specified, results will be filtered based on genre. This allows multiple, pipe delimeted.</param> - /// <param name="officialRatings">Optional. If specified, results will be filtered based on OfficialRating. This allows multiple, pipe delimeted.</param> - /// <param name="tags">Optional. If specified, results will be filtered based on tag. This allows multiple, pipe delimeted.</param> - /// <param name="years">Optional. If specified, results will be filtered based on production year. This allows multiple, comma delimeted.</param> + /// <param name="genres">Optional. If specified, results will be filtered based on genre. This allows multiple, pipe delimited.</param> + /// <param name="officialRatings">Optional. If specified, results will be filtered based on OfficialRating. This allows multiple, pipe delimited.</param> + /// <param name="tags">Optional. If specified, results will be filtered based on tag. This allows multiple, pipe delimited.</param> + /// <param name="years">Optional. If specified, results will be filtered based on production year. This allows multiple, comma delimited.</param> /// <param name="enableUserData">Optional, include user data.</param> /// <param name="imageTypeLimit">Optional, the max number of images to return, per image type.</param> /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> /// <param name="person">Optional. If specified, results will be filtered to include only those containing the specified person.</param> /// <param name="personIds">Optional. If specified, results will be filtered to include only those containing the specified person id.</param> /// <param name="personTypes">Optional. If specified, along with Person, results will be filtered to include only those containing the specified person and PersonType. Allows multiple, comma-delimited.</param> - /// <param name="studios">Optional. If specified, results will be filtered based on studio. This allows multiple, pipe delimeted.</param> - /// <param name="artists">Optional. If specified, results will be filtered based on artists. This allows multiple, pipe delimeted.</param> - /// <param name="excludeArtistIds">Optional. If specified, results will be filtered based on artist id. This allows multiple, pipe delimeted.</param> + /// <param name="studios">Optional. If specified, results will be filtered based on studio. This allows multiple, pipe delimited.</param> + /// <param name="artists">Optional. If specified, results will be filtered based on artists. This allows multiple, pipe delimited.</param> + /// <param name="excludeArtistIds">Optional. If specified, results will be filtered based on artist id. This allows multiple, pipe delimited.</param> /// <param name="artistIds">Optional. If specified, results will be filtered to include only those containing the specified artist id.</param> /// <param name="albumArtistIds">Optional. If specified, results will be filtered to include only those containing the specified album artist id.</param> /// <param name="contributingArtistIds">Optional. If specified, results will be filtered to include only those containing the specified contributing artist id.</param> - /// <param name="albums">Optional. If specified, results will be filtered based on album. This allows multiple, pipe delimeted.</param> - /// <param name="albumIds">Optional. If specified, results will be filtered based on album id. This allows multiple, pipe delimeted.</param> + /// <param name="albums">Optional. If specified, results will be filtered based on album. This allows multiple, pipe delimited.</param> + /// <param name="albumIds">Optional. If specified, results will be filtered based on album id. This allows multiple, pipe delimited.</param> /// <param name="ids">Optional. If specific items are needed, specify a list of item id's to retrieve. This allows multiple, comma delimited.</param> - /// <param name="videoTypes">Optional filter by VideoType (videofile, dvd, bluray, iso). Allows multiple, comma delimeted.</param> + /// <param name="videoTypes">Optional filter by VideoType (videofile, dvd, bluray, iso). Allows multiple, comma delimited.</param> /// <param name="minOfficialRating">Optional filter by minimum official rating (PG, PG-13, TV-MA, etc).</param> /// <param name="isLocked">Optional filter by items that are locked.</param> /// <param name="isPlaceHolder">Optional filter by items that are placeholders.</param> @@ -100,19 +102,19 @@ namespace Jellyfin.Api.Controllers /// <param name="maxWidth">Optional. Filter by the maximum width of the item.</param> /// <param name="maxHeight">Optional. Filter by the maximum height of the item.</param> /// <param name="is3D">Optional filter by items that are 3D, or not.</param> - /// <param name="seriesStatus">Optional filter by Series Status. Allows multiple, comma delimeted.</param> + /// <param name="seriesStatus">Optional filter by Series Status. Allows multiple, comma delimited.</param> /// <param name="nameStartsWithOrGreater">Optional filter by items whose name is sorted equally or greater than a given input string.</param> /// <param name="nameStartsWith">Optional filter by items whose name is sorted equally than a given input string.</param> /// <param name="nameLessThan">Optional filter by items whose name is equally or lesser than a given input string.</param> - /// <param name="studioIds">Optional. If specified, results will be filtered based on studio id. This allows multiple, pipe delimeted.</param> - /// <param name="genreIds">Optional. If specified, results will be filtered based on genre id. This allows multiple, pipe delimeted.</param> + /// <param name="studioIds">Optional. If specified, results will be filtered based on studio id. This allows multiple, pipe delimited.</param> + /// <param name="genreIds">Optional. If specified, results will be filtered based on genre id. This allows multiple, pipe delimited.</param> /// <param name="enableTotalRecordCount">Optional. Enable the total record count.</param> /// <param name="enableImages">Optional, include image information in output.</param> /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the trailers.</returns> [HttpGet] [ProducesResponseType(StatusCodes.Status200OK)] public ActionResult<QueryResult<BaseItemDto>> GetTrailers( - [FromQuery] Guid? userId, + [FromQuery] Guid userId, [FromQuery] string? maxOfficialRating, [FromQuery] bool? hasThemeSong, [FromQuery] bool? hasThemeVideo, @@ -124,8 +126,8 @@ namespace Jellyfin.Api.Controllers [FromQuery] bool? hasParentalRating, [FromQuery] bool? isHd, [FromQuery] bool? is4K, - [FromQuery] string? locationTypes, - [FromQuery] LocationType[] excludeLocationTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] LocationType[] locationTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] LocationType[] excludeLocationTypes, [FromQuery] bool? isMissing, [FromQuery] bool? isUnaired, [FromQuery] double? minCommunityRating, @@ -138,41 +140,41 @@ namespace Jellyfin.Api.Controllers [FromQuery] bool? hasImdbId, [FromQuery] bool? hasTmdbId, [FromQuery] bool? hasTvdbId, - [FromQuery] string? excludeItemIds, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] excludeItemIds, [FromQuery] int? startIndex, [FromQuery] int? limit, [FromQuery] bool? recursive, [FromQuery] string? searchTerm, - [FromQuery] string? sortOrder, - [FromQuery] string? parentId, - [FromQuery] string? fields, - [FromQuery] string? excludeItemTypes, - [FromQuery] ItemFilter[] filters, + [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] string? mediaTypes, - [FromQuery] string? imageTypes, - [FromQuery] string? sortBy, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] mediaTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] imageTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] sortBy, [FromQuery] bool? isPlayed, - [FromQuery] string? genres, - [FromQuery] string? officialRatings, - [FromQuery] string? tags, - [FromQuery] string? years, + [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] string? enableImageTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes, [FromQuery] string? person, - [FromQuery] string? personIds, - [FromQuery] string? personTypes, - [FromQuery] string? studios, - [FromQuery] string? artists, - [FromQuery] string? excludeArtistIds, - [FromQuery] string? artistIds, - [FromQuery] string? albumArtistIds, - [FromQuery] string? contributingArtistIds, - [FromQuery] string? albums, - [FromQuery] string? albumIds, - [FromQuery] string? ids, - [FromQuery] string? videoTypes, + [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, @@ -183,20 +185,19 @@ namespace Jellyfin.Api.Controllers [FromQuery] int? maxWidth, [FromQuery] int? maxHeight, [FromQuery] bool? is3D, - [FromQuery] string? seriesStatus, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] SeriesStatus[] seriesStatus, [FromQuery] string? nameStartsWithOrGreater, [FromQuery] string? nameStartsWith, [FromQuery] string? nameLessThan, - [FromQuery] string? studioIds, - [FromQuery] string? genreIds, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] studioIds, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] genreIds, [FromQuery] bool enableTotalRecordCount = true, [FromQuery] bool? enableImages = true) { - var includeItemTypes = "Trailer"; + var includeItemTypes = new[] { BaseItemKind.Trailer }; return _itemsController .GetItems( - userId, userId, maxOfficialRating, hasThemeSong, diff --git a/Jellyfin.Api/Controllers/TvShowsController.cs b/Jellyfin.Api/Controllers/TvShowsController.cs index d158f6c342..51d40994e1 100644 --- a/Jellyfin.Api/Controllers/TvShowsController.cs +++ b/Jellyfin.Api/Controllers/TvShowsController.cs @@ -1,18 +1,19 @@ using System; 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.Data.Enums; -using MediaBrowser.Common.Extensions; +using Jellyfin.Extensions; using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.TV; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.TV; using MediaBrowser.Model.Dto; +using MediaBrowser.Model.Entities; using MediaBrowser.Model.Querying; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; @@ -57,14 +58,16 @@ namespace Jellyfin.Api.Controllers /// <param name="userId">The user id of the user to get the next up episodes for.</param> /// <param name="startIndex">Optional. The record index to start at. All items with a lower index will be dropped from the results.</param> /// <param name="limit">Optional. The maximum number of records to return.</param> - /// <param name="fields">Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimeted. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines, TrailerUrls.</param> + /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param> /// <param name="seriesId">Optional. Filter by series id.</param> /// <param name="parentId">Optional. Specify this to localize the search to a specific item or folder. Omit to use the root.</param> /// <param name="enableImges">Optional. Include image information in output.</param> /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param> /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> /// <param name="enableUserData">Optional. Include user data.</param> + /// <param name="nextUpDateCutoff">Optional. Starting date of shows to show in Next Up section.</param> /// <param name="enableTotalRecordCount">Whether to enable the total records count. Defaults to true.</param> + /// <param name="disableFirstEpisode">Whether to disable sending the first episode in a series as next up.</param> /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the next up episodes.</returns> [HttpGet("NextUp")] [ProducesResponseType(StatusCodes.Status200OK)] @@ -72,17 +75,18 @@ namespace Jellyfin.Api.Controllers [FromQuery] Guid? userId, [FromQuery] int? startIndex, [FromQuery] int? limit, - [FromQuery] string? fields, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, [FromQuery] string? seriesId, - [FromQuery] string? parentId, + [FromQuery] Guid? parentId, [FromQuery] bool? enableImges, [FromQuery] int? imageTypeLimit, - [FromQuery] string? enableImageTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes, [FromQuery] bool? enableUserData, - [FromQuery] bool enableTotalRecordCount = true) + [FromQuery] DateTime? nextUpDateCutoff, + [FromQuery] bool enableTotalRecordCount = true, + [FromQuery] bool disableFirstEpisode = false) { - var options = new DtoOptions() - .AddItemFields(fields!) + var options = new DtoOptions { Fields = fields } .AddClientFields(Request) .AddAdditionalDtoOptions(enableImges, enableUserData, imageTypeLimit, enableImageTypes!); @@ -94,7 +98,9 @@ namespace Jellyfin.Api.Controllers SeriesId = seriesId, StartIndex = startIndex, UserId = userId ?? Guid.Empty, - EnableTotalRecordCount = enableTotalRecordCount + EnableTotalRecordCount = enableTotalRecordCount, + DisableFirstEpisode = disableFirstEpisode, + NextUpDateCutoff = nextUpDateCutoff ?? DateTime.MinValue }, options); @@ -117,7 +123,7 @@ namespace Jellyfin.Api.Controllers /// <param name="userId">The user id of the user to get the upcoming episodes for.</param> /// <param name="startIndex">Optional. The record index to start at. All items with a lower index will be dropped from the results.</param> /// <param name="limit">Optional. The maximum number of records to return.</param> - /// <param name="fields">Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimeted. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines, TrailerUrls.</param> + /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param> /// <param name="parentId">Optional. Specify this to localize the search to a specific item or folder. Omit to use the root.</param> /// <param name="enableImges">Optional. Include image information in output.</param> /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param> @@ -130,11 +136,11 @@ namespace Jellyfin.Api.Controllers [FromQuery] Guid? userId, [FromQuery] int? startIndex, [FromQuery] int? limit, - [FromQuery] string? fields, - [FromQuery] string? parentId, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, + [FromQuery] Guid? parentId, [FromQuery] bool? enableImges, [FromQuery] int? imageTypeLimit, - [FromQuery] string? enableImageTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes, [FromQuery] bool? enableUserData) { var user = userId.HasValue && !userId.Equals(Guid.Empty) @@ -143,17 +149,16 @@ namespace Jellyfin.Api.Controllers var minPremiereDate = DateTime.Now.Date.ToUniversalTime().AddDays(-1); - var parentIdGuid = string.IsNullOrWhiteSpace(parentId) ? Guid.Empty : new Guid(parentId); + var parentIdGuid = parentId ?? Guid.Empty; - var options = new DtoOptions() - .AddItemFields(fields!) + var options = new DtoOptions { Fields = fields } .AddClientFields(Request) .AddAdditionalDtoOptions(enableImges, enableUserData, imageTypeLimit, enableImageTypes!); var itemsResult = _libraryManager.GetItemList(new InternalItemsQuery(user) { IncludeItemTypes = new[] { nameof(Episode) }, - OrderBy = new[] { ItemSortBy.PremiereDate, ItemSortBy.SortName }.Select(i => new ValueTuple<string, SortOrder>(i, SortOrder.Ascending)).ToArray(), + OrderBy = new[] { (ItemSortBy.PremiereDate, SortOrder.Ascending), (ItemSortBy.SortName, SortOrder.Ascending) }, MinPremiereDate = minPremiereDate, StartIndex = startIndex, Limit = limit, @@ -176,7 +181,7 @@ namespace Jellyfin.Api.Controllers /// </summary> /// <param name="seriesId">The series id.</param> /// <param name="userId">The user id.</param> - /// <param name="fields">Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimeted. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines, TrailerUrls.</param> + /// <param name="fields">Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimited. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines, TrailerUrls.</param> /// <param name="season">Optional filter by season number.</param> /// <param name="seasonId">Optional. Filter by season id.</param> /// <param name="isMissing">Optional. Filter by items that are missing episodes or not.</param> @@ -188,25 +193,25 @@ namespace Jellyfin.Api.Controllers /// <param name="imageTypeLimit">Optional, the max number of images to return, per image type.</param> /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> /// <param name="enableUserData">Optional. Include user data.</param> - /// <param name="sortBy">Optional. Specify one or more sort orders, comma delimeted. Options: Album, AlbumArtist, Artist, Budget, CommunityRating, CriticRating, DateCreated, DatePlayed, PlayCount, PremiereDate, ProductionYear, SortName, Random, Revenue, Runtime.</param> + /// <param name="sortBy">Optional. Specify one or more sort orders, comma delimited. Options: Album, AlbumArtist, Artist, Budget, CommunityRating, CriticRating, DateCreated, DatePlayed, PlayCount, PremiereDate, ProductionYear, SortName, Random, Revenue, Runtime.</param> /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the episodes on success or a <see cref="NotFoundResult"/> if the series was not found.</returns> [HttpGet("{seriesId}/Episodes")] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] public ActionResult<QueryResult<BaseItemDto>> GetEpisodes( - [FromRoute, Required] string seriesId, + [FromRoute, Required] Guid seriesId, [FromQuery] Guid? userId, - [FromQuery] string? fields, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, [FromQuery] int? season, - [FromQuery] string? seasonId, + [FromQuery] Guid? seasonId, [FromQuery] bool? isMissing, [FromQuery] string? adjacentTo, - [FromQuery] string? startItemId, + [FromQuery] Guid? startItemId, [FromQuery] int? startIndex, [FromQuery] int? limit, [FromQuery] bool? enableImages, [FromQuery] int? imageTypeLimit, - [FromQuery] string? enableImageTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes, [FromQuery] bool? enableUserData, [FromQuery] string? sortBy) { @@ -216,14 +221,13 @@ namespace Jellyfin.Api.Controllers List<BaseItem> episodes; - var dtoOptions = new DtoOptions() - .AddItemFields(fields!) + var dtoOptions = new DtoOptions { Fields = fields } .AddClientFields(Request) .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes!); - if (!string.IsNullOrWhiteSpace(seasonId)) // Season id was supplied. Get episodes by season id. + if (seasonId.HasValue) // Season id was supplied. Get episodes by season id. { - var item = _libraryManager.GetItemById(new Guid(seasonId)); + var item = _libraryManager.GetItemById(seasonId.Value); if (!(item is Season seasonItem)) { return NotFound("No season exists with Id " + seasonId); @@ -265,10 +269,10 @@ namespace Jellyfin.Api.Controllers .ToList(); } - if (!string.IsNullOrWhiteSpace(startItemId)) + if (startItemId.HasValue) { episodes = episodes - .SkipWhile(i => !string.Equals(i.Id.ToString("N", CultureInfo.InvariantCulture), startItemId, StringComparison.OrdinalIgnoreCase)) + .SkipWhile(i => !startItemId.Value.Equals(i.Id)) .ToList(); } @@ -304,7 +308,7 @@ namespace Jellyfin.Api.Controllers /// </summary> /// <param name="seriesId">The series id.</param> /// <param name="userId">The user id.</param> - /// <param name="fields">Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimeted. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines, TrailerUrls.</param> + /// <param name="fields">Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimited. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines, TrailerUrls.</param> /// <param name="isSpecialSeason">Optional. Filter by special season.</param> /// <param name="isMissing">Optional. Filter by items that are missing episodes or not.</param> /// <param name="adjacentTo">Optional. Return items that are siblings of a supplied item.</param> @@ -317,15 +321,15 @@ namespace Jellyfin.Api.Controllers [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] public ActionResult<QueryResult<BaseItemDto>> GetSeasons( - [FromRoute, Required] string seriesId, + [FromRoute, Required] Guid seriesId, [FromQuery] Guid? userId, - [FromQuery] string? fields, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, [FromQuery] bool? isSpecialSeason, [FromQuery] bool? isMissing, [FromQuery] string? adjacentTo, [FromQuery] bool? enableImages, [FromQuery] int? imageTypeLimit, - [FromQuery] string? enableImageTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes, [FromQuery] bool? enableUserData) { var user = userId.HasValue && !userId.Equals(Guid.Empty) @@ -344,8 +348,7 @@ namespace Jellyfin.Api.Controllers AdjacentTo = adjacentTo }); - var dtoOptions = new DtoOptions() - .AddItemFields(fields) + var dtoOptions = new DtoOptions { Fields = fields } .AddClientFields(Request) .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes!); diff --git a/Jellyfin.Api/Controllers/UniversalAudioController.cs b/Jellyfin.Api/Controllers/UniversalAudioController.cs index df20a92b3d..679f055bc2 100644 --- a/Jellyfin.Api/Controllers/UniversalAudioController.cs +++ b/Jellyfin.Api/Controllers/UniversalAudioController.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.Globalization; @@ -7,6 +7,7 @@ using System.Threading.Tasks; using Jellyfin.Api.Attributes; using Jellyfin.Api.Constants; using Jellyfin.Api.Helpers; +using Jellyfin.Api.ModelBinders; using Jellyfin.Api.Models.StreamingDtos; using MediaBrowser.Common.Extensions; using MediaBrowser.Controller.Devices; @@ -76,6 +77,7 @@ namespace Jellyfin.Api.Controllers /// <param name="maxAudioChannels">Optional. The maximum number of audio channels.</param> /// <param name="transcodingAudioChannels">Optional. The number of how many audio channels to transcode to.</param> /// <param name="maxStreamingBitrate">Optional. The maximum streaming bitrate.</param> + /// <param name="audioBitRate">Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults.</param> /// <param name="startTimeTicks">Optional. Specify a starting offset, in ticks. 1 tick = 10000 ms.</param> /// <param name="transcodingContainer">Optional. The container to transcode to.</param> /// <param name="transcodingProtocol">Optional. The transcoding protocol.</param> @@ -88,30 +90,29 @@ namespace Jellyfin.Api.Controllers /// <response code="302">Redirected to remote audio stream.</response> /// <returns>A <see cref="Task"/> containing the audio file.</returns> [HttpGet("Audio/{itemId}/universal")] - [HttpGet("Audio/{itemId}/universal.{container}", Name = "GetUniversalAudioStream_2")] [HttpHead("Audio/{itemId}/universal", Name = "HeadUniversalAudioStream")] - [HttpHead("Audio/{itemId}/universal.{container}", Name = "HeadUniversalAudioStream_2")] [Authorize(Policy = Policies.DefaultAuthorization)] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status302Found)] [ProducesAudioFile] public async Task<ActionResult> GetUniversalAudioStream( [FromRoute, Required] Guid itemId, - [FromRoute] string? container, + [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] long? maxStreamingBitrate, + [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, + [FromQuery] bool breakOnNonKeyFrames = false, [FromQuery] bool enableRedirection = true) { var deviceProfile = GetDeviceProfile(container, transcodingContainer, audioCodec, transcodingProtocol, breakOnNonKeyFrames, transcodingAudioChannels, maxAudioSampleRate, maxAudioBitDepth, maxAudioChannels); @@ -191,8 +192,11 @@ namespace Jellyfin.Api.Controllers 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[] { "mpegts", "fmp4" }; + var supportedHlsContainers = new[] { "ts", "mp4" }; var dynamicHlsRequestDto = new HlsAudioRequestDto { @@ -201,7 +205,7 @@ namespace Jellyfin.Api.Controllers 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 : "mpegts", + SegmentContainer = Array.Exists(supportedHlsContainers, element => element == transcodingContainer) ? transcodingContainer : "ts", MediaSourceId = mediaSourceId, DeviceId = deviceId, AudioCodec = audioCodec, @@ -212,14 +216,14 @@ namespace Jellyfin.Api.Controllers AudioSampleRate = maxAudioSampleRate, MaxAudioChannels = maxAudioChannels, MaxAudioBitDepth = maxAudioBitDepth, - AudioChannels = isStatic ? (int?)null : Convert.ToInt32(Math.Min(maxStreamingBitrate ?? 192000, int.MaxValue)), + AudioBitRate = audioBitRate ?? maxStreamingBitrate, StartTimeTicks = startTimeTicks, SubtitleMethod = SubtitleDeliveryMethod.Hls, - RequireAvc = true, - DeInterlace = true, - RequireNonAnamorphic = true, - EnableMpegtsM2TsMode = true, - TranscodeReasons = mediaSource.TranscodeReasons == null ? null : string.Join(",", mediaSource.TranscodeReasons.Select(i => i.ToString()).ToArray()), + RequireAvc = false, + DeInterlace = false, + RequireNonAnamorphic = false, + EnableMpegtsM2TsMode = false, + TranscodeReasons = mediaSource.TranscodeReasons == null ? null : string.Join(',', mediaSource.TranscodeReasons.Select(i => i.ToString()).ToArray()), Context = EncodingContext.Static, StreamOptions = new Dictionary<string, string>(), EnableAdaptiveBitrateStreaming = true @@ -244,13 +248,13 @@ namespace Jellyfin.Api.Controllers BreakOnNonKeyFrames = breakOnNonKeyFrames, AudioSampleRate = maxAudioSampleRate, MaxAudioChannels = maxAudioChannels, - AudioBitRate = isStatic ? (int?)null : Convert.ToInt32(Math.Min(maxStreamingBitrate ?? 192000, int.MaxValue)), + AudioBitRate = isStatic ? (int?)null : (audioBitRate ?? maxStreamingBitrate), MaxAudioBitDepth = maxAudioBitDepth, AudioChannels = maxAudioChannels, CopyTimestamps = true, StartTimeTicks = startTimeTicks, SubtitleMethod = SubtitleDeliveryMethod.Embed, - TranscodeReasons = mediaSource.TranscodeReasons == null ? null : string.Join(",", mediaSource.TranscodeReasons.Select(i => i.ToString()).ToArray()), + TranscodeReasons = mediaSource.TranscodeReasons == null ? null : string.Join(',', mediaSource.TranscodeReasons.Select(i => i.ToString()).ToArray()), Context = EncodingContext.Static }; @@ -258,7 +262,7 @@ namespace Jellyfin.Api.Controllers } private DeviceProfile GetDeviceProfile( - string? container, + string[] containers, string? transcodingContainer, string? audioCodec, string? transcodingProtocol, @@ -270,20 +274,23 @@ namespace Jellyfin.Api.Controllers { var deviceProfile = new DeviceProfile(); - var directPlayProfiles = new List<DirectPlayProfile>(); - - var containers = RequestHelpers.Split(container, ',', true); - - foreach (var cont in containers) + int len = containers.Length; + var directPlayProfiles = new DirectPlayProfile[len]; + for (int i = 0; i < len; i++) { - var parts = RequestHelpers.Split(cont, ',', true); + var parts = containers[i].Split('|', StringSplitOptions.RemoveEmptyEntries); - var audioCodecs = parts.Length == 1 ? null : string.Join(",", parts.Skip(1).ToArray()); + var audioCodecs = parts.Length == 1 ? null : string.Join(',', parts.Skip(1)); - directPlayProfiles.Add(new DirectPlayProfile { Type = DlnaProfileType.Audio, Container = parts[0], AudioCodec = audioCodecs }); + directPlayProfiles[i] = new DirectPlayProfile + { + Type = DlnaProfileType.Audio, + Container = parts[0], + AudioCodec = audioCodecs + }; } - deviceProfile.DirectPlayProfiles = directPlayProfiles.ToArray(); + deviceProfile.DirectPlayProfiles = directPlayProfiles; deviceProfile.TranscodingProfiles = new[] { @@ -291,9 +298,9 @@ namespace Jellyfin.Api.Controllers { Type = DlnaProfileType.Audio, Context = EncodingContext.Streaming, - Container = transcodingContainer, - AudioCodec = audioCodec, - Protocol = transcodingProtocol, + Container = transcodingContainer ?? "mp3", + AudioCodec = audioCodec ?? "mp3", + Protocol = transcodingProtocol ?? "http", BreakOnNonKeyFrames = breakOnNonKeyFrames ?? false, MaxAudioChannels = transcodingAudioChannels?.ToString(CultureInfo.InvariantCulture) } @@ -305,25 +312,52 @@ namespace Jellyfin.Api.Controllers if (maxAudioSampleRate.HasValue) { // codec profile - conditions.Add(new ProfileCondition { Condition = ProfileConditionType.LessThanEqual, IsRequired = false, Property = ProfileConditionValue.AudioSampleRate, Value = maxAudioSampleRate.Value.ToString(CultureInfo.InvariantCulture) }); + 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) }); + 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) }); + 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 = container, Conditions = conditions.ToArray() }); + codecProfiles.Add( + new CodecProfile + { + Type = CodecType.Audio, + Container = string.Join(',', containers), + Conditions = conditions.ToArray() + }); } deviceProfile.CodecProfiles = codecProfiles.ToArray(); diff --git a/Jellyfin.Api/Controllers/UserController.cs b/Jellyfin.Api/Controllers/UserController.cs index 50bb8bb2aa..b13db4baa9 100644 --- a/Jellyfin.Api/Controllers/UserController.cs +++ b/Jellyfin.Api/Controllers/UserController.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.Linq; @@ -21,6 +21,7 @@ using MediaBrowser.Model.Users; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; namespace Jellyfin.Api.Controllers { @@ -36,6 +37,7 @@ namespace Jellyfin.Api.Controllers private readonly IDeviceManager _deviceManager; private readonly IAuthorizationContext _authContext; private readonly IServerConfigurationManager _config; + private readonly ILogger _logger; /// <summary> /// Initializes a new instance of the <see cref="UserController"/> class. @@ -46,13 +48,15 @@ namespace Jellyfin.Api.Controllers /// <param name="deviceManager">Instance of the <see cref="IDeviceManager"/> interface.</param> /// <param name="authContext">Instance of the <see cref="IAuthorizationContext"/> interface.</param> /// <param name="config">Instance of the <see cref="IServerConfigurationManager"/> interface.</param> + /// <param name="logger">Instance of the <see cref="ILogger"/> interface.</param> public UserController( IUserManager userManager, ISessionManager sessionManager, INetworkManager networkManager, IDeviceManager deviceManager, IAuthorizationContext authContext, - IServerConfigurationManager config) + IServerConfigurationManager config, + ILogger<UserController> logger) { _userManager = userManager; _sessionManager = sessionManager; @@ -60,6 +64,7 @@ namespace Jellyfin.Api.Controllers _deviceManager = deviceManager; _authContext = authContext; _config = config; + _logger = logger; } /// <summary> @@ -118,7 +123,7 @@ namespace Jellyfin.Api.Controllers return NotFound("User not found"); } - var result = _userManager.GetUserDto(user, HttpContext.GetNormalizedRemoteIp()); + var result = _userManager.GetUserDto(user, HttpContext.GetNormalizedRemoteIp().ToString()); return result; } @@ -133,11 +138,11 @@ namespace Jellyfin.Api.Controllers [Authorize(Policy = Policies.RequiresElevation)] [ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status404NotFound)] - public ActionResult DeleteUser([FromRoute, Required] Guid userId) + public async Task<ActionResult> DeleteUser([FromRoute, Required] Guid userId) { var user = _userManager.GetUserById(userId); _sessionManager.RevokeUserTokens(user.Id, null); - _userManager.DeleteUser(userId); + await _userManager.DeleteUserAsync(userId).ConfigureAwait(false); return NoContent(); } @@ -169,14 +174,12 @@ namespace Jellyfin.Api.Controllers if (!string.IsNullOrEmpty(password) && string.IsNullOrEmpty(pw)) { - return Forbid("Only sha1 password is not allowed."); + return StatusCode(StatusCodes.Status403Forbidden, "Only sha1 password is not allowed."); } - // Password should always be null AuthenticateUserByName request = new AuthenticateUserByName { Username = user.Username, - Password = null, Pw = pw }; return await AuthenticateUserByName(request).ConfigureAwait(false); @@ -203,8 +206,7 @@ namespace Jellyfin.Api.Controllers DeviceId = auth.DeviceId, DeviceName = auth.Device, Password = request.Pw, - PasswordSha1 = request.Password, - RemoteEndPoint = HttpContext.GetNormalizedRemoteIp(), + RemoteEndPoint = HttpContext.GetNormalizedRemoteIp().ToString(), Username = request.Username }).ConfigureAwait(false); @@ -267,11 +269,11 @@ namespace Jellyfin.Api.Controllers [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task<ActionResult> UpdateUserPassword( [FromRoute, Required] Guid userId, - [FromBody] UpdateUserPassword request) + [FromBody, Required] UpdateUserPassword request) { if (!RequestHelpers.AssertCanUpdateUser(_authContext, HttpContext.Request, userId, true)) { - return Forbid("User is not allowed to update the password."); + return StatusCode(StatusCodes.Status403Forbidden, "User is not allowed to update the password."); } var user = _userManager.GetUserById(userId); @@ -291,12 +293,12 @@ namespace Jellyfin.Api.Controllers user.Username, request.CurrentPw, request.CurrentPw, - HttpContext.GetNormalizedRemoteIp(), + HttpContext.GetNormalizedRemoteIp().ToString(), false).ConfigureAwait(false); if (success == null) { - return Forbid("Invalid user or password entered."); + return StatusCode(StatusCodes.Status403Forbidden, "Invalid user or password entered."); } await _userManager.ChangePassword(user, request.NewPw).ConfigureAwait(false); @@ -325,11 +327,11 @@ namespace Jellyfin.Api.Controllers [ProducesResponseType(StatusCodes.Status404NotFound)] public ActionResult UpdateUserEasyPassword( [FromRoute, Required] Guid userId, - [FromBody] UpdateUserEasyPassword request) + [FromBody, Required] UpdateUserEasyPassword request) { if (!RequestHelpers.AssertCanUpdateUser(_authContext, HttpContext.Request, userId, true)) { - return Forbid("User is not allowed to update the easy password."); + return StatusCode(StatusCodes.Status403Forbidden, "User is not allowed to update the easy password."); } var user = _userManager.GetUserById(userId); @@ -367,31 +369,22 @@ namespace Jellyfin.Api.Controllers [ProducesResponseType(StatusCodes.Status403Forbidden)] public async Task<ActionResult> UpdateUser( [FromRoute, Required] Guid userId, - [FromBody] UserDto updateUser) + [FromBody, Required] UserDto updateUser) { - if (updateUser == null) - { - return BadRequest(); - } - if (!RequestHelpers.AssertCanUpdateUser(_authContext, HttpContext.Request, userId, false)) { - return Forbid("User update not allowed."); + return StatusCode(StatusCodes.Status403Forbidden, "User update not allowed."); } var user = _userManager.GetUserById(userId); - if (string.Equals(user.Username, updateUser.Name, StringComparison.Ordinal)) - { - await _userManager.UpdateUserAsync(user).ConfigureAwait(false); - _userManager.UpdateConfiguration(user.Id, updateUser.Configuration); - } - else + if (!string.Equals(user.Username, updateUser.Name, StringComparison.Ordinal)) { await _userManager.RenameUser(user, updateUser.Name).ConfigureAwait(false); - _userManager.UpdateConfiguration(updateUser.Id, updateUser.Configuration); } + await _userManager.UpdateConfigurationAsync(user.Id, updateUser.Configuration).ConfigureAwait(false); + return NoContent(); } @@ -409,15 +402,10 @@ namespace Jellyfin.Api.Controllers [ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status403Forbidden)] - public ActionResult UpdateUserPolicy( + public async Task<ActionResult> UpdateUserPolicy( [FromRoute, Required] Guid userId, - [FromBody] UserPolicy newPolicy) + [FromBody, Required] UserPolicy newPolicy) { - if (newPolicy == null) - { - return BadRequest(); - } - var user = _userManager.GetUserById(userId); // If removing admin access @@ -425,14 +413,14 @@ namespace Jellyfin.Api.Controllers { if (_userManager.Users.Count(i => i.HasPermission(PermissionKind.IsAdministrator)) == 1) { - return Forbid("There must be at least one user in the system with administrative access."); + 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 Forbid("Administrators cannot be disabled."); + return StatusCode(StatusCodes.Status403Forbidden, "Administrators cannot be disabled."); } // If disabling @@ -440,14 +428,14 @@ namespace Jellyfin.Api.Controllers { if (_userManager.Users.Count(i => !i.HasPermission(PermissionKind.IsDisabled)) == 1) { - return Forbid("There must be at least one enabled user in the system."); + return StatusCode(StatusCodes.Status403Forbidden, "There must be at least one enabled user in the system."); } var currentToken = _authContext.GetAuthorizationInfo(Request).Token; _sessionManager.RevokeUserTokens(user.Id, currentToken); } - _userManager.UpdatePolicy(userId, newPolicy); + await _userManager.UpdatePolicyAsync(userId, newPolicy).ConfigureAwait(false); return NoContent(); } @@ -464,16 +452,16 @@ namespace Jellyfin.Api.Controllers [Authorize(Policy = Policies.DefaultAuthorization)] [ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status403Forbidden)] - public ActionResult UpdateUserConfiguration( + public async Task<ActionResult> UpdateUserConfiguration( [FromRoute, Required] Guid userId, - [FromBody] UserConfiguration userConfig) + [FromBody, Required] UserConfiguration userConfig) { if (!RequestHelpers.AssertCanUpdateUser(_authContext, HttpContext.Request, userId, false)) { - return Forbid("User configuration update not allowed"); + return StatusCode(StatusCodes.Status403Forbidden, "User configuration update not allowed"); } - _userManager.UpdateConfiguration(userId, userConfig); + await _userManager.UpdateConfigurationAsync(userId, userConfig).ConfigureAwait(false); return NoContent(); } @@ -487,7 +475,7 @@ namespace Jellyfin.Api.Controllers [HttpPost("New")] [Authorize(Policy = Policies.RequiresElevation)] [ProducesResponseType(StatusCodes.Status200OK)] - public async Task<ActionResult<UserDto>> CreateUserByName([FromBody] CreateUserByName request) + public async Task<ActionResult<UserDto>> CreateUserByName([FromBody, Required] CreateUserByName request) { var newUser = await _userManager.CreateUserAsync(request.Name).ConfigureAwait(false); @@ -497,7 +485,7 @@ namespace Jellyfin.Api.Controllers await _userManager.ChangePassword(newUser, request.Password).ConfigureAwait(false); } - var result = _userManager.GetUserDto(newUser, HttpContext.GetNormalizedRemoteIp()); + var result = _userManager.GetUserDto(newUser, HttpContext.GetNormalizedRemoteIp().ToString()); return result; } @@ -512,8 +500,14 @@ namespace Jellyfin.Api.Controllers [ProducesResponseType(StatusCodes.Status200OK)] public async Task<ActionResult<ForgotPasswordResult>> ForgotPassword([FromBody, Required] ForgotPasswordDto forgotPasswordRequest) { + var ip = HttpContext.GetNormalizedRemoteIp(); var isLocal = HttpContext.IsLocal() - || _networkManager.IsInLocalNetwork(HttpContext.GetNormalizedRemoteIp()); + || _networkManager.IsInLocalNetwork(ip); + + if (isLocal) + { + _logger.LogWarning("Password reset proccess initiated from outside the local network with IP: {IP}", ip); + } var result = await _userManager.StartForgotPasswordProcess(forgotPasswordRequest.EnteredUsername, isLocal).ConfigureAwait(false); @@ -523,17 +517,44 @@ namespace Jellyfin.Api.Controllers /// <summary> /// Redeems a forgot password pin. /// </summary> - /// <param name="pin">The pin.</param> + /// <param name="forgotPasswordPinRequest">The forgot password pin request containing the entered pin.</param> /// <response code="200">Pin reset process started.</response> /// <returns>A <see cref="Task"/> containing a <see cref="PinRedeemResult"/>.</returns> [HttpPost("ForgotPassword/Pin")] [ProducesResponseType(StatusCodes.Status200OK)] - public async Task<ActionResult<PinRedeemResult>> ForgotPasswordPin([FromBody] string? pin) + public async Task<ActionResult<PinRedeemResult>> ForgotPasswordPin([FromBody, Required] ForgotPasswordPinDto forgotPasswordPinRequest) { - var result = await _userManager.RedeemPasswordResetPin(pin).ConfigureAwait(false); + var result = await _userManager.RedeemPasswordResetPin(forgotPasswordPinRequest.Pin).ConfigureAwait(false); return result; } + /// <summary> + /// Gets the user based on auth token. + /// </summary> + /// <response code="200">User returned.</response> + /// <response code="400">Token is not owned by a user.</response> + /// <returns>A <see cref="UserDto"/> for the authenticated user.</returns> + [HttpGet("Me")] + [Authorize(Policy = Policies.DefaultAuthorization)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public ActionResult<UserDto> GetCurrentUser() + { + var userId = ClaimHelpers.GetUserId(Request.HttpContext.User); + if (userId == null) + { + return BadRequest(); + } + + var user = _userManager.GetUserById(userId.Value); + if (user == null) + { + return BadRequest(); + } + + return _userManager.GetUserDto(user); + } + private IEnumerable<UserDto> Get(bool? isHidden, bool? isDisabled, bool filterByDevice, bool filterByNetwork) { var users = _userManager.Users; @@ -568,7 +589,7 @@ namespace Jellyfin.Api.Controllers var result = users .OrderBy(u => u.Username) - .Select(i => _userManager.GetUserDto(i, HttpContext.GetNormalizedRemoteIp())); + .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 48262f0620..a33a0826c9 100644 --- a/Jellyfin.Api/Controllers/UserLibraryController.cs +++ b/Jellyfin.Api/Controllers/UserLibraryController.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.Linq; @@ -7,7 +7,9 @@ using System.Threading.Tasks; using Jellyfin.Api.Constants; using Jellyfin.Api.Extensions; using Jellyfin.Api.Helpers; -using MediaBrowser.Common.Extensions; +using Jellyfin.Api.ModelBinders; +using Jellyfin.Data.Enums; +using Jellyfin.Extensions; using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.Audio; @@ -251,8 +253,8 @@ namespace Jellyfin.Api.Controllers /// </summary> /// <param name="userId">User id.</param> /// <param name="parentId">Specify this to localize the search to a specific item or folder. Omit to use the root.</param> - /// <param name="fields">Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimeted. Options: Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, SortName, Studios, Taglines.</param> - /// <param name="includeItemTypes">Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimeted.</param> + /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param> + /// <param name="includeItemTypes">Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimited.</param> /// <param name="isPlayed">Filter by items that are played, or not.</param> /// <param name="enableImages">Optional. include image information in output.</param> /// <param name="imageTypeLimit">Optional. the max number of images to return, per image type.</param> @@ -267,12 +269,12 @@ namespace Jellyfin.Api.Controllers public ActionResult<IEnumerable<BaseItemDto>> GetLatestMedia( [FromRoute, Required] Guid userId, [FromQuery] Guid? parentId, - [FromQuery] string? fields, - [FromQuery] string? includeItemTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] includeItemTypes, [FromQuery] bool? isPlayed, [FromQuery] bool? enableImages, [FromQuery] int? imageTypeLimit, - [FromQuery] string? enableImageTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes, [FromQuery] bool? enableUserData, [FromQuery] int limit = 20, [FromQuery] bool groupItems = true) @@ -287,8 +289,7 @@ namespace Jellyfin.Api.Controllers } } - var dtoOptions = new DtoOptions() - .AddItemFields(fields) + var dtoOptions = new DtoOptions { Fields = fields } .AddClientFields(Request) .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); @@ -296,7 +297,7 @@ namespace Jellyfin.Api.Controllers new LatestItemsQuery { GroupItems = groupItems, - IncludeItemTypes = RequestHelpers.Split(includeItemTypes, ',', true), + IncludeItemTypes = RequestHelpers.GetItemTypeStrings(includeItemTypes), IsPlayed = isPlayed, Limit = limit, ParentId = parentId ?? Guid.Empty, diff --git a/Jellyfin.Api/Controllers/UserViewsController.cs b/Jellyfin.Api/Controllers/UserViewsController.cs index d575bfc3b8..7bc5ecdf1d 100644 --- a/Jellyfin.Api/Controllers/UserViewsController.cs +++ b/Jellyfin.Api/Controllers/UserViewsController.cs @@ -1,10 +1,10 @@ -using System; +using System; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.Globalization; using System.Linq; using Jellyfin.Api.Extensions; -using Jellyfin.Api.Helpers; +using Jellyfin.Api.ModelBinders; using Jellyfin.Api.Models.UserViewDtos; using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; @@ -67,7 +67,7 @@ namespace Jellyfin.Api.Controllers public ActionResult<QueryResult<BaseItemDto>> GetUserViews( [FromRoute, Required] Guid userId, [FromQuery] bool? includeExternalContent, - [FromQuery] string? presetViews, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] presetViews, [FromQuery] bool includeHidden = false) { var query = new UserViewQuery @@ -81,9 +81,9 @@ namespace Jellyfin.Api.Controllers query.IncludeExternalContent = includeExternalContent.Value; } - if (!string.IsNullOrWhiteSpace(presetViews)) + if (presetViews.Length != 0) { - query.PresetViews = RequestHelpers.Split(presetViews, ',', true); + query.PresetViews = presetViews; } var app = _authContext.GetAuthorizationInfo(Request).Client ?? string.Empty; diff --git a/Jellyfin.Api/Controllers/VideoAttachmentsController.cs b/Jellyfin.Api/Controllers/VideoAttachmentsController.cs index 09a1c93e6a..c2bb0dfffe 100644 --- a/Jellyfin.Api/Controllers/VideoAttachmentsController.cs +++ b/Jellyfin.Api/Controllers/VideoAttachmentsController.cs @@ -3,6 +3,7 @@ using System.ComponentModel.DataAnnotations; using System.Net.Mime; using System.Threading; using System.Threading.Tasks; +using Jellyfin.Api.Attributes; using MediaBrowser.Common.Extensions; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.MediaEncoding; @@ -43,10 +44,10 @@ namespace Jellyfin.Api.Controllers /// <response code="404">Video or attachment not found.</response> /// <returns>An <see cref="FileStreamResult"/> containing the attachment stream on success, or a <see cref="NotFoundResult"/> if the attachment could not be found.</returns> [HttpGet("{videoId}/{mediaSourceId}/Attachments/{index}")] - [Produces(MediaTypeNames.Application.Octet)] + [ProducesFile(MediaTypeNames.Application.Octet)] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task<ActionResult<FileStreamResult>> GetAttachment( + public async Task<ActionResult> GetAttachment( [FromRoute, Required] Guid videoId, [FromRoute, Required] string mediaSourceId, [FromRoute, Required] int index) diff --git a/Jellyfin.Api/Controllers/VideoHlsController.cs b/Jellyfin.Api/Controllers/VideoHlsController.cs index 2afa878f41..5c941b2767 100644 --- a/Jellyfin.Api/Controllers/VideoHlsController.cs +++ b/Jellyfin.Api/Controllers/VideoHlsController.cs @@ -1,8 +1,9 @@ -using System; +using System; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.Globalization; using System.IO; +using System.Runtime.InteropServices; using System.Threading; using System.Threading.Tasks; using Jellyfin.Api.Attributes; @@ -47,9 +48,6 @@ namespace Jellyfin.Api.Controllers private readonly IMediaSourceManager _mediaSourceManager; private readonly IServerConfigurationManager _serverConfigurationManager; private readonly IMediaEncoder _mediaEncoder; - private readonly IFileSystem _fileSystem; - private readonly ISubtitleEncoder _subtitleEncoder; - private readonly IConfiguration _configuration; private readonly IDeviceManager _deviceManager; private readonly TranscodingJobHelper _transcodingJobHelper; private readonly ILogger<VideoHlsController> _logger; @@ -59,9 +57,6 @@ namespace Jellyfin.Api.Controllers /// Initializes a new instance of the <see cref="VideoHlsController"/> class. /// </summary> /// <param name="mediaEncoder">Instance of the <see cref="IMediaEncoder"/> interface.</param> - /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param> - /// <param name="subtitleEncoder">Instance of the <see cref="ISubtitleEncoder"/> interface.</param> - /// <param name="configuration">Instance of the <see cref="IConfiguration"/> interface.</param> /// <param name="dlnaManager">Instance of the <see cref="IDlnaManager"/> interface.</param> /// <param name="userManger">Instance of the <see cref="IUserManager"/> interface.</param> /// <param name="authorizationContext">Instance of the <see cref="IAuthorizationContext"/> interface.</param> @@ -71,11 +66,9 @@ namespace Jellyfin.Api.Controllers /// <param name="deviceManager">Instance of the <see cref="IDeviceManager"/> interface.</param> /// <param name="transcodingJobHelper">The <see cref="TranscodingJobHelper"/> singleton.</param> /// <param name="logger">Instance of the <see cref="ILogger{VideoHlsController}"/>.</param> + /// <param name="encodingHelper">Instance of <see cref="EncodingHelper"/>.</param> public VideoHlsController( IMediaEncoder mediaEncoder, - IFileSystem fileSystem, - ISubtitleEncoder subtitleEncoder, - IConfiguration configuration, IDlnaManager dlnaManager, IUserManager userManger, IAuthorizationContext authorizationContext, @@ -84,10 +77,9 @@ namespace Jellyfin.Api.Controllers IServerConfigurationManager serverConfigurationManager, IDeviceManager deviceManager, TranscodingJobHelper transcodingJobHelper, - ILogger<VideoHlsController> logger) + ILogger<VideoHlsController> logger, + EncodingHelper encodingHelper) { - _encodingHelper = new EncodingHelper(mediaEncoder, fileSystem, subtitleEncoder, configuration); - _dlnaManager = dlnaManager; _authContext = authorizationContext; _userManager = userManger; @@ -95,12 +87,11 @@ namespace Jellyfin.Api.Controllers _mediaSourceManager = mediaSourceManager; _serverConfigurationManager = serverConfigurationManager; _mediaEncoder = mediaEncoder; - _fileSystem = fileSystem; - _subtitleEncoder = subtitleEncoder; - _configuration = configuration; _deviceManager = deviceManager; _transcodingJobHelper = transcodingJobHelper; _logger = logger; + _encodingHelper = encodingHelper; + _encodingOptions = serverConfigurationManager.GetEncodingOptions(); } @@ -144,14 +135,14 @@ namespace Jellyfin.Api.Controllers /// <param name="maxVideoBitDepth">Optional. The maximum video bit depth.</param> /// <param name="requireAvc">Optional. Whether to require avc.</param> /// <param name="deInterlace">Optional. Whether to deinterlace the video.</param> - /// <param name="requireNonAnamorphic">Optional. Whether to require a non anamporphic stream.</param> + /// <param name="requireNonAnamorphic">Optional. Whether to require a non anamorphic stream.</param> /// <param name="transcodingMaxAudioChannels">Optional. The maximum number of audio channels to transcode.</param> /// <param name="cpuCoreLimit">Optional. The limit of how many cpu cores to use.</param> /// <param name="liveStreamId">The live stream id.</param> /// <param name="enableMpegtsM2TsMode">Optional. Whether to enable the MpegtsM2Ts mode.</param> /// <param name="videoCodec">Optional. Specify a video codec to encode to, e.g. h264. If omitted the server will auto-select using the url's extension. Options: h265, h264, mpeg4, theora, vpx, wmv.</param> /// <param name="subtitleCodec">Optional. Specify a subtitle codec to encode to.</param> - /// <param name="transcodingReasons">Optional. The transcoding reason.</param> + /// <param name="transcodeReasons">Optional. The transcoding reason.</param> /// <param name="audioStreamIndex">Optional. The index of the audio stream to use. If omitted the first audio stream will be used.</param> /// <param name="videoStreamIndex">Optional. The index of the video stream to use. If omitted the first video stream will be used.</param> /// <param name="context">Optional. The <see cref="EncodingContext"/>.</param> @@ -197,7 +188,7 @@ namespace Jellyfin.Api.Controllers [FromQuery] int? height, [FromQuery] int? videoBitRate, [FromQuery] int? subtitleStreamIndex, - [FromQuery] SubtitleDeliveryMethod subtitleMethod, + [FromQuery] SubtitleDeliveryMethod? subtitleMethod, [FromQuery] int? maxRefFrames, [FromQuery] int? maxVideoBitDepth, [FromQuery] bool? requireAvc, @@ -209,10 +200,10 @@ namespace Jellyfin.Api.Controllers [FromQuery] bool? enableMpegtsM2TsMode, [FromQuery] string? videoCodec, [FromQuery] string? subtitleCodec, - [FromQuery] string? transcodingReasons, + [FromQuery] string? transcodeReasons, [FromQuery] int? audioStreamIndex, [FromQuery] int? videoStreamIndex, - [FromQuery] EncodingContext context, + [FromQuery] EncodingContext? context, [FromQuery] Dictionary<string, string> streamOptions, [FromQuery] int? maxWidth, [FromQuery] int? maxHeight, @@ -222,7 +213,7 @@ namespace Jellyfin.Api.Controllers { Id = itemId, Container = container, - Static = @static ?? true, + Static = @static ?? false, Params = @params, Tag = tag, DeviceProfileId = deviceProfileId, @@ -246,34 +237,35 @@ namespace Jellyfin.Api.Controllers Level = level, Framerate = framerate, MaxFramerate = maxFramerate, - CopyTimestamps = copyTimestamps ?? true, + CopyTimestamps = copyTimestamps ?? false, StartTimeTicks = startTimeTicks, Width = width, Height = height, VideoBitRate = videoBitRate, SubtitleStreamIndex = subtitleStreamIndex, - SubtitleMethod = subtitleMethod, + SubtitleMethod = subtitleMethod ?? SubtitleDeliveryMethod.Encode, MaxRefFrames = maxRefFrames, MaxVideoBitDepth = maxVideoBitDepth, - RequireAvc = requireAvc ?? true, - DeInterlace = deInterlace ?? true, - RequireNonAnamorphic = requireNonAnamorphic ?? true, + RequireAvc = requireAvc ?? false, + DeInterlace = deInterlace ?? false, + RequireNonAnamorphic = requireNonAnamorphic ?? false, TranscodingMaxAudioChannels = transcodingMaxAudioChannels, CpuCoreLimit = cpuCoreLimit, LiveStreamId = liveStreamId, - EnableMpegtsM2TsMode = enableMpegtsM2TsMode ?? true, + EnableMpegtsM2TsMode = enableMpegtsM2TsMode ?? false, VideoCodec = videoCodec, SubtitleCodec = subtitleCodec, - TranscodeReasons = transcodingReasons, + TranscodeReasons = transcodeReasons, AudioStreamIndex = audioStreamIndex, VideoStreamIndex = videoStreamIndex, - Context = context, + Context = context ?? EncodingContext.Streaming, StreamOptions = streamOptions, MaxHeight = maxHeight, MaxWidth = maxWidth, EnableSubtitlesInManifest = enableSubtitlesInManifest ?? true }; + // CTS lifecycle is managed internally. var cancellationTokenSource = new CancellationTokenSource(); using var state = await StreamingHelpers.GetStreamingState( streamingRequest, @@ -284,9 +276,7 @@ namespace Jellyfin.Api.Controllers _libraryManager, _serverConfigurationManager, _mediaEncoder, - _fileSystem, - _subtitleEncoder, - _configuration, + _encodingHelper, _dlnaManager, _deviceManager, _transcodingJobHelper, @@ -295,23 +285,23 @@ namespace Jellyfin.Api.Controllers .ConfigureAwait(false); TranscodingJobDto? job = null; - var playlist = state.OutputFilePath; + var playlistPath = Path.ChangeExtension(state.OutputFilePath, ".m3u8"); - if (!System.IO.File.Exists(playlist)) + if (!System.IO.File.Exists(playlistPath)) { - var transcodingLock = _transcodingJobHelper.GetTranscodingLock(playlist); + var transcodingLock = _transcodingJobHelper.GetTranscodingLock(playlistPath); await transcodingLock.WaitAsync(cancellationTokenSource.Token).ConfigureAwait(false); try { - if (!System.IO.File.Exists(playlist)) + if (!System.IO.File.Exists(playlistPath)) { // If the playlist doesn't already exist, startup ffmpeg try { job = await _transcodingJobHelper.StartFfMpeg( state, - playlist, - GetCommandLineArguments(playlist, state), + playlistPath, + GetCommandLineArguments(playlistPath, state), Request, TranscodingJobType, cancellationTokenSource) @@ -327,7 +317,7 @@ namespace Jellyfin.Api.Controllers minSegments = state.MinSegments; if (minSegments > 0) { - await HlsHelpers.WaitForMinimumSegmentCount(playlist, minSegments, _logger, cancellationTokenSource.Token).ConfigureAwait(false); + await HlsHelpers.WaitForMinimumSegmentCount(playlistPath, minSegments, _logger, cancellationTokenSource.Token).ConfigureAwait(false); } } } @@ -337,14 +327,14 @@ namespace Jellyfin.Api.Controllers } } - job ??= _transcodingJobHelper.OnTranscodeBeginRequest(playlist, TranscodingJobType); + job ??= _transcodingJobHelper.OnTranscodeBeginRequest(playlistPath, TranscodingJobType); if (job != null) { _transcodingJobHelper.OnTranscodeEndRequest(job); } - var playlistText = HlsHelpers.GetLivePlaylistText(playlist, state.SegmentLength); + var playlistText = HlsHelpers.GetLivePlaylistText(playlistPath, state); return Content(playlistText, MimeTypes.GetMimeType("playlist.m3u8")); } @@ -358,38 +348,66 @@ namespace Jellyfin.Api.Controllers private string GetCommandLineArguments(string outputPath, StreamState state) { var videoCodec = _encodingHelper.GetVideoEncoder(state, _encodingOptions); - var threads = _encodingHelper.GetNumberOfThreads(state, _encodingOptions, videoCodec); + var threads = EncodingHelper.GetNumberOfThreads(state, _encodingOptions, videoCodec); // GetNumberOfThreads is static. var inputModifier = _encodingHelper.GetInputModifier(state, _encodingOptions); - var format = !string.IsNullOrWhiteSpace(state.Request.SegmentContainer) ? "." + state.Request.SegmentContainer : ".ts"; - var outputTsArg = Path.Combine(Path.GetDirectoryName(outputPath), Path.GetFileNameWithoutExtension(outputPath)) + "%d" + format; + var mapArgs = state.IsOutputVideo ? _encodingHelper.GetMapArgs(state) : string.Empty; - var segmentFormat = format.TrimStart('.'); + 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 = outputExtension.TrimStart('.'); if (string.Equals(segmentFormat, "ts", StringComparison.OrdinalIgnoreCase)) { segmentFormat = "mpegts"; } + else if (string.Equals(segmentFormat, "mp4", StringComparison.OrdinalIgnoreCase)) + { + var outputFmp4HeaderArg = string.Empty; + if (OperatingSystem.IsWindows()) + { + // on Windows, the path of fmp4 header file needs to be configured + outputFmp4HeaderArg = " -hls_fmp4_init_filename \"" + outputPrefix + "-1" + outputExtension + "\""; + } + else + { + // on Linux/Unix, ffmpeg generate fmp4 header file to m3u8 output folder + outputFmp4HeaderArg = " -hls_fmp4_init_filename \"" + outputFileNameWithoutExtension + "-1" + outputExtension + "\""; + } + + segmentFormat = "fmp4" + outputFmp4HeaderArg; + } + else + { + _logger.LogError("Invalid HLS segment container: {SegmentFormat}", segmentFormat); + } + + var maxMuxingQueueSize = _encodingOptions.MaxMuxingQueueSize > 128 + ? _encodingOptions.MaxMuxingQueueSize.ToString(CultureInfo.InvariantCulture) + : "128"; var baseUrlParam = string.Format( CultureInfo.InvariantCulture, - "\"hls{0}\"", + "\"hls/{0}/\"", Path.GetFileNameWithoutExtension(outputPath)); return string.Format( CultureInfo.InvariantCulture, - "{0} {1} -map_metadata -1 -map_chapters -1 -threads {2} {3} {4} {5} -f segment -max_delay 5000000 -avoid_negative_ts disabled -start_at_zero -segment_time {6} {7} -individual_header_trailer 0 -segment_format {8} -segment_list_entry_prefix {9} -segment_list_type m3u8 -segment_start_number 0 -segment_list \"{10}\" -y \"{11}\"", + "{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 0 -hls_base_url {9} -hls_playlist_type event -hls_segment_filename \"{10}\" -y \"{11}\"", inputModifier, _encodingHelper.GetInputArgument(state, _encodingOptions), threads, - _encodingHelper.GetMapArgs(state), + mapArgs, GetVideoArguments(state), GetAudioArguments(state), + maxMuxingQueueSize, state.SegmentLength.ToString(CultureInfo.InvariantCulture), - string.Empty, segmentFormat, baseUrlParam, - outputPath, - outputTsArg) - .Trim(); + outputTsArg, + outputPath).Trim(); } /// <summary> @@ -399,14 +417,53 @@ namespace Jellyfin.Api.Controllers /// <returns>The command line arguments for audio transcoding.</returns> private string GetAudioArguments(StreamState state) { - var codec = _encodingHelper.GetAudioEncoder(state); + if (state.AudioStream == null) + { + return string.Empty; + } - if (EncodingHelper.IsCopyCodec(codec)) + var audioCodec = _encodingHelper.GetAudioEncoder(state); + + if (!state.IsOutputVideo) + { + if (EncodingHelper.IsCopyCodec(audioCodec)) + { + var bitStreamArgs = EncodingHelper.GetAudioBitStreamArguments(state, state.Request.SegmentContainer, state.MediaSource.Container); + + return "-acodec copy -strict -2" + bitStreamArgs; + } + + var audioTranscodeParams = string.Empty; + + audioTranscodeParams += "-acodec " + audioCodec; + + if (state.OutputAudioBitrate.HasValue) + { + audioTranscodeParams += " -ab " + state.OutputAudioBitrate.Value.ToString(CultureInfo.InvariantCulture); + } + + if (state.OutputAudioChannels.HasValue) + { + audioTranscodeParams += " -ac " + state.OutputAudioChannels.Value.ToString(CultureInfo.InvariantCulture); + } + + if (state.OutputAudioSampleRate.HasValue) + { + audioTranscodeParams += " -ar " + state.OutputAudioSampleRate.Value.ToString(CultureInfo.InvariantCulture); + } + + audioTranscodeParams += " -vn"; + return audioTranscodeParams; + } + + if (EncodingHelper.IsCopyCodec(audioCodec)) { - return "-codec:a:0 copy"; + var bitStreamArgs = EncodingHelper.GetAudioBitStreamArguments(state, state.Request.SegmentContainer, state.MediaSource.Container); + + return "-acodec copy -strict -2" + bitStreamArgs; } - var args = "-codec:a:0 " + codec; + var args = "-codec:a:0 " + audioCodec; var channels = state.OutputAudioChannels; @@ -427,7 +484,7 @@ namespace Jellyfin.Api.Controllers args += " -ar " + state.OutputAudioSampleRate.Value.ToString(CultureInfo.InvariantCulture); } - args += " " + _encodingHelper.GetAudioFilterParam(state, _encodingOptions, true); + args += _encodingHelper.GetAudioFilterParam(state, _encodingOptions); return args; } @@ -439,6 +496,11 @@ namespace Jellyfin.Api.Controllers /// <returns>The command line arguments for video transcoding.</returns> private string GetVideoArguments(StreamState state) { + if (state.VideoStream == null) + { + return string.Empty; + } + if (!state.IsOutputVideo) { return string.Empty; @@ -448,47 +510,65 @@ namespace Jellyfin.Api.Controllers var args = "-codec:v:0 " + codec; + // Prefer hvc1 to hev1. + 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)) + { + 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 (codec.Equals("copy", StringComparison.OrdinalIgnoreCase)) + // See if we can save come cpu cycles by avoiding encoding. + if (EncodingHelper.IsCopyCodec(codec)) { - // if h264_mp4toannexb is ever added, do not use it for live tv - if (state.VideoStream != null && - !string.Equals(state.VideoStream.NalLengthSize, "0", StringComparison.OrdinalIgnoreCase)) + // If h264_mp4toannexb is ever added, do not use it for live tv. + if (state.VideoStream != null && !string.Equals(state.VideoStream.NalLengthSize, "0", StringComparison.OrdinalIgnoreCase)) { - string bitStreamArgs = _encodingHelper.GetBitStreamArgs(state.VideoStream); + string bitStreamArgs = EncodingHelper.GetBitStreamArgs(state.VideoStream); if (!string.IsNullOrEmpty(bitStreamArgs)) { args += " " + bitStreamArgs; } } + + args += " -start_at_zero"; } else { - var keyFrameArg = string.Format( - CultureInfo.InvariantCulture, - " -force_key_frames \"expr:gte(t,n_forced*{0})\"", - state.SegmentLength.ToString(CultureInfo.InvariantCulture)); + args += _encodingHelper.GetVideoQualityParam(state, codec, _encodingOptions, DefaultEncoderPreset); - var hasGraphicalSubs = state.SubtitleStream != null && !state.SubtitleStream.IsTextSubtitleStream && state.SubtitleDeliveryMethod == SubtitleDeliveryMethod.Encode; - - args += " " + _encodingHelper.GetVideoQualityParam(state, codec, _encodingOptions, DefaultEncoderPreset) + keyFrameArg; + // Set the key frame params for video encoding to match the hls segment time. + args += _encodingHelper.GetHlsVideoKeyFrameArguments(state, codec, state.SegmentLength, true, null); - // Add resolution params, if specified - if (!hasGraphicalSubs) + // Currenly b-frames in libx265 breaks the FMP4-HLS playback on iOS, disable it for now. + if (string.Equals(codec, "libx265", StringComparison.OrdinalIgnoreCase)) { - args += _encodingHelper.GetOutputSizeParam(state, _encodingOptions, codec); + args += " -bf 0"; } - // This is for internal graphical subs + var hasGraphicalSubs = state.SubtitleStream != null && !state.SubtitleStream.IsTextSubtitleStream && state.SubtitleDeliveryMethod == SubtitleDeliveryMethod.Encode; + if (hasGraphicalSubs) { + // Graphical subs overlay and resolution params. args += _encodingHelper.GetGraphicalSubtitleParam(state, _encodingOptions, codec); } + else + { + // Resolution params. + args += _encodingHelper.GetOutputSizeParam(state, _encodingOptions, codec); + } + + if (state.SubtitleStream == null || !state.SubtitleStream.IsExternal || state.SubtitleStream.IsTextSubtitleStream) + { + args += " -start_at_zero"; + } } args += " -flags -global_header"; diff --git a/Jellyfin.Api/Controllers/VideosController.cs b/Jellyfin.Api/Controllers/VideosController.cs index 4de7aac71d..29a25fa6aa 100644 --- a/Jellyfin.Api/Controllers/VideosController.cs +++ b/Jellyfin.Api/Controllers/VideosController.cs @@ -10,6 +10,7 @@ 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; using MediaBrowser.Common.Configuration; using MediaBrowser.Common.Net; @@ -48,12 +49,10 @@ namespace Jellyfin.Api.Controllers private readonly IMediaSourceManager _mediaSourceManager; private readonly IServerConfigurationManager _serverConfigurationManager; private readonly IMediaEncoder _mediaEncoder; - private readonly IFileSystem _fileSystem; - private readonly ISubtitleEncoder _subtitleEncoder; - private readonly IConfiguration _configuration; private readonly IDeviceManager _deviceManager; private readonly TranscodingJobHelper _transcodingJobHelper; private readonly IHttpClientFactory _httpClientFactory; + private readonly EncodingHelper _encodingHelper; private readonly TranscodingJobType _transcodingJobType = TranscodingJobType.Progressive; @@ -68,12 +67,10 @@ namespace Jellyfin.Api.Controllers /// <param name="mediaSourceManager">Instance of the <see cref="IMediaSourceManager"/> interface.</param> /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param> /// <param name="mediaEncoder">Instance of the <see cref="IMediaEncoder"/> interface.</param> - /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param> - /// <param name="subtitleEncoder">Instance of the <see cref="ISubtitleEncoder"/> interface.</param> - /// <param name="configuration">Instance of the <see cref="IConfiguration"/> interface.</param> /// <param name="deviceManager">Instance of the <see cref="IDeviceManager"/> interface.</param> /// <param name="transcodingJobHelper">Instance of the <see cref="TranscodingJobHelper"/> class.</param> /// <param name="httpClientFactory">Instance of the <see cref="IHttpClientFactory"/> interface.</param> + /// <param name="encodingHelper">Instance of <see cref="EncodingHelper"/>.</param> public VideosController( ILibraryManager libraryManager, IUserManager userManager, @@ -83,12 +80,10 @@ namespace Jellyfin.Api.Controllers IMediaSourceManager mediaSourceManager, IServerConfigurationManager serverConfigurationManager, IMediaEncoder mediaEncoder, - IFileSystem fileSystem, - ISubtitleEncoder subtitleEncoder, - IConfiguration configuration, IDeviceManager deviceManager, TranscodingJobHelper transcodingJobHelper, - IHttpClientFactory httpClientFactory) + IHttpClientFactory httpClientFactory, + EncodingHelper encodingHelper) { _libraryManager = libraryManager; _userManager = userManager; @@ -98,12 +93,10 @@ namespace Jellyfin.Api.Controllers _mediaSourceManager = mediaSourceManager; _serverConfigurationManager = serverConfigurationManager; _mediaEncoder = mediaEncoder; - _fileSystem = fileSystem; - _subtitleEncoder = subtitleEncoder; - _configuration = configuration; _deviceManager = deviceManager; _transcodingJobHelper = transcodingJobHelper; _httpClientFactory = httpClientFactory; + _encodingHelper = encodingHelper; } /// <summary> @@ -195,7 +188,7 @@ namespace Jellyfin.Api.Controllers /// <summary> /// Merges videos into a single record. /// </summary> - /// <param name="itemIds">Item id list. This allows multiple, comma delimited.</param> + /// <param name="ids">Item id list. This allows multiple, comma delimited.</param> /// <response code="204">Videos merged.</response> /// <response code="400">Supply at least 2 video ids.</response> /// <returns>A <see cref="NoContentResult"/> indicating success, or a <see cref="BadRequestResult"/> if less than two ids were supplied.</returns> @@ -203,9 +196,9 @@ namespace Jellyfin.Api.Controllers [Authorize(Policy = Policies.RequiresElevation)] [ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status400BadRequest)] - public async Task<ActionResult> MergeVersions([FromQuery, Required] string itemIds) + public async Task<ActionResult> MergeVersions([FromQuery, Required, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] ids) { - var items = RequestHelpers.Split(itemIds, ',', true) + var items = ids .Select(i => _libraryManager.GetItemById(i)) .OfType<Video>() .OrderBy(i => i.Id) @@ -216,9 +209,7 @@ namespace Jellyfin.Api.Controllers return BadRequest("Please supply at least two videos to merge."); } - var videosWithVersions = items.Where(i => i.MediaSourceCount > 1).ToList(); - - var primaryVersion = videosWithVersions.FirstOrDefault(); + var primaryVersion = items.FirstOrDefault(i => i.MediaSourceCount > 1 && string.IsNullOrEmpty(i.PrimaryVersionId)); if (primaryVersion == null) { primaryVersion = items @@ -283,7 +274,7 @@ namespace Jellyfin.Api.Controllers /// <param name="deviceProfileId">Optional. The dlna device profile id to utilize.</param> /// <param name="playSessionId">The play session id.</param> /// <param name="segmentContainer">The segment container.</param> - /// <param name="segmentLength">The segment lenght.</param> + /// <param name="segmentLength">The segment length.</param> /// <param name="minSegments">The minimum number of segments.</param> /// <param name="mediaSourceId">The media version id, if playing an alternate version.</param> /// <param name="deviceId">The device id of the client requesting. Used to stop encoding processes when needed.</param> @@ -305,6 +296,8 @@ namespace Jellyfin.Api.Controllers /// <param name="startTimeTicks">Optional. Specify a starting offset, in ticks. 1 tick = 10000 ms.</param> /// <param name="width">Optional. The fixed horizontal resolution of the encoded video.</param> /// <param name="height">Optional. The fixed vertical resolution of the encoded video.</param> + /// <param name="maxWidth">Optional. The maximum horizontal resolution of the encoded video.</param> + /// <param name="maxHeight">Optional. The maximum vertical resolution of the encoded video.</param> /// <param name="videoBitRate">Optional. Specify a video bitrate to encode to, e.g. 500000. If omitted this will be left to encoder defaults.</param> /// <param name="subtitleStreamIndex">Optional. The index of the subtitle stream to use. If omitted no subtitles will be used.</param> /// <param name="subtitleMethod">Optional. Specify the subtitle delivery method.</param> @@ -312,29 +305,27 @@ namespace Jellyfin.Api.Controllers /// <param name="maxVideoBitDepth">Optional. The maximum video bit depth.</param> /// <param name="requireAvc">Optional. Whether to require avc.</param> /// <param name="deInterlace">Optional. Whether to deinterlace the video.</param> - /// <param name="requireNonAnamorphic">Optional. Whether to require a non anamporphic stream.</param> + /// <param name="requireNonAnamorphic">Optional. Whether to require a non anamorphic stream.</param> /// <param name="transcodingMaxAudioChannels">Optional. The maximum number of audio channels to transcode.</param> /// <param name="cpuCoreLimit">Optional. The limit of how many cpu cores to use.</param> /// <param name="liveStreamId">The live stream id.</param> /// <param name="enableMpegtsM2TsMode">Optional. Whether to enable the MpegtsM2Ts mode.</param> /// <param name="videoCodec">Optional. Specify a video codec to encode to, e.g. h264. If omitted the server will auto-select using the url's extension. Options: h265, h264, mpeg4, theora, vpx, wmv.</param> /// <param name="subtitleCodec">Optional. Specify a subtitle codec to encode to.</param> - /// <param name="transcodingReasons">Optional. The transcoding reason.</param> + /// <param name="transcodeReasons">Optional. The transcoding reason.</param> /// <param name="audioStreamIndex">Optional. The index of the audio stream to use. If omitted the first audio stream will be used.</param> /// <param name="videoStreamIndex">Optional. The index of the video stream to use. If omitted the first video stream will be used.</param> /// <param name="context">Optional. The <see cref="EncodingContext"/>.</param> /// <param name="streamOptions">Optional. The streaming options.</param> /// <response code="200">Video stream returned.</response> /// <returns>A <see cref="FileResult"/> containing the audio file.</returns> - [HttpGet("{itemId}/{stream=stream}.{container?}", Name = "GetVideoStreamWithExt")] [HttpGet("{itemId}/stream")] - [HttpHead("{itemId}/{stream=stream}.{container?}", Name = "HeadVideoStreamWithExt")] [HttpHead("{itemId}/stream", Name = "HeadVideoStream")] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesVideoFile] public async Task<ActionResult> GetVideoStream( [FromRoute, Required] Guid itemId, - [FromRoute] string? container, + [FromQuery] string? container, [FromQuery] bool? @static, [FromQuery] string? @params, [FromQuery] string? tag, @@ -363,9 +354,11 @@ namespace Jellyfin.Api.Controllers [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] SubtitleDeliveryMethod? subtitleMethod, [FromQuery] int? maxRefFrames, [FromQuery] int? maxVideoBitDepth, [FromQuery] bool? requireAvc, @@ -377,19 +370,20 @@ namespace Jellyfin.Api.Controllers [FromQuery] bool? enableMpegtsM2TsMode, [FromQuery] string? videoCodec, [FromQuery] string? subtitleCodec, - [FromQuery] string? transcodingReasons, + [FromQuery] string? transcodeReasons, [FromQuery] int? audioStreamIndex, [FromQuery] int? videoStreamIndex, - [FromQuery] EncodingContext context, + [FromQuery] EncodingContext? context, [FromQuery] Dictionary<string, string> streamOptions) { var isHeadRequest = Request.Method == System.Net.WebRequestMethods.Http.Head; + // CTS lifecycle is managed internally. var cancellationTokenSource = new CancellationTokenSource(); var streamingRequest = new VideoRequestDto { Id = itemId, Container = container, - Static = @static ?? true, + Static = @static ?? false, Params = @params, Tag = tag, DeviceProfileId = deviceProfileId, @@ -413,28 +407,30 @@ namespace Jellyfin.Api.Controllers Level = level, Framerate = framerate, MaxFramerate = maxFramerate, - CopyTimestamps = copyTimestamps ?? true, + CopyTimestamps = copyTimestamps ?? false, StartTimeTicks = startTimeTicks, Width = width, Height = height, + MaxWidth = maxWidth, + MaxHeight = maxHeight, VideoBitRate = videoBitRate, SubtitleStreamIndex = subtitleStreamIndex, - SubtitleMethod = subtitleMethod, + SubtitleMethod = subtitleMethod ?? SubtitleDeliveryMethod.Encode, MaxRefFrames = maxRefFrames, MaxVideoBitDepth = maxVideoBitDepth, - RequireAvc = requireAvc ?? true, - DeInterlace = deInterlace ?? true, - RequireNonAnamorphic = requireNonAnamorphic ?? true, + RequireAvc = requireAvc ?? false, + DeInterlace = deInterlace ?? false, + RequireNonAnamorphic = requireNonAnamorphic ?? false, TranscodingMaxAudioChannels = transcodingMaxAudioChannels, CpuCoreLimit = cpuCoreLimit, LiveStreamId = liveStreamId, - EnableMpegtsM2TsMode = enableMpegtsM2TsMode ?? true, + EnableMpegtsM2TsMode = enableMpegtsM2TsMode ?? false, VideoCodec = videoCodec, SubtitleCodec = subtitleCodec, - TranscodeReasons = transcodingReasons, + TranscodeReasons = transcodeReasons, AudioStreamIndex = audioStreamIndex, VideoStreamIndex = videoStreamIndex, - Context = context, + Context = context ?? EncodingContext.Streaming, StreamOptions = streamOptions }; @@ -447,9 +443,7 @@ namespace Jellyfin.Api.Controllers _libraryManager, _serverConfigurationManager, _mediaEncoder, - _fileSystem, - _subtitleEncoder, - _configuration, + _encodingHelper, _dlnaManager, _deviceManager, _transcodingJobHelper, @@ -518,8 +512,7 @@ namespace Jellyfin.Api.Controllers // Need to start ffmpeg (because media can't be returned directly) var encodingOptions = _serverConfigurationManager.GetEncodingOptions(); - var encodingHelper = new EncodingHelper(_mediaEncoder, _fileSystem, _subtitleEncoder, _configuration); - var ffmpegCommandLineArguments = encodingHelper.GetProgressiveVideoFullCommandLine(state, encodingOptions, outputPath, "superfast"); + var ffmpegCommandLineArguments = _encodingHelper.GetProgressiveVideoFullCommandLine(state, encodingOptions, outputPath, "superfast"); return await FileStreamResponseHelpers.GetTranscodedFile( state, isHeadRequest, @@ -529,5 +522,172 @@ namespace Jellyfin.Api.Controllers _transcodingJobType, cancellationTokenSource).ConfigureAwait(false); } + + /// <summary> + /// Gets a video stream. + /// </summary> + /// <param name="itemId">The item id.</param> + /// <param name="container">The video container. Possible values are: ts, webm, asf, wmv, ogv, mp4, m4v, mkv, mpeg, mpg, avi, 3gp, wmv, wtv, m2ts, mov, iso, flv. </param> + /// <param name="static">Optional. If true, the original file will be streamed statically without any encoding. Use either no url extension or the original file extension. true/false.</param> + /// <param name="params">The streaming parameters.</param> + /// <param name="tag">The tag.</param> + /// <param name="deviceProfileId">Optional. The dlna device profile id to utilize.</param> + /// <param name="playSessionId">The play session id.</param> + /// <param name="segmentContainer">The segment container.</param> + /// <param name="segmentLength">The segment length.</param> + /// <param name="minSegments">The minimum number of segments.</param> + /// <param name="mediaSourceId">The media version id, if playing an alternate version.</param> + /// <param name="deviceId">The device id of the client requesting. Used to stop encoding processes when needed.</param> + /// <param name="audioCodec">Optional. Specify a audio codec to encode to, e.g. mp3. If omitted the server will auto-select using the url's extension. Options: aac, mp3, vorbis, wma.</param> + /// <param name="enableAutoStreamCopy">Whether or not to allow automatic stream copy if requested values match the original source. Defaults to true.</param> + /// <param name="allowVideoStreamCopy">Whether or not to allow copying of the video stream url.</param> + /// <param name="allowAudioStreamCopy">Whether or not to allow copying of the audio stream url.</param> + /// <param name="breakOnNonKeyFrames">Optional. Whether to break on non key frames.</param> + /// <param name="audioSampleRate">Optional. Specify a specific audio sample rate, e.g. 44100.</param> + /// <param name="maxAudioBitDepth">Optional. The maximum audio bit depth.</param> + /// <param name="audioBitRate">Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults.</param> + /// <param name="audioChannels">Optional. Specify a specific number of audio channels to encode to, e.g. 2.</param> + /// <param name="maxAudioChannels">Optional. Specify a maximum number of audio channels to encode to, e.g. 2.</param> + /// <param name="profile">Optional. Specify a specific an encoder profile (varies by encoder), e.g. main, baseline, high.</param> + /// <param name="level">Optional. Specify a level for the encoder profile (varies by encoder), e.g. 3, 3.1.</param> + /// <param name="framerate">Optional. A specific video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param> + /// <param name="maxFramerate">Optional. A specific maximum video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param> + /// <param name="copyTimestamps">Whether or not to copy timestamps when transcoding with an offset. Defaults to false.</param> + /// <param name="startTimeTicks">Optional. Specify a starting offset, in ticks. 1 tick = 10000 ms.</param> + /// <param name="width">Optional. The fixed horizontal resolution of the encoded video.</param> + /// <param name="height">Optional. The fixed vertical resolution of the encoded video.</param> + /// <param name="maxWidth">Optional. The maximum horizontal resolution of the encoded video.</param> + /// <param name="maxHeight">Optional. The maximum vertical resolution of the encoded video.</param> + /// <param name="videoBitRate">Optional. Specify a video bitrate to encode to, e.g. 500000. If omitted this will be left to encoder defaults.</param> + /// <param name="subtitleStreamIndex">Optional. The index of the subtitle stream to use. If omitted no subtitles will be used.</param> + /// <param name="subtitleMethod">Optional. Specify the subtitle delivery method.</param> + /// <param name="maxRefFrames">Optional.</param> + /// <param name="maxVideoBitDepth">Optional. The maximum video bit depth.</param> + /// <param name="requireAvc">Optional. Whether to require avc.</param> + /// <param name="deInterlace">Optional. Whether to deinterlace the video.</param> + /// <param name="requireNonAnamorphic">Optional. Whether to require a non anamorphic stream.</param> + /// <param name="transcodingMaxAudioChannels">Optional. The maximum number of audio channels to transcode.</param> + /// <param name="cpuCoreLimit">Optional. The limit of how many cpu cores to use.</param> + /// <param name="liveStreamId">The live stream id.</param> + /// <param name="enableMpegtsM2TsMode">Optional. Whether to enable the MpegtsM2Ts mode.</param> + /// <param name="videoCodec">Optional. Specify a video codec to encode to, e.g. h264. If omitted the server will auto-select using the url's extension. Options: h265, h264, mpeg4, theora, vpx, wmv.</param> + /// <param name="subtitleCodec">Optional. Specify a subtitle codec to encode to.</param> + /// <param name="transcodeReasons">Optional. The transcoding reason.</param> + /// <param name="audioStreamIndex">Optional. The index of the audio stream to use. If omitted the first audio stream will be used.</param> + /// <param name="videoStreamIndex">Optional. The index of the video stream to use. If omitted the first video stream will be used.</param> + /// <param name="context">Optional. The <see cref="EncodingContext"/>.</param> + /// <param name="streamOptions">Optional. The streaming options.</param> + /// <response code="200">Video stream returned.</response> + /// <returns>A <see cref="FileResult"/> containing the audio file.</returns> + [HttpGet("{itemId}/stream.{container}")] + [HttpHead("{itemId}/stream.{container}", Name = "HeadVideoStreamByContainer")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesVideoFile] + public Task<ActionResult> GetVideoStreamByContainer( + [FromRoute, Required] Guid itemId, + [FromRoute, Required] string container, + [FromQuery] bool? @static, + [FromQuery] string? @params, + [FromQuery] string? tag, + [FromQuery] string? deviceProfileId, + [FromQuery] string? playSessionId, + [FromQuery] string? segmentContainer, + [FromQuery] int? segmentLength, + [FromQuery] int? minSegments, + [FromQuery] string? mediaSourceId, + [FromQuery] string? deviceId, + [FromQuery] string? audioCodec, + [FromQuery] bool? enableAutoStreamCopy, + [FromQuery] bool? allowVideoStreamCopy, + [FromQuery] bool? allowAudioStreamCopy, + [FromQuery] bool? breakOnNonKeyFrames, + [FromQuery] int? audioSampleRate, + [FromQuery] int? maxAudioBitDepth, + [FromQuery] int? audioBitRate, + [FromQuery] int? audioChannels, + [FromQuery] int? maxAudioChannels, + [FromQuery] string? profile, + [FromQuery] string? level, + [FromQuery] float? framerate, + [FromQuery] float? maxFramerate, + [FromQuery] bool? copyTimestamps, + [FromQuery] long? startTimeTicks, + [FromQuery] int? width, + [FromQuery] int? height, + [FromQuery] int? maxWidth, + [FromQuery] int? maxHeight, + [FromQuery] int? videoBitRate, + [FromQuery] int? subtitleStreamIndex, + [FromQuery] SubtitleDeliveryMethod? subtitleMethod, + [FromQuery] int? maxRefFrames, + [FromQuery] int? maxVideoBitDepth, + [FromQuery] bool? requireAvc, + [FromQuery] bool? deInterlace, + [FromQuery] bool? requireNonAnamorphic, + [FromQuery] int? transcodingMaxAudioChannels, + [FromQuery] int? cpuCoreLimit, + [FromQuery] string? liveStreamId, + [FromQuery] bool? enableMpegtsM2TsMode, + [FromQuery] string? videoCodec, + [FromQuery] string? subtitleCodec, + [FromQuery] string? transcodeReasons, + [FromQuery] int? audioStreamIndex, + [FromQuery] int? videoStreamIndex, + [FromQuery] EncodingContext? context, + [FromQuery] Dictionary<string, string> streamOptions) + { + return GetVideoStream( + itemId, + container, + @static, + @params, + tag, + deviceProfileId, + playSessionId, + segmentContainer, + segmentLength, + minSegments, + mediaSourceId, + deviceId, + audioCodec, + enableAutoStreamCopy, + allowVideoStreamCopy, + allowAudioStreamCopy, + breakOnNonKeyFrames, + audioSampleRate, + maxAudioBitDepth, + audioBitRate, + audioChannels, + maxAudioChannels, + profile, + level, + framerate, + maxFramerate, + copyTimestamps, + startTimeTicks, + width, + height, + maxWidth, + maxHeight, + videoBitRate, + subtitleStreamIndex, + subtitleMethod, + maxRefFrames, + maxVideoBitDepth, + requireAvc, + deInterlace, + requireNonAnamorphic, + transcodingMaxAudioChannels, + cpuCoreLimit, + liveStreamId, + enableMpegtsM2TsMode, + videoCodec, + subtitleCodec, + transcodeReasons, + audioStreamIndex, + videoStreamIndex, + context, + streamOptions); + } } } diff --git a/Jellyfin.Api/Controllers/YearsController.cs b/Jellyfin.Api/Controllers/YearsController.cs index 4ecf0407bf..d6dc6650cf 100644 --- a/Jellyfin.Api/Controllers/YearsController.cs +++ b/Jellyfin.Api/Controllers/YearsController.cs @@ -1,15 +1,18 @@ -using System; +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.Entities; +using Jellyfin.Data.Enums; using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; using MediaBrowser.Model.Dto; +using MediaBrowser.Model.Entities; using MediaBrowser.Model.Querying; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; @@ -50,7 +53,7 @@ namespace Jellyfin.Api.Controllers /// <param name="limit">Optional. The maximum number of records to return.</param> /// <param name="sortOrder">Sort Order - Ascending,Descending.</param> /// <param name="parentId">Specify this to localize the search to a specific item or folder. Omit to use the root.</param> - /// <param name="fields">Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimited. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines.</param> + /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param> /// <param name="excludeItemTypes">Optional. If specified, results will be excluded based on item type. This allows multiple, comma delimited.</param> /// <param name="includeItemTypes">Optional. If specified, results will be included based on item type. This allows multiple, comma delimited.</param> /// <param name="mediaTypes">Optional. Filter by MediaType. Allows multiple, comma delimited.</param> @@ -68,53 +71,43 @@ namespace Jellyfin.Api.Controllers public ActionResult<QueryResult<BaseItemDto>> GetYears( [FromQuery] int? startIndex, [FromQuery] int? limit, - [FromQuery] string? sortOrder, - [FromQuery] string? parentId, - [FromQuery] string? fields, - [FromQuery] string? excludeItemTypes, - [FromQuery] string? includeItemTypes, - [FromQuery] string? mediaTypes, - [FromQuery] string? sortBy, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] SortOrder[] sortOrder, + [FromQuery] Guid? parentId, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] excludeItemTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] includeItemTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] mediaTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] sortBy, [FromQuery] bool? enableUserData, [FromQuery] int? imageTypeLimit, - [FromQuery] string? enableImageTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes, [FromQuery] Guid? userId, [FromQuery] bool recursive = true, [FromQuery] bool? enableImages = true) { - var dtoOptions = new DtoOptions() - .AddItemFields(fields) + var dtoOptions = new DtoOptions { Fields = fields } .AddClientFields(Request) .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); User? user = null; - BaseItem parentItem; + BaseItem parentItem = _libraryManager.GetParentItem(parentId, userId); if (userId.HasValue && !userId.Equals(Guid.Empty)) { user = _userManager.GetUserById(userId.Value); - parentItem = string.IsNullOrEmpty(parentId) ? _libraryManager.GetUserRootFolder() : _libraryManager.GetItemById(parentId); - } - else - { - parentItem = string.IsNullOrEmpty(parentId) ? _libraryManager.RootFolder : _libraryManager.GetItemById(parentId); } IList<BaseItem> items; - var excludeItemTypesArr = RequestHelpers.Split(excludeItemTypes, ',', true); - var includeItemTypesArr = RequestHelpers.Split(includeItemTypes, ',', true); - var mediaTypesArr = RequestHelpers.Split(mediaTypes, ',', true); - var query = new InternalItemsQuery(user) { - ExcludeItemTypes = excludeItemTypesArr, - IncludeItemTypes = includeItemTypesArr, - MediaTypes = mediaTypesArr, + ExcludeItemTypes = RequestHelpers.GetItemTypeStrings(excludeItemTypes), + IncludeItemTypes = RequestHelpers.GetItemTypeStrings(includeItemTypes), + MediaTypes = mediaTypes, DtoOptions = dtoOptions }; - bool Filter(BaseItem i) => FilterItem(i, excludeItemTypesArr, includeItemTypesArr, mediaTypesArr); + bool Filter(BaseItem i) => FilterItem(i, excludeItemTypes, includeItemTypes, mediaTypes); if (parentItem.IsFolder) { @@ -200,16 +193,17 @@ namespace Jellyfin.Api.Controllers return _dtoService.GetBaseItemDto(item, dtoOptions); } - private bool FilterItem(BaseItem f, IReadOnlyCollection<string> excludeItemTypes, IReadOnlyCollection<string> includeItemTypes, IReadOnlyCollection<string> mediaTypes) + private bool FilterItem(BaseItem f, IReadOnlyCollection<BaseItemKind> excludeItemTypes, IReadOnlyCollection<BaseItemKind> includeItemTypes, IReadOnlyCollection<string> mediaTypes) { + var baseItemKind = f.GetBaseItemKind(); // Exclude item types - if (excludeItemTypes.Count > 0 && excludeItemTypes.Contains(f.GetType().Name, StringComparer.OrdinalIgnoreCase)) + if (excludeItemTypes.Count > 0 && excludeItemTypes.Contains(baseItemKind)) { return false; } // Include item types - if (includeItemTypes.Count > 0 && !includeItemTypes.Contains(f.GetType().Name, StringComparer.OrdinalIgnoreCase)) + if (includeItemTypes.Count > 0 && !includeItemTypes.Contains(baseItemKind)) { return false; } diff --git a/Jellyfin.Api/Extensions/DtoExtensions.cs b/Jellyfin.Api/Extensions/DtoExtensions.cs index e61e9c29d9..5e338b67da 100644 --- a/Jellyfin.Api/Extensions/DtoExtensions.cs +++ b/Jellyfin.Api/Extensions/DtoExtensions.cs @@ -1,6 +1,7 @@ using System; -using System.Linq; +using System.Collections.Generic; using Jellyfin.Api.Helpers; +using Jellyfin.Extensions; using MediaBrowser.Controller.Dto; using MediaBrowser.Model.Entities; using MediaBrowser.Model.Querying; @@ -13,42 +14,6 @@ namespace Jellyfin.Api.Extensions /// </summary> public static class DtoExtensions { - /// <summary> - /// Add Dto Item fields. - /// </summary> - /// <remarks> - /// Converted from IHasItemFields. - /// Legacy order: 1. - /// </remarks> - /// <param name="dtoOptions">DtoOptions object.</param> - /// <param name="fields">Comma delimited string of fields.</param> - /// <returns>Modified DtoOptions object.</returns> - internal static DtoOptions AddItemFields(this DtoOptions dtoOptions, string? fields) - { - if (string.IsNullOrEmpty(fields)) - { - dtoOptions.Fields = Array.Empty<ItemFields>(); - } - else - { - dtoOptions.Fields = fields.Split(',') - .Select(v => - { - if (Enum.TryParse(v, true, out ItemFields value)) - { - return (ItemFields?)value; - } - - return null; - }) - .Where(i => i.HasValue) - .Select(i => i!.Value) - .ToArray(); - } - - return dtoOptions; - } - /// <summary> /// Add additional fields depending on client. /// </summary> @@ -79,7 +44,7 @@ namespace Jellyfin.Api.Extensions client.IndexOf("media center", StringComparison.OrdinalIgnoreCase) != -1 || client.IndexOf("classic", StringComparison.OrdinalIgnoreCase) != -1) { - int oldLen = dtoOptions.Fields.Length; + int oldLen = dtoOptions.Fields.Count; var arr = new ItemFields[oldLen + 1]; dtoOptions.Fields.CopyTo(arr, 0); arr[oldLen] = ItemFields.RecursiveItemCount; @@ -97,7 +62,7 @@ namespace Jellyfin.Api.Extensions client.IndexOf("samsung", StringComparison.OrdinalIgnoreCase) != -1 || client.IndexOf("androidtv", StringComparison.OrdinalIgnoreCase) != -1) { - int oldLen = dtoOptions.Fields.Length; + int oldLen = dtoOptions.Fields.Count; var arr = new ItemFields[oldLen + 1]; dtoOptions.Fields.CopyTo(arr, 0); arr[oldLen] = ItemFields.ChildCount; @@ -126,7 +91,7 @@ namespace Jellyfin.Api.Extensions bool? enableImages, bool? enableUserData, int? imageTypeLimit, - string? enableImageTypes) + IReadOnlyList<ImageType> enableImageTypes) { dtoOptions.EnableImages = enableImages ?? true; @@ -140,23 +105,12 @@ namespace Jellyfin.Api.Extensions dtoOptions.EnableUserData = enableUserData.Value; } - if (!string.IsNullOrWhiteSpace(enableImageTypes)) + if (enableImageTypes.Count != 0) { - dtoOptions.ImageTypes = enableImageTypes.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries) - .Select(v => (ImageType)Enum.Parse(typeof(ImageType), v, true)) - .ToArray(); + dtoOptions.ImageTypes = enableImageTypes; } return dtoOptions; } - - /// <summary> - /// Check if DtoOptions contains field. - /// </summary> - /// <param name="dtoOptions">DtoOptions object.</param> - /// <param name="field">Field to check.</param> - /// <returns>Field existence.</returns> - internal static bool ContainsField(this DtoOptions dtoOptions, ItemFields field) - => dtoOptions.Fields != null && dtoOptions.Fields.Contains(field); } } diff --git a/Jellyfin.Api/Helpers/AudioHelper.cs b/Jellyfin.Api/Helpers/AudioHelper.cs index a3f2d88ce5..264131905f 100644 --- a/Jellyfin.Api/Helpers/AudioHelper.cs +++ b/Jellyfin.Api/Helpers/AudioHelper.cs @@ -3,6 +3,7 @@ using System.Threading; using System.Threading.Tasks; using Jellyfin.Api.Models.StreamingDtos; using MediaBrowser.Common.Configuration; +using MediaBrowser.Common.Extensions; using MediaBrowser.Common.Net; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Devices; @@ -31,13 +32,11 @@ namespace Jellyfin.Api.Helpers private readonly IMediaSourceManager _mediaSourceManager; private readonly IServerConfigurationManager _serverConfigurationManager; private readonly IMediaEncoder _mediaEncoder; - private readonly IFileSystem _fileSystem; - private readonly ISubtitleEncoder _subtitleEncoder; - private readonly IConfiguration _configuration; private readonly IDeviceManager _deviceManager; private readonly TranscodingJobHelper _transcodingJobHelper; private readonly IHttpClientFactory _httpClientFactory; private readonly IHttpContextAccessor _httpContextAccessor; + private readonly EncodingHelper _encodingHelper; /// <summary> /// Initializes a new instance of the <see cref="AudioHelper"/> class. @@ -49,13 +48,11 @@ namespace Jellyfin.Api.Helpers /// <param name="mediaSourceManager">Instance of the <see cref="IMediaSourceManager"/> interface.</param> /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param> /// <param name="mediaEncoder">Instance of the <see cref="IMediaEncoder"/> interface.</param> - /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param> - /// <param name="subtitleEncoder">Instance of the <see cref="ISubtitleEncoder"/> interface.</param> - /// <param name="configuration">Instance of the <see cref="IConfiguration"/> interface.</param> /// <param name="deviceManager">Instance of the <see cref="IDeviceManager"/> interface.</param> /// <param name="transcodingJobHelper">Instance of <see cref="TranscodingJobHelper"/>.</param> /// <param name="httpClientFactory">Instance of the <see cref="IHttpClientFactory"/> interface.</param> /// <param name="httpContextAccessor">Instance of the <see cref="IHttpContextAccessor"/> interface.</param> + /// <param name="encodingHelper">Instance of <see cref="EncodingHelper"/>.</param> public AudioHelper( IDlnaManager dlnaManager, IAuthorizationContext authContext, @@ -64,13 +61,11 @@ namespace Jellyfin.Api.Helpers IMediaSourceManager mediaSourceManager, IServerConfigurationManager serverConfigurationManager, IMediaEncoder mediaEncoder, - IFileSystem fileSystem, - ISubtitleEncoder subtitleEncoder, - IConfiguration configuration, IDeviceManager deviceManager, TranscodingJobHelper transcodingJobHelper, IHttpClientFactory httpClientFactory, - IHttpContextAccessor httpContextAccessor) + IHttpContextAccessor httpContextAccessor, + EncodingHelper encodingHelper) { _dlnaManager = dlnaManager; _authContext = authContext; @@ -79,13 +74,11 @@ namespace Jellyfin.Api.Helpers _mediaSourceManager = mediaSourceManager; _serverConfigurationManager = serverConfigurationManager; _mediaEncoder = mediaEncoder; - _fileSystem = fileSystem; - _subtitleEncoder = subtitleEncoder; - _configuration = configuration; _deviceManager = deviceManager; _transcodingJobHelper = transcodingJobHelper; _httpClientFactory = httpClientFactory; _httpContextAccessor = httpContextAccessor; + _encodingHelper = encodingHelper; } /// <summary> @@ -98,7 +91,14 @@ namespace Jellyfin.Api.Helpers TranscodingJobType transcodingJobType, StreamingRequestDto streamingRequest) { + if (_httpContextAccessor.HttpContext == null) + { + throw new ResourceNotFoundException(nameof(_httpContextAccessor.HttpContext)); + } + bool isHeadRequest = _httpContextAccessor.HttpContext.Request.Method == System.Net.WebRequestMethods.Http.Head; + + // CTS lifecycle is managed internally. var cancellationTokenSource = new CancellationTokenSource(); using var state = await StreamingHelpers.GetStreamingState( @@ -110,9 +110,7 @@ namespace Jellyfin.Api.Helpers _libraryManager, _serverConfigurationManager, _mediaEncoder, - _fileSystem, - _subtitleEncoder, - _configuration, + _encodingHelper, _dlnaManager, _deviceManager, _transcodingJobHelper, @@ -181,8 +179,7 @@ namespace Jellyfin.Api.Helpers // Need to start ffmpeg (because media can't be returned directly) var encodingOptions = _serverConfigurationManager.GetEncodingOptions(); - var encodingHelper = new EncodingHelper(_mediaEncoder, _fileSystem, _subtitleEncoder, _configuration); - var ffmpegCommandLineArguments = encodingHelper.GetProgressiveAudioFullCommandLine(state, encodingOptions, outputPath); + var ffmpegCommandLineArguments = _encodingHelper.GetProgressiveAudioFullCommandLine(state, encodingOptions, outputPath); return await FileStreamResponseHelpers.GetTranscodedFile( state, isHeadRequest, diff --git a/Jellyfin.Api/Helpers/ClaimHelpers.cs b/Jellyfin.Api/Helpers/ClaimHelpers.cs index df235ced25..29e6b4193e 100644 --- a/Jellyfin.Api/Helpers/ClaimHelpers.cs +++ b/Jellyfin.Api/Helpers/ClaimHelpers.cs @@ -63,6 +63,19 @@ namespace Jellyfin.Api.Helpers public static string? GetToken(in ClaimsPrincipal user) => GetClaimValue(user, InternalClaimTypes.Token); + /// <summary> + /// Gets a flag specifying whether the request is using an api key. + /// </summary> + /// <param name="user">Current claims principal.</param> + /// <returns>The flag specifying whether the request is using an api key.</returns> + public static bool GetIsApiKey(in ClaimsPrincipal user) + { + var claimValue = GetClaimValue(user, InternalClaimTypes.IsApiKey); + return !string.IsNullOrEmpty(claimValue) + && bool.TryParse(claimValue, out var parsedClaimValue) + && parsedClaimValue; + } + private static string? GetClaimValue(in ClaimsPrincipal user, string name) { return user?.Identities diff --git a/Jellyfin.Api/Helpers/ClassMigrationHelper.cs b/Jellyfin.Api/Helpers/ClassMigrationHelper.cs new file mode 100644 index 0000000000..a911a33241 --- /dev/null +++ b/Jellyfin.Api/Helpers/ClassMigrationHelper.cs @@ -0,0 +1,71 @@ +using System; +using System.Reflection; + +namespace Jellyfin.Api.Helpers +{ + /// <summary> + /// A static class for copying matching properties from one object to another. + /// TODO: remove at the point when a fixed migration path has been decided upon. + /// </summary> + public static class ClassMigrationHelper + { + /// <summary> + /// Extension for 'Object' that copies the properties to a destination object. + /// </summary> + /// <param name="source">The source.</param> + /// <param name="destination">The destination.</param> + public static void CopyProperties(this object source, object destination) + { + // If any this null throw an exception. + if (source == null || destination == null) + { + throw new Exception("Source or/and Destination Objects are null"); + } + + // Getting the Types of the objects. + Type typeDest = destination.GetType(); + Type typeSrc = source.GetType(); + + // Iterate the Properties of the source instance and populate them from their destination counterparts. + PropertyInfo[] srcProps = typeSrc.GetProperties(); + foreach (PropertyInfo srcProp in srcProps) + { + if (!srcProp.CanRead) + { + continue; + } + + var targetProperty = typeDest.GetProperty(srcProp.Name); + if (targetProperty == null) + { + continue; + } + + if (!targetProperty.CanWrite) + { + continue; + } + + var obj = targetProperty.GetSetMethod(true); + if (obj != null && obj.IsPrivate) + { + continue; + } + + var target = targetProperty.GetSetMethod(); + if (target != null && (target.Attributes & MethodAttributes.Static) != 0) + { + continue; + } + + if (!targetProperty.PropertyType.IsAssignableFrom(srcProp.PropertyType)) + { + continue; + } + + // Passed all tests, lets set the value. + targetProperty.SetValue(destination, srcProp.GetValue(source, null), null); + } + } + } +} diff --git a/Jellyfin.Api/Helpers/DynamicHlsHelper.cs b/Jellyfin.Api/Helpers/DynamicHlsHelper.cs index af0519ffa8..dc5d6715b5 100644 --- a/Jellyfin.Api/Helpers/DynamicHlsHelper.cs +++ b/Jellyfin.Api/Helpers/DynamicHlsHelper.cs @@ -40,14 +40,12 @@ namespace Jellyfin.Api.Helpers private readonly IMediaSourceManager _mediaSourceManager; private readonly IServerConfigurationManager _serverConfigurationManager; private readonly IMediaEncoder _mediaEncoder; - private readonly IFileSystem _fileSystem; - private readonly ISubtitleEncoder _subtitleEncoder; - private readonly IConfiguration _configuration; private readonly IDeviceManager _deviceManager; private readonly TranscodingJobHelper _transcodingJobHelper; private readonly INetworkManager _networkManager; private readonly ILogger<DynamicHlsHelper> _logger; private readonly IHttpContextAccessor _httpContextAccessor; + private readonly EncodingHelper _encodingHelper; /// <summary> /// Initializes a new instance of the <see cref="DynamicHlsHelper"/> class. @@ -59,14 +57,12 @@ namespace Jellyfin.Api.Helpers /// <param name="mediaSourceManager">Instance of the <see cref="IMediaSourceManager"/> interface.</param> /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param> /// <param name="mediaEncoder">Instance of the <see cref="IMediaEncoder"/> interface.</param> - /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param> - /// <param name="subtitleEncoder">Instance of the <see cref="ISubtitleEncoder"/> interface.</param> - /// <param name="configuration">Instance of the <see cref="IConfiguration"/> interface.</param> /// <param name="deviceManager">Instance of the <see cref="IDeviceManager"/> interface.</param> /// <param name="transcodingJobHelper">Instance of <see cref="TranscodingJobHelper"/>.</param> /// <param name="networkManager">Instance of the <see cref="INetworkManager"/> interface.</param> /// <param name="logger">Instance of the <see cref="ILogger{DynamicHlsHelper}"/> interface.</param> /// <param name="httpContextAccessor">Instance of the <see cref="IHttpContextAccessor"/> interface.</param> + /// <param name="encodingHelper">Instance of <see cref="EncodingHelper"/>.</param> public DynamicHlsHelper( ILibraryManager libraryManager, IUserManager userManager, @@ -75,14 +71,12 @@ namespace Jellyfin.Api.Helpers IMediaSourceManager mediaSourceManager, IServerConfigurationManager serverConfigurationManager, IMediaEncoder mediaEncoder, - IFileSystem fileSystem, - ISubtitleEncoder subtitleEncoder, - IConfiguration configuration, IDeviceManager deviceManager, TranscodingJobHelper transcodingJobHelper, INetworkManager networkManager, ILogger<DynamicHlsHelper> logger, - IHttpContextAccessor httpContextAccessor) + IHttpContextAccessor httpContextAccessor, + EncodingHelper encodingHelper) { _libraryManager = libraryManager; _userManager = userManager; @@ -91,14 +85,12 @@ namespace Jellyfin.Api.Helpers _mediaSourceManager = mediaSourceManager; _serverConfigurationManager = serverConfigurationManager; _mediaEncoder = mediaEncoder; - _fileSystem = fileSystem; - _subtitleEncoder = subtitleEncoder; - _configuration = configuration; _deviceManager = deviceManager; _transcodingJobHelper = transcodingJobHelper; _networkManager = networkManager; _logger = logger; _httpContextAccessor = httpContextAccessor; + _encodingHelper = encodingHelper; } /// <summary> @@ -113,7 +105,8 @@ namespace Jellyfin.Api.Helpers StreamingRequestDto streamingRequest, bool enableAdaptiveBitrateStreaming) { - var isHeadRequest = _httpContextAccessor.HttpContext.Request.Method == WebRequestMethods.Http.Head; + var isHeadRequest = _httpContextAccessor.HttpContext?.Request.Method == WebRequestMethods.Http.Head; + // CTS lifecycle is managed internally. var cancellationTokenSource = new CancellationTokenSource(); return await GetMasterPlaylistInternal( streamingRequest, @@ -130,6 +123,11 @@ namespace Jellyfin.Api.Helpers TranscodingJobType transcodingJobType, CancellationTokenSource cancellationTokenSource) { + if (_httpContextAccessor.HttpContext == null) + { + throw new ResourceNotFoundException(nameof(_httpContextAccessor.HttpContext)); + } + using var state = await StreamingHelpers.GetStreamingState( streamingRequest, _httpContextAccessor.HttpContext.Request, @@ -139,9 +137,7 @@ namespace Jellyfin.Api.Helpers _libraryManager, _serverConfigurationManager, _mediaEncoder, - _fileSystem, - _subtitleEncoder, - _configuration, + _encodingHelper, _dlnaManager, _deviceManager, _transcodingJobHelper, @@ -155,7 +151,7 @@ namespace Jellyfin.Api.Helpers return new FileContentResult(Array.Empty<byte>(), MimeTypes.GetMimeType("playlist.m3u8")); } - var totalBitrate = state.OutputAudioBitrate ?? 0 + state.OutputVideoBitrate ?? 0; + var totalBitrate = (state.OutputAudioBitrate ?? 0) + (state.OutputVideoBitrate ?? 0); var builder = new StringBuilder(); @@ -166,13 +162,15 @@ namespace Jellyfin.Api.Helpers var queryString = _httpContextAccessor.HttpContext.Request.QueryString.ToString(); // from universal audio service - if (queryString.IndexOf("SegmentContainer", StringComparison.OrdinalIgnoreCase) == -1 && !string.IsNullOrWhiteSpace(state.Request.SegmentContainer)) + if (!string.IsNullOrWhiteSpace(state.Request.SegmentContainer) + && !queryString.Contains("SegmentContainer", StringComparison.OrdinalIgnoreCase)) { queryString += "&SegmentContainer=" + state.Request.SegmentContainer; } // from universal audio service - if (!string.IsNullOrWhiteSpace(state.Request.TranscodeReasons) && queryString.IndexOf("TranscodeReasons=", StringComparison.OrdinalIgnoreCase) == -1) + if (!string.IsNullOrWhiteSpace(state.Request.TranscodeReasons) + && !queryString.Contains("TranscodeReasons=", StringComparison.OrdinalIgnoreCase)) { queryString += "&TranscodeReasons=" + state.Request.TranscodeReasons; } @@ -202,7 +200,60 @@ namespace Jellyfin.Api.Helpers AddSubtitles(state, subtitleStreams, builder, _httpContextAccessor.HttpContext.User); } - AppendPlaylist(builder, state, playlistUrl, totalBitrate, subtitleGroup); + var basicPlaylist = AppendPlaylist(builder, state, playlistUrl, totalBitrate, subtitleGroup); + + if (state.VideoStream != null && state.VideoRequest != null) + { + // Provide SDR HEVC entrance for backward compatibility. + if (EncodingHelper.IsCopyCodec(state.OutputVideoCodec) + && !string.IsNullOrEmpty(state.VideoStream.VideoRange) + && string.Equals(state.VideoStream.VideoRange, "HDR", StringComparison.OrdinalIgnoreCase) + && string.Equals(state.ActualOutputVideoCodec, "hevc", StringComparison.OrdinalIgnoreCase)) + { + var requestedVideoProfiles = state.GetRequestedProfiles("hevc"); + if (requestedVideoProfiles != null && requestedVideoProfiles.Length > 0) + { + // Force HEVC Main Profile and disable video stream copy. + state.OutputVideoCodec = "hevc"; + var sdrVideoUrl = ReplaceProfile(playlistUrl, "hevc", string.Join(',', requestedVideoProfiles), "main"); + sdrVideoUrl += "&AllowVideoStreamCopy=false"; + + var sdrOutputVideoBitrate = _encodingHelper.GetVideoBitrateParamValue(state.VideoRequest, state.VideoStream, state.OutputVideoCodec) ?? 0; + var sdrOutputAudioBitrate = _encodingHelper.GetAudioBitrateParam(state.VideoRequest, state.AudioStream) ?? 0; + var sdrTotalBitrate = sdrOutputAudioBitrate + sdrOutputVideoBitrate; + + AppendPlaylist(builder, state, sdrVideoUrl, sdrTotalBitrate, subtitleGroup); + + // Restore the video codec + state.OutputVideoCodec = "copy"; + } + } + + // Provide Level 5.0 entrance for backward compatibility. + // e.g. Apple A10 chips refuse the master playlist containing SDR HEVC Main Level 5.1 video, + // but in fact it is capable of playing videos up to Level 6.1. + if (EncodingHelper.IsCopyCodec(state.OutputVideoCodec) + && state.VideoStream.Level.HasValue + && state.VideoStream.Level > 150 + && !string.IsNullOrEmpty(state.VideoStream.VideoRange) + && string.Equals(state.VideoStream.VideoRange, "SDR", StringComparison.OrdinalIgnoreCase) + && string.Equals(state.ActualOutputVideoCodec, "hevc", StringComparison.OrdinalIgnoreCase)) + { + var playlistCodecsField = new StringBuilder(); + AppendPlaylistCodecsField(playlistCodecsField, state); + + // Force the video level to 5.0. + var originalLevel = state.VideoStream.Level; + state.VideoStream.Level = 150; + var newPlaylistCodecsField = new StringBuilder(); + AppendPlaylistCodecsField(newPlaylistCodecsField, state); + + // Restore the video level. + state.VideoStream.Level = originalLevel; + var newPlaylist = ReplacePlaylistCodecsField(basicPlaylist, playlistCodecsField, newPlaylistCodecsField); + builder.Append(newPlaylist); + } + } if (EnableAdaptiveBitrateStreaming(state, isLiveStream, enableAdaptiveBitrateStreaming, _httpContextAccessor.HttpContext.GetNormalizedRemoteIp())) { @@ -212,40 +263,77 @@ namespace Jellyfin.Api.Helpers var variation = GetBitrateVariation(totalBitrate); var newBitrate = totalBitrate - variation; - var variantUrl = ReplaceBitrate(playlistUrl, requestedVideoBitrate, requestedVideoBitrate - variation); + var variantUrl = ReplaceVideoBitrate(playlistUrl, requestedVideoBitrate, requestedVideoBitrate - variation); AppendPlaylist(builder, state, variantUrl, newBitrate, subtitleGroup); variation *= 2; newBitrate = totalBitrate - variation; - variantUrl = ReplaceBitrate(playlistUrl, requestedVideoBitrate, requestedVideoBitrate - variation); + variantUrl = ReplaceVideoBitrate(playlistUrl, requestedVideoBitrate, requestedVideoBitrate - variation); AppendPlaylist(builder, state, variantUrl, newBitrate, subtitleGroup); } return new FileContentResult(Encoding.UTF8.GetBytes(builder.ToString()), MimeTypes.GetMimeType("playlist.m3u8")); } - private void AppendPlaylist(StringBuilder builder, StreamState state, string url, int bitrate, string? subtitleGroup) + private StringBuilder AppendPlaylist(StringBuilder builder, StreamState state, string url, int bitrate, string? subtitleGroup) { - builder.Append("#EXT-X-STREAM-INF:BANDWIDTH=") + var playlistBuilder = new StringBuilder(); + playlistBuilder.Append("#EXT-X-STREAM-INF:BANDWIDTH=") .Append(bitrate.ToString(CultureInfo.InvariantCulture)) .Append(",AVERAGE-BANDWIDTH=") .Append(bitrate.ToString(CultureInfo.InvariantCulture)); - AppendPlaylistCodecsField(builder, state); + AppendPlaylistVideoRangeField(playlistBuilder, state); + + AppendPlaylistCodecsField(playlistBuilder, state); - AppendPlaylistResolutionField(builder, state); + AppendPlaylistResolutionField(playlistBuilder, state); - AppendPlaylistFramerateField(builder, state); + AppendPlaylistFramerateField(playlistBuilder, state); if (!string.IsNullOrWhiteSpace(subtitleGroup)) { - builder.Append(",SUBTITLES=\"") + playlistBuilder.Append(",SUBTITLES=\"") .Append(subtitleGroup) .Append('"'); } - builder.Append(Environment.NewLine); - builder.AppendLine(url); + playlistBuilder.Append(Environment.NewLine); + playlistBuilder.AppendLine(url); + builder.Append(playlistBuilder); + + return playlistBuilder; + } + + /// <summary> + /// Appends a VIDEO-RANGE field containing the range of the output video stream. + /// </summary> + /// <seealso cref="AppendPlaylist(StringBuilder, StreamState, string, int, string)"/> + /// <param name="builder">StringBuilder to append the field to.</param> + /// <param name="state">StreamState of the current stream.</param> + private void AppendPlaylistVideoRangeField(StringBuilder builder, StreamState state) + { + if (state.VideoStream != null && !string.IsNullOrEmpty(state.VideoStream.VideoRange)) + { + var videoRange = state.VideoStream.VideoRange; + if (EncodingHelper.IsCopyCodec(state.OutputVideoCodec)) + { + if (string.Equals(videoRange, "SDR", StringComparison.OrdinalIgnoreCase)) + { + builder.Append(",VIDEO-RANGE=SDR"); + } + + if (string.Equals(videoRange, "HDR", StringComparison.OrdinalIgnoreCase)) + { + builder.Append(",VIDEO-RANGE=PQ"); + } + } + else + { + // Currently we only encode to SDR. + builder.Append(",VIDEO-RANGE=SDR"); + } + } } /// <summary> @@ -331,11 +419,11 @@ namespace Jellyfin.Api.Helpers if (framerate.HasValue) { builder.Append(",FRAME-RATE=") - .Append(framerate.Value); + .Append(framerate.Value.ToString(CultureInfo.InvariantCulture)); } } - private bool EnableAdaptiveBitrateStreaming(StreamState state, bool isLiveStream, bool enableAdaptiveBitrateStreaming, string ipAddress) + private bool EnableAdaptiveBitrateStreaming(StreamState state, bool isLiveStream, bool enableAdaptiveBitrateStreaming, IPAddress ipAddress) { // Within the local network this will likely do more harm than good. if (_networkManager.IsInLocalNetwork(ipAddress)) @@ -414,15 +502,27 @@ namespace Jellyfin.Api.Helpers /// <returns>H.26X level of the output video stream.</returns> private int? GetOutputVideoCodecLevel(StreamState state) { - string? levelString; + string levelString = string.Empty; if (EncodingHelper.IsCopyCodec(state.OutputVideoCodec) + && state.VideoStream != null && state.VideoStream.Level.HasValue) { - levelString = state.VideoStream?.Level.ToString(); + levelString = state.VideoStream.Level.ToString() ?? string.Empty; } else { - levelString = state.GetRequestedLevel(state.ActualOutputVideoCodec); + if (string.Equals(state.ActualOutputVideoCodec, "h264", StringComparison.OrdinalIgnoreCase)) + { + levelString = state.GetRequestedLevel(state.ActualOutputVideoCodec) ?? "41"; + levelString = EncodingHelper.NormalizeTranscodingLevel(state, levelString); + } + + if (string.Equals(state.ActualOutputVideoCodec, "h265", StringComparison.OrdinalIgnoreCase) + || string.Equals(state.ActualOutputVideoCodec, "hevc", StringComparison.OrdinalIgnoreCase)) + { + levelString = state.GetRequestedLevel("h265") ?? state.GetRequestedLevel("hevc") ?? "120"; + levelString = EncodingHelper.NormalizeTranscodingLevel(state, levelString); + } } if (int.TryParse(levelString, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsedLevel)) @@ -433,6 +533,38 @@ namespace Jellyfin.Api.Helpers return null; } + /// <summary> + /// Get the H.26X profile of the output video stream. + /// </summary> + /// <param name="state">StreamState of the current stream.</param> + /// <param name="codec">Video codec.</param> + /// <returns>H.26X profile of the output video stream.</returns> + private string GetOutputVideoCodecProfile(StreamState state, string codec) + { + string profileString = string.Empty; + if (EncodingHelper.IsCopyCodec(state.OutputVideoCodec) + && !string.IsNullOrEmpty(state.VideoStream.Profile)) + { + profileString = state.VideoStream.Profile; + } + else if (!string.IsNullOrEmpty(codec)) + { + profileString = state.GetRequestedProfiles(codec).FirstOrDefault() ?? string.Empty; + if (string.Equals(state.ActualOutputVideoCodec, "h264", StringComparison.OrdinalIgnoreCase)) + { + profileString ??= "high"; + } + + if (string.Equals(state.ActualOutputVideoCodec, "h265", StringComparison.OrdinalIgnoreCase) + || string.Equals(state.ActualOutputVideoCodec, "hevc", StringComparison.OrdinalIgnoreCase)) + { + profileString ??= "main"; + } + } + + return profileString; + } + /// <summary> /// Gets a formatted string of the output audio codec, for use in the CODECS field. /// </summary> @@ -463,6 +595,16 @@ namespace Jellyfin.Api.Helpers return HlsCodecStringHelpers.GetEAC3String(); } + if (string.Equals(state.ActualOutputAudioCodec, "flac", StringComparison.OrdinalIgnoreCase)) + { + return HlsCodecStringHelpers.GetFLACString(); + } + + if (string.Equals(state.ActualOutputAudioCodec, "alac", StringComparison.OrdinalIgnoreCase)) + { + return HlsCodecStringHelpers.GetALACString(); + } + return string.Empty; } @@ -487,15 +629,14 @@ namespace Jellyfin.Api.Helpers if (string.Equals(codec, "h264", StringComparison.OrdinalIgnoreCase)) { - string profile = state.GetRequestedProfiles("h264").FirstOrDefault(); + string profile = GetOutputVideoCodecProfile(state, "h264"); return HlsCodecStringHelpers.GetH264String(profile, level); } if (string.Equals(codec, "h265", StringComparison.OrdinalIgnoreCase) || string.Equals(codec, "hevc", StringComparison.OrdinalIgnoreCase)) { - string profile = state.GetRequestedProfiles("h265").FirstOrDefault(); - + string profile = GetOutputVideoCodecProfile(state, "hevc"); return HlsCodecStringHelpers.GetH265String(profile, level); } @@ -539,12 +680,30 @@ namespace Jellyfin.Api.Helpers return variation; } - private string ReplaceBitrate(string url, int oldValue, int newValue) + private string ReplaceVideoBitrate(string url, int oldValue, int newValue) { return url.Replace( "videobitrate=" + oldValue.ToString(CultureInfo.InvariantCulture), "videobitrate=" + newValue.ToString(CultureInfo.InvariantCulture), StringComparison.OrdinalIgnoreCase); } + + private string ReplaceProfile(string url, string codec, string oldValue, string newValue) + { + string profileStr = codec + "-profile="; + return url.Replace( + profileStr + oldValue, + profileStr + newValue, + StringComparison.OrdinalIgnoreCase); + } + + private string ReplacePlaylistCodecsField(StringBuilder playlist, StringBuilder oldValue, StringBuilder newValue) + { + var oldPlaylist = playlist.ToString(); + return oldPlaylist.Replace( + oldValue.ToString(), + newValue.ToString(), + StringComparison.OrdinalIgnoreCase); + } } } diff --git a/Jellyfin.Api/Helpers/FileStreamResponseHelpers.cs b/Jellyfin.Api/Helpers/FileStreamResponseHelpers.cs index 6b516977e8..b0fd59e5e3 100644 --- a/Jellyfin.Api/Helpers/FileStreamResponseHelpers.cs +++ b/Jellyfin.Api/Helpers/FileStreamResponseHelpers.cs @@ -24,12 +24,14 @@ namespace Jellyfin.Api.Helpers /// <param name="isHeadRequest">Whether the current request is a HTTP HEAD request so only the headers get returned.</param> /// <param name="httpClient">The <see cref="HttpClient"/> making the remote request.</param> /// <param name="httpContext">The current http context.</param> + /// <param name="cancellationToken">A cancellation token that can be used to cancel the operation.</param> /// <returns>A <see cref="Task{ActionResult}"/> containing the API response.</returns> public static async Task<ActionResult> GetStaticRemoteStreamResult( StreamState state, bool isHeadRequest, HttpClient httpClient, - HttpContext httpContext) + HttpContext httpContext, + CancellationToken cancellationToken = default) { if (state.RemoteHttpHeaders.TryGetValue(HeaderNames.UserAgent, out var useragent)) { @@ -37,17 +39,18 @@ namespace Jellyfin.Api.Helpers } // Can't dispose the response as it's required up the call chain. - var response = await httpClient.GetAsync(state.MediaPath).ConfigureAwait(false); - var contentType = response.Content.Headers.ContentType.ToString(); + var response = await httpClient.GetAsync(new Uri(state.MediaPath), cancellationToken).ConfigureAwait(false); + var contentType = response.Content.Headers.ContentType?.ToString(); httpContext.Response.Headers[HeaderNames.AcceptRanges] = "none"; if (isHeadRequest) { - return new FileContentResult(Array.Empty<byte>(), contentType); + httpContext.Response.Headers[HeaderNames.ContentType] = contentType; + return new OkResult(); } - return new FileStreamResult(await response.Content.ReadAsStreamAsync().ConfigureAwait(false), contentType); + return new FileStreamResult(await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false), contentType); } /// <summary> @@ -66,10 +69,10 @@ namespace Jellyfin.Api.Helpers { httpContext.Response.ContentType = contentType; - // if the request is a head request, return a NoContent result with the same headers as it would with a GET request + // if the request is a head request, return an OkResult (200) with the same headers as it would with a GET request if (isHeadRequest) { - return new NoContentResult(); + return new OkResult(); } return new PhysicalFileResult(path, contentType) { EnableRangeProcessing = true }; @@ -105,7 +108,8 @@ namespace Jellyfin.Api.Helpers // Headers only if (isHeadRequest) { - return new FileContentResult(Array.Empty<byte>(), contentType); + httpContext.Response.Headers[HeaderNames.ContentType] = contentType; + return new OkResult(); } var transcodingLock = transcodingJobHelper.GetTranscodingLock(outputPath); @@ -123,9 +127,8 @@ namespace Jellyfin.Api.Helpers state.Dispose(); } - await new ProgressiveFileCopier(outputPath, job, transcodingJobHelper, CancellationToken.None) - .WriteToAsync(httpContext.Response.Body, CancellationToken.None).ConfigureAwait(false); - return new FileStreamResult(httpContext.Response.Body, contentType); + var stream = new ProgressiveFileStream(outputPath, job, transcodingJobHelper); + return new FileStreamResult(stream, contentType); } finally { diff --git a/Jellyfin.Api/Helpers/HlsCodecStringHelpers.cs b/Jellyfin.Api/Helpers/HlsCodecStringHelpers.cs index 95f1906ef0..a5369c441c 100644 --- a/Jellyfin.Api/Helpers/HlsCodecStringHelpers.cs +++ b/Jellyfin.Api/Helpers/HlsCodecStringHelpers.cs @@ -9,13 +9,38 @@ namespace Jellyfin.Api.Helpers /// </summary> public static class HlsCodecStringHelpers { + /// <summary> + /// Codec name for MP3. + /// </summary> + public const string MP3 = "mp4a.40.34"; + + /// <summary> + /// Codec name for AC-3. + /// </summary> + public const string AC3 = "mp4a.a5"; + + /// <summary> + /// Codec name for E-AC-3. + /// </summary> + public const string EAC3 = "mp4a.a6"; + + /// <summary> + /// Codec name for FLAC. + /// </summary> + public const string FLAC = "fLaC"; + + /// <summary> + /// Codec name for ALAC. + /// </summary> + public const string ALAC = "alac"; + /// <summary> /// Gets a MP3 codec string. /// </summary> /// <returns>MP3 codec string.</returns> public static string GetMP3String() { - return "mp4a.40.34"; + return MP3; } /// <summary> @@ -23,7 +48,7 @@ namespace Jellyfin.Api.Helpers /// </summary> /// <param name="profile">AAC profile.</param> /// <returns>AAC codec string.</returns> - public static string GetAACString(string profile) + public static string GetAACString(string? profile) { StringBuilder result = new StringBuilder("mp4a", 9); @@ -40,13 +65,49 @@ namespace Jellyfin.Api.Helpers return result.ToString(); } + /// <summary> + /// Gets an AC-3 codec string. + /// </summary> + /// <returns>AC-3 codec string.</returns> + public static string GetAC3String() + { + return AC3; + } + + /// <summary> + /// Gets an E-AC-3 codec string. + /// </summary> + /// <returns>E-AC-3 codec string.</returns> + public static string GetEAC3String() + { + return EAC3; + } + + /// <summary> + /// Gets an FLAC codec string. + /// </summary> + /// <returns>FLAC codec string.</returns> + public static string GetFLACString() + { + return FLAC; + } + + /// <summary> + /// Gets an ALAC codec string. + /// </summary> + /// <returns>ALAC codec string.</returns> + public static string GetALACString() + { + return ALAC; + } + /// <summary> /// Gets a H.264 codec string. /// </summary> /// <param name="profile">H.264 profile.</param> /// <param name="level">H.264 level.</param> /// <returns>H.264 string.</returns> - public static string GetH264String(string profile, int level) + public static string GetH264String(string? profile, int level) { StringBuilder result = new StringBuilder("avc1", 11); @@ -80,46 +141,29 @@ namespace Jellyfin.Api.Helpers /// <param name="profile">H.265 profile.</param> /// <param name="level">H.265 level.</param> /// <returns>H.265 string.</returns> - public static string GetH265String(string profile, int level) + public static string GetH265String(string? profile, int level) { // The h265 syntax is a bit of a mystery at the time this comment was written. // This is what I've found through various sources: // FORMAT: [codecTag].[profile].[constraint?].L[level * 30].[UNKNOWN] - StringBuilder result = new StringBuilder("hev1", 16); + StringBuilder result = new StringBuilder("hvc1", 16); - if (string.Equals(profile, "main10", StringComparison.OrdinalIgnoreCase)) + if (string.Equals(profile, "main10", StringComparison.OrdinalIgnoreCase) + || string.Equals(profile, "main 10", StringComparison.OrdinalIgnoreCase)) { - result.Append(".2.6"); + result.Append(".2.4"); } else { // Default to main if profile is invalid - result.Append(".1.6"); + result.Append(".1.4"); } result.Append(".L") - .Append(level * 3) + .Append(level) .Append(".B0"); return result.ToString(); } - - /// <summary> - /// Gets an AC-3 codec string. - /// </summary> - /// <returns>AC-3 codec string.</returns> - public static string GetAC3String() - { - return "mp4a.a5"; - } - - /// <summary> - /// Gets an E-AC-3 codec string. - /// </summary> - /// <returns>E-AC-3 codec string.</returns> - public static string GetEAC3String() - { - return "mp4a.a6"; - } } } diff --git a/Jellyfin.Api/Helpers/HlsHelpers.cs b/Jellyfin.Api/Helpers/HlsHelpers.cs index 2424966973..d1cdaf867e 100644 --- a/Jellyfin.Api/Helpers/HlsHelpers.cs +++ b/Jellyfin.Api/Helpers/HlsHelpers.cs @@ -1,8 +1,11 @@ using System; using System.Globalization; using System.IO; +using System.Runtime.InteropServices; using System.Threading; using System.Threading.Tasks; +using Jellyfin.Api.Models.StreamingDtos; +using MediaBrowser.Controller.MediaEncoding; using MediaBrowser.Model.IO; using Microsoft.Extensions.Logging; @@ -45,6 +48,11 @@ namespace Jellyfin.Api.Helpers while (!reader.EndOfStream) { var line = await reader.ReadLineAsync().ConfigureAwait(false); + if (line == null) + { + // Nothing currently in buffer. + break; + } if (line.IndexOf("#EXTINF:", StringComparison.OrdinalIgnoreCase) != -1) { @@ -69,25 +77,61 @@ namespace Jellyfin.Api.Helpers } } + /// <summary> + /// Gets the #EXT-X-MAP string. + /// </summary> + /// <param name="outputPath">The output path of the file.</param> + /// <param name="state">The <see cref="StreamState"/>.</param> + /// <param name="isOsDepends">Get a normal string or depends on OS.</param> + /// <returns>The string text of #EXT-X-MAP.</returns> + public static string GetFmp4InitFileName(string outputPath, StreamState state, bool isOsDepends) + { + var directory = Path.GetDirectoryName(outputPath) ?? throw new ArgumentException($"Provided path ({outputPath}) is not valid.", nameof(outputPath)); + var outputFileNameWithoutExtension = Path.GetFileNameWithoutExtension(outputPath); + var outputPrefix = Path.Combine(directory, outputFileNameWithoutExtension); + var outputExtension = EncodingHelper.GetSegmentFileExtension(state.Request.SegmentContainer); + + // on Linux/Unix + // #EXT-X-MAP:URI="prefix-1.mp4" + var fmp4InitFileName = outputFileNameWithoutExtension + "-1" + outputExtension; + if (!isOsDepends) + { + return fmp4InitFileName; + } + + if (OperatingSystem.IsWindows()) + { + // on Windows + // #EXT-X-MAP:URI="X:\transcodes\prefix-1.mp4" + fmp4InitFileName = outputPrefix + "-1" + outputExtension; + } + + return fmp4InitFileName; + } + /// <summary> /// Gets the hls playlist text. /// </summary> /// <param name="path">The path to the playlist file.</param> - /// <param name="segmentLength">The segment length.</param> + /// <param name="state">The <see cref="StreamState"/>.</param> /// <returns>The playlist text as a string.</returns> - public static string GetLivePlaylistText(string path, int segmentLength) + public static string GetLivePlaylistText(string path, StreamState state) { - using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); - using var reader = new StreamReader(stream); + var text = File.ReadAllText(path); - var text = reader.ReadToEnd(); - - text = text.Replace("#EXTM3U", "#EXTM3U\n#EXT-X-PLAYLIST-TYPE:EVENT", StringComparison.InvariantCulture); - - var newDuration = "#EXT-X-TARGETDURATION:" + segmentLength.ToString(CultureInfo.InvariantCulture); + var segmentFormat = EncodingHelper.GetSegmentFileExtension(state.Request.SegmentContainer).TrimStart('.'); + if (string.Equals(segmentFormat, "mp4", StringComparison.OrdinalIgnoreCase)) + { + var fmp4InitFileName = GetFmp4InitFileName(path, state, true); + var baseUrlParam = string.Format( + CultureInfo.InvariantCulture, + "hls/{0}/", + Path.GetFileNameWithoutExtension(path)); + var newFmp4InitFileName = baseUrlParam + GetFmp4InitFileName(path, state, false); - text = text.Replace("#EXT-X-TARGETDURATION:" + (segmentLength - 1).ToString(CultureInfo.InvariantCulture), newDuration, StringComparison.OrdinalIgnoreCase); - // text = text.Replace("#EXT-X-TARGETDURATION:" + (segmentLength + 1).ToString(CultureInfo.InvariantCulture), newDuration, StringComparison.OrdinalIgnoreCase); + // Replace fMP4 init file URI. + text = text.Replace(fmp4InitFileName, newFmp4InitFileName, StringComparison.InvariantCulture); + } return text; } diff --git a/Jellyfin.Api/Helpers/MediaInfoHelper.cs b/Jellyfin.Api/Helpers/MediaInfoHelper.cs index e78f63b256..295cfaf089 100644 --- a/Jellyfin.Api/Helpers/MediaInfoHelper.cs +++ b/Jellyfin.Api/Helpers/MediaInfoHelper.cs @@ -1,6 +1,7 @@ using System; using System.Globalization; using System.Linq; +using System.Net; using System.Text.Json; using System.Threading; using System.Threading.Tasks; @@ -166,7 +167,7 @@ namespace Jellyfin.Api.Helpers MediaSourceInfo mediaSource, DeviceProfile profile, AuthorizationInfo auth, - long? maxBitrate, + int? maxBitrate, long startTimeTicks, string mediaSourceId, int? audioStreamIndex, @@ -179,7 +180,7 @@ namespace Jellyfin.Api.Helpers bool enableTranscoding, bool allowVideoStreamCopy, bool allowAudioStreamCopy, - string ipAddress) + IPAddress ipAddress) { var streamBuilder = new StreamBuilder(_mediaEncoder, _logger); @@ -282,6 +283,7 @@ namespace Jellyfin.Api.Helpers if (streamInfo != null) { SetDeviceSpecificSubtitleInfo(streamInfo, mediaSource, auth.Token); + mediaSource.DefaultAudioStreamIndex = streamInfo.AudioStreamIndex; } } } @@ -307,7 +309,7 @@ namespace Jellyfin.Api.Helpers { if (!user.HasPermission(PermissionKind.EnableAudioPlaybackTranscoding) && !user.HasPermission(PermissionKind.EnableVideoPlaybackTranscoding) - && !user.HasPermission(PermissionKind.EnablePlaybackRemuxing)) + && user.HasPermission(PermissionKind.EnablePlaybackRemuxing)) { options.ForceDirectStream = true; } @@ -326,6 +328,7 @@ namespace Jellyfin.Api.Helpers if (streamInfo != null) { SetDeviceSpecificSubtitleInfo(streamInfo, mediaSource, auth.Token); + mediaSource.DefaultAudioStreamIndex = streamInfo.AudioStreamIndex; } } } @@ -353,6 +356,7 @@ namespace Jellyfin.Api.Helpers // Do this after the above so that StartPositionTicks is set SetDeviceSpecificSubtitleInfo(streamInfo, mediaSource, auth.Token); + mediaSource.DefaultAudioStreamIndex = streamInfo.AudioStreamIndex; } } else @@ -390,6 +394,7 @@ namespace Jellyfin.Api.Helpers // Do this after the above so that StartPositionTicks is set SetDeviceSpecificSubtitleInfo(streamInfo, mediaSource, auth.Token); + mediaSource.DefaultAudioStreamIndex = streamInfo.AudioStreamIndex; } } } @@ -523,7 +528,7 @@ namespace Jellyfin.Api.Helpers /// <param name="type">Dlna profile type.</param> public void NormalizeMediaSourceContainer(MediaSourceInfo mediaSource, DeviceProfile profile, DlnaProfileType type) { - mediaSource.Container = StreamBuilder.NormalizeMediaSourceFormatIntoSingleContainer(mediaSource.Container, mediaSource.Path, profile, type); + mediaSource.Container = StreamBuilder.NormalizeMediaSourceFormatIntoSingleContainer(mediaSource.Container, profile, type); } private void SetDeviceSpecificSubtitleInfo(StreamInfo info, MediaSourceInfo mediaSource, string accessToken) @@ -551,7 +556,7 @@ namespace Jellyfin.Api.Helpers } } - private long? GetMaxBitrate(long? clientMaxBitrate, User user, string ipAddress) + private int? GetMaxBitrate(int? clientMaxBitrate, User user, IPAddress ipAddress) { var maxBitrate = clientMaxBitrate; var remoteClientMaxBitrate = user.RemoteClientBitrateLimit ?? 0; diff --git a/Jellyfin.Api/Helpers/ProgressiveFileCopier.cs b/Jellyfin.Api/Helpers/ProgressiveFileCopier.cs index e00ed33042..963e177245 100644 --- a/Jellyfin.Api/Helpers/ProgressiveFileCopier.cs +++ b/Jellyfin.Api/Helpers/ProgressiveFileCopier.cs @@ -5,6 +5,7 @@ using System.Runtime.InteropServices; using System.Threading; using System.Threading.Tasks; using Jellyfin.Api.Models.PlaybackDtos; +using MediaBrowser.Common.Extensions; using MediaBrowser.Controller.Library; using MediaBrowser.Model.IO; @@ -70,7 +71,8 @@ namespace Jellyfin.Api.Helpers /// <returns>A <see cref="Task"/>.</returns> public async Task WriteToAsync(Stream outputStream, CancellationToken cancellationToken) { - cancellationToken = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, _cancellationToken).Token; + using var linkedCancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, _cancellationToken); + cancellationToken = linkedCancellationTokenSource.Token; try { @@ -90,6 +92,11 @@ namespace Jellyfin.Api.Helpers allowAsyncFileRead = true; } + if (_path == null) + { + throw new ResourceNotFoundException(nameof(_path)); + } + await using var inputStream = new FileStream(_path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite, IODefaults.FileStreamBufferSize, fileOptions); var eofCount = 0; diff --git a/Jellyfin.Api/Helpers/ProgressiveFileStream.cs b/Jellyfin.Api/Helpers/ProgressiveFileStream.cs new file mode 100644 index 0000000000..824870c7ef --- /dev/null +++ b/Jellyfin.Api/Helpers/ProgressiveFileStream.cs @@ -0,0 +1,166 @@ +using System; +using System.IO; +using System.Runtime.InteropServices; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Api.Models.PlaybackDtos; +using MediaBrowser.Model.IO; + +namespace Jellyfin.Api.Helpers +{ + /// <summary> + /// A progressive file stream for transferring transcoded files as they are written to. + /// </summary> + public class ProgressiveFileStream : Stream + { + private readonly FileStream _fileStream; + private readonly TranscodingJobDto? _job; + private readonly TranscodingJobHelper _transcodingJobHelper; + private readonly bool _allowAsyncFileRead; + private int _bytesWritten; + private bool _disposed; + + /// <summary> + /// Initializes a new instance of the <see cref="ProgressiveFileStream"/> class. + /// </summary> + /// <param name="filePath">The path to the transcoded file.</param> + /// <param name="job">The transcoding job information.</param> + /// <param name="transcodingJobHelper">The transcoding job helper.</param> + public ProgressiveFileStream(string filePath, TranscodingJobDto? job, TranscodingJobHelper transcodingJobHelper) + { + _job = job; + _transcodingJobHelper = transcodingJobHelper; + _bytesWritten = 0; + + var fileOptions = FileOptions.SequentialScan; + _allowAsyncFileRead = false; + + // use non-async filestream along with read due to https://github.com/dotnet/corefx/issues/6039 + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + fileOptions |= FileOptions.Asynchronous; + _allowAsyncFileRead = true; + } + + _fileStream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite, IODefaults.FileStreamBufferSize, fileOptions); + } + + /// <inheritdoc /> + public override bool CanRead => _fileStream.CanRead; + + /// <inheritdoc /> + public override bool CanSeek => false; + + /// <inheritdoc /> + public override bool CanWrite => false; + + /// <inheritdoc /> + public override long Length => throw new NotSupportedException(); + + /// <inheritdoc /> + public override long Position + { + get => throw new NotSupportedException(); + set => throw new NotSupportedException(); + } + + /// <inheritdoc /> + public override void Flush() + { + _fileStream.Flush(); + } + + /// <inheritdoc /> + public override int Read(byte[] buffer, int offset, int count) + { + return _fileStream.Read(buffer, offset, count); + } + + /// <inheritdoc /> + public override async Task<int> ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + { + int totalBytesRead = 0; + int remainingBytesToRead = count; + + int newOffset = offset; + while (remainingBytesToRead > 0) + { + cancellationToken.ThrowIfCancellationRequested(); + int bytesRead; + if (_allowAsyncFileRead) + { + bytesRead = await _fileStream.ReadAsync(buffer, newOffset, remainingBytesToRead, cancellationToken).ConfigureAwait(false); + } + else + { + bytesRead = _fileStream.Read(buffer, newOffset, remainingBytesToRead); + } + + remainingBytesToRead -= bytesRead; + newOffset += bytesRead; + + if (bytesRead > 0) + { + _bytesWritten += bytesRead; + totalBytesRead += bytesRead; + + if (_job != null) + { + _job.BytesDownloaded = Math.Max(_job.BytesDownloaded ?? _bytesWritten, _bytesWritten); + } + } + else + { + // If the job is null it's a live stream and will require user action to close + if (_job?.HasExited ?? false) + { + break; + } + + await Task.Delay(50, cancellationToken).ConfigureAwait(false); + } + } + + return totalBytesRead; + } + + /// <inheritdoc /> + public override long Seek(long offset, SeekOrigin origin) + => throw new NotSupportedException(); + + /// <inheritdoc /> + public override void SetLength(long value) + => throw new NotSupportedException(); + + /// <inheritdoc /> + public override void Write(byte[] buffer, int offset, int count) + => throw new NotSupportedException(); + + /// <inheritdoc /> + protected override void Dispose(bool disposing) + { + if (_disposed) + { + return; + } + + try + { + if (disposing) + { + _fileStream.Dispose(); + + if (_job != null) + { + _transcodingJobHelper.OnTranscodeEndRequest(_job); + } + } + } + finally + { + _disposed = true; + base.Dispose(disposing); + } + } + } +} diff --git a/Jellyfin.Api/Helpers/RequestHelpers.cs b/Jellyfin.Api/Helpers/RequestHelpers.cs index fb5c283f59..56585aeab5 100644 --- a/Jellyfin.Api/Helpers/RequestHelpers.cs +++ b/Jellyfin.Api/Helpers/RequestHelpers.cs @@ -1,11 +1,14 @@ using System; using System.Collections.Generic; using System.Linq; -using System.Net; +using Jellyfin.Data.Entities; using Jellyfin.Data.Enums; using MediaBrowser.Common.Extensions; +using MediaBrowser.Controller.Dto; +using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Net; using MediaBrowser.Controller.Session; +using MediaBrowser.Model.Dto; using MediaBrowser.Model.Querying; using Microsoft.AspNetCore.Http; @@ -22,59 +25,32 @@ namespace Jellyfin.Api.Helpers /// <param name="sortBy">Sort By. Comma delimited string.</param> /// <param name="requestedSortOrder">Sort Order. Comma delimited string.</param> /// <returns>Order By.</returns> - public static ValueTuple<string, SortOrder>[] GetOrderBy(string? sortBy, string? requestedSortOrder) + public static (string, SortOrder)[] GetOrderBy(IReadOnlyList<string> sortBy, IReadOnlyList<SortOrder> requestedSortOrder) { - var val = sortBy; - - if (string.IsNullOrEmpty(val)) + if (sortBy.Count == 0) { return Array.Empty<ValueTuple<string, SortOrder>>(); } - var vals = val.Split(','); - if (string.IsNullOrWhiteSpace(requestedSortOrder)) + var result = new (string, SortOrder)[sortBy.Count]; + var i = 0; + // Add elements which have a SortOrder specified + for (; i < requestedSortOrder.Count; i++) { - requestedSortOrder = "Ascending"; + result[i] = (sortBy[i], requestedSortOrder[i]); } - var sortOrders = requestedSortOrder.Split(','); - - var result = new ValueTuple<string, SortOrder>[vals.Length]; - - for (var i = 0; i < vals.Length; i++) + // Add remaining elements with the first specified SortOrder + // or the default one if no SortOrders are specified + var order = requestedSortOrder.Count > 0 ? requestedSortOrder[0] : SortOrder.Ascending; + for (; i < sortBy.Count; i++) { - var sortOrderIndex = sortOrders.Length > i ? i : 0; - - var sortOrderValue = sortOrders.Length > sortOrderIndex ? sortOrders[sortOrderIndex] : null; - var sortOrder = string.Equals(sortOrderValue, "Descending", StringComparison.OrdinalIgnoreCase) - ? SortOrder.Descending - : SortOrder.Ascending; - - result[i] = new ValueTuple<string, SortOrder>(vals[i], sortOrder); + result[i] = (sortBy[i], order); } return result; } - /// <summary> - /// Splits a string at a separating character into an array of substrings. - /// </summary> - /// <param name="value">The string to split.</param> - /// <param name="separator">The char that separates the substrings.</param> - /// <param name="removeEmpty">Option to remove empty substrings from the array.</param> - /// <returns>An array of the substrings.</returns> - internal static string[] Split(string? value, char separator, bool removeEmpty) - { - if (string.IsNullOrWhiteSpace(value)) - { - return Array.Empty<string>(); - } - - return removeEmpty - ? value.Split(new[] { separator }, StringSplitOptions.RemoveEmptyEntries) - : value.Split(separator); - } - /// <summary> /// Checks if the user can update an entry. /// </summary> @@ -108,7 +84,7 @@ namespace Jellyfin.Api.Helpers authorization.Version, authorization.DeviceId, authorization.Device, - request.HttpContext.GetNormalizedRemoteIp(), + request.HttpContext.GetNormalizedRemoteIp().ToString(), user); if (session == null) @@ -119,47 +95,55 @@ namespace Jellyfin.Api.Helpers return session; } - /// <summary> - /// Get Guid array from string. - /// </summary> - /// <param name="value">String value.</param> - /// <returns>Guid array.</returns> - internal static Guid[] GetGuids(string? value) + internal static QueryResult<BaseItemDto> CreateQueryResult( + QueryResult<(BaseItem, ItemCounts)> result, + DtoOptions dtoOptions, + IDtoService dtoService, + bool includeItemTypes, + User? user) { - if (value == null) + var dtos = result.Items.Select(i => { - return Array.Empty<Guid>(); - } + var (baseItem, counts) = i; + var dto = dtoService.GetItemByNameDto(baseItem, dtoOptions, null, user); - return Split(value, ',', true) - .Select(i => new Guid(i)) - .ToArray(); + if (includeItemTypes) + { + dto.ChildCount = counts.ItemCount; + dto.ProgramCount = counts.ProgramCount; + dto.SeriesCount = counts.SeriesCount; + dto.EpisodeCount = counts.EpisodeCount; + dto.MovieCount = counts.MovieCount; + dto.TrailerCount = counts.TrailerCount; + dto.AlbumCount = counts.AlbumCount; + dto.SongCount = counts.SongCount; + dto.ArtistCount = counts.ArtistCount; + } + + return dto; + }); + + return new QueryResult<BaseItemDto> + { + Items = dtos.ToArray(), + TotalRecordCount = result.TotalRecordCount + }; } - /// <summary> - /// Gets the item fields. - /// </summary> - /// <param name="fields">The fields string.</param> - /// <returns>IEnumerable{ItemFields}.</returns> - internal static ItemFields[] GetItemFields(string? fields) + internal static string[] GetItemTypeStrings(IReadOnlyList<BaseItemKind> itemKinds) { - if (string.IsNullOrEmpty(fields)) + if (itemKinds.Count == 0) { - return Array.Empty<ItemFields>(); + return Array.Empty<string>(); } - return Split(fields, ',', true) - .Select(v => - { - if (Enum.TryParse(v, true, out ItemFields value)) - { - return (ItemFields?)value; - } - - return null; - }).Where(i => i.HasValue) - .Select(i => i!.Value) - .ToArray(); + var itemTypes = new string[itemKinds.Count]; + for (var i = 0; i < itemKinds.Count; i++) + { + itemTypes[i] = itemKinds[i].ToString(); + } + + return itemTypes; } } } diff --git a/Jellyfin.Api/Helpers/SimilarItemsHelper.cs b/Jellyfin.Api/Helpers/SimilarItemsHelper.cs deleted file mode 100644 index b922e76cfd..0000000000 --- a/Jellyfin.Api/Helpers/SimilarItemsHelper.cs +++ /dev/null @@ -1,182 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using MediaBrowser.Controller.Dto; -using MediaBrowser.Controller.Entities; -using MediaBrowser.Controller.Library; -using MediaBrowser.Model.Dto; -using MediaBrowser.Model.Entities; -using MediaBrowser.Model.Querying; - -namespace Jellyfin.Api.Helpers -{ - /// <summary> - /// The similar items helper class. - /// </summary> - public static class SimilarItemsHelper - { - internal static QueryResult<BaseItemDto> GetSimilarItemsResult( - DtoOptions dtoOptions, - IUserManager userManager, - ILibraryManager libraryManager, - IDtoService dtoService, - Guid? userId, - string id, - string? excludeArtistIds, - int? limit, - Type[] includeTypes, - Func<BaseItem, List<PersonInfo>, List<PersonInfo>, BaseItem, int> getSimilarityScore) - { - var user = userId.HasValue && !userId.Equals(Guid.Empty) - ? userManager.GetUserById(userId.Value) - : null; - - var item = string.IsNullOrEmpty(id) ? - (!userId.Equals(Guid.Empty) ? libraryManager.GetUserRootFolder() : - libraryManager.RootFolder) : libraryManager.GetItemById(id); - - var query = new InternalItemsQuery(user) - { - IncludeItemTypes = includeTypes.Select(i => i.Name).ToArray(), - Recursive = true, - DtoOptions = dtoOptions, - ExcludeArtistIds = RequestHelpers.GetGuids(excludeArtistIds) - }; - - var inputItems = libraryManager.GetItemList(query); - - var items = GetSimilaritems(item, libraryManager, inputItems, getSimilarityScore) - .ToList(); - - var returnItems = items; - - if (limit.HasValue) - { - returnItems = returnItems.Take(limit.Value).ToList(); - } - - var dtos = dtoService.GetBaseItemDtos(returnItems, dtoOptions, user); - - return new QueryResult<BaseItemDto> - { - Items = dtos, - TotalRecordCount = items.Count - }; - } - - /// <summary> - /// Gets the similaritems. - /// </summary> - /// <param name="item">The item.</param> - /// <param name="libraryManager">The library manager.</param> - /// <param name="inputItems">The input items.</param> - /// <param name="getSimilarityScore">The get similarity score.</param> - /// <returns>IEnumerable{BaseItem}.</returns> - private static IEnumerable<BaseItem> GetSimilaritems( - BaseItem item, - ILibraryManager libraryManager, - IEnumerable<BaseItem> inputItems, - Func<BaseItem, List<PersonInfo>, List<PersonInfo>, BaseItem, int> getSimilarityScore) - { - var itemId = item.Id; - inputItems = inputItems.Where(i => i.Id != itemId); - var itemPeople = libraryManager.GetPeople(item); - var allPeople = libraryManager.GetPeople(new InternalPeopleQuery - { - AppearsInItemId = item.Id - }); - - return inputItems.Select(i => new Tuple<BaseItem, int>(i, getSimilarityScore(item, itemPeople, allPeople, i))) - .Where(i => i.Item2 > 2) - .OrderByDescending(i => i.Item2) - .Select(i => i.Item1); - } - - private static IEnumerable<string> GetTags(BaseItem item) - { - return item.Tags; - } - - /// <summary> - /// Gets the similiarity score. - /// </summary> - /// <param name="item1">The item1.</param> - /// <param name="item1People">The item1 people.</param> - /// <param name="allPeople">All people.</param> - /// <param name="item2">The item2.</param> - /// <returns>System.Int32.</returns> - internal static int GetSimiliarityScore(BaseItem item1, List<PersonInfo> item1People, List<PersonInfo> allPeople, BaseItem item2) - { - var points = 0; - - if (!string.IsNullOrEmpty(item1.OfficialRating) && string.Equals(item1.OfficialRating, item2.OfficialRating, StringComparison.OrdinalIgnoreCase)) - { - points += 10; - } - - // Find common genres - points += item1.Genres.Where(i => item2.Genres.Contains(i, StringComparer.OrdinalIgnoreCase)).Sum(i => 10); - - // Find common tags - points += GetTags(item1).Where(i => GetTags(item2).Contains(i, StringComparer.OrdinalIgnoreCase)).Sum(i => 10); - - // Find common studios - points += item1.Studios.Where(i => item2.Studios.Contains(i, StringComparer.OrdinalIgnoreCase)).Sum(i => 3); - - var item2PeopleNames = allPeople.Where(i => i.ItemId == item2.Id) - .Select(i => i.Name) - .Where(i => !string.IsNullOrWhiteSpace(i)) - .DistinctNames() - .ToDictionary(i => i, StringComparer.OrdinalIgnoreCase); - - points += item1People.Where(i => item2PeopleNames.ContainsKey(i.Name)).Sum(i => - { - if (string.Equals(i.Type, PersonType.Director, StringComparison.OrdinalIgnoreCase) || string.Equals(i.Role, PersonType.Director, StringComparison.OrdinalIgnoreCase)) - { - return 5; - } - - if (string.Equals(i.Type, PersonType.Actor, StringComparison.OrdinalIgnoreCase) || string.Equals(i.Role, PersonType.Actor, StringComparison.OrdinalIgnoreCase)) - { - return 3; - } - - if (string.Equals(i.Type, PersonType.Composer, StringComparison.OrdinalIgnoreCase) || string.Equals(i.Role, PersonType.Composer, StringComparison.OrdinalIgnoreCase)) - { - return 3; - } - - if (string.Equals(i.Type, PersonType.GuestStar, StringComparison.OrdinalIgnoreCase) || string.Equals(i.Role, PersonType.GuestStar, StringComparison.OrdinalIgnoreCase)) - { - return 3; - } - - if (string.Equals(i.Type, PersonType.Writer, StringComparison.OrdinalIgnoreCase) || string.Equals(i.Role, PersonType.Writer, StringComparison.OrdinalIgnoreCase)) - { - return 2; - } - - return 1; - }); - - if (item1.ProductionYear.HasValue && item2.ProductionYear.HasValue) - { - var diff = Math.Abs(item1.ProductionYear.Value - item2.ProductionYear.Value); - - // Add if they came out within the same decade - if (diff < 10) - { - points += 2; - } - - // And more if within five years - if (diff < 5) - { - points += 2; - } - } - - return points; - } - } -} diff --git a/Jellyfin.Api/Helpers/StreamingHelpers.cs b/Jellyfin.Api/Helpers/StreamingHelpers.cs index f4ec29bdef..8cffe9c4c9 100644 --- a/Jellyfin.Api/Helpers/StreamingHelpers.cs +++ b/Jellyfin.Api/Helpers/StreamingHelpers.cs @@ -41,9 +41,7 @@ namespace Jellyfin.Api.Helpers /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param> /// <param name="mediaEncoder">Instance of the <see cref="IMediaEncoder"/> interface.</param> - /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param> - /// <param name="subtitleEncoder">Instance of the <see cref="ISubtitleEncoder"/> interface.</param> - /// <param name="configuration">Instance of the <see cref="IConfiguration"/> interface.</param> + /// <param name="encodingHelper">Instance of <see cref="EncodingHelper"/>.</param> /// <param name="dlnaManager">Instance of the <see cref="IDlnaManager"/> interface.</param> /// <param name="deviceManager">Instance of the <see cref="IDeviceManager"/> interface.</param> /// <param name="transcodingJobHelper">Initialized <see cref="TranscodingJobHelper"/>.</param> @@ -59,16 +57,13 @@ namespace Jellyfin.Api.Helpers ILibraryManager libraryManager, IServerConfigurationManager serverConfigurationManager, IMediaEncoder mediaEncoder, - IFileSystem fileSystem, - ISubtitleEncoder subtitleEncoder, - IConfiguration configuration, + EncodingHelper encodingHelper, IDlnaManager dlnaManager, IDeviceManager deviceManager, TranscodingJobHelper transcodingJobHelper, TranscodingJobType transcodingJobType, CancellationToken cancellationToken) { - EncodingHelper encodingHelper = new EncodingHelper(mediaEncoder, fileSystem, subtitleEncoder, configuration); // Parse the DLNA time seek header if (!streamingRequest.StartTimeTicks.HasValue) { @@ -83,8 +78,12 @@ namespace Jellyfin.Api.Helpers } streamingRequest.StreamOptions = ParseStreamOptions(httpRequest.Query); + if (httpRequest.Path.Value == null) + { + throw new ResourceNotFoundException(nameof(httpRequest.Path)); + } - var url = httpRequest.Path.Value.Split('.').Last(); + var url = httpRequest.Path.Value.Split('.')[^1]; if (string.IsNullOrEmpty(streamingRequest.AudioCodec)) { @@ -117,14 +116,14 @@ namespace Jellyfin.Api.Helpers if (!string.IsNullOrWhiteSpace(streamingRequest.AudioCodec)) { state.SupportedAudioCodecs = streamingRequest.AudioCodec.Split(',', StringSplitOptions.RemoveEmptyEntries); - state.Request.AudioCodec = state.SupportedAudioCodecs.FirstOrDefault(i => mediaEncoder.CanEncodeToAudioCodec(i)) + state.Request.AudioCodec = state.SupportedAudioCodecs.FirstOrDefault(mediaEncoder.CanEncodeToAudioCodec) ?? state.SupportedAudioCodecs.FirstOrDefault(); } if (!string.IsNullOrWhiteSpace(streamingRequest.SubtitleCodec)) { state.SupportedSubtitleCodecs = streamingRequest.SubtitleCodec.Split(',', StringSplitOptions.RemoveEmptyEntries); - state.Request.SubtitleCodec = state.SupportedSubtitleCodecs.FirstOrDefault(i => mediaEncoder.CanEncodeToSubtitleCodec(i)) + state.Request.SubtitleCodec = state.SupportedSubtitleCodecs.FirstOrDefault(mediaEncoder.CanEncodeToSubtitleCodec) ?? state.SupportedSubtitleCodecs.FirstOrDefault(); } @@ -165,7 +164,9 @@ namespace Jellyfin.Api.Helpers state.DirectStreamProvider = liveStreamInfo.Item2; } - encodingHelper.AttachMediaSourceInfo(state, mediaSource, url); + var encodingOptions = serverConfigurationManager.GetEncodingOptions(); + + encodingHelper.AttachMediaSourceInfo(state, encodingOptions, mediaSource, url); string? containerInternal = Path.GetExtension(state.RequestedUrl); @@ -177,13 +178,13 @@ namespace Jellyfin.Api.Helpers if (string.IsNullOrEmpty(containerInternal)) { containerInternal = streamingRequest.Static ? - StreamBuilder.NormalizeMediaSourceFormatIntoSingleContainer(state.InputContainer, state.MediaPath, null, DlnaProfileType.Audio) + StreamBuilder.NormalizeMediaSourceFormatIntoSingleContainer(state.InputContainer, null, DlnaProfileType.Audio) : GetOutputFileExtension(state); } state.OutputContainer = (containerInternal ?? string.Empty).TrimStart('.'); - state.OutputAudioBitrate = encodingHelper.GetAudioBitrateParam(streamingRequest.AudioBitRate, state.AudioStream); + state.OutputAudioBitrate = encodingHelper.GetAudioBitrateParam(streamingRequest.AudioBitRate, streamingRequest.AudioCodec, state.AudioStream); state.OutputAudioCodec = streamingRequest.AudioCodec; @@ -196,20 +197,42 @@ namespace Jellyfin.Api.Helpers encodingHelper.TryStreamCopy(state); - if (state.OutputVideoBitrate.HasValue && !EncodingHelper.IsCopyCodec(state.OutputVideoCodec)) + if (!EncodingHelper.IsCopyCodec(state.OutputVideoCodec) && state.OutputVideoBitrate.HasValue) { - var resolution = ResolutionNormalizer.Normalize( - state.VideoStream?.BitRate, - state.VideoStream?.Width, - state.VideoStream?.Height, - state.OutputVideoBitrate.Value, - state.VideoStream?.Codec, - state.OutputVideoCodec, - state.VideoRequest.MaxWidth, - state.VideoRequest.MaxHeight); - - state.VideoRequest.MaxWidth = resolution.MaxWidth; - state.VideoRequest.MaxHeight = resolution.MaxHeight; + var isVideoResolutionNotRequested = !state.VideoRequest.Width.HasValue + && !state.VideoRequest.Height.HasValue + && !state.VideoRequest.MaxWidth.HasValue + && !state.VideoRequest.MaxHeight.HasValue; + + if (isVideoResolutionNotRequested + && state.VideoStream != null + && state.VideoRequest.VideoBitRate.HasValue + && state.VideoStream.BitRate.HasValue + && state.VideoRequest.VideoBitRate.Value >= state.VideoStream.BitRate.Value) + { + // Don't downscale the resolution if the width/height/MaxWidth/MaxHeight is not requested, + // and the requested video bitrate is higher than source video bitrate. + if (state.VideoStream.Width.HasValue || state.VideoStream.Height.HasValue) + { + state.VideoRequest.MaxWidth = state.VideoStream?.Width; + state.VideoRequest.MaxHeight = state.VideoStream?.Height; + } + } + else + { + var resolution = ResolutionNormalizer.Normalize( + state.VideoStream?.BitRate, + state.VideoStream?.Width, + state.VideoStream?.Height, + state.OutputVideoBitrate.Value, + state.VideoStream?.Codec, + state.OutputVideoCodec, + state.VideoRequest.MaxWidth, + state.VideoRequest.MaxHeight); + + state.VideoRequest.MaxWidth = resolution.MaxWidth; + state.VideoRequest.MaxHeight = resolution.MaxHeight; + } } } @@ -217,7 +240,7 @@ namespace Jellyfin.Api.Helpers var ext = string.IsNullOrWhiteSpace(state.OutputContainer) ? GetOutputFileExtension(state) - : ('.' + state.OutputContainer); + : ("." + state.OutputContainer); state.OutputFilePath = GetOutputFilePath(state, ext!, serverConfigurationManager, streamingRequest.DeviceId, streamingRequest.PlaySessionId); @@ -269,16 +292,14 @@ namespace Jellyfin.Api.Helpers } } - if (profile == null) - { - profile = dlnaManager.GetDefaultProfile(); - } + profile ??= dlnaManager.GetDefaultProfile(); var audioCodec = state.ActualOutputAudioCodec; if (!state.IsVideoRequest) { - responseHeaders.Add("contentFeatures.dlna.org", new ContentFeatureBuilder(profile).BuildAudioHeader( + responseHeaders.Add("contentFeatures.dlna.org", ContentFeatureBuilder.BuildAudioHeader( + profile, state.OutputContainer, audioCodec, state.OutputAudioBitrate, @@ -295,7 +316,7 @@ namespace Jellyfin.Api.Helpers responseHeaders.Add( "contentFeatures.dlna.org", - new ContentFeatureBuilder(profile).BuildVideoHeader(state.OutputContainer, videoCodec, audioCodec, state.OutputWidth, state.OutputHeight, state.TargetVideoBitDepth, state.OutputVideoBitrate, state.TargetTimestamp, isStaticallyStreamed, state.RunTimeTicks, state.TargetVideoProfile, state.TargetVideoLevel, state.TargetFramerate, state.TargetPacketLength, state.TranscodeSeekInfo, state.IsTargetAnamorphic, state.IsTargetInterlaced, state.TargetRefFrames, state.TargetVideoStreamCount, state.TargetAudioStreamCount, state.TargetVideoCodecTag, state.IsTargetAVC).FirstOrDefault() ?? string.Empty); + ContentFeatureBuilder.BuildVideoHeader(profile, state.OutputContainer, videoCodec, audioCodec, state.OutputWidth, state.OutputHeight, state.TargetVideoBitDepth, state.OutputVideoBitrate, state.TargetTimestamp, isStaticallyStreamed, state.RunTimeTicks, state.TargetVideoProfile, state.TargetVideoLevel, state.TargetFramerate, state.TargetPacketLength, state.TranscodeSeekInfo, state.IsTargetAnamorphic, state.IsTargetInterlaced, state.TargetRefFrames, state.TargetVideoStreamCount, state.TargetAudioStreamCount, state.TargetVideoCodecTag, state.IsTargetAVC).FirstOrDefault() ?? string.Empty); } } @@ -480,17 +501,15 @@ namespace Jellyfin.Api.Helpers private static void ApplyDeviceProfileSettings(StreamState state, IDlnaManager dlnaManager, IDeviceManager deviceManager, HttpRequest request, string? deviceProfileId, bool? @static) { - var headers = request.Headers; - if (!string.IsNullOrWhiteSpace(deviceProfileId)) { state.DeviceProfile = dlnaManager.GetProfile(deviceProfileId); - } - else if (!string.IsNullOrWhiteSpace(deviceProfileId)) - { - var caps = deviceManager.GetCapabilities(deviceProfileId); - state.DeviceProfile = caps == null ? dlnaManager.GetProfile(headers) : caps.DeviceProfile; + if (state.DeviceProfile == null) + { + var caps = deviceManager.GetCapabilities(deviceProfileId); + state.DeviceProfile = caps == null ? dlnaManager.GetProfile(request.Headers) : caps.DeviceProfile; + } } var profile = state.DeviceProfile; diff --git a/Jellyfin.Api/Helpers/TranscodingJobHelper.cs b/Jellyfin.Api/Helpers/TranscodingJobHelper.cs index 0db1fabffe..05fa5b1350 100644 --- a/Jellyfin.Api/Helpers/TranscodingJobHelper.cs +++ b/Jellyfin.Api/Helpers/TranscodingJobHelper.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Diagnostics; using System.Globalization; @@ -22,7 +22,6 @@ using MediaBrowser.Model.IO; using MediaBrowser.Model.MediaInfo; using MediaBrowser.Model.Session; using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; namespace Jellyfin.Api.Helpers @@ -45,7 +44,6 @@ namespace Jellyfin.Api.Helpers private readonly IAuthorizationContext _authorizationContext; private readonly EncodingHelper _encodingHelper; private readonly IFileSystem _fileSystem; - private readonly IIsoManager _isoManager; private readonly ILogger<TranscodingJobHelper> _logger; private readonly IMediaEncoder _mediaEncoder; private readonly IMediaSourceManager _mediaSourceManager; @@ -63,9 +61,7 @@ namespace Jellyfin.Api.Helpers /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param> /// <param name="sessionManager">Instance of the <see cref="ISessionManager"/> interface.</param> /// <param name="authorizationContext">Instance of the <see cref="IAuthorizationContext"/> interface.</param> - /// <param name="isoManager">Instance of the <see cref="IIsoManager"/> interface.</param> - /// <param name="subtitleEncoder">Instance of the <see cref="ISubtitleEncoder"/> interface.</param> - /// <param name="configuration">Instance of the <see cref="IConfiguration"/> interface.</param> + /// <param name="encodingHelper">Instance of <see cref="EncodingHelper"/>.</param> /// <param name="loggerFactory">Instance of the <see cref="ILoggerFactory"/> interface.</param> public TranscodingJobHelper( ILogger<TranscodingJobHelper> logger, @@ -75,9 +71,7 @@ namespace Jellyfin.Api.Helpers IServerConfigurationManager serverConfigurationManager, ISessionManager sessionManager, IAuthorizationContext authorizationContext, - IIsoManager isoManager, - ISubtitleEncoder subtitleEncoder, - IConfiguration configuration, + EncodingHelper encodingHelper, ILoggerFactory loggerFactory) { _logger = logger; @@ -87,9 +81,8 @@ namespace Jellyfin.Api.Helpers _serverConfigurationManager = serverConfigurationManager; _sessionManager = sessionManager; _authorizationContext = authorizationContext; - _isoManager = isoManager; + _encodingHelper = encodingHelper; _loggerFactory = loggerFactory; - _encodingHelper = new EncodingHelper(mediaEncoder, fileSystem, subtitleEncoder, configuration); DeleteEncodedMediaCache(); @@ -102,7 +95,7 @@ namespace Jellyfin.Api.Helpers /// </summary> /// <param name="playSessionId">Playback session id.</param> /// <returns>The transcoding job.</returns> - public TranscodingJobDto GetTranscodingJob(string playSessionId) + public TranscodingJobDto? GetTranscodingJob(string playSessionId) { lock (_activeTranscodingJobs) { @@ -116,7 +109,7 @@ namespace Jellyfin.Api.Helpers /// <param name="path">Path to the transcoding file.</param> /// <param name="type">The <see cref="TranscodingJobType"/>.</param> /// <returns>The transcoding job.</returns> - public TranscodingJobDto GetTranscodingJob(string path, TranscodingJobType type) + public TranscodingJobDto? GetTranscodingJob(string path, TranscodingJobType type) { lock (_activeTranscodingJobs) { @@ -144,7 +137,7 @@ namespace Jellyfin.Api.Helpers lock (_activeTranscodingJobs) { // This is really only needed for HLS. - // Progressive streams can stop on their own reliably + // Progressive streams can stop on their own reliably. jobs = _activeTranscodingJobs.Where(j => string.Equals(playSessionId, j.PlaySessionId, StringComparison.OrdinalIgnoreCase)).ToList(); } @@ -193,10 +186,9 @@ namespace Jellyfin.Api.Helpers /// Called when [transcode kill timer stopped]. /// </summary> /// <param name="state">The state.</param> - private async void OnTranscodeKillTimerStopped(object state) + private async void OnTranscodeKillTimerStopped(object? state) { - var job = (TranscodingJobDto)state; - + var job = state as TranscodingJobDto ?? throw new ArgumentException($"{nameof(state)} is not of type {nameof(TranscodingJobDto)}", nameof(state)); if (!job.HasExited && job.Type != TranscodingJobType.Progressive) { var timeSinceLastPing = (DateTime.UtcNow - job.LastPingDate).TotalMilliseconds; @@ -241,7 +233,7 @@ namespace Jellyfin.Api.Helpers lock (_activeTranscodingJobs) { // This is really only needed for HLS. - // Progressive streams can stop on their own reliably + // Progressive streams can stop on their own reliably. jobs.AddRange(_activeTranscodingJobs.Where(killJob)); } @@ -277,7 +269,7 @@ namespace Jellyfin.Api.Helpers { _activeTranscodingJobs.Remove(job); - if (!job.CancellationTokenSource!.IsCancellationRequested) + if (job.CancellationTokenSource?.IsCancellationRequested == false) { job.CancellationTokenSource.Cancel(); } @@ -304,10 +296,10 @@ namespace Jellyfin.Api.Helpers process!.StandardInput.WriteLine("q"); - // Need to wait because killing is asynchronous + // Need to wait because killing is asynchronous. if (!process.WaitForExit(5000)) { - _logger.LogInformation("Killing ffmpeg process for {Path}", job.Path); + _logger.LogInformation("Killing FFmpeg process for {Path}", job.Path); process.Kill(); } } @@ -387,7 +379,9 @@ namespace Jellyfin.Api.Helpers /// <param name="outputFilePath">The output file path.</param> private void DeleteHlsPartialStreamFiles(string outputFilePath) { - var directory = Path.GetDirectoryName(outputFilePath); + var directory = Path.GetDirectoryName(outputFilePath) + ?? throw new ArgumentException("Path can't be a root directory.", nameof(outputFilePath)); + var name = Path.GetFileNameWithoutExtension(outputFilePath); var filesToDelete = _fileSystem.GetFilePaths(directory) @@ -450,6 +444,10 @@ namespace Jellyfin.Api.Helpers { var audioCodec = state.ActualOutputAudioCodec; var videoCodec = state.ActualOutputVideoCodec; + var hardwareAccelerationTypeString = _serverConfigurationManager.GetEncodingOptions().HardwareAccelerationType; + HardwareEncodingType? hardwareAccelerationType = string.IsNullOrEmpty(hardwareAccelerationTypeString) + ? null + : (HardwareEncodingType)Enum.Parse(typeof(HardwareEncodingType), hardwareAccelerationTypeString, true); _sessionManager.ReportTranscodingInfo(deviceId, new TranscodingInfo { @@ -464,17 +462,18 @@ namespace Jellyfin.Api.Helpers AudioChannels = state.OutputAudioChannels, IsAudioDirect = EncodingHelper.IsCopyCodec(state.OutputAudioCodec), IsVideoDirect = EncodingHelper.IsCopyCodec(state.OutputVideoCodec), + HardwareAccelerationType = hardwareAccelerationType, TranscodeReasons = state.TranscodeReasons }); } } /// <summary> - /// Starts the FFMPEG. + /// Starts FFmpeg. /// </summary> /// <param name="state">The state.</param> /// <param name="outputPath">The output path.</param> - /// <param name="commandLineArguments">The command line arguments for ffmpeg.</param> + /// <param name="commandLineArguments">The command line arguments for FFmpeg.</param> /// <param name="request">The <see cref="HttpRequest"/>.</param> /// <param name="transcodingJobType">The <see cref="TranscodingJobType"/>.</param> /// <param name="cancellationTokenSource">The cancellation token source.</param> @@ -489,7 +488,8 @@ namespace Jellyfin.Api.Helpers CancellationTokenSource cancellationTokenSource, string? workingDirectory = null) { - Directory.CreateDirectory(Path.GetDirectoryName(outputPath)); + var directory = Path.GetDirectoryName(outputPath) ?? throw new ArgumentException($"Provided path ({outputPath}) is not valid.", nameof(outputPath)); + Directory.CreateDirectory(directory); await AcquireResources(state, cancellationTokenSource).ConfigureAwait(false); @@ -500,13 +500,13 @@ namespace Jellyfin.Api.Helpers { this.OnTranscodeFailedToStart(outputPath, transcodingJobType, state); - throw new ArgumentException("User does not have access to video transcoding"); + throw new ArgumentException("User does not have access to video transcoding."); } } if (string.IsNullOrEmpty(_mediaEncoder.EncoderPath)) { - throw new ArgumentException("FFMPEG path not set."); + throw new ArgumentException("FFmpeg path not set."); } var process = new Process @@ -523,7 +523,7 @@ namespace Jellyfin.Api.Helpers RedirectStandardInput = true, FileName = _mediaEncoder.EncoderPath, Arguments = commandLineArguments, - WorkingDirectory = string.IsNullOrWhiteSpace(workingDirectory) ? null : workingDirectory, + WorkingDirectory = string.IsNullOrWhiteSpace(workingDirectory) ? string.Empty : workingDirectory, ErrorDialog = false }, EnableRaisingEvents = true @@ -543,18 +543,20 @@ namespace Jellyfin.Api.Helpers var commandLineLogMessage = process.StartInfo.FileName + " " + process.StartInfo.Arguments; _logger.LogInformation(commandLineLogMessage); - var logFilePrefix = "ffmpeg-transcode"; + var logFilePrefix = "FFmpeg.Transcode-"; if (state.VideoRequest != null && EncodingHelper.IsCopyCodec(state.OutputVideoCodec)) { logFilePrefix = EncodingHelper.IsCopyCodec(state.OutputAudioCodec) - ? "ffmpeg-remux" - : "ffmpeg-directstream"; + ? "FFmpeg.Remux-" + : "FFmpeg.DirectStream-"; } - var logFilePath = Path.Combine(_serverConfigurationManager.ApplicationPaths.LogDirectoryPath, logFilePrefix + "-" + Guid.NewGuid() + ".txt"); + var logFilePath = Path.Combine( + _serverConfigurationManager.ApplicationPaths.LogDirectoryPath, + $"{logFilePrefix}{DateTime.Now:yyyy-MM-dd_HH-mm-ss}_{state.Request.MediaSourceId}_{Guid.NewGuid().ToString()[..8]}.log"); - // FFMpeg writes debug/error info to stderr. This is useful when debugging so let's put it in the log directory. + // FFmpeg writes debug/error info to stderr. This is useful when debugging so let's put it in the log directory. Stream logStream = new FileStream(logFilePath, FileMode.Create, FileAccess.Write, FileShare.Read, IODefaults.FileStreamBufferSize, true); var commandLineLogMessageBytes = Encoding.UTF8.GetBytes(request.Path + Environment.NewLine + Environment.NewLine + JsonSerializer.Serialize(state.MediaSource) + Environment.NewLine + Environment.NewLine + commandLineLogMessage + Environment.NewLine + Environment.NewLine); @@ -568,20 +570,20 @@ namespace Jellyfin.Api.Helpers } catch (Exception ex) { - _logger.LogError(ex, "Error starting ffmpeg"); + _logger.LogError(ex, "Error starting FFmpeg"); this.OnTranscodeFailedToStart(outputPath, transcodingJobType, state); throw; } - _logger.LogDebug("Launched ffmpeg process"); + _logger.LogDebug("Launched FFmpeg process"); state.TranscodingJob = transcodingJob; - // Important - don't await the log task or we won't be able to kill ffmpeg when the user stops playback + // Important - don't await the log task or we won't be able to kill FFmpeg when the user stops playback _ = new JobLogger(_logger).StartStreamingLog(state, process.StandardError.BaseStream, logStream); - // Wait for the file to exist before proceeeding + // Wait for the file to exist before proceeding var ffmpegTargetFile = state.WaitForPath ?? outputPath; _logger.LogDebug("Waiting for the creation of {0}", ffmpegTargetFile); while (!File.Exists(ffmpegTargetFile) && !transcodingJob.HasExited) @@ -747,31 +749,27 @@ namespace Jellyfin.Api.Helpers if (process.ExitCode == 0) { - _logger.LogInformation("FFMpeg exited with code 0"); + _logger.LogInformation("FFmpeg exited with code 0"); } else { - _logger.LogError("FFMpeg exited with code {0}", process.ExitCode); + _logger.LogError("FFmpeg exited with code {0}", process.ExitCode); } - process.Dispose(); + job.Dispose(); } private async Task AcquireResources(StreamState state, CancellationTokenSource cancellationTokenSource) { - if (state.VideoType == VideoType.Iso && state.IsoType.HasValue && _isoManager.CanMount(state.MediaPath)) - { - state.IsoMount = await _isoManager.Mount(state.MediaPath, cancellationTokenSource.Token).ConfigureAwait(false); - } - if (state.MediaSource.RequiresOpening && string.IsNullOrWhiteSpace(state.Request.LiveStreamId)) { var liveStreamResponse = await _mediaSourceManager.OpenLiveStream( - new LiveStreamRequest { OpenToken = state.MediaSource.OpenToken }, - cancellationTokenSource.Token) + new LiveStreamRequest { OpenToken = state.MediaSource.OpenToken }, + cancellationTokenSource.Token) .ConfigureAwait(false); + var encodingOptions = _serverConfigurationManager.GetEncodingOptions(); - _encodingHelper.AttachMediaSourceInfo(state, liveStreamResponse.MediaSource, state.RequestedUrl); + _encodingHelper.AttachMediaSourceInfo(state, encodingOptions, liveStreamResponse.MediaSource, state.RequestedUrl); if (state.VideoRequest != null) { @@ -827,7 +825,7 @@ namespace Jellyfin.Api.Helpers { lock (_transcodingLocks) { - if (!_transcodingLocks.TryGetValue(outputPath, out SemaphoreSlim result)) + if (!_transcodingLocks.TryGetValue(outputPath, out SemaphoreSlim? result)) { result = new SemaphoreSlim(1, 1); _transcodingLocks[outputPath] = result; @@ -837,7 +835,7 @@ namespace Jellyfin.Api.Helpers } } - private void OnPlaybackProgress(object sender, PlaybackProgressEventArgs e) + private void OnPlaybackProgress(object? sender, PlaybackProgressEventArgs e) { if (!string.IsNullOrWhiteSpace(e.PlaySessionId)) { diff --git a/Jellyfin.Api/Jellyfin.Api.csproj b/Jellyfin.Api/Jellyfin.Api.csproj index da0852cebc..669925198b 100644 --- a/Jellyfin.Api/Jellyfin.Api.csproj +++ b/Jellyfin.Api/Jellyfin.Api.csproj @@ -6,19 +6,18 @@ </PropertyGroup> <PropertyGroup> - <TargetFramework>netstandard2.1</TargetFramework> + <TargetFramework>net5.0</TargetFramework> <GenerateDocumentationFile>true</GenerateDocumentationFile> - <TreatWarningsAsErrors>true</TreatWarningsAsErrors> - <Nullable>enable</Nullable> + <!-- https://github.com/microsoft/ApplicationInsights-dotnet/issues/2047 --> + <NoWarn>AD0001</NoWarn> + <AnalysisMode>AllDisabledByDefault</AnalysisMode> </PropertyGroup> <ItemGroup> - <PackageReference Include="Microsoft.AspNetCore.Authentication" Version="2.2.0" /> - <PackageReference Include="Microsoft.AspNetCore.Authorization" Version="3.1.9" /> - <PackageReference Include="Microsoft.AspNetCore.Mvc" Version="2.2.0" /> - <PackageReference Include="Microsoft.Extensions.Http" Version="3.1.9" /> - <PackageReference Include="Swashbuckle.AspNetCore" Version="5.6.3" /> - <PackageReference Include="Swashbuckle.AspNetCore.ReDoc" Version="5.6.3" /> + <PackageReference Include="Microsoft.AspNetCore.Authorization" Version="5.0.9" /> + <PackageReference Include="Microsoft.Extensions.Http" Version="5.0.0" /> + <PackageReference Include="Swashbuckle.AspNetCore" Version="6.1.5" /> + <PackageReference Include="Swashbuckle.AspNetCore.ReDoc" Version="6.1.5" /> </ItemGroup> <ItemGroup> @@ -28,14 +27,15 @@ <!-- Code Analyzers--> <ItemGroup Condition=" '$(Configuration)' == 'Debug' "> - <PackageReference Include="Microsoft.CodeAnalysis.FxCopAnalyzers" Version="2.9.8" PrivateAssets="All" /> <PackageReference Include="SerilogAnalyzer" Version="0.15.0" PrivateAssets="All" /> <PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="All" /> <PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" PrivateAssets="All" /> </ItemGroup> - <PropertyGroup Condition=" '$(Configuration)' == 'Debug' "> - <CodeAnalysisRuleSet>../jellyfin.ruleset</CodeAnalysisRuleSet> - </PropertyGroup> + <ItemGroup> + <AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleTo"> + <_Parameter1>Jellyfin.Api.Tests</_Parameter1> + </AssemblyAttribute> + </ItemGroup> </Project> diff --git a/Jellyfin.Api/ModelBinders/CommaDelimitedArrayModelBinder.cs b/Jellyfin.Api/ModelBinders/CommaDelimitedArrayModelBinder.cs index 208566dc8e..c04f3c721b 100644 --- a/Jellyfin.Api/ModelBinders/CommaDelimitedArrayModelBinder.cs +++ b/Jellyfin.Api/ModelBinders/CommaDelimitedArrayModelBinder.cs @@ -1,7 +1,9 @@ using System; +using System.Collections.Generic; using System.ComponentModel; using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc.ModelBinding; +using Microsoft.Extensions.Logging; namespace Jellyfin.Api.ModelBinders { @@ -11,25 +13,28 @@ namespace Jellyfin.Api.ModelBinders /// </summary> public class CommaDelimitedArrayModelBinder : IModelBinder { + private readonly ILogger<CommaDelimitedArrayModelBinder> _logger; + + /// <summary> + /// Initializes a new instance of the <see cref="CommaDelimitedArrayModelBinder"/> class. + /// </summary> + /// <param name="logger">Instance of the <see cref="ILogger{CommaDelimitedArrayModelBinder}"/> interface.</param> + public CommaDelimitedArrayModelBinder(ILogger<CommaDelimitedArrayModelBinder> logger) + { + _logger = logger; + } + /// <inheritdoc/> public Task BindModelAsync(ModelBindingContext bindingContext) { var valueProviderResult = bindingContext.ValueProvider.GetValue(bindingContext.ModelName); - var elementType = bindingContext.ModelType.GetElementType(); + var elementType = bindingContext.ModelType.GetElementType() ?? bindingContext.ModelType.GenericTypeArguments[0]; var converter = TypeDescriptor.GetConverter(elementType); if (valueProviderResult.Length > 1) { - var result = Array.CreateInstance(elementType, valueProviderResult.Length); - - for (int i = 0; i < valueProviderResult.Length; i++) - { - var value = converter.ConvertFromString(valueProviderResult.Values[i].Trim()); - - result.SetValue(value, i); - } - - bindingContext.Result = ModelBindingResult.Success(result); + var typedValues = GetParsedResult(valueProviderResult.Values, elementType, converter); + bindingContext.Result = ModelBindingResult.Success(typedValues); } else { @@ -37,13 +42,8 @@ namespace Jellyfin.Api.ModelBinders if (value != null) { - var values = Array.ConvertAll( - value.Split(new[] { "," }, StringSplitOptions.RemoveEmptyEntries), - x => converter.ConvertFromString(x?.Trim())); - - var typedValues = Array.CreateInstance(elementType, values.Length); - values.CopyTo(typedValues, 0); - + var splitValues = value.Split(',', StringSplitOptions.RemoveEmptyEntries); + var typedValues = GetParsedResult(splitValues, elementType, converter); bindingContext.Result = ModelBindingResult.Success(typedValues); } else @@ -55,5 +55,36 @@ namespace Jellyfin.Api.ModelBinders return Task.CompletedTask; } + + private Array GetParsedResult(IReadOnlyList<string> values, Type elementType, TypeConverter converter) + { + var parsedValues = new object?[values.Count]; + var convertedCount = 0; + for (var i = 0; i < values.Count; i++) + { + try + { + parsedValues[i] = converter.ConvertFromString(values[i].Trim()); + convertedCount++; + } + catch (FormatException e) + { + _logger.LogDebug(e, "Error converting value."); + } + } + + var typedValues = Array.CreateInstance(elementType, convertedCount); + var typedValueIndex = 0; + for (var i = 0; i < parsedValues.Length; i++) + { + if (parsedValues[i] != null) + { + typedValues.SetValue(parsedValues[i], typedValueIndex); + typedValueIndex++; + } + } + + return typedValues; + } } } diff --git a/Jellyfin.Api/ModelBinders/CommaDelimitedArrayModelBinderProvider.cs b/Jellyfin.Api/ModelBinders/CommaDelimitedArrayModelBinderProvider.cs deleted file mode 100644 index b9785a73b8..0000000000 --- a/Jellyfin.Api/ModelBinders/CommaDelimitedArrayModelBinderProvider.cs +++ /dev/null @@ -1,29 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Text; -using Microsoft.AspNetCore.Mvc.ModelBinding; - -namespace Jellyfin.Api.ModelBinders -{ - /// <summary> - /// Comma delimited array model binder provider. - /// </summary> - public class CommaDelimitedArrayModelBinderProvider : IModelBinderProvider - { - private readonly IModelBinder _binder; - - /// <summary> - /// Initializes a new instance of the <see cref="CommaDelimitedArrayModelBinderProvider"/> class. - /// </summary> - public CommaDelimitedArrayModelBinderProvider() - { - _binder = new CommaDelimitedArrayModelBinder(); - } - - /// <inheritdoc /> - public IModelBinder? GetBinder(ModelBinderProviderContext context) - { - return context.Metadata.ModelType.IsArray ? _binder : null; - } - } -} diff --git a/Jellyfin.Api/ModelBinders/LegacyDateTimeModelBinder.cs b/Jellyfin.Api/ModelBinders/LegacyDateTimeModelBinder.cs new file mode 100644 index 0000000000..e1cb725f3e --- /dev/null +++ b/Jellyfin.Api/ModelBinders/LegacyDateTimeModelBinder.cs @@ -0,0 +1,49 @@ +using System; +using System.Globalization; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc.ModelBinding; +using Microsoft.AspNetCore.Mvc.ModelBinding.Binders; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Api.ModelBinders +{ + /// <summary> + /// DateTime model binder. + /// </summary> + public class LegacyDateTimeModelBinder : IModelBinder + { + // Borrowed from the DateTimeModelBinderProvider + private const DateTimeStyles SupportedStyles = DateTimeStyles.AdjustToUniversal | DateTimeStyles.AllowWhiteSpaces; + private readonly DateTimeModelBinder _defaultModelBinder; + + /// <summary> + /// Initializes a new instance of the <see cref="LegacyDateTimeModelBinder"/> class. + /// </summary> + /// <param name="loggerFactory">Instance of the <see cref="ILoggerFactory"/> interface.</param> + public LegacyDateTimeModelBinder(ILoggerFactory loggerFactory) + { + _defaultModelBinder = new DateTimeModelBinder(SupportedStyles, loggerFactory); + } + + /// <inheritdoc /> + public Task BindModelAsync(ModelBindingContext bindingContext) + { + var valueProviderResult = bindingContext.ValueProvider.GetValue(bindingContext.ModelName); + if (valueProviderResult.Values.Count == 1) + { + var dateTimeString = valueProviderResult.FirstValue; + // Mark Played Item. + if (DateTime.TryParseExact(dateTimeString, "yyyyMMddHHmmss", CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var dateTime)) + { + bindingContext.Result = ModelBindingResult.Success(dateTime); + } + else + { + return _defaultModelBinder.BindModelAsync(bindingContext); + } + } + + return Task.CompletedTask; + } + } +} diff --git a/Jellyfin.Api/ModelBinders/NullableEnumModelBinder.cs b/Jellyfin.Api/ModelBinders/NullableEnumModelBinder.cs new file mode 100644 index 0000000000..be2045fbab --- /dev/null +++ b/Jellyfin.Api/ModelBinders/NullableEnumModelBinder.cs @@ -0,0 +1,47 @@ +using System; +using System.ComponentModel; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc.ModelBinding; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Api.ModelBinders +{ + /// <summary> + /// Nullable enum model binder. + /// </summary> + public class NullableEnumModelBinder : IModelBinder + { + private readonly ILogger<NullableEnumModelBinder> _logger; + + /// <summary> + /// Initializes a new instance of the <see cref="NullableEnumModelBinder"/> class. + /// </summary> + /// <param name="logger">Instance of the <see cref="ILogger{NullableEnumModelBinder}"/> interface.</param> + public NullableEnumModelBinder(ILogger<NullableEnumModelBinder> logger) + { + _logger = logger; + } + + /// <inheritdoc /> + public Task BindModelAsync(ModelBindingContext bindingContext) + { + var valueProviderResult = bindingContext.ValueProvider.GetValue(bindingContext.ModelName); + var elementType = bindingContext.ModelType.GetElementType() ?? bindingContext.ModelType.GenericTypeArguments[0]; + var converter = TypeDescriptor.GetConverter(elementType); + if (valueProviderResult.Length != 0) + { + try + { + var convertedValue = converter.ConvertFromString(valueProviderResult.FirstValue); + bindingContext.Result = ModelBindingResult.Success(convertedValue); + } + catch (FormatException e) + { + _logger.LogDebug(e, "Error converting value."); + } + } + + return Task.CompletedTask; + } + } +} \ No newline at end of file diff --git a/Jellyfin.Api/ModelBinders/NullableEnumModelBinderProvider.cs b/Jellyfin.Api/ModelBinders/NullableEnumModelBinderProvider.cs new file mode 100644 index 0000000000..bc12ad05da --- /dev/null +++ b/Jellyfin.Api/ModelBinders/NullableEnumModelBinderProvider.cs @@ -0,0 +1,27 @@ +using System; +using Microsoft.AspNetCore.Mvc.ModelBinding; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Api.ModelBinders +{ + /// <summary> + /// Nullable enum model binder provider. + /// </summary> + public class NullableEnumModelBinderProvider : IModelBinderProvider + { + /// <inheritdoc /> + public IModelBinder? GetBinder(ModelBinderProviderContext context) + { + var nullableType = Nullable.GetUnderlyingType(context.Metadata.ModelType); + if (nullableType == null || !nullableType.IsEnum) + { + // Type isn't nullable or isn't an enum. + return null; + } + + var logger = context.Services.GetRequiredService<ILogger<NullableEnumModelBinder>>(); + return new NullableEnumModelBinder(logger); + } + } +} \ No newline at end of file diff --git a/Jellyfin.Api/ModelBinders/PipeDelimitedArrayModelBinder.cs b/Jellyfin.Api/ModelBinders/PipeDelimitedArrayModelBinder.cs new file mode 100644 index 0000000000..639ab07930 --- /dev/null +++ b/Jellyfin.Api/ModelBinders/PipeDelimitedArrayModelBinder.cs @@ -0,0 +1,90 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc.ModelBinding; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Api.ModelBinders +{ + /// <summary> + /// Comma delimited array model binder. + /// Returns an empty array of specified type if there is no query parameter. + /// </summary> + public class PipeDelimitedArrayModelBinder : IModelBinder + { + private readonly ILogger<PipeDelimitedArrayModelBinder> _logger; + + /// <summary> + /// Initializes a new instance of the <see cref="PipeDelimitedArrayModelBinder"/> class. + /// </summary> + /// <param name="logger">Instance of the <see cref="ILogger{PipeDelimitedArrayModelBinder}"/> interface.</param> + public PipeDelimitedArrayModelBinder(ILogger<PipeDelimitedArrayModelBinder> logger) + { + _logger = logger; + } + + /// <inheritdoc/> + public Task BindModelAsync(ModelBindingContext bindingContext) + { + var valueProviderResult = bindingContext.ValueProvider.GetValue(bindingContext.ModelName); + var elementType = bindingContext.ModelType.GetElementType() ?? bindingContext.ModelType.GenericTypeArguments[0]; + var converter = TypeDescriptor.GetConverter(elementType); + + if (valueProviderResult.Length > 1) + { + var typedValues = GetParsedResult(valueProviderResult.Values, elementType, converter); + bindingContext.Result = ModelBindingResult.Success(typedValues); + } + else + { + var value = valueProviderResult.FirstValue; + + if (value != null) + { + var splitValues = value.Split('|', StringSplitOptions.RemoveEmptyEntries); + var typedValues = GetParsedResult(splitValues, elementType, converter); + bindingContext.Result = ModelBindingResult.Success(typedValues); + } + else + { + var emptyResult = Array.CreateInstance(elementType, 0); + bindingContext.Result = ModelBindingResult.Success(emptyResult); + } + } + + return Task.CompletedTask; + } + + private Array GetParsedResult(IReadOnlyList<string> values, Type elementType, TypeConverter converter) + { + var parsedValues = new object?[values.Count]; + var convertedCount = 0; + for (var i = 0; i < values.Count; i++) + { + try + { + parsedValues[i] = converter.ConvertFromString(values[i].Trim()); + convertedCount++; + } + catch (FormatException e) + { + _logger.LogDebug(e, "Error converting value."); + } + } + + var typedValues = Array.CreateInstance(elementType, convertedCount); + var typedValueIndex = 0; + for (var i = 0; i < parsedValues.Length; i++) + { + if (parsedValues[i] != null) + { + typedValues.SetValue(parsedValues[i], typedValueIndex); + typedValueIndex++; + } + } + + return typedValues; + } + } +} diff --git a/Jellyfin.Api/Models/ConfigurationPageInfo.cs b/Jellyfin.Api/Models/ConfigurationPageInfo.cs index 2aa6373aa9..ec4a0d1a1c 100644 --- a/Jellyfin.Api/Models/ConfigurationPageInfo.cs +++ b/Jellyfin.Api/Models/ConfigurationPageInfo.cs @@ -1,5 +1,5 @@ -using MediaBrowser.Common.Plugins; -using MediaBrowser.Controller.Plugins; +using System; +using MediaBrowser.Common.Plugins; using MediaBrowser.Model.Plugins; namespace Jellyfin.Api.Models @@ -9,39 +9,27 @@ namespace Jellyfin.Api.Models /// </summary> public class ConfigurationPageInfo { - /// <summary> - /// Initializes a new instance of the <see cref="ConfigurationPageInfo"/> class. - /// </summary> - /// <param name="page">Instance of <see cref="IPluginConfigurationPage"/> interface.</param> - public ConfigurationPageInfo(IPluginConfigurationPage page) - { - Name = page.Name; - - ConfigurationPageType = page.ConfigurationPageType; - - if (page.Plugin != null) - { - DisplayName = page.Plugin.Name; - // Don't use "N" because it needs to match Plugin.Id - PluginId = page.Plugin.Id.ToString(); - } - } - /// <summary> /// Initializes a new instance of the <see cref="ConfigurationPageInfo"/> class. /// </summary> /// <param name="plugin">Instance of <see cref="IPlugin"/> interface.</param> /// <param name="page">Instance of <see cref="PluginPageInfo"/> interface.</param> - public ConfigurationPageInfo(IPlugin plugin, PluginPageInfo page) + public ConfigurationPageInfo(IPlugin? plugin, PluginPageInfo page) { Name = page.Name; EnableInMainMenu = page.EnableInMainMenu; MenuSection = page.MenuSection; MenuIcon = page.MenuIcon; - DisplayName = string.IsNullOrWhiteSpace(page.DisplayName) ? plugin.Name : page.DisplayName; + DisplayName = string.IsNullOrWhiteSpace(page.DisplayName) ? plugin?.Name : page.DisplayName; + PluginId = plugin?.Id; + } - // Don't use "N" because it needs to match Plugin.Id - PluginId = plugin.Id.ToString(); + /// <summary> + /// Initializes a new instance of the <see cref="ConfigurationPageInfo"/> class. + /// </summary> + public ConfigurationPageInfo() + { + Name = string.Empty; } /// <summary> @@ -70,16 +58,10 @@ namespace Jellyfin.Api.Models /// </summary> public string? DisplayName { get; set; } - /// <summary> - /// Gets or sets the type of the configuration page. - /// </summary> - /// <value>The type of the configuration page.</value> - public ConfigurationPageType ConfigurationPageType { get; set; } - /// <summary> /// Gets or sets the plugin id. /// </summary> /// <value>The plugin id.</value> - public string? PluginId { get; set; } + public Guid? PluginId { get; set; } } } diff --git a/Jellyfin.Api/Models/LibraryDtos/LibraryOptionsResultDto.cs b/Jellyfin.Api/Models/LibraryDtos/LibraryOptionsResultDto.cs index 33eda33cb9..7de44aa659 100644 --- a/Jellyfin.Api/Models/LibraryDtos/LibraryOptionsResultDto.cs +++ b/Jellyfin.Api/Models/LibraryDtos/LibraryOptionsResultDto.cs @@ -1,4 +1,5 @@ -using System.Diagnostics.CodeAnalysis; +using System; +using System.Collections.Generic; namespace Jellyfin.Api.Models.LibraryDtos { @@ -10,25 +11,21 @@ namespace Jellyfin.Api.Models.LibraryDtos /// <summary> /// Gets or sets the metadata savers. /// </summary> - [SuppressMessage("Microsoft.Performance", "CA1819:ReturnArrays", MessageId = "MetadataSavers", Justification = "Imported from ServiceStack")] - public LibraryOptionInfoDto[] MetadataSavers { get; set; } = null!; + public IReadOnlyList<LibraryOptionInfoDto> MetadataSavers { get; set; } = Array.Empty<LibraryOptionInfoDto>(); /// <summary> /// Gets or sets the metadata readers. /// </summary> - [SuppressMessage("Microsoft.Performance", "CA1819:ReturnArrays", MessageId = "MetadataReaders", Justification = "Imported from ServiceStack")] - public LibraryOptionInfoDto[] MetadataReaders { get; set; } = null!; + public IReadOnlyList<LibraryOptionInfoDto> MetadataReaders { get; set; } = Array.Empty<LibraryOptionInfoDto>(); /// <summary> /// Gets or sets the subtitle fetchers. /// </summary> - [SuppressMessage("Microsoft.Performance", "CA1819:ReturnArrays", MessageId = "SubtitleFetchers", Justification = "Imported from ServiceStack")] - public LibraryOptionInfoDto[] SubtitleFetchers { get; set; } = null!; + public IReadOnlyList<LibraryOptionInfoDto> SubtitleFetchers { get; set; } = Array.Empty<LibraryOptionInfoDto>(); /// <summary> /// Gets or sets the type options. /// </summary> - [SuppressMessage("Microsoft.Performance", "CA1819:ReturnArrays", MessageId = "TypeOptions", Justification = "Imported from ServiceStack")] - public LibraryTypeOptionsDto[] TypeOptions { get; set; } = null!; + public IReadOnlyList<LibraryTypeOptionsDto> TypeOptions { get; set; } = Array.Empty<LibraryTypeOptionsDto>(); } } diff --git a/Jellyfin.Api/Models/LibraryDtos/LibraryTypeOptionsDto.cs b/Jellyfin.Api/Models/LibraryDtos/LibraryTypeOptionsDto.cs index ad031e95e5..20f45196d2 100644 --- a/Jellyfin.Api/Models/LibraryDtos/LibraryTypeOptionsDto.cs +++ b/Jellyfin.Api/Models/LibraryDtos/LibraryTypeOptionsDto.cs @@ -1,4 +1,5 @@ -using System.Diagnostics.CodeAnalysis; +using System; +using System.Collections.Generic; using MediaBrowser.Model.Configuration; using MediaBrowser.Model.Entities; @@ -17,25 +18,21 @@ namespace Jellyfin.Api.Models.LibraryDtos /// <summary> /// Gets or sets the metadata fetchers. /// </summary> - [SuppressMessage("Microsoft.Performance", "CA1819:ReturnArrays", MessageId = "MetadataFetchers", Justification = "Imported from ServiceStack")] - public LibraryOptionInfoDto[] MetadataFetchers { get; set; } = null!; + public IReadOnlyList<LibraryOptionInfoDto> MetadataFetchers { get; set; } = Array.Empty<LibraryOptionInfoDto>(); /// <summary> /// Gets or sets the image fetchers. /// </summary> - [SuppressMessage("Microsoft.Performance", "CA1819:ReturnArrays", MessageId = "ImageFetchers", Justification = "Imported from ServiceStack")] - public LibraryOptionInfoDto[] ImageFetchers { get; set; } = null!; + public IReadOnlyList<LibraryOptionInfoDto> ImageFetchers { get; set; } = Array.Empty<LibraryOptionInfoDto>(); /// <summary> /// Gets or sets the supported image types. /// </summary> - [SuppressMessage("Microsoft.Performance", "CA1819:ReturnArrays", MessageId = "SupportedImageTypes", Justification = "Imported from ServiceStack")] - public ImageType[] SupportedImageTypes { get; set; } = null!; + public IReadOnlyList<ImageType> SupportedImageTypes { get; set; } = Array.Empty<ImageType>(); /// <summary> /// Gets or sets the default image options. /// </summary> - [SuppressMessage("Microsoft.Performance", "CA1819:ReturnArrays", MessageId = "DefaultImageOptions", Justification = "Imported from ServiceStack")] - public ImageOption[] DefaultImageOptions { get; set; } = null!; + public IReadOnlyList<ImageOption> DefaultImageOptions { get; set; } = Array.Empty<ImageOption>(); } } diff --git a/Jellyfin.Api/Models/LibraryDtos/MediaUpdateInfoDto.cs b/Jellyfin.Api/Models/LibraryDtos/MediaUpdateInfoDto.cs index 991dbfc502..f936388980 100644 --- a/Jellyfin.Api/Models/LibraryDtos/MediaUpdateInfoDto.cs +++ b/Jellyfin.Api/Models/LibraryDtos/MediaUpdateInfoDto.cs @@ -1,4 +1,7 @@ -namespace Jellyfin.Api.Models.LibraryDtos +using System; +using System.Collections.Generic; + +namespace Jellyfin.Api.Models.LibraryDtos { /// <summary> /// Media Update Info Dto. @@ -6,14 +9,8 @@ public class MediaUpdateInfoDto { /// <summary> - /// Gets or sets media path. - /// </summary> - public string? Path { get; set; } - - /// <summary> - /// Gets or sets media update type. - /// Created, Modified, Deleted. + /// Gets or sets the list of updates. /// </summary> - public string? UpdateType { get; set; } + public IReadOnlyList<MediaUpdateInfoPathDto> Updates { get; set; } = Array.Empty<MediaUpdateInfoPathDto>(); } } diff --git a/Jellyfin.Api/Models/LibraryDtos/MediaUpdateInfoPathDto.cs b/Jellyfin.Api/Models/LibraryDtos/MediaUpdateInfoPathDto.cs new file mode 100644 index 0000000000..852315b92d --- /dev/null +++ b/Jellyfin.Api/Models/LibraryDtos/MediaUpdateInfoPathDto.cs @@ -0,0 +1,19 @@ +namespace Jellyfin.Api.Models.LibraryDtos +{ + /// <summary> + /// The media update info path. + /// </summary> + public class MediaUpdateInfoPathDto + { + /// <summary> + /// Gets or sets media path. + /// </summary> + public string? Path { get; set; } + + /// <summary> + /// Gets or sets media update type. + /// Created, Modified, Deleted. + /// </summary> + public string? UpdateType { get; set; } + } +} diff --git a/Jellyfin.Api/Models/LibraryStructureDto/UpdateMediaPathRequestDto.cs b/Jellyfin.Api/Models/LibraryStructureDto/UpdateMediaPathRequestDto.cs new file mode 100644 index 0000000000..fbd4985f9c --- /dev/null +++ b/Jellyfin.Api/Models/LibraryStructureDto/UpdateMediaPathRequestDto.cs @@ -0,0 +1,23 @@ +using System.ComponentModel.DataAnnotations; +using MediaBrowser.Model.Configuration; + +namespace Jellyfin.Api.Models.LibraryStructureDto +{ + /// <summary> + /// Update library options dto. + /// </summary> + public class UpdateMediaPathRequestDto + { + /// <summary> + /// Gets or sets the library name. + /// </summary> + [Required] + public string Name { get; set; } = null!; + + /// <summary> + /// Gets or sets library folder path information. + /// </summary> + [Required] + public MediaPathInfo PathInfo { get; set; } = null!; + } +} diff --git a/Jellyfin.Api/Models/LiveTvDtos/ChannelMappingOptionsDto.cs b/Jellyfin.Api/Models/LiveTvDtos/ChannelMappingOptionsDto.cs index 970d8acdbc..f43822da77 100644 --- a/Jellyfin.Api/Models/LiveTvDtos/ChannelMappingOptionsDto.cs +++ b/Jellyfin.Api/Models/LiveTvDtos/ChannelMappingOptionsDto.cs @@ -1,4 +1,5 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using MediaBrowser.Controller.LiveTv; using MediaBrowser.Model.Dto; @@ -25,8 +26,7 @@ namespace Jellyfin.Api.Models.LiveTvDtos /// <summary> /// Gets or sets list of mappings. /// </summary> - [SuppressMessage("Microsoft.Performance", "CA1819:DontReturnArrays", MessageId = "Mappings", Justification = "Imported from ServiceStack")] - public NameValuePair[] Mappings { get; set; } = null!; + public IReadOnlyList<NameValuePair> Mappings { get; set; } = Array.Empty<NameValuePair>(); /// <summary> /// Gets or sets provider name. diff --git a/Jellyfin.Api/Models/LiveTvDtos/GetProgramsDto.cs b/Jellyfin.Api/Models/LiveTvDtos/GetProgramsDto.cs index d7eaab30de..411e4c550c 100644 --- a/Jellyfin.Api/Models/LiveTvDtos/GetProgramsDto.cs +++ b/Jellyfin.Api/Models/LiveTvDtos/GetProgramsDto.cs @@ -1,4 +1,10 @@ using System; +using System.Collections.Generic; +using System.Text.Json.Serialization; +using Jellyfin.Data.Enums; +using Jellyfin.Extensions.Json.Converters; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.Querying; namespace Jellyfin.Api.Models.LiveTvDtos { @@ -10,7 +16,8 @@ namespace Jellyfin.Api.Models.LiveTvDtos /// <summary> /// Gets or sets the channels to return guide information for. /// </summary> - public string? ChannelIds { get; set; } + [JsonConverter(typeof(JsonCommaDelimitedArrayConverterFactory))] + public IReadOnlyList<Guid> ChannelIds { get; set; } = Array.Empty<Guid>(); /// <summary> /// Gets or sets optional. Filter by user id. @@ -99,22 +106,26 @@ namespace Jellyfin.Api.Models.LiveTvDtos /// Gets or sets specify one or more sort orders, comma delimited. Options: Name, StartDate. /// Optional. /// </summary> - public string? SortBy { get; set; } + [JsonConverter(typeof(JsonCommaDelimitedArrayConverterFactory))] + public IReadOnlyList<string> SortBy { get; set; } = Array.Empty<string>(); /// <summary> /// Gets or sets sort Order - Ascending,Descending. /// </summary> - public string? SortOrder { get; set; } + [JsonConverter(typeof(JsonCommaDelimitedArrayConverterFactory))] + public IReadOnlyList<SortOrder> SortOrder { get; set; } = Array.Empty<SortOrder>(); /// <summary> /// Gets or sets the genres to return guide information for. /// </summary> - public string? Genres { get; set; } + [JsonConverter(typeof(JsonPipeDelimitedArrayConverterFactory))] + public IReadOnlyList<string> Genres { get; set; } = Array.Empty<string>(); /// <summary> /// Gets or sets the genre ids to return guide information for. /// </summary> - public string? GenreIds { get; set; } + [JsonConverter(typeof(JsonCommaDelimitedArrayConverterFactory))] + public IReadOnlyList<Guid> GenreIds { get; set; } = Array.Empty<Guid>(); /// <summary> /// Gets or sets include image information in output. @@ -137,7 +148,8 @@ namespace Jellyfin.Api.Models.LiveTvDtos /// Gets or sets the image types to include in the output. /// Optional. /// </summary> - public string? EnableImageTypes { get; set; } + [JsonConverter(typeof(JsonCommaDelimitedArrayConverterFactory))] + public IReadOnlyList<ImageType> EnableImageTypes { get; set; } = Array.Empty<ImageType>(); /// <summary> /// Gets or sets include user data. @@ -161,6 +173,7 @@ namespace Jellyfin.Api.Models.LiveTvDtos /// Gets or sets specify additional fields of information to return in the output. This allows multiple, comma delimited. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines. /// Optional. /// </summary> - public string? Fields { get; set; } + [JsonConverter(typeof(JsonCommaDelimitedArrayConverterFactory))] + public IReadOnlyList<ItemFields> Fields { get; set; } = Array.Empty<ItemFields>(); } } diff --git a/Jellyfin.Api/Models/LiveTvDtos/SetChannelMappingDto.cs b/Jellyfin.Api/Models/LiveTvDtos/SetChannelMappingDto.cs new file mode 100644 index 0000000000..2ddaa89e8b --- /dev/null +++ b/Jellyfin.Api/Models/LiveTvDtos/SetChannelMappingDto.cs @@ -0,0 +1,28 @@ +using System.ComponentModel.DataAnnotations; + +namespace Jellyfin.Api.Models.LiveTvDtos +{ + /// <summary> + /// Set channel mapping dto. + /// </summary> + public class SetChannelMappingDto + { + /// <summary> + /// Gets or sets the provider id. + /// </summary> + [Required] + public string ProviderId { get; set; } = string.Empty; + + /// <summary> + /// Gets or sets the tuner channel id. + /// </summary> + [Required] + public string TunerChannelId { get; set; } = string.Empty; + + /// <summary> + /// Gets or sets the provider channel id. + /// </summary> + [Required] + public string ProviderChannelId { get; set; } = string.Empty; + } +} \ No newline at end of file diff --git a/Jellyfin.Api/Models/MediaInfoDtos/OpenLiveStreamDto.cs b/Jellyfin.Api/Models/MediaInfoDtos/OpenLiveStreamDto.cs index f797a38076..7045423261 100644 --- a/Jellyfin.Api/Models/MediaInfoDtos/OpenLiveStreamDto.cs +++ b/Jellyfin.Api/Models/MediaInfoDtos/OpenLiveStreamDto.cs @@ -1,4 +1,5 @@ -using System.Diagnostics.CodeAnalysis; +using System; +using System.Collections.Generic; using MediaBrowser.Model.Dlna; using MediaBrowser.Model.MediaInfo; @@ -9,6 +10,61 @@ namespace Jellyfin.Api.Models.MediaInfoDtos /// </summary> public class OpenLiveStreamDto { + /// <summary> + /// Gets or sets the open token. + /// </summary> + public string? OpenToken { get; set; } + + /// <summary> + /// Gets or sets the user id. + /// </summary> + public Guid? UserId { get; set; } + + /// <summary> + /// Gets or sets the play session id. + /// </summary> + public string? PlaySessionId { get; set; } + + /// <summary> + /// Gets or sets the max streaming bitrate. + /// </summary> + public int? MaxStreamingBitrate { get; set; } + + /// <summary> + /// Gets or sets the start time in ticks. + /// </summary> + public long? StartTimeTicks { get; set; } + + /// <summary> + /// Gets or sets the audio stream index. + /// </summary> + public int? AudioStreamIndex { get; set; } + + /// <summary> + /// Gets or sets the subtitle stream index. + /// </summary> + public int? SubtitleStreamIndex { get; set; } + + /// <summary> + /// Gets or sets the max audio channels. + /// </summary> + public int? MaxAudioChannels { get; set; } + + /// <summary> + /// Gets or sets the item id. + /// </summary> + public Guid? ItemId { get; set; } + + /// <summary> + /// Gets or sets a value indicating whether to enable direct play. + /// </summary> + public bool? EnableDirectPlay { get; set; } + + /// <summary> + /// Gets or sets a value indicating whether to enale direct stream. + /// </summary> + public bool? EnableDirectStream { get; set; } + /// <summary> /// Gets or sets the device profile. /// </summary> @@ -17,8 +73,6 @@ namespace Jellyfin.Api.Models.MediaInfoDtos /// <summary> /// Gets or sets the device play protocols. /// </summary> - [SuppressMessage("Microsoft.Performance", "CA1819:DontReturnArrays", MessageId = "DevicePlayProtocols", Justification = "Imported from ServiceStack")] - [SuppressMessage("Microsoft.Performance", "SA1011:ClosingBracketsSpace", MessageId = "DevicePlayProtocols", Justification = "Imported from ServiceStack")] - public MediaProtocol[]? DirectPlayProtocols { get; set; } + public IReadOnlyList<MediaProtocol> DirectPlayProtocols { get; set; } = Array.Empty<MediaProtocol>(); } } diff --git a/Jellyfin.Api/Models/MediaInfoDtos/PlaybackInfoDto.cs b/Jellyfin.Api/Models/MediaInfoDtos/PlaybackInfoDto.cs new file mode 100644 index 0000000000..2cfdba507e --- /dev/null +++ b/Jellyfin.Api/Models/MediaInfoDtos/PlaybackInfoDto.cs @@ -0,0 +1,86 @@ +using System; +using MediaBrowser.Model.Dlna; + +namespace Jellyfin.Api.Models.MediaInfoDtos +{ + /// <summary> + /// Plabyback info dto. + /// </summary> + public class PlaybackInfoDto + { + /// <summary> + /// Gets or sets the playback userId. + /// </summary> + public Guid? UserId { get; set; } + + /// <summary> + /// Gets or sets the max streaming bitrate. + /// </summary> + public int? MaxStreamingBitrate { get; set; } + + /// <summary> + /// Gets or sets the start time in ticks. + /// </summary> + public long? StartTimeTicks { get; set; } + + /// <summary> + /// Gets or sets the audio stream index. + /// </summary> + public int? AudioStreamIndex { get; set; } + + /// <summary> + /// Gets or sets the subtitle stream index. + /// </summary> + public int? SubtitleStreamIndex { get; set; } + + /// <summary> + /// Gets or sets the max audio channels. + /// </summary> + public int? MaxAudioChannels { get; set; } + + /// <summary> + /// Gets or sets the media source id. + /// </summary> + public string? MediaSourceId { get; set; } + + /// <summary> + /// Gets or sets the live stream id. + /// </summary> + public string? LiveStreamId { get; set; } + + /// <summary> + /// Gets or sets the device profile. + /// </summary> + public DeviceProfile? DeviceProfile { get; set; } + + /// <summary> + /// Gets or sets a value indicating whether to enable direct play. + /// </summary> + public bool? EnableDirectPlay { get; set; } + + /// <summary> + /// Gets or sets a value indicating whether to enable direct stream. + /// </summary> + public bool? EnableDirectStream { get; set; } + + /// <summary> + /// Gets or sets a value indicating whether to enable transcoding. + /// </summary> + public bool? EnableTranscoding { get; set; } + + /// <summary> + /// Gets or sets a value indicating whether to enable video stream copy. + /// </summary> + public bool? AllowVideoStreamCopy { get; set; } + + /// <summary> + /// Gets or sets a value indicating whether to allow audio stream copy. + /// </summary> + public bool? AllowAudioStreamCopy { get; set; } + + /// <summary> + /// Gets or sets a value indicating whether to auto open the live stream. + /// </summary> + public bool? AutoOpenLiveStream { get; set; } + } +} \ No newline at end of file diff --git a/Jellyfin.Api/Models/NotificationDtos/AdminNotificationDto.cs b/Jellyfin.Api/Models/NotificationDtos/AdminNotificationDto.cs new file mode 100644 index 0000000000..2c3a6282f2 --- /dev/null +++ b/Jellyfin.Api/Models/NotificationDtos/AdminNotificationDto.cs @@ -0,0 +1,30 @@ +using MediaBrowser.Model.Notifications; + +namespace Jellyfin.Api.Models.NotificationDtos +{ + /// <summary> + /// The admin notification dto. + /// </summary> + public class AdminNotificationDto + { + /// <summary> + /// Gets or sets the notification name. + /// </summary> + public string? Name { get; set; } + + /// <summary> + /// Gets or sets the notification description. + /// </summary> + public string? Description { get; set; } + + /// <summary> + /// Gets or sets the notification level. + /// </summary> + public NotificationLevel? NotificationLevel { get; set; } + + /// <summary> + /// Gets or sets the notification url. + /// </summary> + public string? Url { get; set; } + } +} diff --git a/Jellyfin.Api/Models/PlaybackDtos/TranscodingJobDto.cs b/Jellyfin.Api/Models/PlaybackDtos/TranscodingJobDto.cs index b9507a4e50..291e571dcb 100644 --- a/Jellyfin.Api/Models/PlaybackDtos/TranscodingJobDto.cs +++ b/Jellyfin.Api/Models/PlaybackDtos/TranscodingJobDto.cs @@ -11,7 +11,7 @@ namespace Jellyfin.Api.Models.PlaybackDtos /// <summary> /// Class TranscodingJob. /// </summary> - public class TranscodingJobDto + public class TranscodingJobDto : IDisposable { /// <summary> /// The process lock. @@ -196,7 +196,7 @@ namespace Jellyfin.Api.Models.PlaybackDtos /// Start kill timer. /// </summary> /// <param name="callback">Callback action.</param> - public void StartKillTimer(Action<object> callback) + public void StartKillTimer(Action<object?> callback) { StartKillTimer(callback, PingTimeout); } @@ -206,7 +206,7 @@ namespace Jellyfin.Api.Models.PlaybackDtos /// </summary> /// <param name="callback">Callback action.</param> /// <param name="intervalMs">Callback interval.</param> - public void StartKillTimer(Action<object> callback, int intervalMs) + public void StartKillTimer(Action<object?> callback, int intervalMs) { if (HasExited) { @@ -249,5 +249,31 @@ namespace Jellyfin.Api.Models.PlaybackDtos } } } + + /// <inheritdoc /> + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + /// <summary> + /// Dispose all resources. + /// </summary> + /// <param name="disposing">Whether to dispose all resources.</param> + protected virtual void Dispose(bool disposing) + { + if (disposing) + { + Process?.Dispose(); + Process = null; + KillTimer?.Dispose(); + KillTimer = null; + CancellationTokenSource?.Dispose(); + CancellationTokenSource = null; + TranscodingThrottler?.Dispose(); + TranscodingThrottler = null; + } + } } } diff --git a/Jellyfin.Api/Models/PlaybackDtos/TranscodingThrottler.cs b/Jellyfin.Api/Models/PlaybackDtos/TranscodingThrottler.cs index b5e42ea299..7b32d76ba7 100644 --- a/Jellyfin.Api/Models/PlaybackDtos/TranscodingThrottler.cs +++ b/Jellyfin.Api/Models/PlaybackDtos/TranscodingThrottler.cs @@ -98,10 +98,10 @@ namespace Jellyfin.Api.Models.PlaybackDtos private EncodingOptions GetOptions() { - return _config.GetConfiguration<EncodingOptions>("encoding"); + return _config.GetEncodingOptions(); } - private async void TimerCallback(object state) + private async void TimerCallback(object? state) { if (_job.HasExited) { @@ -145,7 +145,8 @@ namespace Jellyfin.Api.Models.PlaybackDtos var transcodingPositionTicks = job.TranscodingPositionTicks ?? 0; var downloadPositionTicks = job.DownloadPositionTicks ?? 0; - var path = job.Path; + var path = job.Path ?? throw new ArgumentException("Path can't be null."); + var gapLengthInTicks = TimeSpan.FromSeconds(thresholdSeconds).Ticks; if (downloadPositionTicks > 0 && transcodingPositionTicks > 0) diff --git a/Jellyfin.Api/Models/PlaylistDtos/CreatePlaylistDto.cs b/Jellyfin.Api/Models/PlaylistDtos/CreatePlaylistDto.cs index 0d67c86f71..0761b20855 100644 --- a/Jellyfin.Api/Models/PlaylistDtos/CreatePlaylistDto.cs +++ b/Jellyfin.Api/Models/PlaylistDtos/CreatePlaylistDto.cs @@ -1,4 +1,7 @@ using System; +using System.Collections.Generic; +using System.Text.Json.Serialization; +using Jellyfin.Extensions.Json.Converters; namespace Jellyfin.Api.Models.PlaylistDtos { @@ -15,12 +18,13 @@ namespace Jellyfin.Api.Models.PlaylistDtos /// <summary> /// Gets or sets item ids to add to the playlist. /// </summary> - public string? Ids { get; set; } + [JsonConverter(typeof(JsonCommaDelimitedArrayConverterFactory))] + public IReadOnlyList<Guid> Ids { get; set; } = Array.Empty<Guid>(); /// <summary> /// Gets or sets the user id. /// </summary> - public Guid UserId { get; set; } + public Guid? UserId { get; set; } /// <summary> /// Gets or sets the media type. diff --git a/Jellyfin.Api/Models/SessionDtos/ClientCapabilitiesDto.cs b/Jellyfin.Api/Models/SessionDtos/ClientCapabilitiesDto.cs new file mode 100644 index 0000000000..fa62472e1e --- /dev/null +++ b/Jellyfin.Api/Models/SessionDtos/ClientCapabilitiesDto.cs @@ -0,0 +1,88 @@ +using System; +using System.Collections.Generic; +using System.Text.Json.Serialization; +using Jellyfin.Extensions.Json.Converters; +using MediaBrowser.Model.Dlna; +using MediaBrowser.Model.Session; + +namespace Jellyfin.Api.Models.SessionDtos +{ + /// <summary> + /// Client capabilities dto. + /// </summary> + public class ClientCapabilitiesDto + { + /// <summary> + /// Gets or sets the list of playable media types. + /// </summary> + [JsonConverter(typeof(JsonCommaDelimitedArrayConverterFactory))] + public IReadOnlyList<string> PlayableMediaTypes { get; set; } = Array.Empty<string>(); + + /// <summary> + /// Gets or sets the list of supported commands. + /// </summary> + [JsonConverter(typeof(JsonCommaDelimitedArrayConverterFactory))] + public IReadOnlyList<GeneralCommandType> SupportedCommands { get; set; } = Array.Empty<GeneralCommandType>(); + + /// <summary> + /// Gets or sets a value indicating whether session supports media control. + /// </summary> + public bool SupportsMediaControl { get; set; } + + /// <summary> + /// Gets or sets a value indicating whether session supports content uploading. + /// </summary> + public bool SupportsContentUploading { get; set; } + + /// <summary> + /// Gets or sets the message callback url. + /// </summary> + public string? MessageCallbackUrl { get; set; } + + /// <summary> + /// Gets or sets a value indicating whether session supports a persistent identifier. + /// </summary> + public bool SupportsPersistentIdentifier { get; set; } + + /// <summary> + /// Gets or sets a value indicating whether session supports sync. + /// </summary> + public bool SupportsSync { get; set; } + + /// <summary> + /// Gets or sets the device profile. + /// </summary> + public DeviceProfile? DeviceProfile { get; set; } + + /// <summary> + /// Gets or sets the app store url. + /// </summary> + public string? AppStoreUrl { get; set; } + + /// <summary> + /// Gets or sets the icon url. + /// </summary> + public string? IconUrl { get; set; } + + /// <summary> + /// Convert the dto to the full <see cref="ClientCapabilities"/> model. + /// </summary> + /// <returns>The converted <see cref="ClientCapabilities"/> model.</returns> + public ClientCapabilities ToClientCapabilities() + { + return new ClientCapabilities + { + PlayableMediaTypes = PlayableMediaTypes, + SupportedCommands = SupportedCommands, + SupportsMediaControl = SupportsMediaControl, + SupportsContentUploading = SupportsContentUploading, + MessageCallbackUrl = MessageCallbackUrl, + SupportsPersistentIdentifier = SupportsPersistentIdentifier, + SupportsSync = SupportsSync, + DeviceProfile = DeviceProfile, + AppStoreUrl = AppStoreUrl, + IconUrl = IconUrl + }; + } + } +} diff --git a/Jellyfin.Api/Models/SubtitleDtos/UploadSubtitleDto.cs b/Jellyfin.Api/Models/SubtitleDtos/UploadSubtitleDto.cs new file mode 100644 index 0000000000..30473255e8 --- /dev/null +++ b/Jellyfin.Api/Models/SubtitleDtos/UploadSubtitleDto.cs @@ -0,0 +1,34 @@ +using System.ComponentModel.DataAnnotations; + +namespace Jellyfin.Api.Models.SubtitleDtos +{ + /// <summary> + /// Upload subtitles dto. + /// </summary> + public class UploadSubtitleDto + { + /// <summary> + /// Gets or sets the subtitle language. + /// </summary> + [Required] + public string Language { get; set; } = string.Empty; + + /// <summary> + /// Gets or sets the subtitle format. + /// </summary> + [Required] + public string Format { get; set; } = string.Empty; + + /// <summary> + /// Gets or sets a value indicating whether the subtitle is forced. + /// </summary> + [Required] + public bool IsForced { get; set; } + + /// <summary> + /// Gets or sets the subtitle data. + /// </summary> + [Required] + public string Data { get; set; } = string.Empty; + } +} \ No newline at end of file diff --git a/Jellyfin.Api/Models/SyncPlayDtos/BufferRequestDto.cs b/Jellyfin.Api/Models/SyncPlayDtos/BufferRequestDto.cs new file mode 100644 index 0000000000..479c440840 --- /dev/null +++ b/Jellyfin.Api/Models/SyncPlayDtos/BufferRequestDto.cs @@ -0,0 +1,42 @@ +using System; + +namespace Jellyfin.Api.Models.SyncPlayDtos +{ + /// <summary> + /// Class BufferRequestDto. + /// </summary> + public class BufferRequestDto + { + /// <summary> + /// Initializes a new instance of the <see cref="BufferRequestDto"/> class. + /// </summary> + public BufferRequestDto() + { + PlaylistItemId = Guid.Empty; + } + + /// <summary> + /// Gets or sets when the request has been made by the client. + /// </summary> + /// <value>The date of the request.</value> + public DateTime When { get; set; } + + /// <summary> + /// Gets or sets the position ticks. + /// </summary> + /// <value>The position ticks.</value> + public long PositionTicks { get; set; } + + /// <summary> + /// Gets or sets a value indicating whether the client playback is unpaused. + /// </summary> + /// <value>The client playback status.</value> + public bool IsPlaying { get; set; } + + /// <summary> + /// Gets or sets the playlist item identifier of the playing item. + /// </summary> + /// <value>The playlist item identifier.</value> + public Guid PlaylistItemId { get; set; } + } +} diff --git a/Jellyfin.Api/Models/SyncPlayDtos/IgnoreWaitRequestDto.cs b/Jellyfin.Api/Models/SyncPlayDtos/IgnoreWaitRequestDto.cs new file mode 100644 index 0000000000..4c30b7be43 --- /dev/null +++ b/Jellyfin.Api/Models/SyncPlayDtos/IgnoreWaitRequestDto.cs @@ -0,0 +1,14 @@ +namespace Jellyfin.Api.Models.SyncPlayDtos +{ + /// <summary> + /// Class IgnoreWaitRequestDto. + /// </summary> + public class IgnoreWaitRequestDto + { + /// <summary> + /// Gets or sets a value indicating whether the client should be ignored. + /// </summary> + /// <value>The client group-wait status.</value> + public bool IgnoreWait { get; set; } + } +} diff --git a/Jellyfin.Api/Models/SyncPlayDtos/JoinGroupRequestDto.cs b/Jellyfin.Api/Models/SyncPlayDtos/JoinGroupRequestDto.cs new file mode 100644 index 0000000000..ed97b8d6a5 --- /dev/null +++ b/Jellyfin.Api/Models/SyncPlayDtos/JoinGroupRequestDto.cs @@ -0,0 +1,16 @@ +using System; + +namespace Jellyfin.Api.Models.SyncPlayDtos +{ + /// <summary> + /// Class JoinGroupRequestDto. + /// </summary> + public class JoinGroupRequestDto + { + /// <summary> + /// Gets or sets the group identifier. + /// </summary> + /// <value>The identifier of the group to join.</value> + public Guid GroupId { get; set; } + } +} diff --git a/Jellyfin.Api/Models/SyncPlayDtos/MovePlaylistItemRequestDto.cs b/Jellyfin.Api/Models/SyncPlayDtos/MovePlaylistItemRequestDto.cs new file mode 100644 index 0000000000..3af25f3e3e --- /dev/null +++ b/Jellyfin.Api/Models/SyncPlayDtos/MovePlaylistItemRequestDto.cs @@ -0,0 +1,30 @@ +using System; + +namespace Jellyfin.Api.Models.SyncPlayDtos +{ + /// <summary> + /// Class MovePlaylistItemRequestDto. + /// </summary> + public class MovePlaylistItemRequestDto + { + /// <summary> + /// Initializes a new instance of the <see cref="MovePlaylistItemRequestDto"/> class. + /// </summary> + public MovePlaylistItemRequestDto() + { + PlaylistItemId = Guid.Empty; + } + + /// <summary> + /// Gets or sets the playlist identifier of the item. + /// </summary> + /// <value>The playlist identifier of the item.</value> + public Guid PlaylistItemId { get; set; } + + /// <summary> + /// Gets or sets the new position. + /// </summary> + /// <value>The new position.</value> + public int NewIndex { get; set; } + } +} diff --git a/Jellyfin.Api/Models/SyncPlayDtos/NewGroupRequestDto.cs b/Jellyfin.Api/Models/SyncPlayDtos/NewGroupRequestDto.cs new file mode 100644 index 0000000000..441d7be367 --- /dev/null +++ b/Jellyfin.Api/Models/SyncPlayDtos/NewGroupRequestDto.cs @@ -0,0 +1,22 @@ +namespace Jellyfin.Api.Models.SyncPlayDtos +{ + /// <summary> + /// Class NewGroupRequestDto. + /// </summary> + public class NewGroupRequestDto + { + /// <summary> + /// Initializes a new instance of the <see cref="NewGroupRequestDto"/> class. + /// </summary> + public NewGroupRequestDto() + { + GroupName = string.Empty; + } + + /// <summary> + /// Gets or sets the group name. + /// </summary> + /// <value>The name of the new group.</value> + public string GroupName { get; set; } + } +} diff --git a/Jellyfin.Api/Models/SyncPlayDtos/NextItemRequestDto.cs b/Jellyfin.Api/Models/SyncPlayDtos/NextItemRequestDto.cs new file mode 100644 index 0000000000..f59a93f13d --- /dev/null +++ b/Jellyfin.Api/Models/SyncPlayDtos/NextItemRequestDto.cs @@ -0,0 +1,24 @@ +using System; + +namespace Jellyfin.Api.Models.SyncPlayDtos +{ + /// <summary> + /// Class NextItemRequestDto. + /// </summary> + public class NextItemRequestDto + { + /// <summary> + /// Initializes a new instance of the <see cref="NextItemRequestDto"/> class. + /// </summary> + public NextItemRequestDto() + { + PlaylistItemId = Guid.Empty; + } + + /// <summary> + /// Gets or sets the playing item identifier. + /// </summary> + /// <value>The playing item identifier.</value> + public Guid PlaylistItemId { get; set; } + } +} diff --git a/Jellyfin.Api/Models/SyncPlayDtos/PingRequestDto.cs b/Jellyfin.Api/Models/SyncPlayDtos/PingRequestDto.cs new file mode 100644 index 0000000000..c4ac068565 --- /dev/null +++ b/Jellyfin.Api/Models/SyncPlayDtos/PingRequestDto.cs @@ -0,0 +1,14 @@ +namespace Jellyfin.Api.Models.SyncPlayDtos +{ + /// <summary> + /// Class PingRequestDto. + /// </summary> + public class PingRequestDto + { + /// <summary> + /// Gets or sets the ping time. + /// </summary> + /// <value>The ping time.</value> + public long Ping { get; set; } + } +} diff --git a/Jellyfin.Api/Models/SyncPlayDtos/PlayRequestDto.cs b/Jellyfin.Api/Models/SyncPlayDtos/PlayRequestDto.cs new file mode 100644 index 0000000000..844388cd99 --- /dev/null +++ b/Jellyfin.Api/Models/SyncPlayDtos/PlayRequestDto.cs @@ -0,0 +1,37 @@ +using System; +using System.Collections.Generic; + +namespace Jellyfin.Api.Models.SyncPlayDtos +{ + /// <summary> + /// Class PlayRequestDto. + /// </summary> + public class PlayRequestDto + { + /// <summary> + /// Initializes a new instance of the <see cref="PlayRequestDto"/> class. + /// </summary> + public PlayRequestDto() + { + PlayingQueue = Array.Empty<Guid>(); + } + + /// <summary> + /// Gets or sets the playing queue. + /// </summary> + /// <value>The playing queue.</value> + public IReadOnlyList<Guid> PlayingQueue { get; set; } + + /// <summary> + /// Gets or sets the position of the playing item in the queue. + /// </summary> + /// <value>The playing item position.</value> + public int PlayingItemPosition { get; set; } + + /// <summary> + /// Gets or sets the start position ticks. + /// </summary> + /// <value>The start position ticks.</value> + public long StartPositionTicks { get; set; } + } +} diff --git a/Jellyfin.Api/Models/SyncPlayDtos/PreviousItemRequestDto.cs b/Jellyfin.Api/Models/SyncPlayDtos/PreviousItemRequestDto.cs new file mode 100644 index 0000000000..7fd4a49be2 --- /dev/null +++ b/Jellyfin.Api/Models/SyncPlayDtos/PreviousItemRequestDto.cs @@ -0,0 +1,24 @@ +using System; + +namespace Jellyfin.Api.Models.SyncPlayDtos +{ + /// <summary> + /// Class PreviousItemRequestDto. + /// </summary> + public class PreviousItemRequestDto + { + /// <summary> + /// Initializes a new instance of the <see cref="PreviousItemRequestDto"/> class. + /// </summary> + public PreviousItemRequestDto() + { + PlaylistItemId = Guid.Empty; + } + + /// <summary> + /// Gets or sets the playing item identifier. + /// </summary> + /// <value>The playing item identifier.</value> + public Guid PlaylistItemId { get; set; } + } +} diff --git a/Jellyfin.Api/Models/SyncPlayDtos/QueueRequestDto.cs b/Jellyfin.Api/Models/SyncPlayDtos/QueueRequestDto.cs new file mode 100644 index 0000000000..2b187f443f --- /dev/null +++ b/Jellyfin.Api/Models/SyncPlayDtos/QueueRequestDto.cs @@ -0,0 +1,32 @@ +using System; +using System.Collections.Generic; +using MediaBrowser.Model.SyncPlay; + +namespace Jellyfin.Api.Models.SyncPlayDtos +{ + /// <summary> + /// Class QueueRequestDto. + /// </summary> + public class QueueRequestDto + { + /// <summary> + /// Initializes a new instance of the <see cref="QueueRequestDto"/> class. + /// </summary> + public QueueRequestDto() + { + ItemIds = Array.Empty<Guid>(); + } + + /// <summary> + /// Gets or sets the items to enqueue. + /// </summary> + /// <value>The items to enqueue.</value> + public IReadOnlyList<Guid> ItemIds { get; set; } + + /// <summary> + /// Gets or sets the mode in which to add the new items. + /// </summary> + /// <value>The enqueue mode.</value> + public GroupQueueMode Mode { get; set; } + } +} diff --git a/Jellyfin.Api/Models/SyncPlayDtos/ReadyRequestDto.cs b/Jellyfin.Api/Models/SyncPlayDtos/ReadyRequestDto.cs new file mode 100644 index 0000000000..d9c193016a --- /dev/null +++ b/Jellyfin.Api/Models/SyncPlayDtos/ReadyRequestDto.cs @@ -0,0 +1,42 @@ +using System; + +namespace Jellyfin.Api.Models.SyncPlayDtos +{ + /// <summary> + /// Class ReadyRequest. + /// </summary> + public class ReadyRequestDto + { + /// <summary> + /// Initializes a new instance of the <see cref="ReadyRequestDto"/> class. + /// </summary> + public ReadyRequestDto() + { + PlaylistItemId = Guid.Empty; + } + + /// <summary> + /// Gets or sets when the request has been made by the client. + /// </summary> + /// <value>The date of the request.</value> + public DateTime When { get; set; } + + /// <summary> + /// Gets or sets the position ticks. + /// </summary> + /// <value>The position ticks.</value> + public long PositionTicks { get; set; } + + /// <summary> + /// Gets or sets a value indicating whether the client playback is unpaused. + /// </summary> + /// <value>The client playback status.</value> + public bool IsPlaying { get; set; } + + /// <summary> + /// Gets or sets the playlist item identifier of the playing item. + /// </summary> + /// <value>The playlist item identifier.</value> + public Guid PlaylistItemId { get; set; } + } +} diff --git a/Jellyfin.Api/Models/SyncPlayDtos/RemoveFromPlaylistRequestDto.cs b/Jellyfin.Api/Models/SyncPlayDtos/RemoveFromPlaylistRequestDto.cs new file mode 100644 index 0000000000..e9b2b2cb37 --- /dev/null +++ b/Jellyfin.Api/Models/SyncPlayDtos/RemoveFromPlaylistRequestDto.cs @@ -0,0 +1,25 @@ +using System; +using System.Collections.Generic; + +namespace Jellyfin.Api.Models.SyncPlayDtos +{ + /// <summary> + /// Class RemoveFromPlaylistRequestDto. + /// </summary> + public class RemoveFromPlaylistRequestDto + { + /// <summary> + /// Initializes a new instance of the <see cref="RemoveFromPlaylistRequestDto"/> class. + /// </summary> + public RemoveFromPlaylistRequestDto() + { + PlaylistItemIds = Array.Empty<Guid>(); + } + + /// <summary> + /// Gets or sets the playlist identifiers ot the items. + /// </summary> + /// <value>The playlist identifiers ot the items.</value> + public IReadOnlyList<Guid> PlaylistItemIds { get; set; } + } +} diff --git a/Jellyfin.Api/Models/SyncPlayDtos/SeekRequestDto.cs b/Jellyfin.Api/Models/SyncPlayDtos/SeekRequestDto.cs new file mode 100644 index 0000000000..b9af0be7ff --- /dev/null +++ b/Jellyfin.Api/Models/SyncPlayDtos/SeekRequestDto.cs @@ -0,0 +1,14 @@ +namespace Jellyfin.Api.Models.SyncPlayDtos +{ + /// <summary> + /// Class SeekRequestDto. + /// </summary> + public class SeekRequestDto + { + /// <summary> + /// Gets or sets the position ticks. + /// </summary> + /// <value>The position ticks.</value> + public long PositionTicks { get; set; } + } +} diff --git a/Jellyfin.Api/Models/SyncPlayDtos/SetPlaylistItemRequestDto.cs b/Jellyfin.Api/Models/SyncPlayDtos/SetPlaylistItemRequestDto.cs new file mode 100644 index 0000000000..b937679fc1 --- /dev/null +++ b/Jellyfin.Api/Models/SyncPlayDtos/SetPlaylistItemRequestDto.cs @@ -0,0 +1,24 @@ +using System; + +namespace Jellyfin.Api.Models.SyncPlayDtos +{ + /// <summary> + /// Class SetPlaylistItemRequestDto. + /// </summary> + public class SetPlaylistItemRequestDto + { + /// <summary> + /// Initializes a new instance of the <see cref="SetPlaylistItemRequestDto"/> class. + /// </summary> + public SetPlaylistItemRequestDto() + { + PlaylistItemId = Guid.Empty; + } + + /// <summary> + /// Gets or sets the playlist identifier of the playing item. + /// </summary> + /// <value>The playlist identifier of the playing item.</value> + public Guid PlaylistItemId { get; set; } + } +} diff --git a/Jellyfin.Api/Models/SyncPlayDtos/SetRepeatModeRequestDto.cs b/Jellyfin.Api/Models/SyncPlayDtos/SetRepeatModeRequestDto.cs new file mode 100644 index 0000000000..e748fc3e0f --- /dev/null +++ b/Jellyfin.Api/Models/SyncPlayDtos/SetRepeatModeRequestDto.cs @@ -0,0 +1,16 @@ +using MediaBrowser.Model.SyncPlay; + +namespace Jellyfin.Api.Models.SyncPlayDtos +{ + /// <summary> + /// Class SetRepeatModeRequestDto. + /// </summary> + public class SetRepeatModeRequestDto + { + /// <summary> + /// Gets or sets the repeat mode. + /// </summary> + /// <value>The repeat mode.</value> + public GroupRepeatMode Mode { get; set; } + } +} diff --git a/Jellyfin.Api/Models/SyncPlayDtos/SetShuffleModeRequestDto.cs b/Jellyfin.Api/Models/SyncPlayDtos/SetShuffleModeRequestDto.cs new file mode 100644 index 0000000000..0e427f4a4d --- /dev/null +++ b/Jellyfin.Api/Models/SyncPlayDtos/SetShuffleModeRequestDto.cs @@ -0,0 +1,16 @@ +using MediaBrowser.Model.SyncPlay; + +namespace Jellyfin.Api.Models.SyncPlayDtos +{ + /// <summary> + /// Class SetShuffleModeRequestDto. + /// </summary> + public class SetShuffleModeRequestDto + { + /// <summary> + /// Gets or sets the shuffle mode. + /// </summary> + /// <value>The shuffle mode.</value> + public GroupShuffleMode Mode { get; set; } + } +} diff --git a/Jellyfin.Api/Models/UserDtos/AuthenticateUserByName.cs b/Jellyfin.Api/Models/UserDtos/AuthenticateUserByName.cs index 3936274356..41f7b169eb 100644 --- a/Jellyfin.Api/Models/UserDtos/AuthenticateUserByName.cs +++ b/Jellyfin.Api/Models/UserDtos/AuthenticateUserByName.cs @@ -1,4 +1,6 @@ -namespace Jellyfin.Api.Models.UserDtos +using System; + +namespace Jellyfin.Api.Models.UserDtos { /// <summary> /// The authenticate user by name request body. @@ -18,6 +20,7 @@ /// <summary> /// Gets or sets the sha1-hashed password. /// </summary> + [Obsolete("Send password using pw field")] public string? Password { get; set; } } } diff --git a/Jellyfin.Api/Models/UserDtos/ForgotPasswordPinDto.cs b/Jellyfin.Api/Models/UserDtos/ForgotPasswordPinDto.cs new file mode 100644 index 0000000000..62780e23c5 --- /dev/null +++ b/Jellyfin.Api/Models/UserDtos/ForgotPasswordPinDto.cs @@ -0,0 +1,16 @@ +using System.ComponentModel.DataAnnotations; + +namespace Jellyfin.Api.Models.UserDtos +{ + /// <summary> + /// Forgot Password Pin enter request body DTO. + /// </summary> + public class ForgotPasswordPinDto + { + /// <summary> + /// Gets or sets the entered pin to have the password reset. + /// </summary> + [Required] + public string? Pin { get; set; } + } +} diff --git a/Jellyfin.Api/Models/VideoDtos/DeviceProfileDto.cs b/Jellyfin.Api/Models/VideoDtos/DeviceProfileDto.cs deleted file mode 100644 index db55dc34b5..0000000000 --- a/Jellyfin.Api/Models/VideoDtos/DeviceProfileDto.cs +++ /dev/null @@ -1,15 +0,0 @@ -using MediaBrowser.Model.Dlna; - -namespace Jellyfin.Api.Models.VideoDtos -{ - /// <summary> - /// Device profile dto. - /// </summary> - public class DeviceProfileDto - { - /// <summary> - /// Gets or sets device profile. - /// </summary> - public DeviceProfile? DeviceProfile { get; set; } - } -} diff --git a/Jellyfin.Api/TypeConverters/DateTimeTypeConverter.cs b/Jellyfin.Api/TypeConverters/DateTimeTypeConverter.cs deleted file mode 100644 index 315b473293..0000000000 --- a/Jellyfin.Api/TypeConverters/DateTimeTypeConverter.cs +++ /dev/null @@ -1,44 +0,0 @@ -using System; -using System.ComponentModel; -using System.Globalization; - -namespace Jellyfin.Api.TypeConverters -{ - /// <summary> - /// Custom datetime parser. - /// </summary> - public class DateTimeTypeConverter : TypeConverter - { - /// <inheritdoc /> - public override bool CanConvertFrom(ITypeDescriptorContext context, Type sourceType) - { - if (sourceType == typeof(string)) - { - return true; - } - - return base.CanConvertFrom(context, sourceType); - } - - /// <inheritdoc /> - public override object ConvertFrom(ITypeDescriptorContext context, CultureInfo culture, object value) - { - if (value is string dateString) - { - // Mark Played Item. - if (DateTime.TryParseExact(dateString, "yyyyMMddHHmmss", CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var dateTime)) - { - return dateTime; - } - - // Get Activity Logs. - if (DateTime.TryParse(dateString, null, DateTimeStyles.RoundtripKind, out dateTime)) - { - return dateTime; - } - } - - return base.ConvertFrom(context, culture, value); - } - } -} diff --git a/Jellyfin.Api/WebSocketListeners/ActivityLogWebSocketListener.cs b/Jellyfin.Api/WebSocketListeners/ActivityLogWebSocketListener.cs index 77d55828d1..288e03fcff 100644 --- a/Jellyfin.Api/WebSocketListeners/ActivityLogWebSocketListener.cs +++ b/Jellyfin.Api/WebSocketListeners/ActivityLogWebSocketListener.cs @@ -56,9 +56,9 @@ namespace Jellyfin.Api.WebSocketListeners base.Dispose(dispose); } - private void OnEntryCreated(object sender, GenericEventArgs<ActivityLogEntry> e) + private void OnEntryCreated(object? sender, GenericEventArgs<ActivityLogEntry> e) { - SendData(true); + SendData(true).GetAwaiter().GetResult(); } } } diff --git a/Jellyfin.Api/WebSocketListeners/ScheduledTasksWebSocketListener.cs b/Jellyfin.Api/WebSocketListeners/ScheduledTasksWebSocketListener.cs index 80314b9236..94df23e569 100644 --- a/Jellyfin.Api/WebSocketListeners/ScheduledTasksWebSocketListener.cs +++ b/Jellyfin.Api/WebSocketListeners/ScheduledTasksWebSocketListener.cs @@ -64,19 +64,19 @@ namespace Jellyfin.Api.WebSocketListeners base.Dispose(dispose); } - private void OnTaskCompleted(object sender, TaskCompletionEventArgs e) + private void OnTaskCompleted(object? sender, TaskCompletionEventArgs e) { SendData(true); e.Task.TaskProgress -= OnTaskProgress; } - private void OnTaskExecuting(object sender, GenericEventArgs<IScheduledTaskWorker> e) + private void OnTaskExecuting(object? sender, GenericEventArgs<IScheduledTaskWorker> e) { SendData(true); e.Argument.TaskProgress += OnTaskProgress; } - private void OnTaskProgress(object sender, GenericEventArgs<double> e) + private void OnTaskProgress(object? sender, GenericEventArgs<double> e) { SendData(false); } diff --git a/Jellyfin.Api/WebSocketListeners/SessionInfoWebSocketListener.cs b/Jellyfin.Api/WebSocketListeners/SessionInfoWebSocketListener.cs index 1cf43a0053..d996ac69f9 100644 --- a/Jellyfin.Api/WebSocketListeners/SessionInfoWebSocketListener.cs +++ b/Jellyfin.Api/WebSocketListeners/SessionInfoWebSocketListener.cs @@ -66,37 +66,37 @@ namespace Jellyfin.Api.WebSocketListeners base.Dispose(dispose); } - private async void OnSessionManagerSessionActivity(object sender, SessionEventArgs e) + private async void OnSessionManagerSessionActivity(object? sender, SessionEventArgs e) { await SendData(false).ConfigureAwait(false); } - private async void OnSessionManagerCapabilitiesChanged(object sender, SessionEventArgs e) + private async void OnSessionManagerCapabilitiesChanged(object? sender, SessionEventArgs e) { await SendData(true).ConfigureAwait(false); } - private async void OnSessionManagerPlaybackProgress(object sender, PlaybackProgressEventArgs e) + private async void OnSessionManagerPlaybackProgress(object? sender, PlaybackProgressEventArgs e) { await SendData(!e.IsAutomated).ConfigureAwait(false); } - private async void OnSessionManagerPlaybackStopped(object sender, PlaybackStopEventArgs e) + private async void OnSessionManagerPlaybackStopped(object? sender, PlaybackStopEventArgs e) { await SendData(true).ConfigureAwait(false); } - private async void OnSessionManagerPlaybackStart(object sender, PlaybackProgressEventArgs e) + private async void OnSessionManagerPlaybackStart(object? sender, PlaybackProgressEventArgs e) { await SendData(true).ConfigureAwait(false); } - private async void OnSessionManagerSessionEnded(object sender, SessionEventArgs e) + private async void OnSessionManagerSessionEnded(object? sender, SessionEventArgs e) { await SendData(true).ConfigureAwait(false); } - private async void OnSessionManagerSessionStarted(object sender, SessionEventArgs e) + private async void OnSessionManagerSessionStarted(object? sender, SessionEventArgs e) { await SendData(true).ConfigureAwait(false); } diff --git a/Jellyfin.Data/DayOfWeekHelper.cs b/Jellyfin.Data/DayOfWeekHelper.cs index 4e75f4cfd1..b7ba30180e 100644 --- a/Jellyfin.Data/DayOfWeekHelper.cs +++ b/Jellyfin.Data/DayOfWeekHelper.cs @@ -1,67 +1,21 @@ #pragma warning disable CS1591 using System; -using System.Collections.Generic; using Jellyfin.Data.Enums; namespace Jellyfin.Data { public static class DayOfWeekHelper { - public static List<DayOfWeek> GetDaysOfWeek(DynamicDayOfWeek day) + public static DayOfWeek[] GetDaysOfWeek(DynamicDayOfWeek day) { - var days = new List<DayOfWeek>(7); - - if (day == DynamicDayOfWeek.Sunday - || day == DynamicDayOfWeek.Weekend - || day == DynamicDayOfWeek.Everyday) - { - days.Add(DayOfWeek.Sunday); - } - - if (day == DynamicDayOfWeek.Monday - || day == DynamicDayOfWeek.Weekday - || day == DynamicDayOfWeek.Everyday) - { - days.Add(DayOfWeek.Monday); - } - - if (day == DynamicDayOfWeek.Tuesday - || day == DynamicDayOfWeek.Weekday - || day == DynamicDayOfWeek.Everyday) - { - days.Add(DayOfWeek.Tuesday); - } - - if (day == DynamicDayOfWeek.Wednesday - || day == DynamicDayOfWeek.Weekday - || day == DynamicDayOfWeek.Everyday) + return day switch { - days.Add(DayOfWeek.Wednesday); - } - - if (day == DynamicDayOfWeek.Thursday - || day == DynamicDayOfWeek.Weekday - || day == DynamicDayOfWeek.Everyday) - { - days.Add(DayOfWeek.Thursday); - } - - if (day == DynamicDayOfWeek.Friday - || day == DynamicDayOfWeek.Weekday - || day == DynamicDayOfWeek.Everyday) - { - days.Add(DayOfWeek.Friday); - } - - if (day == DynamicDayOfWeek.Saturday - || day == DynamicDayOfWeek.Weekend - || day == DynamicDayOfWeek.Everyday) - { - days.Add(DayOfWeek.Saturday); - } - - return days; + DynamicDayOfWeek.Everyday => new[] { DayOfWeek.Sunday, DayOfWeek.Monday, DayOfWeek.Tuesday, DayOfWeek.Wednesday, DayOfWeek.Thursday, DayOfWeek.Friday, DayOfWeek.Saturday }, + DynamicDayOfWeek.Weekday => new[] { DayOfWeek.Monday, DayOfWeek.Tuesday, DayOfWeek.Wednesday, DayOfWeek.Thursday, DayOfWeek.Friday }, + DynamicDayOfWeek.Weekend => new[] { DayOfWeek.Sunday, DayOfWeek.Saturday }, + _ => new[] { (DayOfWeek)day } + }; } } } diff --git a/Jellyfin.Data/Entities/AccessSchedule.cs b/Jellyfin.Data/Entities/AccessSchedule.cs index 7d1b76a3f8..befc4ca02c 100644 --- a/Jellyfin.Data/Entities/AccessSchedule.cs +++ b/Jellyfin.Data/Entities/AccessSchedule.cs @@ -1,7 +1,5 @@ using System; -using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; -using System.Text.Json.Serialization; using System.Xml.Serialization; using Jellyfin.Data.Enums; @@ -28,64 +26,37 @@ namespace Jellyfin.Data.Entities } /// <summary> - /// Initializes a new instance of the <see cref="AccessSchedule"/> class. - /// Default constructor. Protected due to required properties, but present because EF needs it. - /// </summary> - protected AccessSchedule() - { - } - - /// <summary> - /// Gets or sets the id of this instance. + /// Gets the id of this instance. /// </summary> /// <remarks> /// Identity, Indexed, Required. /// </remarks> [XmlIgnore] - [Key] - [Required] [DatabaseGenerated(DatabaseGeneratedOption.Identity)] - public int Id { get; protected set; } + public int Id { get; private set; } /// <summary> - /// Gets or sets the id of the associated user. + /// Gets the id of the associated user. /// </summary> [XmlIgnore] - [Required] - public Guid UserId { get; protected set; } + public Guid UserId { get; private set; } /// <summary> /// Gets or sets the day of week. /// </summary> /// <value>The day of week.</value> - [Required] public DynamicDayOfWeek DayOfWeek { get; set; } /// <summary> /// Gets or sets the start hour. /// </summary> /// <value>The start hour.</value> - [Required] public double StartHour { get; set; } /// <summary> /// Gets or sets the end hour. /// </summary> /// <value>The end hour.</value> - [Required] public double EndHour { get; set; } - - /// <summary> - /// Static create function (for use in LINQ queries, etc.) - /// </summary> - /// <param name="dayOfWeek">The day of the week.</param> - /// <param name="startHour">The start hour.</param> - /// <param name="endHour">The end hour.</param> - /// <param name="userId">The associated user's id.</param> - /// <returns>The newly created instance.</returns> - public static AccessSchedule Create(DynamicDayOfWeek dayOfWeek, double startHour, double endHour, Guid userId) - { - return new AccessSchedule(dayOfWeek, startHour, endHour, userId); - } } } diff --git a/Jellyfin.Data/Entities/ActivityLog.cs b/Jellyfin.Data/Entities/ActivityLog.cs index 620e828306..1d1b86552f 100644 --- a/Jellyfin.Data/Entities/ActivityLog.cs +++ b/Jellyfin.Data/Entities/ActivityLog.cs @@ -34,23 +34,14 @@ namespace Jellyfin.Data.Entities Type = type; UserId = userId; DateCreated = DateTime.UtcNow; - LogSeverity = LogLevel.Trace; + LogSeverity = LogLevel.Information; } /// <summary> - /// Initializes a new instance of the <see cref="ActivityLog"/> class. - /// Default constructor. Protected due to required properties, but present because EF needs it. - /// </summary> - protected ActivityLog() - { - } - - /// <summary> - /// Gets or sets the identity of this instance. - /// This is the key in the backing database. + /// Gets the identity of this instance. /// </summary> [DatabaseGenerated(DatabaseGeneratedOption.Identity)] - public int Id { get; protected set; } + public int Id { get; private set; } /// <summary> /// Gets or sets the name. @@ -58,7 +49,6 @@ namespace Jellyfin.Data.Entities /// <remarks> /// Required, Max length = 512. /// </remarks> - [Required] [MaxLength(512)] [StringLength(512)] public string Name { get; set; } @@ -71,7 +61,7 @@ namespace Jellyfin.Data.Entities /// </remarks> [MaxLength(512)] [StringLength(512)] - public string Overview { get; set; } + public string? Overview { get; set; } /// <summary> /// Gets or sets the short overview. @@ -81,7 +71,7 @@ namespace Jellyfin.Data.Entities /// </remarks> [MaxLength(512)] [StringLength(512)] - public string ShortOverview { get; set; } + public string? ShortOverview { get; set; } /// <summary> /// Gets or sets the type. @@ -89,7 +79,6 @@ namespace Jellyfin.Data.Entities /// <remarks> /// Required, Max length = 256. /// </remarks> - [Required] [MaxLength(256)] [StringLength(256)] public string Type { get; set; } @@ -110,7 +99,7 @@ namespace Jellyfin.Data.Entities /// </remarks> [MaxLength(256)] [StringLength(256)] - public string ItemId { get; set; } + public string? ItemId { get; set; } /// <summary> /// Gets or sets the date created. This should be in UTC. @@ -130,7 +119,7 @@ namespace Jellyfin.Data.Entities /// <inheritdoc /> [ConcurrencyCheck] - public uint RowVersion { get; set; } + public uint RowVersion { get; private set; } /// <inheritdoc /> public void OnSavingChanges() diff --git a/Jellyfin.Data/Entities/CustomItemDisplayPreferences.cs b/Jellyfin.Data/Entities/CustomItemDisplayPreferences.cs new file mode 100644 index 0000000000..b07b7c7315 --- /dev/null +++ b/Jellyfin.Data/Entities/CustomItemDisplayPreferences.cs @@ -0,0 +1,80 @@ +using System; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Jellyfin.Data.Entities +{ + /// <summary> + /// An entity that represents a user's custom display preferences for a specific item. + /// </summary> + public class CustomItemDisplayPreferences + { + /// <summary> + /// Initializes a new instance of the <see cref="CustomItemDisplayPreferences"/> class. + /// </summary> + /// <param name="userId">The user id.</param> + /// <param name="itemId">The item id.</param> + /// <param name="client">The client.</param> + /// <param name="key">The preference key.</param> + /// <param name="value">The preference value.</param> + public CustomItemDisplayPreferences(Guid userId, Guid itemId, string client, string key, string? value) + { + UserId = userId; + ItemId = itemId; + Client = client; + Key = key; + Value = value; + } + + /// <summary> + /// Gets the Id. + /// </summary> + /// <remarks> + /// Required. + /// </remarks> + [DatabaseGenerated(DatabaseGeneratedOption.Identity)] + public int Id { get; private set; } + + /// <summary> + /// Gets or sets the user Id. + /// </summary> + /// <remarks> + /// Required. + /// </remarks> + public Guid UserId { get; set; } + + /// <summary> + /// Gets or sets the id of the associated item. + /// </summary> + /// <remarks> + /// Required. + /// </remarks> + public Guid ItemId { get; set; } + + /// <summary> + /// Gets or sets the client string. + /// </summary> + /// <remarks> + /// Required. Max Length = 32. + /// </remarks> + [MaxLength(32)] + [StringLength(32)] + public string Client { get; set; } + + /// <summary> + /// Gets or sets the preference key. + /// </summary> + /// <remarks> + /// Required. + /// </remarks> + public string Key { get; set; } + + /// <summary> + /// Gets or sets the preference value. + /// </summary> + /// <remarks> + /// Required. + /// </remarks> + public string? Value { get; set; } + } +} diff --git a/Jellyfin.Data/Entities/DisplayPreferences.cs b/Jellyfin.Data/Entities/DisplayPreferences.cs index 701e4df004..646961238b 100644 --- a/Jellyfin.Data/Entities/DisplayPreferences.cs +++ b/Jellyfin.Data/Entities/DisplayPreferences.cs @@ -1,6 +1,4 @@ -#pragma warning disable CA2227 - -using System; +using System; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; @@ -17,10 +15,12 @@ namespace Jellyfin.Data.Entities /// Initializes a new instance of the <see cref="DisplayPreferences"/> class. /// </summary> /// <param name="userId">The user's id.</param> + /// <param name="itemId">The item id.</param> /// <param name="client">The client string.</param> - public DisplayPreferences(Guid userId, string client) + public DisplayPreferences(Guid userId, Guid itemId, string client) { UserId = userId; + ItemId = itemId; Client = client; ShowSidebar = false; ShowBackdrop = true; @@ -28,27 +28,18 @@ namespace Jellyfin.Data.Entities SkipBackwardLength = 10000; ScrollDirection = ScrollDirection.Horizontal; ChromecastVersion = ChromecastVersion.Stable; - DashboardTheme = string.Empty; - TvHome = string.Empty; HomeSections = new HashSet<HomeSection>(); } /// <summary> - /// Initializes a new instance of the <see cref="DisplayPreferences"/> class. - /// </summary> - protected DisplayPreferences() - { - } - - /// <summary> - /// Gets or sets the Id. + /// Gets the Id. /// </summary> /// <remarks> /// Required. /// </remarks> [DatabaseGenerated(DatabaseGeneratedOption.Identity)] - public int Id { get; protected set; } + public int Id { get; private set; } /// <summary> /// Gets or sets the user Id. @@ -58,13 +49,20 @@ namespace Jellyfin.Data.Entities /// </remarks> public Guid UserId { get; set; } + /// <summary> + /// Gets or sets the id of the associated item. + /// </summary> + /// <remarks> + /// Required. + /// </remarks> + public Guid ItemId { get; set; } + /// <summary> /// Gets or sets the client string. /// </summary> /// <remarks> /// Required. Max Length = 32. /// </remarks> - [Required] [MaxLength(32)] [StringLength(32)] public string Client { get; set; } @@ -135,18 +133,18 @@ namespace Jellyfin.Data.Entities /// </summary> [MaxLength(32)] [StringLength(32)] - public string DashboardTheme { get; set; } + public string? DashboardTheme { get; set; } /// <summary> /// Gets or sets the tv home screen. /// </summary> [MaxLength(32)] [StringLength(32)] - public string TvHome { get; set; } + public string? TvHome { get; set; } /// <summary> - /// Gets or sets the home sections. + /// Gets the home sections. /// </summary> - public virtual ICollection<HomeSection> HomeSections { get; protected set; } + public virtual ICollection<HomeSection> HomeSections { get; private set; } } } diff --git a/Jellyfin.Data/Entities/Group.cs b/Jellyfin.Data/Entities/Group.cs index 878811e59c..14da0bb155 100644 --- a/Jellyfin.Data/Entities/Group.cs +++ b/Jellyfin.Data/Entities/Group.cs @@ -1,5 +1,3 @@ -#pragma warning disable CA2227 - using System; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; @@ -33,22 +31,12 @@ namespace Jellyfin.Data.Entities } /// <summary> - /// Initializes a new instance of the <see cref="Group"/> class. - /// </summary> - /// <remarks> - /// Default constructor. Protected due to required properties, but present because EF needs it. - /// </remarks> - protected Group() - { - } - - /// <summary> - /// Gets or sets the id of this group. + /// Gets the id of this group. /// </summary> /// <remarks> /// Identity, Indexed, Required. /// </remarks> - public Guid Id { get; protected set; } + public Guid Id { get; private set; } /// <summary> /// Gets or sets the group's name. @@ -56,24 +44,23 @@ namespace Jellyfin.Data.Entities /// <remarks> /// Required, Max length = 255. /// </remarks> - [Required] [MaxLength(255)] [StringLength(255)] public string Name { get; set; } /// <inheritdoc /> [ConcurrencyCheck] - public uint RowVersion { get; set; } + public uint RowVersion { get; private set; } /// <summary> - /// Gets or sets a collection containing the group's permissions. + /// Gets a collection containing the group's permissions. /// </summary> - public virtual ICollection<Permission> Permissions { get; protected set; } + public virtual ICollection<Permission> Permissions { get; private set; } /// <summary> - /// Gets or sets a collection containing the group's preferences. + /// Gets a collection containing the group's preferences. /// </summary> - public virtual ICollection<Preference> Preferences { get; protected set; } + public virtual ICollection<Preference> Preferences { get; private set; } /// <inheritdoc/> public bool HasPermission(PermissionKind kind) diff --git a/Jellyfin.Data/Entities/HomeSection.cs b/Jellyfin.Data/Entities/HomeSection.cs index 0620462602..e194aa537c 100644 --- a/Jellyfin.Data/Entities/HomeSection.cs +++ b/Jellyfin.Data/Entities/HomeSection.cs @@ -1,5 +1,4 @@ -using System.ComponentModel.DataAnnotations; -using System.ComponentModel.DataAnnotations.Schema; +using System.ComponentModel.DataAnnotations.Schema; using Jellyfin.Data.Enums; namespace Jellyfin.Data.Entities @@ -10,14 +9,13 @@ namespace Jellyfin.Data.Entities public class HomeSection { /// <summary> - /// Gets or sets the id. + /// Gets the id. /// </summary> /// <remarks> /// Identity. Required. /// </remarks> - [Key] [DatabaseGenerated(DatabaseGeneratedOption.Identity)] - public int Id { get; protected set; } + public int Id { get; private set; } /// <summary> /// Gets or sets the Id of the associated display preferences. diff --git a/Jellyfin.Data/Entities/ImageInfo.cs b/Jellyfin.Data/Entities/ImageInfo.cs index ab8452e62c..b5c7a1c120 100644 --- a/Jellyfin.Data/Entities/ImageInfo.cs +++ b/Jellyfin.Data/Entities/ImageInfo.cs @@ -20,28 +20,18 @@ namespace Jellyfin.Data.Entities } /// <summary> - /// Initializes a new instance of the <see cref="ImageInfo"/> class. - /// </summary> - /// <remarks> - /// Default constructor. Protected due to required properties, but present because EF needs it. - /// </remarks> - protected ImageInfo() - { - } - - /// <summary> - /// Gets or sets the id. + /// Gets the id. /// </summary> /// <remarks> /// Identity, Indexed, Required. /// </remarks> [DatabaseGenerated(DatabaseGeneratedOption.Identity)] - public int Id { get; protected set; } + public int Id { get; private set; } /// <summary> - /// Gets or sets the user id. + /// Gets the user id. /// </summary> - public Guid? UserId { get; protected set; } + public Guid? UserId { get; private set; } /// <summary> /// Gets or sets the path of the image. @@ -49,7 +39,6 @@ namespace Jellyfin.Data.Entities /// <remarks> /// Required. /// </remarks> - [Required] [MaxLength(512)] [StringLength(512)] public string Path { get; set; } diff --git a/Jellyfin.Data/Entities/ItemDisplayPreferences.cs b/Jellyfin.Data/Entities/ItemDisplayPreferences.cs index d81e4a31c4..948126d0a3 100644 --- a/Jellyfin.Data/Entities/ItemDisplayPreferences.cs +++ b/Jellyfin.Data/Entities/ItemDisplayPreferences.cs @@ -23,27 +23,19 @@ namespace Jellyfin.Data.Entities Client = client; SortBy = "SortName"; - ViewType = ViewType.Poster; SortOrder = SortOrder.Ascending; RememberSorting = false; RememberIndexing = false; } /// <summary> - /// Initializes a new instance of the <see cref="ItemDisplayPreferences"/> class. - /// </summary> - protected ItemDisplayPreferences() - { - } - - /// <summary> - /// Gets or sets the Id. + /// Gets the id. /// </summary> /// <remarks> /// Required. /// </remarks> [DatabaseGenerated(DatabaseGeneratedOption.Identity)] - public int Id { get; protected set; } + public int Id { get; private set; } /// <summary> /// Gets or sets the user Id. @@ -67,7 +59,6 @@ namespace Jellyfin.Data.Entities /// <remarks> /// Required. Max Length = 32. /// </remarks> - [Required] [MaxLength(32)] [StringLength(32)] public string Client { get; set; } @@ -107,7 +98,6 @@ namespace Jellyfin.Data.Entities /// <remarks> /// Required. /// </remarks> - [Required] [MaxLength(64)] [StringLength(64)] public string SortBy { get; set; } diff --git a/Jellyfin.Data/Entities/Libraries/Artwork.cs b/Jellyfin.Data/Entities/Libraries/Artwork.cs index 06cd333301..923525fc52 100644 --- a/Jellyfin.Data/Entities/Libraries/Artwork.cs +++ b/Jellyfin.Data/Entities/Libraries/Artwork.cs @@ -1,5 +1,3 @@ -#pragma warning disable CA2227 - using System; using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; @@ -18,8 +16,7 @@ namespace Jellyfin.Data.Entities.Libraries /// </summary> /// <param name="path">The path.</param> /// <param name="kind">The kind of art.</param> - /// <param name="owner">The owner.</param> - public Artwork(string path, ArtKind kind, IHasArtwork owner) + public Artwork(string path, ArtKind kind) { if (string.IsNullOrEmpty(path)) { @@ -28,28 +25,16 @@ namespace Jellyfin.Data.Entities.Libraries Path = path; Kind = kind; - - owner?.Artwork.Add(this); - } - - /// <summary> - /// Initializes a new instance of the <see cref="Artwork"/> class. - /// </summary> - /// <remarks> - /// Default constructor. Protected due to required properties, but present because EF needs it. - /// </remarks> - protected Artwork() - { } /// <summary> - /// Gets or sets the id. + /// Gets the id. /// </summary> /// <remarks> /// Identity, Indexed, Required. /// </remarks> [DatabaseGenerated(DatabaseGeneratedOption.Identity)] - public int Id { get; protected set; } + public int Id { get; private set; } /// <summary> /// Gets or sets the path. @@ -57,7 +42,6 @@ namespace Jellyfin.Data.Entities.Libraries /// <remarks> /// Required, Max length = 65535. /// </remarks> - [Required] [MaxLength(65535)] [StringLength(65535)] public string Path { get; set; } @@ -72,7 +56,7 @@ namespace Jellyfin.Data.Entities.Libraries /// <inheritdoc /> [ConcurrencyCheck] - public uint RowVersion { get; set; } + public uint RowVersion { get; private set; } /// <inheritdoc /> public void OnSavingChanges() diff --git a/Jellyfin.Data/Entities/Libraries/Book.cs b/Jellyfin.Data/Entities/Libraries/Book.cs index 2e63f75bd9..a838686d05 100644 --- a/Jellyfin.Data/Entities/Libraries/Book.cs +++ b/Jellyfin.Data/Entities/Libraries/Book.cs @@ -1,5 +1,3 @@ -#pragma warning disable CA2227 - using System.Collections.Generic; using Jellyfin.Data.Interfaces; @@ -13,18 +11,19 @@ namespace Jellyfin.Data.Entities.Libraries /// <summary> /// Initializes a new instance of the <see cref="Book"/> class. /// </summary> - public Book() + /// <param name="library">The library.</param> + public Book(Library library) : base(library) { BookMetadata = new HashSet<BookMetadata>(); Releases = new HashSet<Release>(); } /// <summary> - /// Gets or sets a collection containing the metadata for this book. + /// Gets a collection containing the metadata for this book. /// </summary> - public virtual ICollection<BookMetadata> BookMetadata { get; protected set; } + public virtual ICollection<BookMetadata> BookMetadata { get; private set; } /// <inheritdoc /> - public virtual ICollection<Release> Releases { get; protected set; } + public virtual ICollection<Release> Releases { get; private set; } } } diff --git a/Jellyfin.Data/Entities/Libraries/BookMetadata.cs b/Jellyfin.Data/Entities/Libraries/BookMetadata.cs index 4a3d290f01..4a350d200e 100644 --- a/Jellyfin.Data/Entities/Libraries/BookMetadata.cs +++ b/Jellyfin.Data/Entities/Libraries/BookMetadata.cs @@ -1,8 +1,4 @@ -#pragma warning disable CA2227 - -using System; using System.Collections.Generic; -using System.ComponentModel.DataAnnotations.Schema; using Jellyfin.Data.Interfaces; namespace Jellyfin.Data.Entities.Libraries @@ -17,41 +13,22 @@ namespace Jellyfin.Data.Entities.Libraries /// </summary> /// <param name="title">The title or name of the object.</param> /// <param name="language">ISO-639-3 3-character language codes.</param> - /// <param name="book">The book.</param> - public BookMetadata(string title, string language, Book book) : base(title, language) + public BookMetadata(string title, string language) : base(title, language) { - if (book == null) - { - throw new ArgumentNullException(nameof(book)); - } - - book.BookMetadata.Add(this); - Publishers = new HashSet<Company>(); } - /// <summary> - /// Initializes a new instance of the <see cref="BookMetadata"/> class. - /// </summary> - /// <remarks> - /// Default constructor. Protected due to required properties, but present because EF needs it. - /// </remarks> - protected BookMetadata() - { - } - /// <summary> /// Gets or sets the ISBN. /// </summary> public long? Isbn { get; set; } /// <summary> - /// Gets or sets a collection of the publishers for this book. + /// Gets a collection of the publishers for this book. /// </summary> - public virtual ICollection<Company> Publishers { get; protected set; } + public virtual ICollection<Company> Publishers { get; private set; } /// <inheritdoc /> - [NotMapped] public ICollection<Company> Companies => Publishers; } } diff --git a/Jellyfin.Data/Entities/Libraries/Chapter.cs b/Jellyfin.Data/Entities/Libraries/Chapter.cs index f503de3794..3d81f713d6 100644 --- a/Jellyfin.Data/Entities/Libraries/Chapter.cs +++ b/Jellyfin.Data/Entities/Libraries/Chapter.cs @@ -1,5 +1,3 @@ -#pragma warning disable CA2227 - using System; using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; @@ -17,8 +15,7 @@ namespace Jellyfin.Data.Entities.Libraries /// </summary> /// <param name="language">ISO-639-3 3-character language codes.</param> /// <param name="startTime">The start time for this chapter.</param> - /// <param name="release">The release.</param> - public Chapter(string language, long startTime, Release release) + public Chapter(string language, long startTime) { if (string.IsNullOrEmpty(language)) { @@ -27,33 +24,16 @@ namespace Jellyfin.Data.Entities.Libraries Language = language; StartTime = startTime; - - if (release == null) - { - throw new ArgumentNullException(nameof(release)); - } - - release.Chapters.Add(this); - } - - /// <summary> - /// Initializes a new instance of the <see cref="Chapter"/> class. - /// </summary> - /// <remarks> - /// Default constructor. Protected due to required properties, but present because EF needs it. - /// </remarks> - protected Chapter() - { } /// <summary> - /// Gets or sets the id. + /// Gets the id. /// </summary> /// <remarks> /// Identity, Indexed, Required. /// </remarks> [DatabaseGenerated(DatabaseGeneratedOption.Identity)] - public int Id { get; protected set; } + public int Id { get; private set; } /// <summary> /// Gets or sets the name. @@ -63,7 +43,7 @@ namespace Jellyfin.Data.Entities.Libraries /// </remarks> [MaxLength(1024)] [StringLength(1024)] - public string Name { get; set; } + public string? Name { get; set; } /// <summary> /// Gets or sets the language. @@ -72,7 +52,6 @@ namespace Jellyfin.Data.Entities.Libraries /// Required, Min length = 3, Max length = 3 /// ISO-639-3 3-character language codes. /// </remarks> - [Required] [MinLength(3)] [MaxLength(3)] [StringLength(3)] @@ -93,7 +72,7 @@ namespace Jellyfin.Data.Entities.Libraries /// <inheritdoc /> [ConcurrencyCheck] - public uint RowVersion { get; protected set; } + public uint RowVersion { get; private set; } /// <inheritdoc /> public void OnSavingChanges() diff --git a/Jellyfin.Data/Entities/Libraries/Collection.cs b/Jellyfin.Data/Entities/Libraries/Collection.cs index 39eded752d..7de6019692 100644 --- a/Jellyfin.Data/Entities/Libraries/Collection.cs +++ b/Jellyfin.Data/Entities/Libraries/Collection.cs @@ -1,4 +1,4 @@ -#pragma warning disable CA2227 +#pragma warning disable CA1711 // Identifiers should not have incorrect suffix using System.Collections.Generic; using System.ComponentModel.DataAnnotations; @@ -21,13 +21,13 @@ namespace Jellyfin.Data.Entities.Libraries } /// <summary> - /// Gets or sets the id. + /// Gets the id. /// </summary> /// <remarks> /// Identity, Indexed, Required. /// </remarks> [DatabaseGenerated(DatabaseGeneratedOption.Identity)] - public int Id { get; protected set; } + public int Id { get; private set; } /// <summary> /// Gets or sets the name. @@ -37,16 +37,16 @@ namespace Jellyfin.Data.Entities.Libraries /// </remarks> [MaxLength(1024)] [StringLength(1024)] - public string Name { get; set; } + public string? Name { get; set; } /// <inheritdoc /> [ConcurrencyCheck] - public uint RowVersion { get; set; } + public uint RowVersion { get; private set; } /// <summary> - /// Gets or sets a collection containing this collection's items. + /// Gets a collection containing this collection's items. /// </summary> - public virtual ICollection<CollectionItem> Items { get; protected set; } + public virtual ICollection<CollectionItem> Items { get; private set; } /// <inheritdoc /> public void OnSavingChanges() diff --git a/Jellyfin.Data/Entities/Libraries/CollectionItem.cs b/Jellyfin.Data/Entities/Libraries/CollectionItem.cs index 4467c9bbdc..0cb4716dbe 100644 --- a/Jellyfin.Data/Entities/Libraries/CollectionItem.cs +++ b/Jellyfin.Data/Entities/Libraries/CollectionItem.cs @@ -1,4 +1,3 @@ -using System; using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; using Jellyfin.Data.Interfaces; @@ -13,39 +12,10 @@ namespace Jellyfin.Data.Entities.Libraries /// <summary> /// Initializes a new instance of the <see cref="CollectionItem"/> class. /// </summary> - /// <param name="collection">The collection.</param> - /// <param name="previous">The previous item.</param> - /// <param name="next">The next item.</param> - public CollectionItem(Collection collection, CollectionItem previous, CollectionItem next) - { - if (collection == null) - { - throw new ArgumentNullException(nameof(collection)); - } - - collection.Items.Add(this); - - if (next != null) - { - Next = next; - next.Previous = this; - } - - if (previous != null) - { - Previous = previous; - previous.Next = this; - } - } - - /// <summary> - /// Initializes a new instance of the <see cref="CollectionItem"/> class. - /// </summary> - /// <remarks> - /// Default constructor. Protected due to required properties, but present because EF needs it. - /// </remarks> - protected CollectionItem() + /// <param name="libraryItem">The library item.</param> + public CollectionItem(LibraryItem libraryItem) { + LibraryItem = libraryItem; } /// <summary> @@ -59,7 +29,7 @@ namespace Jellyfin.Data.Entities.Libraries /// <inheritdoc /> [ConcurrencyCheck] - public uint RowVersion { get; set; } + public uint RowVersion { get; private set; } /// <summary> /// Gets or sets the library item. @@ -73,17 +43,17 @@ namespace Jellyfin.Data.Entities.Libraries /// Gets or sets the next item in the collection. /// </summary> /// <remarks> - /// TODO check if this properly updated dependant and has the proper principal relationship. + /// TODO check if this properly updated Dependant and has the proper principal relationship. /// </remarks> - public virtual CollectionItem Next { get; set; } + public virtual CollectionItem? Next { get; set; } /// <summary> /// Gets or sets the previous item in the collection. /// </summary> /// <remarks> - /// TODO check if this properly updated dependant and has the proper principal relationship. + /// TODO check if this properly updated Dependant and has the proper principal relationship. /// </remarks> - public virtual CollectionItem Previous { get; set; } + public virtual CollectionItem? Previous { get; set; } /// <inheritdoc /> public void OnSavingChanges() diff --git a/Jellyfin.Data/Entities/Libraries/Company.cs b/Jellyfin.Data/Entities/Libraries/Company.cs index 3b6ed3309a..1abbee4458 100644 --- a/Jellyfin.Data/Entities/Libraries/Company.cs +++ b/Jellyfin.Data/Entities/Libraries/Company.cs @@ -1,5 +1,3 @@ -#pragma warning disable CA2227 - using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; @@ -15,49 +13,36 @@ namespace Jellyfin.Data.Entities.Libraries /// <summary> /// Initializes a new instance of the <see cref="Company"/> class. /// </summary> - /// <param name="owner">The owner of this company.</param> - public Company(IHasCompanies owner) + public Company() { - owner?.Companies.Add(this); - CompanyMetadata = new HashSet<CompanyMetadata>(); + ChildCompanies = new HashSet<Company>(); } /// <summary> - /// Initializes a new instance of the <see cref="Company"/> class. - /// </summary> - /// <remarks> - /// Default constructor. Protected due to required properties, but present because EF needs it. - /// </remarks> - protected Company() - { - } - - /// <summary> - /// Gets or sets the id. + /// Gets the id. /// </summary> /// <remarks> /// Identity, Indexed, Required. /// </remarks> [DatabaseGenerated(DatabaseGeneratedOption.Identity)] - public int Id { get; protected set; } + public int Id { get; private set; } /// <inheritdoc /> [ConcurrencyCheck] - public uint RowVersion { get; set; } + public uint RowVersion { get; private set; } /// <summary> - /// Gets or sets a collection containing the metadata. + /// Gets a collection containing the metadata. /// </summary> - public virtual ICollection<CompanyMetadata> CompanyMetadata { get; protected set; } + public virtual ICollection<CompanyMetadata> CompanyMetadata { get; private set; } /// <summary> - /// Gets or sets a collection containing this company's child companies. + /// Gets a collection containing this company's child companies. /// </summary> - public virtual ICollection<Company> ChildCompanies { get; protected set; } + public virtual ICollection<Company> ChildCompanies { get; private set; } /// <inheritdoc /> - [NotMapped] public ICollection<Company> Companies => ChildCompanies; /// <inheritdoc /> diff --git a/Jellyfin.Data/Entities/Libraries/CompanyMetadata.cs b/Jellyfin.Data/Entities/Libraries/CompanyMetadata.cs index 8aa0486afa..a29f08c7f6 100644 --- a/Jellyfin.Data/Entities/Libraries/CompanyMetadata.cs +++ b/Jellyfin.Data/Entities/Libraries/CompanyMetadata.cs @@ -1,4 +1,3 @@ -using System; using System.ComponentModel.DataAnnotations; namespace Jellyfin.Data.Entities.Libraries @@ -13,21 +12,7 @@ namespace Jellyfin.Data.Entities.Libraries /// </summary> /// <param name="title">The title or name of the object.</param> /// <param name="language">ISO-639-3 3-character language codes.</param> - /// <param name="company">The company.</param> - public CompanyMetadata(string title, string language, Company company) : base(title, language) - { - if (company == null) - { - throw new ArgumentNullException(nameof(company)); - } - - company.CompanyMetadata.Add(this); - } - - /// <summary> - /// Initializes a new instance of the <see cref="CompanyMetadata"/> class. - /// </summary> - protected CompanyMetadata() + public CompanyMetadata(string title, string language) : base(title, language) { } @@ -39,7 +24,7 @@ namespace Jellyfin.Data.Entities.Libraries /// </remarks> [MaxLength(65535)] [StringLength(65535)] - public string Description { get; set; } + public string? Description { get; set; } /// <summary> /// Gets or sets the headquarters. @@ -49,7 +34,7 @@ namespace Jellyfin.Data.Entities.Libraries /// </remarks> [MaxLength(255)] [StringLength(255)] - public string Headquarters { get; set; } + public string? Headquarters { get; set; } /// <summary> /// Gets or sets the country code. @@ -59,7 +44,7 @@ namespace Jellyfin.Data.Entities.Libraries /// </remarks> [MaxLength(2)] [StringLength(2)] - public string Country { get; set; } + public string? Country { get; set; } /// <summary> /// Gets or sets the homepage. @@ -69,6 +54,6 @@ namespace Jellyfin.Data.Entities.Libraries /// </remarks> [MaxLength(1024)] [StringLength(1024)] - public string Homepage { get; set; } + public string? Homepage { get; set; } } } diff --git a/Jellyfin.Data/Entities/Libraries/CustomItem.cs b/Jellyfin.Data/Entities/Libraries/CustomItem.cs index 115489c786..e27d01d860 100644 --- a/Jellyfin.Data/Entities/Libraries/CustomItem.cs +++ b/Jellyfin.Data/Entities/Libraries/CustomItem.cs @@ -1,5 +1,3 @@ -#pragma warning disable CA2227 - using System.Collections.Generic; using Jellyfin.Data.Interfaces; @@ -13,18 +11,19 @@ namespace Jellyfin.Data.Entities.Libraries /// <summary> /// Initializes a new instance of the <see cref="CustomItem"/> class. /// </summary> - public CustomItem() + /// <param name="library">The library.</param> + public CustomItem(Library library) : base(library) { CustomItemMetadata = new HashSet<CustomItemMetadata>(); Releases = new HashSet<Release>(); } /// <summary> - /// Gets or sets a collection containing the metadata for this item. + /// Gets a collection containing the metadata for this item. /// </summary> - public virtual ICollection<CustomItemMetadata> CustomItemMetadata { get; protected set; } + public virtual ICollection<CustomItemMetadata> CustomItemMetadata { get; private set; } /// <inheritdoc /> - public virtual ICollection<Release> Releases { get; protected set; } + public virtual ICollection<Release> Releases { get; private set; } } } diff --git a/Jellyfin.Data/Entities/Libraries/CustomItemMetadata.cs b/Jellyfin.Data/Entities/Libraries/CustomItemMetadata.cs index f0daedfbe8..af2393870f 100644 --- a/Jellyfin.Data/Entities/Libraries/CustomItemMetadata.cs +++ b/Jellyfin.Data/Entities/Libraries/CustomItemMetadata.cs @@ -1,5 +1,3 @@ -using System; - namespace Jellyfin.Data.Entities.Libraries { /// <summary> @@ -12,24 +10,7 @@ namespace Jellyfin.Data.Entities.Libraries /// </summary> /// <param name="title">The title or name of the object.</param> /// <param name="language">ISO-639-3 3-character language codes.</param> - /// <param name="item">The item.</param> - public CustomItemMetadata(string title, string language, CustomItem item) : base(title, language) - { - if (item == null) - { - throw new ArgumentNullException(nameof(item)); - } - - item.CustomItemMetadata.Add(this); - } - - /// <summary> - /// Initializes a new instance of the <see cref="CustomItemMetadata"/> class. - /// </summary> - /// <remarks> - /// Default constructor. Protected due to required properties, but present because EF needs it. - /// </remarks> - protected CustomItemMetadata() + public CustomItemMetadata(string title, string language) : base(title, language) { } } diff --git a/Jellyfin.Data/Entities/Libraries/Episode.cs b/Jellyfin.Data/Entities/Libraries/Episode.cs index 0bdc2d7642..ce2f0c6178 100644 --- a/Jellyfin.Data/Entities/Libraries/Episode.cs +++ b/Jellyfin.Data/Entities/Libraries/Episode.cs @@ -1,6 +1,3 @@ -#pragma warning disable CA2227 - -using System; using System.Collections.Generic; using Jellyfin.Data.Interfaces; @@ -14,41 +11,24 @@ namespace Jellyfin.Data.Entities.Libraries /// <summary> /// Initializes a new instance of the <see cref="Episode"/> class. /// </summary> - /// <param name="season">The season.</param> - public Episode(Season season) + /// <param name="library">The library.</param> + public Episode(Library library) : base(library) { - if (season == null) - { - throw new ArgumentNullException(nameof(season)); - } - - season.Episodes.Add(this); - Releases = new HashSet<Release>(); EpisodeMetadata = new HashSet<EpisodeMetadata>(); } - /// <summary> - /// Initializes a new instance of the <see cref="Episode"/> class. - /// </summary> - /// <remarks> - /// Default constructor. Protected due to required properties, but present because EF needs it. - /// </remarks> - protected Episode() - { - } - /// <summary> /// Gets or sets the episode number. /// </summary> public int? EpisodeNumber { get; set; } /// <inheritdoc /> - public virtual ICollection<Release> Releases { get; protected set; } + public virtual ICollection<Release> Releases { get; private set; } /// <summary> - /// Gets or sets a collection containing the metadata for this episode. + /// Gets a collection containing the metadata for this episode. /// </summary> - public virtual ICollection<EpisodeMetadata> EpisodeMetadata { get; protected set; } + public virtual ICollection<EpisodeMetadata> EpisodeMetadata { get; private set; } } } diff --git a/Jellyfin.Data/Entities/Libraries/EpisodeMetadata.cs b/Jellyfin.Data/Entities/Libraries/EpisodeMetadata.cs index 7efb840f0b..b0ef11e0f2 100644 --- a/Jellyfin.Data/Entities/Libraries/EpisodeMetadata.cs +++ b/Jellyfin.Data/Entities/Libraries/EpisodeMetadata.cs @@ -1,4 +1,3 @@ -using System; using System.ComponentModel.DataAnnotations; namespace Jellyfin.Data.Entities.Libraries @@ -13,24 +12,7 @@ namespace Jellyfin.Data.Entities.Libraries /// </summary> /// <param name="title">The title or name of the object.</param> /// <param name="language">ISO-639-3 3-character language codes.</param> - /// <param name="episode">The episode.</param> - public EpisodeMetadata(string title, string language, Episode episode) : base(title, language) - { - if (episode == null) - { - throw new ArgumentNullException(nameof(episode)); - } - - episode.EpisodeMetadata.Add(this); - } - - /// <summary> - /// Initializes a new instance of the <see cref="EpisodeMetadata"/> class. - /// </summary> - /// <remarks> - /// Default constructor. Protected due to required properties, but present because EF needs it. - /// </remarks> - protected EpisodeMetadata() + public EpisodeMetadata(string title, string language) : base(title, language) { } @@ -42,7 +24,7 @@ namespace Jellyfin.Data.Entities.Libraries /// </remarks> [MaxLength(1024)] [StringLength(1024)] - public string Outline { get; set; } + public string? Outline { get; set; } /// <summary> /// Gets or sets the plot. @@ -52,7 +34,7 @@ namespace Jellyfin.Data.Entities.Libraries /// </remarks> [MaxLength(65535)] [StringLength(65535)] - public string Plot { get; set; } + public string? Plot { get; set; } /// <summary> /// Gets or sets the tagline. @@ -62,6 +44,6 @@ namespace Jellyfin.Data.Entities.Libraries /// </remarks> [MaxLength(1024)] [StringLength(1024)] - public string Tagline { get; set; } + public string? Tagline { get; set; } } } diff --git a/Jellyfin.Data/Entities/Libraries/Genre.cs b/Jellyfin.Data/Entities/Libraries/Genre.cs index 2a2dbd1a5d..3b822ee828 100644 --- a/Jellyfin.Data/Entities/Libraries/Genre.cs +++ b/Jellyfin.Data/Entities/Libraries/Genre.cs @@ -1,4 +1,3 @@ -using System; using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; using Jellyfin.Data.Interfaces; @@ -14,42 +13,19 @@ namespace Jellyfin.Data.Entities.Libraries /// Initializes a new instance of the <see cref="Genre"/> class. /// </summary> /// <param name="name">The name.</param> - /// <param name="itemMetadata">The metadata.</param> - public Genre(string name, ItemMetadata itemMetadata) + public Genre(string name) { - if (string.IsNullOrEmpty(name)) - { - throw new ArgumentNullException(nameof(name)); - } - Name = name; - - if (itemMetadata == null) - { - throw new ArgumentNullException(nameof(itemMetadata)); - } - - itemMetadata.Genres.Add(this); - } - - /// <summary> - /// Initializes a new instance of the <see cref="Genre"/> class. - /// </summary> - /// <remarks> - /// Default constructor. Protected due to required properties, but present because EF needs it. - /// </remarks> - protected Genre() - { } /// <summary> - /// Gets or sets the id. + /// Gets the id. /// </summary> /// <remarks> /// Identity, Indexed, Required. /// </remarks> [DatabaseGenerated(DatabaseGeneratedOption.Identity)] - public int Id { get; protected set; } + public int Id { get; private set; } /// <summary> /// Gets or sets the name. @@ -57,14 +33,13 @@ namespace Jellyfin.Data.Entities.Libraries /// <remarks> /// Indexed, Required, Max length = 255. /// </remarks> - [Required] [MaxLength(255)] [StringLength(255)] public string Name { get; set; } /// <inheritdoc /> [ConcurrencyCheck] - public uint RowVersion { get; protected set; } + public uint RowVersion { get; private set; } /// <inheritdoc /> public void OnSavingChanges() diff --git a/Jellyfin.Data/Entities/Libraries/ItemMetadata.cs b/Jellyfin.Data/Entities/Libraries/ItemMetadata.cs index 1d2dc0f669..d429a90c61 100644 --- a/Jellyfin.Data/Entities/Libraries/ItemMetadata.cs +++ b/Jellyfin.Data/Entities/Libraries/ItemMetadata.cs @@ -1,5 +1,3 @@ -#pragma warning disable CA2227 - using System; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; @@ -43,23 +41,13 @@ namespace Jellyfin.Data.Entities.Libraries } /// <summary> - /// Initializes a new instance of the <see cref="ItemMetadata"/> class. - /// </summary> - /// <remarks> - /// Default constructor. Protected due to being abstract. - /// </remarks> - protected ItemMetadata() - { - } - - /// <summary> - /// Gets or sets the id. + /// Gets the id. /// </summary> /// <remarks> /// Identity, Indexed, Required. /// </remarks> [DatabaseGenerated(DatabaseGeneratedOption.Identity)] - public int Id { get; protected set; } + public int Id { get; private set; } /// <summary> /// Gets or sets the title. @@ -67,7 +55,6 @@ namespace Jellyfin.Data.Entities.Libraries /// <remarks> /// Required, Max length = 1024. /// </remarks> - [Required] [MaxLength(1024)] [StringLength(1024)] public string Title { get; set; } @@ -80,7 +67,7 @@ namespace Jellyfin.Data.Entities.Libraries /// </remarks> [MaxLength(1024)] [StringLength(1024)] - public string OriginalTitle { get; set; } + public string? OriginalTitle { get; set; } /// <summary> /// Gets or sets the sort title. @@ -90,7 +77,7 @@ namespace Jellyfin.Data.Entities.Libraries /// </remarks> [MaxLength(1024)] [StringLength(1024)] - public string SortTitle { get; set; } + public string? SortTitle { get; set; } /// <summary> /// Gets or sets the language. @@ -99,7 +86,6 @@ namespace Jellyfin.Data.Entities.Libraries /// Required, Min length = 3, Max length = 3. /// ISO-639-3 3-character language codes. /// </remarks> - [Required] [MinLength(3)] [MaxLength(3)] [StringLength(3)] @@ -111,12 +97,12 @@ namespace Jellyfin.Data.Entities.Libraries public DateTimeOffset? ReleaseDate { get; set; } /// <summary> - /// Gets or sets the date added. + /// Gets the date added. /// </summary> /// <remarks> /// Required. /// </remarks> - public DateTime DateAdded { get; protected set; } + public DateTime DateAdded { get; private set; } /// <summary> /// Gets or sets the date modified. @@ -126,37 +112,32 @@ namespace Jellyfin.Data.Entities.Libraries /// </remarks> public DateTime DateModified { get; set; } - /// <summary> - /// Gets or sets the row version. - /// </summary> - /// <remarks> - /// Required, ConcurrencyToken. - /// </remarks> + /// <inheritdoc /> [ConcurrencyCheck] - public uint RowVersion { get; set; } + public uint RowVersion { get; private set; } /// <summary> - /// Gets or sets a collection containing the person roles for this item. + /// Gets a collection containing the person roles for this item. /// </summary> - public virtual ICollection<PersonRole> PersonRoles { get; protected set; } + public virtual ICollection<PersonRole> PersonRoles { get; private set; } /// <summary> - /// Gets or sets a collection containing the generes for this item. + /// Gets a collection containing the genres for this item. /// </summary> - public virtual ICollection<Genre> Genres { get; protected set; } + public virtual ICollection<Genre> Genres { get; private set; } /// <inheritdoc /> - public virtual ICollection<Artwork> Artwork { get; protected set; } + public virtual ICollection<Artwork> Artwork { get; private set; } /// <summary> - /// Gets or sets a collection containing the ratings for this item. + /// Gets a collection containing the ratings for this item. /// </summary> - public virtual ICollection<Rating> Ratings { get; protected set; } + public virtual ICollection<Rating> Ratings { get; private set; } /// <summary> - /// Gets or sets a collection containing the metadata sources for this item. + /// Gets a collection containing the metadata sources for this item. /// </summary> - public virtual ICollection<MetadataProviderId> Sources { get; protected set; } + public virtual ICollection<MetadataProviderId> Sources { get; private set; } /// <inheritdoc /> public void OnSavingChanges() diff --git a/Jellyfin.Data/Entities/Libraries/Library.cs b/Jellyfin.Data/Entities/Libraries/Library.cs index 4f82a2e2a7..0db42a1c7b 100644 --- a/Jellyfin.Data/Entities/Libraries/Library.cs +++ b/Jellyfin.Data/Entities/Libraries/Library.cs @@ -1,4 +1,3 @@ -using System; using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; using Jellyfin.Data.Interfaces; @@ -14,34 +13,21 @@ namespace Jellyfin.Data.Entities.Libraries /// Initializes a new instance of the <see cref="Library"/> class. /// </summary> /// <param name="name">The name of the library.</param> - public Library(string name) + /// <param name="path">The path of the library.</param> + public Library(string name, string path) { - if (string.IsNullOrWhiteSpace(name)) - { - throw new ArgumentNullException(nameof(name)); - } - Name = name; + Path = path; } /// <summary> - /// Initializes a new instance of the <see cref="Library"/> class. - /// </summary> - /// <remarks> - /// Default constructor. Protected due to required properties, but present because EF needs it. - /// </remarks> - protected Library() - { - } - - /// <summary> - /// Gets or sets the id. + /// Gets the id. /// </summary> /// <remarks> /// Identity, Indexed, Required. /// </remarks> [DatabaseGenerated(DatabaseGeneratedOption.Identity)] - public int Id { get; protected set; } + public int Id { get; private set; } /// <summary> /// Gets or sets the name. @@ -49,7 +35,6 @@ namespace Jellyfin.Data.Entities.Libraries /// <remarks> /// Required, Max length = 128. /// </remarks> - [Required] [MaxLength(128)] [StringLength(128)] public string Name { get; set; } @@ -60,12 +45,11 @@ namespace Jellyfin.Data.Entities.Libraries /// <remarks> /// Required. /// </remarks> - [Required] public string Path { get; set; } /// <inheritdoc /> [ConcurrencyCheck] - public uint RowVersion { get; set; } + public uint RowVersion { get; private set; } /// <inheritdoc /> public void OnSavingChanges() diff --git a/Jellyfin.Data/Entities/Libraries/LibraryItem.cs b/Jellyfin.Data/Entities/Libraries/LibraryItem.cs index a9167aa7fd..d889b871ed 100644 --- a/Jellyfin.Data/Entities/Libraries/LibraryItem.cs +++ b/Jellyfin.Data/Entities/Libraries/LibraryItem.cs @@ -21,29 +21,22 @@ namespace Jellyfin.Data.Entities.Libraries } /// <summary> - /// Initializes a new instance of the <see cref="LibraryItem"/> class. - /// </summary> - protected LibraryItem() - { - } - - /// <summary> - /// Gets or sets the id. + /// Gets the id. /// </summary> /// <remarks> /// Identity, Indexed, Required. /// </remarks> [DatabaseGenerated(DatabaseGeneratedOption.Identity)] - public int Id { get; protected set; } + public int Id { get; private set; } /// <summary> - /// Gets or sets the date this library item was added. + /// Gets the date this library item was added. /// </summary> - public DateTime DateAdded { get; protected set; } + public DateTime DateAdded { get; private set; } /// <inheritdoc /> [ConcurrencyCheck] - public uint RowVersion { get; protected set; } + public uint RowVersion { get; private set; } /// <summary> /// Gets or sets the library of this item. @@ -51,7 +44,6 @@ namespace Jellyfin.Data.Entities.Libraries /// <remarks> /// Required. /// </remarks> - [Required] public virtual Library Library { get; set; } /// <inheritdoc /> diff --git a/Jellyfin.Data/Entities/Libraries/MediaFile.cs b/Jellyfin.Data/Entities/Libraries/MediaFile.cs index 9924d5728d..36e1e47776 100644 --- a/Jellyfin.Data/Entities/Libraries/MediaFile.cs +++ b/Jellyfin.Data/Entities/Libraries/MediaFile.cs @@ -1,5 +1,3 @@ -#pragma warning disable CA2227 - using System; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; @@ -19,8 +17,7 @@ namespace Jellyfin.Data.Entities.Libraries /// </summary> /// <param name="path">The path relative to the LibraryRoot.</param> /// <param name="kind">The file kind.</param> - /// <param name="release">The release.</param> - public MediaFile(string path, MediaFileKind kind, Release release) + public MediaFile(string path, MediaFileKind kind) { if (string.IsNullOrEmpty(path)) { @@ -30,34 +27,17 @@ namespace Jellyfin.Data.Entities.Libraries Path = path; Kind = kind; - if (release == null) - { - throw new ArgumentNullException(nameof(release)); - } - - release.MediaFiles.Add(this); - MediaFileStreams = new HashSet<MediaFileStream>(); } /// <summary> - /// Initializes a new instance of the <see cref="MediaFile"/> class. - /// </summary> - /// <remarks> - /// Default constructor. Protected due to required properties, but present because EF needs it. - /// </remarks> - protected MediaFile() - { - } - - /// <summary> - /// Gets or sets the id. + /// Gets the id. /// </summary> /// <remarks> /// Identity, Indexed, Required. /// </remarks> [DatabaseGenerated(DatabaseGeneratedOption.Identity)] - public int Id { get; protected set; } + public int Id { get; private set; } /// <summary> /// Gets or sets the path relative to the library root. @@ -65,7 +45,6 @@ namespace Jellyfin.Data.Entities.Libraries /// <remarks> /// Required, Max length = 65535. /// </remarks> - [Required] [MaxLength(65535)] [StringLength(65535)] public string Path { get; set; } @@ -80,12 +59,12 @@ namespace Jellyfin.Data.Entities.Libraries /// <inheritdoc /> [ConcurrencyCheck] - public uint RowVersion { get; set; } + public uint RowVersion { get; private set; } /// <summary> - /// Gets or sets a collection containing the streams in this file. + /// Gets a collection containing the streams in this file. /// </summary> - public virtual ICollection<MediaFileStream> MediaFileStreams { get; protected set; } + public virtual ICollection<MediaFileStream> MediaFileStreams { get; private set; } /// <inheritdoc /> public void OnSavingChanges() diff --git a/Jellyfin.Data/Entities/Libraries/MediaFileStream.cs b/Jellyfin.Data/Entities/Libraries/MediaFileStream.cs index 5b03e260e2..e24e73ecb7 100644 --- a/Jellyfin.Data/Entities/Libraries/MediaFileStream.cs +++ b/Jellyfin.Data/Entities/Libraries/MediaFileStream.cs @@ -1,4 +1,5 @@ -using System; +#pragma warning disable CA1711 // Identifiers should not have incorrect suffix + using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; using Jellyfin.Data.Interfaces; @@ -14,37 +15,19 @@ namespace Jellyfin.Data.Entities.Libraries /// Initializes a new instance of the <see cref="MediaFileStream"/> class. /// </summary> /// <param name="streamNumber">The number of this stream.</param> - /// <param name="mediaFile">The media file.</param> - public MediaFileStream(int streamNumber, MediaFile mediaFile) + public MediaFileStream(int streamNumber) { StreamNumber = streamNumber; - - if (mediaFile == null) - { - throw new ArgumentNullException(nameof(mediaFile)); - } - - mediaFile.MediaFileStreams.Add(this); - } - - /// <summary> - /// Initializes a new instance of the <see cref="MediaFileStream"/> class. - /// </summary> - /// <remarks> - /// Default constructor. Protected due to required properties, but present because EF needs it. - /// </remarks> - protected MediaFileStream() - { } /// <summary> - /// Gets or sets the id. + /// Gets the id. /// </summary> /// <remarks> /// Identity, Indexed, Required. /// </remarks> [DatabaseGenerated(DatabaseGeneratedOption.Identity)] - public int Id { get; protected set; } + public int Id { get; private set; } /// <summary> /// Gets or sets the stream number. @@ -56,7 +39,7 @@ namespace Jellyfin.Data.Entities.Libraries /// <inheritdoc /> [ConcurrencyCheck] - public uint RowVersion { get; set; } + public uint RowVersion { get; private set; } /// <inheritdoc /> public void OnSavingChanges() diff --git a/Jellyfin.Data/Entities/Libraries/MetadataProvider.cs b/Jellyfin.Data/Entities/Libraries/MetadataProvider.cs index a18a612bcd..b271960787 100644 --- a/Jellyfin.Data/Entities/Libraries/MetadataProvider.cs +++ b/Jellyfin.Data/Entities/Libraries/MetadataProvider.cs @@ -25,23 +25,13 @@ namespace Jellyfin.Data.Entities.Libraries } /// <summary> - /// Initializes a new instance of the <see cref="MetadataProvider"/> class. - /// </summary> - /// <remarks> - /// Default constructor. Protected due to required properties, but present because EF needs it. - /// </remarks> - protected MetadataProvider() - { - } - - /// <summary> - /// Gets or sets the id. + /// Gets the id. /// </summary> /// <remarks> /// Identity, Indexed, Required. /// </remarks> [DatabaseGenerated(DatabaseGeneratedOption.Identity)] - public int Id { get; protected set; } + public int Id { get; private set; } /// <summary> /// Gets or sets the name. @@ -49,14 +39,13 @@ namespace Jellyfin.Data.Entities.Libraries /// <remarks> /// Required, Max length = 1024. /// </remarks> - [Required] [MaxLength(1024)] [StringLength(1024)] public string Name { get; set; } /// <inheritdoc /> [ConcurrencyCheck] - public uint RowVersion { get; set; } + public uint RowVersion { get; private set; } /// <inheritdoc /> public void OnSavingChanges() diff --git a/Jellyfin.Data/Entities/Libraries/MetadataProviderId.cs b/Jellyfin.Data/Entities/Libraries/MetadataProviderId.cs index fcfb35bfac..44c1985181 100644 --- a/Jellyfin.Data/Entities/Libraries/MetadataProviderId.cs +++ b/Jellyfin.Data/Entities/Libraries/MetadataProviderId.cs @@ -14,8 +14,8 @@ namespace Jellyfin.Data.Entities.Libraries /// Initializes a new instance of the <see cref="MetadataProviderId"/> class. /// </summary> /// <param name="providerId">The provider id.</param> - /// <param name="itemMetadata">The metadata entity.</param> - public MetadataProviderId(string providerId, ItemMetadata itemMetadata) + /// <param name="metadataProvider">The metadata provider.</param> + public MetadataProviderId(string providerId, MetadataProvider metadataProvider) { if (string.IsNullOrEmpty(providerId)) { @@ -23,33 +23,17 @@ namespace Jellyfin.Data.Entities.Libraries } ProviderId = providerId; - - if (itemMetadata == null) - { - throw new ArgumentNullException(nameof(itemMetadata)); - } - - itemMetadata.Sources.Add(this); - } - - /// <summary> - /// Initializes a new instance of the <see cref="MetadataProviderId"/> class. - /// </summary> - /// <remarks> - /// Default constructor. Protected due to required properties, but present because EF needs it. - /// </remarks> - protected MetadataProviderId() - { + MetadataProvider = metadataProvider; } /// <summary> - /// Gets or sets the id. + /// Gets the id. /// </summary> /// <remarks> /// Identity, Indexed, Required. /// </remarks> [DatabaseGenerated(DatabaseGeneratedOption.Identity)] - public int Id { get; protected set; } + public int Id { get; private set; } /// <summary> /// Gets or sets the provider id. @@ -57,14 +41,13 @@ namespace Jellyfin.Data.Entities.Libraries /// <remarks> /// Required, Max length = 255. /// </remarks> - [Required] [MaxLength(255)] [StringLength(255)] public string ProviderId { get; set; } /// <inheritdoc /> [ConcurrencyCheck] - public uint RowVersion { get; set; } + public uint RowVersion { get; private set; } /// <summary> /// Gets or sets the metadata provider. diff --git a/Jellyfin.Data/Entities/Libraries/Movie.cs b/Jellyfin.Data/Entities/Libraries/Movie.cs index 08db904fa8..499fafd0e1 100644 --- a/Jellyfin.Data/Entities/Libraries/Movie.cs +++ b/Jellyfin.Data/Entities/Libraries/Movie.cs @@ -1,5 +1,3 @@ -#pragma warning disable CA2227 - using System.Collections.Generic; using Jellyfin.Data.Interfaces; @@ -13,18 +11,19 @@ namespace Jellyfin.Data.Entities.Libraries /// <summary> /// Initializes a new instance of the <see cref="Movie"/> class. /// </summary> - public Movie() + /// <param name="library">The library.</param> + public Movie(Library library) : base(library) { Releases = new HashSet<Release>(); MovieMetadata = new HashSet<MovieMetadata>(); } /// <inheritdoc /> - public virtual ICollection<Release> Releases { get; protected set; } + public virtual ICollection<Release> Releases { get; private set; } /// <summary> - /// Gets or sets a collection containing the metadata for this movie. + /// Gets a collection containing the metadata for this movie. /// </summary> - public virtual ICollection<MovieMetadata> MovieMetadata { get; protected set; } + public virtual ICollection<MovieMetadata> MovieMetadata { get; private set; } } } diff --git a/Jellyfin.Data/Entities/Libraries/MovieMetadata.cs b/Jellyfin.Data/Entities/Libraries/MovieMetadata.cs index aa1501a5cc..44b5f34d7f 100644 --- a/Jellyfin.Data/Entities/Libraries/MovieMetadata.cs +++ b/Jellyfin.Data/Entities/Libraries/MovieMetadata.cs @@ -1,8 +1,5 @@ -#pragma warning disable CA2227 - using System.Collections.Generic; using System.ComponentModel.DataAnnotations; -using System.ComponentModel.DataAnnotations.Schema; using Jellyfin.Data.Interfaces; namespace Jellyfin.Data.Entities.Libraries @@ -17,22 +14,9 @@ namespace Jellyfin.Data.Entities.Libraries /// </summary> /// <param name="title">The title or name of the movie.</param> /// <param name="language">ISO-639-3 3-character language codes.</param> - /// <param name="movie">The movie.</param> - public MovieMetadata(string title, string language, Movie movie) : base(title, language) + public MovieMetadata(string title, string language) : base(title, language) { Studios = new HashSet<Company>(); - - movie.MovieMetadata.Add(this); - } - - /// <summary> - /// Initializes a new instance of the <see cref="MovieMetadata"/> class. - /// </summary> - /// <remarks> - /// Default constructor. Protected due to required properties, but present because EF needs it. - /// </remarks> - protected MovieMetadata() - { } /// <summary> @@ -43,7 +27,7 @@ namespace Jellyfin.Data.Entities.Libraries /// </remarks> [MaxLength(1024)] [StringLength(1024)] - public string Outline { get; set; } + public string? Outline { get; set; } /// <summary> /// Gets or sets the tagline. @@ -53,7 +37,7 @@ namespace Jellyfin.Data.Entities.Libraries /// </remarks> [MaxLength(1024)] [StringLength(1024)] - public string Tagline { get; set; } + public string? Tagline { get; set; } /// <summary> /// Gets or sets the plot. @@ -63,7 +47,7 @@ namespace Jellyfin.Data.Entities.Libraries /// </remarks> [MaxLength(65535)] [StringLength(65535)] - public string Plot { get; set; } + public string? Plot { get; set; } /// <summary> /// Gets or sets the country code. @@ -73,15 +57,14 @@ namespace Jellyfin.Data.Entities.Libraries /// </remarks> [MaxLength(2)] [StringLength(2)] - public string Country { get; set; } + public string? Country { get; set; } /// <summary> - /// Gets or sets the studios that produced this movie. + /// Gets the studios that produced this movie. /// </summary> - public virtual ICollection<Company> Studios { get; protected set; } + public virtual ICollection<Company> Studios { get; private set; } /// <inheritdoc /> - [NotMapped] public ICollection<Company> Companies => Studios; } } diff --git a/Jellyfin.Data/Entities/Libraries/MusicAlbum.cs b/Jellyfin.Data/Entities/Libraries/MusicAlbum.cs index 06aff6f457..d6231bbf02 100644 --- a/Jellyfin.Data/Entities/Libraries/MusicAlbum.cs +++ b/Jellyfin.Data/Entities/Libraries/MusicAlbum.cs @@ -1,5 +1,3 @@ -#pragma warning disable CA2227 - using System.Collections.Generic; namespace Jellyfin.Data.Entities.Libraries @@ -12,20 +10,21 @@ namespace Jellyfin.Data.Entities.Libraries /// <summary> /// Initializes a new instance of the <see cref="MusicAlbum"/> class. /// </summary> - public MusicAlbum() + /// <param name="library">The library.</param> + public MusicAlbum(Library library) : base(library) { MusicAlbumMetadata = new HashSet<MusicAlbumMetadata>(); Tracks = new HashSet<Track>(); } /// <summary> - /// Gets or sets a collection containing the album metadata. + /// Gets a collection containing the album metadata. /// </summary> - public virtual ICollection<MusicAlbumMetadata> MusicAlbumMetadata { get; protected set; } + public virtual ICollection<MusicAlbumMetadata> MusicAlbumMetadata { get; private set; } /// <summary> - /// Gets or sets a collection containing the tracks. + /// Gets a collection containing the tracks. /// </summary> - public virtual ICollection<Track> Tracks { get; protected set; } + public virtual ICollection<Track> Tracks { get; private set; } } } diff --git a/Jellyfin.Data/Entities/Libraries/MusicAlbumMetadata.cs b/Jellyfin.Data/Entities/Libraries/MusicAlbumMetadata.cs index 05c0b0374b..691f3504fa 100644 --- a/Jellyfin.Data/Entities/Libraries/MusicAlbumMetadata.cs +++ b/Jellyfin.Data/Entities/Libraries/MusicAlbumMetadata.cs @@ -1,5 +1,3 @@ -#pragma warning disable CA2227 - using System.Collections.Generic; using System.ComponentModel.DataAnnotations; @@ -15,22 +13,9 @@ namespace Jellyfin.Data.Entities.Libraries /// </summary> /// <param name="title">The title or name of the album.</param> /// <param name="language">ISO-639-3 3-character language codes.</param> - /// <param name="album">The music album.</param> - public MusicAlbumMetadata(string title, string language, MusicAlbum album) : base(title, language) + public MusicAlbumMetadata(string title, string language) : base(title, language) { Labels = new HashSet<Company>(); - - album.MusicAlbumMetadata.Add(this); - } - - /// <summary> - /// Initializes a new instance of the <see cref="MusicAlbumMetadata"/> class. - /// </summary> - /// <remarks> - /// Default constructor. Protected due to required properties, but present because EF needs it. - /// </remarks> - protected MusicAlbumMetadata() - { } /// <summary> @@ -41,7 +26,7 @@ namespace Jellyfin.Data.Entities.Libraries /// </remarks> [MaxLength(255)] [StringLength(255)] - public string Barcode { get; set; } + public string? Barcode { get; set; } /// <summary> /// Gets or sets the label number. @@ -51,7 +36,7 @@ namespace Jellyfin.Data.Entities.Libraries /// </remarks> [MaxLength(255)] [StringLength(255)] - public string LabelNumber { get; set; } + public string? LabelNumber { get; set; } /// <summary> /// Gets or sets the country code. @@ -61,11 +46,11 @@ namespace Jellyfin.Data.Entities.Libraries /// </remarks> [MaxLength(2)] [StringLength(2)] - public string Country { get; set; } + public string? Country { get; set; } /// <summary> - /// Gets or sets a collection containing the labels. + /// Gets a collection containing the labels. /// </summary> - public virtual ICollection<Company> Labels { get; protected set; } + public virtual ICollection<Company> Labels { get; private set; } } } diff --git a/Jellyfin.Data/Entities/Libraries/Person.cs b/Jellyfin.Data/Entities/Libraries/Person.cs index af4c87b73c..8b67d920d2 100644 --- a/Jellyfin.Data/Entities/Libraries/Person.cs +++ b/Jellyfin.Data/Entities/Libraries/Person.cs @@ -1,5 +1,3 @@ -#pragma warning disable CA2227 - using System; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; @@ -32,23 +30,13 @@ namespace Jellyfin.Data.Entities.Libraries } /// <summary> - /// Initializes a new instance of the <see cref="Person"/> class. - /// </summary> - /// <remarks> - /// Default constructor. Protected due to required properties, but present because EF needs it. - /// </remarks> - protected Person() - { - } - - /// <summary> - /// Gets or sets the id. + /// Gets the id. /// </summary> /// <remarks> /// Identity, Indexed, Required. /// </remarks> [DatabaseGenerated(DatabaseGeneratedOption.Identity)] - public int Id { get; protected set; } + public int Id { get; private set; } /// <summary> /// Gets or sets the name. @@ -56,7 +44,6 @@ namespace Jellyfin.Data.Entities.Libraries /// <remarks> /// Required, Max length = 1024. /// </remarks> - [Required] [MaxLength(1024)] [StringLength(1024)] public string Name { get; set; } @@ -69,15 +56,15 @@ namespace Jellyfin.Data.Entities.Libraries /// </remarks> [MaxLength(256)] [StringLength(256)] - public string SourceId { get; set; } + public string? SourceId { get; set; } /// <summary> - /// Gets or sets the date added. + /// Gets the date added. /// </summary> /// <remarks> /// Required. /// </remarks> - public DateTime DateAdded { get; protected set; } + public DateTime DateAdded { get; private set; } /// <summary> /// Gets or sets the date modified. @@ -89,12 +76,12 @@ namespace Jellyfin.Data.Entities.Libraries /// <inheritdoc /> [ConcurrencyCheck] - public uint RowVersion { get; set; } + public uint RowVersion { get; private set; } /// <summary> - /// Gets or sets a list of metadata sources for this person. + /// Gets a list of metadata sources for this person. /// </summary> - public virtual ICollection<MetadataProviderId> Sources { get; protected set; } + public virtual ICollection<MetadataProviderId> Sources { get; private set; } /// <inheritdoc /> public void OnSavingChanges() diff --git a/Jellyfin.Data/Entities/Libraries/PersonRole.cs b/Jellyfin.Data/Entities/Libraries/PersonRole.cs index cd38ee83d0..7d40bdf448 100644 --- a/Jellyfin.Data/Entities/Libraries/PersonRole.cs +++ b/Jellyfin.Data/Entities/Libraries/PersonRole.cs @@ -1,6 +1,3 @@ -#pragma warning disable CA2227 - -using System; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; @@ -18,39 +15,23 @@ namespace Jellyfin.Data.Entities.Libraries /// Initializes a new instance of the <see cref="PersonRole"/> class. /// </summary> /// <param name="type">The role type.</param> - /// <param name="itemMetadata">The metadata.</param> - public PersonRole(PersonRoleType type, ItemMetadata itemMetadata) + /// <param name="person">The person.</param> + public PersonRole(PersonRoleType type, Person person) { Type = type; - - if (itemMetadata == null) - { - throw new ArgumentNullException(nameof(itemMetadata)); - } - - itemMetadata.PersonRoles.Add(this); - + Person = person; + Artwork = new HashSet<Artwork>(); Sources = new HashSet<MetadataProviderId>(); } /// <summary> - /// Initializes a new instance of the <see cref="PersonRole"/> class. - /// </summary> - /// <remarks> - /// Default constructor. Protected due to required properties, but present because EF needs it. - /// </remarks> - protected PersonRole() - { - } - - /// <summary> - /// Gets or sets the id. + /// Gets the id. /// </summary> /// <remarks> /// Identity, Indexed, Required. /// </remarks> [DatabaseGenerated(DatabaseGeneratedOption.Identity)] - public int Id { get; protected set; } + public int Id { get; private set; } /// <summary> /// Gets or sets the name of the person's role. @@ -60,7 +41,7 @@ namespace Jellyfin.Data.Entities.Libraries /// </remarks> [MaxLength(1024)] [StringLength(1024)] - public string Role { get; set; } + public string? Role { get; set; } /// <summary> /// Gets or sets the person's role type. @@ -72,7 +53,7 @@ namespace Jellyfin.Data.Entities.Libraries /// <inheritdoc /> [ConcurrencyCheck] - public uint RowVersion { get; protected set; } + public uint RowVersion { get; private set; } /// <summary> /// Gets or sets the person. @@ -80,16 +61,15 @@ namespace Jellyfin.Data.Entities.Libraries /// <remarks> /// Required. /// </remarks> - [Required] public virtual Person Person { get; set; } /// <inheritdoc /> - public virtual ICollection<Artwork> Artwork { get; protected set; } + public virtual ICollection<Artwork> Artwork { get; private set; } /// <summary> - /// Gets or sets a collection containing the metadata sources for this person role. + /// Gets a collection containing the metadata sources for this person role. /// </summary> - public virtual ICollection<MetadataProviderId> Sources { get; protected set; } + public virtual ICollection<MetadataProviderId> Sources { get; private set; } /// <inheritdoc /> public void OnSavingChanges() diff --git a/Jellyfin.Data/Entities/Libraries/Photo.cs b/Jellyfin.Data/Entities/Libraries/Photo.cs index 25562ec96f..4b459432bc 100644 --- a/Jellyfin.Data/Entities/Libraries/Photo.cs +++ b/Jellyfin.Data/Entities/Libraries/Photo.cs @@ -1,5 +1,3 @@ -#pragma warning disable CA2227 - using System.Collections.Generic; using Jellyfin.Data.Interfaces; @@ -13,18 +11,19 @@ namespace Jellyfin.Data.Entities.Libraries /// <summary> /// Initializes a new instance of the <see cref="Photo"/> class. /// </summary> - public Photo() + /// <param name="library">The library.</param> + public Photo(Library library) : base(library) { PhotoMetadata = new HashSet<PhotoMetadata>(); Releases = new HashSet<Release>(); } /// <summary> - /// Gets or sets a collection containing the photo metadata. + /// Gets a collection containing the photo metadata. /// </summary> - public virtual ICollection<PhotoMetadata> PhotoMetadata { get; protected set; } + public virtual ICollection<PhotoMetadata> PhotoMetadata { get; private set; } /// <inheritdoc /> - public virtual ICollection<Release> Releases { get; protected set; } + public virtual ICollection<Release> Releases { get; private set; } } } diff --git a/Jellyfin.Data/Entities/Libraries/PhotoMetadata.cs b/Jellyfin.Data/Entities/Libraries/PhotoMetadata.cs index ffc790b574..6c284307d7 100644 --- a/Jellyfin.Data/Entities/Libraries/PhotoMetadata.cs +++ b/Jellyfin.Data/Entities/Libraries/PhotoMetadata.cs @@ -1,5 +1,3 @@ -using System; - namespace Jellyfin.Data.Entities.Libraries { /// <summary> @@ -12,24 +10,7 @@ namespace Jellyfin.Data.Entities.Libraries /// </summary> /// <param name="title">The title or name of the photo.</param> /// <param name="language">ISO-639-3 3-character language codes.</param> - /// <param name="photo">The photo.</param> - public PhotoMetadata(string title, string language, Photo photo) : base(title, language) - { - if (photo == null) - { - throw new ArgumentNullException(nameof(photo)); - } - - photo.PhotoMetadata.Add(this); - } - - /// <summary> - /// Initializes a new instance of the <see cref="PhotoMetadata"/> class. - /// </summary> - /// <remarks> - /// Default constructor. Protected due to required properties, but present because EF needs it. - /// </remarks> - protected PhotoMetadata() + public PhotoMetadata(string title, string language) : base(title, language) { } } diff --git a/Jellyfin.Data/Entities/Libraries/Rating.cs b/Jellyfin.Data/Entities/Libraries/Rating.cs index 98226cd802..58c8fa49ef 100644 --- a/Jellyfin.Data/Entities/Libraries/Rating.cs +++ b/Jellyfin.Data/Entities/Libraries/Rating.cs @@ -1,4 +1,3 @@ -using System; using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; using Jellyfin.Data.Interfaces; @@ -14,37 +13,19 @@ namespace Jellyfin.Data.Entities.Libraries /// Initializes a new instance of the <see cref="Rating"/> class. /// </summary> /// <param name="value">The value.</param> - /// <param name="itemMetadata">The metadata.</param> - public Rating(double value, ItemMetadata itemMetadata) + public Rating(double value) { Value = value; - - if (itemMetadata == null) - { - throw new ArgumentNullException(nameof(itemMetadata)); - } - - itemMetadata.Ratings.Add(this); - } - - /// <summary> - /// Initializes a new instance of the <see cref="Rating"/> class. - /// </summary> - /// <remarks> - /// Default constructor. Protected due to required properties, but present because EF needs it. - /// </remarks> - protected Rating() - { } /// <summary> - /// Gets or sets the id. + /// Gets the id. /// </summary> /// <remarks> /// Identity, Indexed, Required. /// </remarks> [DatabaseGenerated(DatabaseGeneratedOption.Identity)] - public int Id { get; protected set; } + public int Id { get; private set; } /// <summary> /// Gets or sets the value. @@ -61,13 +42,13 @@ namespace Jellyfin.Data.Entities.Libraries /// <inheritdoc /> [ConcurrencyCheck] - public uint RowVersion { get; set; } + public uint RowVersion { get; private set; } /// <summary> /// Gets or sets the rating type. /// If this is <c>null</c> it's the internal user rating. /// </summary> - public virtual RatingSource RatingType { get; set; } + public virtual RatingSource? RatingType { get; set; } /// <inheritdoc /> public void OnSavingChanges() diff --git a/Jellyfin.Data/Entities/Libraries/RatingSource.cs b/Jellyfin.Data/Entities/Libraries/RatingSource.cs index 549f418042..0f3a073244 100644 --- a/Jellyfin.Data/Entities/Libraries/RatingSource.cs +++ b/Jellyfin.Data/Entities/Libraries/RatingSource.cs @@ -1,4 +1,3 @@ -using System; using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; using Jellyfin.Data.Interfaces; @@ -15,38 +14,20 @@ namespace Jellyfin.Data.Entities.Libraries /// </summary> /// <param name="minimumValue">The minimum value.</param> /// <param name="maximumValue">The maximum value.</param> - /// <param name="rating">The rating.</param> - public RatingSource(double minimumValue, double maximumValue, Rating rating) + public RatingSource(double minimumValue, double maximumValue) { MinimumValue = minimumValue; MaximumValue = maximumValue; - - if (rating == null) - { - throw new ArgumentNullException(nameof(rating)); - } - - rating.RatingType = this; - } - - /// <summary> - /// Initializes a new instance of the <see cref="RatingSource"/> class. - /// </summary> - /// <remarks> - /// Default constructor. Protected due to required properties, but present because EF needs it. - /// </remarks> - protected RatingSource() - { } /// <summary> - /// Gets or sets the id. + /// Gets the id. /// </summary> /// <remarks> /// Identity, Indexed, Required. /// </remarks> [DatabaseGenerated(DatabaseGeneratedOption.Identity)] - public int Id { get; protected set; } + public int Id { get; private set; } /// <summary> /// Gets or sets the name. @@ -56,7 +37,7 @@ namespace Jellyfin.Data.Entities.Libraries /// </remarks> [MaxLength(1024)] [StringLength(1024)] - public string Name { get; set; } + public string? Name { get; set; } /// <summary> /// Gets or sets the minimum value. @@ -76,12 +57,12 @@ namespace Jellyfin.Data.Entities.Libraries /// <inheritdoc /> [ConcurrencyCheck] - public uint RowVersion { get; set; } + public uint RowVersion { get; private set; } /// <summary> /// Gets or sets the metadata source. /// </summary> - public virtual MetadataProviderId Source { get; set; } + public virtual MetadataProviderId? Source { get; set; } /// <inheritdoc /> public void OnSavingChanges() diff --git a/Jellyfin.Data/Entities/Libraries/Release.cs b/Jellyfin.Data/Entities/Libraries/Release.cs index b633e08fb3..d3d52bf5cd 100644 --- a/Jellyfin.Data/Entities/Libraries/Release.cs +++ b/Jellyfin.Data/Entities/Libraries/Release.cs @@ -1,5 +1,3 @@ -#pragma warning disable CA2227 - using System; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; @@ -17,8 +15,7 @@ namespace Jellyfin.Data.Entities.Libraries /// Initializes a new instance of the <see cref="Release"/> class. /// </summary> /// <param name="name">The name of this release.</param> - /// <param name="owner">The owner of this release.</param> - public Release(string name, IHasReleases owner) + public Release(string name) { if (string.IsNullOrEmpty(name)) { @@ -27,30 +24,18 @@ namespace Jellyfin.Data.Entities.Libraries Name = name; - owner?.Releases.Add(this); - MediaFiles = new HashSet<MediaFile>(); Chapters = new HashSet<Chapter>(); } /// <summary> - /// Initializes a new instance of the <see cref="Release"/> class. - /// </summary> - /// <remarks> - /// Default constructor. Protected due to required properties, but present because EF needs it. - /// </remarks> - protected Release() - { - } - - /// <summary> - /// Gets or sets the id. + /// Gets the id. /// </summary> /// <remarks> /// Identity, Indexed, Required. /// </remarks> [DatabaseGenerated(DatabaseGeneratedOption.Identity)] - public int Id { get; protected set; } + public int Id { get; private set; } /// <summary> /// Gets or sets the name. @@ -58,24 +43,23 @@ namespace Jellyfin.Data.Entities.Libraries /// <remarks> /// Required, Max length = 1024. /// </remarks> - [Required] [MaxLength(1024)] [StringLength(1024)] public string Name { get; set; } /// <inheritdoc /> [ConcurrencyCheck] - public uint RowVersion { get; set; } + public uint RowVersion { get; private set; } /// <summary> - /// Gets or sets a collection containing the media files for this release. + /// Gets a collection containing the media files for this release. /// </summary> - public virtual ICollection<MediaFile> MediaFiles { get; protected set; } + public virtual ICollection<MediaFile> MediaFiles { get; private set; } /// <summary> - /// Gets or sets a collection containing the chapters for this release. + /// Gets a collection containing the chapters for this release. /// </summary> - public virtual ICollection<Chapter> Chapters { get; protected set; } + public virtual ICollection<Chapter> Chapters { get; private set; } /// <inheritdoc /> public void OnSavingChanges() diff --git a/Jellyfin.Data/Entities/Libraries/Season.cs b/Jellyfin.Data/Entities/Libraries/Season.cs index eb6674dbc3..fc110b49da 100644 --- a/Jellyfin.Data/Entities/Libraries/Season.cs +++ b/Jellyfin.Data/Entities/Libraries/Season.cs @@ -1,6 +1,3 @@ -#pragma warning disable CA2227 - -using System; using System.Collections.Generic; namespace Jellyfin.Data.Entities.Libraries @@ -13,43 +10,26 @@ namespace Jellyfin.Data.Entities.Libraries /// <summary> /// Initializes a new instance of the <see cref="Season"/> class. /// </summary> - /// <param name="series">The series.</param> - public Season(Series series) + /// <param name="library">The library.</param> + public Season(Library library) : base(library) { - if (series == null) - { - throw new ArgumentNullException(nameof(series)); - } - - series.Seasons.Add(this); - Episodes = new HashSet<Episode>(); SeasonMetadata = new HashSet<SeasonMetadata>(); } - /// <summary> - /// Initializes a new instance of the <see cref="Season"/> class. - /// </summary> - /// <remarks> - /// Default constructor. Protected due to required properties, but present because EF needs it. - /// </remarks> - protected Season() - { - } - /// <summary> /// Gets or sets the season number. /// </summary> public int? SeasonNumber { get; set; } /// <summary> - /// Gets or sets the season metadata. + /// Gets the season metadata. /// </summary> - public virtual ICollection<SeasonMetadata> SeasonMetadata { get; protected set; } + public virtual ICollection<SeasonMetadata> SeasonMetadata { get; private set; } /// <summary> - /// Gets or sets a collection containing the number of episodes. + /// Gets a collection containing the number of episodes. /// </summary> - public virtual ICollection<Episode> Episodes { get; protected set; } + public virtual ICollection<Episode> Episodes { get; private set; } } } diff --git a/Jellyfin.Data/Entities/Libraries/SeasonMetadata.cs b/Jellyfin.Data/Entities/Libraries/SeasonMetadata.cs index 7ce79756b2..da40a075f5 100644 --- a/Jellyfin.Data/Entities/Libraries/SeasonMetadata.cs +++ b/Jellyfin.Data/Entities/Libraries/SeasonMetadata.cs @@ -1,4 +1,3 @@ -using System; using System.ComponentModel.DataAnnotations; namespace Jellyfin.Data.Entities.Libraries @@ -13,24 +12,7 @@ namespace Jellyfin.Data.Entities.Libraries /// </summary> /// <param name="title">The title or name of the object.</param> /// <param name="language">ISO-639-3 3-character language codes.</param> - /// <param name="season">The season.</param> - public SeasonMetadata(string title, string language, Season season) : base(title, language) - { - if (season == null) - { - throw new ArgumentNullException(nameof(season)); - } - - season.SeasonMetadata.Add(this); - } - - /// <summary> - /// Initializes a new instance of the <see cref="SeasonMetadata"/> class. - /// </summary> - /// <remarks> - /// Default constructor. Protected due to required properties, but present because EF needs it. - /// </remarks> - protected SeasonMetadata() + public SeasonMetadata(string title, string language) : base(title, language) { } @@ -42,6 +24,6 @@ namespace Jellyfin.Data.Entities.Libraries /// </remarks> [MaxLength(1024)] [StringLength(1024)] - public string Outline { get; set; } + public string? Outline { get; set; } } } diff --git a/Jellyfin.Data/Entities/Libraries/Series.cs b/Jellyfin.Data/Entities/Libraries/Series.cs index 8c8317d14b..0354433e08 100644 --- a/Jellyfin.Data/Entities/Libraries/Series.cs +++ b/Jellyfin.Data/Entities/Libraries/Series.cs @@ -1,5 +1,3 @@ -#pragma warning disable CA2227 - using System; using System.Collections.Generic; @@ -13,9 +11,9 @@ namespace Jellyfin.Data.Entities.Libraries /// <summary> /// Initializes a new instance of the <see cref="Series"/> class. /// </summary> - public Series() + /// <param name="library">The library.</param> + public Series(Library library) : base(library) { - DateAdded = DateTime.UtcNow; Seasons = new HashSet<Season>(); SeriesMetadata = new HashSet<SeriesMetadata>(); } @@ -36,13 +34,13 @@ namespace Jellyfin.Data.Entities.Libraries public DateTime? FirstAired { get; set; } /// <summary> - /// Gets or sets a collection containing the series metadata. + /// Gets a collection containing the series metadata. /// </summary> - public virtual ICollection<SeriesMetadata> SeriesMetadata { get; protected set; } + public virtual ICollection<SeriesMetadata> SeriesMetadata { get; private set; } /// <summary> - /// Gets or sets a collection containing the seasons. + /// Gets a collection containing the seasons. /// </summary> - public virtual ICollection<Season> Seasons { get; protected set; } + public virtual ICollection<Season> Seasons { get; private set; } } } diff --git a/Jellyfin.Data/Entities/Libraries/SeriesMetadata.cs b/Jellyfin.Data/Entities/Libraries/SeriesMetadata.cs index 877dbfc69c..42115802c5 100644 --- a/Jellyfin.Data/Entities/Libraries/SeriesMetadata.cs +++ b/Jellyfin.Data/Entities/Libraries/SeriesMetadata.cs @@ -1,9 +1,5 @@ -#pragma warning disable CA2227 - -using System; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; -using System.ComponentModel.DataAnnotations.Schema; using Jellyfin.Data.Interfaces; namespace Jellyfin.Data.Entities.Libraries @@ -18,29 +14,11 @@ namespace Jellyfin.Data.Entities.Libraries /// </summary> /// <param name="title">The title or name of the object.</param> /// <param name="language">ISO-639-3 3-character language codes.</param> - /// <param name="series">The series.</param> - public SeriesMetadata(string title, string language, Series series) : base(title, language) + public SeriesMetadata(string title, string language) : base(title, language) { - if (series == null) - { - throw new ArgumentNullException(nameof(series)); - } - - series.SeriesMetadata.Add(this); - Networks = new HashSet<Company>(); } - /// <summary> - /// Initializes a new instance of the <see cref="SeriesMetadata"/> class. - /// </summary> - /// <remarks> - /// Default constructor. Protected due to required properties, but present because EF needs it. - /// </remarks> - protected SeriesMetadata() - { - } - /// <summary> /// Gets or sets the outline. /// </summary> @@ -49,7 +27,7 @@ namespace Jellyfin.Data.Entities.Libraries /// </remarks> [MaxLength(1024)] [StringLength(1024)] - public string Outline { get; set; } + public string? Outline { get; set; } /// <summary> /// Gets or sets the plot. @@ -59,7 +37,7 @@ namespace Jellyfin.Data.Entities.Libraries /// </remarks> [MaxLength(65535)] [StringLength(65535)] - public string Plot { get; set; } + public string? Plot { get; set; } /// <summary> /// Gets or sets the tagline. @@ -69,7 +47,7 @@ namespace Jellyfin.Data.Entities.Libraries /// </remarks> [MaxLength(1024)] [StringLength(1024)] - public string Tagline { get; set; } + public string? Tagline { get; set; } /// <summary> /// Gets or sets the country code. @@ -79,15 +57,14 @@ namespace Jellyfin.Data.Entities.Libraries /// </remarks> [MaxLength(2)] [StringLength(2)] - public string Country { get; set; } + public string? Country { get; set; } /// <summary> - /// Gets or sets a collection containing the networks. + /// Gets a collection containing the networks. /// </summary> - public virtual ICollection<Company> Networks { get; protected set; } + public virtual ICollection<Company> Networks { get; private set; } /// <inheritdoc /> - [NotMapped] public ICollection<Company> Companies => Networks; } } diff --git a/Jellyfin.Data/Entities/Libraries/Track.cs b/Jellyfin.Data/Entities/Libraries/Track.cs index 782bfb5ce7..d354000337 100644 --- a/Jellyfin.Data/Entities/Libraries/Track.cs +++ b/Jellyfin.Data/Entities/Libraries/Track.cs @@ -1,6 +1,3 @@ -#pragma warning disable CA2227 - -using System; using System.Collections.Generic; using Jellyfin.Data.Interfaces; @@ -14,41 +11,24 @@ namespace Jellyfin.Data.Entities.Libraries /// <summary> /// Initializes a new instance of the <see cref="Track"/> class. /// </summary> - /// <param name="album">The album.</param> - public Track(MusicAlbum album) + /// <param name="library">The library.</param> + public Track(Library library) : base(library) { - if (album == null) - { - throw new ArgumentNullException(nameof(album)); - } - - album.Tracks.Add(this); - Releases = new HashSet<Release>(); TrackMetadata = new HashSet<TrackMetadata>(); } - /// <summary> - /// Initializes a new instance of the <see cref="Track"/> class. - /// </summary> - /// <remarks> - /// Default constructor. Protected due to required properties, but present because EF needs it. - /// </remarks> - protected Track() - { - } - /// <summary> /// Gets or sets the track number. /// </summary> public int? TrackNumber { get; set; } /// <inheritdoc /> - public virtual ICollection<Release> Releases { get; protected set; } + public virtual ICollection<Release> Releases { get; private set; } /// <summary> - /// Gets or sets a collection containing the track metadata. + /// Gets a collection containing the track metadata. /// </summary> - public virtual ICollection<TrackMetadata> TrackMetadata { get; protected set; } + public virtual ICollection<TrackMetadata> TrackMetadata { get; private set; } } } diff --git a/Jellyfin.Data/Entities/Libraries/TrackMetadata.cs b/Jellyfin.Data/Entities/Libraries/TrackMetadata.cs index 321f93bf2e..042d2b90db 100644 --- a/Jellyfin.Data/Entities/Libraries/TrackMetadata.cs +++ b/Jellyfin.Data/Entities/Libraries/TrackMetadata.cs @@ -1,5 +1,3 @@ -using System; - namespace Jellyfin.Data.Entities.Libraries { /// <summary> @@ -12,24 +10,7 @@ namespace Jellyfin.Data.Entities.Libraries /// </summary> /// <param name="title">The title or name of the object.</param> /// <param name="language">ISO-639-3 3-character language codes.</param> - /// <param name="track">The track.</param> - public TrackMetadata(string title, string language, Track track) : base(title, language) - { - if (track == null) - { - throw new ArgumentNullException(nameof(track)); - } - - track.TrackMetadata.Add(this); - } - - /// <summary> - /// Initializes a new instance of the <see cref="TrackMetadata"/> class. - /// </summary> - /// <remarks> - /// Default constructor. Protected due to required properties, but present because EF needs it. - /// </remarks> - protected TrackMetadata() + public TrackMetadata(string title, string language) : base(title, language) { } } diff --git a/Jellyfin.Data/Entities/Permission.cs b/Jellyfin.Data/Entities/Permission.cs index d92e5d9d25..6d2e68077c 100644 --- a/Jellyfin.Data/Entities/Permission.cs +++ b/Jellyfin.Data/Entities/Permission.cs @@ -1,3 +1,6 @@ +#pragma warning disable CA1711 // Identifiers should not have incorrect suffix + +using System; using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; using Jellyfin.Data.Enums; @@ -23,29 +26,26 @@ namespace Jellyfin.Data.Entities } /// <summary> - /// Initializes a new instance of the <see cref="Permission"/> class. - /// Default constructor. Protected due to required properties, but present because EF needs it. - /// </summary> - protected Permission() - { - } - - /// <summary> - /// Gets or sets the id of this permission. + /// Gets the id of this permission. /// </summary> /// <remarks> /// Identity, Indexed, Required. /// </remarks> [DatabaseGenerated(DatabaseGeneratedOption.Identity)] - public int Id { get; protected set; } + public int Id { get; private set; } + + /// <summary> + /// Gets or sets the id of the associated user. + /// </summary> + public Guid? UserId { get; set; } /// <summary> - /// Gets or sets the type of this permission. + /// Gets the type of this permission. /// </summary> /// <remarks> /// Required. /// </remarks> - public PermissionKind Kind { get; protected set; } + public PermissionKind Kind { get; private set; } /// <summary> /// Gets or sets a value indicating whether the associated user has this permission. @@ -57,7 +57,7 @@ namespace Jellyfin.Data.Entities /// <inheritdoc /> [ConcurrencyCheck] - public uint RowVersion { get; set; } + public uint RowVersion { get; private set; } /// <inheritdoc/> public void OnSavingChanges() diff --git a/Jellyfin.Data/Entities/Preference.cs b/Jellyfin.Data/Entities/Preference.cs index 4efddf2a41..a6ab275d31 100644 --- a/Jellyfin.Data/Entities/Preference.cs +++ b/Jellyfin.Data/Entities/Preference.cs @@ -24,29 +24,26 @@ namespace Jellyfin.Data.Entities } /// <summary> - /// Initializes a new instance of the <see cref="Preference"/> class. - /// Default constructor. Protected due to required properties, but present because EF needs it. - /// </summary> - protected Preference() - { - } - - /// <summary> - /// Gets or sets the id of this preference. + /// Gets the id of this preference. /// </summary> /// <remarks> /// Identity, Indexed, Required. /// </remarks> [DatabaseGenerated(DatabaseGeneratedOption.Identity)] - public int Id { get; protected set; } + public int Id { get; private set; } + + /// <summary> + /// Gets or sets the id of the associated user. + /// </summary> + public Guid? UserId { get; set; } /// <summary> - /// Gets or sets the type of this preference. + /// Gets the type of this preference. /// </summary> /// <remarks> /// Required. /// </remarks> - public PreferenceKind Kind { get; protected set; } + public PreferenceKind Kind { get; private set; } /// <summary> /// Gets or sets the value of this preference. @@ -54,14 +51,13 @@ namespace Jellyfin.Data.Entities /// <remarks> /// Required, Max length = 65535. /// </remarks> - [Required] [MaxLength(65535)] [StringLength(65535)] public string Value { get; set; } /// <inheritdoc/> [ConcurrencyCheck] - public uint RowVersion { get; set; } + public uint RowVersion { get; private set; } /// <inheritdoc/> public void OnSavingChanges() diff --git a/Jellyfin.Data/Entities/User.cs b/Jellyfin.Data/Entities/User.cs index 6d46819143..e309e54de9 100644 --- a/Jellyfin.Data/Entities/User.cs +++ b/Jellyfin.Data/Entities/User.cs @@ -1,10 +1,8 @@ -#pragma warning disable CA2227 - using System; using System.Collections.Generic; +using System.ComponentModel; using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; -using System.Globalization; using System.Linq; using System.Text.Json.Serialization; using Jellyfin.Data.Enums; @@ -51,6 +49,7 @@ namespace Jellyfin.Data.Entities PasswordResetProviderId = passwordResetProviderId; AccessSchedules = new HashSet<AccessSchedule>(); + DisplayPreferences = new HashSet<DisplayPreferences>(); ItemDisplayPreferences = new HashSet<ItemDisplayPreferences>(); // Groups = new HashSet<Group>(); Permissions = new HashSet<Permission>(); @@ -71,18 +70,7 @@ namespace Jellyfin.Data.Entities EnableAutoLogin = false; PlayDefaultAudioTrack = true; SubtitleMode = SubtitlePlaybackMode.Default; - SyncPlayAccess = SyncPlayAccess.CreateAndJoinGroups; - - AddDefaultPermissions(); - AddDefaultPreferences(); - } - - /// <summary> - /// Initializes a new instance of the <see cref="User"/> class. - /// Default constructor. Protected due to required properties, but present because EF needs it. - /// </summary> - protected User() - { + SyncPlayAccess = SyncPlayUserAccessType.CreateAndJoinGroups; } /// <summary> @@ -100,7 +88,6 @@ namespace Jellyfin.Data.Entities /// <remarks> /// Required, Max length = 255. /// </remarks> - [Required] [MaxLength(255)] [StringLength(255)] public string Username { get; set; } @@ -113,7 +100,7 @@ namespace Jellyfin.Data.Entities /// </remarks> [MaxLength(65535)] [StringLength(65535)] - public string Password { get; set; } + public string? Password { get; set; } /// <summary> /// Gets or sets the user's easy password, or <c>null</c> if none is set. @@ -123,7 +110,7 @@ namespace Jellyfin.Data.Entities /// </remarks> [MaxLength(65535)] [StringLength(65535)] - public string EasyPassword { get; set; } + public string? EasyPassword { get; set; } /// <summary> /// Gets or sets a value indicating whether the user must update their password. @@ -141,7 +128,7 @@ namespace Jellyfin.Data.Entities /// </remarks> [MaxLength(255)] [StringLength(255)] - public string AudioLanguagePreference { get; set; } + public string? AudioLanguagePreference { get; set; } /// <summary> /// Gets or sets the authentication provider id. @@ -149,7 +136,6 @@ namespace Jellyfin.Data.Entities /// <remarks> /// Required, Max length = 255. /// </remarks> - [Required] [MaxLength(255)] [StringLength(255)] public string AuthenticationProviderId { get; set; } @@ -160,7 +146,6 @@ namespace Jellyfin.Data.Entities /// <remarks> /// Required, Max length = 255. /// </remarks> - [Required] [MaxLength(255)] [StringLength(255)] public string PasswordResetProviderId { get; set; } @@ -217,7 +202,7 @@ namespace Jellyfin.Data.Entities /// </remarks> [MaxLength(255)] [StringLength(255)] - public string SubtitleLanguagePreference { get; set; } + public string? SubtitleLanguagePreference { get; set; } /// <summary> /// Gets or sets a value indicating whether missing episodes should be displayed. @@ -312,68 +297,57 @@ namespace Jellyfin.Data.Entities /// Gets or sets the user's profile image. Can be <c>null</c>. /// </summary> // [ForeignKey("UserId")] - public virtual ImageInfo ProfileImage { get; set; } + public virtual ImageInfo? ProfileImage { get; set; } /// <summary> - /// Gets or sets the user's display preferences. + /// Gets the user's display preferences. /// </summary> - /// <remarks> - /// Required. - /// </remarks> - [Required] - public virtual DisplayPreferences DisplayPreferences { get; set; } + public virtual ICollection<DisplayPreferences> DisplayPreferences { get; private set; } /// <summary> /// Gets or sets the level of sync play permissions this user has. /// </summary> - public SyncPlayAccess SyncPlayAccess { get; set; } + public SyncPlayUserAccessType SyncPlayAccess { get; set; } - /// <summary> - /// Gets or sets the row version. - /// </summary> - /// <remarks> - /// Required, Concurrency Token. - /// </remarks> + /// <inheritdoc /> [ConcurrencyCheck] - public uint RowVersion { get; set; } + public uint RowVersion { get; private set; } /// <summary> - /// Gets or sets the list of access schedules this user has. + /// Gets the list of access schedules this user has. /// </summary> - public virtual ICollection<AccessSchedule> AccessSchedules { get; protected set; } + public virtual ICollection<AccessSchedule> AccessSchedules { get; private set; } /// <summary> - /// Gets or sets the list of item display preferences. + /// Gets the list of item display preferences. /// </summary> - public virtual ICollection<ItemDisplayPreferences> ItemDisplayPreferences { get; protected set; } + public virtual ICollection<ItemDisplayPreferences> ItemDisplayPreferences { get; private set; } /* /// <summary> - /// Gets or sets the list of groups this user is a member of. + /// Gets the list of groups this user is a member of. /// </summary> - [ForeignKey("Group_Groups_Guid")] - public virtual ICollection<Group> Groups { get; protected set; } + public virtual ICollection<Group> Groups { get; private set; } */ /// <summary> - /// Gets or sets the list of permissions this user has. + /// Gets the list of permissions this user has. /// </summary> [ForeignKey("Permission_Permissions_Guid")] - public virtual ICollection<Permission> Permissions { get; protected set; } + public virtual ICollection<Permission> Permissions { get; private set; } /* /// <summary> - /// Gets or sets the list of provider mappings this user has. + /// Gets the list of provider mappings this user has. /// </summary> - [ForeignKey("ProviderMapping_ProviderMappings_Id")] - public virtual ICollection<ProviderMapping> ProviderMappings { get; protected set; } + public virtual ICollection<ProviderMapping> ProviderMappings { get; private set; } */ /// <summary> - /// Gets or sets the list of preferences this user has. + /// Gets the list of preferences this user has. /// </summary> [ForeignKey("Preference_Preferences_Guid")] - public virtual ICollection<Preference> Preferences { get; protected set; } + public virtual ICollection<Preference> Preferences { get; private set; } /// <inheritdoc/> public void OnSavingChanges() @@ -413,6 +387,44 @@ namespace Jellyfin.Data.Entities return Equals(val, string.Empty) ? Array.Empty<string>() : val.Split(Delimiter); } + /// <summary> + /// Gets the user's preferences for the given preference kind. + /// </summary> + /// <param name="preference">The preference kind.</param> + /// <typeparam name="T">Type of preference.</typeparam> + /// <returns>A {T} array containing the user's preference.</returns> + public T[] GetPreferenceValues<T>(PreferenceKind preference) + { + var val = Preferences.First(p => p.Kind == preference).Value; + if (string.IsNullOrEmpty(val)) + { + return Array.Empty<T>(); + } + + // Convert array of {string} to array of {T} + var converter = TypeDescriptor.GetConverter(typeof(T)); + var stringValues = val.Split(Delimiter); + var convertedCount = 0; + var parsedValues = new T[stringValues.Length]; + for (var i = 0; i < stringValues.Length; i++) + { + try + { + var parsedValue = converter.ConvertFromString(stringValues[i].Trim()); + if (parsedValue != null) + { + parsedValues[convertedCount++] = (T)parsedValue; + } + } + catch (FormatException) + { + // Unable to convert value + } + } + + return parsedValues[..convertedCount]; + } + /// <summary> /// Sets the specified preference to the given value. /// </summary> @@ -421,7 +433,19 @@ namespace Jellyfin.Data.Entities public void SetPreference(PreferenceKind preference, string[] values) { Preferences.First(p => p.Kind == preference).Value - = string.Join(Delimiter.ToString(CultureInfo.InvariantCulture), values); + = string.Join(Delimiter, values); + } + + /// <summary> + /// Sets the specified preference to the given value. + /// </summary> + /// <param name="preference">The preference kind.</param> + /// <param name="values">The values.</param> + /// <typeparam name="T">The type of value.</typeparam> + public void SetPreference<T>(PreferenceKind preference, T[] values) + { + Preferences.First(p => p.Kind == preference).Value + = string.Join(Delimiter, values); } /// <summary> @@ -441,21 +465,14 @@ namespace Jellyfin.Data.Entities /// <returns><c>True</c> if the folder is in the user's grouped folders.</returns> public bool IsFolderGrouped(Guid id) { - return GetPreference(PreferenceKind.GroupedFolders).Any(i => new Guid(i) == id); - } - - private static bool IsParentalScheduleAllowed(AccessSchedule schedule, DateTime date) - { - var localTime = date.ToLocalTime(); - var hour = localTime.TimeOfDay.TotalHours; - - return DayOfWeekHelper.GetDaysOfWeek(schedule.DayOfWeek).Contains(localTime.DayOfWeek) - && hour >= schedule.StartHour - && hour <= schedule.EndHour; + return Array.IndexOf(GetPreferenceValues<Guid>(PreferenceKind.GroupedFolders), id) != -1; } + /// <summary> + /// Initializes the default permissions for a user. Should only be called on user creation. + /// </summary> // TODO: make these user configurable? - private void AddDefaultPermissions() + public void AddDefaultPermissions() { Permissions.Add(new Permission(PermissionKind.IsAdministrator, false)); Permissions.Add(new Permission(PermissionKind.IsDisabled, false)); @@ -480,12 +497,25 @@ namespace Jellyfin.Data.Entities Permissions.Add(new Permission(PermissionKind.EnableRemoteControlOfOtherUsers, false)); } - private void AddDefaultPreferences() + /// <summary> + /// Initializes the default preferences. Should only be called on user creation. + /// </summary> + public void AddDefaultPreferences() { foreach (var val in Enum.GetValues(typeof(PreferenceKind)).Cast<PreferenceKind>()) { Preferences.Add(new Preference(val, string.Empty)); } } + + private static bool IsParentalScheduleAllowed(AccessSchedule schedule, DateTime date) + { + var localTime = date.ToLocalTime(); + var hour = localTime.TimeOfDay.TotalHours; + + return DayOfWeekHelper.GetDaysOfWeek(schedule.DayOfWeek).Contains(localTime.DayOfWeek) + && hour >= schedule.StartHour + && hour <= schedule.EndHour; + } } } diff --git a/Jellyfin.Data/Enums/BaseItemKind.cs b/Jellyfin.Data/Enums/BaseItemKind.cs new file mode 100644 index 0000000000..aac30279ed --- /dev/null +++ b/Jellyfin.Data/Enums/BaseItemKind.cs @@ -0,0 +1,190 @@ +namespace Jellyfin.Data.Enums +{ + /// <summary> + /// The base item kind. + /// </summary> + /// <remarks> + /// This enum is generated from all classes that inherit from <c>BaseItem</c>. + /// </remarks> + public enum BaseItemKind + { + /// <summary> + /// Item is aggregate folder. + /// </summary> + AggregateFolder, + + /// <summary> + /// Item is audio. + /// </summary> + Audio, + + /// <summary> + /// Item is audio book. + /// </summary> + AudioBook, + + /// <summary> + /// Item is base plugin folder. + /// </summary> + BasePluginFolder, + + /// <summary> + /// Item is book. + /// </summary> + Book, + + /// <summary> + /// Item is box set. + /// </summary> + BoxSet, + + /// <summary> + /// Item is channel. + /// </summary> + Channel, + + /// <summary> + /// Item is channel folder item. + /// </summary> + ChannelFolderItem, + + /// <summary> + /// Item is collection folder. + /// </summary> + CollectionFolder, + + /// <summary> + /// Item is episode. + /// </summary> + Episode, + + /// <summary> + /// Item is folder. + /// </summary> + Folder, + + /// <summary> + /// Item is genre. + /// </summary> + Genre, + + /// <summary> + /// Item is manual playlists folder. + /// </summary> + ManualPlaylistsFolder, + + /// <summary> + /// Item is movie. + /// </summary> + Movie, + + /// <summary> + /// Item is music album. + /// </summary> + MusicAlbum, + + /// <summary> + /// Item is music artist. + /// </summary> + MusicArtist, + + /// <summary> + /// Item is music genre. + /// </summary> + MusicGenre, + + /// <summary> + /// Item is music video. + /// </summary> + MusicVideo, + + /// <summary> + /// Item is person. + /// </summary> + Person, + + /// <summary> + /// Item is photo. + /// </summary> + Photo, + + /// <summary> + /// Item is photo album. + /// </summary> + PhotoAlbum, + + /// <summary> + /// Item is playlist. + /// </summary> + Playlist, + + /// <summary> + /// Item is program + /// </summary> + Program, + + /// <summary> + /// Item is recording. + /// </summary> + /// <remarks> + /// Manually added. + /// </remarks> + Recording, + + /// <summary> + /// Item is season. + /// </summary> + Season, + + /// <summary> + /// Item is series. + /// </summary> + Series, + + /// <summary> + /// Item is studio. + /// </summary> + Studio, + + /// <summary> + /// Item is trailer. + /// </summary> + Trailer, + + /// <summary> + /// Item is live tv channel. + /// </summary> + /// <remarks> + /// Type is overridden. + /// </remarks> + TvChannel, + + /// <summary> + /// Item is live tv program. + /// </summary> + /// <remarks> + /// Type is overridden. + /// </remarks> + TvProgram, + + /// <summary> + /// Item is user root folder. + /// </summary> + UserRootFolder, + + /// <summary> + /// Item is user view. + /// </summary> + UserView, + + /// <summary> + /// Item is video. + /// </summary> + Video, + + /// <summary> + /// Item is year. + /// </summary> + Year + } +} \ No newline at end of file diff --git a/Jellyfin.Data/Enums/HomeSectionType.cs b/Jellyfin.Data/Enums/HomeSectionType.cs index e597c9431a..9bcd097dc6 100644 --- a/Jellyfin.Data/Enums/HomeSectionType.cs +++ b/Jellyfin.Data/Enums/HomeSectionType.cs @@ -48,6 +48,11 @@ /// <summary> /// Live TV. /// </summary> - LiveTv = 8 + LiveTv = 8, + + /// <summary> + /// Continue Reading. + /// </summary> + ResumeBook = 9 } } diff --git a/Jellyfin.Data/Enums/SyncPlayAccessRequirementType.cs b/Jellyfin.Data/Enums/SyncPlayAccessRequirementType.cs new file mode 100644 index 0000000000..8c3e6cb17e --- /dev/null +++ b/Jellyfin.Data/Enums/SyncPlayAccessRequirementType.cs @@ -0,0 +1,28 @@ +namespace Jellyfin.Data.Enums +{ + /// <summary> + /// Enum SyncPlayAccessRequirementType. + /// </summary> + public enum SyncPlayAccessRequirementType + { + /// <summary> + /// User must have access to SyncPlay, in some form. + /// </summary> + HasAccess = 0, + + /// <summary> + /// User must be able to create groups. + /// </summary> + CreateGroup = 1, + + /// <summary> + /// User must be able to join groups. + /// </summary> + JoinGroup = 2, + + /// <summary> + /// User must be in a group. + /// </summary> + IsInGroup = 3 + } +} diff --git a/Jellyfin.Data/Enums/SyncPlayAccess.cs b/Jellyfin.Data/Enums/SyncPlayUserAccessType.cs similarity index 85% rename from Jellyfin.Data/Enums/SyncPlayAccess.cs rename to Jellyfin.Data/Enums/SyncPlayUserAccessType.cs index 8c13b37a13..030d16fb90 100644 --- a/Jellyfin.Data/Enums/SyncPlayAccess.cs +++ b/Jellyfin.Data/Enums/SyncPlayUserAccessType.cs @@ -1,9 +1,9 @@ namespace Jellyfin.Data.Enums { /// <summary> - /// Enum SyncPlayAccess. + /// Enum SyncPlayUserAccessType. /// </summary> - public enum SyncPlayAccess + public enum SyncPlayUserAccessType { /// <summary> /// User can create groups and join them. diff --git a/Jellyfin.Data/Enums/ViewType.cs b/Jellyfin.Data/Enums/ViewType.cs index 595429ab1b..c0fd7d448b 100644 --- a/Jellyfin.Data/Enums/ViewType.cs +++ b/Jellyfin.Data/Enums/ViewType.cs @@ -1,4 +1,4 @@ -namespace Jellyfin.Data.Enums +namespace Jellyfin.Data.Enums { /// <summary> /// An enum representing the type of view for a library or collection. @@ -6,33 +6,108 @@ public enum ViewType { /// <summary> - /// Shows banners. + /// Shows albums. /// </summary> - Banner = 0, + Albums = 0, /// <summary> - /// Shows a list of content. + /// Shows album artists. /// </summary> - List = 1, + AlbumArtists = 1, /// <summary> - /// Shows poster artwork. + /// Shows artists. /// </summary> - Poster = 2, + Artists = 2, /// <summary> - /// Shows poster artwork with a card containing the name and year. + /// Shows channels. /// </summary> - PosterCard = 3, + Channels = 3, /// <summary> - /// Shows a thumbnail. + /// Shows collections. /// </summary> - Thumb = 4, + Collections = 4, /// <summary> - /// Shows a thumbnail with a card containing the name and year. + /// Shows episodes. /// </summary> - ThumbCard = 5 + Episodes = 5, + + /// <summary> + /// Shows favorites. + /// </summary> + Favorites = 6, + + /// <summary> + /// Shows genres. + /// </summary> + Genres = 7, + + /// <summary> + /// Shows guide. + /// </summary> + Guide = 8, + + /// <summary> + /// Shows movies. + /// </summary> + Movies = 9, + + /// <summary> + /// Shows networks. + /// </summary> + Networks = 10, + + /// <summary> + /// Shows playlists. + /// </summary> + Playlists = 11, + + /// <summary> + /// Shows programs. + /// </summary> + Programs = 12, + + /// <summary> + /// Shows recordings. + /// </summary> + Recordings = 13, + + /// <summary> + /// Shows schedule. + /// </summary> + Schedule = 14, + + /// <summary> + /// Shows series. + /// </summary> + Series = 15, + + /// <summary> + /// Shows shows. + /// </summary> + Shows = 16, + + /// <summary> + /// Shows songs. + /// </summary> + Songs = 17, + + /// <summary> + /// Shows songs. + /// </summary> + Suggestions = 18, + + /// <summary> + /// Shows trailers. + /// </summary> + Trailers = 19, + + /// <summary> + /// Shows upcoming. + /// </summary> + Upcoming = 20 } } diff --git a/Jellyfin.Data/Interfaces/IHasPermissions.cs b/Jellyfin.Data/Interfaces/IHasPermissions.cs index 3be72259ad..85ee12ad74 100644 --- a/Jellyfin.Data/Interfaces/IHasPermissions.cs +++ b/Jellyfin.Data/Interfaces/IHasPermissions.cs @@ -2,7 +2,7 @@ using Jellyfin.Data.Entities; using Jellyfin.Data.Enums; -namespace Jellyfin.Data +namespace Jellyfin.Data.Interfaces { /// <summary> /// An abstraction representing an entity that has permissions. diff --git a/Jellyfin.Data/Jellyfin.Data.csproj b/Jellyfin.Data/Jellyfin.Data.csproj index 5038988f96..65bbd49da2 100644 --- a/Jellyfin.Data/Jellyfin.Data.csproj +++ b/Jellyfin.Data/Jellyfin.Data.csproj @@ -1,10 +1,9 @@ -<Project Sdk="Microsoft.NET.Sdk"> +<Project Sdk="Microsoft.NET.Sdk"> <PropertyGroup> - <TargetFrameworks>netstandard2.0;netstandard2.1</TargetFrameworks> + <TargetFramework>net5.0</TargetFramework> <GenerateAssemblyInfo>false</GenerateAssemblyInfo> <GenerateDocumentationFile>true</GenerateDocumentationFile> - <TreatWarningsAsErrors>true</TreatWarningsAsErrors> <PublishRepositoryUrl>true</PublishRepositoryUrl> <EmbedUntrackedSources>true</EmbedUntrackedSources> <IncludeSymbols>true</IncludeSymbols> @@ -19,30 +18,24 @@ <PropertyGroup> <Authors>Jellyfin Contributors</Authors> <PackageId>Jellyfin.Data</PackageId> - <VersionPrefix>10.7.0</VersionPrefix> + <VersionPrefix>10.8.0</VersionPrefix> <RepositoryUrl>https://github.com/jellyfin/jellyfin</RepositoryUrl> <PackageLicenseExpression>GPL-3.0-only</PackageLicenseExpression> </PropertyGroup> - <PropertyGroup Condition=" '$(Configuration)' == 'Debug' "> - <CodeAnalysisRuleSet>../jellyfin.ruleset</CodeAnalysisRuleSet> - </PropertyGroup> - <ItemGroup> - <PackageReference Include="Microsoft.SourceLink.GitHub" Version="1.0.0" PrivateAssets="All"/> + <PackageReference Include="Microsoft.SourceLink.GitHub" Version="1.0.0" PrivateAssets="All" /> </ItemGroup> <!-- Code analysers--> <ItemGroup Condition=" '$(Configuration)' == 'Debug' "> - <PackageReference Include="Microsoft.CodeAnalysis.FxCopAnalyzers" Version="2.9.8" PrivateAssets="All" /> <PackageReference Include="SerilogAnalyzer" Version="0.15.0" PrivateAssets="All" /> <PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="All" /> <PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" PrivateAssets="All" /> </ItemGroup> <ItemGroup> - <PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="3.1.9" /> - <PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="3.1.9" /> + <PackageReference Include="Microsoft.Extensions.Logging" Version="5.0.0" /> </ItemGroup> <ItemGroup> diff --git a/Jellyfin.Drawing.Skia/Jellyfin.Drawing.Skia.csproj b/Jellyfin.Drawing.Skia/Jellyfin.Drawing.Skia.csproj index c11ac5fb37..8cee5dcaee 100644 --- a/Jellyfin.Drawing.Skia/Jellyfin.Drawing.Skia.csproj +++ b/Jellyfin.Drawing.Skia/Jellyfin.Drawing.Skia.csproj @@ -6,11 +6,9 @@ </PropertyGroup> <PropertyGroup> - <TargetFramework>netstandard2.1</TargetFramework> + <TargetFramework>net5.0</TargetFramework> <GenerateAssemblyInfo>false</GenerateAssemblyInfo> <GenerateDocumentationFile>true</GenerateDocumentationFile> - <TreatWarningsAsErrors>true</TreatWarningsAsErrors> - <Nullable>enable</Nullable> </PropertyGroup> <ItemGroup> @@ -18,10 +16,10 @@ </ItemGroup> <ItemGroup> - <PackageReference Include="BlurHashSharp" Version="1.1.1" /> - <PackageReference Include="BlurHashSharp.SkiaSharp" Version="1.1.1" /> - <PackageReference Include="SkiaSharp" Version="2.80.2" /> - <PackageReference Include="SkiaSharp.NativeAssets.Linux" Version="2.80.2" /> + <PackageReference Include="BlurHashSharp" Version="1.2.0" /> + <PackageReference Include="BlurHashSharp.SkiaSharp" Version="1.2.0" /> + <PackageReference Include="SkiaSharp" Version="2.80.3" /> + <PackageReference Include="SkiaSharp.NativeAssets.Linux" Version="2.80.3" /> </ItemGroup> <ItemGroup> @@ -30,16 +28,16 @@ <ProjectReference Include="..\MediaBrowser.Common\MediaBrowser.Common.csproj" /> </ItemGroup> + <ItemGroup> + <!-- Needed for https://github.com/dotnet/roslyn-analyzers/issues/4382 which is in the SDK yet --> + <PackageReference Include="Microsoft.CodeAnalysis.NetAnalyzers" Version="5.0.3" PrivateAssets="All" /> + </ItemGroup> + <!-- Code analysers--> <ItemGroup Condition=" '$(Configuration)' == 'Debug' "> - <PackageReference Include="Microsoft.CodeAnalysis.FxCopAnalyzers" Version="2.9.8" PrivateAssets="All" /> <PackageReference Include="SerilogAnalyzer" Version="0.15.0" PrivateAssets="All" /> <PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="All" /> <PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" PrivateAssets="All" /> </ItemGroup> - <PropertyGroup Condition=" '$(Configuration)' == 'Debug' "> - <CodeAnalysisRuleSet>../jellyfin.ruleset</CodeAnalysisRuleSet> - </PropertyGroup> - </Project> diff --git a/Jellyfin.Drawing.Skia/SkiaEncoder.cs b/Jellyfin.Drawing.Skia/SkiaEncoder.cs index 6a9dbdae42..6d0a5ac2b9 100644 --- a/Jellyfin.Drawing.Skia/SkiaEncoder.cs +++ b/Jellyfin.Drawing.Skia/SkiaEncoder.cs @@ -3,9 +3,10 @@ using System.Collections.Generic; using System.Globalization; using System.IO; using BlurHashSharp.SkiaSharp; +using Diacritics.Extensions; using MediaBrowser.Common.Configuration; +using MediaBrowser.Common.Extensions; using MediaBrowser.Controller.Drawing; -using MediaBrowser.Controller.Extensions; using MediaBrowser.Model.Drawing; using Microsoft.Extensions.Logging; using SkiaSharp; @@ -90,9 +91,6 @@ namespace Jellyfin.Drawing.Skia } } - private static bool IsTransparent(SKColor color) - => (color.Red == 255 && color.Green == 255 && color.Blue == 255) || color.Alpha == 0; - /// <summary> /// Convert a <see cref="ImageFormat"/> to a <see cref="SKEncodedImageFormat"/>. /// </summary> @@ -110,65 +108,6 @@ namespace Jellyfin.Drawing.Skia }; } - private static bool IsTransparentRow(SKBitmap bmp, int row) - { - for (var i = 0; i < bmp.Width; ++i) - { - if (!IsTransparent(bmp.GetPixel(i, row))) - { - return false; - } - } - - return true; - } - - private static bool IsTransparentColumn(SKBitmap bmp, int col) - { - for (var i = 0; i < bmp.Height; ++i) - { - if (!IsTransparent(bmp.GetPixel(col, i))) - { - return false; - } - } - - return true; - } - - private SKBitmap CropWhiteSpace(SKBitmap bitmap) - { - var topmost = 0; - while (topmost < bitmap.Height && IsTransparentRow(bitmap, topmost)) - { - topmost++; - } - - int bottommost = bitmap.Height; - while (bottommost >= 0 && IsTransparentRow(bitmap, bottommost - 1)) - { - bottommost--; - } - - var leftmost = 0; - while (leftmost < bitmap.Width && IsTransparentColumn(bitmap, leftmost)) - { - leftmost++; - } - - var rightmost = bitmap.Width; - while (rightmost >= 0 && IsTransparentColumn(bitmap, rightmost - 1)) - { - rightmost--; - } - - var newRect = SKRectI.Create(leftmost, topmost, rightmost - leftmost, bottommost - topmost); - - using var image = SKImage.FromBitmap(bitmap); - using var subset = image.Subset(newRect); - return SKBitmap.FromImage(subset); - } - /// <inheritdoc /> /// <exception cref="ArgumentNullException">The path is null.</exception> /// <exception cref="FileNotFoundException">The path is not valid.</exception> @@ -203,9 +142,6 @@ namespace Jellyfin.Drawing.Skia return BlurHashEncoder.Encode(xComp, yComp, path, 128, 128); } - private static bool HasDiacritics(string text) - => !string.Equals(text, text.RemoveDiacritics(), StringComparison.Ordinal); - private bool RequiresSpecialCharacterHack(string path) { for (int i = 0; i < path.Length; i++) @@ -216,7 +152,7 @@ namespace Jellyfin.Drawing.Skia } } - return HasDiacritics(path); + return path.HasDiacritics(); } private string NormalizePath(string path) @@ -227,8 +163,8 @@ namespace Jellyfin.Drawing.Skia } var tempPath = Path.Combine(_appPaths.TempDirectory, Guid.NewGuid() + Path.GetExtension(path)); - - Directory.CreateDirectory(Path.GetDirectoryName(tempPath)); + var directory = Path.GetDirectoryName(tempPath) ?? throw new ResourceNotFoundException($"Provided path ({tempPath}) is not valid."); + Directory.CreateDirectory(directory); File.Copy(path, tempPath, true); return tempPath; @@ -273,8 +209,8 @@ namespace Jellyfin.Drawing.Skia if (requiresTransparencyHack || forceCleanBitmap) { - using var codec = SKCodec.Create(NormalizePath(path)); - if (codec == null) + using SKCodec codec = SKCodec.Create(NormalizePath(path), out SKCodecResult res); + if (res != SKCodecResult.Success) { origin = GetSKEncodedOrigin(orientation); return null; @@ -311,22 +247,11 @@ namespace Jellyfin.Drawing.Skia return resultBitmap; } - private SKBitmap? GetBitmap(string path, bool cropWhitespace, bool forceAnalyzeBitmap, ImageOrientation? orientation, out SKEncodedOrigin origin) - { - if (cropWhitespace) - { - using var bitmap = Decode(path, forceAnalyzeBitmap, orientation, out origin); - return bitmap == null ? null : CropWhiteSpace(bitmap); - } - - return Decode(path, forceAnalyzeBitmap, orientation, out origin); - } - - private SKBitmap? GetBitmap(string path, bool cropWhitespace, bool autoOrient, ImageOrientation? orientation) + private SKBitmap? GetBitmap(string path, bool autoOrient, ImageOrientation? orientation) { if (autoOrient) { - var bitmap = GetBitmap(path, cropWhitespace, true, orientation, out var origin); + var bitmap = Decode(path, true, orientation, out var origin); if (bitmap != null && origin != SKEncodedOrigin.TopLeft) { @@ -339,16 +264,11 @@ namespace Jellyfin.Drawing.Skia return bitmap; } - return GetBitmap(path, cropWhitespace, false, orientation, out _); + return Decode(path, false, orientation, out _); } private SKBitmap OrientImage(SKBitmap bitmap, SKEncodedOrigin origin) { - if (origin == SKEncodedOrigin.Default) - { - return bitmap; - } - var needsFlip = origin == SKEncodedOrigin.LeftBottom || origin == SKEncodedOrigin.LeftTop || origin == SKEncodedOrigin.RightBottom @@ -434,7 +354,7 @@ namespace Jellyfin.Drawing.Skia 0f, kernelOffset, SKShaderTileMode.Clamp, - false); + true); canvas.DrawBitmap( source, @@ -446,7 +366,7 @@ namespace Jellyfin.Drawing.Skia } /// <inheritdoc/> - public string EncodeImage(string inputPath, DateTime dateModified, string outputPath, bool autoOrient, ImageOrientation? orientation, int quality, ImageProcessingOptions options, ImageFormat selectedOutputFormat) + public string EncodeImage(string inputPath, DateTime dateModified, string outputPath, bool autoOrient, ImageOrientation? orientation, int quality, ImageProcessingOptions options, ImageFormat outputFormat) { if (inputPath.Length == 0) { @@ -458,14 +378,14 @@ namespace Jellyfin.Drawing.Skia throw new ArgumentException("String can't be empty.", nameof(outputPath)); } - var skiaOutputFormat = GetImageFormat(selectedOutputFormat); + var skiaOutputFormat = GetImageFormat(outputFormat); var hasBackgroundColor = !string.IsNullOrWhiteSpace(options.BackgroundColor); var hasForegroundColor = !string.IsNullOrWhiteSpace(options.ForegroundLayer); var blur = options.Blur ?? 0; var hasIndicator = options.AddPlayedIndicator || options.UnplayedCount.HasValue || !options.PercentPlayed.Equals(0); - using var bitmap = GetBitmap(inputPath, options.CropWhiteSpace, autoOrient, orientation); + using var bitmap = GetBitmap(inputPath, autoOrient, orientation); if (bitmap == null) { throw new InvalidDataException($"Skia unable to read image {inputPath}"); @@ -473,9 +393,7 @@ namespace Jellyfin.Drawing.Skia var originalImageSize = new ImageDimensions(bitmap.Width, bitmap.Height); - if (!options.CropWhiteSpace - && options.HasDefaultOptions(inputPath, originalImageSize) - && !autoOrient) + if (options.HasDefaultOptions(inputPath, originalImageSize) && !autoOrient) { // Just spit out the original file if all the options are default return inputPath; @@ -493,7 +411,8 @@ namespace Jellyfin.Drawing.Skia // If all we're doing is resizing then we can stop now if (!hasBackgroundColor && !hasForegroundColor && blur == 0 && !hasIndicator) { - Directory.CreateDirectory(Path.GetDirectoryName(outputPath)); + var outputDirectory = Path.GetDirectoryName(outputPath) ?? throw new ArgumentException($"Provided path ({outputPath}) is not valid.", nameof(outputPath)); + Directory.CreateDirectory(outputDirectory); using var outputStream = new SKFileWStream(outputPath); using var pixmap = new SKPixmap(new SKImageInfo(width, height), resizedBitmap.GetPixels()); resizedBitmap.Encode(outputStream, skiaOutputFormat, quality); @@ -540,7 +459,8 @@ namespace Jellyfin.Drawing.Skia DrawIndicator(canvas, width, height, options); } - Directory.CreateDirectory(Path.GetDirectoryName(outputPath)); + var directory = Path.GetDirectoryName(outputPath) ?? throw new ArgumentException($"Provided path ({outputPath}) is not valid.", nameof(outputPath)); + Directory.CreateDirectory(directory); using (var outputStream = new SKFileWStream(outputPath)) { using (var pixmap = new SKPixmap(new SKImageInfo(width, height), saveBitmap.GetPixels())) diff --git a/Jellyfin.Drawing.Skia/StripCollageBuilder.cs b/Jellyfin.Drawing.Skia/StripCollageBuilder.cs index 0e94f87f6a..09a3702385 100644 --- a/Jellyfin.Drawing.Skia/StripCollageBuilder.cs +++ b/Jellyfin.Drawing.Skia/StripCollageBuilder.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.IO; +using System.Text.RegularExpressions; using SkiaSharp; namespace Jellyfin.Drawing.Skia @@ -67,7 +68,7 @@ namespace Jellyfin.Drawing.Skia /// <param name="outputPath">The path at which to place the resulting collage image.</param> /// <param name="width">The desired width of the collage.</param> /// <param name="height">The desired height of the collage.</param> - public void BuildSquareCollage(string[] paths, string outputPath, int width, int height) + public void BuildSquareCollage(IReadOnlyList<string> paths, string outputPath, int width, int height) { using var bitmap = BuildSquareCollageBitmap(paths, width, height); using var outputStream = new SKFileWStream(outputPath); @@ -83,7 +84,7 @@ namespace Jellyfin.Drawing.Skia /// <param name="width">The desired width of the collage.</param> /// <param name="height">The desired height of the collage.</param> /// <param name="libraryName">The name of the library to draw on the collage.</param> - public void BuildThumbCollage(string[] paths, string outputPath, int width, int height, string? libraryName) + public void BuildThumbCollage(IReadOnlyList<string> paths, string outputPath, int width, int height, string? libraryName) { using var bitmap = BuildThumbCollageBitmap(paths, width, height, libraryName); using var outputStream = new SKFileWStream(outputPath); @@ -91,7 +92,7 @@ namespace Jellyfin.Drawing.Skia pixmap.Encode(outputStream, GetEncodedFormat(outputPath), 90); } - private SKBitmap BuildThumbCollageBitmap(string[] paths, int width, int height, string? libraryName) + private SKBitmap BuildThumbCollageBitmap(IReadOnlyList<string> paths, int width, int height, string? libraryName) { var bitmap = new SKBitmap(width, height); @@ -118,6 +119,16 @@ namespace Jellyfin.Drawing.Skia }; canvas.DrawRect(0, 0, width, height, paintColor); + var typeFace = SKTypeface.FromFamilyName("sans-serif", SKFontStyleWeight.Bold, SKFontStyleWidth.Normal, SKFontStyleSlant.Upright); + + // use the system fallback to find a typeface for the given CJK character + var nonCjkPattern = @"[^\p{IsCJKUnifiedIdeographs}\p{IsCJKUnifiedIdeographsExtensionA}\p{IsKatakana}\p{IsHiragana}\p{IsHangulSyllables}\p{IsHangulJamo}]"; + var filteredName = Regex.Replace(libraryName ?? string.Empty, nonCjkPattern, string.Empty); + if (!string.IsNullOrEmpty(filteredName)) + { + typeFace = SKFontManager.Default.MatchCharacter(null, SKFontStyleWeight.Bold, SKFontStyleWidth.Normal, SKFontStyleSlant.Upright, null, filteredName[0]); + } + // draw library name var textPaint = new SKPaint { @@ -125,7 +136,7 @@ namespace Jellyfin.Drawing.Skia Style = SKPaintStyle.Fill, TextSize = 112, TextAlign = SKTextAlign.Center, - Typeface = SKTypeface.FromFamilyName("sans-serif", SKFontStyleWeight.Bold, SKFontStyleWidth.Normal, SKFontStyleSlant.Upright), + Typeface = typeFace, IsAntialias = true }; @@ -141,14 +152,14 @@ namespace Jellyfin.Drawing.Skia return bitmap; } - private SKBitmap? GetNextValidImage(string[] paths, int currentIndex, out int newIndex) + private SKBitmap? GetNextValidImage(IReadOnlyList<string> paths, int currentIndex, out int newIndex) { var imagesTested = new Dictionary<int, int>(); SKBitmap? bitmap = null; - while (imagesTested.Count < paths.Length) + while (imagesTested.Count < paths.Count) { - if (currentIndex >= paths.Length) + if (currentIndex >= paths.Count) { currentIndex = 0; } @@ -169,7 +180,7 @@ namespace Jellyfin.Drawing.Skia return bitmap; } - private SKBitmap BuildSquareCollageBitmap(string[] paths, int width, int height) + private SKBitmap BuildSquareCollageBitmap(IReadOnlyList<string> paths, int width, int height) { var bitmap = new SKBitmap(width, height); var imageIndex = 0; diff --git a/Jellyfin.Networking/Configuration/NetworkConfiguration.cs b/Jellyfin.Networking/Configuration/NetworkConfiguration.cs new file mode 100644 index 0000000000..faf814c060 --- /dev/null +++ b/Jellyfin.Networking/Configuration/NetworkConfiguration.cs @@ -0,0 +1,230 @@ +#pragma warning disable CA1819 // Properties should not return arrays + +using System; + +namespace Jellyfin.Networking.Configuration +{ + /// <summary> + /// Defines the <see cref="NetworkConfiguration" />. + /// </summary> + public class NetworkConfiguration + { + /// <summary> + /// The default value for <see cref="HttpServerPortNumber"/>. + /// </summary> + public const int DefaultHttpPort = 8096; + + /// <summary> + /// The default value for <see cref="PublicHttpsPort"/> and <see cref="HttpsPortNumber"/>. + /// </summary> + public const int DefaultHttpsPort = 8920; + + private string _baseUrl = string.Empty; + + /// <summary> + /// Gets or sets a value indicating whether the server should force connections over HTTPS. + /// </summary> + public bool RequireHttps { get; set; } + + /// <summary> + /// Gets or sets the filesystem path of an X.509 certificate to use for SSL. + /// </summary> + public string CertificatePath { get; set; } = string.Empty; + + /// <summary> + /// Gets or sets the password required to access the X.509 certificate data in the file specified by <see cref="CertificatePath"/>. + /// </summary> + public string CertificatePassword { get; set; } = string.Empty; + + /// <summary> + /// Gets or sets a value used to specify the URL prefix that your Jellyfin instance can be accessed at. + /// </summary> + public string BaseUrl + { + get => _baseUrl; + set + { + // Normalize the start of the string + if (string.IsNullOrWhiteSpace(value)) + { + // If baseUrl is empty, set an empty prefix string + _baseUrl = string.Empty; + return; + } + + if (value[0] != '/') + { + // If baseUrl was not configured with a leading slash, append one for consistency + value = "/" + value; + } + + // Normalize the end of the string + if (value[^1] == '/') + { + // If baseUrl was configured with a trailing slash, remove it for consistency + value = value.Remove(value.Length - 1); + } + + _baseUrl = value; + } + } + + /// <summary> + /// Gets or sets the public HTTPS port. + /// </summary> + /// <value>The public HTTPS port.</value> + public int PublicHttpsPort { get; set; } = DefaultHttpsPort; + + /// <summary> + /// Gets or sets the HTTP server port number. + /// </summary> + /// <value>The HTTP server port number.</value> + public int HttpServerPortNumber { get; set; } = DefaultHttpPort; + + /// <summary> + /// Gets or sets the HTTPS server port number. + /// </summary> + /// <value>The HTTPS server port number.</value> + public int HttpsPortNumber { get; set; } = DefaultHttpsPort; + + /// <summary> + /// Gets or sets a value indicating whether to use HTTPS. + /// </summary> + /// <remarks> + /// In order for HTTPS to be used, in addition to setting this to true, valid values must also be + /// provided for <see cref="CertificatePath"/> and <see cref="CertificatePassword"/>. + /// </remarks> + public bool EnableHttps { get; set; } + + /// <summary> + /// Gets or sets the public mapped port. + /// </summary> + /// <value>The public mapped port.</value> + public int PublicPort { get; set; } = DefaultHttpPort; + + /// <summary> + /// Gets or sets a value indicating whether the http port should be mapped as part of UPnP automatic port forwarding. + /// </summary> + public bool UPnPCreateHttpPortMap { get; set; } + + /// <summary> + /// Gets or sets the UDPPortRange. + /// </summary> + public string UDPPortRange { get; set; } = string.Empty; + + /// <summary> + /// Gets or sets a value indicating whether gets or sets IPV6 capability. + /// </summary> + public bool EnableIPV6 { get; set; } + + /// <summary> + /// Gets or sets a value indicating whether gets or sets IPV4 capability. + /// </summary> + public bool EnableIPV4 { get; set; } = true; + + /// <summary> + /// Gets or sets a value indicating whether detailed SSDP logs are sent to the console/log. + /// "Emby.Dlna": "Debug" must be set in logging.default.json for this property to have any effect. + /// </summary> + public bool EnableSSDPTracing { get; set; } + + /// <summary> + /// Gets or sets the SSDPTracingFilter + /// Gets or sets a value indicating whether an IP address is to be used to filter the detailed ssdp logs that are being sent to the console/log. + /// If the setting "Emby.Dlna": "Debug" msut be set in logging.default.json for this property to work. + /// </summary> + public string SSDPTracingFilter { get; set; } = string.Empty; + + /// <summary> + /// Gets or sets the number of times SSDP UDP messages are sent. + /// </summary> + public int UDPSendCount { get; set; } = 2; + + /// <summary> + /// Gets or sets the delay between each groups of SSDP messages (in ms). + /// </summary> + public int UDPSendDelay { get; set; } = 100; + + /// <summary> + /// Gets or sets a value indicating whether address names that match <see cref="VirtualInterfaceNames"/> should be Ignore for the purposes of binding. + /// </summary> + public bool IgnoreVirtualInterfaces { get; set; } = true; + + /// <summary> + /// Gets or sets a value indicating the interfaces that should be ignored. The list can be comma separated. <seealso cref="IgnoreVirtualInterfaces"/>. + /// </summary> + public string VirtualInterfaceNames { get; set; } = "vEthernet*"; + + /// <summary> + /// Gets or sets the time (in seconds) between the pings of SSDP gateway monitor. + /// </summary> + public int GatewayMonitorPeriod { get; set; } = 60; + + /// <summary> + /// Gets a value indicating whether multi-socket binding is available. + /// </summary> + public bool EnableMultiSocketBinding { get; } = true; + + /// <summary> + /// Gets or sets a value indicating whether all IPv6 interfaces should be treated as on the internal network. + /// Depending on the address range implemented ULA ranges might not be used. + /// </summary> + public bool TrustAllIP6Interfaces { get; set; } + + /// <summary> + /// Gets or sets the ports that HDHomerun uses. + /// </summary> + public string HDHomerunPortRange { get; set; } = string.Empty; + + /// <summary> + /// Gets or sets the PublishedServerUriBySubnet + /// Gets or sets PublishedServerUri to advertise for specific subnets. + /// </summary> + public string[] PublishedServerUriBySubnet { get; set; } = Array.Empty<string>(); + + /// <summary> + /// Gets or sets a value indicating whether Autodiscovery tracing is enabled. + /// </summary> + public bool AutoDiscoveryTracing { get; set; } + + /// <summary> + /// Gets or sets a value indicating whether Autodiscovery is enabled. + /// </summary> + public bool AutoDiscovery { get; set; } = true; + + /// <summary> + /// Gets or sets the filter for remote IP connectivity. Used in conjuntion with <seealso cref="IsRemoteIPFilterBlacklist"/>. + /// </summary> + public string[] RemoteIPFilter { get; set; } = Array.Empty<string>(); + + /// <summary> + /// Gets or sets a value indicating whether <seealso cref="RemoteIPFilter"/> contains a blacklist or a whitelist. Default is a whitelist. + /// </summary> + public bool IsRemoteIPFilterBlacklist { get; set; } + + /// <summary> + /// Gets or sets a value indicating whether to enable automatic port forwarding. + /// </summary> + public bool EnableUPnP { get; set; } + + /// <summary> + /// Gets or sets a value indicating whether access outside of the LAN is permitted. + /// </summary> + public bool EnableRemoteAccess { get; set; } = true; + + /// <summary> + /// Gets or sets the subnets that are deemed to make up the LAN. + /// </summary> + public string[] LocalNetworkSubnets { get; set; } = Array.Empty<string>(); + + /// <summary> + /// Gets or sets the interface addresses which Jellyfin will bind to. If empty, all interfaces will be used. + /// </summary> + public string[] LocalNetworkAddresses { get; set; } = Array.Empty<string>(); + + /// <summary> + /// Gets or sets the known proxies. If the proxy is a network, it's added to the KnownNetworks. + /// </summary> + public string[] KnownProxies { get; set; } = Array.Empty<string>(); + } +} diff --git a/Jellyfin.Networking/Configuration/NetworkConfigurationExtensions.cs b/Jellyfin.Networking/Configuration/NetworkConfigurationExtensions.cs new file mode 100644 index 0000000000..8cbe398b07 --- /dev/null +++ b/Jellyfin.Networking/Configuration/NetworkConfigurationExtensions.cs @@ -0,0 +1,20 @@ +using MediaBrowser.Common.Configuration; + +namespace Jellyfin.Networking.Configuration +{ + /// <summary> + /// Defines the <see cref="NetworkConfigurationExtensions" />. + /// </summary> + public static class NetworkConfigurationExtensions + { + /// <summary> + /// Retrieves the network configuration. + /// </summary> + /// <param name="config">The <see cref="IConfigurationManager"/>.</param> + /// <returns>The <see cref="NetworkConfiguration"/>.</returns> + public static NetworkConfiguration GetNetworkConfiguration(this IConfigurationManager config) + { + return config.GetConfiguration<NetworkConfiguration>("network"); + } + } +} diff --git a/Jellyfin.Networking/Configuration/NetworkConfigurationFactory.cs b/Jellyfin.Networking/Configuration/NetworkConfigurationFactory.cs new file mode 100644 index 0000000000..ac0485d871 --- /dev/null +++ b/Jellyfin.Networking/Configuration/NetworkConfigurationFactory.cs @@ -0,0 +1,27 @@ +using System.Collections.Generic; +using MediaBrowser.Common.Configuration; + +namespace Jellyfin.Networking.Configuration +{ + /// <summary> + /// Defines the <see cref="NetworkConfigurationFactory" />. + /// </summary> + public class NetworkConfigurationFactory : IConfigurationFactory + { + /// <summary> + /// The GetConfigurations. + /// </summary> + /// <returns>The <see cref="IEnumerable{ConfigurationStore}"/>.</returns> + public IEnumerable<ConfigurationStore> GetConfigurations() + { + return new[] + { + new ConfigurationStore + { + Key = "network", + ConfigurationType = typeof(NetworkConfiguration) + } + }; + } + } +} diff --git a/Jellyfin.Networking/Jellyfin.Networking.csproj b/Jellyfin.Networking/Jellyfin.Networking.csproj new file mode 100644 index 0000000000..227a41ce44 --- /dev/null +++ b/Jellyfin.Networking/Jellyfin.Networking.csproj @@ -0,0 +1,23 @@ +<Project Sdk="Microsoft.NET.Sdk"> + <PropertyGroup> + <TargetFramework>net5.0</TargetFramework> + <GenerateAssemblyInfo>false</GenerateAssemblyInfo> + <GenerateDocumentationFile>true</GenerateDocumentationFile> + </PropertyGroup> + + <ItemGroup> + <Compile Include="..\SharedVersion.cs" /> + </ItemGroup> + + <!-- Code Analyzers--> + <ItemGroup Condition=" '$(Configuration)' == 'Debug' "> + <PackageReference Include="SerilogAnalyzer" Version="0.15.0" PrivateAssets="All" /> + <PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="All" /> + <PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" PrivateAssets="All" /> + </ItemGroup> + + <ItemGroup> + <ProjectReference Include="..\MediaBrowser.Common\MediaBrowser.Common.csproj" /> + <ProjectReference Include="..\MediaBrowser.Controller\MediaBrowser.Controller.csproj" /> + </ItemGroup> +</Project> diff --git a/Jellyfin.Networking/Manager/NetworkManager.cs b/Jellyfin.Networking/Manager/NetworkManager.cs new file mode 100644 index 0000000000..4078fd1269 --- /dev/null +++ b/Jellyfin.Networking/Manager/NetworkManager.cs @@ -0,0 +1,1380 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.Linq; +using System.Net; +using System.Net.NetworkInformation; +using System.Net.Sockets; +using System.Threading.Tasks; +using Jellyfin.Networking.Configuration; +using MediaBrowser.Common.Configuration; +using MediaBrowser.Common.Net; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Networking.Manager +{ + /// <summary> + /// Class to take care of network interface management. + /// Note: The normal collection methods and properties will not work with Collection{IPObject}. <see cref="MediaBrowser.Common.Net.NetworkExtensions"/>. + /// </summary> + public class NetworkManager : INetworkManager, IDisposable + { + /// <summary> + /// Contains the description of the interface along with its index. + /// </summary> + private readonly Dictionary<string, int> _interfaceNames; + + /// <summary> + /// Threading lock for network properties. + /// </summary> + private readonly object _intLock = new object(); + + /// <summary> + /// List of all interface addresses and masks. + /// </summary> + private readonly Collection<IPObject> _interfaceAddresses; + + /// <summary> + /// List of all interface MAC addresses. + /// </summary> + private readonly List<PhysicalAddress> _macAddresses; + + private readonly ILogger<NetworkManager> _logger; + + private readonly IConfigurationManager _configurationManager; + + private readonly object _eventFireLock; + + /// <summary> + /// Holds the bind address overrides. + /// </summary> + private readonly Dictionary<IPNetAddress, string> _publishedServerUrls; + + /// <summary> + /// Used to stop "event-racing conditions". + /// </summary> + private bool _eventfire; + + /// <summary> + /// Unfiltered user defined LAN subnets. (<see cref="NetworkConfiguration.LocalNetworkSubnets"/>) + /// or internal interface network subnets if undefined by user. + /// </summary> + private Collection<IPObject> _lanSubnets; + + /// <summary> + /// User defined list of subnets to excluded from the LAN. + /// </summary> + private Collection<IPObject> _excludedSubnets; + + /// <summary> + /// List of interface addresses to bind the WS. + /// </summary> + private Collection<IPObject> _bindAddresses; + + /// <summary> + /// List of interface addresses to exclude from bind. + /// </summary> + private Collection<IPObject> _bindExclusions; + + /// <summary> + /// Caches list of all internal filtered interface addresses and masks. + /// </summary> + private Collection<IPObject> _internalInterfaces; + + /// <summary> + /// Flag set when no custom LAN has been defined in the configuration. + /// </summary> + private bool _usingPrivateAddresses; + + /// <summary> + /// True if this object is disposed. + /// </summary> + private bool _disposed; + + /// <summary> + /// Initializes a new instance of the <see cref="NetworkManager"/> class. + /// </summary> + /// <param name="configurationManager">IServerConfigurationManager instance.</param> + /// <param name="logger">Logger to use for messages.</param> +#pragma warning disable CS8618 // Non-nullable field is uninitialized. : Values are set in UpdateSettings function. Compiler doesn't yet recognise this. + public NetworkManager(IConfigurationManager configurationManager, ILogger<NetworkManager> logger) + { + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _configurationManager = configurationManager ?? throw new ArgumentNullException(nameof(configurationManager)); + + _interfaceAddresses = new Collection<IPObject>(); + _macAddresses = new List<PhysicalAddress>(); + _interfaceNames = new Dictionary<string, int>(); + _publishedServerUrls = new Dictionary<IPNetAddress, string>(); + _eventFireLock = new object(); + + UpdateSettings(_configurationManager.GetNetworkConfiguration()); + + NetworkChange.NetworkAddressChanged += OnNetworkAddressChanged; + NetworkChange.NetworkAvailabilityChanged += OnNetworkAvailabilityChanged; + + _configurationManager.NamedConfigurationUpdated += ConfigurationUpdated; + } +#pragma warning restore CS8618 // Non-nullable field is uninitialized. + + /// <summary> + /// Event triggered on network changes. + /// </summary> + public event EventHandler? NetworkChanged; + + /// <summary> + /// Gets or sets a value indicating whether testing is taking place. + /// </summary> + public static string MockNetworkSettings { get; set; } = string.Empty; + + /// <summary> + /// Gets or sets a value indicating whether IP6 is enabled. + /// </summary> + public bool IsIP6Enabled { get; set; } + + /// <summary> + /// Gets or sets a value indicating whether IP4 is enabled. + /// </summary> + public bool IsIP4Enabled { get; set; } + + /// <inheritdoc/> + public Collection<IPObject> RemoteAddressFilter { get; private set; } + + /// <summary> + /// Gets a value indicating whether is all IPv6 interfaces are trusted as internal. + /// </summary> + public bool TrustAllIP6Interfaces { get; internal set; } + + /// <summary> + /// Gets the Published server override list. + /// </summary> + public Dictionary<IPNetAddress, string> PublishedServerUrls => _publishedServerUrls; + + /// <summary> + /// Creates a new network collection. + /// </summary> + /// <param name="source">Items to assign the collection, or null.</param> + /// <returns>The collection created.</returns> + public static Collection<IPObject> CreateCollection(IEnumerable<IPObject>? source = null) + { + var result = new Collection<IPObject>(); + if (source != null) + { + foreach (var item in source) + { + result.AddItem(item, false); + } + } + + return result; + } + + /// <inheritdoc/> + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + /// <inheritdoc/> + public IReadOnlyCollection<PhysicalAddress> GetMacAddresses() + { + // Populated in construction - so always has values. + return _macAddresses; + } + + /// <inheritdoc/> + public bool IsGatewayInterface(IPObject? addressObj) + { + var address = addressObj?.Address ?? IPAddress.None; + return _internalInterfaces.Any(i => i.Address.Equals(address) && i.Tag < 0); + } + + /// <inheritdoc/> + public bool IsGatewayInterface(IPAddress? addressObj) + { + return _internalInterfaces.Any(i => i.Address.Equals(addressObj ?? IPAddress.None) && i.Tag < 0); + } + + /// <inheritdoc/> + public Collection<IPObject> GetLoopbacks() + { + Collection<IPObject> nc = new Collection<IPObject>(); + if (IsIP4Enabled) + { + nc.AddItem(IPAddress.Loopback); + } + + if (IsIP6Enabled) + { + nc.AddItem(IPAddress.IPv6Loopback); + } + + return nc; + } + + /// <inheritdoc/> + public bool IsExcluded(IPAddress ip) + { + return _excludedSubnets.ContainsAddress(ip); + } + + /// <inheritdoc/> + public bool IsExcluded(EndPoint ip) + { + return ip != null && IsExcluded(((IPEndPoint)ip).Address); + } + + /// <inheritdoc/> + public Collection<IPObject> CreateIPCollection(string[] values, bool negated = false) + { + Collection<IPObject> col = new Collection<IPObject>(); + if (values == null) + { + return col; + } + + for (int a = 0; a < values.Length; a++) + { + string v = values[a].Trim(); + + try + { + if (v.StartsWith('!')) + { + if (negated) + { + AddToCollection(col, v[1..]); + } + } + else if (!negated) + { + AddToCollection(col, v); + } + } + catch (ArgumentException e) + { + _logger.LogWarning(e, "Ignoring LAN value {Value}.", v); + } + } + + return col; + } + + /// <inheritdoc/> + public Collection<IPObject> GetAllBindInterfaces(bool individualInterfaces = false) + { + int count = _bindAddresses.Count; + + if (count == 0) + { + if (_bindExclusions.Count > 0) + { + // Return all the interfaces except the ones specifically excluded. + return _interfaceAddresses.Exclude(_bindExclusions, false); + } + + if (individualInterfaces) + { + return new Collection<IPObject>(_interfaceAddresses); + } + + // No bind address and no exclusions, so listen on all interfaces. + Collection<IPObject> result = new Collection<IPObject>(); + + if (IsIP6Enabled && IsIP4Enabled) + { + // Kestrel source code shows it uses Sockets.DualMode - so this also covers IPAddress.Any + result.AddItem(IPAddress.IPv6Any); + } + else if (IsIP4Enabled) + { + result.AddItem(IPAddress.Any); + } + else if (IsIP6Enabled) + { + // Cannot use IPv6Any as Kestrel will bind to IPv4 addresses. + foreach (var iface in _interfaceAddresses) + { + if (iface.AddressFamily == AddressFamily.InterNetworkV6) + { + result.AddItem(iface.Address); + } + } + } + + return result; + } + + // Remove any excluded bind interfaces. + return _bindAddresses.Exclude(_bindExclusions, false); + } + + /// <inheritdoc/> + public string GetBindInterface(string source, out int? port) + { + if (!string.IsNullOrEmpty(source) && IPHost.TryParse(source, out IPHost host)) + { + return GetBindInterface(host, out port); + } + + return GetBindInterface(IPHost.None, out port); + } + + /// <inheritdoc/> + public string GetBindInterface(IPAddress source, out int? port) + { + return GetBindInterface(new IPNetAddress(source), out port); + } + + /// <inheritdoc/> + public string GetBindInterface(HttpRequest source, out int? port) + { + string result; + + if (source != null && IPHost.TryParse(source.Host.Host, out IPHost host)) + { + result = GetBindInterface(host, out port); + port ??= source.Host.Port; + } + else + { + result = GetBindInterface(IPNetAddress.None, out port); + port ??= source?.Host.Port; + } + + return result; + } + + /// <inheritdoc/> + public string GetBindInterface(IPObject source, out int? port) + { + port = null; + if (source == null) + { + throw new ArgumentNullException(nameof(source)); + } + + // Do we have a source? + bool haveSource = !source.Address.Equals(IPAddress.None); + bool isExternal = false; + + if (haveSource) + { + if (!IsIP6Enabled && source.AddressFamily == AddressFamily.InterNetworkV6) + { + _logger.LogWarning("IPv6 is disabled in Jellyfin, but enabled in the OS. This may affect how the interface is selected."); + } + + if (!IsIP4Enabled && source.AddressFamily == AddressFamily.InterNetwork) + { + _logger.LogWarning("IPv4 is disabled in Jellyfin, but enabled in the OS. This may affect how the interface is selected."); + } + + isExternal = !IsInLocalNetwork(source); + + if (MatchesPublishedServerUrl(source, isExternal, out string res, out port)) + { + _logger.LogInformation("{Source}: Using BindAddress {Address}:{Port}", source, res, port); + return res; + } + } + + _logger.LogDebug("GetBindInterface: Source: {HaveSource}, External: {IsExternal}:", haveSource, isExternal); + + // No preference given, so move on to bind addresses. + if (MatchesBindInterface(source, isExternal, out string result)) + { + return result; + } + + if (isExternal && MatchesExternalInterface(source, out result)) + { + return result; + } + + // Get the first LAN interface address that isn't a loopback. + var interfaces = CreateCollection( + _interfaceAddresses + .Exclude(_bindExclusions, false) + .Where(IsInLocalNetwork) + .OrderBy(p => p.Tag)); + + if (interfaces.Count > 0) + { + if (haveSource) + { + foreach (var intf in interfaces) + { + if (intf.Address.Equals(source.Address)) + { + result = FormatIP6String(intf.Address); + _logger.LogDebug("{Source}: GetBindInterface: Has found matching interface. {Result}", source, result); + return result; + } + } + + // Does the request originate in one of the interface subnets? + // (For systems with multiple internal network cards, and multiple subnets) + foreach (var intf in interfaces) + { + if (intf.Contains(source)) + { + result = FormatIP6String(intf.Address); + _logger.LogDebug("{Source}: GetBindInterface: Has source, matched best internal interface on range. {Result}", source, result); + return result; + } + } + } + + result = FormatIP6String(interfaces.First().Address); + _logger.LogDebug("{Source}: GetBindInterface: Matched first internal interface. {Result}", source, result); + return result; + } + + // There isn't any others, so we'll use the loopback. + result = IsIP6Enabled ? "::1" : "127.0.0.1"; + _logger.LogWarning("{Source}: GetBindInterface: Loopback {Result} returned.", source, result); + return result; + } + + /// <inheritdoc/> + public Collection<IPObject> GetInternalBindAddresses() + { + int count = _bindAddresses.Count; + + if (count == 0) + { + if (_bindExclusions.Count > 0) + { + // Return all the internal interfaces except the ones excluded. + return CreateCollection(_internalInterfaces.Where(p => !_bindExclusions.ContainsAddress(p))); + } + + // No bind address, so return all internal interfaces. + return CreateCollection(_internalInterfaces.Where(p => !p.IsLoopback())); + } + + return new Collection<IPObject>(_bindAddresses); + } + + /// <inheritdoc/> + public bool IsInLocalNetwork(IPObject address) + { + if (address == null) + { + throw new ArgumentNullException(nameof(address)); + } + + if (address.Equals(IPAddress.None)) + { + return false; + } + + // See conversation at https://github.com/jellyfin/jellyfin/pull/3515. + if (TrustAllIP6Interfaces && address.AddressFamily == AddressFamily.InterNetworkV6) + { + return true; + } + + // As private addresses can be redefined by Configuration.LocalNetworkAddresses + return _lanSubnets.ContainsAddress(address) && !_excludedSubnets.ContainsAddress(address); + } + + /// <inheritdoc/> + public bool IsInLocalNetwork(string address) + { + if (IPHost.TryParse(address, out IPHost ep)) + { + return _lanSubnets.ContainsAddress(ep) && !_excludedSubnets.ContainsAddress(ep); + } + + return false; + } + + /// <inheritdoc/> + public bool IsInLocalNetwork(IPAddress address) + { + if (address == null) + { + throw new ArgumentNullException(nameof(address)); + } + + // See conversation at https://github.com/jellyfin/jellyfin/pull/3515. + if (TrustAllIP6Interfaces && address.AddressFamily == AddressFamily.InterNetworkV6) + { + return true; + } + + // As private addresses can be redefined by Configuration.LocalNetworkAddresses + return _lanSubnets.ContainsAddress(address) && !_excludedSubnets.ContainsAddress(address); + } + + /// <inheritdoc/> + public bool IsPrivateAddressRange(IPObject address) + { + if (address == null) + { + throw new ArgumentNullException(nameof(address)); + } + + // See conversation at https://github.com/jellyfin/jellyfin/pull/3515. + if (TrustAllIP6Interfaces && address.AddressFamily == AddressFamily.InterNetworkV6) + { + return true; + } + else + { + return address.IsPrivateAddressRange(); + } + } + + /// <inheritdoc/> + public bool IsExcludedInterface(IPAddress address) + { + return _bindExclusions.ContainsAddress(address); + } + + /// <inheritdoc/> + public Collection<IPObject> GetFilteredLANSubnets(Collection<IPObject>? filter = null) + { + if (filter == null) + { + return _lanSubnets.Exclude(_excludedSubnets, true).AsNetworks(); + } + + return _lanSubnets.Exclude(filter, true); + } + + /// <inheritdoc/> + public bool IsValidInterfaceAddress(IPAddress address) + { + return _interfaceAddresses.ContainsAddress(address); + } + + /// <inheritdoc/> + public bool TryParseInterface(string token, out Collection<IPObject>? result) + { + result = null; + if (string.IsNullOrEmpty(token)) + { + return false; + } + + if (_interfaceNames != null && _interfaceNames.TryGetValue(token.ToLower(CultureInfo.InvariantCulture), out int index)) + { + result = new Collection<IPObject>(); + + _logger.LogInformation("Interface {Token} used in settings. Using its interface addresses.", token); + + // Replace interface tags with the interface IP's. + foreach (IPNetAddress iface in _interfaceAddresses) + { + if (Math.Abs(iface.Tag) == index + && ((IsIP4Enabled && iface.Address.AddressFamily == AddressFamily.InterNetwork) + || (IsIP6Enabled && iface.Address.AddressFamily == AddressFamily.InterNetworkV6))) + { + result.AddItem(iface, false); + } + } + + return true; + } + + return false; + } + + /// <inheritdoc/> + public bool HasRemoteAccess(IPAddress remoteIp) + { + var config = _configurationManager.GetNetworkConfiguration(); + if (config.EnableRemoteAccess) + { + // Comma separated list of IP addresses or IP/netmask entries for networks that will be allowed to connect remotely. + // If left blank, all remote addresses will be allowed. + if (RemoteAddressFilter.Count > 0 && !IsInLocalNetwork(remoteIp)) + { + // remoteAddressFilter is a whitelist or blacklist. + return RemoteAddressFilter.ContainsAddress(remoteIp) == !config.IsRemoteIPFilterBlacklist; + } + } + else if (!IsInLocalNetwork(remoteIp)) + { + // Remote not enabled. So everyone should be LAN. + return false; + } + + return true; + } + + /// <summary> + /// Reloads all settings and re-initialises the instance. + /// </summary> + /// <param name="configuration">The <see cref="NetworkConfiguration"/> to use.</param> + public void UpdateSettings(object configuration) + { + NetworkConfiguration config = (NetworkConfiguration)configuration ?? throw new ArgumentNullException(nameof(configuration)); + + IsIP4Enabled = Socket.OSSupportsIPv4 && config.EnableIPV4; + IsIP6Enabled = Socket.OSSupportsIPv6 && config.EnableIPV6; + + if (!IsIP6Enabled && !IsIP4Enabled) + { + _logger.LogError("IPv4 and IPv6 cannot both be disabled."); + IsIP4Enabled = true; + } + + TrustAllIP6Interfaces = config.TrustAllIP6Interfaces; + // UdpHelper.EnableMultiSocketBinding = config.EnableMultiSocketBinding; + + if (string.IsNullOrEmpty(MockNetworkSettings)) + { + InitialiseInterfaces(); + } + else // Used in testing only. + { + // Format is <IPAddress>,<Index>,<Name>: <next interface>. Set index to -ve to simulate a gateway. + var interfaceList = MockNetworkSettings.Split('|'); + foreach (var details in interfaceList) + { + var parts = details.Split(','); + var address = IPNetAddress.Parse(parts[0]); + var index = int.Parse(parts[1], CultureInfo.InvariantCulture); + address.Tag = index; + _interfaceAddresses.AddItem(address, false); + _interfaceNames[parts[2]] = Math.Abs(index); + } + + if (IsIP4Enabled) + { + _interfaceAddresses.AddItem(IPNetAddress.IP4Loopback); + } + + if (IsIP6Enabled) + { + _interfaceAddresses.AddItem(IPNetAddress.IP6Loopback); + } + } + + InitialiseLAN(config); + InitialiseBind(config); + InitialiseRemote(config); + InitialiseOverrides(config); + } + + /// <summary> + /// Protected implementation of Dispose pattern. + /// </summary> + /// <param name="disposing"><c>True</c> to dispose the managed state.</param> + protected virtual void Dispose(bool disposing) + { + if (!_disposed) + { + if (disposing) + { + _configurationManager.NamedConfigurationUpdated -= ConfigurationUpdated; + NetworkChange.NetworkAddressChanged -= OnNetworkAddressChanged; + NetworkChange.NetworkAvailabilityChanged -= OnNetworkAvailabilityChanged; + } + + _disposed = true; + } + } + + /// <summary> + /// Tries to identify the string and return an object of that class. + /// </summary> + /// <param name="addr">String to parse.</param> + /// <param name="result">IPObject to return.</param> + /// <returns><c>true</c> if the value parsed successfully, <c>false</c> otherwise.</returns> + private static bool TryParse(string addr, out IPObject result) + { + if (!string.IsNullOrEmpty(addr)) + { + // Is it an IP address + if (IPNetAddress.TryParse(addr, out IPNetAddress nw)) + { + result = nw; + return true; + } + + if (IPHost.TryParse(addr, out IPHost h)) + { + result = h; + return true; + } + } + + result = IPNetAddress.None; + return false; + } + + /// <summary> + /// Converts an IPAddress into a string. + /// Ipv6 addresses are returned in [ ], with their scope removed. + /// </summary> + /// <param name="address">Address to convert.</param> + /// <returns>URI safe conversion of the address.</returns> + private static string FormatIP6String(IPAddress address) + { + var str = address.ToString(); + if (address.AddressFamily == AddressFamily.InterNetworkV6) + { + int i = str.IndexOf("%", StringComparison.OrdinalIgnoreCase); + if (i != -1) + { + str = str.Substring(0, i); + } + + return $"[{str}]"; + } + + return str; + } + + private void ConfigurationUpdated(object? sender, ConfigurationUpdateEventArgs evt) + { + if (evt.Key.Equals("network", StringComparison.Ordinal)) + { + UpdateSettings((NetworkConfiguration)evt.NewConfiguration); + } + } + + /// <summary> + /// Checks the string to see if it matches any interface names. + /// </summary> + /// <param name="token">String to check.</param> + /// <param name="index">Interface index numbers that match.</param> + /// <returns><c>true</c> if an interface name matches the token, <c>False</c> otherwise.</returns> + private bool TryGetInterfaces(string token, [NotNullWhen(true)] out List<int>? index) + { + index = null; + + // Is it the name of an interface (windows) eg, Wireless LAN adapter Wireless Network Connection 1. + // Null check required here for automated testing. + if (_interfaceNames != null && token.Length > 1) + { + bool partial = token[^1] == '*'; + if (partial) + { + token = token[0..^1]; + } + + foreach ((string interfc, int interfcIndex) in _interfaceNames) + { + if ((!partial && string.Equals(interfc, token, StringComparison.OrdinalIgnoreCase)) + || (partial && interfc.StartsWith(token, true, CultureInfo.InvariantCulture))) + { + index ??= new List<int>(); + index.Add(interfcIndex); + } + } + } + + return index != null; + } + + /// <summary> + /// Parses a string and adds it into the collection, replacing any interface references. + /// </summary> + /// <param name="col"><see cref="Collection{IPObject}"/>Collection.</param> + /// <param name="token">String value to parse.</param> + private void AddToCollection(Collection<IPObject> col, string token) + { + // Is it the name of an interface (windows) eg, Wireless LAN adapter Wireless Network Connection 1. + // Null check required here for automated testing. + if (TryGetInterfaces(token, out var indices)) + { + _logger.LogInformation("Interface {Token} used in settings. Using its interface addresses.", token); + + // Replace all the interface tags with the interface IP's. + foreach (IPNetAddress iface in _interfaceAddresses) + { + if (indices.Contains(Math.Abs(iface.Tag)) + && ((IsIP4Enabled && iface.Address.AddressFamily == AddressFamily.InterNetwork) + || (IsIP6Enabled && iface.Address.AddressFamily == AddressFamily.InterNetworkV6))) + { + col.AddItem(iface); + } + } + } + else if (TryParse(token, out IPObject obj)) + { + // Expand if the ip address is "any". + if ((obj.Address.Equals(IPAddress.Any) && IsIP4Enabled) + || (obj.Address.Equals(IPAddress.IPv6Any) && IsIP6Enabled)) + { + foreach (IPNetAddress iface in _interfaceAddresses) + { + if (obj.AddressFamily == iface.AddressFamily) + { + col.AddItem(iface); + } + } + } + else if (!IsIP6Enabled) + { + // Remove IP6 addresses from multi-homed IPHosts. + obj.Remove(AddressFamily.InterNetworkV6); + if (!obj.IsIP6()) + { + col.AddItem(obj); + } + } + else if (!IsIP4Enabled) + { + // Remove IP4 addresses from multi-homed IPHosts. + obj.Remove(AddressFamily.InterNetwork); + if (obj.IsIP6()) + { + col.AddItem(obj); + } + } + else + { + col.AddItem(obj); + } + } + else + { + _logger.LogDebug("Invalid or unknown object {Token}.", token); + } + } + + /// <summary> + /// Handler for network change events. + /// </summary> + /// <param name="sender">Sender.</param> + /// <param name="e">A <see cref="NetworkAvailabilityEventArgs"/> containing network availability information.</param> + private void OnNetworkAvailabilityChanged(object? sender, NetworkAvailabilityEventArgs e) + { + _logger.LogDebug("Network availability changed."); + OnNetworkChanged(); + } + + /// <summary> + /// Handler for network change events. + /// </summary> + /// <param name="sender">Sender.</param> + /// <param name="e">An <see cref="EventArgs"/>.</param> + private void OnNetworkAddressChanged(object? sender, EventArgs e) + { + _logger.LogDebug("Network address change detected."); + OnNetworkChanged(); + } + + /// <summary> + /// Async task that waits for 2 seconds before re-initialising the settings, as typically these events fire multiple times in succession. + /// </summary> + /// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns> + private async Task OnNetworkChangeAsync() + { + try + { + await Task.Delay(2000).ConfigureAwait(false); + InitialiseInterfaces(); + // Recalculate LAN caches. + InitialiseLAN(_configurationManager.GetNetworkConfiguration()); + + NetworkChanged?.Invoke(this, EventArgs.Empty); + } + finally + { + _eventfire = false; + } + } + + /// <summary> + /// Triggers our event, and re-loads interface information. + /// </summary> + private void OnNetworkChanged() + { + lock (_eventFireLock) + { + if (!_eventfire) + { + _logger.LogDebug("Network Address Change Event."); + // As network events tend to fire one after the other only fire once every second. + _eventfire = true; + OnNetworkChangeAsync().GetAwaiter().GetResult(); + } + } + } + + /// <summary> + /// Parses the user defined overrides into the dictionary object. + /// Overrides are the equivalent of localised publishedServerUrl, enabling + /// different addresses to be advertised over different subnets. + /// format is subnet=ipaddress|host|uri + /// when subnet = 0.0.0.0, any external address matches. + /// </summary> + private void InitialiseOverrides(NetworkConfiguration config) + { + lock (_intLock) + { + _publishedServerUrls.Clear(); + string[] overrides = config.PublishedServerUriBySubnet; + if (overrides == null) + { + return; + } + + foreach (var entry in overrides) + { + var parts = entry.Split('='); + if (parts.Length != 2) + { + _logger.LogError("Unable to parse bind override: {Entry}", entry); + } + else + { + var replacement = parts[1].Trim(); + if (string.Equals(parts[0], "all", StringComparison.OrdinalIgnoreCase)) + { + _publishedServerUrls[new IPNetAddress(IPAddress.Broadcast)] = replacement; + } + else if (string.Equals(parts[0], "external", StringComparison.OrdinalIgnoreCase)) + { + _publishedServerUrls[new IPNetAddress(IPAddress.Any)] = replacement; + } + else if (TryParseInterface(parts[0], out Collection<IPObject>? addresses) && addresses != null) + { + foreach (IPNetAddress na in addresses) + { + _publishedServerUrls[na] = replacement; + } + } + else if (IPNetAddress.TryParse(parts[0], out IPNetAddress result)) + { + _publishedServerUrls[result] = replacement; + } + else + { + _logger.LogError("Unable to parse bind ip address. {Parts}", parts[1]); + } + } + } + } + } + + /// <summary> + /// Initialises the network bind addresses. + /// </summary> + private void InitialiseBind(NetworkConfiguration config) + { + lock (_intLock) + { + string[] lanAddresses = config.LocalNetworkAddresses; + + // Add virtual machine interface names to the list of bind exclusions, so that they are auto-excluded. + if (config.IgnoreVirtualInterfaces) + { + // each virtual interface name must be pre-pended with the exclusion symbol ! + var virtualInterfaceNames = config.VirtualInterfaceNames.Split(',').Select(p => "!" + p).ToArray(); + if (lanAddresses.Length > 0) + { + var newList = new string[lanAddresses.Length + virtualInterfaceNames.Length]; + Array.Copy(lanAddresses, newList, lanAddresses.Length); + Array.Copy(virtualInterfaceNames, 0, newList, lanAddresses.Length, virtualInterfaceNames.Length); + lanAddresses = newList; + } + else + { + lanAddresses = virtualInterfaceNames; + } + } + + // Read and parse bind addresses and exclusions, removing ones that don't exist. + _bindAddresses = CreateIPCollection(lanAddresses).ThatAreContainedInNetworks(_interfaceAddresses); + _bindExclusions = CreateIPCollection(lanAddresses, true).ThatAreContainedInNetworks(_interfaceAddresses); + _logger.LogInformation("Using bind addresses: {0}", _bindAddresses.AsString()); + _logger.LogInformation("Using bind exclusions: {0}", _bindExclusions.AsString()); + } + } + + /// <summary> + /// Initialises the remote address values. + /// </summary> + private void InitialiseRemote(NetworkConfiguration config) + { + lock (_intLock) + { + RemoteAddressFilter = CreateIPCollection(config.RemoteIPFilter); + } + } + + /// <summary> + /// Initialises internal LAN cache settings. + /// </summary> + private void InitialiseLAN(NetworkConfiguration config) + { + lock (_intLock) + { + _logger.LogDebug("Refreshing LAN information."); + + // Get configuration options. + string[] subnets = config.LocalNetworkSubnets; + + // Create lists from user settings. + + _lanSubnets = CreateIPCollection(subnets); + _excludedSubnets = CreateIPCollection(subnets, true).AsNetworks(); + + // If no LAN addresses are specified - all private subnets are deemed to be the LAN + _usingPrivateAddresses = _lanSubnets.Count == 0; + + // NOTE: The order of the commands generating the collection in this statement matters. + // Altering the order will cause the collections to be created incorrectly. + if (_usingPrivateAddresses) + { + _logger.LogDebug("Using LAN interface addresses as user provided no LAN details."); + // Internal interfaces must be private and not excluded. + _internalInterfaces = CreateCollection(_interfaceAddresses.Where(i => IsPrivateAddressRange(i) && !_excludedSubnets.ContainsAddress(i))); + + // Subnets are the same as the calculated internal interface. + _lanSubnets = new Collection<IPObject>(); + + // We must listen on loopback for LiveTV to function regardless of the settings. + if (IsIP6Enabled) + { + _lanSubnets.AddItem(IPNetAddress.IP6Loopback); + _lanSubnets.AddItem(IPNetAddress.Parse("fc00::/7")); // ULA + _lanSubnets.AddItem(IPNetAddress.Parse("fe80::/10")); // Site local + } + + if (IsIP4Enabled) + { + _lanSubnets.AddItem(IPNetAddress.IP4Loopback); + _lanSubnets.AddItem(IPNetAddress.Parse("10.0.0.0/8")); + _lanSubnets.AddItem(IPNetAddress.Parse("172.16.0.0/12")); + _lanSubnets.AddItem(IPNetAddress.Parse("192.168.0.0/16")); + } + } + else + { + // We must listen on loopback for LiveTV to function regardless of the settings. + if (IsIP6Enabled) + { + _lanSubnets.AddItem(IPNetAddress.IP6Loopback); + } + + if (IsIP4Enabled) + { + _lanSubnets.AddItem(IPNetAddress.IP4Loopback); + } + + // Internal interfaces must be private, not excluded and part of the LocalNetworkSubnet. + _internalInterfaces = CreateCollection(_interfaceAddresses.Where(IsInLocalNetwork)); + } + + _logger.LogInformation("Defined LAN addresses : {0}", _lanSubnets.AsString()); + _logger.LogInformation("Defined LAN exclusions : {0}", _excludedSubnets.AsString()); + _logger.LogInformation("Using LAN addresses: {0}", _lanSubnets.Exclude(_excludedSubnets, true).AsNetworks().AsString()); + } + } + + /// <summary> + /// Generate a list of all the interface ip addresses and submasks where that are in the active/unknown state. + /// Generate a list of all active mac addresses that aren't loopback addresses. + /// </summary> + private void InitialiseInterfaces() + { + lock (_intLock) + { + _logger.LogDebug("Refreshing interfaces."); + + _interfaceNames.Clear(); + _interfaceAddresses.Clear(); + _macAddresses.Clear(); + + try + { + IEnumerable<NetworkInterface> nics = NetworkInterface.GetAllNetworkInterfaces() + .Where(i => i.SupportsMulticast && i.OperationalStatus == OperationalStatus.Up); + + foreach (NetworkInterface adapter in nics) + { + try + { + IPInterfaceProperties ipProperties = adapter.GetIPProperties(); + PhysicalAddress mac = adapter.GetPhysicalAddress(); + + // populate mac list + if (adapter.NetworkInterfaceType != NetworkInterfaceType.Loopback && mac != null && mac != PhysicalAddress.None) + { + _macAddresses.Add(mac); + } + + // populate interface address list + foreach (UnicastIPAddressInformation info in ipProperties.UnicastAddresses) + { + if (IsIP4Enabled && info.Address.AddressFamily == AddressFamily.InterNetwork) + { + IPNetAddress nw = new IPNetAddress(info.Address, IPObject.MaskToCidr(info.IPv4Mask)) + { + // Keep the number of gateways on this interface, along with its index. + Tag = ipProperties.GetIPv4Properties().Index + }; + + int tag = nw.Tag; + if (ipProperties.GatewayAddresses.Count > 0 && !nw.IsLoopback()) + { + // -ve Tags signify the interface has a gateway. + nw.Tag *= -1; + } + + _interfaceAddresses.AddItem(nw, false); + + // Store interface name so we can use the name in Collections. + _interfaceNames[adapter.Description.ToLower(CultureInfo.InvariantCulture)] = tag; + _interfaceNames["eth" + tag.ToString(CultureInfo.InvariantCulture)] = tag; + } + else if (IsIP6Enabled && info.Address.AddressFamily == AddressFamily.InterNetworkV6) + { + IPNetAddress nw = new IPNetAddress(info.Address, (byte)info.PrefixLength) + { + // Keep the number of gateways on this interface, along with its index. + Tag = ipProperties.GetIPv6Properties().Index + }; + + int tag = nw.Tag; + if (ipProperties.GatewayAddresses.Count > 0 && !nw.IsLoopback()) + { + // -ve Tags signify the interface has a gateway. + nw.Tag *= -1; + } + + _interfaceAddresses.AddItem(nw, false); + + // Store interface name so we can use the name in Collections. + _interfaceNames[adapter.Description.ToLower(CultureInfo.InvariantCulture)] = tag; + _interfaceNames["eth" + tag.ToString(CultureInfo.InvariantCulture)] = tag; + } + } + } +#pragma warning disable CA1031 // Do not catch general exception types + catch (Exception ex) + { + // Ignore error, and attempt to continue. + _logger.LogError(ex, "Error encountered parsing interfaces."); + } +#pragma warning restore CA1031 // Do not catch general exception types + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error in InitialiseInterfaces."); + } + + // If for some reason we don't have an interface info, resolve our DNS name. + if (_interfaceAddresses.Count == 0) + { + _logger.LogError("No interfaces information available. Resolving DNS name."); + IPHost host = new IPHost(Dns.GetHostName()); + foreach (var a in host.GetAddresses()) + { + _interfaceAddresses.AddItem(a); + } + + if (_interfaceAddresses.Count == 0) + { + _logger.LogWarning("No interfaces information available. Using loopback."); + } + } + + if (IsIP4Enabled) + { + _interfaceAddresses.AddItem(IPNetAddress.IP4Loopback); + } + + if (IsIP6Enabled) + { + _interfaceAddresses.AddItem(IPNetAddress.IP6Loopback); + } + + _logger.LogDebug("Discovered {0} interfaces.", _interfaceAddresses.Count); + _logger.LogDebug("Interfaces addresses : {0}", _interfaceAddresses.AsString()); + } + } + + /// <summary> + /// Attempts to match the source against a user defined bind interface. + /// </summary> + /// <param name="source">IP source address to use.</param> + /// <param name="isInExternalSubnet">True if the source is in the external subnet.</param> + /// <param name="bindPreference">The published server url that matches the source address.</param> + /// <param name="port">The resultant port, if one exists.</param> + /// <returns><c>true</c> if a match is found, <c>false</c> otherwise.</returns> + private bool MatchesPublishedServerUrl(IPObject source, bool isInExternalSubnet, out string bindPreference, out int? port) + { + bindPreference = string.Empty; + port = null; + + // Check for user override. + foreach (var addr in _publishedServerUrls) + { + // Remaining. Match anything. + if (addr.Key.Address.Equals(IPAddress.Broadcast)) + { + bindPreference = addr.Value; + break; + } + else if ((addr.Key.Address.Equals(IPAddress.Any) || addr.Key.Address.Equals(IPAddress.IPv6Any)) && isInExternalSubnet) + { + // External. + bindPreference = addr.Value; + break; + } + else if (addr.Key.Contains(source)) + { + // Match ip address. + bindPreference = addr.Value; + break; + } + } + + if (string.IsNullOrEmpty(bindPreference)) + { + return false; + } + + // Has it got a port defined? + var parts = bindPreference.Split(':'); + if (parts.Length > 1) + { + if (int.TryParse(parts[1], out int p)) + { + bindPreference = parts[0]; + port = p; + } + } + + return true; + } + + /// <summary> + /// Attempts to match the source against a user defined bind interface. + /// </summary> + /// <param name="source">IP source address to use.</param> + /// <param name="isInExternalSubnet">True if the source is in the external subnet.</param> + /// <param name="result">The result, if a match is found.</param> + /// <returns><c>true</c> if a match is found, <c>false</c> otherwise.</returns> + private bool MatchesBindInterface(IPObject source, bool isInExternalSubnet, out string result) + { + result = string.Empty; + var addresses = _bindAddresses.Exclude(_bindExclusions, false); + + int count = addresses.Count; + if (count == 1 && (_bindAddresses[0].Equals(IPAddress.Any) || _bindAddresses[0].Equals(IPAddress.IPv6Any))) + { + // Ignore IPAny addresses. + count = 0; + } + + if (count != 0) + { + // Check to see if any of the bind interfaces are in the same subnet. + + IPAddress? defaultGateway = null; + IPAddress? bindAddress = null; + + if (isInExternalSubnet) + { + // Find all external bind addresses. Store the default gateway, but check to see if there is a better match first. + foreach (var addr in addresses.OrderBy(p => p.Tag)) + { + if (defaultGateway == null && !IsInLocalNetwork(addr)) + { + defaultGateway = addr.Address; + } + + if (bindAddress == null && addr.Contains(source)) + { + bindAddress = addr.Address; + } + + if (defaultGateway != null && bindAddress != null) + { + break; + } + } + } + else + { + // Look for the best internal address. + bindAddress = addresses + .Where(p => IsInLocalNetwork(p) && (p.Contains(source) || p.Equals(IPAddress.None))) + .OrderBy(p => p.Tag) + .FirstOrDefault()?.Address; + } + + if (bindAddress != null) + { + result = FormatIP6String(bindAddress); + _logger.LogDebug("{Source}: GetBindInterface: Has source, found a match bind interface subnets. {Result}", source, result); + return true; + } + + if (isInExternalSubnet && defaultGateway != null) + { + result = FormatIP6String(defaultGateway); + _logger.LogDebug("{Source}: GetBindInterface: Using first user defined external interface. {Result}", source, result); + return true; + } + + result = FormatIP6String(addresses[0].Address); + _logger.LogDebug("{Source}: GetBindInterface: Selected first user defined interface. {Result}", source, result); + + if (isInExternalSubnet) + { + _logger.LogWarning("{Source}: External request received, however, only an internal interface bind found.", source); + } + + return true; + } + + return false; + } + + /// <summary> + /// Attempts to match the source against an external interface. + /// </summary> + /// <param name="source">IP source address to use.</param> + /// <param name="result">The result, if a match is found.</param> + /// <returns><c>true</c> if a match is found, <c>false</c> otherwise.</returns> + private bool MatchesExternalInterface(IPObject source, out string result) + { + result = string.Empty; + // Get the first WAN interface address that isn't a loopback. + var extResult = _interfaceAddresses + .Exclude(_bindExclusions, false) + .Where(p => !IsInLocalNetwork(p)) + .OrderBy(p => p.Tag); + + if (extResult.Any()) + { + // Does the request originate in one of the interface subnets? + // (For systems with multiple internal network cards, and multiple subnets) + foreach (var intf in extResult) + { + if (!IsInLocalNetwork(intf) && intf.Contains(source)) + { + result = FormatIP6String(intf.Address); + _logger.LogDebug("{Source}: GetBindInterface: Selected best external on interface on range. {Result}", source, result); + return true; + } + } + + result = FormatIP6String(extResult.First().Address); + _logger.LogDebug("{Source}: GetBindInterface: Selected first external interface. {Result}", source, result); + return true; + } + + _logger.LogDebug("{Source}: External request received, but no WAN interface found. Need to route through internal network.", source); + return false; + } + } +} diff --git a/Jellyfin.Server.Implementations/Activity/ActivityManager.cs b/Jellyfin.Server.Implementations/Activity/ActivityManager.cs index 5926abfe0d..a3a2a8baff 100644 --- a/Jellyfin.Server.Implementations/Activity/ActivityManager.cs +++ b/Jellyfin.Server.Implementations/Activity/ActivityManager.cs @@ -27,7 +27,7 @@ namespace Jellyfin.Server.Implementations.Activity } /// <inheritdoc/> - public event EventHandler<GenericEventArgs<ActivityLogEntry>> EntryCreated; + public event EventHandler<GenericEventArgs<ActivityLogEntry>>? EntryCreated; /// <inheritdoc/> public async Task CreateAsync(ActivityLog entry) @@ -72,17 +72,26 @@ namespace Jellyfin.Server.Implementations.Activity }; } + /// <inheritdoc /> + public async Task CleanAsync(DateTime startDate) + { + await using var dbContext = _provider.CreateContext(); + var entries = dbContext.ActivityLogs + .AsQueryable() + .Where(entry => entry.DateCreated <= startDate); + + dbContext.RemoveRange(entries); + await dbContext.SaveChangesAsync().ConfigureAwait(false); + } + private static ActivityLogEntry ConvertToOldModel(ActivityLog entry) { - return new ActivityLogEntry + return new ActivityLogEntry(entry.Name, entry.Type, entry.UserId) { Id = entry.Id, - Name = entry.Name, Overview = entry.Overview, ShortOverview = entry.ShortOverview, - Type = entry.Type, ItemId = entry.ItemId, - UserId = entry.UserId, Date = entry.DateCreated, Severity = entry.LogSeverity }; diff --git a/Jellyfin.Server.Implementations/Events/Consumers/Security/AuthenticationSucceededLogger.cs b/Jellyfin.Server.Implementations/Events/Consumers/Security/AuthenticationSucceededLogger.cs index 2f9f44ed67..8b0bd84c66 100644 --- a/Jellyfin.Server.Implementations/Events/Consumers/Security/AuthenticationSucceededLogger.cs +++ b/Jellyfin.Server.Implementations/Events/Consumers/Security/AuthenticationSucceededLogger.cs @@ -29,20 +29,20 @@ namespace Jellyfin.Server.Implementations.Events.Consumers.Security } /// <inheritdoc /> - public async Task OnEvent(GenericEventArgs<AuthenticationResult> e) + public async Task OnEvent(GenericEventArgs<AuthenticationResult> eventArgs) { await _activityManager.CreateAsync(new ActivityLog( string.Format( CultureInfo.InvariantCulture, _localizationManager.GetLocalizedString("AuthenticationSucceededWithUserName"), - e.Argument.User.Name), + eventArgs.Argument.User.Name), "AuthenticationSucceeded", - e.Argument.User.Id) + eventArgs.Argument.User.Id) { ShortOverview = string.Format( CultureInfo.InvariantCulture, _localizationManager.GetLocalizedString("LabelIpAddressValue"), - e.Argument.SessionInfo.RemoteEndPoint), + eventArgs.Argument.SessionInfo.RemoteEndPoint), }).ConfigureAwait(false); } } diff --git a/Jellyfin.Server.Implementations/Events/Consumers/Session/PlaybackStartLogger.cs b/Jellyfin.Server.Implementations/Events/Consumers/Session/PlaybackStartLogger.cs index ec4a76e7ff..aa6015caae 100644 --- a/Jellyfin.Server.Implementations/Events/Consumers/Session/PlaybackStartLogger.cs +++ b/Jellyfin.Server.Implementations/Events/Consumers/Session/PlaybackStartLogger.cs @@ -98,7 +98,7 @@ namespace Jellyfin.Server.Implementations.Events.Consumers.Session return NotificationType.VideoPlayback.ToString(); } - return null; + return "Playback"; } } } diff --git a/Jellyfin.Server.Implementations/Events/Consumers/Session/PlaybackStopLogger.cs b/Jellyfin.Server.Implementations/Events/Consumers/Session/PlaybackStopLogger.cs index 51a882c143..1648b1b475 100644 --- a/Jellyfin.Server.Implementations/Events/Consumers/Session/PlaybackStopLogger.cs +++ b/Jellyfin.Server.Implementations/Events/Consumers/Session/PlaybackStopLogger.cs @@ -59,6 +59,12 @@ namespace Jellyfin.Server.Implementations.Events.Consumers.Session var user = eventArgs.Users[0]; + var notificationType = GetPlaybackStoppedNotificationType(item.MediaType); + if (notificationType == null) + { + return; + } + await _activityManager.CreateAsync(new ActivityLog( string.Format( CultureInfo.InvariantCulture, @@ -66,7 +72,7 @@ namespace Jellyfin.Server.Implementations.Events.Consumers.Session user.Username, GetItemName(item), eventArgs.DeviceName), - GetPlaybackStoppedNotificationType(item.MediaType), + notificationType, user.Id)) .ConfigureAwait(false); } @@ -88,7 +94,7 @@ namespace Jellyfin.Server.Implementations.Events.Consumers.Session return name; } - private static string GetPlaybackStoppedNotificationType(string mediaType) + private static string? GetPlaybackStoppedNotificationType(string mediaType) { if (string.Equals(mediaType, MediaType.Audio, StringComparison.OrdinalIgnoreCase)) { diff --git a/Jellyfin.Server.Implementations/Events/Consumers/System/TaskCompletedLogger.cs b/Jellyfin.Server.Implementations/Events/Consumers/System/TaskCompletedLogger.cs index 05201a3469..cbc9f30173 100644 --- a/Jellyfin.Server.Implementations/Events/Consumers/System/TaskCompletedLogger.cs +++ b/Jellyfin.Server.Implementations/Events/Consumers/System/TaskCompletedLogger.cs @@ -33,10 +33,10 @@ namespace Jellyfin.Server.Implementations.Events.Consumers.System } /// <inheritdoc /> - public async Task OnEvent(TaskCompletionEventArgs e) + public async Task OnEvent(TaskCompletionEventArgs eventArgs) { - var result = e.Result; - var task = e.Task; + var result = eventArgs.Result; + var task = eventArgs.Task; if (task.ScheduledTask is IConfigurableScheduledTask activityTask && !activityTask.IsLogged) @@ -54,14 +54,14 @@ namespace Jellyfin.Server.Implementations.Events.Consumers.System { var vals = new List<string>(); - if (!string.IsNullOrEmpty(e.Result.ErrorMessage)) + if (!string.IsNullOrEmpty(eventArgs.Result.ErrorMessage)) { - vals.Add(e.Result.ErrorMessage); + vals.Add(eventArgs.Result.ErrorMessage); } - if (!string.IsNullOrEmpty(e.Result.LongErrorMessage)) + if (!string.IsNullOrEmpty(eventArgs.Result.LongErrorMessage)) { - vals.Add(e.Result.LongErrorMessage); + vals.Add(eventArgs.Result.LongErrorMessage); } await _activityManager.CreateAsync(new ActivityLog( diff --git a/Jellyfin.Server.Implementations/Events/Consumers/Updates/PluginUninstalledLogger.cs b/Jellyfin.Server.Implementations/Events/Consumers/Updates/PluginUninstalledLogger.cs index 91a30069e8..eb7572ac66 100644 --- a/Jellyfin.Server.Implementations/Events/Consumers/Updates/PluginUninstalledLogger.cs +++ b/Jellyfin.Server.Implementations/Events/Consumers/Updates/PluginUninstalledLogger.cs @@ -30,13 +30,13 @@ namespace Jellyfin.Server.Implementations.Events.Consumers.Updates } /// <inheritdoc /> - public async Task OnEvent(PluginUninstalledEventArgs e) + public async Task OnEvent(PluginUninstalledEventArgs eventArgs) { await _activityManager.CreateAsync(new ActivityLog( string.Format( CultureInfo.InvariantCulture, _localizationManager.GetLocalizedString("PluginUninstalledWithName"), - e.Argument.Name), + eventArgs.Argument.Name), NotificationType.PluginUninstalled.ToString(), Guid.Empty)) .ConfigureAwait(false); diff --git a/Jellyfin.Server.Implementations/Events/Consumers/Users/UserUpdatedNotifier.cs b/Jellyfin.Server.Implementations/Events/Consumers/Users/UserUpdatedNotifier.cs index a14911b94a..9beb6f2f25 100644 --- a/Jellyfin.Server.Implementations/Events/Consumers/Users/UserUpdatedNotifier.cs +++ b/Jellyfin.Server.Implementations/Events/Consumers/Users/UserUpdatedNotifier.cs @@ -30,12 +30,12 @@ namespace Jellyfin.Server.Implementations.Events.Consumers.Users } /// <inheritdoc /> - public async Task OnEvent(UserUpdatedEventArgs e) + public async Task OnEvent(UserUpdatedEventArgs eventArgs) { await _sessionManager.SendMessageToUserSessions( - new List<Guid> { e.Argument.Id }, + new List<Guid> { eventArgs.Argument.Id }, SessionMessageType.UserUpdated, - _userManager.GetUserDto(e.Argument), + _userManager.GetUserDto(eventArgs.Argument), CancellationToken.None).ConfigureAwait(false); } } diff --git a/Jellyfin.Server.Implementations/Events/EventManager.cs b/Jellyfin.Server.Implementations/Events/EventManager.cs index 7070024422..8c5d8f2ce6 100644 --- a/Jellyfin.Server.Implementations/Events/EventManager.cs +++ b/Jellyfin.Server.Implementations/Events/EventManager.cs @@ -30,7 +30,7 @@ namespace Jellyfin.Server.Implementations.Events public void Publish<T>(T eventArgs) where T : EventArgs { - Task.WaitAll(PublishInternal(eventArgs)); + PublishInternal(eventArgs).GetAwaiter().GetResult(); } /// <inheritdoc /> @@ -43,7 +43,12 @@ namespace Jellyfin.Server.Implementations.Events private async Task PublishInternal<T>(T eventArgs) where T : EventArgs { - using var scope = _appHost.ServiceProvider.CreateScope(); + using var scope = _appHost.ServiceProvider?.CreateScope(); + if (scope == null) + { + return; + } + foreach (var service in scope.ServiceProvider.GetServices<IEventConsumer<T>>()) { try diff --git a/Jellyfin.Server.Implementations/Jellyfin.Server.Implementations.csproj b/Jellyfin.Server.Implementations/Jellyfin.Server.Implementations.csproj index c52be3b8a9..a75b285936 100644 --- a/Jellyfin.Server.Implementations/Jellyfin.Server.Implementations.csproj +++ b/Jellyfin.Server.Implementations/Jellyfin.Server.Implementations.csproj @@ -1,19 +1,13 @@ <Project Sdk="Microsoft.NET.Sdk"> <PropertyGroup> - <TargetFramework>netcoreapp3.1</TargetFramework> + <TargetFramework>net5.0</TargetFramework> <GenerateAssemblyInfo>false</GenerateAssemblyInfo> <GenerateDocumentationFile>true</GenerateDocumentationFile> - <TreatWarningsAsErrors>true</TreatWarningsAsErrors> - </PropertyGroup> - - <PropertyGroup Condition=" '$(Configuration)' == 'Debug' "> - <CodeAnalysisRuleSet>../jellyfin.ruleset</CodeAnalysisRuleSet> </PropertyGroup> <!-- Code analysers--> <ItemGroup Condition=" '$(Configuration)' == 'Debug' "> - <PackageReference Include="Microsoft.CodeAnalysis.FxCopAnalyzers" Version="2.9.8" PrivateAssets="All" /> <PackageReference Include="SerilogAnalyzer" Version="0.15.0" PrivateAssets="All" /> <PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="All" /> <PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" PrivateAssets="All" /> @@ -24,12 +18,14 @@ </ItemGroup> <ItemGroup> - <PackageReference Include="System.Linq.Async" Version="4.1.1" /> - <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="3.1.9"> + <PackageReference Include="System.Linq.Async" Version="5.0.0" /> + <PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="5.0.9" /> + <PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="5.0.9" /> + <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="5.0.9"> <PrivateAssets>all</PrivateAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> </PackageReference> - <PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="3.1.9"> + <PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="5.0.9"> <PrivateAssets>all</PrivateAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> </PackageReference> diff --git a/Jellyfin.Server.Implementations/JellyfinDb.cs b/Jellyfin.Server.Implementations/JellyfinDb.cs index 45e71f16eb..db648472d1 100644 --- a/Jellyfin.Server.Implementations/JellyfinDb.cs +++ b/Jellyfin.Server.Implementations/JellyfinDb.cs @@ -1,5 +1,7 @@ +#nullable disable #pragma warning disable CS1591 +using System; using System.Linq; using Jellyfin.Data.Entities; using Jellyfin.Data.Interfaces; @@ -33,6 +35,8 @@ namespace Jellyfin.Server.Implementations public virtual DbSet<ItemDisplayPreferences> ItemDisplayPreferences { get; set; } + public virtual DbSet<CustomItemDisplayPreferences> CustomItemDisplayPreferences { get; set; } + public virtual DbSet<Permission> Permissions { get; set; } public virtual DbSet<Preference> Preferences { get; set; } @@ -140,16 +144,81 @@ namespace Jellyfin.Server.Implementations /// <inheritdoc /> protected override void OnModelCreating(ModelBuilder modelBuilder) { + modelBuilder.SetDefaultDateTimeKind(DateTimeKind.Utc); base.OnModelCreating(modelBuilder); modelBuilder.HasDefaultSchema("jellyfin"); + // Collations + + modelBuilder.Entity<User>() + .Property(user => user.Username) + .UseCollation("NOCASE"); + + // Delete behavior + + modelBuilder.Entity<User>() + .HasOne(u => u.ProfileImage) + .WithOne() + .OnDelete(DeleteBehavior.Cascade); + + modelBuilder.Entity<User>() + .HasMany(u => u.Permissions) + .WithOne() + .HasForeignKey(p => p.UserId) + .OnDelete(DeleteBehavior.Cascade); + + modelBuilder.Entity<User>() + .HasMany(u => u.Preferences) + .WithOne() + .HasForeignKey(p => p.UserId) + .OnDelete(DeleteBehavior.Cascade); + + modelBuilder.Entity<User>() + .HasMany(u => u.AccessSchedules) + .WithOne() + .OnDelete(DeleteBehavior.Cascade); + + modelBuilder.Entity<User>() + .HasMany(u => u.DisplayPreferences) + .WithOne() + .OnDelete(DeleteBehavior.Cascade); + + modelBuilder.Entity<User>() + .HasMany(u => u.ItemDisplayPreferences) + .WithOne() + .OnDelete(DeleteBehavior.Cascade); + modelBuilder.Entity<DisplayPreferences>() - .HasIndex(entity => entity.UserId) - .IsUnique(false); + .HasMany(d => d.HomeSections) + .WithOne() + .OnDelete(DeleteBehavior.Cascade); + + // Indexes + + modelBuilder.Entity<User>() + .HasIndex(entity => entity.Username) + .IsUnique(); modelBuilder.Entity<DisplayPreferences>() - .HasIndex(entity => new { entity.UserId, entity.Client }) + .HasIndex(entity => new { entity.UserId, entity.ItemId, entity.Client }) + .IsUnique(); + + modelBuilder.Entity<CustomItemDisplayPreferences>() + .HasIndex(entity => new { entity.UserId, entity.ItemId, entity.Client, entity.Key }) + .IsUnique(); + + // Used to get a user's permissions or a specific permission for a user. + // Also prevents multiple values being created for a user. + // Filtered over non-null user ids for when other entities (groups, API keys) get permissions + modelBuilder.Entity<Permission>() + .HasIndex(p => new { p.UserId, p.Kind }) + .HasFilter("[UserId] IS NOT NULL") + .IsUnique(); + + modelBuilder.Entity<Preference>() + .HasIndex(p => new { p.UserId, p.Kind }) + .HasFilter("[UserId] IS NOT NULL") .IsUnique(); } } diff --git a/Jellyfin.Server.Implementations/Migrations/20201204223655_AddCustomDisplayPreferences.Designer.cs b/Jellyfin.Server.Implementations/Migrations/20201204223655_AddCustomDisplayPreferences.Designer.cs new file mode 100644 index 0000000000..10663d0655 --- /dev/null +++ b/Jellyfin.Server.Implementations/Migrations/20201204223655_AddCustomDisplayPreferences.Designer.cs @@ -0,0 +1,522 @@ +#pragma warning disable CS1591 +// <auto-generated /> +using System; +using Jellyfin.Server.Implementations; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +namespace Jellyfin.Server.Implementations.Migrations +{ + [DbContext(typeof(JellyfinDb))] + [Migration("20201204223655_AddCustomDisplayPreferences")] + partial class AddCustomDisplayPreferences + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("jellyfin") + .HasAnnotation("ProductVersion", "5.0.0"); + + modelBuilder.Entity("Jellyfin.Data.Entities.AccessSchedule", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property<int>("DayOfWeek") + .HasColumnType("INTEGER"); + + b.Property<double>("EndHour") + .HasColumnType("REAL"); + + b.Property<double>("StartHour") + .HasColumnType("REAL"); + + b.Property<Guid>("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AccessSchedules"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.ActivityLog", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property<DateTime>("DateCreated") + .HasColumnType("TEXT"); + + b.Property<string>("ItemId") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property<int>("LogSeverity") + .HasColumnType("INTEGER"); + + b.Property<string>("Name") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property<string>("Overview") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property<uint>("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property<string>("ShortOverview") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property<string>("Type") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property<Guid>("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("ActivityLogs"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.CustomItemDisplayPreferences", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property<string>("Client") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property<Guid>("ItemId") + .HasColumnType("TEXT"); + + b.Property<string>("Key") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property<Guid>("UserId") + .HasColumnType("TEXT"); + + b.Property<string>("Value") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("UserId", "ItemId", "Client", "Key") + .IsUnique(); + + b.ToTable("CustomItemDisplayPreferences"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.DisplayPreferences", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property<int>("ChromecastVersion") + .HasColumnType("INTEGER"); + + b.Property<string>("Client") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property<string>("DashboardTheme") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property<bool>("EnableNextVideoInfoOverlay") + .HasColumnType("INTEGER"); + + b.Property<int?>("IndexBy") + .HasColumnType("INTEGER"); + + b.Property<Guid>("ItemId") + .HasColumnType("TEXT"); + + b.Property<int>("ScrollDirection") + .HasColumnType("INTEGER"); + + b.Property<bool>("ShowBackdrop") + .HasColumnType("INTEGER"); + + b.Property<bool>("ShowSidebar") + .HasColumnType("INTEGER"); + + b.Property<int>("SkipBackwardLength") + .HasColumnType("INTEGER"); + + b.Property<int>("SkipForwardLength") + .HasColumnType("INTEGER"); + + b.Property<string>("TvHome") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property<Guid>("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("UserId", "ItemId", "Client") + .IsUnique(); + + b.ToTable("DisplayPreferences"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.HomeSection", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property<int>("DisplayPreferencesId") + .HasColumnType("INTEGER"); + + b.Property<int>("Order") + .HasColumnType("INTEGER"); + + b.Property<int>("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("DisplayPreferencesId"); + + b.ToTable("HomeSection"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.ImageInfo", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property<DateTime>("LastModified") + .HasColumnType("TEXT"); + + b.Property<string>("Path") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property<Guid?>("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("ImageInfos"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.ItemDisplayPreferences", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property<string>("Client") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property<int?>("IndexBy") + .HasColumnType("INTEGER"); + + b.Property<Guid>("ItemId") + .HasColumnType("TEXT"); + + b.Property<bool>("RememberIndexing") + .HasColumnType("INTEGER"); + + b.Property<bool>("RememberSorting") + .HasColumnType("INTEGER"); + + b.Property<string>("SortBy") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property<int>("SortOrder") + .HasColumnType("INTEGER"); + + b.Property<Guid>("UserId") + .HasColumnType("TEXT"); + + b.Property<int>("ViewType") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("ItemDisplayPreferences"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.Permission", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property<int>("Kind") + .HasColumnType("INTEGER"); + + b.Property<Guid?>("Permission_Permissions_Guid") + .HasColumnType("TEXT"); + + b.Property<uint>("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property<bool>("Value") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Permission_Permissions_Guid"); + + b.ToTable("Permissions"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.Preference", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property<int>("Kind") + .HasColumnType("INTEGER"); + + b.Property<Guid?>("Preference_Preferences_Guid") + .HasColumnType("TEXT"); + + b.Property<uint>("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property<string>("Value") + .IsRequired() + .HasMaxLength(65535) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Preference_Preferences_Guid"); + + b.ToTable("Preferences"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.User", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property<string>("AudioLanguagePreference") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property<string>("AuthenticationProviderId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property<bool>("DisplayCollectionsView") + .HasColumnType("INTEGER"); + + b.Property<bool>("DisplayMissingEpisodes") + .HasColumnType("INTEGER"); + + b.Property<string>("EasyPassword") + .HasMaxLength(65535) + .HasColumnType("TEXT"); + + b.Property<bool>("EnableAutoLogin") + .HasColumnType("INTEGER"); + + b.Property<bool>("EnableLocalPassword") + .HasColumnType("INTEGER"); + + b.Property<bool>("EnableNextEpisodeAutoPlay") + .HasColumnType("INTEGER"); + + b.Property<bool>("EnableUserPreferenceAccess") + .HasColumnType("INTEGER"); + + b.Property<bool>("HidePlayedInLatest") + .HasColumnType("INTEGER"); + + b.Property<long>("InternalId") + .HasColumnType("INTEGER"); + + b.Property<int>("InvalidLoginAttemptCount") + .HasColumnType("INTEGER"); + + b.Property<DateTime?>("LastActivityDate") + .HasColumnType("TEXT"); + + b.Property<DateTime?>("LastLoginDate") + .HasColumnType("TEXT"); + + b.Property<int?>("LoginAttemptsBeforeLockout") + .HasColumnType("INTEGER"); + + b.Property<int>("MaxActiveSessions") + .HasColumnType("INTEGER"); + + b.Property<int?>("MaxParentalAgeRating") + .HasColumnType("INTEGER"); + + b.Property<bool>("MustUpdatePassword") + .HasColumnType("INTEGER"); + + b.Property<string>("Password") + .HasMaxLength(65535) + .HasColumnType("TEXT"); + + b.Property<string>("PasswordResetProviderId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property<bool>("PlayDefaultAudioTrack") + .HasColumnType("INTEGER"); + + b.Property<bool>("RememberAudioSelections") + .HasColumnType("INTEGER"); + + b.Property<bool>("RememberSubtitleSelections") + .HasColumnType("INTEGER"); + + b.Property<int?>("RemoteClientBitrateLimit") + .HasColumnType("INTEGER"); + + b.Property<uint>("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property<string>("SubtitleLanguagePreference") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property<int>("SubtitleMode") + .HasColumnType("INTEGER"); + + b.Property<int>("SyncPlayAccess") + .HasColumnType("INTEGER"); + + b.Property<string>("Username") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.AccessSchedule", b => + { + b.HasOne("Jellyfin.Data.Entities.User", null) + .WithMany("AccessSchedules") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.DisplayPreferences", b => + { + b.HasOne("Jellyfin.Data.Entities.User", null) + .WithOne("DisplayPreferences") + .HasForeignKey("Jellyfin.Data.Entities.DisplayPreferences", "UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.HomeSection", b => + { + b.HasOne("Jellyfin.Data.Entities.DisplayPreferences", null) + .WithMany("HomeSections") + .HasForeignKey("DisplayPreferencesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.ImageInfo", b => + { + b.HasOne("Jellyfin.Data.Entities.User", null) + .WithOne("ProfileImage") + .HasForeignKey("Jellyfin.Data.Entities.ImageInfo", "UserId"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.ItemDisplayPreferences", b => + { + b.HasOne("Jellyfin.Data.Entities.User", null) + .WithMany("ItemDisplayPreferences") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.Permission", b => + { + b.HasOne("Jellyfin.Data.Entities.User", null) + .WithMany("Permissions") + .HasForeignKey("Permission_Permissions_Guid"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.Preference", b => + { + b.HasOne("Jellyfin.Data.Entities.User", null) + .WithMany("Preferences") + .HasForeignKey("Preference_Preferences_Guid"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.DisplayPreferences", b => + { + b.Navigation("HomeSections"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.User", b => + { + b.Navigation("AccessSchedules"); + + b.Navigation("DisplayPreferences") + .IsRequired(); + + b.Navigation("ItemDisplayPreferences"); + + b.Navigation("Permissions"); + + b.Navigation("Preferences"); + + b.Navigation("ProfileImage"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Jellyfin.Server.Implementations/Migrations/20201204223655_AddCustomDisplayPreferences.cs b/Jellyfin.Server.Implementations/Migrations/20201204223655_AddCustomDisplayPreferences.cs new file mode 100644 index 0000000000..fbc0bffa97 --- /dev/null +++ b/Jellyfin.Server.Implementations/Migrations/20201204223655_AddCustomDisplayPreferences.cs @@ -0,0 +1,108 @@ +#pragma warning disable CS1591 +// <auto-generated /> +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +namespace Jellyfin.Server.Implementations.Migrations +{ + public partial class AddCustomDisplayPreferences : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_DisplayPreferences_UserId_Client", + schema: "jellyfin", + table: "DisplayPreferences"); + + migrationBuilder.AlterColumn<int>( + name: "MaxActiveSessions", + schema: "jellyfin", + table: "Users", + type: "INTEGER", + nullable: false, + defaultValue: 0, + oldClrType: typeof(int), + oldType: "INTEGER", + oldNullable: true); + + migrationBuilder.AddColumn<Guid>( + name: "ItemId", + schema: "jellyfin", + table: "DisplayPreferences", + type: "TEXT", + nullable: false, + defaultValue: new Guid("00000000-0000-0000-0000-000000000000")); + + migrationBuilder.CreateTable( + name: "CustomItemDisplayPreferences", + schema: "jellyfin", + columns: table => new + { + Id = table.Column<int>(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + UserId = table.Column<Guid>(type: "TEXT", nullable: false), + ItemId = table.Column<Guid>(type: "TEXT", nullable: false), + Client = table.Column<string>(type: "TEXT", maxLength: 32, nullable: false), + Key = table.Column<string>(type: "TEXT", nullable: false), + Value = table.Column<string>(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_CustomItemDisplayPreferences", x => x.Id); + }); + + migrationBuilder.CreateIndex( + name: "IX_DisplayPreferences_UserId_ItemId_Client", + schema: "jellyfin", + table: "DisplayPreferences", + columns: new[] { "UserId", "ItemId", "Client" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_CustomItemDisplayPreferences_UserId", + schema: "jellyfin", + table: "CustomItemDisplayPreferences", + column: "UserId"); + + migrationBuilder.CreateIndex( + name: "IX_CustomItemDisplayPreferences_UserId_ItemId_Client_Key", + schema: "jellyfin", + table: "CustomItemDisplayPreferences", + columns: new[] { "UserId", "ItemId", "Client", "Key" }, + unique: true); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "CustomItemDisplayPreferences", + schema: "jellyfin"); + + migrationBuilder.DropIndex( + name: "IX_DisplayPreferences_UserId_ItemId_Client", + schema: "jellyfin", + table: "DisplayPreferences"); + + migrationBuilder.DropColumn( + name: "ItemId", + schema: "jellyfin", + table: "DisplayPreferences"); + + migrationBuilder.AlterColumn<int>( + name: "MaxActiveSessions", + schema: "jellyfin", + table: "Users", + type: "INTEGER", + nullable: true, + oldClrType: typeof(int), + oldType: "INTEGER"); + + migrationBuilder.CreateIndex( + name: "IX_DisplayPreferences_UserId_Client", + schema: "jellyfin", + table: "DisplayPreferences", + columns: new[] { "UserId", "Client" }, + unique: true); + } + } +} diff --git a/Jellyfin.Server.Implementations/Migrations/20210320181425_AddIndexesAndCollations.Designer.cs b/Jellyfin.Server.Implementations/Migrations/20210320181425_AddIndexesAndCollations.Designer.cs new file mode 100644 index 0000000000..8696768245 --- /dev/null +++ b/Jellyfin.Server.Implementations/Migrations/20210320181425_AddIndexesAndCollations.Designer.cs @@ -0,0 +1,535 @@ +#pragma warning disable CS1591 + +// <auto-generated /> +using System; +using Jellyfin.Server.Implementations; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +namespace Jellyfin.Server.Implementations.Migrations +{ + [DbContext(typeof(JellyfinDb))] + [Migration("20210320181425_AddIndexesAndCollations")] + partial class AddIndexesAndCollations + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("jellyfin") + .HasAnnotation("ProductVersion", "5.0.3"); + + modelBuilder.Entity("Jellyfin.Data.Entities.AccessSchedule", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property<int>("DayOfWeek") + .HasColumnType("INTEGER"); + + b.Property<double>("EndHour") + .HasColumnType("REAL"); + + b.Property<double>("StartHour") + .HasColumnType("REAL"); + + b.Property<Guid>("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AccessSchedules"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.ActivityLog", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property<DateTime>("DateCreated") + .HasColumnType("TEXT"); + + b.Property<string>("ItemId") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property<int>("LogSeverity") + .HasColumnType("INTEGER"); + + b.Property<string>("Name") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property<string>("Overview") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property<uint>("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property<string>("ShortOverview") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property<string>("Type") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property<Guid>("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("ActivityLogs"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.CustomItemDisplayPreferences", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property<string>("Client") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property<Guid>("ItemId") + .HasColumnType("TEXT"); + + b.Property<string>("Key") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property<Guid>("UserId") + .HasColumnType("TEXT"); + + b.Property<string>("Value") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "ItemId", "Client", "Key") + .IsUnique(); + + b.ToTable("CustomItemDisplayPreferences"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.DisplayPreferences", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property<int>("ChromecastVersion") + .HasColumnType("INTEGER"); + + b.Property<string>("Client") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property<string>("DashboardTheme") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property<bool>("EnableNextVideoInfoOverlay") + .HasColumnType("INTEGER"); + + b.Property<int?>("IndexBy") + .HasColumnType("INTEGER"); + + b.Property<Guid>("ItemId") + .HasColumnType("TEXT"); + + b.Property<int>("ScrollDirection") + .HasColumnType("INTEGER"); + + b.Property<bool>("ShowBackdrop") + .HasColumnType("INTEGER"); + + b.Property<bool>("ShowSidebar") + .HasColumnType("INTEGER"); + + b.Property<int>("SkipBackwardLength") + .HasColumnType("INTEGER"); + + b.Property<int>("SkipForwardLength") + .HasColumnType("INTEGER"); + + b.Property<string>("TvHome") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property<Guid>("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "ItemId", "Client") + .IsUnique(); + + b.ToTable("DisplayPreferences"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.HomeSection", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property<int>("DisplayPreferencesId") + .HasColumnType("INTEGER"); + + b.Property<int>("Order") + .HasColumnType("INTEGER"); + + b.Property<int>("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("DisplayPreferencesId"); + + b.ToTable("HomeSection"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.ImageInfo", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property<DateTime>("LastModified") + .HasColumnType("TEXT"); + + b.Property<string>("Path") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property<Guid?>("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("ImageInfos"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.ItemDisplayPreferences", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property<string>("Client") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property<int?>("IndexBy") + .HasColumnType("INTEGER"); + + b.Property<Guid>("ItemId") + .HasColumnType("TEXT"); + + b.Property<bool>("RememberIndexing") + .HasColumnType("INTEGER"); + + b.Property<bool>("RememberSorting") + .HasColumnType("INTEGER"); + + b.Property<string>("SortBy") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property<int>("SortOrder") + .HasColumnType("INTEGER"); + + b.Property<Guid>("UserId") + .HasColumnType("TEXT"); + + b.Property<int>("ViewType") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("ItemDisplayPreferences"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.Permission", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property<int>("Kind") + .HasColumnType("INTEGER"); + + b.Property<Guid?>("Permission_Permissions_Guid") + .HasColumnType("TEXT"); + + b.Property<uint>("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property<Guid?>("UserId") + .HasColumnType("TEXT"); + + b.Property<bool>("Value") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "Kind") + .IsUnique() + .HasFilter("[UserId] IS NOT NULL"); + + b.ToTable("Permissions"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.Preference", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property<int>("Kind") + .HasColumnType("INTEGER"); + + b.Property<Guid?>("Preference_Preferences_Guid") + .HasColumnType("TEXT"); + + b.Property<uint>("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property<Guid?>("UserId") + .HasColumnType("TEXT"); + + b.Property<string>("Value") + .IsRequired() + .HasMaxLength(65535) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "Kind") + .IsUnique() + .HasFilter("[UserId] IS NOT NULL"); + + b.ToTable("Preferences"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.User", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property<string>("AudioLanguagePreference") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property<string>("AuthenticationProviderId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property<bool>("DisplayCollectionsView") + .HasColumnType("INTEGER"); + + b.Property<bool>("DisplayMissingEpisodes") + .HasColumnType("INTEGER"); + + b.Property<string>("EasyPassword") + .HasMaxLength(65535) + .HasColumnType("TEXT"); + + b.Property<bool>("EnableAutoLogin") + .HasColumnType("INTEGER"); + + b.Property<bool>("EnableLocalPassword") + .HasColumnType("INTEGER"); + + b.Property<bool>("EnableNextEpisodeAutoPlay") + .HasColumnType("INTEGER"); + + b.Property<bool>("EnableUserPreferenceAccess") + .HasColumnType("INTEGER"); + + b.Property<bool>("HidePlayedInLatest") + .HasColumnType("INTEGER"); + + b.Property<long>("InternalId") + .HasColumnType("INTEGER"); + + b.Property<int>("InvalidLoginAttemptCount") + .HasColumnType("INTEGER"); + + b.Property<DateTime?>("LastActivityDate") + .HasColumnType("TEXT"); + + b.Property<DateTime?>("LastLoginDate") + .HasColumnType("TEXT"); + + b.Property<int?>("LoginAttemptsBeforeLockout") + .HasColumnType("INTEGER"); + + b.Property<int>("MaxActiveSessions") + .HasColumnType("INTEGER"); + + b.Property<int?>("MaxParentalAgeRating") + .HasColumnType("INTEGER"); + + b.Property<bool>("MustUpdatePassword") + .HasColumnType("INTEGER"); + + b.Property<string>("Password") + .HasMaxLength(65535) + .HasColumnType("TEXT"); + + b.Property<string>("PasswordResetProviderId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property<bool>("PlayDefaultAudioTrack") + .HasColumnType("INTEGER"); + + b.Property<bool>("RememberAudioSelections") + .HasColumnType("INTEGER"); + + b.Property<bool>("RememberSubtitleSelections") + .HasColumnType("INTEGER"); + + b.Property<int?>("RemoteClientBitrateLimit") + .HasColumnType("INTEGER"); + + b.Property<uint>("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property<string>("SubtitleLanguagePreference") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property<int>("SubtitleMode") + .HasColumnType("INTEGER"); + + b.Property<int>("SyncPlayAccess") + .HasColumnType("INTEGER"); + + b.Property<string>("Username") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT") + .UseCollation("NOCASE"); + + b.HasKey("Id"); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.AccessSchedule", b => + { + b.HasOne("Jellyfin.Data.Entities.User", null) + .WithMany("AccessSchedules") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.DisplayPreferences", b => + { + b.HasOne("Jellyfin.Data.Entities.User", null) + .WithMany("DisplayPreferences") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.HomeSection", b => + { + b.HasOne("Jellyfin.Data.Entities.DisplayPreferences", null) + .WithMany("HomeSections") + .HasForeignKey("DisplayPreferencesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.ImageInfo", b => + { + b.HasOne("Jellyfin.Data.Entities.User", null) + .WithOne("ProfileImage") + .HasForeignKey("Jellyfin.Data.Entities.ImageInfo", "UserId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.ItemDisplayPreferences", b => + { + b.HasOne("Jellyfin.Data.Entities.User", null) + .WithMany("ItemDisplayPreferences") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.Permission", b => + { + b.HasOne("Jellyfin.Data.Entities.User", null) + .WithMany("Permissions") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.Preference", b => + { + b.HasOne("Jellyfin.Data.Entities.User", null) + .WithMany("Preferences") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.DisplayPreferences", b => + { + b.Navigation("HomeSections"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.User", b => + { + b.Navigation("AccessSchedules"); + + b.Navigation("DisplayPreferences"); + + b.Navigation("ItemDisplayPreferences"); + + b.Navigation("Permissions"); + + b.Navigation("Preferences"); + + b.Navigation("ProfileImage"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Jellyfin.Server.Implementations/Migrations/20210320181425_AddIndexesAndCollations.cs b/Jellyfin.Server.Implementations/Migrations/20210320181425_AddIndexesAndCollations.cs new file mode 100644 index 0000000000..506e4ae661 --- /dev/null +++ b/Jellyfin.Server.Implementations/Migrations/20210320181425_AddIndexesAndCollations.cs @@ -0,0 +1,240 @@ +#pragma warning disable CS1591 +#pragma warning disable SA1601 + +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +namespace Jellyfin.Server.Implementations.Migrations +{ + public partial class AddIndexesAndCollations : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_ImageInfos_Users_UserId", + schema: "jellyfin", + table: "ImageInfos"); + + migrationBuilder.DropForeignKey( + name: "FK_Permissions_Users_Permission_Permissions_Guid", + schema: "jellyfin", + table: "Permissions"); + + migrationBuilder.DropForeignKey( + name: "FK_Preferences_Users_Preference_Preferences_Guid", + schema: "jellyfin", + table: "Preferences"); + + migrationBuilder.DropIndex( + name: "IX_Preferences_Preference_Preferences_Guid", + schema: "jellyfin", + table: "Preferences"); + + migrationBuilder.DropIndex( + name: "IX_Permissions_Permission_Permissions_Guid", + schema: "jellyfin", + table: "Permissions"); + + migrationBuilder.DropIndex( + name: "IX_DisplayPreferences_UserId", + schema: "jellyfin", + table: "DisplayPreferences"); + + migrationBuilder.DropIndex( + name: "IX_CustomItemDisplayPreferences_UserId", + schema: "jellyfin", + table: "CustomItemDisplayPreferences"); + + migrationBuilder.AlterColumn<string>( + name: "Username", + schema: "jellyfin", + table: "Users", + type: "TEXT", + maxLength: 255, + nullable: false, + collation: "NOCASE", + oldClrType: typeof(string), + oldType: "TEXT", + oldMaxLength: 255); + + migrationBuilder.AddColumn<Guid>( + name: "UserId", + schema: "jellyfin", + table: "Preferences", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn<Guid>( + name: "UserId", + schema: "jellyfin", + table: "Permissions", + type: "TEXT", + nullable: true); + + migrationBuilder.Sql("UPDATE Preferences SET UserId = Preference_Preferences_Guid"); + migrationBuilder.Sql("UPDATE Permissions SET UserId = Permission_Permissions_Guid"); + + migrationBuilder.CreateIndex( + name: "IX_Users_Username", + schema: "jellyfin", + table: "Users", + column: "Username", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_Preferences_UserId_Kind", + schema: "jellyfin", + table: "Preferences", + columns: new[] { "UserId", "Kind" }, + unique: true, + filter: "[UserId] IS NOT NULL"); + + migrationBuilder.CreateIndex( + name: "IX_Permissions_UserId_Kind", + schema: "jellyfin", + table: "Permissions", + columns: new[] { "UserId", "Kind" }, + unique: true, + filter: "[UserId] IS NOT NULL"); + + migrationBuilder.AddForeignKey( + name: "FK_ImageInfos_Users_UserId", + schema: "jellyfin", + table: "ImageInfos", + column: "UserId", + principalSchema: "jellyfin", + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + + migrationBuilder.AddForeignKey( + name: "FK_Permissions_Users_UserId", + schema: "jellyfin", + table: "Permissions", + column: "UserId", + principalSchema: "jellyfin", + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + + migrationBuilder.AddForeignKey( + name: "FK_Preferences_Users_UserId", + schema: "jellyfin", + table: "Preferences", + column: "UserId", + principalSchema: "jellyfin", + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_ImageInfos_Users_UserId", + schema: "jellyfin", + table: "ImageInfos"); + + migrationBuilder.DropForeignKey( + name: "FK_Permissions_Users_UserId", + schema: "jellyfin", + table: "Permissions"); + + migrationBuilder.DropForeignKey( + name: "FK_Preferences_Users_UserId", + schema: "jellyfin", + table: "Preferences"); + + migrationBuilder.DropIndex( + name: "IX_Users_Username", + schema: "jellyfin", + table: "Users"); + + migrationBuilder.DropIndex( + name: "IX_Preferences_UserId_Kind", + schema: "jellyfin", + table: "Preferences"); + + migrationBuilder.DropIndex( + name: "IX_Permissions_UserId_Kind", + schema: "jellyfin", + table: "Permissions"); + + migrationBuilder.DropColumn( + name: "UserId", + schema: "jellyfin", + table: "Preferences"); + + migrationBuilder.DropColumn( + name: "UserId", + schema: "jellyfin", + table: "Permissions"); + + migrationBuilder.AlterColumn<string>( + name: "Username", + schema: "jellyfin", + table: "Users", + type: "TEXT", + maxLength: 255, + nullable: false, + oldClrType: typeof(string), + oldType: "TEXT", + oldMaxLength: 255, + oldCollation: "NOCASE"); + + migrationBuilder.CreateIndex( + name: "IX_Preferences_Preference_Preferences_Guid", + schema: "jellyfin", + table: "Preferences", + column: "Preference_Preferences_Guid"); + + migrationBuilder.CreateIndex( + name: "IX_Permissions_Permission_Permissions_Guid", + schema: "jellyfin", + table: "Permissions", + column: "Permission_Permissions_Guid"); + + migrationBuilder.CreateIndex( + name: "IX_DisplayPreferences_UserId", + schema: "jellyfin", + table: "DisplayPreferences", + column: "UserId"); + + migrationBuilder.CreateIndex( + name: "IX_CustomItemDisplayPreferences_UserId", + schema: "jellyfin", + table: "CustomItemDisplayPreferences", + column: "UserId"); + + migrationBuilder.AddForeignKey( + name: "FK_ImageInfos_Users_UserId", + schema: "jellyfin", + table: "ImageInfos", + column: "UserId", + principalSchema: "jellyfin", + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + + migrationBuilder.AddForeignKey( + name: "FK_Permissions_Users_Permission_Permissions_Guid", + schema: "jellyfin", + table: "Permissions", + column: "Permission_Permissions_Guid", + principalSchema: "jellyfin", + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + + migrationBuilder.AddForeignKey( + name: "FK_Preferences_Users_Preference_Preferences_Guid", + schema: "jellyfin", + table: "Preferences", + column: "Preference_Preferences_Guid", + principalSchema: "jellyfin", + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + } + } +} diff --git a/Jellyfin.Server.Implementations/Migrations/20210407110544_NullableCustomPrefValue.Designer.cs b/Jellyfin.Server.Implementations/Migrations/20210407110544_NullableCustomPrefValue.Designer.cs new file mode 100644 index 0000000000..d332d19f28 --- /dev/null +++ b/Jellyfin.Server.Implementations/Migrations/20210407110544_NullableCustomPrefValue.Designer.cs @@ -0,0 +1,520 @@ +#pragma warning disable CS1591 +// <auto-generated /> +using System; +using Jellyfin.Server.Implementations; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +namespace Jellyfin.Server.Implementations.Migrations +{ + [DbContext(typeof(JellyfinDb))] + [Migration("20210407110544_NullableCustomPrefValue")] + partial class NullableCustomPrefValue + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("jellyfin") + .HasAnnotation("ProductVersion", "5.0.3"); + + modelBuilder.Entity("Jellyfin.Data.Entities.AccessSchedule", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property<int>("DayOfWeek") + .HasColumnType("INTEGER"); + + b.Property<double>("EndHour") + .HasColumnType("REAL"); + + b.Property<double>("StartHour") + .HasColumnType("REAL"); + + b.Property<Guid>("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AccessSchedules"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.ActivityLog", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property<DateTime>("DateCreated") + .HasColumnType("TEXT"); + + b.Property<string>("ItemId") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property<int>("LogSeverity") + .HasColumnType("INTEGER"); + + b.Property<string>("Name") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property<string>("Overview") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property<uint>("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property<string>("ShortOverview") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property<string>("Type") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property<Guid>("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("ActivityLogs"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.CustomItemDisplayPreferences", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property<string>("Client") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property<Guid>("ItemId") + .HasColumnType("TEXT"); + + b.Property<string>("Key") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property<Guid>("UserId") + .HasColumnType("TEXT"); + + b.Property<string>("Value") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("UserId", "ItemId", "Client", "Key") + .IsUnique(); + + b.ToTable("CustomItemDisplayPreferences"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.DisplayPreferences", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property<int>("ChromecastVersion") + .HasColumnType("INTEGER"); + + b.Property<string>("Client") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property<string>("DashboardTheme") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property<bool>("EnableNextVideoInfoOverlay") + .HasColumnType("INTEGER"); + + b.Property<int?>("IndexBy") + .HasColumnType("INTEGER"); + + b.Property<Guid>("ItemId") + .HasColumnType("TEXT"); + + b.Property<int>("ScrollDirection") + .HasColumnType("INTEGER"); + + b.Property<bool>("ShowBackdrop") + .HasColumnType("INTEGER"); + + b.Property<bool>("ShowSidebar") + .HasColumnType("INTEGER"); + + b.Property<int>("SkipBackwardLength") + .HasColumnType("INTEGER"); + + b.Property<int>("SkipForwardLength") + .HasColumnType("INTEGER"); + + b.Property<string>("TvHome") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property<Guid>("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("UserId", "ItemId", "Client") + .IsUnique(); + + b.ToTable("DisplayPreferences"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.HomeSection", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property<int>("DisplayPreferencesId") + .HasColumnType("INTEGER"); + + b.Property<int>("Order") + .HasColumnType("INTEGER"); + + b.Property<int>("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("DisplayPreferencesId"); + + b.ToTable("HomeSection"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.ImageInfo", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property<DateTime>("LastModified") + .HasColumnType("TEXT"); + + b.Property<string>("Path") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property<Guid?>("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("ImageInfos"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.ItemDisplayPreferences", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property<string>("Client") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property<int?>("IndexBy") + .HasColumnType("INTEGER"); + + b.Property<Guid>("ItemId") + .HasColumnType("TEXT"); + + b.Property<bool>("RememberIndexing") + .HasColumnType("INTEGER"); + + b.Property<bool>("RememberSorting") + .HasColumnType("INTEGER"); + + b.Property<string>("SortBy") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property<int>("SortOrder") + .HasColumnType("INTEGER"); + + b.Property<Guid>("UserId") + .HasColumnType("TEXT"); + + b.Property<int>("ViewType") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("ItemDisplayPreferences"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.Permission", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property<int>("Kind") + .HasColumnType("INTEGER"); + + b.Property<Guid?>("Permission_Permissions_Guid") + .HasColumnType("TEXT"); + + b.Property<uint>("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property<bool>("Value") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Permission_Permissions_Guid"); + + b.ToTable("Permissions"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.Preference", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property<int>("Kind") + .HasColumnType("INTEGER"); + + b.Property<Guid?>("Preference_Preferences_Guid") + .HasColumnType("TEXT"); + + b.Property<uint>("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property<string>("Value") + .IsRequired() + .HasMaxLength(65535) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Preference_Preferences_Guid"); + + b.ToTable("Preferences"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.User", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property<string>("AudioLanguagePreference") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property<string>("AuthenticationProviderId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property<bool>("DisplayCollectionsView") + .HasColumnType("INTEGER"); + + b.Property<bool>("DisplayMissingEpisodes") + .HasColumnType("INTEGER"); + + b.Property<string>("EasyPassword") + .HasMaxLength(65535) + .HasColumnType("TEXT"); + + b.Property<bool>("EnableAutoLogin") + .HasColumnType("INTEGER"); + + b.Property<bool>("EnableLocalPassword") + .HasColumnType("INTEGER"); + + b.Property<bool>("EnableNextEpisodeAutoPlay") + .HasColumnType("INTEGER"); + + b.Property<bool>("EnableUserPreferenceAccess") + .HasColumnType("INTEGER"); + + b.Property<bool>("HidePlayedInLatest") + .HasColumnType("INTEGER"); + + b.Property<long>("InternalId") + .HasColumnType("INTEGER"); + + b.Property<int>("InvalidLoginAttemptCount") + .HasColumnType("INTEGER"); + + b.Property<DateTime?>("LastActivityDate") + .HasColumnType("TEXT"); + + b.Property<DateTime?>("LastLoginDate") + .HasColumnType("TEXT"); + + b.Property<int?>("LoginAttemptsBeforeLockout") + .HasColumnType("INTEGER"); + + b.Property<int>("MaxActiveSessions") + .HasColumnType("INTEGER"); + + b.Property<int?>("MaxParentalAgeRating") + .HasColumnType("INTEGER"); + + b.Property<bool>("MustUpdatePassword") + .HasColumnType("INTEGER"); + + b.Property<string>("Password") + .HasMaxLength(65535) + .HasColumnType("TEXT"); + + b.Property<string>("PasswordResetProviderId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property<bool>("PlayDefaultAudioTrack") + .HasColumnType("INTEGER"); + + b.Property<bool>("RememberAudioSelections") + .HasColumnType("INTEGER"); + + b.Property<bool>("RememberSubtitleSelections") + .HasColumnType("INTEGER"); + + b.Property<int?>("RemoteClientBitrateLimit") + .HasColumnType("INTEGER"); + + b.Property<uint>("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property<string>("SubtitleLanguagePreference") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property<int>("SubtitleMode") + .HasColumnType("INTEGER"); + + b.Property<int>("SyncPlayAccess") + .HasColumnType("INTEGER"); + + b.Property<string>("Username") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.AccessSchedule", b => + { + b.HasOne("Jellyfin.Data.Entities.User", null) + .WithMany("AccessSchedules") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.DisplayPreferences", b => + { + b.HasOne("Jellyfin.Data.Entities.User", null) + .WithMany("DisplayPreferences") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.HomeSection", b => + { + b.HasOne("Jellyfin.Data.Entities.DisplayPreferences", null) + .WithMany("HomeSections") + .HasForeignKey("DisplayPreferencesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.ImageInfo", b => + { + b.HasOne("Jellyfin.Data.Entities.User", null) + .WithOne("ProfileImage") + .HasForeignKey("Jellyfin.Data.Entities.ImageInfo", "UserId"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.ItemDisplayPreferences", b => + { + b.HasOne("Jellyfin.Data.Entities.User", null) + .WithMany("ItemDisplayPreferences") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.Permission", b => + { + b.HasOne("Jellyfin.Data.Entities.User", null) + .WithMany("Permissions") + .HasForeignKey("Permission_Permissions_Guid"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.Preference", b => + { + b.HasOne("Jellyfin.Data.Entities.User", null) + .WithMany("Preferences") + .HasForeignKey("Preference_Preferences_Guid"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.DisplayPreferences", b => + { + b.Navigation("HomeSections"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.User", b => + { + b.Navigation("AccessSchedules"); + + b.Navigation("DisplayPreferences"); + + b.Navigation("ItemDisplayPreferences"); + + b.Navigation("Permissions"); + + b.Navigation("Preferences"); + + b.Navigation("ProfileImage"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Jellyfin.Server.Implementations/Migrations/20210407110544_NullableCustomPrefValue.cs b/Jellyfin.Server.Implementations/Migrations/20210407110544_NullableCustomPrefValue.cs new file mode 100644 index 0000000000..ade68612c0 --- /dev/null +++ b/Jellyfin.Server.Implementations/Migrations/20210407110544_NullableCustomPrefValue.cs @@ -0,0 +1,35 @@ +#pragma warning disable CS1591 +// <auto-generated /> +using Microsoft.EntityFrameworkCore.Migrations; + +namespace Jellyfin.Server.Implementations.Migrations +{ + public partial class NullableCustomPrefValue : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn<string>( + name: "Value", + schema: "jellyfin", + table: "CustomItemDisplayPreferences", + type: "TEXT", + nullable: true, + oldClrType: typeof(string), + oldType: "TEXT"); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn<string>( + name: "Value", + schema: "jellyfin", + table: "CustomItemDisplayPreferences", + type: "TEXT", + nullable: false, + defaultValue: "", + oldClrType: typeof(string), + oldType: "TEXT", + oldNullable: true); + } + } +} diff --git a/Jellyfin.Server.Implementations/Migrations/JellyfinDbModelSnapshot.cs b/Jellyfin.Server.Implementations/Migrations/JellyfinDbModelSnapshot.cs index 16d62f4826..286eb7468a 100644 --- a/Jellyfin.Server.Implementations/Migrations/JellyfinDbModelSnapshot.cs +++ b/Jellyfin.Server.Implementations/Migrations/JellyfinDbModelSnapshot.cs @@ -15,7 +15,7 @@ namespace Jellyfin.Server.Implementations.Migrations #pragma warning disable 612, 618 modelBuilder .HasDefaultSchema("jellyfin") - .HasAnnotation("ProductVersion", "3.1.8"); + .HasAnnotation("ProductVersion", "5.0.3"); modelBuilder.Entity("Jellyfin.Data.Entities.AccessSchedule", b => { @@ -52,33 +52,33 @@ namespace Jellyfin.Server.Implementations.Migrations .HasColumnType("TEXT"); b.Property<string>("ItemId") - .HasColumnType("TEXT") - .HasMaxLength(256); + .HasMaxLength(256) + .HasColumnType("TEXT"); b.Property<int>("LogSeverity") .HasColumnType("INTEGER"); b.Property<string>("Name") .IsRequired() - .HasColumnType("TEXT") - .HasMaxLength(512); + .HasMaxLength(512) + .HasColumnType("TEXT"); b.Property<string>("Overview") - .HasColumnType("TEXT") - .HasMaxLength(512); + .HasMaxLength(512) + .HasColumnType("TEXT"); b.Property<uint>("RowVersion") .IsConcurrencyToken() .HasColumnType("INTEGER"); b.Property<string>("ShortOverview") - .HasColumnType("TEXT") - .HasMaxLength(512); + .HasMaxLength(512) + .HasColumnType("TEXT"); b.Property<string>("Type") .IsRequired() - .HasColumnType("TEXT") - .HasMaxLength(256); + .HasMaxLength(256) + .HasColumnType("TEXT"); b.Property<Guid>("UserId") .HasColumnType("TEXT"); @@ -88,6 +88,38 @@ namespace Jellyfin.Server.Implementations.Migrations b.ToTable("ActivityLogs"); }); + modelBuilder.Entity("Jellyfin.Data.Entities.CustomItemDisplayPreferences", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property<string>("Client") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property<Guid>("ItemId") + .HasColumnType("TEXT"); + + b.Property<string>("Key") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property<Guid>("UserId") + .HasColumnType("TEXT"); + + b.Property<string>("Value") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "ItemId", "Client", "Key") + .IsUnique(); + + b.ToTable("CustomItemDisplayPreferences"); + }); + modelBuilder.Entity("Jellyfin.Data.Entities.DisplayPreferences", b => { b.Property<int>("Id") @@ -99,12 +131,12 @@ namespace Jellyfin.Server.Implementations.Migrations b.Property<string>("Client") .IsRequired() - .HasColumnType("TEXT") - .HasMaxLength(32); + .HasMaxLength(32) + .HasColumnType("TEXT"); b.Property<string>("DashboardTheme") - .HasColumnType("TEXT") - .HasMaxLength(32); + .HasMaxLength(32) + .HasColumnType("TEXT"); b.Property<bool>("EnableNextVideoInfoOverlay") .HasColumnType("INTEGER"); @@ -112,6 +144,9 @@ namespace Jellyfin.Server.Implementations.Migrations b.Property<int?>("IndexBy") .HasColumnType("INTEGER"); + b.Property<Guid>("ItemId") + .HasColumnType("TEXT"); + b.Property<int>("ScrollDirection") .HasColumnType("INTEGER"); @@ -128,17 +163,15 @@ namespace Jellyfin.Server.Implementations.Migrations .HasColumnType("INTEGER"); b.Property<string>("TvHome") - .HasColumnType("TEXT") - .HasMaxLength(32); + .HasMaxLength(32) + .HasColumnType("TEXT"); b.Property<Guid>("UserId") .HasColumnType("TEXT"); b.HasKey("Id"); - b.HasIndex("UserId"); - - b.HasIndex("UserId", "Client") + b.HasIndex("UserId", "ItemId", "Client") .IsUnique(); b.ToTable("DisplayPreferences"); @@ -177,8 +210,8 @@ namespace Jellyfin.Server.Implementations.Migrations b.Property<string>("Path") .IsRequired() - .HasColumnType("TEXT") - .HasMaxLength(512); + .HasMaxLength(512) + .HasColumnType("TEXT"); b.Property<Guid?>("UserId") .HasColumnType("TEXT"); @@ -199,8 +232,8 @@ namespace Jellyfin.Server.Implementations.Migrations b.Property<string>("Client") .IsRequired() - .HasColumnType("TEXT") - .HasMaxLength(32); + .HasMaxLength(32) + .HasColumnType("TEXT"); b.Property<int?>("IndexBy") .HasColumnType("INTEGER"); @@ -216,8 +249,8 @@ namespace Jellyfin.Server.Implementations.Migrations b.Property<string>("SortBy") .IsRequired() - .HasColumnType("TEXT") - .HasMaxLength(64); + .HasMaxLength(64) + .HasColumnType("TEXT"); b.Property<int>("SortOrder") .HasColumnType("INTEGER"); @@ -251,12 +284,17 @@ namespace Jellyfin.Server.Implementations.Migrations .IsConcurrencyToken() .HasColumnType("INTEGER"); + b.Property<Guid?>("UserId") + .HasColumnType("TEXT"); + b.Property<bool>("Value") .HasColumnType("INTEGER"); b.HasKey("Id"); - b.HasIndex("Permission_Permissions_Guid"); + b.HasIndex("UserId", "Kind") + .IsUnique() + .HasFilter("[UserId] IS NOT NULL"); b.ToTable("Permissions"); }); @@ -277,14 +315,19 @@ namespace Jellyfin.Server.Implementations.Migrations .IsConcurrencyToken() .HasColumnType("INTEGER"); + b.Property<Guid?>("UserId") + .HasColumnType("TEXT"); + b.Property<string>("Value") .IsRequired() - .HasColumnType("TEXT") - .HasMaxLength(65535); + .HasMaxLength(65535) + .HasColumnType("TEXT"); b.HasKey("Id"); - b.HasIndex("Preference_Preferences_Guid"); + b.HasIndex("UserId", "Kind") + .IsUnique() + .HasFilter("[UserId] IS NOT NULL"); b.ToTable("Preferences"); }); @@ -296,13 +339,13 @@ namespace Jellyfin.Server.Implementations.Migrations .HasColumnType("TEXT"); b.Property<string>("AudioLanguagePreference") - .HasColumnType("TEXT") - .HasMaxLength(255); + .HasMaxLength(255) + .HasColumnType("TEXT"); b.Property<string>("AuthenticationProviderId") .IsRequired() - .HasColumnType("TEXT") - .HasMaxLength(255); + .HasMaxLength(255) + .HasColumnType("TEXT"); b.Property<bool>("DisplayCollectionsView") .HasColumnType("INTEGER"); @@ -311,8 +354,8 @@ namespace Jellyfin.Server.Implementations.Migrations .HasColumnType("INTEGER"); b.Property<string>("EasyPassword") - .HasColumnType("TEXT") - .HasMaxLength(65535); + .HasMaxLength(65535) + .HasColumnType("TEXT"); b.Property<bool>("EnableAutoLogin") .HasColumnType("INTEGER"); @@ -354,13 +397,13 @@ namespace Jellyfin.Server.Implementations.Migrations .HasColumnType("INTEGER"); b.Property<string>("Password") - .HasColumnType("TEXT") - .HasMaxLength(65535); + .HasMaxLength(65535) + .HasColumnType("TEXT"); b.Property<string>("PasswordResetProviderId") .IsRequired() - .HasColumnType("TEXT") - .HasMaxLength(255); + .HasMaxLength(255) + .HasColumnType("TEXT"); b.Property<bool>("PlayDefaultAudioTrack") .HasColumnType("INTEGER"); @@ -379,8 +422,8 @@ namespace Jellyfin.Server.Implementations.Migrations .HasColumnType("INTEGER"); b.Property<string>("SubtitleLanguagePreference") - .HasColumnType("TEXT") - .HasMaxLength(255); + .HasMaxLength(255) + .HasColumnType("TEXT"); b.Property<int>("SubtitleMode") .HasColumnType("INTEGER"); @@ -390,11 +433,15 @@ namespace Jellyfin.Server.Implementations.Migrations b.Property<string>("Username") .IsRequired() + .HasMaxLength(255) .HasColumnType("TEXT") - .HasMaxLength(255); + .UseCollation("NOCASE"); b.HasKey("Id"); + b.HasIndex("Username") + .IsUnique(); + b.ToTable("Users"); }); @@ -410,8 +457,8 @@ namespace Jellyfin.Server.Implementations.Migrations modelBuilder.Entity("Jellyfin.Data.Entities.DisplayPreferences", b => { b.HasOne("Jellyfin.Data.Entities.User", null) - .WithOne("DisplayPreferences") - .HasForeignKey("Jellyfin.Data.Entities.DisplayPreferences", "UserId") + .WithMany("DisplayPreferences") + .HasForeignKey("UserId") .OnDelete(DeleteBehavior.Cascade) .IsRequired(); }); @@ -429,7 +476,8 @@ namespace Jellyfin.Server.Implementations.Migrations { b.HasOne("Jellyfin.Data.Entities.User", null) .WithOne("ProfileImage") - .HasForeignKey("Jellyfin.Data.Entities.ImageInfo", "UserId"); + .HasForeignKey("Jellyfin.Data.Entities.ImageInfo", "UserId") + .OnDelete(DeleteBehavior.Cascade); }); modelBuilder.Entity("Jellyfin.Data.Entities.ItemDisplayPreferences", b => @@ -445,14 +493,36 @@ namespace Jellyfin.Server.Implementations.Migrations { b.HasOne("Jellyfin.Data.Entities.User", null) .WithMany("Permissions") - .HasForeignKey("Permission_Permissions_Guid"); + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade); }); modelBuilder.Entity("Jellyfin.Data.Entities.Preference", b => { b.HasOne("Jellyfin.Data.Entities.User", null) .WithMany("Preferences") - .HasForeignKey("Preference_Preferences_Guid"); + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.DisplayPreferences", b => + { + b.Navigation("HomeSections"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.User", b => + { + b.Navigation("AccessSchedules"); + + b.Navigation("DisplayPreferences"); + + b.Navigation("ItemDisplayPreferences"); + + b.Navigation("Permissions"); + + b.Navigation("Preferences"); + + b.Navigation("ProfileImage"); }); #pragma warning restore 612, 618 } diff --git a/Jellyfin.Server.Implementations/ModelBuilderExtensions.cs b/Jellyfin.Server.Implementations/ModelBuilderExtensions.cs new file mode 100644 index 0000000000..80ad65a42c --- /dev/null +++ b/Jellyfin.Server.Implementations/ModelBuilderExtensions.cs @@ -0,0 +1,48 @@ +using System; +using Jellyfin.Server.Implementations.ValueConverters; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +namespace Jellyfin.Server.Implementations +{ + /// <summary> + /// Model builder extensions. + /// </summary> + public static class ModelBuilderExtensions + { + /// <summary> + /// Specify value converter for the object type. + /// </summary> + /// <param name="modelBuilder">The model builder.</param> + /// <param name="converter">The <see cref="ValueConverter{TModel,TProvider}"/>.</param> + /// <typeparam name="T">The type to convert.</typeparam> + /// <returns>The modified <see cref="ModelBuilder"/>.</returns> + public static ModelBuilder UseValueConverterForType<T>(this ModelBuilder modelBuilder, ValueConverter converter) + { + var type = typeof(T); + foreach (var entityType in modelBuilder.Model.GetEntityTypes()) + { + foreach (var property in entityType.GetProperties()) + { + if (property.ClrType == type) + { + property.SetValueConverter(converter); + } + } + } + + return modelBuilder; + } + + /// <summary> + /// Specify the default <see cref="DateTimeKind"/>. + /// </summary> + /// <param name="modelBuilder">The model builder to extend.</param> + /// <param name="kind">The <see cref="DateTimeKind"/> to specify.</param> + public static void SetDefaultDateTimeKind(this ModelBuilder modelBuilder, DateTimeKind kind) + { + modelBuilder.UseValueConverterForType<DateTime>(new DateTimeKindValueConverter(kind)); + modelBuilder.UseValueConverterForType<DateTime?>(new DateTimeKindValueConverter(kind)); + } + } +} \ No newline at end of file diff --git a/Jellyfin.Server.Implementations/Properties/AssemblyInfo.cs b/Jellyfin.Server.Implementations/Properties/AssemblyInfo.cs new file mode 100644 index 0000000000..6528d4fdcf --- /dev/null +++ b/Jellyfin.Server.Implementations/Properties/AssemblyInfo.cs @@ -0,0 +1,23 @@ +using System.Reflection; +using System.Resources; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; + +// General Information about an assembly is controlled through the following +// set of attributes. Change these attribute values to modify the information +// associated with an assembly. +[assembly: AssemblyTitle("Jellyfin.Server.Implementations")] +[assembly: AssemblyDescription("")] +[assembly: AssemblyConfiguration("")] +[assembly: AssemblyCompany("Jellyfin Project")] +[assembly: AssemblyProduct("Jellyfin Server")] +[assembly: AssemblyCopyright("Copyright © 2019 Jellyfin Contributors. Code released under the GNU General Public License")] +[assembly: AssemblyTrademark("")] +[assembly: AssemblyCulture("")] +[assembly: NeutralResourcesLanguage("en")] +[assembly: InternalsVisibleTo("Jellyfin.Server.Implementations.Tests")] + +// Setting ComVisible to false makes the types in this assembly not visible +// to COM components. If you need to access a type in this assembly from +// COM, set the ComVisible attribute to true on that type. +[assembly: ComVisible(false)] diff --git a/Jellyfin.Server.Implementations/Users/DefaultAuthenticationProvider.cs b/Jellyfin.Server.Implementations/Users/DefaultAuthenticationProvider.cs index f79e433a67..6a78e7ee6f 100644 --- a/Jellyfin.Server.Implementations/Users/DefaultAuthenticationProvider.cs +++ b/Jellyfin.Server.Implementations/Users/DefaultAuthenticationProvider.cs @@ -1,5 +1,3 @@ -#nullable enable - using System; using System.Linq; using System.Text; @@ -53,7 +51,7 @@ namespace Jellyfin.Server.Implementations.Users bool success = false; - // As long as jellyfin supports passwordless users, we need this little block here to accommodate + // As long as jellyfin supports password-less users, we need this little block here to accommodate if (!HasPassword(resolvedUser) && string.IsNullOrEmpty(password)) { return Task.FromResult(new ProviderAuthenticationResult diff --git a/Jellyfin.Server.Implementations/Users/DefaultPasswordResetProvider.cs b/Jellyfin.Server.Implementations/Users/DefaultPasswordResetProvider.cs index 6cb13cd23e..c99c5e4efc 100644 --- a/Jellyfin.Server.Implementations/Users/DefaultPasswordResetProvider.cs +++ b/Jellyfin.Server.Implementations/Users/DefaultPasswordResetProvider.cs @@ -1,5 +1,3 @@ -#nullable enable - using System; using System.Collections.Generic; using System.IO; @@ -57,7 +55,8 @@ namespace Jellyfin.Server.Implementations.Users SerializablePasswordReset spr; await using (var str = File.OpenRead(resetFile)) { - spr = await JsonSerializer.DeserializeAsync<SerializablePasswordReset>(str).ConfigureAwait(false); + spr = await JsonSerializer.DeserializeAsync<SerializablePasswordReset>(str).ConfigureAwait(false) + ?? throw new ResourceNotFoundException($"Provided path ({resetFile}) is not valid."); } if (spr.ExpirationDate < DateTime.UtcNow) @@ -67,7 +66,7 @@ namespace Jellyfin.Server.Implementations.Users else if (string.Equals( spr.Pin.Replace("-", string.Empty, StringComparison.Ordinal), pin.Replace("-", string.Empty, StringComparison.Ordinal), - StringComparison.InvariantCultureIgnoreCase)) + StringComparison.OrdinalIgnoreCase)) { var resetUser = userManager.GetUserByName(spr.UserName) ?? throw new ResourceNotFoundException($"User with a username of {spr.UserName} not found"); diff --git a/Jellyfin.Server.Implementations/Users/DeviceAccessEntryPoint.cs b/Jellyfin.Server.Implementations/Users/DeviceAccessEntryPoint.cs index 1fb89c4a63..dbba80c210 100644 --- a/Jellyfin.Server.Implementations/Users/DeviceAccessEntryPoint.cs +++ b/Jellyfin.Server.Implementations/Users/DeviceAccessEntryPoint.cs @@ -1,5 +1,4 @@ -#nullable enable -#pragma warning disable CS1591 +#pragma warning disable CS1591 using System.Threading.Tasks; using Jellyfin.Data.Entities; diff --git a/Jellyfin.Server.Implementations/Users/DisplayPreferencesManager.cs b/Jellyfin.Server.Implementations/Users/DisplayPreferencesManager.cs index 76f9433854..c89e3c74d5 100644 --- a/Jellyfin.Server.Implementations/Users/DisplayPreferencesManager.cs +++ b/Jellyfin.Server.Implementations/Users/DisplayPreferencesManager.cs @@ -1,4 +1,5 @@ #pragma warning disable CA1307 +#pragma warning disable CA1309 using System; using System.Collections.Generic; @@ -26,16 +27,16 @@ namespace Jellyfin.Server.Implementations.Users } /// <inheritdoc /> - public DisplayPreferences GetDisplayPreferences(Guid userId, string client) + public DisplayPreferences GetDisplayPreferences(Guid userId, Guid itemId, string client) { var prefs = _dbContext.DisplayPreferences .Include(pref => pref.HomeSections) .FirstOrDefault(pref => - pref.UserId == userId && string.Equals(pref.Client, client)); + pref.UserId == userId && string.Equals(pref.Client, client) && pref.ItemId == itemId); if (prefs == null) { - prefs = new DisplayPreferences(userId, client); + prefs = new DisplayPreferences(userId, itemId, client); _dbContext.DisplayPreferences.Add(prefs); } @@ -66,6 +67,34 @@ namespace Jellyfin.Server.Implementations.Users .ToList(); } + /// <inheritdoc /> + public Dictionary<string, string?> ListCustomItemDisplayPreferences(Guid userId, Guid itemId, string client) + { + return _dbContext.CustomItemDisplayPreferences + .AsQueryable() + .Where(prefs => prefs.UserId == userId + && prefs.ItemId == itemId + && string.Equals(prefs.Client, client)) + .ToDictionary(prefs => prefs.Key, prefs => prefs.Value); + } + + /// <inheritdoc /> + public void SetCustomItemDisplayPreferences(Guid userId, Guid itemId, string client, Dictionary<string, string> customPreferences) + { + var existingPrefs = _dbContext.CustomItemDisplayPreferences + .AsQueryable() + .Where(prefs => prefs.UserId == userId + && prefs.ItemId == itemId + && string.Equals(prefs.Client, client)); + _dbContext.CustomItemDisplayPreferences.RemoveRange(existingPrefs); + + foreach (var (key, value) in customPreferences) + { + _dbContext.CustomItemDisplayPreferences + .Add(new CustomItemDisplayPreferences(userId, itemId, client, key, value)); + } + } + /// <inheritdoc /> public void SaveChanges() { diff --git a/Jellyfin.Server.Implementations/Users/InvalidAuthProvider.cs b/Jellyfin.Server.Implementations/Users/InvalidAuthProvider.cs index 5f32479e1d..c4e4c460a6 100644 --- a/Jellyfin.Server.Implementations/Users/InvalidAuthProvider.cs +++ b/Jellyfin.Server.Implementations/Users/InvalidAuthProvider.cs @@ -1,5 +1,3 @@ -#nullable enable - using System.Threading.Tasks; using Jellyfin.Data.Entities; using MediaBrowser.Controller.Authentication; diff --git a/Jellyfin.Server.Implementations/Users/UserManager.cs b/Jellyfin.Server.Implementations/Users/UserManager.cs index 437833aa38..27d4f40d30 100644 --- a/Jellyfin.Server.Implementations/Users/UserManager.cs +++ b/Jellyfin.Server.Implementations/Users/UserManager.cs @@ -1,7 +1,7 @@ -#nullable enable -#pragma warning disable CA1307 +#pragma warning disable CA1307 using System; +using System.Collections.Concurrent; using System.Collections.Generic; using System.Globalization; using System.Linq; @@ -48,6 +48,8 @@ namespace Jellyfin.Server.Implementations.Users private readonly DefaultAuthenticationProvider _defaultAuthenticationProvider; private readonly DefaultPasswordResetProvider _defaultPasswordResetProvider; + private readonly IDictionary<Guid, User> _users; + /// <summary> /// Initializes a new instance of the <see cref="UserManager"/> class. /// </summary> @@ -81,38 +83,28 @@ namespace Jellyfin.Server.Implementations.Users _invalidAuthProvider = _authenticationProviders.OfType<InvalidAuthProvider>().First(); _defaultAuthenticationProvider = _authenticationProviders.OfType<DefaultAuthenticationProvider>().First(); _defaultPasswordResetProvider = _passwordResetProviders.OfType<DefaultPasswordResetProvider>().First(); + + _users = new ConcurrentDictionary<Guid, User>(); + using var dbContext = _dbProvider.CreateContext(); + foreach (var user in dbContext.Users + .Include(user => user.Permissions) + .Include(user => user.Preferences) + .Include(user => user.AccessSchedules) + .Include(user => user.ProfileImage) + .AsEnumerable()) + { + _users.Add(user.Id, user); + } } /// <inheritdoc/> public event EventHandler<GenericEventArgs<User>>? OnUserUpdated; /// <inheritdoc/> - public IEnumerable<User> Users - { - get - { - using var dbContext = _dbProvider.CreateContext(); - return dbContext.Users - .Include(user => user.Permissions) - .Include(user => user.Preferences) - .Include(user => user.AccessSchedules) - .Include(user => user.ProfileImage) - .ToList(); - } - } + public IEnumerable<User> Users => _users.Values; /// <inheritdoc/> - public IEnumerable<Guid> UsersIds - { - get - { - using var dbContext = _dbProvider.CreateContext(); - return dbContext.Users - .AsQueryable() - .Select(user => user.Id) - .ToList(); - } - } + public IEnumerable<Guid> UsersIds => _users.Keys; /// <inheritdoc/> public User? GetUserById(Guid id) @@ -122,13 +114,8 @@ namespace Jellyfin.Server.Implementations.Users throw new ArgumentException("Guid can't be empty", nameof(id)); } - using var dbContext = _dbProvider.CreateContext(); - return dbContext.Users - .Include(user => user.Permissions) - .Include(user => user.Preferences) - .Include(user => user.AccessSchedules) - .Include(user => user.ProfileImage) - .FirstOrDefault(user => user.Id == id); + _users.TryGetValue(id, out var user); + return user; } /// <inheritdoc/> @@ -139,14 +126,7 @@ namespace Jellyfin.Server.Implementations.Users throw new ArgumentException("Invalid username", nameof(name)); } - using var dbContext = _dbProvider.CreateContext(); - return dbContext.Users - .Include(user => user.Permissions) - .Include(user => user.Preferences) - .Include(user => user.AccessSchedules) - .Include(user => user.ProfileImage) - .AsEnumerable() - .FirstOrDefault(u => string.Equals(u.Username, name, StringComparison.OrdinalIgnoreCase)); + return _users.Values.FirstOrDefault(u => string.Equals(u.Username, name, StringComparison.OrdinalIgnoreCase)); } /// <inheritdoc/> @@ -157,17 +137,20 @@ namespace Jellyfin.Server.Implementations.Users throw new ArgumentNullException(nameof(user)); } - if (string.IsNullOrWhiteSpace(newName)) - { - throw new ArgumentException("Invalid username", nameof(newName)); - } + ThrowIfInvalidUsername(newName); if (user.Username.Equals(newName, StringComparison.Ordinal)) { throw new ArgumentException("The new and old names must be different."); } - if (Users.Any(u => u.Id != user.Id && u.Username.Equals(newName, StringComparison.Ordinal))) + await using var dbContext = _dbProvider.CreateContext(); + + if (await dbContext.Users + .AsQueryable() + .Where(u => u.Username == newName && u.Id != user.Id) + .AnyAsync() + .ConfigureAwait(false)) { throw new ArgumentException(string.Format( CultureInfo.InvariantCulture, @@ -177,7 +160,6 @@ namespace Jellyfin.Server.Implementations.Users user.Username = newName; await UpdateUserAsync(user).ConfigureAwait(false); - OnUserUpdated?.Invoke(this, new GenericEventArgs<User>(user)); } @@ -186,6 +168,7 @@ namespace Jellyfin.Server.Implementations.Users { using var dbContext = _dbProvider.CreateContext(); dbContext.Users.Update(user); + _users[user.Id] = user; dbContext.SaveChanges(); } @@ -194,7 +177,7 @@ namespace Jellyfin.Server.Implementations.Users { await using var dbContext = _dbProvider.CreateContext(); dbContext.Users.Update(user); - + _users[user.Id] = user; await dbContext.SaveChangesAsync().ConfigureAwait(false); } @@ -205,21 +188,33 @@ namespace Jellyfin.Server.Implementations.Users ? await dbContext.Users.AsQueryable().Select(u => u.InternalId).MaxAsync().ConfigureAwait(false) : 0; - return new User( + var user = new User( name, - _defaultAuthenticationProvider.GetType().FullName, - _defaultPasswordResetProvider.GetType().FullName) + _defaultAuthenticationProvider.GetType().FullName!, + _defaultPasswordResetProvider.GetType().FullName!) { InternalId = max + 1 }; + + user.AddDefaultPermissions(); + user.AddDefaultPreferences(); + + _users.Add(user.Id, user); + + return user; } /// <inheritdoc/> public async Task<User> CreateUserAsync(string name) { - if (!IsValidUsername(name)) + ThrowIfInvalidUsername(name); + + if (Users.Any(u => u.Username.Equals(name, StringComparison.OrdinalIgnoreCase))) { - throw new ArgumentException("Usernames can contain unicode symbols, numbers (0-9), dashes (-), underscores (_), apostrophes ('), and periods (.)"); + throw new ArgumentException(string.Format( + CultureInfo.InvariantCulture, + "A user with the name '{0}' already exists.", + name)); } await using var dbContext = _dbProvider.CreateContext(); @@ -235,30 +230,14 @@ namespace Jellyfin.Server.Implementations.Users } /// <inheritdoc/> - public void DeleteUser(Guid userId) + public async Task DeleteUserAsync(Guid userId) { - using var dbContext = _dbProvider.CreateContext(); - var user = dbContext.Users - .Include(u => u.Permissions) - .Include(u => u.Preferences) - .Include(u => u.AccessSchedules) - .Include(u => u.ProfileImage) - .FirstOrDefault(u => u.Id == userId); - if (user == null) + if (!_users.TryGetValue(userId, out var user)) { throw new ResourceNotFoundException(nameof(userId)); } - if (dbContext.Users.Find(user.Id) == null) - { - throw new ArgumentException(string.Format( - CultureInfo.InvariantCulture, - "The user cannot be deleted because there is no user with the Name {0} and Id {1}.", - user.Username, - user.Id)); - } - - if (dbContext.Users.Count() == 1) + if (_users.Count == 1) { throw new InvalidOperationException(string.Format( CultureInfo.InvariantCulture, @@ -277,19 +256,12 @@ namespace Jellyfin.Server.Implementations.Users nameof(userId)); } - // Clear all entities related to the user from the database. - if (user.ProfileImage != null) - { - dbContext.Remove(user.ProfileImage); - } - - dbContext.RemoveRange(user.Permissions); - dbContext.RemoveRange(user.Preferences); - dbContext.RemoveRange(user.AccessSchedules); + await using var dbContext = _dbProvider.CreateContext(); dbContext.Users.Remove(user); - dbContext.SaveChanges(); + await dbContext.SaveChangesAsync().ConfigureAwait(false); + _users.Remove(userId); - _eventManager.Publish(new UserDeletedEventArgs(user)); + await _eventManager.PublishAsync(new UserDeletedEventArgs(user)).ConfigureAwait(false); } /// <inheritdoc/> @@ -404,14 +376,14 @@ namespace Jellyfin.Server.Implementations.Users EnablePublicSharing = user.HasPermission(PermissionKind.EnablePublicSharing), AccessSchedules = user.AccessSchedules.ToArray(), BlockedTags = user.GetPreference(PreferenceKind.BlockedTags), - EnabledChannels = user.GetPreference(PreferenceKind.EnabledChannels)?.Select(Guid.Parse).ToArray(), + EnabledChannels = user.GetPreferenceValues<Guid>(PreferenceKind.EnabledChannels), EnabledDevices = user.GetPreference(PreferenceKind.EnabledDevices), - EnabledFolders = user.GetPreference(PreferenceKind.EnabledFolders)?.Select(Guid.Parse).ToArray(), + EnabledFolders = user.GetPreferenceValues<Guid>(PreferenceKind.EnabledFolders), EnableContentDeletionFromFolders = user.GetPreference(PreferenceKind.EnableContentDeletionFromFolders), SyncPlayAccess = user.SyncPlayAccess, - BlockedChannels = user.GetPreference(PreferenceKind.BlockedChannels)?.Select(Guid.Parse).ToArray(), - BlockedMediaFolders = user.GetPreference(PreferenceKind.BlockedMediaFolders)?.Select(Guid.Parse).ToArray(), - BlockUnratedItems = user.GetPreference(PreferenceKind.BlockUnratedItems).Select(Enum.Parse<UnratedItem>).ToArray() + BlockedChannels = user.GetPreferenceValues<Guid>(PreferenceKind.BlockedChannels), + BlockedMediaFolders = user.GetPreferenceValues<Guid>(PreferenceKind.BlockedMediaFolders), + BlockUnratedItems = user.GetPreferenceValues<UnratedItem>(PreferenceKind.BlockUnratedItems) } }; } @@ -431,27 +403,18 @@ namespace Jellyfin.Server.Implementations.Users } var user = Users.FirstOrDefault(i => string.Equals(username, i.Username, StringComparison.OrdinalIgnoreCase)); - bool success; - IAuthenticationProvider? authenticationProvider; + var authResult = await AuthenticateLocalUser(username, password, user, remoteEndPoint) + .ConfigureAwait(false); + var authenticationProvider = authResult.authenticationProvider; + var success = authResult.success; - if (user != null) - { - var authResult = await AuthenticateLocalUser(username, password, user, remoteEndPoint) - .ConfigureAwait(false); - authenticationProvider = authResult.authenticationProvider; - success = authResult.success; - } - else + if (user == null) { - var authResult = await AuthenticateLocalUser(username, password, null, remoteEndPoint) - .ConfigureAwait(false); - authenticationProvider = authResult.authenticationProvider; string updatedUsername = authResult.username; - success = authResult.success; if (success && authenticationProvider != null - && !(authenticationProvider is DefaultAuthenticationProvider)) + && authenticationProvider is not DefaultAuthenticationProvider) { // Trust the username returned by the authentication provider username = updatedUsername; @@ -460,11 +423,9 @@ namespace Jellyfin.Server.Implementations.Users // the authentication provider might have created it user = Users.FirstOrDefault(i => string.Equals(username, i.Username, StringComparison.OrdinalIgnoreCase)); - if (authenticationProvider is IHasNewUserPolicy hasNewUserPolicy) + if (authenticationProvider is IHasNewUserPolicy hasNewUserPolicy && user != null) { - UpdatePolicy(user.Id, hasNewUserPolicy.GetNewUserPolicy()); - - await UpdateUserAsync(user).ConfigureAwait(false); + await UpdatePolicyAsync(user.Id, hasNewUserPolicy.GetNewUserPolicy()).ConfigureAwait(false); } } } @@ -473,7 +434,7 @@ namespace Jellyfin.Server.Implementations.Users { var providerId = authenticationProvider.GetType().FullName; - if (!string.Equals(providerId, user.AuthenticationProviderId, StringComparison.OrdinalIgnoreCase)) + if (providerId != null && !string.Equals(providerId, user.AuthenticationProviderId, StringComparison.OrdinalIgnoreCase)) { user.AuthenticationProviderId = providerId; await UpdateUserAsync(user).ConfigureAwait(false); @@ -589,9 +550,7 @@ namespace Jellyfin.Server.Implementations.Users public async Task InitializeAsync() { // TODO: Refactor the startup wizard so that it doesn't require a user to already exist. - await using var dbContext = _dbProvider.CreateContext(); - - if (await dbContext.Users.AsQueryable().AnyAsync().ConfigureAwait(false)) + if (_users.Any()) { return; } @@ -604,6 +563,7 @@ namespace Jellyfin.Server.Implementations.Users _logger.LogWarning("No users, creating one with username {UserName}", defaultName); + await using var dbContext = _dbProvider.CreateContext(); var newUser = await CreateUserInternalAsync(defaultName, dbContext).ConfigureAwait(false); newUser.SetPermission(PermissionKind.IsAdministrator, true); newUser.SetPermission(PermissionKind.EnableContentDeletion, true); @@ -644,9 +604,9 @@ namespace Jellyfin.Server.Implementations.Users } /// <inheritdoc/> - public void UpdateConfiguration(Guid userId, UserConfiguration config) + public async Task UpdateConfigurationAsync(Guid userId, UserConfiguration config) { - using var dbContext = _dbProvider.CreateContext(); + await using var dbContext = _dbProvider.CreateContext(); var user = dbContext.Users .Include(u => u.Permissions) .Include(u => u.Preferences) @@ -673,13 +633,14 @@ namespace Jellyfin.Server.Implementations.Users user.SetPreference(PreferenceKind.LatestItemExcludes, config.LatestItemsExcludes); dbContext.Update(user); - dbContext.SaveChanges(); + _users[user.Id] = user; + await dbContext.SaveChangesAsync().ConfigureAwait(false); } /// <inheritdoc/> - public void UpdatePolicy(Guid userId, UserPolicy policy) + public async Task UpdatePolicyAsync(Guid userId, UserPolicy policy) { - using var dbContext = _dbProvider.CreateContext(); + await using var dbContext = _dbProvider.CreateContext(); var user = dbContext.Users .Include(u => u.Permissions) .Include(u => u.Preferences) @@ -734,25 +695,36 @@ namespace Jellyfin.Server.Implementations.Users } // TODO: fix this at some point - user.SetPreference( - PreferenceKind.BlockUnratedItems, - policy.BlockUnratedItems?.Select(i => i.ToString()).ToArray() ?? Array.Empty<string>()); + user.SetPreference(PreferenceKind.BlockUnratedItems, policy.BlockUnratedItems ?? Array.Empty<UnratedItem>()); user.SetPreference(PreferenceKind.BlockedTags, policy.BlockedTags); - user.SetPreference(PreferenceKind.EnabledChannels, policy.EnabledChannels?.Select(i => i.ToString("N", CultureInfo.InvariantCulture)).ToArray()); + user.SetPreference(PreferenceKind.EnabledChannels, policy.EnabledChannels); user.SetPreference(PreferenceKind.EnabledDevices, policy.EnabledDevices); - user.SetPreference(PreferenceKind.EnabledFolders, policy.EnabledFolders?.Select(i => i.ToString("N", CultureInfo.InvariantCulture)).ToArray()); + user.SetPreference(PreferenceKind.EnabledFolders, policy.EnabledFolders); user.SetPreference(PreferenceKind.EnableContentDeletionFromFolders, policy.EnableContentDeletionFromFolders); dbContext.Update(user); - dbContext.SaveChanges(); + _users[user.Id] = user; + await dbContext.SaveChangesAsync().ConfigureAwait(false); } /// <inheritdoc/> - public void ClearProfileImage(User user) + public async Task ClearProfileImageAsync(User user) { - using var dbContext = _dbProvider.CreateContext(); + await using var dbContext = _dbProvider.CreateContext(); dbContext.Remove(user.ProfileImage); - dbContext.SaveChanges(); + await dbContext.SaveChangesAsync().ConfigureAwait(false); + user.ProfileImage = null; + _users[user.Id] = user; + } + + internal static void ThrowIfInvalidUsername(string name) + { + if (!string.IsNullOrWhiteSpace(name) && IsValidUsername(name)) + { + return; + } + + throw new ArgumentException("Usernames can contain unicode symbols, numbers (0-9), dashes (-), underscores (_), apostrophes ('), and periods (.)", nameof(name)); } private static bool IsValidUsername(string name) @@ -760,7 +732,7 @@ namespace Jellyfin.Server.Implementations.Users // This is some regex that matches only on unicode "word" characters, as well as -, _ and @ // In theory this will cut out most if not all 'control' characters which should help minimize any weirdness // Usernames can contain letters (a-z + whatever else unicode is cool with), numbers (0-9), at-signs (@), dashes (-), underscores (_), apostrophes ('), periods (.) and spaces ( ) - return Regex.IsMatch(name, @"^[\w\ \-'._@]*$"); + return Regex.IsMatch(name, @"^[\w\ \-'._@]+$"); } private IAuthenticationProvider GetAuthenticationProvider(User user) diff --git a/Jellyfin.Server.Implementations/ValueConverters/DateTimeKindValueConverter.cs b/Jellyfin.Server.Implementations/ValueConverters/DateTimeKindValueConverter.cs new file mode 100644 index 0000000000..a9a18c823a --- /dev/null +++ b/Jellyfin.Server.Implementations/ValueConverters/DateTimeKindValueConverter.cs @@ -0,0 +1,21 @@ +using System; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +namespace Jellyfin.Server.Implementations.ValueConverters +{ + /// <summary> + /// ValueConverter to specify kind. + /// </summary> + public class DateTimeKindValueConverter : ValueConverter<DateTime, DateTime> + { + /// <summary> + /// Initializes a new instance of the <see cref="DateTimeKindValueConverter"/> class. + /// </summary> + /// <param name="kind">The kind to specify.</param> + /// <param name="mappingHints">The mapping hints.</param> + public DateTimeKindValueConverter(DateTimeKind kind, ConverterMappingHints? mappingHints = null) + : base(v => v.ToUniversalTime(), v => DateTime.SpecifyKind(v, kind), mappingHints) + { + } + } +} diff --git a/Jellyfin.Server/CoreAppHost.cs b/Jellyfin.Server/CoreAppHost.cs index c44736447f..94c3ca4a95 100644 --- a/Jellyfin.Server/CoreAppHost.cs +++ b/Jellyfin.Server/CoreAppHost.cs @@ -11,8 +11,8 @@ using Jellyfin.Server.Implementations; using Jellyfin.Server.Implementations.Activity; using Jellyfin.Server.Implementations.Events; using Jellyfin.Server.Implementations.Users; -using MediaBrowser.Common.Net; using MediaBrowser.Controller; +using MediaBrowser.Controller.BaseItemManager; using MediaBrowser.Controller.Drawing; using MediaBrowser.Controller.Events; using MediaBrowser.Controller.Library; @@ -20,6 +20,7 @@ using MediaBrowser.Controller.Net; using MediaBrowser.Model.Activity; using MediaBrowser.Model.IO; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; @@ -36,22 +37,22 @@ namespace Jellyfin.Server /// <param name="applicationPaths">The <see cref="ServerApplicationPaths" /> to be used by the <see cref="CoreAppHost" />.</param> /// <param name="loggerFactory">The <see cref="ILoggerFactory" /> to be used by the <see cref="CoreAppHost" />.</param> /// <param name="options">The <see cref="StartupOptions" /> to be used by the <see cref="CoreAppHost" />.</param> + /// <param name="startupConfig">The <see cref="IConfiguration" /> to be used by the <see cref="CoreAppHost" />.</param> /// <param name="fileSystem">The <see cref="IFileSystem" /> to be used by the <see cref="CoreAppHost" />.</param> - /// <param name="networkManager">The <see cref="INetworkManager" /> to be used by the <see cref="CoreAppHost" />.</param> /// <param name="collection">The <see cref="IServiceCollection"/> to be used by the <see cref="CoreAppHost"/>.</param> public CoreAppHost( IServerApplicationPaths applicationPaths, ILoggerFactory loggerFactory, IStartupOptions options, + IConfiguration startupConfig, IFileSystem fileSystem, - INetworkManager networkManager, IServiceCollection collection) : base( applicationPaths, loggerFactory, options, + startupConfig, fileSystem, - networkManager, collection) { } @@ -76,6 +77,7 @@ namespace Jellyfin.Server options => options.UseSqlite($"Filename={Path.Combine(ApplicationPaths.DataPath, "jellyfin.db")}")); ServiceCollection.AddEventServices(); + ServiceCollection.AddSingleton<IBaseItemManager, BaseItemManager>(); ServiceCollection.AddSingleton<IEventManager, EventManager>(); ServiceCollection.AddSingleton<JellyfinDbProvider>(); @@ -83,13 +85,11 @@ namespace Jellyfin.Server ServiceCollection.AddSingleton<IUserManager, UserManager>(); ServiceCollection.AddSingleton<IDisplayPreferencesManager, DisplayPreferencesManager>(); - ServiceCollection.AddScoped<IWebSocketListener, SessionWebSocketListener>(); - ServiceCollection.AddScoped<IWebSocketListener, ActivityLogWebSocketListener>(); - ServiceCollection.AddScoped<IWebSocketListener, ScheduledTasksWebSocketListener>(); - ServiceCollection.AddScoped<IWebSocketListener, SessionInfoWebSocketListener>(); - - // TODO fix circular dependency on IWebSocketManager - ServiceCollection.AddScoped(serviceProvider => new Lazy<IEnumerable<IWebSocketListener>>(serviceProvider.GetRequiredService<IEnumerable<IWebSocketListener>>)); + // TODO search the assemblies instead of adding them manually? + ServiceCollection.AddSingleton<IWebSocketListener, SessionWebSocketListener>(); + ServiceCollection.AddSingleton<IWebSocketListener, ActivityLogWebSocketListener>(); + ServiceCollection.AddSingleton<IWebSocketListener, ScheduledTasksWebSocketListener>(); + ServiceCollection.AddSingleton<IWebSocketListener, SessionInfoWebSocketListener>(); base.RegisterServices(); } diff --git a/Jellyfin.Server/Extensions/ApiApplicationBuilderExtensions.cs b/Jellyfin.Server/Extensions/ApiApplicationBuilderExtensions.cs index c7fbfa4d02..e291677475 100644 --- a/Jellyfin.Server/Extensions/ApiApplicationBuilderExtensions.cs +++ b/Jellyfin.Server/Extensions/ApiApplicationBuilderExtensions.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using Jellyfin.Networking.Configuration; using Jellyfin.Server.Middleware; using MediaBrowser.Controller.Configuration; using Microsoft.AspNetCore.Builder; @@ -24,8 +25,8 @@ namespace Jellyfin.Server.Extensions // Enable middleware to serve swagger-ui (HTML, JS, CSS, etc.), // specifying the Swagger JSON endpoint. - var baseUrl = serverConfigurationManager.Configuration.BaseUrl.Trim('/'); - var apiDocBaseUrl = serverConfigurationManager.Configuration.BaseUrl; + var baseUrl = serverConfigurationManager.GetNetworkConfiguration().BaseUrl.Trim('/'); + var apiDocBaseUrl = serverConfigurationManager.GetNetworkConfiguration().BaseUrl; if (!string.IsNullOrEmpty(baseUrl)) { baseUrl += '/'; @@ -77,6 +78,16 @@ namespace Jellyfin.Server.Extensions return appBuilder.UseMiddleware<LanFilteringMiddleware>(); } + /// <summary> + /// Enables url decoding before binding to the application pipeline. + /// </summary> + /// <param name="appBuilder">The <see cref="IApplicationBuilder"/>.</param> + /// <returns>The updated application builder.</returns> + public static IApplicationBuilder UseQueryStringDecoding(this IApplicationBuilder appBuilder) + { + return appBuilder.UseMiddleware<QueryStringDecodingMiddleware>(); + } + /// <summary> /// Adds base url redirection to the application pipeline. /// </summary> @@ -106,5 +117,28 @@ namespace Jellyfin.Server.Extensions { return appBuilder.UseMiddleware<WebSocketHandlerMiddleware>(); } + + /// <summary> + /// Adds robots.txt redirection to the application pipeline. + /// </summary> + /// <param name="appBuilder">The application builder.</param> + /// <returns>The updated application builder.</returns> + public static IApplicationBuilder UseRobotsRedirection(this IApplicationBuilder appBuilder) + { + return appBuilder.UseMiddleware<RobotsRedirectionMiddleware>(); + } + + /// <summary> + /// Adds /emby and /mediabrowser route trimming to the application pipeline. + /// </summary> + /// <remarks> + /// This must be injected before any path related middleware. + /// </remarks> + /// <param name="appBuilder">The application builder.</param> + /// <returns>The updated application builder.</returns> + public static IApplicationBuilder UsePathTrim(this IApplicationBuilder appBuilder) + { + return appBuilder.UseMiddleware<LegacyEmbyRouteRewriteMiddleware>(); + } } } diff --git a/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs b/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs index 416ae6d7c2..01c822fea6 100644 --- a/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs +++ b/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs @@ -3,7 +3,9 @@ using System.Collections.Generic; using System.IO; using System.Linq; using System.Net; +using System.Net.Sockets; using System.Reflection; +using Emby.Server.Implementations; using Jellyfin.Api.Auth; using Jellyfin.Api.Auth.DefaultAuthorizationPolicy; using Jellyfin.Api.Auth.DownloadPolicy; @@ -14,13 +16,17 @@ using Jellyfin.Api.Auth.IgnoreParentalControlPolicy; using Jellyfin.Api.Auth.LocalAccessOrRequiresElevationPolicy; using Jellyfin.Api.Auth.LocalAccessPolicy; using Jellyfin.Api.Auth.RequiresElevationPolicy; +using Jellyfin.Api.Auth.SyncPlayAccessPolicy; using Jellyfin.Api.Constants; using Jellyfin.Api.Controllers; using Jellyfin.Api.ModelBinders; +using Jellyfin.Data.Enums; +using Jellyfin.Extensions.Json; +using Jellyfin.Networking.Configuration; using Jellyfin.Server.Configuration; using Jellyfin.Server.Filters; using Jellyfin.Server.Formatters; -using MediaBrowser.Common.Json; +using MediaBrowser.Common.Net; using MediaBrowser.Model.Entities; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authorization; @@ -28,6 +34,8 @@ using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Cors.Infrastructure; using Microsoft.AspNetCore.HttpOverrides; using Microsoft.Extensions.DependencyInjection; +using Microsoft.OpenApi.Any; +using Microsoft.OpenApi.Interfaces; using Microsoft.OpenApi.Models; using Swashbuckle.AspNetCore.SwaggerGen; using AuthenticationSchemes = Jellyfin.Api.Constants.AuthenticationSchemes; @@ -55,6 +63,7 @@ namespace Jellyfin.Server.Extensions serviceCollection.AddSingleton<IAuthorizationHandler, LocalAccessHandler>(); serviceCollection.AddSingleton<IAuthorizationHandler, LocalAccessOrRequiresElevationHandler>(); serviceCollection.AddSingleton<IAuthorizationHandler, RequiresElevationHandler>(); + serviceCollection.AddSingleton<IAuthorizationHandler, SyncPlayAccessHandler>(); return serviceCollection.AddAuthorizationCore(options => { options.AddPolicy( @@ -120,6 +129,34 @@ namespace Jellyfin.Server.Extensions policy.AddAuthenticationSchemes(AuthenticationSchemes.CustomAuthentication); policy.AddRequirements(new RequiresElevationRequirement()); }); + options.AddPolicy( + Policies.SyncPlayHasAccess, + policy => + { + policy.AddAuthenticationSchemes(AuthenticationSchemes.CustomAuthentication); + policy.AddRequirements(new SyncPlayAccessRequirement(SyncPlayAccessRequirementType.HasAccess)); + }); + options.AddPolicy( + Policies.SyncPlayCreateGroup, + policy => + { + policy.AddAuthenticationSchemes(AuthenticationSchemes.CustomAuthentication); + policy.AddRequirements(new SyncPlayAccessRequirement(SyncPlayAccessRequirementType.CreateGroup)); + }); + options.AddPolicy( + Policies.SyncPlayJoinGroup, + policy => + { + policy.AddAuthenticationSchemes(AuthenticationSchemes.CustomAuthentication); + policy.AddRequirements(new SyncPlayAccessRequirement(SyncPlayAccessRequirementType.JoinGroup)); + }); + options.AddPolicy( + Policies.SyncPlayIsInGroup, + policy => + { + policy.AddAuthenticationSchemes(AuthenticationSchemes.CustomAuthentication); + policy.AddRequirements(new SyncPlayAccessRequirement(SyncPlayAccessRequirementType.IsInGroup)); + }); }); } @@ -139,22 +176,33 @@ namespace Jellyfin.Server.Extensions /// </summary> /// <param name="serviceCollection">The service collection.</param> /// <param name="pluginAssemblies">An IEnumerable containing all plugin assemblies with API controllers.</param> - /// <param name="knownProxies">A list of all known proxies to trust for X-Forwarded-For.</param> + /// <param name="config">The <see cref="NetworkConfiguration"/>.</param> /// <returns>The MVC builder.</returns> - public static IMvcBuilder AddJellyfinApi(this IServiceCollection serviceCollection, IEnumerable<Assembly> pluginAssemblies, IReadOnlyList<string> knownProxies) + public static IMvcBuilder AddJellyfinApi(this IServiceCollection serviceCollection, IEnumerable<Assembly> pluginAssemblies, NetworkConfiguration config) { IMvcBuilder mvcBuilder = serviceCollection .AddCors() .AddTransient<ICorsPolicyProvider, CorsPolicyProvider>() .Configure<ForwardedHeadersOptions>(options => { + // https://github.com/dotnet/aspnetcore/blob/master/src/Middleware/HttpOverrides/src/ForwardedHeadersMiddleware.cs + // Enable debug logging on Microsoft.AspNetCore.HttpOverrides.ForwardedHeadersMiddleware to help investigate issues. + options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto; - for (var i = 0; i < knownProxies.Count; i++) + if (config.KnownProxies.Length == 0) { - if (IPAddress.TryParse(knownProxies[i], out var address)) - { - options.KnownProxies.Add(address); - } + options.KnownNetworks.Clear(); + options.KnownProxies.Clear(); + } + else + { + AddProxyAddresses(config, config.KnownProxies, options); + } + + // Only set forward limit if we have some known proxies or some known networks. + if (options.KnownProxies.Count != 0 || options.KnownNetworks.Count != 0) + { + options.ForwardLimit = null; } }) .AddMvc(opts => @@ -171,7 +219,7 @@ namespace Jellyfin.Server.Extensions opts.OutputFormatters.Add(new CssOutputFormatter()); opts.OutputFormatters.Add(new XmlOutputFormatter()); - opts.ModelBinderProviders.Insert(0, new CommaDelimitedArrayModelBinderProvider()); + opts.ModelBinderProviders.Insert(0, new NullableEnumModelBinderProvider()); }) // Clear app parts to avoid other assemblies being picked up @@ -180,7 +228,7 @@ namespace Jellyfin.Server.Extensions .AddJsonOptions(options => { // Update all properties that are set in JsonDefaults - var jsonOptions = JsonDefaults.GetPascalCaseOptions(); + var jsonOptions = JsonDefaults.PascalCaseOptions; // From JsonDefaults options.JsonSerializerOptions.ReadCommentHandling = jsonOptions.ReadCommentHandling; @@ -215,7 +263,20 @@ namespace Jellyfin.Server.Extensions { return serviceCollection.AddSwaggerGen(c => { - c.SwaggerDoc("api-docs", new OpenApiInfo { Title = "Jellyfin API", Version = "v1" }); + var version = typeof(ApplicationHost).Assembly.GetName().Version?.ToString(3) ?? "0.0.1"; + c.SwaggerDoc("api-docs", new OpenApiInfo + { + Title = "Jellyfin API", + Version = version, + Extensions = new Dictionary<string, IOpenApiExtension> + { + { + "x-jellyfin-version", + new OpenApiString(version) + } + } + }); + c.AddSecurityDefinition(AuthenticationSchemes.CustomAuthentication, new OpenApiSecurityScheme { Type = SecuritySchemeType.ApiKey, @@ -224,18 +285,6 @@ namespace Jellyfin.Server.Extensions Description = "API key header parameter" }); - var securitySchemeRef = new OpenApiSecurityScheme - { - Reference = new OpenApiReference { Type = ReferenceType.SecurityScheme, Id = AuthenticationSchemes.CustomAuthentication }, - }; - - // TODO: Apply this with an operation filter instead of globally - // https://github.com/domaindrivendev/Swashbuckle.AspNetCore#add-security-definitions-and-requirements - c.AddSecurityRequirement(new OpenApiSecurityRequirement - { - { securitySchemeRef, Array.Empty<string>() } - }); - // Add all xml doc files to swagger generator. var xmlFiles = Directory.GetFiles( AppContext.BaseDirectory, @@ -257,36 +306,89 @@ namespace Jellyfin.Server.Extensions { description.TryGetMethodInfo(out MethodInfo methodInfo); // Attribute name, method name, none. - return description?.ActionDescriptor?.AttributeRouteInfo?.Name + return description?.ActionDescriptor.AttributeRouteInfo?.Name ?? methodInfo?.Name ?? null; }); + // Allow parameters to properly be nullable. + c.UseAllOfToExtendReferenceSchemas(); + c.SupportNonNullableReferenceTypes(); + // TODO - remove when all types are supported in System.Text.Json c.AddSwaggerTypeMappings(); + c.OperationFilter<SecurityRequirementsOperationFilter>(); c.OperationFilter<FileResponseFilter>(); - c.DocumentFilter<WebsocketModelFilter>(); + c.OperationFilter<FileRequestFilter>(); + c.OperationFilter<ParameterObsoleteFilter>(); + c.DocumentFilter<AdditionalModelFilter>(); }); } + /// <summary> + /// Sets up the proxy configuration based on the addresses in <paramref name="allowedProxies"/>. + /// </summary> + /// <param name="config">The <see cref="NetworkConfiguration"/> containing the config settings.</param> + /// <param name="allowedProxies">The string array to parse.</param> + /// <param name="options">The <see cref="ForwardedHeadersOptions"/> instance.</param> + internal static void AddProxyAddresses(NetworkConfiguration config, string[] allowedProxies, ForwardedHeadersOptions options) + { + for (var i = 0; i < allowedProxies.Length; i++) + { + if (IPNetAddress.TryParse(allowedProxies[i], out var addr)) + { + AddIpAddress(config, options, addr.Address, addr.PrefixLength); + } + else if (IPHost.TryParse(allowedProxies[i], out var host)) + { + foreach (var address in host.GetAddresses()) + { + AddIpAddress(config, options, address, address.AddressFamily == AddressFamily.InterNetwork ? 32 : 128); + } + } + } + } + + private static void AddIpAddress(NetworkConfiguration config, ForwardedHeadersOptions options, IPAddress addr, int prefixLength) + { + if ((!config.EnableIPV4 && addr.AddressFamily == AddressFamily.InterNetwork) || (!config.EnableIPV6 && addr.AddressFamily == AddressFamily.InterNetworkV6)) + { + return; + } + + // In order for dual-mode sockets to be used, IP6 has to be enabled in JF and an interface has to have an IP6 address. + if (addr.AddressFamily == AddressFamily.InterNetwork && config.EnableIPV6) + { + // If the server is using dual-mode sockets, IPv4 addresses are supplied in an IPv6 format. + // https://docs.microsoft.com/en-us/aspnet/core/host-and-deploy/proxy-load-balancer?view=aspnetcore-5.0 . + addr = addr.MapToIPv6(); + } + + if (prefixLength == 32) + { + options.KnownProxies.Add(addr); + } + else + { + options.KnownNetworks.Add(new IPNetwork(addr, prefixLength)); + } + } + private static void AddSwaggerTypeMappings(this SwaggerGenOptions options) { /* - * TODO remove when System.Text.Json supports non-string keys. - * Used in Jellyfin.Api.Controller.GetChannels. + * TODO remove when System.Text.Json properly supports non-string keys. + * Used in BaseItemDto.ImageBlurHashes */ options.MapType<Dictionary<ImageType, string>>(() => new OpenApiSchema { Type = "object", - Properties = typeof(ImageType).GetEnumNames().ToDictionary( - name => name, - name => new OpenApiSchema - { - Type = "string", - Format = "string" - }) + AdditionalProperties = new OpenApiSchema + { + Type = "string" + } }); /* @@ -298,18 +400,12 @@ namespace Jellyfin.Server.Extensions Type = "object", Properties = typeof(ImageType).GetEnumNames().ToDictionary( name => name, - name => new OpenApiSchema + _ => new OpenApiSchema { - Type = "object", Properties = new Dictionary<string, OpenApiSchema> + Type = "object", + AdditionalProperties = new OpenApiSchema { - { - "string", - new OpenApiSchema - { - Type = "string", - Format = "string" - } - } + Type = "string" } }) }); diff --git a/Jellyfin.Server/Filters/WebsocketModelFilter.cs b/Jellyfin.Server/Filters/AdditionalModelFilter.cs similarity index 76% rename from Jellyfin.Server/Filters/WebsocketModelFilter.cs rename to Jellyfin.Server/Filters/AdditionalModelFilter.cs index 2488028576..87a59e0b45 100644 --- a/Jellyfin.Server/Filters/WebsocketModelFilter.cs +++ b/Jellyfin.Server/Filters/AdditionalModelFilter.cs @@ -1,5 +1,6 @@ using MediaBrowser.Common.Plugins; using MediaBrowser.Controller.LiveTv; +using MediaBrowser.Model.ApiClient; using MediaBrowser.Model.Entities; using MediaBrowser.Model.Session; using MediaBrowser.Model.SyncPlay; @@ -9,9 +10,9 @@ using Swashbuckle.AspNetCore.SwaggerGen; namespace Jellyfin.Server.Filters { /// <summary> - /// Add models used in websocket messaging. + /// Add models not directly used by the API, but used for discovery and websockets. /// </summary> - public class WebsocketModelFilter : IDocumentFilter + public class AdditionalModelFilter : IDocumentFilter { /// <inheritdoc /> public void Apply(OpenApiDocument swaggerDoc, DocumentFilterContext context) @@ -25,6 +26,9 @@ namespace Jellyfin.Server.Filters context.SchemaGenerator.GenerateSchema(typeof(GeneralCommandType), context.SchemaRepository); context.SchemaGenerator.GenerateSchema(typeof(GroupUpdate<object>), context.SchemaRepository); + + context.SchemaGenerator.GenerateSchema(typeof(SessionMessageType), context.SchemaRepository); + context.SchemaGenerator.GenerateSchema(typeof(ServerDiscoveryInfo), context.SchemaRepository); } } } diff --git a/Jellyfin.Server/Filters/FileRequestFilter.cs b/Jellyfin.Server/Filters/FileRequestFilter.cs new file mode 100644 index 0000000000..69e10994f4 --- /dev/null +++ b/Jellyfin.Server/Filters/FileRequestFilter.cs @@ -0,0 +1,43 @@ +using System.Collections.Generic; +using Jellyfin.Api.Attributes; +using Microsoft.OpenApi.Models; +using Swashbuckle.AspNetCore.SwaggerGen; + +namespace Jellyfin.Server.Filters +{ + /// <inheritdoc /> + public class FileRequestFilter : IOperationFilter + { + /// <inheritdoc /> + public void Apply(OpenApiOperation operation, OperationFilterContext context) + { + foreach (var attribute in context.ApiDescription.ActionDescriptor.EndpointMetadata) + { + if (attribute is AcceptsFileAttribute acceptsFileAttribute) + { + operation.RequestBody = GetRequestBody(acceptsFileAttribute.GetContentTypes()); + break; + } + } + } + + private static OpenApiRequestBody GetRequestBody(IEnumerable<string> contentTypes) + { + var body = new OpenApiRequestBody(); + var mediaType = new OpenApiMediaType + { + Schema = new OpenApiSchema + { + Type = "string", + Format = "binary" + } + }; + foreach (var contentType in contentTypes) + { + body.Content.Add(contentType, mediaType); + } + + return body; + } + } +} diff --git a/Jellyfin.Server/Filters/FileResponseFilter.cs b/Jellyfin.Server/Filters/FileResponseFilter.cs index 8ea35c2818..eae9a80048 100644 --- a/Jellyfin.Server/Filters/FileResponseFilter.cs +++ b/Jellyfin.Server/Filters/FileResponseFilter.cs @@ -14,7 +14,8 @@ namespace Jellyfin.Server.Filters { Schema = new OpenApiSchema { - Type = "file" + Type = "string", + Format = "binary" } }; @@ -26,22 +27,22 @@ namespace Jellyfin.Server.Filters if (attribute is ProducesFileAttribute producesFileAttribute) { // Get operation response values. - var (_, value) = operation.Responses + var response = operation.Responses .FirstOrDefault(o => o.Key.Equals(SuccessCode, StringComparison.Ordinal)); // Operation doesn't have a response. - if (value == null) + if (response.Value == null) { continue; } // Clear existing responses. - value.Content.Clear(); + response.Value.Content.Clear(); // Add all content-types as file. foreach (var contentType in producesFileAttribute.GetContentTypes()) { - value.Content.Add(contentType, _openApiMediaType); + response.Value.Content.Add(contentType, _openApiMediaType); } break; diff --git a/Jellyfin.Server/Filters/ParameterObsoleteFilter.cs b/Jellyfin.Server/Filters/ParameterObsoleteFilter.cs new file mode 100644 index 0000000000..b9ce221f5c --- /dev/null +++ b/Jellyfin.Server/Filters/ParameterObsoleteFilter.cs @@ -0,0 +1,36 @@ +using System; +using System.Linq; +using Jellyfin.Api.Attributes; +using Microsoft.OpenApi.Models; +using Swashbuckle.AspNetCore.SwaggerGen; + +namespace Jellyfin.Server.Filters +{ + /// <summary> + /// Mark parameter as deprecated if it has the <see cref="ParameterObsoleteAttribute"/>. + /// </summary> + public class ParameterObsoleteFilter : IOperationFilter + { + /// <inheritdoc /> + public void Apply(OpenApiOperation operation, OperationFilterContext context) + { + foreach (var parameterDescription in context.ApiDescription.ParameterDescriptions) + { + if (parameterDescription + .CustomAttributes() + .OfType<ParameterObsoleteAttribute>() + .Any()) + { + foreach (var parameter in operation.Parameters) + { + if (parameter.Name.Equals(parameterDescription.Name, StringComparison.Ordinal)) + { + parameter.Deprecated = true; + break; + } + } + } + } + } + } +} diff --git a/Jellyfin.Server/Filters/SecurityRequirementsOperationFilter.cs b/Jellyfin.Server/Filters/SecurityRequirementsOperationFilter.cs new file mode 100644 index 0000000000..802662ce2f --- /dev/null +++ b/Jellyfin.Server/Filters/SecurityRequirementsOperationFilter.cs @@ -0,0 +1,78 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Jellyfin.Api.Constants; +using Microsoft.AspNetCore.Authorization; +using Microsoft.OpenApi.Models; +using Swashbuckle.AspNetCore.SwaggerGen; + +namespace Jellyfin.Server.Filters +{ + /// <summary> + /// Security requirement operation filter. + /// </summary> + public class SecurityRequirementsOperationFilter : IOperationFilter + { + /// <inheritdoc /> + public void Apply(OpenApiOperation operation, OperationFilterContext context) + { + var requiredScopes = new List<string>(); + + // Add all method scopes. + foreach (var attribute in context.MethodInfo.GetCustomAttributes(true)) + { + if (attribute is AuthorizeAttribute authorizeAttribute + && authorizeAttribute.Policy != null + && !requiredScopes.Contains(authorizeAttribute.Policy, StringComparer.Ordinal)) + { + requiredScopes.Add(authorizeAttribute.Policy); + } + } + + // Add controller scopes if any. + var controllerAttributes = context.MethodInfo.DeclaringType?.GetCustomAttributes(true); + if (controllerAttributes != null) + { + foreach (var attribute in controllerAttributes) + { + if (attribute is AuthorizeAttribute authorizeAttribute + && authorizeAttribute.Policy != null + && !requiredScopes.Contains(authorizeAttribute.Policy, StringComparer.Ordinal)) + { + requiredScopes.Add(authorizeAttribute.Policy); + } + } + } + + if (requiredScopes.Count != 0) + { + if (!operation.Responses.ContainsKey("401")) + { + operation.Responses.Add("401", new OpenApiResponse { Description = "Unauthorized" }); + } + + if (!operation.Responses.ContainsKey("403")) + { + operation.Responses.Add("403", new OpenApiResponse { Description = "Forbidden" }); + } + + var scheme = new OpenApiSecurityScheme + { + Reference = new OpenApiReference + { + Type = ReferenceType.SecurityScheme, + Id = AuthenticationSchemes.CustomAuthentication + } + }; + + operation.Security = new List<OpenApiSecurityRequirement> + { + new OpenApiSecurityRequirement + { + [scheme] = requiredScopes + } + }; + } + } + } +} \ No newline at end of file diff --git a/Jellyfin.Server/Formatters/CamelCaseJsonProfileFormatter.cs b/Jellyfin.Server/Formatters/CamelCaseJsonProfileFormatter.cs index 8043989b1e..ea8c5ecdb1 100644 --- a/Jellyfin.Server/Formatters/CamelCaseJsonProfileFormatter.cs +++ b/Jellyfin.Server/Formatters/CamelCaseJsonProfileFormatter.cs @@ -1,4 +1,4 @@ -using MediaBrowser.Common.Json; +using Jellyfin.Extensions.Json; using Microsoft.AspNetCore.Mvc.Formatters; using Microsoft.Net.Http.Headers; @@ -12,7 +12,7 @@ namespace Jellyfin.Server.Formatters /// <summary> /// Initializes a new instance of the <see cref="CamelCaseJsonProfileFormatter"/> class. /// </summary> - public CamelCaseJsonProfileFormatter() : base(JsonDefaults.GetCamelCaseOptions()) + public CamelCaseJsonProfileFormatter() : base(JsonDefaults.CamelCaseOptions) { SupportedMediaTypes.Clear(); SupportedMediaTypes.Add(MediaTypeHeaderValue.Parse(JsonDefaults.CamelCaseMediaType)); diff --git a/Jellyfin.Server/Formatters/CssOutputFormatter.cs b/Jellyfin.Server/Formatters/CssOutputFormatter.cs index b3771b7fe6..cfc9d1ad3b 100644 --- a/Jellyfin.Server/Formatters/CssOutputFormatter.cs +++ b/Jellyfin.Server/Formatters/CssOutputFormatter.cs @@ -1,5 +1,4 @@ -using System; -using System.Text; +using System.Text; using System.Threading.Tasks; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc.Formatters; @@ -30,7 +29,8 @@ namespace Jellyfin.Server.Formatters /// <returns>Write stream task.</returns> public override Task WriteResponseBodyAsync(OutputFormatterWriteContext context, Encoding selectedEncoding) { - return context.HttpContext.Response.WriteAsync(context.Object?.ToString()); + var stringResponse = context.Object?.ToString(); + return stringResponse == null ? Task.CompletedTask : context.HttpContext.Response.WriteAsync(stringResponse); } } } diff --git a/Jellyfin.Server/Formatters/PascalCaseJsonProfileFormatter.cs b/Jellyfin.Server/Formatters/PascalCaseJsonProfileFormatter.cs index d0110b125c..03ca7dda72 100644 --- a/Jellyfin.Server/Formatters/PascalCaseJsonProfileFormatter.cs +++ b/Jellyfin.Server/Formatters/PascalCaseJsonProfileFormatter.cs @@ -1,5 +1,5 @@ using System.Net.Mime; -using MediaBrowser.Common.Json; +using Jellyfin.Extensions.Json; using Microsoft.AspNetCore.Mvc.Formatters; using Microsoft.Net.Http.Headers; @@ -13,7 +13,7 @@ namespace Jellyfin.Server.Formatters /// <summary> /// Initializes a new instance of the <see cref="PascalCaseJsonProfileFormatter"/> class. /// </summary> - public PascalCaseJsonProfileFormatter() : base(JsonDefaults.GetPascalCaseOptions()) + public PascalCaseJsonProfileFormatter() : base(JsonDefaults.PascalCaseOptions) { SupportedMediaTypes.Clear(); // Add application/json for default formatter diff --git a/Jellyfin.Server/Formatters/XmlOutputFormatter.cs b/Jellyfin.Server/Formatters/XmlOutputFormatter.cs index 01d99d7c87..be0baea2d2 100644 --- a/Jellyfin.Server/Formatters/XmlOutputFormatter.cs +++ b/Jellyfin.Server/Formatters/XmlOutputFormatter.cs @@ -26,7 +26,8 @@ namespace Jellyfin.Server.Formatters /// <inheritdoc /> public override Task WriteResponseBodyAsync(OutputFormatterWriteContext context, Encoding selectedEncoding) { - return context.HttpContext.Response.WriteAsync(context.Object?.ToString()); + var stringResponse = context.Object?.ToString(); + return stringResponse == null ? Task.CompletedTask : context.HttpContext.Response.WriteAsync(stringResponse); } } } diff --git a/Jellyfin.Server/Jellyfin.Server.csproj b/Jellyfin.Server/Jellyfin.Server.csproj index 7de34f4160..ea64663bd7 100644 --- a/Jellyfin.Server/Jellyfin.Server.csproj +++ b/Jellyfin.Server/Jellyfin.Server.csproj @@ -8,12 +8,10 @@ <PropertyGroup> <AssemblyName>jellyfin</AssemblyName> <OutputType>Exe</OutputType> - <TargetFramework>netcoreapp3.1</TargetFramework> + <TargetFramework>net5.0</TargetFramework> + <ServerGarbageCollection>false</ServerGarbageCollection> <GenerateAssemblyInfo>false</GenerateAssemblyInfo> <GenerateDocumentationFile>true</GenerateDocumentationFile> - <TreatWarningsAsErrors>true</TreatWarningsAsErrors> - <Nullable>enable</Nullable> - <DisableImplicitAspNetCoreAnalyzers>true</DisableImplicitAspNetCoreAnalyzers> </PropertyGroup> <ItemGroup> @@ -26,33 +24,27 @@ <!-- Code Analyzers--> <ItemGroup Condition=" '$(Configuration)' == 'Debug' "> - <PackageReference Include="Microsoft.CodeAnalysis.FxCopAnalyzers" Version="2.9.8" PrivateAssets="All" /> <PackageReference Include="SerilogAnalyzer" Version="0.15.0" PrivateAssets="All" /> <PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="All" /> <PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" PrivateAssets="All" /> </ItemGroup> - <PropertyGroup Condition=" '$(Configuration)' == 'Debug' "> - <CodeAnalysisRuleSet>../jellyfin.ruleset</CodeAnalysisRuleSet> - </PropertyGroup> - <ItemGroup> <PackageReference Include="CommandLineParser" Version="2.8.0" /> - <PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="3.1.9" /> - <PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="3.1.9" /> - <PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks" Version="3.1.9" /> - <PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="3.1.9" /> - <PackageReference Include="prometheus-net" Version="3.6.0" /> - <PackageReference Include="prometheus-net.AspNetCore" Version="3.6.0" /> - <PackageReference Include="Serilog.AspNetCore" Version="3.4.0" /> + <PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="5.0.0" /> + <PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="5.0.0" /> + <PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks" Version="5.0.9" /> + <PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="5.0.9" /> + <PackageReference Include="prometheus-net" Version="4.2.0" /> + <PackageReference Include="prometheus-net.AspNetCore" Version="4.2.0" /> + <PackageReference Include="Serilog.AspNetCore" Version="4.1.0" /> <PackageReference Include="Serilog.Enrichers.Thread" Version="3.1.0" /> - <PackageReference Include="Serilog.Settings.Configuration" Version="3.1.0" /> - <PackageReference Include="Serilog.Sinks.Async" Version="1.4.0" /> - <PackageReference Include="Serilog.Sinks.Console" Version="3.1.1" /> - <PackageReference Include="Serilog.Sinks.File" Version="4.1.0" /> - <PackageReference Include="Serilog.Sinks.Graylog" Version="2.2.1" /> + <PackageReference Include="Serilog.Settings.Configuration" Version="3.2.0" /> + <PackageReference Include="Serilog.Sinks.Async" Version="1.5.0" /> + <PackageReference Include="Serilog.Sinks.Console" Version="4.0.0" /> + <PackageReference Include="Serilog.Sinks.File" Version="5.0.0" /> + <PackageReference Include="Serilog.Sinks.Graylog" Version="2.2.2" /> <PackageReference Include="SQLitePCLRaw.bundle_e_sqlite3" Version="2.0.4" /> - <PackageReference Include="SQLitePCLRaw.provider.sqlite3.netstandard11" Version="1.1.14" /> </ItemGroup> <ItemGroup> diff --git a/Jellyfin.Server/Middleware/BaseUrlRedirectionMiddleware.cs b/Jellyfin.Server/Middleware/BaseUrlRedirectionMiddleware.cs index 9316737bdf..3e5982eedf 100644 --- a/Jellyfin.Server/Middleware/BaseUrlRedirectionMiddleware.cs +++ b/Jellyfin.Server/Middleware/BaseUrlRedirectionMiddleware.cs @@ -1,5 +1,6 @@ using System; using System.Threading.Tasks; +using Jellyfin.Networking.Configuration; using MediaBrowser.Controller.Configuration; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Configuration; @@ -42,17 +43,38 @@ namespace Jellyfin.Server.Middleware public async Task Invoke(HttpContext httpContext, IServerConfigurationManager serverConfigurationManager) { var localPath = httpContext.Request.Path.ToString(); - var baseUrlPrefix = serverConfigurationManager.Configuration.BaseUrl; + var baseUrlPrefix = serverConfigurationManager.GetNetworkConfiguration().BaseUrl; - if (string.Equals(localPath, baseUrlPrefix + "/", StringComparison.OrdinalIgnoreCase) - || string.Equals(localPath, baseUrlPrefix, StringComparison.OrdinalIgnoreCase) - || string.Equals(localPath, "/", StringComparison.OrdinalIgnoreCase) - || string.IsNullOrEmpty(localPath) - || !localPath.StartsWith(baseUrlPrefix, StringComparison.OrdinalIgnoreCase)) + if (!string.IsNullOrEmpty(baseUrlPrefix)) { - // Always redirect back to the default path if the base prefix is invalid or missing + var startsWithBaseUrl = localPath.StartsWith(baseUrlPrefix, StringComparison.OrdinalIgnoreCase); + + if (!startsWithBaseUrl + && (localPath.Equals("/health", StringComparison.OrdinalIgnoreCase) + || localPath.Equals("/health/", StringComparison.OrdinalIgnoreCase))) + { + _logger.LogDebug("Redirecting /health check"); + httpContext.Response.Redirect(baseUrlPrefix + "/health"); + return; + } + + if (!startsWithBaseUrl + || localPath.Length == baseUrlPrefix.Length + // Local path is /baseUrl/ + || (localPath.Length == baseUrlPrefix.Length + 1 && localPath[^1] == '/')) + { + // Always redirect back to the default path if the base prefix is invalid, missing, or is the full path. + _logger.LogDebug("Normalizing an URL at {LocalPath}", localPath); + httpContext.Response.Redirect(baseUrlPrefix + "/" + _configuration[ConfigurationExtensions.DefaultRedirectKey]); + return; + } + } + else if (string.IsNullOrEmpty(localPath) + || localPath.Equals("/", StringComparison.Ordinal)) + { + // Always redirect back to the default path if root is requested. _logger.LogDebug("Normalizing an URL at {LocalPath}", localPath); - httpContext.Response.Redirect(baseUrlPrefix + "/" + _configuration[ConfigurationExtensions.DefaultRedirectKey]); + httpContext.Response.Redirect("/" + _configuration[ConfigurationExtensions.DefaultRedirectKey]); return; } diff --git a/Jellyfin.Server/Middleware/ExceptionMiddleware.cs b/Jellyfin.Server/Middleware/ExceptionMiddleware.cs index f6c76e4d9e..db7877c31e 100644 --- a/Jellyfin.Server/Middleware/ExceptionMiddleware.cs +++ b/Jellyfin.Server/Middleware/ExceptionMiddleware.cs @@ -137,11 +137,6 @@ namespace Jellyfin.Server.Middleware private string NormalizeExceptionMessage(string msg) { - if (msg == null) - { - return string.Empty; - } - // Strip any information we don't want to reveal return msg.Replace( _configuration.ApplicationPaths.ProgramSystemPath, diff --git a/Jellyfin.Server/Middleware/IpBasedAccessValidationMiddleware.cs b/Jellyfin.Server/Middleware/IpBasedAccessValidationMiddleware.cs index 4bda8f2737..0afcd61a05 100644 --- a/Jellyfin.Server/Middleware/IpBasedAccessValidationMiddleware.cs +++ b/Jellyfin.Server/Middleware/IpBasedAccessValidationMiddleware.cs @@ -1,8 +1,7 @@ -using System.Linq; +using System.Net; using System.Threading.Tasks; using MediaBrowser.Common.Extensions; using MediaBrowser.Common.Net; -using MediaBrowser.Controller.Configuration; using Microsoft.AspNetCore.Http; namespace Jellyfin.Server.Middleware @@ -28,46 +27,21 @@ namespace Jellyfin.Server.Middleware /// </summary> /// <param name="httpContext">The current HTTP context.</param> /// <param name="networkManager">The network manager.</param> - /// <param name="serverConfigurationManager">The server configuration manager.</param> /// <returns>The async task.</returns> - public async Task Invoke(HttpContext httpContext, INetworkManager networkManager, IServerConfigurationManager serverConfigurationManager) + public async Task Invoke(HttpContext httpContext, INetworkManager networkManager) { if (httpContext.IsLocal()) { + // Running locally. await _next(httpContext).ConfigureAwait(false); return; } - var remoteIp = httpContext.GetNormalizedRemoteIp(); + var remoteIp = httpContext.Connection.RemoteIpAddress ?? IPAddress.Loopback; - if (serverConfigurationManager.Configuration.EnableRemoteAccess) + if (!networkManager.HasRemoteAccess(remoteIp)) { - var addressFilter = serverConfigurationManager.Configuration.RemoteIPFilter.Where(i => !string.IsNullOrWhiteSpace(i)).ToArray(); - - if (addressFilter.Length > 0 && !networkManager.IsInLocalNetwork(remoteIp)) - { - if (serverConfigurationManager.Configuration.IsRemoteIPFilterBlacklist) - { - if (networkManager.IsAddressInSubnets(remoteIp, addressFilter)) - { - return; - } - } - else - { - if (!networkManager.IsAddressInSubnets(remoteIp, addressFilter)) - { - return; - } - } - } - } - else - { - if (!networkManager.IsInLocalNetwork(remoteIp)) - { - return; - } + return; } await _next(httpContext).ConfigureAwait(false); diff --git a/Jellyfin.Server/Middleware/LanFilteringMiddleware.cs b/Jellyfin.Server/Middleware/LanFilteringMiddleware.cs index 9d795145aa..67bf24d2a5 100644 --- a/Jellyfin.Server/Middleware/LanFilteringMiddleware.cs +++ b/Jellyfin.Server/Middleware/LanFilteringMiddleware.cs @@ -1,6 +1,6 @@ -using System; -using System.Linq; +using System.Net; using System.Threading.Tasks; +using Jellyfin.Networking.Configuration; using MediaBrowser.Common.Net; using MediaBrowser.Controller.Configuration; using Microsoft.AspNetCore.Http; @@ -32,45 +32,14 @@ namespace Jellyfin.Server.Middleware /// <returns>The async task.</returns> public async Task Invoke(HttpContext httpContext, INetworkManager networkManager, IServerConfigurationManager serverConfigurationManager) { - var currentHost = httpContext.Request.Host.ToString(); - var hosts = serverConfigurationManager - .Configuration - .LocalNetworkAddresses - .Select(NormalizeConfiguredLocalAddress) - .ToList(); + var host = httpContext.Connection.RemoteIpAddress ?? IPAddress.Loopback; - if (hosts.Count == 0) + if (!networkManager.IsInLocalNetwork(host) && !serverConfigurationManager.GetNetworkConfiguration().EnableRemoteAccess) { - await _next(httpContext).ConfigureAwait(false); return; } - currentHost ??= string.Empty; - - if (networkManager.IsInPrivateAddressSpace(currentHost)) - { - hosts.Add("localhost"); - hosts.Add("127.0.0.1"); - - if (hosts.All(i => currentHost.IndexOf(i, StringComparison.OrdinalIgnoreCase) == -1)) - { - return; - } - } - await _next(httpContext).ConfigureAwait(false); } - - private static string NormalizeConfiguredLocalAddress(string address) - { - var add = address.AsSpan().Trim('/'); - int index = add.IndexOf('/'); - if (index != -1) - { - add = add.Slice(index + 1); - } - - return add.TrimStart('/').ToString(); - } } } diff --git a/Jellyfin.Server/Middleware/LegacyEmbyRouteRewriteMiddleware.cs b/Jellyfin.Server/Middleware/LegacyEmbyRouteRewriteMiddleware.cs new file mode 100644 index 0000000000..fdd8974d2b --- /dev/null +++ b/Jellyfin.Server/Middleware/LegacyEmbyRouteRewriteMiddleware.cs @@ -0,0 +1,54 @@ +using System; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Server.Middleware +{ + /// <summary> + /// Removes /emby and /mediabrowser from requested route. + /// </summary> + public class LegacyEmbyRouteRewriteMiddleware + { + private const string EmbyPath = "/emby"; + private const string MediabrowserPath = "/mediabrowser"; + + private readonly RequestDelegate _next; + private readonly ILogger<LegacyEmbyRouteRewriteMiddleware> _logger; + + /// <summary> + /// Initializes a new instance of the <see cref="LegacyEmbyRouteRewriteMiddleware"/> class. + /// </summary> + /// <param name="next">The next delegate in the pipeline.</param> + /// <param name="logger">The logger.</param> + public LegacyEmbyRouteRewriteMiddleware( + RequestDelegate next, + ILogger<LegacyEmbyRouteRewriteMiddleware> logger) + { + _next = next; + _logger = logger; + } + + /// <summary> + /// Executes the middleware action. + /// </summary> + /// <param name="httpContext">The current HTTP context.</param> + /// <returns>The async task.</returns> + public async Task Invoke(HttpContext httpContext) + { + var localPath = httpContext.Request.Path.ToString(); + if (localPath.StartsWith(EmbyPath, StringComparison.OrdinalIgnoreCase)) + { + httpContext.Request.Path = localPath[EmbyPath.Length..]; + _logger.LogDebug("Removing {EmbyPath} from route.", EmbyPath); + } + else if (localPath.StartsWith(MediabrowserPath, StringComparison.OrdinalIgnoreCase)) + { + httpContext.Request.Path = localPath[MediabrowserPath.Length..]; + _logger.LogDebug("Removing {MediabrowserPath} from route.", MediabrowserPath); + } + + await _next(httpContext).ConfigureAwait(false); + } + } +} \ No newline at end of file diff --git a/Jellyfin.Server/Middleware/QueryStringDecodingMiddleware.cs b/Jellyfin.Server/Middleware/QueryStringDecodingMiddleware.cs new file mode 100644 index 0000000000..fd0ebbf438 --- /dev/null +++ b/Jellyfin.Server/Middleware/QueryStringDecodingMiddleware.cs @@ -0,0 +1,35 @@ +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Features; + +namespace Jellyfin.Server.Middleware +{ + /// <summary> + /// URL decodes the querystring before binding. + /// </summary> + public class QueryStringDecodingMiddleware + { + private readonly RequestDelegate _next; + + /// <summary> + /// Initializes a new instance of the <see cref="QueryStringDecodingMiddleware"/> class. + /// </summary> + /// <param name="next">The next delegate in the pipeline.</param> + public QueryStringDecodingMiddleware(RequestDelegate next) + { + _next = next; + } + + /// <summary> + /// Executes the middleware action. + /// </summary> + /// <param name="httpContext">The current HTTP context.</param> + /// <returns>The async task.</returns> + public async Task Invoke(HttpContext httpContext) + { + httpContext.Features.Set<IQueryFeature>(new UrlDecodeQueryFeature(httpContext.Features.Get<IQueryFeature>())); + + await _next(httpContext).ConfigureAwait(false); + } + } +} diff --git a/Jellyfin.Server/Middleware/RobotsRedirectionMiddleware.cs b/Jellyfin.Server/Middleware/RobotsRedirectionMiddleware.cs new file mode 100644 index 0000000000..9d40d74fe9 --- /dev/null +++ b/Jellyfin.Server/Middleware/RobotsRedirectionMiddleware.cs @@ -0,0 +1,47 @@ +using System; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Server.Middleware +{ + /// <summary> + /// Redirect requests to robots.txt to web/robots.txt. + /// </summary> + public class RobotsRedirectionMiddleware + { + private readonly RequestDelegate _next; + private readonly ILogger<RobotsRedirectionMiddleware> _logger; + + /// <summary> + /// Initializes a new instance of the <see cref="RobotsRedirectionMiddleware"/> class. + /// </summary> + /// <param name="next">The next delegate in the pipeline.</param> + /// <param name="logger">The logger.</param> + public RobotsRedirectionMiddleware( + RequestDelegate next, + ILogger<RobotsRedirectionMiddleware> logger) + { + _next = next; + _logger = logger; + } + + /// <summary> + /// Executes the middleware action. + /// </summary> + /// <param name="httpContext">The current HTTP context.</param> + /// <returns>The async task.</returns> + public async Task Invoke(HttpContext httpContext) + { + var localPath = httpContext.Request.Path.ToString(); + if (string.Equals(localPath, "/robots.txt", StringComparison.OrdinalIgnoreCase)) + { + _logger.LogDebug("Redirecting robots.txt request to web/robots.txt"); + httpContext.Response.Redirect("web/robots.txt"); + return; + } + + await _next(httpContext).ConfigureAwait(false); + } + } +} \ No newline at end of file diff --git a/Jellyfin.Server/Middleware/UrlDecodeQueryFeature.cs b/Jellyfin.Server/Middleware/UrlDecodeQueryFeature.cs new file mode 100644 index 0000000000..c1f5b5dfaf --- /dev/null +++ b/Jellyfin.Server/Middleware/UrlDecodeQueryFeature.cs @@ -0,0 +1,91 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Web; +using Jellyfin.Extensions; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Features; +using Microsoft.Extensions.Primitives; + +namespace Jellyfin.Server.Middleware +{ + /// <summary> + /// Defines the <see cref="UrlDecodeQueryFeature"/>. + /// </summary> + public class UrlDecodeQueryFeature : IQueryFeature + { + private IQueryCollection? _store; + + /// <summary> + /// Initializes a new instance of the <see cref="UrlDecodeQueryFeature"/> class. + /// </summary> + /// <param name="feature">The <see cref="IQueryFeature"/> instance.</param> + public UrlDecodeQueryFeature(IQueryFeature feature) + { + Query = feature.Query; + } + + /// <summary> + /// Gets or sets a value indicating the url decoded <see cref="IQueryCollection"/>. + /// </summary> + public IQueryCollection Query + { + get + { + return _store ?? QueryCollection.Empty; + } + + set + { + // Only interested in where the querystring is encoded which shows up as one key with nothing in the value. + if (value.Count != 1) + { + _store = value; + return; + } + + // Encoded querystrings have no value, so don't process anything if a value is present. + var (key, stringValues) = value.First(); + if (!string.IsNullOrEmpty(stringValues)) + { + _store = value; + return; + } + + // Unencode and re-parse querystring. + var unencodedKey = HttpUtility.UrlDecode(key); + + if (string.Equals(unencodedKey, key, StringComparison.Ordinal)) + { + // Don't do anything if it's not encoded. + _store = value; + return; + } + + var pairs = new Dictionary<string, StringValues>(); + var queryString = unencodedKey.SpanSplit('&'); + + foreach (var pair in queryString) + { + var i = pair.IndexOf('='); + if (i == -1) + { + // encoded is an equals. + // We use TryAdd so duplicate keys get ignored + pairs.TryAdd(pair.ToString(), StringValues.Empty); + continue; + } + + var k = pair[..i].ToString(); + var v = pair[(i + 1)..].ToString(); + if (!pairs.TryAdd(k, new StringValues(v))) + { + pairs[k] = StringValues.Concat(pairs[k], v); + } + } + + _store = new QueryCollection(pairs); + } + } + } +} diff --git a/Jellyfin.Server/Migrations/MigrationOptions.cs b/Jellyfin.Server/Migrations/MigrationOptions.cs index 816dd9ee74..c9710f1fd1 100644 --- a/Jellyfin.Server/Migrations/MigrationOptions.cs +++ b/Jellyfin.Server/Migrations/MigrationOptions.cs @@ -16,9 +16,12 @@ namespace Jellyfin.Server.Migrations Applied = new List<(Guid Id, string Name)>(); } +// .Net xml serializer can't handle interfaces +#pragma warning disable CA1002 // Do not expose generic lists /// <summary> /// Gets the list of applied migration routine names. /// </summary> public List<(Guid Id, string Name)> Applied { get; } +#pragma warning restore CA1002 } } diff --git a/Jellyfin.Server/Migrations/MigrationRunner.cs b/Jellyfin.Server/Migrations/MigrationRunner.cs index 844dae61f6..0af5cfd619 100644 --- a/Jellyfin.Server/Migrations/MigrationRunner.cs +++ b/Jellyfin.Server/Migrations/MigrationRunner.cs @@ -23,7 +23,9 @@ namespace Jellyfin.Server.Migrations typeof(Routines.AddDefaultPluginRepository), typeof(Routines.MigrateUserDb), typeof(Routines.ReaddDefaultPluginRepository), - typeof(Routines.MigrateDisplayPreferencesDb) + typeof(Routines.MigrateDisplayPreferencesDb), + typeof(Routines.RemoveDownloadImagesInAdvance), + typeof(Routines.AddPeopleQueryIndex) }; /// <summary> @@ -38,15 +40,15 @@ namespace Jellyfin.Server.Migrations .Select(m => ActivatorUtilities.CreateInstance(host.ServiceProvider, m)) .OfType<IMigrationRoutine>() .ToArray(); - var migrationOptions = ((IConfigurationManager)host.ServerConfigurationManager).GetConfiguration<MigrationOptions>(MigrationsListStore.StoreKey); + var migrationOptions = host.ConfigurationManager.GetConfiguration<MigrationOptions>(MigrationsListStore.StoreKey); - if (!host.ServerConfigurationManager.Configuration.IsStartupWizardCompleted && migrationOptions.Applied.Count == 0) + if (!host.ConfigurationManager.Configuration.IsStartupWizardCompleted && migrationOptions.Applied.Count == 0) { // If startup wizard is not finished, this is a fresh install. // Don't run any migrations, just mark all of them as applied. logger.LogInformation("Marking all known migrations as applied because this is a fresh install"); migrationOptions.Applied.AddRange(migrations.Where(m => !m.PerformOnNewInstall).Select(m => (m.Id, m.Name))); - host.ServerConfigurationManager.SaveConfiguration(MigrationsListStore.StoreKey, migrationOptions); + host.ConfigurationManager.SaveConfiguration(MigrationsListStore.StoreKey, migrationOptions); } var appliedMigrationIds = migrationOptions.Applied.Select(m => m.Id).ToHashSet(); @@ -75,7 +77,7 @@ namespace Jellyfin.Server.Migrations // Mark the migration as completed logger.LogInformation("Migration '{Name}' applied successfully", migrationRoutine.Name); migrationOptions.Applied.Add((migrationRoutine.Id, migrationRoutine.Name)); - host.ServerConfigurationManager.SaveConfiguration(MigrationsListStore.StoreKey, migrationOptions); + host.ConfigurationManager.SaveConfiguration(MigrationsListStore.StoreKey, migrationOptions); logger.LogDebug("Migration '{Name}' marked as applied in configuration.", migrationRoutine.Name); } } diff --git a/Jellyfin.Server/Migrations/Routines/AddPeopleQueryIndex.cs b/Jellyfin.Server/Migrations/Routines/AddPeopleQueryIndex.cs new file mode 100644 index 0000000000..6343c422d5 --- /dev/null +++ b/Jellyfin.Server/Migrations/Routines/AddPeopleQueryIndex.cs @@ -0,0 +1,49 @@ +using System; +using System.IO; +using MediaBrowser.Controller; +using Microsoft.Extensions.Logging; +using SQLitePCL.pretty; + +namespace Jellyfin.Server.Migrations.Routines +{ + /// <summary> + /// Migration to add table indexes to optimize the Persons query. + /// </summary> + public class AddPeopleQueryIndex : IMigrationRoutine + { + private const string DbFilename = "library.db"; + private readonly ILogger<AddPeopleQueryIndex> _logger; + private readonly IServerApplicationPaths _serverApplicationPaths; + + /// <summary> + /// Initializes a new instance of the <see cref="AddPeopleQueryIndex"/> class. + /// </summary> + /// <param name="logger">Instance of the <see cref="ILogger{AddPeopleQueryIndex}"/> interface.</param> + /// <param name="serverApplicationPaths">Instance of the <see cref="IServerApplicationPaths"/> interface.</param> + public AddPeopleQueryIndex(ILogger<AddPeopleQueryIndex> logger, IServerApplicationPaths serverApplicationPaths) + { + _logger = logger; + _serverApplicationPaths = serverApplicationPaths; + } + + /// <inheritdoc /> + public Guid Id => new Guid("DE009B59-BAAE-428D-A810-F67762DC05B8"); + + /// <inheritdoc /> + public string Name => "AddPeopleQueryIndex"; + + /// <inheritdoc /> + public bool PerformOnNewInstall => true; + + /// <inheritdoc /> + public void Perform() + { + var databasePath = Path.Join(_serverApplicationPaths.DataPath, DbFilename); + using var connection = SQLite3.Open(databasePath, ConnectionFlags.ReadWrite, null); + _logger.LogInformation("Creating index idx_TypedBaseItemsUserDataKeyType"); + connection.Execute("CREATE INDEX IF NOT EXISTS idx_TypedBaseItemsUserDataKeyType ON TypedBaseItems(UserDataKey, Type);"); + _logger.LogInformation("Creating index idx_PeopleNameListOrder"); + connection.Execute("CREATE INDEX IF NOT EXISTS idx_PeopleNameListOrder ON People(Name, ListOrder);"); + } + } +} diff --git a/Jellyfin.Server/Migrations/Routines/CreateUserLoggingConfigFile.cs b/Jellyfin.Server/Migrations/Routines/CreateUserLoggingConfigFile.cs index 6821630db7..ee4f8b0bab 100644 --- a/Jellyfin.Server/Migrations/Routines/CreateUserLoggingConfigFile.cs +++ b/Jellyfin.Server/Migrations/Routines/CreateUserLoggingConfigFile.cs @@ -75,7 +75,7 @@ namespace Jellyfin.Server.Migrations.Routines { var existingConfigJson = JToken.Parse(File.ReadAllText(oldConfigPath)); return _defaultConfigHistory - .Select(historicalConfigText => JToken.Parse(historicalConfigText)) + .Select(JToken.Parse) .Any(historicalConfigJson => JToken.DeepEquals(existingConfigJson, historicalConfigJson)); } } diff --git a/Jellyfin.Server/Migrations/Routines/DisableTranscodingThrottling.cs b/Jellyfin.Server/Migrations/Routines/DisableTranscodingThrottling.cs index 0925a87b55..378e88e25b 100644 --- a/Jellyfin.Server/Migrations/Routines/DisableTranscodingThrottling.cs +++ b/Jellyfin.Server/Migrations/Routines/DisableTranscodingThrottling.cs @@ -1,6 +1,5 @@ using System; using MediaBrowser.Common.Configuration; -using MediaBrowser.Model.Configuration; using Microsoft.Extensions.Logging; namespace Jellyfin.Server.Migrations.Routines @@ -32,7 +31,7 @@ namespace Jellyfin.Server.Migrations.Routines public void Perform() { // Set EnableThrottling to false since it wasn't used before and may introduce issues - var encoding = _configManager.GetConfiguration<EncodingOptions>("encoding"); + var encoding = _configManager.GetEncodingOptions(); if (encoding.EnableThrottling) { _logger.LogInformation("Disabling transcoding throttling during migration"); diff --git a/Jellyfin.Server/Migrations/Routines/MigrateActivityLogDb.cs b/Jellyfin.Server/Migrations/Routines/MigrateActivityLogDb.cs index 6048160c63..9e22978aee 100644 --- a/Jellyfin.Server/Migrations/Routines/MigrateActivityLogDb.cs +++ b/Jellyfin.Server/Migrations/Routines/MigrateActivityLogDb.cs @@ -92,7 +92,7 @@ namespace Jellyfin.Server.Migrations.Routines if (entry[6].SQLiteType != SQLiteType.Null && !Guid.TryParse(entry[6].ToString(), out guid)) { // This is not a valid Guid, see if it is an internal ID from an old Emby schema - _logger.LogWarning("Invalid Guid in UserId column: ", entry[6].ToString()); + _logger.LogWarning("Invalid Guid in UserId column: {Guid}", entry[6].ToString()); using var statement = userDbConnection.PrepareStatement("SELECT guid FROM LocalUsersv2 WHERE Id=@Id"); statement.TryBind("@Id", entry[6].ToString()); diff --git a/Jellyfin.Server/Migrations/Routines/MigrateDisplayPreferencesDb.cs b/Jellyfin.Server/Migrations/Routines/MigrateDisplayPreferencesDb.cs index 7f57358ec0..6ff59626de 100644 --- a/Jellyfin.Server/Migrations/Routines/MigrateDisplayPreferencesDb.cs +++ b/Jellyfin.Server/Migrations/Routines/MigrateDisplayPreferencesDb.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Generic; -using System.Globalization; using System.IO; using System.Linq; using System.Text.Json; @@ -9,6 +8,7 @@ using Jellyfin.Data.Entities; using Jellyfin.Data.Enums; using Jellyfin.Server.Implementations; using MediaBrowser.Controller; +using MediaBrowser.Controller.Library; using MediaBrowser.Model.Entities; using Microsoft.Extensions.Logging; using SQLitePCL.pretty; @@ -26,6 +26,7 @@ namespace Jellyfin.Server.Migrations.Routines private readonly IServerApplicationPaths _paths; private readonly JellyfinDbProvider _provider; private readonly JsonSerializerOptions _jsonOptions; + private readonly IUserManager _userManager; /// <summary> /// Initializes a new instance of the <see cref="MigrateDisplayPreferencesDb"/> class. @@ -33,11 +34,17 @@ namespace Jellyfin.Server.Migrations.Routines /// <param name="logger">The logger.</param> /// <param name="paths">The server application paths.</param> /// <param name="provider">The database provider.</param> - public MigrateDisplayPreferencesDb(ILogger<MigrateDisplayPreferencesDb> logger, IServerApplicationPaths paths, JellyfinDbProvider provider) + /// <param name="userManager">The user manager.</param> + public MigrateDisplayPreferencesDb( + ILogger<MigrateDisplayPreferencesDb> logger, + IServerApplicationPaths paths, + JellyfinDbProvider provider, + IUserManager userManager) { _logger = logger; _paths = paths; _provider = provider; + _userManager = userManager; _jsonOptions = new JsonSerializerOptions(); _jsonOptions.Converters.Add(new JsonStringEnumConverter()); } @@ -72,6 +79,8 @@ namespace Jellyfin.Server.Migrations.Routines { "unstable", ChromecastVersion.Unstable } }; + var displayPrefs = new HashSet<string>(StringComparer.OrdinalIgnoreCase); + var customDisplayPrefs = new HashSet<string>(StringComparer.OrdinalIgnoreCase); var dbFilePath = Path.Combine(_paths.DataPath, DbFilename); using (var connection = SQLite3.Open(dbFilePath, ConnectionFlags.ReadOnly, null)) { @@ -80,45 +89,73 @@ namespace Jellyfin.Server.Migrations.Routines var results = connection.Query("SELECT * FROM userdisplaypreferences"); foreach (var result in results) { - var dto = JsonSerializer.Deserialize<DisplayPreferencesDto>(result[3].ToString(), _jsonOptions); + var dto = JsonSerializer.Deserialize<DisplayPreferencesDto>(result[3].ToBlob(), _jsonOptions); if (dto == null) { continue; } + var itemId = new Guid(result[1].ToBlob()); + var dtoUserId = new Guid(result[1].ToBlob()); + var client = result[2].ToString(); + var displayPreferencesKey = $"{dtoUserId}|{itemId}|{client}"; + if (displayPrefs.Contains(displayPreferencesKey)) + { + // Duplicate display preference. + continue; + } + + displayPrefs.Add(displayPreferencesKey); + var existingUser = _userManager.GetUserById(dtoUserId); + if (existingUser == null) + { + _logger.LogWarning("User with ID {UserId} does not exist in the database, skipping migration.", dtoUserId); + continue; + } + var chromecastVersion = dto.CustomPrefs.TryGetValue("chromecastVersion", out var version) ? chromecastDict[version] : ChromecastVersion.Stable; + dto.CustomPrefs.Remove("chromecastVersion"); - var displayPreferences = new DisplayPreferences(new Guid(result[1].ToBlob()), result[2].ToString()) + var displayPreferences = new DisplayPreferences(dtoUserId, itemId, client) { IndexBy = Enum.TryParse<IndexingKind>(dto.IndexBy, true, out var indexBy) ? indexBy : (IndexingKind?)null, ShowBackdrop = dto.ShowBackdrop, ShowSidebar = dto.ShowSidebar, ScrollDirection = dto.ScrollDirection, ChromecastVersion = chromecastVersion, - SkipForwardLength = dto.CustomPrefs.TryGetValue("skipForwardLength", out var length) - ? int.Parse(length, CultureInfo.InvariantCulture) + SkipForwardLength = dto.CustomPrefs.TryGetValue("skipForwardLength", out var length) && int.TryParse(length, out var skipForwardLength) + ? skipForwardLength : 30000, - SkipBackwardLength = dto.CustomPrefs.TryGetValue("skipBackLength", out length) - ? int.Parse(length, CultureInfo.InvariantCulture) + SkipBackwardLength = dto.CustomPrefs.TryGetValue("skipBackLength", out length) && !string.IsNullOrEmpty(length) && int.TryParse(length, out var skipBackwardLength) + ? skipBackwardLength : 10000, - EnableNextVideoInfoOverlay = dto.CustomPrefs.TryGetValue("enableNextVideoInfoOverlay", out var enabled) + EnableNextVideoInfoOverlay = dto.CustomPrefs.TryGetValue("enableNextVideoInfoOverlay", out var enabled) && !string.IsNullOrEmpty(enabled) ? bool.Parse(enabled) : true, DashboardTheme = dto.CustomPrefs.TryGetValue("dashboardtheme", out var theme) ? theme : string.Empty, TvHome = dto.CustomPrefs.TryGetValue("tvhome", out var home) ? home : string.Empty }; + dto.CustomPrefs.Remove("skipForwardLength"); + dto.CustomPrefs.Remove("skipBackLength"); + dto.CustomPrefs.Remove("enableNextVideoInfoOverlay"); + dto.CustomPrefs.Remove("dashboardtheme"); + dto.CustomPrefs.Remove("tvhome"); + for (int i = 0; i < 7; i++) { - dto.CustomPrefs.TryGetValue("homesection" + i, out var homeSection); + var key = "homesection" + i; + dto.CustomPrefs.TryGetValue(key, out var homeSection); displayPreferences.HomeSections.Add(new HomeSection { Order = i, Type = Enum.TryParse<HomeSectionType>(homeSection, true, out var type) ? type : defaults[i] }); + + dto.CustomPrefs.Remove(key); } var defaultLibraryPrefs = new ItemDisplayPreferences(displayPreferences.UserId, Guid.Empty, displayPreferences.Client) @@ -133,12 +170,12 @@ namespace Jellyfin.Server.Migrations.Routines foreach (var key in dto.CustomPrefs.Keys.Where(key => key.StartsWith("landing-", StringComparison.Ordinal))) { - if (!Guid.TryParse(key.AsSpan().Slice("landing-".Length), out var itemId)) + if (!Guid.TryParse(key.AsSpan().Slice("landing-".Length), out var landingItemId)) { continue; } - var libraryDisplayPreferences = new ItemDisplayPreferences(displayPreferences.UserId, itemId, displayPreferences.Client) + var libraryDisplayPreferences = new ItemDisplayPreferences(displayPreferences.UserId, landingItemId, displayPreferences.Client) { SortBy = dto.SortBy ?? "SortName", SortOrder = dto.SortOrder, @@ -151,9 +188,21 @@ namespace Jellyfin.Server.Migrations.Routines libraryDisplayPreferences.ViewType = viewType; } + dto.CustomPrefs.Remove(key); dbContext.ItemDisplayPreferences.Add(libraryDisplayPreferences); } + foreach (var (key, value) in dto.CustomPrefs) + { + // Custom display preferences can have a key collision. + var indexKey = $"{displayPreferences.UserId}|{itemId}|{displayPreferences.Client}|{key}"; + if (!customDisplayPrefs.Contains(indexKey)) + { + dbContext.Add(new CustomItemDisplayPreferences(displayPreferences.UserId, itemId, displayPreferences.Client, key, value)); + customDisplayPrefs.Add(indexKey); + } + } + dbContext.Add(displayPreferences); } diff --git a/Jellyfin.Server/Migrations/Routines/MigrateUserDb.cs b/Jellyfin.Server/Migrations/Routines/MigrateUserDb.cs index 74c5503314..d9524645a1 100644 --- a/Jellyfin.Server/Migrations/Routines/MigrateUserDb.cs +++ b/Jellyfin.Server/Migrations/Routines/MigrateUserDb.cs @@ -1,14 +1,12 @@ using System; -using System.Globalization; using System.IO; -using System.Linq; using Emby.Server.Implementations.Data; using Emby.Server.Implementations.Serialization; using Jellyfin.Data.Entities; using Jellyfin.Data.Enums; +using Jellyfin.Extensions.Json; using Jellyfin.Server.Implementations; using Jellyfin.Server.Implementations.Users; -using MediaBrowser.Common.Json; using MediaBrowser.Controller; using MediaBrowser.Controller.Entities; using MediaBrowser.Model.Configuration; @@ -76,7 +74,7 @@ namespace Jellyfin.Server.Migrations.Routines foreach (var entry in queryResult) { - UserMockup? mockup = JsonSerializer.Deserialize<UserMockup>(entry[2].ToBlob(), JsonDefaults.GetOptions()); + UserMockup? mockup = JsonSerializer.Deserialize<UserMockup>(entry[2].ToBlob(), JsonDefaults.Options); if (mockup == null) { continue; @@ -84,11 +82,14 @@ namespace Jellyfin.Server.Migrations.Routines var userDataDir = Path.Combine(_paths.UserConfigurationDirectoryPath, mockup.Name); - var config = File.Exists(Path.Combine(userDataDir, "config.xml")) - ? (UserConfiguration)_xmlSerializer.DeserializeFromFile(typeof(UserConfiguration), Path.Combine(userDataDir, "config.xml")) + var configPath = Path.Combine(userDataDir, "config.xml"); + var config = File.Exists(configPath) + ? (UserConfiguration?)_xmlSerializer.DeserializeFromFile(typeof(UserConfiguration), configPath) ?? new UserConfiguration() : new UserConfiguration(); - var policy = File.Exists(Path.Combine(userDataDir, "policy.xml")) - ? (UserPolicy)_xmlSerializer.DeserializeFromFile(typeof(UserPolicy), Path.Combine(userDataDir, "policy.xml")) + + var policyPath = Path.Combine(userDataDir, "policy.xml"); + var policy = File.Exists(policyPath) + ? (UserPolicy?)_xmlSerializer.DeserializeFromFile(typeof(UserPolicy), policyPath) ?? new UserPolicy() : new UserPolicy(); policy.AuthenticationProviderId = policy.AuthenticationProviderId?.Replace( "Emby.Server.Implementations.Library", @@ -104,7 +105,7 @@ namespace Jellyfin.Server.Migrations.Routines _ => policy.LoginAttemptsBeforeLockout }; - var user = new User(mockup.Name, policy.AuthenticationProviderId, policy.PasswordResetProviderId) + var user = new User(mockup.Name, policy.AuthenticationProviderId!, policy.PasswordResetProviderId!) { Id = entry[1].ReadGuidFromBlob(), InternalId = entry[0].ToInt64(), @@ -168,9 +169,9 @@ namespace Jellyfin.Server.Migrations.Routines } user.SetPreference(PreferenceKind.BlockedTags, policy.BlockedTags); - user.SetPreference(PreferenceKind.EnabledChannels, policy.EnabledChannels?.Select(i => i.ToString("N", CultureInfo.InvariantCulture)).ToArray()); + user.SetPreference(PreferenceKind.EnabledChannels, policy.EnabledChannels); user.SetPreference(PreferenceKind.EnabledDevices, policy.EnabledDevices); - user.SetPreference(PreferenceKind.EnabledFolders, policy.EnabledFolders?.Select(i => i.ToString("N", CultureInfo.InvariantCulture)).ToArray()); + user.SetPreference(PreferenceKind.EnabledFolders, policy.EnabledFolders); user.SetPreference(PreferenceKind.EnableContentDeletionFromFolders, policy.EnableContentDeletionFromFolders); user.SetPreference(PreferenceKind.OrderedViews, config.OrderedViews); user.SetPreference(PreferenceKind.GroupedFolders, config.GroupedFolders); diff --git a/Jellyfin.Server/Migrations/Routines/ReaddDefaultPluginRepository.cs b/Jellyfin.Server/Migrations/Routines/ReaddDefaultPluginRepository.cs index b281b5cc09..394f14d63c 100644 --- a/Jellyfin.Server/Migrations/Routines/ReaddDefaultPluginRepository.cs +++ b/Jellyfin.Server/Migrations/Routines/ReaddDefaultPluginRepository.cs @@ -1,4 +1,4 @@ -using System; +using System; using MediaBrowser.Controller.Configuration; using MediaBrowser.Model.Updates; @@ -46,4 +46,4 @@ namespace Jellyfin.Server.Migrations.Routines } } } -} \ No newline at end of file +} diff --git a/Jellyfin.Server/Migrations/Routines/RemoveDownloadImagesInAdvance.cs b/Jellyfin.Server/Migrations/Routines/RemoveDownloadImagesInAdvance.cs new file mode 100644 index 0000000000..9137ea234e --- /dev/null +++ b/Jellyfin.Server/Migrations/Routines/RemoveDownloadImagesInAdvance.cs @@ -0,0 +1,52 @@ +using System; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Library; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Server.Migrations.Routines +{ + /// <summary> + /// Removes the old 'RemoveDownloadImagesInAdvance' from library options. + /// </summary> + internal class RemoveDownloadImagesInAdvance : IMigrationRoutine + { + private readonly ILogger<RemoveDownloadImagesInAdvance> _logger; + private readonly ILibraryManager _libraryManager; + + public RemoveDownloadImagesInAdvance(ILogger<RemoveDownloadImagesInAdvance> logger, ILibraryManager libraryManager) + { + _logger = logger; + _libraryManager = libraryManager; + } + + /// <inheritdoc/> + public Guid Id => Guid.Parse("{A81F75E0-8F43-416F-A5E8-516CCAB4D8CC}"); + + /// <inheritdoc/> + public string Name => "RemoveDownloadImagesInAdvance"; + + /// <inheritdoc/> + public bool PerformOnNewInstall => false; + + /// <inheritdoc/> + public void Perform() + { + var virtualFolders = _libraryManager.GetVirtualFolders(false); + _logger.LogInformation("Removing 'RemoveDownloadImagesInAdvance' settings in all the libraries"); + foreach (var virtualFolder in virtualFolders) + { + // Some virtual folders don't have a proper item id. + if (!Guid.TryParse(virtualFolder.ItemId, out var folderId)) + { + continue; + } + + var libraryOptions = virtualFolder.LibraryOptions; + var collectionFolder = (CollectionFolder)_libraryManager.GetItemById(folderId); + // The property no longer exists in LibraryOptions, so we just re-save the options to get old data removed. + collectionFolder.UpdateLibraryOptions(libraryOptions); + _logger.LogInformation("Removed from '{VirtualFolder}'", virtualFolder.Name); + } + } + } +} diff --git a/Jellyfin.Server/Program.cs b/Jellyfin.Server/Program.cs index 4b04281bc7..37010d22fe 100644 --- a/Jellyfin.Server/Program.cs +++ b/Jellyfin.Server/Program.cs @@ -5,20 +5,19 @@ using System.IO; using System.Linq; using System.Net; using System.Reflection; -using System.Runtime.InteropServices; using System.Text; using System.Threading; using System.Threading.Tasks; using CommandLine; using Emby.Server.Implementations; using Emby.Server.Implementations.IO; -using Emby.Server.Implementations.Networking; -using Jellyfin.Api.Controllers; +using Jellyfin.Server.Implementations; using MediaBrowser.Common.Configuration; +using MediaBrowser.Common.Net; using MediaBrowser.Controller.Extensions; using MediaBrowser.Model.Configuration; using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.Server.Kestrel.Core; +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; @@ -107,6 +106,10 @@ namespace Jellyfin.Server // $JELLYFIN_LOG_DIR needs to be set for the logger configuration manager Environment.SetEnvironmentVariable("JELLYFIN_LOG_DIR", appPaths.LogDirectoryPath); + // Enable cl-va P010 interop for tonemapping on Intel VAAPI + Environment.SetEnvironmentVariable("NEOReadDebugKeys", "1"); + Environment.SetEnvironmentVariable("EnableExtendedVaFormats", "1"); + await InitLoggingConfigFile(appPaths).ConfigureAwait(false); // Create an instance of the application configuration to use for application startup @@ -118,11 +121,11 @@ namespace Jellyfin.Server // Log uncaught exceptions to the logging instead of std error AppDomain.CurrentDomain.UnhandledException -= UnhandledExceptionToConsole; - AppDomain.CurrentDomain.UnhandledException += (sender, e) + AppDomain.CurrentDomain.UnhandledException += (_, e) => _logger.LogCritical((Exception)e.ExceptionObject, "Unhandled Exception"); // Intercept Ctrl+C and Ctrl+Break - Console.CancelKeyPress += (sender, e) => + Console.CancelKeyPress += (_, e) => { if (_tokenSource.IsCancellationRequested) { @@ -136,7 +139,7 @@ namespace Jellyfin.Server }; // Register a SIGTERM handler - AppDomain.CurrentDomain.ProcessExit += (sender, e) => + AppDomain.CurrentDomain.ProcessExit += (_, _) => { if (_tokenSource.IsCancellationRequested) { @@ -161,8 +164,8 @@ namespace Jellyfin.Server appPaths, _loggerFactory, options, + startupConfig, new ManagedFileSystem(_loggerFactory.CreateLogger<ManagedFileSystem>(), appPaths), - new NetworkManager(_loggerFactory.CreateLogger<NetworkManager>()), serviceCollection); try @@ -170,14 +173,14 @@ namespace Jellyfin.Server // If hosting the web client, validate the client content path if (startupConfig.HostWebClient()) { - string? webContentPath = appHost.ServerConfigurationManager.ApplicationPaths.WebPath; + string? webContentPath = appHost.ConfigurationManager.ApplicationPaths.WebPath; if (!Directory.Exists(webContentPath) || Directory.GetFiles(webContentPath).Length == 0) { throw new InvalidOperationException( "The server is expected to host the web client, but the provided content directory is either " + $"invalid or empty: {webContentPath}. If you do not want to host the web client with the " + "server, you may set the '--nowebclient' command line flag, or set" + - $"'{MediaBrowser.Controller.Extensions.ConfigurationExtensions.HostWebClientKey}=false' in your config settings."); + $"'{ConfigurationExtensions.HostWebClientKey}=false' in your config settings."); } } @@ -196,11 +199,11 @@ namespace Jellyfin.Server } catch { - _logger.LogError("Kestrel failed to start! This is most likely due to an invalid address or port bind - correct your bind configuration in system.xml and try again."); + _logger.LogError("Kestrel failed to start! This is most likely due to an invalid address or port bind - correct your bind configuration in network.xml and try again."); throw; } - await appHost.RunStartupTasksAsync().ConfigureAwait(false); + await appHost.RunStartupTasksAsync(_tokenSource.Token).ConfigureAwait(false); stopWatch.Stop(); @@ -219,7 +222,15 @@ namespace Jellyfin.Server } finally { - appHost?.Dispose(); + _logger.LogInformation("Running query planner optimizations in the database... This might take a while"); + // Run before disposing the application + using var context = new JellyfinDbProvider(appHost.ServiceProvider, appPaths).CreateContext(); + if (context.Database.IsSqlite()) + { + context.Database.ExecuteSqlRaw("PRAGMA optimize"); + } + + appHost.Dispose(); } if (_restartOnShutdown) @@ -273,81 +284,42 @@ namespace Jellyfin.Server return builder .UseKestrel((builderContext, options) => { - var addresses = appHost.ServerConfigurationManager - .Configuration - .LocalNetworkAddresses - .Select(x => appHost.NormalizeConfiguredLocalAddress(x)) - .Where(i => i != null) - .ToHashSet(); - if (addresses.Count > 0 && !addresses.Contains(IPAddress.Any)) - { - if (!addresses.Contains(IPAddress.Loopback)) - { - // we must listen on loopback for LiveTV to function regardless of the settings - addresses.Add(IPAddress.Loopback); - } + var addresses = appHost.NetManager.GetAllBindInterfaces(); - foreach (var address in addresses) - { - _logger.LogInformation("Kestrel listening on {IpAddress}", address); - options.Listen(address, appHost.HttpPort); - if (appHost.ListenWithHttps) - { - options.Listen(address, appHost.HttpsPort, listenOptions => - { - listenOptions.UseHttps(appHost.Certificate); - listenOptions.Protocols = HttpProtocols.Http1AndHttp2; - }); - } - else if (builderContext.HostingEnvironment.IsDevelopment()) - { - try - { - options.Listen(address, appHost.HttpsPort, listenOptions => - { - listenOptions.UseHttps(); - listenOptions.Protocols = HttpProtocols.Http1AndHttp2; - }); - } - catch (InvalidOperationException ex) - { - _logger.LogError(ex, "Failed to listen to HTTPS using the ASP.NET Core HTTPS development certificate. Please ensure it has been installed and set as trusted."); - } - } - } - } - else + bool flagged = false; + foreach (IPObject netAdd in addresses) { - _logger.LogInformation("Kestrel listening on all interfaces"); - options.ListenAnyIP(appHost.HttpPort); - + _logger.LogInformation("Kestrel listening on {Address}", netAdd.Address == IPAddress.IPv6Any ? "All Addresses" : netAdd); + options.Listen(netAdd.Address, appHost.HttpPort); if (appHost.ListenWithHttps) { - options.ListenAnyIP(appHost.HttpsPort, listenOptions => - { - listenOptions.UseHttps(appHost.Certificate); - listenOptions.Protocols = HttpProtocols.Http1AndHttp2; - }); + options.Listen( + netAdd.Address, + appHost.HttpsPort, + listenOptions => listenOptions.UseHttps(appHost.Certificate)); } else if (builderContext.HostingEnvironment.IsDevelopment()) { try { - options.ListenAnyIP(appHost.HttpsPort, listenOptions => - { - listenOptions.UseHttps(); - listenOptions.Protocols = HttpProtocols.Http1AndHttp2; - }); + options.Listen( + netAdd.Address, + appHost.HttpsPort, + listenOptions => listenOptions.UseHttps()); } - catch (InvalidOperationException ex) + catch (InvalidOperationException) { - _logger.LogError(ex, "Failed to listen to HTTPS using the ASP.NET Core HTTPS development certificate. Please ensure it has been installed and set as trusted."); + if (!flagged) + { + _logger.LogWarning("Failed to listen to HTTPS using the ASP.NET Core HTTPS development certificate. Please ensure it has been installed and set as trusted."); + flagged = true; + } } } } - // Bind to unix socket (only on macOS and Linux) - if (startupConfig.UseUnixSocket() && !RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + // Bind to unix socket (only on unix systems) + if (startupConfig.UseUnixSocket() && Environment.OSVersion.Platform == PlatformID.Unix) { var socketPath = startupConfig.GetUnixSocketPath(); if (string.IsNullOrEmpty(socketPath)) @@ -465,7 +437,7 @@ namespace Jellyfin.Server { if (options.DataDir != null || Directory.Exists(Path.Combine(dataDir, "config")) - || RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + || OperatingSystem.IsWindows()) { // Hang config folder off already set dataDir configDir = Path.Combine(dataDir, "config"); @@ -503,7 +475,7 @@ namespace Jellyfin.Server if (string.IsNullOrEmpty(cacheDir)) { - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + if (OperatingSystem.IsWindows()) { // Hang cache folder off already set dataDir cacheDir = Path.Combine(dataDir, "cache"); @@ -604,7 +576,7 @@ namespace Jellyfin.Server // Get a stream of the resource contents // NOTE: The .csproj name is used instead of the assembly name in the resource path const string ResourcePath = "Jellyfin.Server.Resources.Configuration.logging.json"; - await using Stream? resource = typeof(Program).Assembly.GetManifestResourceStream(ResourcePath) + await using Stream resource = typeof(Program).Assembly.GetManifestResourceStream(ResourcePath) ?? throw new InvalidOperationException($"Invalid resource path: '{ResourcePath}'"); // Copy the resource contents to the expected file path for the config file @@ -668,7 +640,8 @@ namespace Jellyfin.Server .WriteTo.Async(x => x.File( Path.Combine(appPaths.LogDirectoryPath, "log_.log"), rollingInterval: RollingInterval.Day, - outputTemplate: "[{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz}] [{Level:u3}] [{ThreadId}] {SourceContext}: {Message}{NewLine}{Exception}")) + outputTemplate: "[{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz}] [{Level:u3}] [{ThreadId}] {SourceContext}: {Message}{NewLine}{Exception}", + encoding: Encoding.UTF8)) .Enrich.FromLogContext() .Enrich.WithThreadId() .CreateLogger(); @@ -691,7 +664,7 @@ namespace Jellyfin.Server string commandLineArgsString; if (options.RestartArgs != null) { - commandLineArgsString = options.RestartArgs ?? string.Empty; + commandLineArgsString = options.RestartArgs; } else { diff --git a/Jellyfin.Server/Properties/AssemblyInfo.cs b/Jellyfin.Server/Properties/AssemblyInfo.cs index 5de1e653d9..fe2d5c5f97 100644 --- a/Jellyfin.Server/Properties/AssemblyInfo.cs +++ b/Jellyfin.Server/Properties/AssemblyInfo.cs @@ -1,5 +1,6 @@ using System.Reflection; using System.Resources; +using System.Runtime.CompilerServices; using System.Runtime.InteropServices; // General Information about an assembly is controlled through the following @@ -19,3 +20,5 @@ using System.Runtime.InteropServices; // to COM components. If you need to access a type in this assembly from // COM, set the ComVisible attribute to true on that type. [assembly: ComVisible(false)] + +[assembly: InternalsVisibleTo("Jellyfin.Server.Tests")] diff --git a/Jellyfin.Server/Startup.cs b/Jellyfin.Server/Startup.cs index 62ffe174cd..60cdc2f6fe 100644 --- a/Jellyfin.Server/Startup.cs +++ b/Jellyfin.Server/Startup.cs @@ -1,8 +1,10 @@ using System; -using System.ComponentModel; +using System.Net; +using System.Net.Http; using System.Net.Http.Headers; using System.Net.Mime; -using Jellyfin.Api.TypeConverters; +using System.Text; +using Jellyfin.Networking.Configuration; using Jellyfin.Server.Extensions; using Jellyfin.Server.Implementations; using Jellyfin.Server.Middleware; @@ -54,7 +56,7 @@ namespace Jellyfin.Server { options.HttpsPort = _serverApplicationHost.HttpsPort; }); - services.AddJellyfinApi(_serverApplicationHost.GetApiPluginAssemblies(), _serverConfigurationManager.Configuration.KnownProxies); + services.AddJellyfinApi(_serverApplicationHost.GetApiPluginAssemblies(), _serverConfigurationManager.GetNetworkConfiguration()); services.AddJellyfinApiSwagger(); @@ -66,19 +68,33 @@ namespace Jellyfin.Server var productHeader = new ProductInfoHeaderValue( _serverApplicationHost.Name.Replace(' ', '-'), _serverApplicationHost.ApplicationVersionString); + var acceptJsonHeader = new MediaTypeWithQualityHeaderValue(MediaTypeNames.Application.Json, 1.0); + var acceptXmlHeader = new MediaTypeWithQualityHeaderValue(MediaTypeNames.Application.Xml, 0.9); + var acceptAnyHeader = new MediaTypeWithQualityHeaderValue("*/*", 0.8); + Func<IServiceProvider, HttpMessageHandler> defaultHttpClientHandlerDelegate = (_) => new SocketsHttpHandler() + { + AutomaticDecompression = DecompressionMethods.All, + RequestHeaderEncodingSelector = (_, _) => Encoding.UTF8 + }; + services .AddHttpClient(NamedClient.Default, c => { c.DefaultRequestHeaders.UserAgent.Add(productHeader); + c.DefaultRequestHeaders.Accept.Add(acceptJsonHeader); + c.DefaultRequestHeaders.Accept.Add(acceptXmlHeader); + c.DefaultRequestHeaders.Accept.Add(acceptAnyHeader); }) - .ConfigurePrimaryHttpMessageHandler(x => new DefaultHttpClientHandler()); + .ConfigurePrimaryHttpMessageHandler(defaultHttpClientHandlerDelegate); services.AddHttpClient(NamedClient.MusicBrainz, c => { c.DefaultRequestHeaders.UserAgent.Add(productHeader); c.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue($"({_serverApplicationHost.ApplicationUserAgentAddress})")); + c.DefaultRequestHeaders.Accept.Add(acceptXmlHeader); + c.DefaultRequestHeaders.Accept.Add(acceptAnyHeader); }) - .ConfigurePrimaryHttpMessageHandler(x => new DefaultHttpClientHandler()); + .ConfigurePrimaryHttpMessageHandler(defaultHttpClientHandlerDelegate); services.AddHealthChecks() .AddDbContextCheck<JellyfinDb>(); @@ -98,7 +114,8 @@ namespace Jellyfin.Server app.UseBaseUrlRedirection(); // Wrap rest of configuration so everything only listens on BaseUrl. - app.Map(_serverConfigurationManager.Configuration.BaseUrl, mainApp => + var config = _serverConfigurationManager.GetNetworkConfiguration(); + app.Map(config.BaseUrl, mainApp => { if (env.IsDevelopment()) { @@ -116,29 +133,34 @@ namespace Jellyfin.Server mainApp.UseCors(); - if (_serverConfigurationManager.Configuration.RequireHttps - && _serverApplicationHost.ListenWithHttps) + if (config.RequireHttps && _serverApplicationHost.ListenWithHttps) { mainApp.UseHttpsRedirection(); } + // This must be injected before any path related middleware. + mainApp.UsePathTrim(); mainApp.UseStaticFiles(); if (appConfig.HostWebClient()) { var extensionProvider = new FileExtensionContentTypeProvider(); - // subtitles octopus requires .data files. + // subtitles octopus requires .data, .mem files. extensionProvider.Mappings.Add(".data", MediaTypeNames.Application.Octet); + extensionProvider.Mappings.Add(".mem", MediaTypeNames.Application.Octet); mainApp.UseStaticFiles(new StaticFileOptions { FileProvider = new PhysicalFileProvider(_serverConfigurationManager.ApplicationPaths.WebPath), RequestPath = "/web", ContentTypeProvider = extensionProvider }); + + mainApp.UseRobotsRedirection(); } mainApp.UseAuthentication(); mainApp.UseJellyfinApiSwagger(_serverConfigurationManager); + mainApp.UseQueryStringDecoding(); mainApp.UseRouting(); mainApp.UseAuthorization(); @@ -164,9 +186,6 @@ namespace Jellyfin.Server endpoints.MapHealthChecks("/health"); }); }); - - // Add type descriptor for legacy datetime parsing. - TypeDescriptor.AddAttributes(typeof(DateTime?), new TypeConverterAttribute(typeof(DateTimeTypeConverter))); } } } diff --git a/Jellyfin.Server/StartupOptions.cs b/Jellyfin.Server/StartupOptions.cs index b634340927..a1cecc8c63 100644 --- a/Jellyfin.Server/StartupOptions.cs +++ b/Jellyfin.Server/StartupOptions.cs @@ -1,10 +1,7 @@ -using System; using System.Collections.Generic; using CommandLine; using Emby.Server.Implementations; -using Emby.Server.Implementations.EntryPoints; using Emby.Server.Implementations.Udp; -using Emby.Server.Implementations.Updates; using MediaBrowser.Controller.Extensions; namespace Jellyfin.Server @@ -77,7 +74,7 @@ namespace Jellyfin.Server /// <inheritdoc /> [Option("published-server-url", Required = false, HelpText = "Jellyfin Server URL to publish via auto discover process")] - public Uri? PublishedServerUrl { get; set; } + public string? PublishedServerUrl { get; set; } /// <summary> /// Gets the command line options as a dictionary that can be used in the .NET configuration system. @@ -94,7 +91,7 @@ namespace Jellyfin.Server if (PublishedServerUrl != null) { - config.Add(UdpServer.AddressOverrideConfigKey, PublishedServerUrl.ToString()); + config.Add(UdpServer.AddressOverrideConfigKey, PublishedServerUrl); } if (FFmpegPath != null) diff --git a/MediaBrowser.sln b/Jellyfin.sln similarity index 70% rename from MediaBrowser.sln rename to Jellyfin.sln index 25402aee13..4626601c3d 100644 --- a/MediaBrowser.sln +++ b/Jellyfin.sln @@ -1,6 +1,7 @@ + Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio 15 -VisualStudioVersion = 15.0.26730.3 +# Visual Studio Version 16 +VisualStudioVersion = 16.0.30503.244 MinimumVisualStudioVersion = 10.0.40219.1 Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Jellyfin.Server", "Jellyfin.Server\Jellyfin.Server.csproj", "{07E39F42-A2C6-4B32-AF8C-725F957A73FF}" EndProject @@ -66,12 +67,38 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Jellyfin.Data", "Jellyfin.D EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Jellyfin.Server.Implementations", "Jellyfin.Server.Implementations\Jellyfin.Server.Implementations.csproj", "{DAE48069-6D86-4BA6-B148-D1D49B6DDA52}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Jellyfin.Networking", "Jellyfin.Networking\Jellyfin.Networking.csproj", "{0A3FCC4D-C714-4072-B90F-E374A15F9FF9}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Jellyfin.Dlna.Tests", "tests\Jellyfin.Dlna.Tests\Jellyfin.Dlna.Tests.csproj", "{B8AE4B9D-E8D3-4B03-A95E-7FD8CECECC50}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Jellyfin.XbmcMetadata.Tests", "tests\Jellyfin.XbmcMetadata.Tests\Jellyfin.XbmcMetadata.Tests.csproj", "{30922383-D513-4F4D-B890-A940B57FA353}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Jellyfin.Model.Tests", "tests\Jellyfin.Model.Tests\Jellyfin.Model.Tests.csproj", "{FC1BC0CE-E8D2-4AE9-A6AB-8A02143B335D}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Jellyfin.Networking.Tests", "tests\Jellyfin.Networking.Tests\Jellyfin.Networking.Tests.csproj", "{42816EA8-4511-4CBF-A9C7-7791D5DDDAE6}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.Server.Tests", "tests\Jellyfin.Server.Tests\Jellyfin.Server.Tests.csproj", "{3ADBCD8C-C0F2-4956-8FDC-35D686B74CF9}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.Server.Integration.Tests", "tests\Jellyfin.Server.Integration.Tests\Jellyfin.Server.Integration.Tests.csproj", "{68B0B823-A5AC-4E8B-82EA-965AAC7BF76E}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.Providers.Tests", "tests\Jellyfin.Providers.Tests\Jellyfin.Providers.Tests.csproj", "{A964008C-2136-4716-B6CB-B3426C22320A}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{C9F0AB5D-F4D7-40C8-A353-3305C86D6D4C}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.Extensions", "src\Jellyfin.Extensions\Jellyfin.Extensions.csproj", "{750B8757-BE3D-4F8C-941A-FBAD94904ADA}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.Extensions.Tests", "tests\Jellyfin.Extensions.Tests\Jellyfin.Extensions.Tests.csproj", "{332A5C7A-F907-47CA-910E-BE6F7371B9E0}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU Release|Any CPU = Release|Any CPU EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution + {07E39F42-A2C6-4B32-AF8C-725F957A73FF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {07E39F42-A2C6-4B32-AF8C-725F957A73FF}.Debug|Any CPU.Build.0 = Debug|Any CPU + {07E39F42-A2C6-4B32-AF8C-725F957A73FF}.Release|Any CPU.ActiveCfg = Release|Any CPU + {07E39F42-A2C6-4B32-AF8C-725F957A73FF}.Release|Any CPU.Build.0 = Release|Any CPU {17E1F4E6-8ABD-4FE5-9ECF-43D4B6087BA2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {17E1F4E6-8ABD-4FE5-9ECF-43D4B6087BA2}.Debug|Any CPU.Build.0 = Debug|Any CPU {17E1F4E6-8ABD-4FE5-9ECF-43D4B6087BA2}.Release|Any CPU.ActiveCfg = Release|Any CPU @@ -132,10 +159,6 @@ Global {960295EE-4AF4-4440-A525-B4C295B01A61}.Debug|Any CPU.Build.0 = Debug|Any CPU {960295EE-4AF4-4440-A525-B4C295B01A61}.Release|Any CPU.ActiveCfg = Release|Any CPU {960295EE-4AF4-4440-A525-B4C295B01A61}.Release|Any CPU.Build.0 = Release|Any CPU - {07E39F42-A2C6-4B32-AF8C-725F957A73FF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {07E39F42-A2C6-4B32-AF8C-725F957A73FF}.Debug|Any CPU.Build.0 = Debug|Any CPU - {07E39F42-A2C6-4B32-AF8C-725F957A73FF}.Release|Any CPU.ActiveCfg = Release|Any CPU - {07E39F42-A2C6-4B32-AF8C-725F957A73FF}.Release|Any CPU.Build.0 = Release|Any CPU {154872D9-6C12-4007-96E3-8F70A58386CE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {154872D9-6C12-4007-96E3-8F70A58386CE}.Debug|Any CPU.Build.0 = Debug|Any CPU {154872D9-6C12-4007-96E3-8F70A58386CE}.Release|Any CPU.ActiveCfg = Release|Any CPU @@ -176,10 +199,71 @@ Global {DAE48069-6D86-4BA6-B148-D1D49B6DDA52}.Debug|Any CPU.Build.0 = Debug|Any CPU {DAE48069-6D86-4BA6-B148-D1D49B6DDA52}.Release|Any CPU.ActiveCfg = Release|Any CPU {DAE48069-6D86-4BA6-B148-D1D49B6DDA52}.Release|Any CPU.Build.0 = Release|Any CPU + {0A3FCC4D-C714-4072-B90F-E374A15F9FF9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {0A3FCC4D-C714-4072-B90F-E374A15F9FF9}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0A3FCC4D-C714-4072-B90F-E374A15F9FF9}.Release|Any CPU.ActiveCfg = Release|Any CPU + {0A3FCC4D-C714-4072-B90F-E374A15F9FF9}.Release|Any CPU.Build.0 = Release|Any CPU + {B8AE4B9D-E8D3-4B03-A95E-7FD8CECECC50}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B8AE4B9D-E8D3-4B03-A95E-7FD8CECECC50}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B8AE4B9D-E8D3-4B03-A95E-7FD8CECECC50}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B8AE4B9D-E8D3-4B03-A95E-7FD8CECECC50}.Release|Any CPU.Build.0 = Release|Any CPU + {30922383-D513-4F4D-B890-A940B57FA353}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {30922383-D513-4F4D-B890-A940B57FA353}.Debug|Any CPU.Build.0 = Debug|Any CPU + {30922383-D513-4F4D-B890-A940B57FA353}.Release|Any CPU.ActiveCfg = Release|Any CPU + {30922383-D513-4F4D-B890-A940B57FA353}.Release|Any CPU.Build.0 = Release|Any CPU + {FC1BC0CE-E8D2-4AE9-A6AB-8A02143B335D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {FC1BC0CE-E8D2-4AE9-A6AB-8A02143B335D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {FC1BC0CE-E8D2-4AE9-A6AB-8A02143B335D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {FC1BC0CE-E8D2-4AE9-A6AB-8A02143B335D}.Release|Any CPU.Build.0 = Release|Any CPU + {42816EA8-4511-4CBF-A9C7-7791D5DDDAE6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {42816EA8-4511-4CBF-A9C7-7791D5DDDAE6}.Debug|Any CPU.Build.0 = Debug|Any CPU + {42816EA8-4511-4CBF-A9C7-7791D5DDDAE6}.Release|Any CPU.ActiveCfg = Release|Any CPU + {42816EA8-4511-4CBF-A9C7-7791D5DDDAE6}.Release|Any CPU.Build.0 = Release|Any CPU + {25E40B0B-7C89-4230-8911-CBBBCE83FC5B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {25E40B0B-7C89-4230-8911-CBBBCE83FC5B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {25E40B0B-7C89-4230-8911-CBBBCE83FC5B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {25E40B0B-7C89-4230-8911-CBBBCE83FC5B}.Release|Any CPU.Build.0 = Release|Any CPU + {3ADBCD8C-C0F2-4956-8FDC-35D686B74CF9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3ADBCD8C-C0F2-4956-8FDC-35D686B74CF9}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3ADBCD8C-C0F2-4956-8FDC-35D686B74CF9}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3ADBCD8C-C0F2-4956-8FDC-35D686B74CF9}.Release|Any CPU.Build.0 = Release|Any CPU + {68B0B823-A5AC-4E8B-82EA-965AAC7BF76E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {68B0B823-A5AC-4E8B-82EA-965AAC7BF76E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {68B0B823-A5AC-4E8B-82EA-965AAC7BF76E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {68B0B823-A5AC-4E8B-82EA-965AAC7BF76E}.Release|Any CPU.Build.0 = Release|Any CPU + {A964008C-2136-4716-B6CB-B3426C22320A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A964008C-2136-4716-B6CB-B3426C22320A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A964008C-2136-4716-B6CB-B3426C22320A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A964008C-2136-4716-B6CB-B3426C22320A}.Release|Any CPU.Build.0 = Release|Any CPU + {750B8757-BE3D-4F8C-941A-FBAD94904ADA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {750B8757-BE3D-4F8C-941A-FBAD94904ADA}.Debug|Any CPU.Build.0 = Debug|Any CPU + {750B8757-BE3D-4F8C-941A-FBAD94904ADA}.Release|Any CPU.ActiveCfg = Release|Any CPU + {750B8757-BE3D-4F8C-941A-FBAD94904ADA}.Release|Any CPU.Build.0 = Release|Any CPU + {332A5C7A-F907-47CA-910E-BE6F7371B9E0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {332A5C7A-F907-47CA-910E-BE6F7371B9E0}.Debug|Any CPU.Build.0 = Debug|Any CPU + {332A5C7A-F907-47CA-910E-BE6F7371B9E0}.Release|Any CPU.ActiveCfg = Release|Any CPU + {332A5C7A-F907-47CA-910E-BE6F7371B9E0}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {DF194677-DFD3-42AF-9F75-D44D5A416478} = {FBBB5129-006E-4AD7-BAD5-8B7CA1D10ED6} + {28464062-0939-4AA7-9F7B-24DDDA61A7C0} = {FBBB5129-006E-4AD7-BAD5-8B7CA1D10ED6} + {3998657B-1CCC-49DD-A19F-275DC8495F57} = {FBBB5129-006E-4AD7-BAD5-8B7CA1D10ED6} + {A2FD0A10-8F62-4F9D-B171-FFDF9F0AFA9D} = {FBBB5129-006E-4AD7-BAD5-8B7CA1D10ED6} + {2E3A1B4B-4225-4AAA-8B29-0181A84E7AEE} = {FBBB5129-006E-4AD7-BAD5-8B7CA1D10ED6} + {462584F7-5023-4019-9EAC-B98CA458C0A0} = {FBBB5129-006E-4AD7-BAD5-8B7CA1D10ED6} + {B8AE4B9D-E8D3-4B03-A95E-7FD8CECECC50} = {FBBB5129-006E-4AD7-BAD5-8B7CA1D10ED6} + {30922383-D513-4F4D-B890-A940B57FA353} = {FBBB5129-006E-4AD7-BAD5-8B7CA1D10ED6} + {FC1BC0CE-E8D2-4AE9-A6AB-8A02143B335D} = {FBBB5129-006E-4AD7-BAD5-8B7CA1D10ED6} + {42816EA8-4511-4CBF-A9C7-7791D5DDDAE6} = {FBBB5129-006E-4AD7-BAD5-8B7CA1D10ED6} + {3ADBCD8C-C0F2-4956-8FDC-35D686B74CF9} = {FBBB5129-006E-4AD7-BAD5-8B7CA1D10ED6} + {68B0B823-A5AC-4E8B-82EA-965AAC7BF76E} = {FBBB5129-006E-4AD7-BAD5-8B7CA1D10ED6} + {A964008C-2136-4716-B6CB-B3426C22320A} = {FBBB5129-006E-4AD7-BAD5-8B7CA1D10ED6} + {750B8757-BE3D-4F8C-941A-FBAD94904ADA} = {C9F0AB5D-F4D7-40C8-A353-3305C86D6D4C} + {332A5C7A-F907-47CA-910E-BE6F7371B9E0} = {FBBB5129-006E-4AD7-BAD5-8B7CA1D10ED6} + EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {3448830C-EBDC-426C-85CD-7BBB9651A7FE} EndGlobalSection @@ -201,12 +285,4 @@ Global $0.DotNetNamingPolicy = $2 $2.DirectoryNamespaceAssociation = PrefixedHierarchical EndGlobalSection - GlobalSection(NestedProjects) = preSolution - {DF194677-DFD3-42AF-9F75-D44D5A416478} = {FBBB5129-006E-4AD7-BAD5-8B7CA1D10ED6} - {28464062-0939-4AA7-9F7B-24DDDA61A7C0} = {FBBB5129-006E-4AD7-BAD5-8B7CA1D10ED6} - {3998657B-1CCC-49DD-A19F-275DC8495F57} = {FBBB5129-006E-4AD7-BAD5-8B7CA1D10ED6} - {A2FD0A10-8F62-4F9D-B171-FFDF9F0AFA9D} = {FBBB5129-006E-4AD7-BAD5-8B7CA1D10ED6} - {2E3A1B4B-4225-4AAA-8B29-0181A84E7AEE} = {FBBB5129-006E-4AD7-BAD5-8B7CA1D10ED6} - {462584F7-5023-4019-9EAC-B98CA458C0A0} = {FBBB5129-006E-4AD7-BAD5-8B7CA1D10ED6} - EndGlobalSection EndGlobal diff --git a/MediaBrowser.Common/Configuration/ConfigurationStore.cs b/MediaBrowser.Common/Configuration/ConfigurationStore.cs index d31d45e4c3..050ab1ab51 100644 --- a/MediaBrowser.Common/Configuration/ConfigurationStore.cs +++ b/MediaBrowser.Common/Configuration/ConfigurationStore.cs @@ -1,3 +1,5 @@ +#nullable disable + using System; namespace MediaBrowser.Common.Configuration diff --git a/MediaBrowser.Common/Configuration/ConfigurationUpdateEventArgs.cs b/MediaBrowser.Common/Configuration/ConfigurationUpdateEventArgs.cs index 344aecf530..2df87d8792 100644 --- a/MediaBrowser.Common/Configuration/ConfigurationUpdateEventArgs.cs +++ b/MediaBrowser.Common/Configuration/ConfigurationUpdateEventArgs.cs @@ -1,3 +1,4 @@ +#nullable disable #pragma warning disable CS1591 using System; diff --git a/MediaBrowser.Common/Configuration/IApplicationPaths.cs b/MediaBrowser.Common/Configuration/IApplicationPaths.cs index 57c6546675..1370e6d79e 100644 --- a/MediaBrowser.Common/Configuration/IApplicationPaths.cs +++ b/MediaBrowser.Common/Configuration/IApplicationPaths.cs @@ -1,3 +1,5 @@ +#nullable disable + namespace MediaBrowser.Common.Configuration { /// <summary> diff --git a/MediaBrowser.Common/Configuration/IConfigurationManager.cs b/MediaBrowser.Common/Configuration/IConfigurationManager.cs index fe726090d6..fc63d93503 100644 --- a/MediaBrowser.Common/Configuration/IConfigurationManager.cs +++ b/MediaBrowser.Common/Configuration/IConfigurationManager.cs @@ -46,6 +46,13 @@ namespace MediaBrowser.Common.Configuration /// <param name="newConfiguration">The new configuration.</param> void ReplaceConfiguration(BaseApplicationConfiguration newConfiguration); + /// <summary> + /// Manually pre-loads a factory so that it is available pre system initialisation. + /// </summary> + /// <typeparam name="T">Class to register.</typeparam> + void RegisterConfiguration<T>() + where T : IConfigurationFactory; + /// <summary> /// Gets the configuration. /// </summary> diff --git a/MediaBrowser.Common/Crc32.cs b/MediaBrowser.Common/Crc32.cs new file mode 100644 index 0000000000..599eb4c99a --- /dev/null +++ b/MediaBrowser.Common/Crc32.cs @@ -0,0 +1,89 @@ +#pragma warning disable CS1591 + +using System; + +namespace MediaBrowser.Common +{ + public static class Crc32 + { + private static readonly uint[] _crcTable = + { + 0x00000000, 0x77073096, 0xee0e612c, 0x990951ba, + 0x076dc419, 0x706af48f, 0xe963a535, 0x9e6495a3, + 0x0edb8832, 0x79dcb8a4, 0xe0d5e91e, 0x97d2d988, + 0x09b64c2b, 0x7eb17cbd, 0xe7b82d07, 0x90bf1d91, + 0x1db71064, 0x6ab020f2, 0xf3b97148, 0x84be41de, + 0x1adad47d, 0x6ddde4eb, 0xf4d4b551, 0x83d385c7, + 0x136c9856, 0x646ba8c0, 0xfd62f97a, 0x8a65c9ec, + 0x14015c4f, 0x63066cd9, 0xfa0f3d63, 0x8d080df5, + 0x3b6e20c8, 0x4c69105e, 0xd56041e4, 0xa2677172, + 0x3c03e4d1, 0x4b04d447, 0xd20d85fd, 0xa50ab56b, + 0x35b5a8fa, 0x42b2986c, 0xdbbbc9d6, 0xacbcf940, + 0x32d86ce3, 0x45df5c75, 0xdcd60dcf, 0xabd13d59, + 0x26d930ac, 0x51de003a, 0xc8d75180, 0xbfd06116, + 0x21b4f4b5, 0x56b3c423, 0xcfba9599, 0xb8bda50f, + 0x2802b89e, 0x5f058808, 0xc60cd9b2, 0xb10be924, + 0x2f6f7c87, 0x58684c11, 0xc1611dab, 0xb6662d3d, + 0x76dc4190, 0x01db7106, 0x98d220bc, 0xefd5102a, + 0x71b18589, 0x06b6b51f, 0x9fbfe4a5, 0xe8b8d433, + 0x7807c9a2, 0x0f00f934, 0x9609a88e, 0xe10e9818, + 0x7f6a0dbb, 0x086d3d2d, 0x91646c97, 0xe6635c01, + 0x6b6b51f4, 0x1c6c6162, 0x856530d8, 0xf262004e, + 0x6c0695ed, 0x1b01a57b, 0x8208f4c1, 0xf50fc457, + 0x65b0d9c6, 0x12b7e950, 0x8bbeb8ea, 0xfcb9887c, + 0x62dd1ddf, 0x15da2d49, 0x8cd37cf3, 0xfbd44c65, + 0x4db26158, 0x3ab551ce, 0xa3bc0074, 0xd4bb30e2, + 0x4adfa541, 0x3dd895d7, 0xa4d1c46d, 0xd3d6f4fb, + 0x4369e96a, 0x346ed9fc, 0xad678846, 0xda60b8d0, + 0x44042d73, 0x33031de5, 0xaa0a4c5f, 0xdd0d7cc9, + 0x5005713c, 0x270241aa, 0xbe0b1010, 0xc90c2086, + 0x5768b525, 0x206f85b3, 0xb966d409, 0xce61e49f, + 0x5edef90e, 0x29d9c998, 0xb0d09822, 0xc7d7a8b4, + 0x59b33d17, 0x2eb40d81, 0xb7bd5c3b, 0xc0ba6cad, + 0xedb88320, 0x9abfb3b6, 0x03b6e20c, 0x74b1d29a, + 0xead54739, 0x9dd277af, 0x04db2615, 0x73dc1683, + 0xe3630b12, 0x94643b84, 0x0d6d6a3e, 0x7a6a5aa8, + 0xe40ecf0b, 0x9309ff9d, 0x0a00ae27, 0x7d079eb1, + 0xf00f9344, 0x8708a3d2, 0x1e01f268, 0x6906c2fe, + 0xf762575d, 0x806567cb, 0x196c3671, 0x6e6b06e7, + 0xfed41b76, 0x89d32be0, 0x10da7a5a, 0x67dd4acc, + 0xf9b9df6f, 0x8ebeeff9, 0x17b7be43, 0x60b08ed5, + 0xd6d6a3e8, 0xa1d1937e, 0x38d8c2c4, 0x4fdff252, + 0xd1bb67f1, 0xa6bc5767, 0x3fb506dd, 0x48b2364b, + 0xd80d2bda, 0xaf0a1b4c, 0x36034af6, 0x41047a60, + 0xdf60efc3, 0xa867df55, 0x316e8eef, 0x4669be79, + 0xcb61b38c, 0xbc66831a, 0x256fd2a0, 0x5268e236, + 0xcc0c7795, 0xbb0b4703, 0x220216b9, 0x5505262f, + 0xc5ba3bbe, 0xb2bd0b28, 0x2bb45a92, 0x5cb36a04, + 0xc2d7ffa7, 0xb5d0cf31, 0x2cd99e8b, 0x5bdeae1d, + 0x9b64c2b0, 0xec63f226, 0x756aa39c, 0x026d930a, + 0x9c0906a9, 0xeb0e363f, 0x72076785, 0x05005713, + 0x95bf4a82, 0xe2b87a14, 0x7bb12bae, 0x0cb61b38, + 0x92d28e9b, 0xe5d5be0d, 0x7cdcefb7, 0x0bdbdf21, + 0x86d3d2d4, 0xf1d4e242, 0x68ddb3f8, 0x1fda836e, + 0x81be16cd, 0xf6b9265b, 0x6fb077e1, 0x18b74777, + 0x88085ae6, 0xff0f6a70, 0x66063bca, 0x11010b5c, + 0x8f659eff, 0xf862ae69, 0x616bffd3, 0x166ccf45, + 0xa00ae278, 0xd70dd2ee, 0x4e048354, 0x3903b3c2, + 0xa7672661, 0xd06016f7, 0x4969474d, 0x3e6e77db, + 0xaed16a4a, 0xd9d65adc, 0x40df0b66, 0x37d83bf0, + 0xa9bcae53, 0xdebb9ec5, 0x47b2cf7f, 0x30b5ffe9, + 0xbdbdf21c, 0xcabac28a, 0x53b39330, 0x24b4a3a6, + 0xbad03605, 0xcdd70693, 0x54de5729, 0x23d967bf, + 0xb3667a2e, 0xc4614ab8, 0x5d681b02, 0x2a6f2b94, + 0xb40bbe37, 0xc30c8ea1, 0x5a05df1b, 0x2d02ef8d + }; + + public static uint Compute(ReadOnlySpan<byte> bytes) + { + var crc = 0xffffffff; + var len = bytes.Length; + for (var i = 0; i < len; i++) + { + crc = (crc >> 8) ^ _crcTable[(bytes[i] ^ crc) & 0xff]; + } + + return ~crc; + } + } +} diff --git a/MediaBrowser.Common/Cryptography/PasswordHash.cs b/MediaBrowser.Common/Cryptography/PasswordHash.cs index 3e12536ec7..0e20653029 100644 --- a/MediaBrowser.Common/Cryptography/PasswordHash.cs +++ b/MediaBrowser.Common/Cryptography/PasswordHash.cs @@ -2,7 +2,6 @@ using System; using System.Collections.Generic; -using System.IO; using System.Text; namespace MediaBrowser.Common.Cryptography @@ -30,6 +29,16 @@ namespace MediaBrowser.Common.Cryptography public PasswordHash(string id, byte[] hash, byte[] salt, Dictionary<string, string> parameters) { + if (id == null) + { + throw new ArgumentNullException(nameof(id)); + } + + if (id.Length == 0) + { + throw new ArgumentException("String can't be empty", nameof(id)); + } + Id = id; _hash = hash; _salt = salt; @@ -59,58 +68,109 @@ namespace MediaBrowser.Common.Cryptography /// <value>Return the hashed password.</value> public ReadOnlySpan<byte> Hash => _hash; - public static PasswordHash Parse(string hashString) + public static PasswordHash Parse(ReadOnlySpan<char> hashString) { - // The string should at least contain the hash function and the hash itself - string[] splitted = hashString.Split('$'); - if (splitted.Length < 3) + if (hashString.IsEmpty) + { + throw new ArgumentException("String can't be empty", nameof(hashString)); + } + + if (hashString[0] != '$') { - throw new ArgumentException("String doesn't contain enough segments", nameof(hashString)); + throw new FormatException("Hash string must start with a $"); } - // Start at 1, the first index shouldn't contain any data - int index = 1; + // Ignore first $ + hashString = hashString[1..]; - // Name of the hash function - string id = splitted[index++]; + int nextSegment = hashString.IndexOf('$'); + if (hashString.IsEmpty || nextSegment == 0) + { + throw new FormatException("Hash string must contain a valid id"); + } + else if (nextSegment == -1) + { + return new PasswordHash(hashString.ToString(), Array.Empty<byte>()); + } + + ReadOnlySpan<char> id = hashString[..nextSegment]; + hashString = hashString[(nextSegment + 1)..]; + Dictionary<string, string>? parameters = null; + + nextSegment = hashString.IndexOf('$'); // Optional parameters - Dictionary<string, string> parameters = new Dictionary<string, string>(); - if (splitted[index].IndexOf('=', StringComparison.Ordinal) != -1) + ReadOnlySpan<char> parametersSpan = nextSegment == -1 ? hashString : hashString[..nextSegment]; + if (parametersSpan.Contains('=')) { - foreach (string paramset in splitted[index++].Split(',')) + while (!parametersSpan.IsEmpty) { - if (string.IsNullOrEmpty(paramset)) + ReadOnlySpan<char> parameter; + int index = parametersSpan.IndexOf(','); + if (index == -1) + { + parameter = parametersSpan; + parametersSpan = ReadOnlySpan<char>.Empty; + } + else { - continue; + parameter = parametersSpan[..index]; + parametersSpan = parametersSpan[(index + 1)..]; } - string[] fields = paramset.Split('='); - if (fields.Length != 2) + int splitIndex = parameter.IndexOf('='); + if (splitIndex == -1 || splitIndex == 0 || splitIndex == parameter.Length - 1) { - throw new InvalidDataException($"Malformed parameter in password hash string {paramset}"); + throw new FormatException("Malformed parameter in password hash string"); } - parameters.Add(fields[0], fields[1]); + (parameters ??= new Dictionary<string, string>()).Add( + parameter[..splitIndex].ToString(), + parameter[(splitIndex + 1)..].ToString()); + } + + if (nextSegment == -1) + { + // parameters can't be null here + return new PasswordHash(id.ToString(), Array.Empty<byte>(), Array.Empty<byte>(), parameters!); } + + hashString = hashString[(nextSegment + 1)..]; + nextSegment = hashString.IndexOf('$'); + } + + if (nextSegment == 0) + { + throw new FormatException("Hash string contains an empty segment"); } byte[] hash; byte[] salt; - // Check if the string also contains a salt - if (splitted.Length - index == 2) + if (nextSegment == -1) { - salt = Hex.Decode(splitted[index++]); - hash = Hex.Decode(splitted[index++]); + salt = Array.Empty<byte>(); + hash = Convert.FromHexString(hashString); } else { - salt = Array.Empty<byte>(); - hash = Hex.Decode(splitted[index++]); + salt = Convert.FromHexString(hashString[..nextSegment]); + hashString = hashString[(nextSegment + 1)..]; + nextSegment = hashString.IndexOf('$'); + if (nextSegment != -1) + { + throw new FormatException("Hash string contains too many segments"); + } + + if (hashString.IsEmpty) + { + throw new FormatException("Hash segment is empty"); + } + + hash = Convert.FromHexString(hashString); } - return new PasswordHash(id, hash, salt, parameters); + return new PasswordHash(id.ToString(), hash, salt, parameters ?? new Dictionary<string, string>()); } private void SerializeParameters(StringBuilder stringBuilder) @@ -144,11 +204,16 @@ namespace MediaBrowser.Common.Cryptography if (_salt.Length != 0) { str.Append('$') - .Append(Hex.Encode(_salt, false)); + .Append(Convert.ToHexString(_salt)); + } + + if (_hash.Length != 0) + { + str.Append('$') + .Append(Convert.ToHexString(_hash)); } - return str.Append('$') - .Append(Hex.Encode(_hash, false)).ToString(); + return str.ToString(); } } } diff --git a/MediaBrowser.Common/Events/EventHelper.cs b/MediaBrowser.Common/Events/EventHelper.cs index c9d3226ac8..a9cf86fbc3 100644 --- a/MediaBrowser.Common/Events/EventHelper.cs +++ b/MediaBrowser.Common/Events/EventHelper.cs @@ -17,7 +17,7 @@ namespace MediaBrowser.Common.Events /// <param name="sender">The sender.</param> /// <param name="args">The <see cref="EventArgs" /> instance containing the event data.</param> /// <param name="logger">The logger.</param> - public static void QueueEventIfNotNull(EventHandler handler, object sender, EventArgs args, ILogger logger) + public static void QueueEventIfNotNull(EventHandler? handler, object sender, EventArgs args, ILogger logger) { if (handler != null) { @@ -43,7 +43,7 @@ namespace MediaBrowser.Common.Events /// <param name="sender">The sender.</param> /// <param name="args">The args.</param> /// <param name="logger">The logger.</param> - public static void QueueEventIfNotNull<T>(EventHandler<T> handler, object sender, T args, ILogger logger) + public static void QueueEventIfNotNull<T>(EventHandler<T>? handler, object sender, T args, ILogger logger) { if (handler != null) { diff --git a/MediaBrowser.Common/Extensions/BaseExtensions.cs b/MediaBrowser.Common/Extensions/BaseExtensions.cs index 40020093b6..08964420e7 100644 --- a/MediaBrowser.Common/Extensions/BaseExtensions.cs +++ b/MediaBrowser.Common/Extensions/BaseExtensions.cs @@ -1,5 +1,3 @@ -#nullable enable - using System; using System.Security.Cryptography; using System.Text; diff --git a/MediaBrowser.Common/Extensions/HttpContextExtensions.cs b/MediaBrowser.Common/Extensions/HttpContextExtensions.cs index 19fa95480d..1e5877c84c 100644 --- a/MediaBrowser.Common/Extensions/HttpContextExtensions.cs +++ b/MediaBrowser.Common/Extensions/HttpContextExtensions.cs @@ -17,7 +17,7 @@ namespace MediaBrowser.Common.Extensions { return (context.Connection.LocalIpAddress == null && context.Connection.RemoteIpAddress == null) - || context.Connection.LocalIpAddress.Equals(context.Connection.RemoteIpAddress); + || Equals(context.Connection.LocalIpAddress, context.Connection.RemoteIpAddress); } /// <summary> @@ -25,7 +25,7 @@ namespace MediaBrowser.Common.Extensions /// </summary> /// <param name="context">The HTTP context.</param> /// <returns>The remote caller IP address.</returns> - public static string GetNormalizedRemoteIp(this HttpContext context) + public static IPAddress GetNormalizedRemoteIp(this HttpContext context) { // Default to the loopback address if no RemoteIpAddress is specified (i.e. during integration tests) var ip = context.Connection.RemoteIpAddress ?? IPAddress.Loopback; @@ -35,7 +35,7 @@ namespace MediaBrowser.Common.Extensions ip = ip.MapToIPv4(); } - return ip.ToString(); + return ip; } } } diff --git a/MediaBrowser.Common/Extensions/MethodNotAllowedException.cs b/MediaBrowser.Common/Extensions/MethodNotAllowedException.cs index 258bd6662c..48e758ee4c 100644 --- a/MediaBrowser.Common/Extensions/MethodNotAllowedException.cs +++ b/MediaBrowser.Common/Extensions/MethodNotAllowedException.cs @@ -1,5 +1,3 @@ -#nullable enable - using System; namespace MediaBrowser.Common.Extensions diff --git a/MediaBrowser.Common/Extensions/ProcessExtensions.cs b/MediaBrowser.Common/Extensions/ProcessExtensions.cs index 2f52ba196a..08e01bfd65 100644 --- a/MediaBrowser.Common/Extensions/ProcessExtensions.cs +++ b/MediaBrowser.Common/Extensions/ProcessExtensions.cs @@ -1,5 +1,3 @@ -#nullable enable - using System; using System.Diagnostics; using System.Threading; @@ -42,7 +40,7 @@ namespace MediaBrowser.Common.Extensions // Add an event handler for the process exit event var tcs = new TaskCompletionSource<bool>(); - process.Exited += (sender, args) => tcs.TrySetResult(true); + process.Exited += (_, _) => tcs.TrySetResult(true); // Return immediately if the process has already exited if (process.HasExitedSafe()) diff --git a/MediaBrowser.Common/Extensions/RateLimitExceededException.cs b/MediaBrowser.Common/Extensions/RateLimitExceededException.cs index 7c7bdaa92f..95802a4626 100644 --- a/MediaBrowser.Common/Extensions/RateLimitExceededException.cs +++ b/MediaBrowser.Common/Extensions/RateLimitExceededException.cs @@ -1,4 +1,3 @@ -#nullable enable #pragma warning disable CS1591 using System; diff --git a/MediaBrowser.Common/Extensions/ResourceNotFoundException.cs b/MediaBrowser.Common/Extensions/ResourceNotFoundException.cs index ebac9d8e6b..22130c5a1e 100644 --- a/MediaBrowser.Common/Extensions/ResourceNotFoundException.cs +++ b/MediaBrowser.Common/Extensions/ResourceNotFoundException.cs @@ -1,5 +1,3 @@ -#nullable enable - using System; namespace MediaBrowser.Common.Extensions diff --git a/MediaBrowser.Common/Extensions/StringExtensions.cs b/MediaBrowser.Common/Extensions/StringExtensions.cs deleted file mode 100644 index 7643017412..0000000000 --- a/MediaBrowser.Common/Extensions/StringExtensions.cs +++ /dev/null @@ -1,37 +0,0 @@ -#nullable enable - -using System; - -namespace MediaBrowser.Common.Extensions -{ - /// <summary> - /// Extensions methods to simplify string operations. - /// </summary> - public static class StringExtensions - { - /// <summary> - /// Returns the part on the left of the <c>needle</c>. - /// </summary> - /// <param name="haystack">The string to seek.</param> - /// <param name="needle">The needle to find.</param> - /// <returns>The part left of the <paramref name="needle" />.</returns> - public static ReadOnlySpan<char> LeftPart(this ReadOnlySpan<char> haystack, char needle) - { - var pos = haystack.IndexOf(needle); - return pos == -1 ? haystack : haystack[..pos]; - } - - /// <summary> - /// Returns the part on the left of the <c>needle</c>. - /// </summary> - /// <param name="haystack">The string to seek.</param> - /// <param name="needle">The needle to find.</param> - /// <param name="stringComparison">One of the enumeration values that specifies the rules for the search.</param> - /// <returns>The part left of the <c>needle</c>.</returns> - public static ReadOnlySpan<char> LeftPart(this ReadOnlySpan<char> haystack, ReadOnlySpan<char> needle, StringComparison stringComparison = default) - { - var pos = haystack.IndexOf(needle, stringComparison); - return pos == -1 ? haystack : haystack[..pos]; - } - } -} diff --git a/MediaBrowser.Common/Hex.cs b/MediaBrowser.Common/Hex.cs deleted file mode 100644 index 559109f74f..0000000000 --- a/MediaBrowser.Common/Hex.cs +++ /dev/null @@ -1,94 +0,0 @@ -using System; -using System.Diagnostics.CodeAnalysis; - -namespace MediaBrowser.Common -{ - /// <summary> - /// Encoding and decoding hex strings. - /// </summary> - public static class Hex - { - internal const string HexCharsLower = "0123456789abcdef"; - internal const string HexCharsUpper = "0123456789ABCDEF"; - - internal const int LastHexSymbol = 0x66; // 102: f - - /// <summary> - /// Gets a map from an ASCII char to its hex value shifted, - /// e.g. <c>b</c> -> 11. 0xFF means it's not a hex symbol. - /// </summary> - internal static ReadOnlySpan<byte> HexLookup => new byte[] - { - 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, - 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, - 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, - 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, - 0xFF, 0x0A, 0x0B, 0x0C, 0x0D, 0x0E, 0x0F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, - 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, - 0xFF, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f - }; - - /// <summary> - /// Encodes each element of the specified bytes as its hexadecimal string representation. - /// </summary> - /// <param name="bytes">An array of bytes.</param> - /// <param name="lowercase"><c>true</c> to use lowercase hexadecimal characters; otherwise <c>false</c>.</param> - /// <returns><c>bytes</c> as a hex string.</returns> - public static string Encode(ReadOnlySpan<byte> bytes, bool lowercase = true) - { - var hexChars = lowercase ? HexCharsLower : HexCharsUpper; - - // TODO: use string.Create when it's supports spans - // Ref: https://github.com/dotnet/corefx/issues/29120 - char[] s = new char[bytes.Length * 2]; - int j = 0; - for (int i = 0; i < bytes.Length; i++) - { - s[j++] = hexChars[bytes[i] >> 4]; - s[j++] = hexChars[bytes[i] & 0x0f]; - } - - return new string(s); - } - - /// <summary> - /// Decodes a hex string into bytes. - /// </summary> - /// <param name="str">The <see cref="string" />.</param> - /// <returns>The decoded bytes.</returns> - public static byte[] Decode(ReadOnlySpan<char> str) - { - if (str.Length == 0) - { - return Array.Empty<byte>(); - } - - var unHex = HexLookup; - - int byteLen = str.Length / 2; - byte[] bytes = new byte[byteLen]; - int i = 0; - for (int j = 0; j < byteLen; j++) - { - byte a; - byte b; - if (str[i] > LastHexSymbol - || (a = unHex[str[i++]]) == 0xFF - || str[i] > LastHexSymbol - || (b = unHex[str[i++]]) == 0xFF) - { - ThrowArgumentException(nameof(str)); - break; // Unreachable - } - - bytes[j] = (byte)((a * 16) | b); - } - - return bytes; - } - - [DoesNotReturn] - private static void ThrowArgumentException(string paramName) - => throw new ArgumentException("Character is not a hex symbol.", paramName); - } -} diff --git a/MediaBrowser.Common/IApplicationHost.cs b/MediaBrowser.Common/IApplicationHost.cs index 849037ac46..192a776115 100644 --- a/MediaBrowser.Common/IApplicationHost.cs +++ b/MediaBrowser.Common/IApplicationHost.cs @@ -2,11 +2,16 @@ using System; using System.Collections.Generic; using System.Reflection; using System.Threading.Tasks; -using MediaBrowser.Common.Plugins; -using Microsoft.Extensions.DependencyInjection; namespace MediaBrowser.Common { + /// <summary> + /// Delegate used with GetExports{T}. + /// </summary> + /// <param name="type">Type to create.</param> + /// <returns>New instance of type <param>type</param>.</returns> + public delegate object? CreationDelegateFactory(Type type); + /// <summary> /// An interface to be implemented by the applications hosting a kernel. /// </summary> @@ -15,7 +20,7 @@ namespace MediaBrowser.Common /// <summary> /// Occurs when [has pending restart changed]. /// </summary> - event EventHandler HasPendingRestartChanged; + event EventHandler? HasPendingRestartChanged; /// <summary> /// Gets the name. @@ -53,6 +58,11 @@ namespace MediaBrowser.Common /// <value>The application version.</value> Version ApplicationVersion { get; } + /// <summary> + /// Gets or sets the service provider. + /// </summary> + IServiceProvider? ServiceProvider { get; set; } + /// <summary> /// Gets the application version. /// </summary> @@ -71,12 +81,6 @@ namespace MediaBrowser.Common /// </summary> string ApplicationUserAgentAddress { get; } - /// <summary> - /// Gets the plugins. - /// </summary> - /// <value>The plugins.</value> - IReadOnlyList<IPlugin> Plugins { get; } - /// <summary> /// Gets all plugin assemblies which implement a custom rest api. /// </summary> @@ -101,6 +105,22 @@ namespace MediaBrowser.Common /// <returns><see cref="IReadOnlyCollection{T}" />.</returns> IReadOnlyCollection<T> GetExports<T>(bool manageLifetime = true); + /// <summary> + /// Gets the exports. + /// </summary> + /// <typeparam name="T">The type.</typeparam> + /// <param name="defaultFunc">Delegate function that gets called to create the object.</param> + /// <param name="manageLifetime">If set to <c>true</c> [manage lifetime].</param> + /// <returns><see cref="IReadOnlyCollection{T}" />.</returns> + IReadOnlyCollection<T> GetExports<T>(CreationDelegateFactory defaultFunc, bool manageLifetime = true); + + /// <summary> + /// Gets the export types. + /// </summary> + /// <typeparam name="T">The type.</typeparam> + /// <returns>IEnumerable{Type}.</returns> + IEnumerable<Type> GetExportTypes<T>(); + /// <summary> /// Resolves this instance. /// </summary> @@ -114,12 +134,6 @@ namespace MediaBrowser.Common /// <returns>A task.</returns> Task Shutdown(); - /// <summary> - /// Removes the plugin. - /// </summary> - /// <param name="plugin">The plugin.</param> - void RemovePlugin(IPlugin plugin); - /// <summary> /// Initializes this instance. /// </summary> diff --git a/MediaBrowser.Common/Json/JsonDefaults.cs b/MediaBrowser.Common/Json/JsonDefaults.cs deleted file mode 100644 index 6605ae9624..0000000000 --- a/MediaBrowser.Common/Json/JsonDefaults.cs +++ /dev/null @@ -1,71 +0,0 @@ -using System.Text.Json; -using System.Text.Json.Serialization; -using MediaBrowser.Common.Json.Converters; - -namespace MediaBrowser.Common.Json -{ - /// <summary> - /// Helper class for having compatible JSON throughout the codebase. - /// </summary> - public static class JsonDefaults - { - /// <summary> - /// Pascal case json profile media type. - /// </summary> - public const string PascalCaseMediaType = "application/json; profile=\"PascalCase\""; - - /// <summary> - /// Camel case json profile media type. - /// </summary> - public const string CamelCaseMediaType = "application/json; profile=\"CamelCase\""; - - /// <summary> - /// Gets the default <see cref="JsonSerializerOptions" /> options. - /// </summary> - /// <remarks> - /// When changing these options, update - /// Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs - /// -> AddJellyfinApi - /// -> AddJsonOptions. - /// </remarks> - /// <returns>The default <see cref="JsonSerializerOptions" /> options.</returns> - public static JsonSerializerOptions GetOptions() - { - var options = new JsonSerializerOptions - { - ReadCommentHandling = JsonCommentHandling.Disallow, - WriteIndented = false, - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, - NumberHandling = JsonNumberHandling.AllowReadingFromString - }; - - options.Converters.Add(new JsonGuidConverter()); - options.Converters.Add(new JsonStringEnumConverter()); - options.Converters.Add(new JsonNullableStructConverterFactory()); - - return options; - } - - /// <summary> - /// Gets camelCase json options. - /// </summary> - /// <returns>The camelCase <see cref="JsonSerializerOptions" /> options.</returns> - public static JsonSerializerOptions GetCamelCaseOptions() - { - var options = GetOptions(); - options.PropertyNamingPolicy = JsonNamingPolicy.CamelCase; - return options; - } - - /// <summary> - /// Gets PascalCase json options. - /// </summary> - /// <returns>The PascalCase <see cref="JsonSerializerOptions" /> options.</returns> - public static JsonSerializerOptions GetPascalCaseOptions() - { - var options = GetOptions(); - options.PropertyNamingPolicy = null; - return options; - } - } -} diff --git a/MediaBrowser.Common/MediaBrowser.Common.csproj b/MediaBrowser.Common/MediaBrowser.Common.csproj index e716a6610f..12cfaf9789 100644 --- a/MediaBrowser.Common/MediaBrowser.Common.csproj +++ b/MediaBrowser.Common/MediaBrowser.Common.csproj @@ -1,4 +1,4 @@ -<Project Sdk="Microsoft.NET.Sdk"> +<Project Sdk="Microsoft.NET.Sdk"> <!-- ProjectGuid is only included as a requirement for SonarQube analysis --> <PropertyGroup> @@ -8,20 +8,20 @@ <PropertyGroup> <Authors>Jellyfin Contributors</Authors> <PackageId>Jellyfin.Common</PackageId> - <VersionPrefix>10.7.0</VersionPrefix> + <VersionPrefix>10.8.0</VersionPrefix> <RepositoryUrl>https://github.com/jellyfin/jellyfin</RepositoryUrl> <PackageLicenseExpression>GPL-3.0-only</PackageLicenseExpression> </PropertyGroup> <ItemGroup> + <FrameworkReference Include="Microsoft.AspNetCore.App" /> <ProjectReference Include="..\MediaBrowser.Model\MediaBrowser.Model.csproj" /> </ItemGroup> <ItemGroup> - <PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="3.1.9" /> - <PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="3.1.9" /> - <PackageReference Include="Microsoft.SourceLink.GitHub" Version="1.0.0" PrivateAssets="All"/> - <PackageReference Include="Microsoft.Net.Http.Headers" Version="2.2.8" /> + <PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="5.0.0" /> + <PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="5.0.0" /> + <PackageReference Include="Microsoft.SourceLink.GitHub" Version="1.0.0" PrivateAssets="All" /> </ItemGroup> <ItemGroup> @@ -29,10 +29,9 @@ </ItemGroup> <PropertyGroup> - <TargetFramework>netstandard2.1</TargetFramework> + <TargetFramework>net5.0</TargetFramework> <GenerateAssemblyInfo>false</GenerateAssemblyInfo> <GenerateDocumentationFile>true</GenerateDocumentationFile> - <TreatWarningsAsErrors>true</TreatWarningsAsErrors> <PublishRepositoryUrl>true</PublishRepositoryUrl> <EmbedUntrackedSources>true</EmbedUntrackedSources> <IncludeSymbols>true</IncludeSymbols> @@ -46,16 +45,11 @@ <!-- Code analyzers--> <ItemGroup Condition=" '$(Configuration)' == 'Debug' "> - <PackageReference Include="Microsoft.CodeAnalysis.FxCopAnalyzers" Version="2.9.8" PrivateAssets="All" /> <PackageReference Include="SerilogAnalyzer" Version="0.15.0" PrivateAssets="All" /> <PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="All" /> <PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" PrivateAssets="All" /> </ItemGroup> - <PropertyGroup Condition=" '$(Configuration)' == 'Debug' "> - <CodeAnalysisRuleSet>../jellyfin.ruleset</CodeAnalysisRuleSet> - </PropertyGroup> - <ItemGroup> <AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleTo"> <_Parameter1>Jellyfin.Common.Tests</_Parameter1> diff --git a/MediaBrowser.Common/Net/DefaultHttpClientHandler.cs b/MediaBrowser.Common/Net/DefaultHttpClientHandler.cs deleted file mode 100644 index e189d6e706..0000000000 --- a/MediaBrowser.Common/Net/DefaultHttpClientHandler.cs +++ /dev/null @@ -1,20 +0,0 @@ -using System.Net; -using System.Net.Http; - -namespace MediaBrowser.Common.Net -{ - /// <summary> - /// Default http client handler. - /// </summary> - public class DefaultHttpClientHandler : HttpClientHandler - { - /// <summary> - /// Initializes a new instance of the <see cref="DefaultHttpClientHandler"/> class. - /// </summary> - public DefaultHttpClientHandler() - { - // TODO change to DecompressionMethods.All with .NET5 - AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate; - } - } -} diff --git a/MediaBrowser.Common/Net/INetworkManager.cs b/MediaBrowser.Common/Net/INetworkManager.cs index a0330afeff..b939397301 100644 --- a/MediaBrowser.Common/Net/INetworkManager.cs +++ b/MediaBrowser.Common/Net/INetworkManager.cs @@ -1,97 +1,238 @@ -#pragma warning disable CS1591 - using System; using System.Collections.Generic; +using System.Collections.ObjectModel; using System.Net; using System.Net.NetworkInformation; +using Microsoft.AspNetCore.Http; namespace MediaBrowser.Common.Net { + /// <summary> + /// Interface for the NetworkManager class. + /// </summary> public interface INetworkManager { + /// <summary> + /// Event triggered on network changes. + /// </summary> event EventHandler NetworkChanged; /// <summary> - /// Gets or sets a function to return the list of user defined LAN addresses. + /// Gets the published server urls list. + /// </summary> + Dictionary<IPNetAddress, string> PublishedServerUrls { get; } + + /// <summary> + /// Gets a value indicating whether is all IPv6 interfaces are trusted as internal. + /// </summary> + bool TrustAllIP6Interfaces { get; } + + /// <summary> + /// Gets the remote address filter. + /// </summary> + Collection<IPObject> RemoteAddressFilter { get; } + + /// <summary> + /// Gets or sets a value indicating whether iP6 is enabled. + /// </summary> + bool IsIP6Enabled { get; set; } + + /// <summary> + /// Gets or sets a value indicating whether iP4 is enabled. + /// </summary> + bool IsIP4Enabled { get; set; } + + /// <summary> + /// Calculates the list of interfaces to use for Kestrel. + /// </summary> + /// <returns>A Collection{IPObject} object containing all the interfaces to bind. + /// If all the interfaces are specified, and none are excluded, it returns zero items + /// to represent any address.</returns> + /// <param name="individualInterfaces">When false, return <see cref="IPAddress.Any"/> or <see cref="IPAddress.IPv6Any"/> for all interfaces.</param> + Collection<IPObject> GetAllBindInterfaces(bool individualInterfaces = false); + + /// <summary> + /// Returns a collection containing the loopback interfaces. + /// </summary> + /// <returns>Collection{IPObject}.</returns> + Collection<IPObject> GetLoopbacks(); + + /// <summary> + /// Retrieves the bind address to use in system url's. (Server Discovery, PlayTo, LiveTV, SystemInfo) + /// If no bind addresses are specified, an internal interface address is selected. + /// The priority of selection is as follows:- + /// + /// The value contained in the startup parameter --published-server-url. + /// + /// If the user specified custom subnet overrides, the correct subnet for the source address. + /// + /// If the user specified bind interfaces to use:- + /// The bind interface that contains the source subnet. + /// The first bind interface specified that suits best first the source's endpoint. eg. external or internal. + /// + /// If the source is from a public subnet address range and the user hasn't specified any bind addresses:- + /// The first public interface that isn't a loopback and contains the source subnet. + /// The first public interface that isn't a loopback. Priority is given to interfaces with gateways. + /// An internal interface if there are no public ip addresses. + /// + /// If the source is from a private subnet address range and the user hasn't specified any bind addresses:- + /// The first private interface that contains the source subnet. + /// The first private interface that isn't a loopback. Priority is given to interfaces with gateways. + /// + /// If no interfaces meet any of these criteria, then a loopback address is returned. + /// + /// Interface that have been specifically excluded from binding are not used in any of the calculations. + /// </summary> + /// <param name="source">Source of the request.</param> + /// <param name="port">Optional port returned, if it's part of an override.</param> + /// <returns>IP Address to use, or loopback address if all else fails.</returns> + string GetBindInterface(IPObject source, out int? port); + + /// <summary> + /// Retrieves the bind address to use in system url's. (Server Discovery, PlayTo, LiveTV, SystemInfo) + /// If no bind addresses are specified, an internal interface address is selected. + /// (See <see cref="GetBindInterface(IPObject, out int?)"/>. + /// </summary> + /// <param name="source">Source of the request.</param> + /// <param name="port">Optional port returned, if it's part of an override.</param> + /// <returns>IP Address to use, or loopback address if all else fails.</returns> + string GetBindInterface(HttpRequest source, out int? port); + + /// <summary> + /// Retrieves the bind address to use in system url's. (Server Discovery, PlayTo, LiveTV, SystemInfo) + /// If no bind addresses are specified, an internal interface address is selected. + /// (See <see cref="GetBindInterface(IPObject, out int?)"/>. + /// </summary> + /// <param name="source">IP address of the request.</param> + /// <param name="port">Optional port returned, if it's part of an override.</param> + /// <returns>IP Address to use, or loopback address if all else fails.</returns> + string GetBindInterface(IPAddress source, out int? port); + + /// <summary> + /// Retrieves the bind address to use in system url's. (Server Discovery, PlayTo, LiveTV, SystemInfo) + /// If no bind addresses are specified, an internal interface address is selected. + /// (See <see cref="GetBindInterface(IPObject, out int?)"/>. + /// </summary> + /// <param name="source">Source of the request.</param> + /// <param name="port">Optional port returned, if it's part of an override.</param> + /// <returns>IP Address to use, or loopback address if all else fails.</returns> + string GetBindInterface(string source, out int? port); + + /// <summary> + /// Checks to see if the ip address is specifically excluded in LocalNetworkAddresses. + /// </summary> + /// <param name="address">IP address to check.</param> + /// <returns>True if it is.</returns> + bool IsExcludedInterface(IPAddress address); + + /// <summary> + /// Get a list of all the MAC addresses associated with active interfaces. + /// </summary> + /// <returns>List of MAC addresses.</returns> + IReadOnlyCollection<PhysicalAddress> GetMacAddresses(); + + /// <summary> + /// Checks to see if the IP Address provided matches an interface that has a gateway. + /// </summary> + /// <param name="addressObj">IP to check. Can be an IPAddress or an IPObject.</param> + /// <returns>Result of the check.</returns> + bool IsGatewayInterface(IPObject? addressObj); + + /// <summary> + /// Checks to see if the IP Address provided matches an interface that has a gateway. + /// </summary> + /// <param name="addressObj">IP to check. Can be an IPAddress or an IPObject.</param> + /// <returns>Result of the check.</returns> + bool IsGatewayInterface(IPAddress? addressObj); + + /// <summary> + /// Returns true if the address is a private address. + /// The configuration option TrustIP6Interfaces overrides this functions behaviour. /// </summary> - Func<string[]> LocalSubnetsFn { get; set; } + /// <param name="address">Address to check.</param> + /// <returns>True or False.</returns> + bool IsPrivateAddressRange(IPObject address); /// <summary> - /// Gets a random port TCP number that is currently available. + /// Returns true if the address is part of the user defined LAN. + /// The configuration option TrustIP6Interfaces overrides this functions behaviour. /// </summary> - /// <returns>System.Int32.</returns> - int GetRandomUnusedTcpPort(); + /// <param name="address">IP to check.</param> + /// <returns>True if endpoint is within the LAN range.</returns> + bool IsInLocalNetwork(string address); /// <summary> - /// Gets a random port UDP number that is currently available. + /// Returns true if the address is part of the user defined LAN. + /// The configuration option TrustIP6Interfaces overrides this functions behaviour. /// </summary> - /// <returns>System.Int32.</returns> - int GetRandomUnusedUdpPort(); + /// <param name="address">IP to check.</param> + /// <returns>True if endpoint is within the LAN range.</returns> + bool IsInLocalNetwork(IPObject address); /// <summary> - /// Returns the MAC Address from first Network Card in Computer. + /// Returns true if the address is part of the user defined LAN. + /// The configuration option TrustIP6Interfaces overrides this functions behaviour. /// </summary> - /// <returns>The MAC Address.</returns> - List<PhysicalAddress> GetMacAddresses(); + /// <param name="address">IP to check.</param> + /// <returns>True if endpoint is within the LAN range.</returns> + bool IsInLocalNetwork(IPAddress address); /// <summary> - /// Determines whether [is in private address space] [the specified endpoint]. + /// Attempts to convert the token to an IP address, permitting for interface descriptions and indexes. + /// eg. "eth1", or "TP-LINK Wireless USB Adapter". /// </summary> - /// <param name="endpoint">The endpoint.</param> - /// <returns><c>true</c> if [is in private address space] [the specified endpoint]; otherwise, <c>false</c>.</returns> - bool IsInPrivateAddressSpace(string endpoint); + /// <param name="token">Token to parse.</param> + /// <param name="result">Resultant object's ip addresses, if successful.</param> + /// <returns>Success of the operation.</returns> + bool TryParseInterface(string token, out Collection<IPObject>? result); /// <summary> - /// Determines whether [is in private address space 10.x.x.x] [the specified endpoint] and exists in the subnets returned by GetSubnets(). + /// Parses an array of strings into a Collection{IPObject}. /// </summary> - /// <param name="endpoint">The endpoint.</param> - /// <returns><c>true</c> if [is in private address space 10.x.x.x] [the specified endpoint]; otherwise, <c>false</c>.</returns> - bool IsInPrivateAddressSpaceAndLocalSubnet(string endpoint); + /// <param name="values">Values to parse.</param> + /// <param name="negated">When true, only include values beginning with !. When false, ignore ! values.</param> + /// <returns>IPCollection object containing the value strings.</returns> + Collection<IPObject> CreateIPCollection(string[] values, bool negated = false); /// <summary> - /// Determines whether [is in local network] [the specified endpoint]. + /// Returns all the internal Bind interface addresses. /// </summary> - /// <param name="endpoint">The endpoint.</param> - /// <returns><c>true</c> if [is in local network] [the specified endpoint]; otherwise, <c>false</c>.</returns> - bool IsInLocalNetwork(string endpoint); + /// <returns>An internal list of interfaces addresses.</returns> + Collection<IPObject> GetInternalBindAddresses(); /// <summary> - /// Investigates an caches a list of interface addresses, excluding local link and LAN excluded addresses. + /// Checks to see if an IP address is still a valid interface address. /// </summary> - /// <returns>The list of ipaddresses.</returns> - IPAddress[] GetLocalIpAddresses(); + /// <param name="address">IP address to check.</param> + /// <returns>True if it is.</returns> + bool IsValidInterfaceAddress(IPAddress address); /// <summary> - /// Checks if the given address falls within the ranges given in [subnets]. The addresses in subnets can be hosts or subnets in the CIDR format. + /// Returns true if the IP address is in the excluded list. /// </summary> - /// <param name="addressString">The address to check.</param> - /// <param name="subnets">If true, check against addresses in the LAN settings surrounded by brackets ([]).</param> - /// <returns><c>true</c>if the address is in at least one of the given subnets, <c>false</c> otherwise.</returns> - bool IsAddressInSubnets(string addressString, string[] subnets); + /// <param name="ip">IP to check.</param> + /// <returns>True if excluded.</returns> + bool IsExcluded(IPAddress ip); /// <summary> - /// Returns true if address is in the LAN list in the config file. + /// Returns true if the IP address is in the excluded list. /// </summary> - /// <param name="address">The address to check.</param> - /// <param name="excludeInterfaces">If true, check against addresses in the LAN settings which have [] arroud and return true if it matches the address give in address.</param> - /// <param name="excludeRFC">If true, returns false if address is in the 127.x.x.x or 169.128.x.x range.</param> - /// <returns><c>false</c>if the address isn't in the LAN list, <c>true</c> if the address has been defined as a LAN address.</returns> - bool IsAddressInSubnets(IPAddress address, bool excludeInterfaces, bool excludeRFC); + /// <param name="ip">IP to check.</param> + /// <returns>True if excluded.</returns> + bool IsExcluded(EndPoint ip); /// <summary> - /// Checks if address is in the LAN list in the config file. + /// Gets the filtered LAN ip addresses. /// </summary> - /// <param name="address1">Source address to check.</param> - /// <param name="address2">Destination address to check against.</param> - /// <param name="subnetMask">Destination subnet to check against.</param> - /// <returns><c>true/false</c>depending on whether address1 is in the same subnet as IPAddress2 with subnetMask.</returns> - bool IsInSameSubnet(IPAddress address1, IPAddress address2, IPAddress subnetMask); + /// <param name="filter">Optional filter for the list.</param> + /// <returns>Returns a filtered list of LAN addresses.</returns> + Collection<IPObject> GetFilteredLANSubnets(Collection<IPObject>? filter = null); /// <summary> - /// Returns the subnet mask of an interface with the given address. + /// Checks to see if <paramref name="remoteIp"/> has access. /// </summary> - /// <param name="address">The address to check.</param> - /// <returns>Returns the subnet mask of an interface with the given address, or null if an interface match cannot be found.</returns> - IPAddress GetLocalIpSubnetMask(IPAddress address); + /// <param name="remoteIp">IP Address of client.</param> + /// <returns><b>True</b> if has access, otherwise <b>false</b>.</returns> + bool HasRemoteAccess(IPAddress remoteIp); } } diff --git a/MediaBrowser.Common/Net/IPHost.cs b/MediaBrowser.Common/Net/IPHost.cs new file mode 100644 index 0000000000..1f125f2b1d --- /dev/null +++ b/MediaBrowser.Common/Net/IPHost.cs @@ -0,0 +1,441 @@ +using System; +using System.Diagnostics; +using System.Linq; +using System.Net; +using System.Net.Sockets; +using System.Text.RegularExpressions; + +namespace MediaBrowser.Common.Net +{ + /// <summary> + /// Object that holds a host name. + /// </summary> + public class IPHost : IPObject + { + /// <summary> + /// Gets or sets timeout value before resolve required, in minutes. + /// </summary> + public const int Timeout = 30; + + /// <summary> + /// Represents an IPHost that has no value. + /// </summary> + public static readonly IPHost None = new IPHost(string.Empty, IPAddress.None); + + /// <summary> + /// Time when last resolved in ticks. + /// </summary> + private DateTime? _lastResolved = null; + + /// <summary> + /// Gets the IP Addresses, attempting to resolve the name, if there are none. + /// </summary> + private IPAddress[] _addresses; + + /// <summary> + /// Initializes a new instance of the <see cref="IPHost"/> class. + /// </summary> + /// <param name="name">Host name to assign.</param> + public IPHost(string name) + { + HostName = name ?? throw new ArgumentNullException(nameof(name)); + _addresses = Array.Empty<IPAddress>(); + Resolved = false; + } + + /// <summary> + /// Initializes a new instance of the <see cref="IPHost"/> class. + /// </summary> + /// <param name="name">Host name to assign.</param> + /// <param name="address">Address to assign.</param> + private IPHost(string name, IPAddress address) + { + HostName = name ?? throw new ArgumentNullException(nameof(name)); + _addresses = new IPAddress[] { address ?? throw new ArgumentNullException(nameof(address)) }; + Resolved = !address.Equals(IPAddress.None); + } + + /// <summary> + /// Gets or sets the object's first IP address. + /// </summary> + public override IPAddress Address + { + get + { + return ResolveHost() ? this[0] : IPAddress.None; + } + + set + { + // Not implemented, as a host's address is determined by DNS. + throw new NotImplementedException("The address of a host is determined by DNS."); + } + } + + /// <summary> + /// Gets or sets the object's first IP's subnet prefix. + /// The setter does nothing, but shouldn't raise an exception. + /// </summary> + public override byte PrefixLength + { + get => (byte)(ResolveHost() ? 128 : 32); + + // Not implemented, as a host object can only have a prefix length of 128 (IPv6) or 32 (IPv4) prefix length, + // which is automatically determined by it's IP type. Anything else is meaningless. + set => throw new NotImplementedException(); + } + + /// <summary> + /// Gets a value indicating whether the address has a value. + /// </summary> + public bool HasAddress => _addresses.Length != 0; + + /// <summary> + /// Gets the host name of this object. + /// </summary> + public string HostName { get; } + + /// <summary> + /// Gets a value indicating whether this host has attempted to be resolved. + /// </summary> + public bool Resolved { get; private set; } + + /// <summary> + /// Gets or sets the IP Addresses associated with this object. + /// </summary> + /// <param name="index">Index of address.</param> + public IPAddress this[int index] + { + get + { + ResolveHost(); + return index >= 0 && index < _addresses.Length ? _addresses[index] : IPAddress.None; + } + } + + /// <summary> + /// Attempts to parse the host string. + /// </summary> + /// <param name="host">Host name to parse.</param> + /// <param name="hostObj">Object representing the string, if it has successfully been parsed.</param> + /// <returns><c>true</c> if the parsing is successful, <c>false</c> if not.</returns> + public static bool TryParse(string host, out IPHost hostObj) + { + if (string.IsNullOrWhiteSpace(host)) + { + hostObj = IPHost.None; + return false; + } + + // See if it's an IPv6 with port address e.g. [::1] or [::1]:120. + int i = host.IndexOf(']', StringComparison.Ordinal); + if (i != -1) + { + return TryParse(host.Remove(i - 1).TrimStart(' ', '['), out hostObj); + } + + if (IPNetAddress.TryParse(host, out var netAddress)) + { + // Host name is an ip address, so fake resolve. + hostObj = new IPHost(host, netAddress.Address); + return true; + } + + // Is it a host, IPv4/6 with/out port? + string[] hosts = host.Split(':'); + + if (hosts.Length <= 2) + { + // This is either a hostname: port, or an IP4:port. + host = hosts[0]; + + if (string.Equals("localhost", host, StringComparison.OrdinalIgnoreCase)) + { + hostObj = new IPHost(host); + return true; + } + + if (IPAddress.TryParse(host, out var netIP)) + { + // Host name is an ip address, so fake resolve. + hostObj = new IPHost(host, netIP); + return true; + } + } + else + { + // Invalid host name, as it cannot contain : + hostObj = new IPHost(string.Empty, IPAddress.None); + return false; + } + + // Use regular expression as CheckHostName isn't RFC5892 compliant. + // Modified from gSkinner's expression at https://stackoverflow.com/questions/11809631/fully-qualified-domain-name-validation + string pattern = @"(?im)^(?!:\/\/)(?=.{1,255}$)((.{1,63}\.){0,127}(?![0-9]*$)[a-z0-9-]+\.?)$"; + + if (Regex.IsMatch(host, pattern)) + { + hostObj = new IPHost(host); + return true; + } + + hostObj = IPHost.None; + return false; + } + + /// <summary> + /// Attempts to parse the host string. + /// </summary> + /// <param name="host">Host name to parse.</param> + /// <returns>Object representing the string, if it has successfully been parsed.</returns> + public static IPHost Parse(string host) + { + if (!string.IsNullOrEmpty(host) && IPHost.TryParse(host, out IPHost res)) + { + return res; + } + + throw new InvalidCastException($"Host does not contain a valid value. {host}"); + } + + /// <summary> + /// Attempts to parse the host string, ensuring that it resolves only to a specific IP type. + /// </summary> + /// <param name="host">Host name to parse.</param> + /// <param name="family">Addressfamily filter.</param> + /// <returns>Object representing the string, if it has successfully been parsed.</returns> + public static IPHost Parse(string host, AddressFamily family) + { + if (!string.IsNullOrEmpty(host) && IPHost.TryParse(host, out IPHost res)) + { + if (family == AddressFamily.InterNetwork) + { + res.Remove(AddressFamily.InterNetworkV6); + } + else + { + res.Remove(AddressFamily.InterNetwork); + } + + return res; + } + + throw new InvalidCastException($"Host does not contain a valid value. {host}"); + } + + /// <summary> + /// Returns the Addresses that this item resolved to. + /// </summary> + /// <returns>IPAddress Array.</returns> + public IPAddress[] GetAddresses() + { + ResolveHost(); + return _addresses; + } + + /// <inheritdoc/> + public override bool Contains(IPAddress address) + { + if (address != null && !Address.Equals(IPAddress.None)) + { + if (address.IsIPv4MappedToIPv6) + { + address = address.MapToIPv4(); + } + + foreach (var addr in GetAddresses()) + { + if (address.Equals(addr)) + { + return true; + } + } + } + + return false; + } + + /// <inheritdoc/> + public override bool Equals(IPObject? other) + { + if (other is IPHost otherObj) + { + // Do we have the name Hostname? + if (string.Equals(otherObj.HostName, HostName, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + if (!ResolveHost() || !otherObj.ResolveHost()) + { + return false; + } + + // Do any of our IP addresses match? + foreach (IPAddress addr in _addresses) + { + foreach (IPAddress otherAddress in otherObj._addresses) + { + if (addr.Equals(otherAddress)) + { + return true; + } + } + } + } + + return false; + } + + /// <inheritdoc/> + public override bool IsIP6() + { + // Returns true if interfaces are only IP6. + if (ResolveHost()) + { + foreach (IPAddress i in _addresses) + { + if (i.AddressFamily != AddressFamily.InterNetworkV6) + { + return false; + } + } + + return true; + } + + return false; + } + + /// <inheritdoc/> + public override string ToString() + { + // StringBuilder not optimum here. + string output = string.Empty; + if (_addresses.Length > 0) + { + bool moreThanOne = _addresses.Length > 1; + if (moreThanOne) + { + output = "["; + } + + foreach (var i in _addresses) + { + if (Address.Equals(IPAddress.None) && Address.AddressFamily == AddressFamily.Unspecified) + { + output += HostName + ","; + } + else if (i.Equals(IPAddress.Any)) + { + output += "Any IP4 Address,"; + } + else if (Address.Equals(IPAddress.IPv6Any)) + { + output += "Any IP6 Address,"; + } + else if (i.Equals(IPAddress.Broadcast)) + { + output += "Any Address,"; + } + else if (i.AddressFamily == AddressFamily.InterNetwork) + { + output += $"{i}/32,"; + } + else + { + output += $"{i}/128,"; + } + } + + output = output[..^1]; + + if (moreThanOne) + { + output += "]"; + } + } + else + { + output = HostName; + } + + return output; + } + + /// <inheritdoc/> + public override void Remove(AddressFamily family) + { + if (ResolveHost()) + { + _addresses = _addresses.Where(p => p.AddressFamily != family).ToArray(); + } + } + + /// <inheritdoc/> + public override bool Contains(IPObject address) + { + // An IPHost cannot contain another IPObject, it can only be equal. + return Equals(address); + } + + /// <inheritdoc/> + protected override IPObject CalculateNetworkAddress() + { + var (address, prefixLength) = NetworkAddressOf(this[0], PrefixLength); + return new IPNetAddress(address, prefixLength); + } + + /// <summary> + /// Attempt to resolve the ip address of a host. + /// </summary> + /// <returns><c>true</c> if any addresses have been resolved, otherwise <c>false</c>.</returns> + private bool ResolveHost() + { + // When was the last time we resolved? + _lastResolved ??= DateTime.UtcNow; + + // If we haven't resolved before, or our timer has run out... + if ((_addresses.Length == 0 && !Resolved) || (DateTime.UtcNow > _lastResolved.Value.AddMinutes(Timeout))) + { + _lastResolved = DateTime.UtcNow; + ResolveHostInternal(); + Resolved = true; + } + + return _addresses.Length > 0; + } + + /// <summary> + /// Task that looks up a Host name and returns its IP addresses. + /// </summary> + private void ResolveHostInternal() + { + var hostName = HostName; + if (string.IsNullOrEmpty(hostName)) + { + return; + } + + // Resolves the host name - so save a DNS lookup. + if (string.Equals(hostName, "localhost", StringComparison.OrdinalIgnoreCase)) + { + _addresses = new IPAddress[] { IPAddress.Loopback, IPAddress.IPv6Loopback }; + return; + } + + if (Uri.CheckHostName(hostName) == UriHostNameType.Dns) + { + try + { + _addresses = Dns.GetHostEntry(hostName).AddressList; + } + catch (SocketException ex) + { + // Log and then ignore socket errors, as the result value will just be an empty array. + Debug.WriteLine("GetHostAddresses failed with {Message}.", ex.Message); + } + } + } + } +} diff --git a/MediaBrowser.Common/Net/IPNetAddress.cs b/MediaBrowser.Common/Net/IPNetAddress.cs new file mode 100644 index 0000000000..f6e3971bf3 --- /dev/null +++ b/MediaBrowser.Common/Net/IPNetAddress.cs @@ -0,0 +1,276 @@ +using System; +using System.Net; +using System.Net.Sockets; + +namespace MediaBrowser.Common.Net +{ + /// <summary> + /// An object that holds and IP address and subnet mask. + /// </summary> + public class IPNetAddress : IPObject + { + /// <summary> + /// Represents an IPNetAddress that has no value. + /// </summary> + public static readonly IPNetAddress None = new IPNetAddress(IPAddress.None); + + /// <summary> + /// IPv4 multicast address. + /// </summary> + public static readonly IPAddress SSDPMulticastIPv4 = IPAddress.Parse("239.255.255.250"); + + /// <summary> + /// IPv6 local link multicast address. + /// </summary> + public static readonly IPAddress SSDPMulticastIPv6LinkLocal = IPAddress.Parse("ff02::C"); + + /// <summary> + /// IPv6 site local multicast address. + /// </summary> + public static readonly IPAddress SSDPMulticastIPv6SiteLocal = IPAddress.Parse("ff05::C"); + + /// <summary> + /// IP4Loopback address host. + /// </summary> + public static readonly IPNetAddress IP4Loopback = IPNetAddress.Parse("127.0.0.1/8"); + + /// <summary> + /// IP6Loopback address host. + /// </summary> + public static readonly IPNetAddress IP6Loopback = new IPNetAddress(IPAddress.IPv6Loopback); + + /// <summary> + /// Object's IP address. + /// </summary> + private IPAddress _address; + + /// <summary> + /// Initializes a new instance of the <see cref="IPNetAddress"/> class. + /// </summary> + /// <param name="address">Address to assign.</param> + public IPNetAddress(IPAddress address) + { + _address = address ?? throw new ArgumentNullException(nameof(address)); + PrefixLength = (byte)(address.AddressFamily == AddressFamily.InterNetwork ? 32 : 128); + } + + /// <summary> + /// Initializes a new instance of the <see cref="IPNetAddress"/> class. + /// </summary> + /// <param name="address">IP Address.</param> + /// <param name="prefixLength">Mask as a CIDR.</param> + public IPNetAddress(IPAddress address, byte prefixLength) + { + if (address?.IsIPv4MappedToIPv6 ?? throw new ArgumentNullException(nameof(address))) + { + _address = address.MapToIPv4(); + } + else + { + _address = address; + } + + PrefixLength = prefixLength; + } + + /// <summary> + /// Gets or sets the object's IP address. + /// </summary> + public override IPAddress Address + { + get + { + return _address; + } + + set + { + _address = value ?? IPAddress.None; + } + } + + /// <inheritdoc/> + public override byte PrefixLength { get; set; } + + /// <summary> + /// Try to parse the address and subnet strings into an IPNetAddress object. + /// </summary> + /// <param name="addr">IP address to parse. Can be CIDR or X.X.X.X notation.</param> + /// <param name="ip">Resultant object.</param> + /// <returns>True if the values parsed successfully. False if not, resulting in the IP being null.</returns> + public static bool TryParse(string addr, out IPNetAddress ip) + { + if (!string.IsNullOrEmpty(addr)) + { + addr = addr.Trim(); + + // Try to parse it as is. + if (IPAddress.TryParse(addr, out IPAddress? res)) + { + ip = new IPNetAddress(res); + return true; + } + + // Is it a network? + string[] tokens = addr.Split('/'); + + if (tokens.Length == 2) + { + tokens[0] = tokens[0].TrimEnd(); + tokens[1] = tokens[1].TrimStart(); + + if (IPAddress.TryParse(tokens[0], out res)) + { + // Is the subnet part a cidr? + if (byte.TryParse(tokens[1], out byte cidr)) + { + ip = new IPNetAddress(res, cidr); + return true; + } + + // Is the subnet in x.y.a.b form? + if (IPAddress.TryParse(tokens[1], out IPAddress? mask)) + { + ip = new IPNetAddress(res, MaskToCidr(mask)); + return true; + } + } + } + } + + ip = None; + return false; + } + + /// <summary> + /// Parses the string provided, throwing an exception if it is badly formed. + /// </summary> + /// <param name="addr">String to parse.</param> + /// <returns>IPNetAddress object.</returns> + public static IPNetAddress Parse(string addr) + { + if (TryParse(addr, out IPNetAddress o)) + { + return o; + } + + throw new ArgumentException("Unable to recognise object :" + addr); + } + + /// <inheritdoc/> + public override bool Contains(IPAddress address) + { + if (address == null) + { + throw new ArgumentNullException(nameof(address)); + } + + if (address.IsIPv4MappedToIPv6) + { + address = address.MapToIPv4(); + } + + var (altAddress, altPrefix) = NetworkAddressOf(address, PrefixLength); + return NetworkAddress.Address.Equals(altAddress) && NetworkAddress.PrefixLength >= altPrefix; + } + + /// <inheritdoc/> + public override bool Contains(IPObject address) + { + if (address is IPHost addressObj && addressObj.HasAddress) + { + foreach (IPAddress addr in addressObj.GetAddresses()) + { + if (Contains(addr)) + { + return true; + } + } + } + else if (address is IPNetAddress netaddrObj) + { + // Have the same network address, but different subnets? + if (NetworkAddress.Address.Equals(netaddrObj.NetworkAddress.Address)) + { + return NetworkAddress.PrefixLength <= netaddrObj.PrefixLength; + } + + var altAddress = NetworkAddressOf(netaddrObj.Address, PrefixLength).address; + return NetworkAddress.Address.Equals(altAddress); + } + + return false; + } + + /// <inheritdoc/> + public override bool Equals(IPObject? other) + { + if (other is IPNetAddress otherObj && !Address.Equals(IPAddress.None) && !otherObj.Address.Equals(IPAddress.None)) + { + return Address.Equals(otherObj.Address) && + PrefixLength == otherObj.PrefixLength; + } + + return false; + } + + /// <inheritdoc/> + public override bool Equals(IPAddress ip) + { + if (ip != null && !ip.Equals(IPAddress.None) && !Address.Equals(IPAddress.None)) + { + return ip.Equals(Address); + } + + return false; + } + + /// <inheritdoc/> + public override string ToString() + { + return ToString(false); + } + + /// <summary> + /// Returns a textual representation of this object. + /// </summary> + /// <param name="shortVersion">Set to true, if the subnet is to be excluded as part of the address.</param> + /// <returns>String representation of this object.</returns> + public string ToString(bool shortVersion) + { + if (!Address.Equals(IPAddress.None)) + { + if (Address.Equals(IPAddress.Any)) + { + return "Any IP4 Address"; + } + + if (Address.Equals(IPAddress.IPv6Any)) + { + return "Any IP6 Address"; + } + + if (Address.Equals(IPAddress.Broadcast)) + { + return "Any Address"; + } + + if (shortVersion) + { + return Address.ToString(); + } + + return $"{Address}/{PrefixLength}"; + } + + return string.Empty; + } + + /// <inheritdoc/> + protected override IPObject CalculateNetworkAddress() + { + var (address, prefixLength) = NetworkAddressOf(_address, PrefixLength); + return new IPNetAddress(address, prefixLength); + } + } +} diff --git a/MediaBrowser.Common/Net/IPObject.cs b/MediaBrowser.Common/Net/IPObject.cs new file mode 100644 index 0000000000..2612268fd8 --- /dev/null +++ b/MediaBrowser.Common/Net/IPObject.cs @@ -0,0 +1,395 @@ +using System; +using System.Net; +using System.Net.Sockets; + +namespace MediaBrowser.Common.Net +{ + /// <summary> + /// Base network object class. + /// </summary> + public abstract class IPObject : IEquatable<IPObject> + { + /// <summary> + /// The network address of this object. + /// </summary> + private IPObject? _networkAddress; + + /// <summary> + /// Gets or sets a user defined value that is associated with this object. + /// </summary> + public int Tag { get; set; } + + /// <summary> + /// Gets or sets the object's IP address. + /// </summary> + public abstract IPAddress Address { get; set; } + + /// <summary> + /// Gets the object's network address. + /// </summary> + public IPObject NetworkAddress => _networkAddress ??= CalculateNetworkAddress(); + + /// <summary> + /// Gets or sets the object's IP address. + /// </summary> + public abstract byte PrefixLength { get; set; } + + /// <summary> + /// Gets the AddressFamily of this object. + /// </summary> + public AddressFamily AddressFamily + { + get + { + // Keep terms separate as Address performs other functions in inherited objects. + IPAddress address = Address; + return address.Equals(IPAddress.None) ? AddressFamily.Unspecified : address.AddressFamily; + } + } + + /// <summary> + /// Returns the network address of an object. + /// </summary> + /// <param name="address">IP Address to convert.</param> + /// <param name="prefixLength">Subnet prefix.</param> + /// <returns>IPAddress.</returns> + public static (IPAddress address, byte prefixLength) NetworkAddressOf(IPAddress address, byte prefixLength) + { + if (address == null) + { + throw new ArgumentNullException(nameof(address)); + } + + if (address.IsIPv4MappedToIPv6) + { + address = address.MapToIPv4(); + } + + if (IsLoopback(address)) + { + return (address, prefixLength); + } + + // An ip address is just a list of bytes, each one representing a segment on the network. + // This separates the IP address into octets and calculates how many octets will need to be altered or set to zero dependant upon the + // prefix length value. eg. /16 on a 4 octet ip4 address (192.168.2.240) will result in the 2 and the 240 being zeroed out. + // Where there is not an exact boundary (eg /23), mod is used to calculate how many bits of this value are to be kept. + + // GetAddressBytes + Span<byte> addressBytes = stackalloc byte[address.AddressFamily == AddressFamily.InterNetwork ? 4 : 16]; + address.TryWriteBytes(addressBytes, out _); + + int div = prefixLength / 8; + int mod = prefixLength % 8; + if (mod != 0) + { + // Prefix length is counted right to left, so subtract 8 so we know how many bits to clear. + mod = 8 - mod; + + // Shift out the bits from the octet that we don't want, by moving right then back left. + addressBytes[div] = (byte)((int)addressBytes[div] >> mod << mod); + // Move on the next byte. + div++; + } + + // Blank out the remaining octets from mod + 1 to the end of the byte array. (192.168.2.240/16 becomes 192.168.0.0) + for (int octet = div; octet < addressBytes.Length; octet++) + { + addressBytes[octet] = 0; + } + + // Return the network address for the prefix. + return (new IPAddress(addressBytes), prefixLength); + } + + /// <summary> + /// Tests to see if the ip address is a Loopback address. + /// </summary> + /// <param name="address">Value to test.</param> + /// <returns>True if it is.</returns> + public static bool IsLoopback(IPAddress address) + { + if (address == null) + { + throw new ArgumentNullException(nameof(address)); + } + + if (!address.Equals(IPAddress.None)) + { + if (address.IsIPv4MappedToIPv6) + { + address = address.MapToIPv4(); + } + + return address.Equals(IPAddress.Loopback) || address.Equals(IPAddress.IPv6Loopback); + } + + return false; + } + + /// <summary> + /// Tests to see if the ip address is an IP6 address. + /// </summary> + /// <param name="address">Value to test.</param> + /// <returns>True if it is.</returns> + public static bool IsIP6(IPAddress address) + { + if (address == null) + { + throw new ArgumentNullException(nameof(address)); + } + + if (address.IsIPv4MappedToIPv6) + { + address = address.MapToIPv4(); + } + + return !address.Equals(IPAddress.None) && (address.AddressFamily == AddressFamily.InterNetworkV6); + } + + /// <summary> + /// Tests to see if the address in the private address range. + /// </summary> + /// <param name="address">Object to test.</param> + /// <returns>True if it contains a private address.</returns> + public static bool IsPrivateAddressRange(IPAddress address) + { + if (address == null) + { + throw new ArgumentNullException(nameof(address)); + } + + if (!address.Equals(IPAddress.None)) + { + if (address.IsIPv4MappedToIPv6) + { + address = address.MapToIPv4(); + } + + if (address.AddressFamily == AddressFamily.InterNetwork) + { + // GetAddressBytes + Span<byte> octet = stackalloc byte[4]; + address.TryWriteBytes(octet, out _); + + return (octet[0] == 10) + || (octet[0] == 172 && octet[1] >= 16 && octet[1] <= 31) // RFC1918 + || (octet[0] == 192 && octet[1] == 168) // RFC1918 + || (octet[0] == 127); // RFC1122 + } + else + { + // GetAddressBytes + Span<byte> octet = stackalloc byte[16]; + address.TryWriteBytes(octet, out _); + + uint word = (uint)(octet[0] << 8) + octet[1]; + + return (word >= 0xfe80 && word <= 0xfebf) // fe80::/10 :Local link. + || (word >= 0xfc00 && word <= 0xfdff); // fc00::/7 :Unique local address. + } + } + + return false; + } + + /// <summary> + /// Returns true if the IPAddress contains an IP6 Local link address. + /// </summary> + /// <param name="address">IPAddress object to check.</param> + /// <returns>True if it is a local link address.</returns> + /// <remarks> + /// See https://stackoverflow.com/questions/6459928/explain-the-instance-properties-of-system-net-ipaddress + /// it appears that the IPAddress.IsIPv6LinkLocal is out of date. + /// </remarks> + public static bool IsIPv6LinkLocal(IPAddress address) + { + if (address == null) + { + throw new ArgumentNullException(nameof(address)); + } + + if (address.IsIPv4MappedToIPv6) + { + address = address.MapToIPv4(); + } + + if (address.AddressFamily != AddressFamily.InterNetworkV6) + { + return false; + } + + // GetAddressBytes + Span<byte> octet = stackalloc byte[16]; + address.TryWriteBytes(octet, out _); + uint word = (uint)(octet[0] << 8) + octet[1]; + + return word >= 0xfe80 && word <= 0xfebf; // fe80::/10 :Local link. + } + + /// <summary> + /// Convert a subnet mask in CIDR notation to a dotted decimal string value. IPv4 only. + /// </summary> + /// <param name="cidr">Subnet mask in CIDR notation.</param> + /// <param name="family">IPv4 or IPv6 family.</param> + /// <returns>String value of the subnet mask in dotted decimal notation.</returns> + public static IPAddress CidrToMask(byte cidr, AddressFamily family) + { + uint addr = 0xFFFFFFFF << (family == AddressFamily.InterNetwork ? 32 : 128 - cidr); + addr = ((addr & 0xff000000) >> 24) + | ((addr & 0x00ff0000) >> 8) + | ((addr & 0x0000ff00) << 8) + | ((addr & 0x000000ff) << 24); + return new IPAddress(addr); + } + + /// <summary> + /// Convert a mask to a CIDR. IPv4 only. + /// https://stackoverflow.com/questions/36954345/get-cidr-from-netmask. + /// </summary> + /// <param name="mask">Subnet mask.</param> + /// <returns>Byte CIDR representing the mask.</returns> + public static byte MaskToCidr(IPAddress mask) + { + if (mask == null) + { + throw new ArgumentNullException(nameof(mask)); + } + + byte cidrnet = 0; + if (!mask.Equals(IPAddress.Any)) + { + // GetAddressBytes + Span<byte> bytes = stackalloc byte[mask.AddressFamily == AddressFamily.InterNetwork ? 4 : 16]; + mask.TryWriteBytes(bytes, out _); + + var zeroed = false; + for (var i = 0; i < bytes.Length; i++) + { + for (int v = bytes[i]; (v & 0xFF) != 0; v <<= 1) + { + if (zeroed) + { + // Invalid netmask. + return (byte)~cidrnet; + } + + if ((v & 0x80) == 0) + { + zeroed = true; + } + else + { + cidrnet++; + } + } + } + } + + return cidrnet; + } + + /// <summary> + /// Tests to see if this object is a Loopback address. + /// </summary> + /// <returns>True if it is.</returns> + public virtual bool IsLoopback() + { + return IsLoopback(Address); + } + + /// <summary> + /// Removes all addresses of a specific type from this object. + /// </summary> + /// <param name="family">Type of address to remove.</param> + public virtual void Remove(AddressFamily family) + { + // This method only performs a function in the IPHost implementation of IPObject. + } + + /// <summary> + /// Tests to see if this object is an IPv6 address. + /// </summary> + /// <returns>True if it is.</returns> + public virtual bool IsIP6() + { + return IsIP6(Address); + } + + /// <summary> + /// Returns true if this IP address is in the RFC private address range. + /// </summary> + /// <returns>True this object has a private address.</returns> + public virtual bool IsPrivateAddressRange() + { + return IsPrivateAddressRange(Address); + } + + /// <summary> + /// Compares this to the object passed as a parameter. + /// </summary> + /// <param name="ip">Object to compare to.</param> + /// <returns>Equality result.</returns> + public virtual bool Equals(IPAddress ip) + { + if (ip != null) + { + if (ip.IsIPv4MappedToIPv6) + { + ip = ip.MapToIPv4(); + } + + return !Address.Equals(IPAddress.None) && Address.Equals(ip); + } + + return false; + } + + /// <summary> + /// Compares this to the object passed as a parameter. + /// </summary> + /// <param name="other">Object to compare to.</param> + /// <returns>Equality result.</returns> + public virtual bool Equals(IPObject? other) + { + if (other != null) + { + return !Address.Equals(IPAddress.None) && Address.Equals(other.Address); + } + + return false; + } + + /// <summary> + /// Compares the address in this object and the address in the object passed as a parameter. + /// </summary> + /// <param name="address">Object's IP address to compare to.</param> + /// <returns>Comparison result.</returns> + public abstract bool Contains(IPObject address); + + /// <summary> + /// Compares the address in this object and the address in the object passed as a parameter. + /// </summary> + /// <param name="address">Object's IP address to compare to.</param> + /// <returns>Comparison result.</returns> + public abstract bool Contains(IPAddress address); + + /// <inheritdoc/> + public override int GetHashCode() + { + return Address.GetHashCode(); + } + + /// <inheritdoc/> + public override bool Equals(object? obj) + { + return Equals(obj as IPObject); + } + + /// <summary> + /// Calculates the network address of this object. + /// </summary> + /// <returns>Returns the network address of this object.</returns> + protected abstract IPObject CalculateNetworkAddress(); + } +} diff --git a/MediaBrowser.Common/Net/NetworkExtensions.cs b/MediaBrowser.Common/Net/NetworkExtensions.cs new file mode 100644 index 0000000000..264bfacb49 --- /dev/null +++ b/MediaBrowser.Common/Net/NetworkExtensions.cs @@ -0,0 +1,260 @@ +using System; +using System.Collections.ObjectModel; +using System.Net; + +namespace MediaBrowser.Common.Net +{ + /// <summary> + /// Defines the <see cref="NetworkExtensions" />. + /// </summary> + public static class NetworkExtensions + { + /// <summary> + /// Add an address to the collection. + /// </summary> + /// <param name="source">The <see cref="Collection{IPObject}"/>.</param> + /// <param name="ip">Item to add.</param> + public static void AddItem(this Collection<IPObject> source, IPAddress ip) + { + if (!source.ContainsAddress(ip)) + { + source.Add(new IPNetAddress(ip, 32)); + } + } + + /// <summary> + /// Adds a network to the collection. + /// </summary> + /// <param name="source">The <see cref="Collection{IPObject}"/>.</param> + /// <param name="item">Item to add.</param> + /// <param name="itemsAreNetworks">If <c>true</c> the values are treated as subnets. + /// If <b>false</b> items are addresses.</param> + public static void AddItem(this Collection<IPObject> source, IPObject item, bool itemsAreNetworks = true) + { + if (!source.ContainsAddress(item) || !itemsAreNetworks) + { + source.Add(item); + } + } + + /// <summary> + /// Converts this object to a string. + /// </summary> + /// <param name="source">The <see cref="Collection{IPObject}"/>.</param> + /// <returns>Returns a string representation of this object.</returns> + public static string AsString(this Collection<IPObject> source) + { + return $"[{string.Join(',', source)}]"; + } + + /// <summary> + /// Returns true if the collection contains an item with the ip address, + /// or the ip address falls within any of the collection's network ranges. + /// </summary> + /// <param name="source">The <see cref="Collection{IPObject}"/>.</param> + /// <param name="item">The item to look for.</param> + /// <returns>True if the collection contains the item.</returns> + public static bool ContainsAddress(this Collection<IPObject> source, IPAddress item) + { + if (source.Count == 0) + { + return false; + } + + if (item == null) + { + throw new ArgumentNullException(nameof(item)); + } + + if (item.IsIPv4MappedToIPv6) + { + item = item.MapToIPv4(); + } + + foreach (var i in source) + { + if (i.Contains(item)) + { + return true; + } + } + + return false; + } + + /// <summary> + /// Returns true if the collection contains an item with the ip address, + /// or the ip address falls within any of the collection's network ranges. + /// </summary> + /// <param name="source">The <see cref="Collection{IPObject}"/>.</param> + /// <param name="item">The item to look for.</param> + /// <returns>True if the collection contains the item.</returns> + public static bool ContainsAddress(this Collection<IPObject> source, IPObject item) + { + if (source.Count == 0) + { + return false; + } + + if (item == null) + { + throw new ArgumentNullException(nameof(item)); + } + + foreach (var i in source) + { + if (i.Contains(item)) + { + return true; + } + } + + return false; + } + + /// <summary> + /// Compares two Collection{IPObject} objects. The order is ignored. + /// </summary> + /// <param name="source">The <see cref="Collection{IPObject}"/>.</param> + /// <param name="dest">Item to compare to.</param> + /// <returns>True if both are equal.</returns> + public static bool Compare(this Collection<IPObject> source, Collection<IPObject> dest) + { + if (dest == null || source.Count != dest.Count) + { + return false; + } + + foreach (var sourceItem in source) + { + bool found = false; + foreach (var destItem in dest) + { + if (sourceItem.Equals(destItem)) + { + found = true; + break; + } + } + + if (!found) + { + return false; + } + } + + return true; + } + + /// <summary> + /// Returns a collection containing the subnets of this collection given. + /// </summary> + /// <param name="source">The <see cref="Collection{IPObject}"/>.</param> + /// <returns>Collection{IPObject} object containing the subnets.</returns> + public static Collection<IPObject> AsNetworks(this Collection<IPObject> source) + { + if (source == null) + { + throw new ArgumentNullException(nameof(source)); + } + + Collection<IPObject> res = new Collection<IPObject>(); + + foreach (IPObject i in source) + { + if (i is IPNetAddress nw) + { + // Add the subnet calculated from the interface address/mask. + var na = nw.NetworkAddress; + na.Tag = i.Tag; + res.AddItem(na); + } + else if (i is IPHost ipHost) + { + // Flatten out IPHost and add all its ip addresses. + foreach (var addr in ipHost.GetAddresses()) + { + IPNetAddress host = new IPNetAddress(addr) + { + Tag = i.Tag + }; + + res.AddItem(host); + } + } + } + + return res; + } + + /// <summary> + /// Excludes all the items from this list that are found in excludeList. + /// </summary> + /// <param name="source">The <see cref="Collection{IPObject}"/>.</param> + /// <param name="excludeList">Items to exclude.</param> + /// <param name="isNetwork">Collection is a network collection.</param> + /// <returns>A new collection, with the items excluded.</returns> + public static Collection<IPObject> Exclude(this Collection<IPObject> source, Collection<IPObject> excludeList, bool isNetwork) + { + if (source.Count == 0 || excludeList == null) + { + return new Collection<IPObject>(source); + } + + Collection<IPObject> results = new Collection<IPObject>(); + + bool found; + foreach (var outer in source) + { + found = false; + + foreach (var inner in excludeList) + { + if (outer.Equals(inner)) + { + found = true; + break; + } + } + + if (!found) + { + results.AddItem(outer, isNetwork); + } + } + + return results; + } + + /// <summary> + /// Returns all items that co-exist in this object and target. + /// </summary> + /// <param name="source">The <see cref="Collection{IPObject}"/>.</param> + /// <param name="target">Collection to compare with.</param> + /// <returns>A collection containing all the matches.</returns> + public static Collection<IPObject> ThatAreContainedInNetworks(this Collection<IPObject> source, Collection<IPObject> target) + { + if (source.Count == 0) + { + return new Collection<IPObject>(); + } + + if (target == null) + { + throw new ArgumentNullException(nameof(target)); + } + + Collection<IPObject> nc = new Collection<IPObject>(); + + foreach (IPObject i in source) + { + if (target.ContainsAddress(i)) + { + nc.AddItem(i); + } + } + + return nc; + } + } +} diff --git a/MediaBrowser.Common/Plugins/BasePlugin.cs b/MediaBrowser.Common/Plugins/BasePlugin.cs index 8545fd5dcf..8972089a8e 100644 --- a/MediaBrowser.Common/Plugins/BasePlugin.cs +++ b/MediaBrowser.Common/Plugins/BasePlugin.cs @@ -1,13 +1,9 @@ -#pragma warning disable SA1402 +#nullable disable using System; using System.IO; using System.Reflection; -using System.Runtime.InteropServices; -using MediaBrowser.Common.Configuration; using MediaBrowser.Model.Plugins; -using MediaBrowser.Model.Serialization; -using Microsoft.Extensions.DependencyInjection; namespace MediaBrowser.Common.Plugins { @@ -56,7 +52,7 @@ namespace MediaBrowser.Common.Plugins /// Gets a value indicating whether the plugin can be uninstalled. /// </summary> public bool CanUninstall => !Path.GetDirectoryName(AssemblyFilePath) - .Equals(Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location), StringComparison.InvariantCulture); + .Equals(Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location), StringComparison.Ordinal); /// <summary> /// Gets the plugin info. @@ -64,14 +60,12 @@ namespace MediaBrowser.Common.Plugins /// <returns>PluginInfo.</returns> public virtual PluginInfo GetPluginInfo() { - var info = new PluginInfo - { - Name = Name, - Version = Version.ToString(), - Description = Description, - Id = Id.ToString(), - CanUninstall = CanUninstall - }; + var info = new PluginInfo( + Name, + Version, + Description, + Id, + CanUninstall); return info; } @@ -83,16 +77,6 @@ namespace MediaBrowser.Common.Plugins { } - /// <inheritdoc /> - public virtual void RegisterServices(IServiceCollection serviceCollection) - { - } - - /// <inheritdoc /> - public virtual void UnregisterServices(IServiceCollection serviceCollection) - { - } - /// <inheritdoc /> public void SetAttributes(string assemblyFilePath, string dataFolderPath, Version assemblyVersion) { @@ -107,189 +91,4 @@ namespace MediaBrowser.Common.Plugins Id = assemblyId; } } - - /// <summary> - /// Provides a common base class for all plugins. - /// </summary> - /// <typeparam name="TConfigurationType">The type of the T configuration type.</typeparam> - public abstract class BasePlugin<TConfigurationType> : BasePlugin, IHasPluginConfiguration - where TConfigurationType : BasePluginConfiguration - { - /// <summary> - /// The configuration sync lock. - /// </summary> - private readonly object _configurationSyncLock = new object(); - - /// <summary> - /// The configuration save lock. - /// </summary> - private readonly object _configurationSaveLock = new object(); - - private Action<string> _directoryCreateFn; - - /// <summary> - /// The configuration. - /// </summary> - private TConfigurationType _configuration; - - /// <summary> - /// Initializes a new instance of the <see cref="BasePlugin{TConfigurationType}" /> class. - /// </summary> - /// <param name="applicationPaths">The application paths.</param> - /// <param name="xmlSerializer">The XML serializer.</param> - protected BasePlugin(IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer) - { - ApplicationPaths = applicationPaths; - XmlSerializer = xmlSerializer; - if (this is IPluginAssembly assemblyPlugin) - { - var assembly = GetType().Assembly; - var assemblyName = assembly.GetName(); - var assemblyFilePath = assembly.Location; - - var dataFolderPath = Path.Combine(ApplicationPaths.PluginsPath, Path.GetFileNameWithoutExtension(assemblyFilePath)); - - assemblyPlugin.SetAttributes(assemblyFilePath, dataFolderPath, assemblyName.Version); - - var idAttributes = assembly.GetCustomAttributes(typeof(GuidAttribute), true); - if (idAttributes.Length > 0) - { - var attribute = (GuidAttribute)idAttributes[0]; - var assemblyId = new Guid(attribute.Value); - - assemblyPlugin.SetId(assemblyId); - } - } - - if (this is IHasPluginConfiguration hasPluginConfiguration) - { - hasPluginConfiguration.SetStartupInfo(s => Directory.CreateDirectory(s)); - } - } - - /// <summary> - /// Gets the application paths. - /// </summary> - /// <value>The application paths.</value> - protected IApplicationPaths ApplicationPaths { get; private set; } - - /// <summary> - /// Gets the XML serializer. - /// </summary> - /// <value>The XML serializer.</value> - protected IXmlSerializer XmlSerializer { get; private set; } - - /// <summary> - /// Gets the type of configuration this plugin uses. - /// </summary> - /// <value>The type of the configuration.</value> - public Type ConfigurationType => typeof(TConfigurationType); - - /// <summary> - /// Gets the name the assembly file. - /// </summary> - /// <value>The name of the assembly file.</value> - protected string AssemblyFileName => Path.GetFileName(AssemblyFilePath); - - /// <summary> - /// Gets or sets the plugin configuration. - /// </summary> - /// <value>The configuration.</value> - public TConfigurationType Configuration - { - get - { - // Lazy load - if (_configuration == null) - { - lock (_configurationSyncLock) - { - if (_configuration == null) - { - _configuration = LoadConfiguration(); - } - } - } - - return _configuration; - } - - protected set => _configuration = value; - } - - /// <summary> - /// Gets the name of the configuration file. Subclasses should override. - /// </summary> - /// <value>The name of the configuration file.</value> - public virtual string ConfigurationFileName => Path.ChangeExtension(AssemblyFileName, ".xml"); - - /// <summary> - /// Gets the full path to the configuration file. - /// </summary> - /// <value>The configuration file path.</value> - public string ConfigurationFilePath => Path.Combine(ApplicationPaths.PluginConfigurationsPath, ConfigurationFileName); - - /// <summary> - /// Gets the plugin configuration. - /// </summary> - /// <value>The configuration.</value> - BasePluginConfiguration IHasPluginConfiguration.Configuration => Configuration; - - /// <inheritdoc /> - public void SetStartupInfo(Action<string> directoryCreateFn) - { - // hack alert, until the .net core transition is complete - _directoryCreateFn = directoryCreateFn; - } - - private TConfigurationType LoadConfiguration() - { - var path = ConfigurationFilePath; - - try - { - return (TConfigurationType)XmlSerializer.DeserializeFromFile(typeof(TConfigurationType), path); - } - catch - { - return (TConfigurationType)Activator.CreateInstance(typeof(TConfigurationType)); - } - } - - /// <summary> - /// Saves the current configuration to the file system. - /// </summary> - public virtual void SaveConfiguration() - { - lock (_configurationSaveLock) - { - _directoryCreateFn(Path.GetDirectoryName(ConfigurationFilePath)); - - XmlSerializer.SerializeToFile(Configuration, ConfigurationFilePath); - } - } - - /// <inheritdoc /> - public virtual void UpdateConfiguration(BasePluginConfiguration configuration) - { - if (configuration == null) - { - throw new ArgumentNullException(nameof(configuration)); - } - - Configuration = (TConfigurationType)configuration; - - SaveConfiguration(); - } - - /// <inheritdoc /> - public override PluginInfo GetPluginInfo() - { - var info = base.GetPluginInfo(); - - info.ConfigurationFileName = ConfigurationFileName; - - return info; - } - } } diff --git a/MediaBrowser.Common/Plugins/BasePluginOfT.cs b/MediaBrowser.Common/Plugins/BasePluginOfT.cs new file mode 100644 index 0000000000..afda83a7c5 --- /dev/null +++ b/MediaBrowser.Common/Plugins/BasePluginOfT.cs @@ -0,0 +1,205 @@ +#nullable disable +#pragma warning disable SA1649 // File name should match first type name + +using System; +using System.IO; +using System.Runtime.InteropServices; +using MediaBrowser.Common.Configuration; +using MediaBrowser.Model.Plugins; +using MediaBrowser.Model.Serialization; + +namespace MediaBrowser.Common.Plugins +{ + /// <summary> + /// Provides a common base class for all plugins. + /// </summary> + /// <typeparam name="TConfigurationType">The type of the T configuration type.</typeparam> + public abstract class BasePlugin<TConfigurationType> : BasePlugin, IHasPluginConfiguration + where TConfigurationType : BasePluginConfiguration + { + /// <summary> + /// The configuration sync lock. + /// </summary> + private readonly object _configurationSyncLock = new object(); + + /// <summary> + /// The configuration save lock. + /// </summary> + private readonly object _configurationSaveLock = new object(); + + /// <summary> + /// The configuration. + /// </summary> + private TConfigurationType _configuration; + + /// <summary> + /// Initializes a new instance of the <see cref="BasePlugin{TConfigurationType}" /> class. + /// </summary> + /// <param name="applicationPaths">The application paths.</param> + /// <param name="xmlSerializer">The XML serializer.</param> + protected BasePlugin(IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer) + { + ApplicationPaths = applicationPaths; + XmlSerializer = xmlSerializer; + + var assembly = GetType().Assembly; + var assemblyName = assembly.GetName(); + var assemblyFilePath = assembly.Location; + + var dataFolderPath = Path.Combine(ApplicationPaths.PluginsPath, Path.GetFileNameWithoutExtension(assemblyFilePath)); + if (Version != null && !Directory.Exists(dataFolderPath)) + { + // Try again with the version number appended to the folder name. + dataFolderPath += "_" + Version.ToString(); + } + + SetAttributes(assemblyFilePath, dataFolderPath, assemblyName.Version); + + var idAttributes = assembly.GetCustomAttributes(typeof(GuidAttribute), true); + if (idAttributes.Length > 0) + { + var attribute = (GuidAttribute)idAttributes[0]; + var assemblyId = new Guid(attribute.Value); + + SetId(assemblyId); + } + } + + /// <summary> + /// Gets the application paths. + /// </summary> + /// <value>The application paths.</value> + protected IApplicationPaths ApplicationPaths { get; private set; } + + /// <summary> + /// Gets the XML serializer. + /// </summary> + /// <value>The XML serializer.</value> + protected IXmlSerializer XmlSerializer { get; private set; } + + /// <summary> + /// Gets the type of configuration this plugin uses. + /// </summary> + /// <value>The type of the configuration.</value> + public Type ConfigurationType => typeof(TConfigurationType); + + /// <summary> + /// Gets or sets the event handler that is triggered when this configuration changes. + /// </summary> + public EventHandler<BasePluginConfiguration> ConfigurationChanged { get; set; } + + /// <summary> + /// Gets the name the assembly file. + /// </summary> + /// <value>The name of the assembly file.</value> + protected string AssemblyFileName => Path.GetFileName(AssemblyFilePath); + + /// <summary> + /// Gets or sets the plugin configuration. + /// </summary> + /// <value>The configuration.</value> + public TConfigurationType Configuration + { + get + { + // Lazy load + if (_configuration == null) + { + lock (_configurationSyncLock) + { + _configuration ??= LoadConfiguration(); + } + } + + return _configuration; + } + + protected set => _configuration = value; + } + + /// <summary> + /// Gets the name of the configuration file. Subclasses should override. + /// </summary> + /// <value>The name of the configuration file.</value> + public virtual string ConfigurationFileName => Path.ChangeExtension(AssemblyFileName, ".xml"); + + /// <summary> + /// Gets the full path to the configuration file. + /// </summary> + /// <value>The configuration file path.</value> + public string ConfigurationFilePath => Path.Combine(ApplicationPaths.PluginConfigurationsPath, ConfigurationFileName); + + /// <summary> + /// Gets the plugin configuration. + /// </summary> + /// <value>The configuration.</value> + BasePluginConfiguration IHasPluginConfiguration.Configuration => Configuration; + + /// <summary> + /// Saves the current configuration to the file system. + /// </summary> + /// <param name="config">Configuration to save.</param> + public virtual void SaveConfiguration(TConfigurationType config) + { + lock (_configurationSaveLock) + { + var folder = Path.GetDirectoryName(ConfigurationFilePath); + if (!Directory.Exists(folder)) + { + Directory.CreateDirectory(folder); + } + + XmlSerializer.SerializeToFile(config, ConfigurationFilePath); + } + } + + /// <summary> + /// Saves the current configuration to the file system. + /// </summary> + public virtual void SaveConfiguration() + { + SaveConfiguration(Configuration); + } + + /// <inheritdoc /> + public virtual void UpdateConfiguration(BasePluginConfiguration configuration) + { + if (configuration == null) + { + throw new ArgumentNullException(nameof(configuration)); + } + + Configuration = (TConfigurationType)configuration; + + SaveConfiguration(Configuration); + + ConfigurationChanged?.Invoke(this, configuration); + } + + /// <inheritdoc /> + public override PluginInfo GetPluginInfo() + { + var info = base.GetPluginInfo(); + + info.ConfigurationFileName = ConfigurationFileName; + + return info; + } + + private TConfigurationType LoadConfiguration() + { + var path = ConfigurationFilePath; + + try + { + return (TConfigurationType)XmlSerializer.DeserializeFromFile(typeof(TConfigurationType), path); + } + catch + { + var config = (TConfigurationType)Activator.CreateInstance(typeof(TConfigurationType)); + SaveConfiguration(config); + return config; + } + } + } +} diff --git a/MediaBrowser.Common/Plugins/IHasPluginConfiguration.cs b/MediaBrowser.Common/Plugins/IHasPluginConfiguration.cs new file mode 100644 index 0000000000..af9272caae --- /dev/null +++ b/MediaBrowser.Common/Plugins/IHasPluginConfiguration.cs @@ -0,0 +1,27 @@ +using System; +using MediaBrowser.Model.Plugins; + +namespace MediaBrowser.Common.Plugins +{ + /// <summary> + /// Defines the <see cref="IHasPluginConfiguration" />. + /// </summary> + public interface IHasPluginConfiguration + { + /// <summary> + /// Gets the type of configuration this plugin uses. + /// </summary> + Type ConfigurationType { get; } + + /// <summary> + /// Gets the plugin's configuration. + /// </summary> + BasePluginConfiguration Configuration { get; } + + /// <summary> + /// Completely overwrites the current configuration with a new copy. + /// </summary> + /// <param name="configuration">The configuration.</param> + void UpdateConfiguration(BasePluginConfiguration configuration); + } +} diff --git a/MediaBrowser.Common/Plugins/IPlugin.cs b/MediaBrowser.Common/Plugins/IPlugin.cs index 1844eb124f..01e0a536da 100644 --- a/MediaBrowser.Common/Plugins/IPlugin.cs +++ b/MediaBrowser.Common/Plugins/IPlugin.cs @@ -1,44 +1,38 @@ -#pragma warning disable CS1591 +#nullable disable using System; using MediaBrowser.Model.Plugins; -using Microsoft.Extensions.DependencyInjection; namespace MediaBrowser.Common.Plugins { /// <summary> - /// Interface IPlugin. + /// Defines the <see cref="IPlugin" />. /// </summary> public interface IPlugin { /// <summary> /// Gets the name of the plugin. /// </summary> - /// <value>The name.</value> string Name { get; } /// <summary> - /// Gets the description. + /// Gets the Description. /// </summary> - /// <value>The description.</value> string Description { get; } /// <summary> /// Gets the unique id. /// </summary> - /// <value>The unique id.</value> Guid Id { get; } /// <summary> /// Gets the plugin version. /// </summary> - /// <value>The version.</value> Version Version { get; } /// <summary> /// Gets the path to the assembly file. /// </summary> - /// <value>The assembly file path.</value> string AssemblyFilePath { get; } /// <summary> @@ -49,11 +43,10 @@ namespace MediaBrowser.Common.Plugins /// <summary> /// Gets the full path to the data folder, where the plugin can store any miscellaneous files needed. /// </summary> - /// <value>The data folder path.</value> string DataFolderPath { get; } /// <summary> - /// Gets the plugin info. + /// Gets the <see cref="PluginInfo"/>. /// </summary> /// <returns>PluginInfo.</returns> PluginInfo GetPluginInfo(); @@ -62,42 +55,5 @@ namespace MediaBrowser.Common.Plugins /// Called when just before the plugin is uninstalled from the server. /// </summary> void OnUninstalling(); - - /// <summary> - /// Registers the plugin's services to the service collection. - /// </summary> - /// <param name="serviceCollection">The service collection.</param> - void RegisterServices(IServiceCollection serviceCollection); - - /// <summary> - /// Unregisters the plugin's services from the service collection. - /// </summary> - /// <param name="serviceCollection">The service collection.</param> - void UnregisterServices(IServiceCollection serviceCollection); - } - - public interface IHasPluginConfiguration - { - /// <summary> - /// Gets the type of configuration this plugin uses. - /// </summary> - /// <value>The type of the configuration.</value> - Type ConfigurationType { get; } - - /// <summary> - /// Gets the plugin's configuration. - /// </summary> - /// <value>The configuration.</value> - BasePluginConfiguration Configuration { get; } - - /// <summary> - /// Completely overwrites the current configuration with a new copy - /// Returns true or false indicating success or failure. - /// </summary> - /// <param name="configuration">The configuration.</param> - /// <exception cref="ArgumentNullException"><c>configuration</c> is <c>null</c>.</exception> - void UpdateConfiguration(BasePluginConfiguration configuration); - - void SetStartupInfo(Action<string> directoryCreateFn); } } diff --git a/MediaBrowser.Common/Plugins/IPluginManager.cs b/MediaBrowser.Common/Plugins/IPluginManager.cs new file mode 100644 index 0000000000..176bcbbd54 --- /dev/null +++ b/MediaBrowser.Common/Plugins/IPluginManager.cs @@ -0,0 +1,96 @@ +using System; +using System.Collections.Generic; +using System.Reflection; +using System.Threading.Tasks; +using MediaBrowser.Model.Plugins; +using MediaBrowser.Model.Updates; +using Microsoft.Extensions.DependencyInjection; + +namespace MediaBrowser.Common.Plugins +{ + /// <summary> + /// Defines the <see cref="IPluginManager" />. + /// </summary> + public interface IPluginManager + { + /// <summary> + /// Gets the Plugins. + /// </summary> + IReadOnlyList<LocalPlugin> Plugins { get; } + + /// <summary> + /// Creates the plugins. + /// </summary> + void CreatePlugins(); + + /// <summary> + /// Returns all the assemblies. + /// </summary> + /// <returns>An IEnumerable{Assembly}.</returns> + IEnumerable<Assembly> LoadAssemblies(); + + /// <summary> + /// Registers the plugin's services with the DI. + /// Note: DI is not yet instantiated yet. + /// </summary> + /// <param name="serviceCollection">A <see cref="ServiceCollection"/> instance.</param> + void RegisterServices(IServiceCollection serviceCollection); + + /// <summary> + /// Saves the manifest back to disk. + /// </summary> + /// <param name="manifest">The <see cref="PluginManifest"/> to save.</param> + /// <param name="path">The path where to save the manifest.</param> + /// <returns>True if successful.</returns> + bool SaveManifest(PluginManifest manifest, string path); + + /// <summary> + /// Generates a manifest from repository data. + /// </summary> + /// <param name="packageInfo">The <see cref="PackageInfo"/> used to generate a manifest.</param> + /// <param name="version">Version to be installed.</param> + /// <param name="path">The path where to save the manifest.</param> + /// <param name="status">Initial status of the plugin.</param> + /// <returns>True if successful.</returns> + Task<bool> GenerateManifest(PackageInfo packageInfo, Version version, string path, PluginStatus status); + + /// <summary> + /// Imports plugin details from a folder. + /// </summary> + /// <param name="folder">Folder of the plugin.</param> + void ImportPluginFrom(string folder); + + /// <summary> + /// Disable the plugin. + /// </summary> + /// <param name="assembly">The <see cref="Assembly"/> of the plug to disable.</param> + void FailPlugin(Assembly assembly); + + /// <summary> + /// Disable the plugin. + /// </summary> + /// <param name="plugin">The <see cref="LocalPlugin"/> of the plug to disable.</param> + void DisablePlugin(LocalPlugin plugin); + + /// <summary> + /// Enables the plugin, disabling all other versions. + /// </summary> + /// <param name="plugin">The <see cref="LocalPlugin"/> of the plug to disable.</param> + void EnablePlugin(LocalPlugin plugin); + + /// <summary> + /// Attempts to find the plugin with and id of <paramref name="id"/>. + /// </summary> + /// <param name="id">Id of plugin.</param> + /// <param name="version">The version of the plugin to locate.</param> + /// <returns>A <see cref="LocalPlugin"/> if located, or null if not.</returns> + LocalPlugin? GetPlugin(Guid id, Version? version = null); + + /// <summary> + /// Removes the plugin. + /// </summary> + /// <param name="plugin">The plugin.</param> + /// <returns>Outcome of the operation.</returns> + bool RemovePlugin(LocalPlugin plugin); + } +} diff --git a/MediaBrowser.Common/Plugins/IPluginServiceRegistrator.cs b/MediaBrowser.Common/Plugins/IPluginServiceRegistrator.cs new file mode 100644 index 0000000000..3afe874c52 --- /dev/null +++ b/MediaBrowser.Common/Plugins/IPluginServiceRegistrator.cs @@ -0,0 +1,19 @@ +namespace MediaBrowser.Common.Plugins +{ + using Microsoft.Extensions.DependencyInjection; + + /// <summary> + /// Defines the <see cref="IPluginServiceRegistrator" />. + /// </summary> + public interface IPluginServiceRegistrator + { + /// <summary> + /// Registers the plugin's services with the service collection. + /// </summary> + /// <remarks> + /// This interface is only used for service registration and requires a parameterless constructor. + /// </remarks> + /// <param name="serviceCollection">The service collection.</param> + void RegisterServices(IServiceCollection serviceCollection); + } +} diff --git a/MediaBrowser.Common/Plugins/LocalPlugin.cs b/MediaBrowser.Common/Plugins/LocalPlugin.cs new file mode 100644 index 0000000000..4c8e2d5044 --- /dev/null +++ b/MediaBrowser.Common/Plugins/LocalPlugin.cs @@ -0,0 +1,139 @@ +using System; +using System.Collections.Generic; +using MediaBrowser.Model.Plugins; + +namespace MediaBrowser.Common.Plugins +{ + /// <summary> + /// Local plugin class. + /// </summary> + public class LocalPlugin : IEquatable<LocalPlugin> + { + private readonly bool _supported; + private Version? _version; + + /// <summary> + /// Initializes a new instance of the <see cref="LocalPlugin"/> class. + /// </summary> + /// <param name="path">The plugin path.</param> + /// <param name="isSupported"><b>True</b> if Jellyfin supports this version of the plugin.</param> + /// <param name="manifest">The manifest record for this plugin, or null if one does not exist.</param> + public LocalPlugin(string path, bool isSupported, PluginManifest manifest) + { + Path = path; + DllFiles = Array.Empty<string>(); + _supported = isSupported; + Manifest = manifest; + } + + /// <summary> + /// Gets the plugin id. + /// </summary> + public Guid Id => Manifest.Id; + + /// <summary> + /// Gets the plugin name. + /// </summary> + public string Name => Manifest.Name; + + /// <summary> + /// Gets the plugin version. + /// </summary> + public Version Version + { + get + { + if (_version == null) + { + _version = Version.Parse(Manifest.Version); + } + + return _version; + } + } + + /// <summary> + /// Gets the plugin path. + /// </summary> + public string Path { get; } + + /// <summary> + /// Gets or sets the list of dll files for this plugin. + /// </summary> + public IReadOnlyList<string> DllFiles { get; set; } + + /// <summary> + /// Gets or sets the instance of this plugin. + /// </summary> + public IPlugin? Instance { get; set; } + + /// <summary> + /// Gets a value indicating whether Jellyfin supports this version of the plugin, and it's enabled. + /// </summary> + public bool IsEnabledAndSupported => _supported && Manifest.Status >= PluginStatus.Active; + + /// <summary> + /// Gets a value indicating whether the plugin has a manifest. + /// </summary> + public PluginManifest Manifest { get; } + + /// <summary> + /// Compare two <see cref="LocalPlugin"/>. + /// </summary> + /// <param name="a">The first item.</param> + /// <param name="b">The second item.</param> + /// <returns>Comparison result.</returns> + public static int Compare(LocalPlugin a, LocalPlugin b) + { + if (a == null || b == null) + { + throw new ArgumentNullException(a == null ? nameof(a) : nameof(b)); + } + + var compare = string.Compare(a.Name, b.Name, StringComparison.OrdinalIgnoreCase); + + // Id is not equal but name is. + if (!a.Id.Equals(b.Id) && compare == 0) + { + compare = a.Id.CompareTo(b.Id); + } + + return compare == 0 ? a.Version.CompareTo(b.Version) : compare; + } + + /// <summary> + /// Returns the plugin information. + /// </summary> + /// <returns>A <see cref="PluginInfo"/> instance containing the information.</returns> + public PluginInfo GetPluginInfo() + { + var inst = Instance?.GetPluginInfo() ?? new PluginInfo(Manifest.Name, Version, Manifest.Description, Manifest.Id, true); + inst.Status = Manifest.Status; + inst.HasImage = !string.IsNullOrEmpty(Manifest.ImagePath); + return inst; + } + + /// <inheritdoc /> + public override bool Equals(object? obj) + { + return obj is LocalPlugin other && this.Equals(other); + } + + /// <inheritdoc /> + public override int GetHashCode() + { + return Name.GetHashCode(StringComparison.OrdinalIgnoreCase); + } + + /// <inheritdoc /> + public bool Equals(LocalPlugin? other) + { + if (other == null) + { + return false; + } + + return Name.Equals(other.Name, StringComparison.OrdinalIgnoreCase) && Id.Equals(other.Id) && Version.Equals(other.Version); + } + } +} diff --git a/MediaBrowser.Common/Plugins/PluginManifest.cs b/MediaBrowser.Common/Plugins/PluginManifest.cs new file mode 100644 index 0000000000..2910dbe144 --- /dev/null +++ b/MediaBrowser.Common/Plugins/PluginManifest.cs @@ -0,0 +1,108 @@ +using System; +using System.Text.Json.Serialization; +using MediaBrowser.Model.Plugins; + +namespace MediaBrowser.Common.Plugins +{ + /// <summary> + /// Defines a Plugin manifest file. + /// </summary> + public class PluginManifest + { + /// <summary> + /// Initializes a new instance of the <see cref="PluginManifest"/> class. + /// </summary> + public PluginManifest() + { + Category = string.Empty; + Changelog = string.Empty; + Description = string.Empty; + Id = Guid.Empty; + Name = string.Empty; + Owner = string.Empty; + Overview = string.Empty; + TargetAbi = string.Empty; + Version = string.Empty; + } + + /// <summary> + /// Gets or sets the category of the plugin. + /// </summary> + [JsonPropertyName("category")] + public string Category { get; set; } + + /// <summary> + /// Gets or sets the changelog information. + /// </summary> + [JsonPropertyName("changelog")] + public string Changelog { get; set; } + + /// <summary> + /// Gets or sets the description of the plugin. + /// </summary> + [JsonPropertyName("description")] + public string Description { get; set; } + + /// <summary> + /// Gets or sets the Global Unique Identifier for the plugin. + /// </summary> + [JsonPropertyName("guid")] + public Guid Id { get; set; } + + /// <summary> + /// Gets or sets the Name of the plugin. + /// </summary> + [JsonPropertyName("name")] + public string Name { get; set; } + + /// <summary> + /// Gets or sets an overview of the plugin. + /// </summary> + [JsonPropertyName("overview")] + public string Overview { get; set; } + + /// <summary> + /// Gets or sets the owner of the plugin. + /// </summary> + [JsonPropertyName("owner")] + public string Owner { get; set; } + + /// <summary> + /// Gets or sets the compatibility version for the plugin. + /// </summary> + [JsonPropertyName("targetAbi")] + public string TargetAbi { get; set; } + + /// <summary> + /// Gets or sets the timestamp of the plugin. + /// </summary> + [JsonPropertyName("timestamp")] + public DateTime Timestamp { get; set; } + + /// <summary> + /// Gets or sets the Version number of the plugin. + /// </summary> + [JsonPropertyName("version")] + public string Version { get; set; } + + /// <summary> + /// Gets or sets a value indicating the operational status of this plugin. + /// </summary> + [JsonPropertyName("status")] + public PluginStatus Status { get; set; } + + /// <summary> + /// Gets or sets a value indicating whether this plugin should automatically update. + /// </summary> + [JsonPropertyName("autoUpdate")] + public bool AutoUpdate { get; set; } = true; // DO NOT MOVE THIS INTO THE CONSTRUCTOR. + + /// <summary> + /// Gets or sets the ImagePath + /// Gets or sets a value indicating whether this plugin has an image. + /// Image must be located in the local plugin folder. + /// </summary> + [JsonPropertyName("imagePath")] + public string? ImagePath { get; set; } + } +} diff --git a/MediaBrowser.Common/Progress/ActionableProgress.cs b/MediaBrowser.Common/Progress/ActionableProgress.cs index d5bcd5be96..0ba46ea3ba 100644 --- a/MediaBrowser.Common/Progress/ActionableProgress.cs +++ b/MediaBrowser.Common/Progress/ActionableProgress.cs @@ -1,4 +1,5 @@ #pragma warning disable CS1591 +#pragma warning disable CA1003 using System; @@ -13,9 +14,9 @@ namespace MediaBrowser.Common.Progress /// <summary> /// The _actions. /// </summary> - private Action<T> _action; + private Action<T>? _action; - public event EventHandler<T> ProgressChanged; + public event EventHandler<T>? ProgressChanged; /// <summary> /// Registers the action. diff --git a/MediaBrowser.Common/Progress/SimpleProgress.cs b/MediaBrowser.Common/Progress/SimpleProgress.cs index d75675bf17..7071f2bc3d 100644 --- a/MediaBrowser.Common/Progress/SimpleProgress.cs +++ b/MediaBrowser.Common/Progress/SimpleProgress.cs @@ -1,4 +1,5 @@ #pragma warning disable CS1591 +#pragma warning disable CA1003 using System; @@ -6,7 +7,7 @@ namespace MediaBrowser.Common.Progress { public class SimpleProgress<T> : IProgress<T> { - public event EventHandler<T> ProgressChanged; + public event EventHandler<T>? ProgressChanged; public void Report(T value) { diff --git a/MediaBrowser.Common/Providers/ProviderIdParsers.cs b/MediaBrowser.Common/Providers/ProviderIdParsers.cs new file mode 100644 index 0000000000..487b5a6d29 --- /dev/null +++ b/MediaBrowser.Common/Providers/ProviderIdParsers.cs @@ -0,0 +1,123 @@ +using System; +using System.Diagnostics.CodeAnalysis; + +namespace MediaBrowser.Common.Providers +{ + /// <summary> + /// Parsers for provider ids. + /// </summary> + public static class ProviderIdParsers + { + private const int ImdbMinNumbers = 7; + private const int ImdbMaxNumbers = 8; + private const string ImdbPrefix = "tt"; + + /// <summary> + /// Parses an IMDb id from a string. + /// </summary> + /// <param name="text">The text to parse.</param> + /// <param name="imdbId">The parsed IMDb id.</param> + /// <returns>True if parsing was successful, false otherwise.</returns> + public static bool TryFindImdbId(ReadOnlySpan<char> text, out ReadOnlySpan<char> imdbId) + { + // imdb id is at least 9 chars (tt + 7 numbers) + while (text.Length >= 2 + ImdbMinNumbers) + { + var ttPos = text.IndexOf(ImdbPrefix); + if (ttPos == -1) + { + imdbId = default; + return false; + } + + text = text.Slice(ttPos); + var i = 2; + var limit = Math.Min(text.Length, ImdbMaxNumbers + 2); + for (; i < limit; i++) + { + var c = text[i]; + if (!IsDigit(c)) + { + break; + } + } + + // skip if more than 8 digits + 2 chars for tt + if (i <= ImdbMaxNumbers + 2 && i >= ImdbMinNumbers + 2) + { + imdbId = text.Slice(0, i); + return true; + } + + text = text.Slice(i); + } + + imdbId = default; + return false; + } + + /// <summary> + /// Parses an TMDb id from a movie url. + /// </summary> + /// <param name="text">The text with the url to parse.</param> + /// <param name="tmdbId">The parsed TMDb id.</param> + /// <returns>True if parsing was successful, false otherwise.</returns> + public static bool TryFindTmdbMovieId(ReadOnlySpan<char> text, out ReadOnlySpan<char> tmdbId) + => TryFindProviderId(text, "themoviedb.org/movie/", out tmdbId); + + /// <summary> + /// Parses an TMDb id from a series url. + /// </summary> + /// <param name="text">The text with the url to parse.</param> + /// <param name="tmdbId">The parsed TMDb id.</param> + /// <returns>True if parsing was successful, false otherwise.</returns> + public static bool TryFindTmdbSeriesId(ReadOnlySpan<char> text, out ReadOnlySpan<char> tmdbId) + => TryFindProviderId(text, "themoviedb.org/tv/", out tmdbId); + + /// <summary> + /// Parses an TVDb id from a url. + /// </summary> + /// <param name="text">The text with the url to parse.</param> + /// <param name="tvdbId">The parsed TVDb id.</param> + /// <returns>True if parsing was successful, false otherwise.</returns> + public static bool TryFindTvdbId(ReadOnlySpan<char> text, out ReadOnlySpan<char> tvdbId) + => TryFindProviderId(text, "thetvdb.com/?tab=series&id=", out tvdbId); + + private static bool TryFindProviderId(ReadOnlySpan<char> text, ReadOnlySpan<char> searchString, [NotNullWhen(true)] out ReadOnlySpan<char> providerId) + { + var searchPos = text.IndexOf(searchString); + if (searchPos == -1) + { + providerId = default; + return false; + } + + text = text.Slice(searchPos + searchString.Length); + + int i = 0; + for (; i < text.Length; i++) + { + var c = text[i]; + + if (!IsDigit(c)) + { + break; + } + } + + if (i >= 1) + { + providerId = text.Slice(0, i); + return true; + } + + providerId = default; + return false; + } + + private static bool IsDigit(char c) + { + return c >= '0' && c <= '9'; + } + } +} diff --git a/MediaBrowser.Common/Updates/IInstallationManager.cs b/MediaBrowser.Common/Updates/IInstallationManager.cs index 169aca2ca0..458494bdc3 100644 --- a/MediaBrowser.Common/Updates/IInstallationManager.cs +++ b/MediaBrowser.Common/Updates/IInstallationManager.cs @@ -1,5 +1,3 @@ -#pragma warning disable CS1591 - using System; using System.Collections.Generic; using System.Threading; @@ -9,31 +7,11 @@ using MediaBrowser.Model.Updates; namespace MediaBrowser.Common.Updates { + /// <summary> + /// Defines the <see cref="IInstallationManager" />. + /// </summary> public interface IInstallationManager : IDisposable { - event EventHandler<InstallationInfo> PackageInstalling; - - event EventHandler<InstallationInfo> PackageInstallationCompleted; - - event EventHandler<InstallationFailedEventArgs> PackageInstallationFailed; - - event EventHandler<InstallationInfo> PackageInstallationCancelled; - - /// <summary> - /// Occurs when a plugin is uninstalled. - /// </summary> - event EventHandler<IPlugin> PluginUninstalled; - - /// <summary> - /// Occurs when a plugin is updated. - /// </summary> - event EventHandler<InstallationInfo> PluginUpdated; - - /// <summary> - /// Occurs when a plugin is installed. - /// </summary> - event EventHandler<InstallationInfo> PluginInstalled; - /// <summary> /// Gets the completed installations. /// </summary> @@ -42,13 +20,15 @@ namespace MediaBrowser.Common.Updates /// <summary> /// Parses a plugin manifest at the supplied URL. /// </summary> + /// <param name="manifestName">Name of the repository.</param> /// <param name="manifest">The URL to query.</param> + /// <param name="filterIncompatible">Filter out incompatible plugins.</param> /// <param name="cancellationToken">The cancellation token.</param> /// <returns>Task{IReadOnlyList{PackageInfo}}.</returns> - Task<IReadOnlyList<PackageInfo>> GetPackages(string manifest, CancellationToken cancellationToken = default); + Task<PackageInfo[]> GetPackages(string manifestName, string manifest, bool filterIncompatible, CancellationToken cancellationToken = default); /// <summary> - /// Gets all available packages. + /// Gets all available packages that are supported by this version. /// </summary> /// <param name="cancellationToken">The cancellation token.</param> /// <returns>Task{IReadOnlyList{PackageInfo}}.</returns> @@ -59,31 +39,33 @@ namespace MediaBrowser.Common.Updates /// </summary> /// <param name="availablePackages">The available packages.</param> /// <param name="name">The name of the plugin.</param> - /// <param name="guid">The id of the plugin.</param> + /// <param name="id">The id of the plugin.</param> + /// <param name="specificVersion">The version of the plugin.</param> /// <returns>All plugins matching the requirements.</returns> IEnumerable<PackageInfo> FilterPackages( IEnumerable<PackageInfo> availablePackages, - string name = null, - Guid guid = default); + string? name = null, + Guid id = default, + Version? specificVersion = null); /// <summary> /// Returns all compatible versions ordered from newest to oldest. /// </summary> /// <param name="availablePackages">The available packages.</param> /// <param name="name">The name.</param> - /// <param name="guid">The guid of the plugin.</param> + /// <param name="id">The id of the plugin.</param> /// <param name="minVersion">The minimum required version of the plugin.</param> /// <param name="specificVersion">The specific version of the plugin to install.</param> /// <returns>All compatible versions ordered from newest to oldest.</returns> IEnumerable<InstallationInfo> GetCompatibleVersions( IEnumerable<PackageInfo> availablePackages, - string name = null, - Guid guid = default, - Version minVersion = null, - Version specificVersion = null); + string? name = null, + Guid id = default, + Version? minVersion = null, + Version? specificVersion = null); /// <summary> - /// Returns the available plugin updates. + /// Returns the available compatible plugin updates. /// </summary> /// <param name="cancellationToken">The cancellation token.</param> /// <returns>The available plugin updates.</returns> @@ -101,7 +83,7 @@ namespace MediaBrowser.Common.Updates /// Uninstalls a plugin. /// </summary> /// <param name="plugin">The plugin.</param> - void UninstallPlugin(IPlugin plugin); + void UninstallPlugin(LocalPlugin plugin); /// <summary> /// Cancels the installation. diff --git a/MediaBrowser.Common/Updates/InstallationEventArgs.cs b/MediaBrowser.Common/Updates/InstallationEventArgs.cs index 61178f631c..f4f7599559 100644 --- a/MediaBrowser.Common/Updates/InstallationEventArgs.cs +++ b/MediaBrowser.Common/Updates/InstallationEventArgs.cs @@ -1,14 +1,23 @@ -#pragma warning disable CS1591 +#nullable disable using System; using MediaBrowser.Model.Updates; namespace MediaBrowser.Common.Updates { + /// <summary> + /// Defines the <see cref="InstallationEventArgs" />. + /// </summary> public class InstallationEventArgs : EventArgs { + /// <summary> + /// Gets or sets the <see cref="InstallationInfo"/>. + /// </summary> public InstallationInfo InstallationInfo { get; set; } + /// <summary> + /// Gets or sets the <see cref="VersionInfo"/>. + /// </summary> public VersionInfo VersionInfo { get; set; } } } diff --git a/MediaBrowser.Common/Updates/InstallationFailedEventArgs.cs b/MediaBrowser.Common/Updates/InstallationFailedEventArgs.cs index 46f10c84fd..d37146195c 100644 --- a/MediaBrowser.Common/Updates/InstallationFailedEventArgs.cs +++ b/MediaBrowser.Common/Updates/InstallationFailedEventArgs.cs @@ -1,3 +1,4 @@ +#nullable disable #pragma warning disable CS1591 using System; diff --git a/MediaBrowser.Controller/Authentication/AuthenticationResult.cs b/MediaBrowser.Controller/Authentication/AuthenticationResult.cs index 4249a9a667..635e4eb3d7 100644 --- a/MediaBrowser.Controller/Authentication/AuthenticationResult.cs +++ b/MediaBrowser.Controller/Authentication/AuthenticationResult.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using MediaBrowser.Controller.Session; diff --git a/MediaBrowser.Controller/Authentication/IAuthenticationProvider.cs b/MediaBrowser.Controller/Authentication/IAuthenticationProvider.cs index ecdffa2ebe..a56d3c8223 100644 --- a/MediaBrowser.Controller/Authentication/IAuthenticationProvider.cs +++ b/MediaBrowser.Controller/Authentication/IAuthenticationProvider.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System.Threading.Tasks; diff --git a/MediaBrowser.Controller/Authentication/IPasswordResetProvider.cs b/MediaBrowser.Controller/Authentication/IPasswordResetProvider.cs index 6729b91157..8c9d1baf88 100644 --- a/MediaBrowser.Controller/Authentication/IPasswordResetProvider.cs +++ b/MediaBrowser.Controller/Authentication/IPasswordResetProvider.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System; diff --git a/MediaBrowser.Controller/BaseItemManager/BaseItemManager.cs b/MediaBrowser.Controller/BaseItemManager/BaseItemManager.cs new file mode 100644 index 0000000000..abfdb41d80 --- /dev/null +++ b/MediaBrowser.Controller/BaseItemManager/BaseItemManager.cs @@ -0,0 +1,137 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Threading; +using Jellyfin.Extensions; +using MediaBrowser.Controller.Channels; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Model.Configuration; + +namespace MediaBrowser.Controller.BaseItemManager +{ + /// <inheritdoc /> + public class BaseItemManager : IBaseItemManager + { + private readonly IServerConfigurationManager _serverConfigurationManager; + + private int _metadataRefreshConcurrency; + + /// <summary> + /// Initializes a new instance of the <see cref="BaseItemManager"/> class. + /// </summary> + /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param> + public BaseItemManager(IServerConfigurationManager serverConfigurationManager) + { + _serverConfigurationManager = serverConfigurationManager; + + _metadataRefreshConcurrency = GetMetadataRefreshConcurrency(); + SetupMetadataThrottler(); + + _serverConfigurationManager.ConfigurationUpdated += OnConfigurationUpdated; + } + + /// <inheritdoc /> + public SemaphoreSlim MetadataRefreshThrottler { get; private set; } + + /// <inheritdoc /> + public bool IsMetadataFetcherEnabled(BaseItem baseItem, LibraryOptions libraryOptions, string name) + { + if (baseItem is Channel) + { + // Hack alert. + return true; + } + + if (baseItem.SourceType == SourceType.Channel) + { + // Hack alert. + return !baseItem.EnableMediaSourceDisplay; + } + + var typeOptions = libraryOptions.GetTypeOptions(baseItem.GetType().Name); + if (typeOptions != null) + { + return typeOptions.MetadataFetchers.Contains(name.AsSpan(), StringComparison.OrdinalIgnoreCase); + } + + if (!libraryOptions.EnableInternetProviders) + { + return false; + } + + var itemConfig = _serverConfigurationManager.Configuration.MetadataOptions.FirstOrDefault(i => string.Equals(i.ItemType, GetType().Name, StringComparison.OrdinalIgnoreCase)); + + return itemConfig == null || !itemConfig.DisabledMetadataFetchers.Contains(name.AsSpan(), StringComparison.OrdinalIgnoreCase); + } + + /// <inheritdoc /> + public bool IsImageFetcherEnabled(BaseItem baseItem, LibraryOptions libraryOptions, string name) + { + if (baseItem is Channel) + { + // Hack alert. + return true; + } + + if (baseItem.SourceType == SourceType.Channel) + { + // Hack alert. + return !baseItem.EnableMediaSourceDisplay; + } + + var typeOptions = libraryOptions.GetTypeOptions(baseItem.GetType().Name); + if (typeOptions != null) + { + return typeOptions.ImageFetchers.Contains(name.AsSpan(), StringComparison.OrdinalIgnoreCase); + } + + if (!libraryOptions.EnableInternetProviders) + { + return false; + } + + var itemConfig = _serverConfigurationManager.Configuration.MetadataOptions.FirstOrDefault(i => string.Equals(i.ItemType, GetType().Name, StringComparison.OrdinalIgnoreCase)); + + return itemConfig == null || !itemConfig.DisabledImageFetchers.Contains(name.AsSpan(), StringComparison.OrdinalIgnoreCase); + } + + /// <summary> + /// Called when the configuration is updated. + /// It will refresh the metadata throttler if the relevant config changed. + /// </summary> + private void OnConfigurationUpdated(object? sender, EventArgs e) + { + int newMetadataRefreshConcurrency = GetMetadataRefreshConcurrency(); + if (_metadataRefreshConcurrency != newMetadataRefreshConcurrency) + { + _metadataRefreshConcurrency = newMetadataRefreshConcurrency; + SetupMetadataThrottler(); + } + } + + /// <summary> + /// Creates the metadata refresh throttler. + /// </summary> + [MemberNotNull(nameof(MetadataRefreshThrottler))] + private void SetupMetadataThrottler() + { + MetadataRefreshThrottler = new SemaphoreSlim(_metadataRefreshConcurrency); + } + + /// <summary> + /// Returns the metadata refresh concurrency. + /// </summary> + private int GetMetadataRefreshConcurrency() + { + var concurrency = _serverConfigurationManager.Configuration.LibraryMetadataRefreshConcurrency; + + if (concurrency <= 0) + { + concurrency = Environment.ProcessorCount; + } + + return concurrency; + } + } +} diff --git a/MediaBrowser.Controller/BaseItemManager/IBaseItemManager.cs b/MediaBrowser.Controller/BaseItemManager/IBaseItemManager.cs new file mode 100644 index 0000000000..e18994214e --- /dev/null +++ b/MediaBrowser.Controller/BaseItemManager/IBaseItemManager.cs @@ -0,0 +1,35 @@ +using System.Threading; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Model.Configuration; + +namespace MediaBrowser.Controller.BaseItemManager +{ + /// <summary> + /// The <c>BaseItem</c> manager. + /// </summary> + public interface IBaseItemManager + { + /// <summary> + /// Gets the semaphore used to limit the amount of concurrent metadata refreshes. + /// </summary> + SemaphoreSlim MetadataRefreshThrottler { get; } + + /// <summary> + /// Is metadata fetcher enabled. + /// </summary> + /// <param name="baseItem">The base item.</param> + /// <param name="libraryOptions">The library options.</param> + /// <param name="name">The metadata fetcher name.</param> + /// <returns><c>true</c> if metadata fetcher is enabled, else false.</returns> + bool IsMetadataFetcherEnabled(BaseItem baseItem, LibraryOptions libraryOptions, string name); + + /// <summary> + /// Is image fetcher enabled. + /// </summary> + /// <param name="baseItem">The base item.</param> + /// <param name="libraryOptions">The library options.</param> + /// <param name="name">The image fetcher name.</param> + /// <returns><c>true</c> if image fetcher is enabled, else false.</returns> + bool IsImageFetcherEnabled(BaseItem baseItem, LibraryOptions libraryOptions, string name); + } +} diff --git a/MediaBrowser.Controller/Channels/Channel.cs b/MediaBrowser.Controller/Channels/Channel.cs index 129cdb519f..e6923b55ca 100644 --- a/MediaBrowser.Controller/Channels/Channel.cs +++ b/MediaBrowser.Controller/Channels/Channel.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System; @@ -15,11 +17,18 @@ namespace MediaBrowser.Controller.Channels { public class Channel : Folder { + [JsonIgnore] + public override bool SupportsInheritedParentImages => false; + + [JsonIgnore] + public override SourceType SourceType => SourceType.Channel; + public override bool IsVisible(User user) { - if (user.GetPreference(PreferenceKind.BlockedChannels) != null) + var blockedChannelsPreference = user.GetPreferenceValues<Guid>(PreferenceKind.BlockedChannels); + if (blockedChannelsPreference.Length != 0) { - if (user.GetPreference(PreferenceKind.BlockedChannels).Contains(Id.ToString("N", CultureInfo.InvariantCulture), StringComparer.OrdinalIgnoreCase)) + if (blockedChannelsPreference.Contains(Id)) { return false; } @@ -27,8 +36,7 @@ namespace MediaBrowser.Controller.Channels else { if (!user.HasPermission(PermissionKind.EnableAllChannels) - && !user.GetPreference(PreferenceKind.EnabledChannels) - .Contains(Id.ToString("N", CultureInfo.InvariantCulture), StringComparer.OrdinalIgnoreCase)) + && !user.GetPreferenceValues<Guid>(PreferenceKind.EnabledChannels).Contains(Id)) { return false; } @@ -37,12 +45,6 @@ namespace MediaBrowser.Controller.Channels return base.IsVisible(user); } - [JsonIgnore] - public override bool SupportsInheritedParentImages => false; - - [JsonIgnore] - public override SourceType SourceType => SourceType.Channel; - protected override QueryResult<BaseItem> GetItemsInternal(InternalItemsQuery query) { try @@ -82,7 +84,7 @@ namespace MediaBrowser.Controller.Channels internal static bool IsChannelVisible(BaseItem channelItem, User user) { - var channel = ChannelManager.GetChannel(channelItem.ChannelId.ToString("")); + var channel = ChannelManager.GetChannel(channelItem.ChannelId.ToString(string.Empty)); return channel.IsVisible(user); } diff --git a/MediaBrowser.Controller/Channels/ChannelItemInfo.cs b/MediaBrowser.Controller/Channels/ChannelItemInfo.cs index 476992cbd4..55f80b240f 100644 --- a/MediaBrowser.Controller/Channels/ChannelItemInfo.cs +++ b/MediaBrowser.Controller/Channels/ChannelItemInfo.cs @@ -1,4 +1,6 @@ -#pragma warning disable CS1591 +#nullable disable + +#pragma warning disable CA1002, CA2227, CS1591 using System; using System.Collections.Generic; @@ -11,6 +13,19 @@ namespace MediaBrowser.Controller.Channels { public class ChannelItemInfo : IHasProviderIds { + public ChannelItemInfo() + { + MediaSources = new List<MediaSourceInfo>(); + TrailerTypes = new List<TrailerType>(); + Genres = new List<string>(); + Studios = new List<string>(); + People = new List<PersonInfo>(); + Tags = new List<string>(); + ProviderIds = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase); + Artists = new List<string>(); + AlbumArtists = new List<string>(); + } + public string Name { get; set; } public string SeriesName { get; set; } @@ -78,18 +93,5 @@ namespace MediaBrowser.Controller.Channels public bool IsLiveStream { get; set; } public string Etag { get; set; } - - public ChannelItemInfo() - { - MediaSources = new List<MediaSourceInfo>(); - TrailerTypes = new List<TrailerType>(); - Genres = new List<string>(); - Studios = new List<string>(); - People = new List<PersonInfo>(); - Tags = new List<string>(); - ProviderIds = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase); - Artists = new List<string>(); - AlbumArtists = new List<string>(); - } } } diff --git a/MediaBrowser.Controller/Channels/ChannelItemResult.cs b/MediaBrowser.Controller/Channels/ChannelItemResult.cs index cee7b20039..ca7721991d 100644 --- a/MediaBrowser.Controller/Channels/ChannelItemResult.cs +++ b/MediaBrowser.Controller/Channels/ChannelItemResult.cs @@ -1,18 +1,19 @@ #pragma warning disable CS1591 +using System; using System.Collections.Generic; namespace MediaBrowser.Controller.Channels { public class ChannelItemResult { - public List<ChannelItemInfo> Items { get; set; } - - public int? TotalRecordCount { get; set; } - public ChannelItemResult() { - Items = new List<ChannelItemInfo>(); + Items = Array.Empty<ChannelItemInfo>(); } + + public IReadOnlyList<ChannelItemInfo> Items { get; set; } + + public int? TotalRecordCount { get; set; } } } diff --git a/MediaBrowser.Controller/Channels/ChannelLatestMediaSearch.cs b/MediaBrowser.Controller/Channels/ChannelLatestMediaSearch.cs new file mode 100644 index 0000000000..6f0761e64b --- /dev/null +++ b/MediaBrowser.Controller/Channels/ChannelLatestMediaSearch.cs @@ -0,0 +1,11 @@ +#nullable disable + +#pragma warning disable CS1591 + +namespace MediaBrowser.Controller.Channels +{ + public class ChannelLatestMediaSearch + { + public string UserId { get; set; } + } +} \ No newline at end of file diff --git a/MediaBrowser.Controller/Channels/ChannelSearchInfo.cs b/MediaBrowser.Controller/Channels/ChannelSearchInfo.cs index 32469d4d7b..990b025bcb 100644 --- a/MediaBrowser.Controller/Channels/ChannelSearchInfo.cs +++ b/MediaBrowser.Controller/Channels/ChannelSearchInfo.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 namespace MediaBrowser.Controller.Channels @@ -8,9 +10,4 @@ namespace MediaBrowser.Controller.Channels public string UserId { get; set; } } - - public class ChannelLatestMediaSearch - { - public string UserId { get; set; } - } } diff --git a/MediaBrowser.Controller/Channels/IChannel.cs b/MediaBrowser.Controller/Channels/IChannel.cs index 2c0eadf950..01bf8d5c85 100644 --- a/MediaBrowser.Controller/Channels/IChannel.cs +++ b/MediaBrowser.Controller/Channels/IChannel.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System.Collections.Generic; diff --git a/MediaBrowser.Controller/Channels/IChannelManager.cs b/MediaBrowser.Controller/Channels/IChannelManager.cs index 9a9d22d33d..49be897ef3 100644 --- a/MediaBrowser.Controller/Channels/IChannelManager.cs +++ b/MediaBrowser.Controller/Channels/IChannelManager.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System; @@ -24,7 +26,7 @@ namespace MediaBrowser.Controller.Channels /// </summary> /// <param name="id">The identifier.</param> /// <returns>ChannelFeatures.</returns> - ChannelFeatures GetChannelFeatures(string id); + ChannelFeatures GetChannelFeatures(Guid? id); /// <summary> /// Gets all channel features. @@ -49,32 +51,47 @@ namespace MediaBrowser.Controller.Channels /// Gets the channels internal. /// </summary> /// <param name="query">The query.</param> + /// <returns>The channels.</returns> QueryResult<Channel> GetChannelsInternal(ChannelQuery query); /// <summary> /// Gets the channels. /// </summary> /// <param name="query">The query.</param> + /// <returns>The channels.</returns> QueryResult<BaseItemDto> GetChannels(ChannelQuery query); /// <summary> - /// Gets the latest media. + /// Gets the latest channel items. /// </summary> + /// <param name="query">The item query.</param> + /// <param name="cancellationToken">The cancellation token.</param> + /// <returns>The latest channels.</returns> Task<QueryResult<BaseItemDto>> GetLatestChannelItems(InternalItemsQuery query, CancellationToken cancellationToken); /// <summary> - /// Gets the latest media. + /// Gets the latest channel items. /// </summary> + /// <param name="query">The item query.</param> + /// <param name="cancellationToken">The cancellation token.</param> + /// <returns>The latest channels.</returns> Task<QueryResult<BaseItem>> GetLatestChannelItemsInternal(InternalItemsQuery query, CancellationToken cancellationToken); /// <summary> /// Gets the channel items. /// </summary> + /// <param name="query">The query.</param> + /// <param name="cancellationToken">The cancellation token.</param> + /// <returns>The channel items.</returns> Task<QueryResult<BaseItemDto>> GetChannelItems(InternalItemsQuery query, CancellationToken cancellationToken); /// <summary> - /// Gets the channel items internal. + /// Gets the channel items. /// </summary> + /// <param name="query">The query.</param> + /// <param name="progress">The progress to report to.</param> + /// <param name="cancellationToken">The cancellation token.</param> + /// <returns>The channel items.</returns> Task<QueryResult<BaseItem>> GetChannelItemsInternal(InternalItemsQuery query, IProgress<double> progress, CancellationToken cancellationToken); /// <summary> @@ -82,9 +99,14 @@ namespace MediaBrowser.Controller.Channels /// </summary> /// <param name="item">The item.</param> /// <param name="cancellationToken">The cancellation token.</param> - /// <returns>Task{IEnumerable{MediaSourceInfo}}.</returns> + /// <returns>The item media sources.</returns> IEnumerable<MediaSourceInfo> GetStaticMediaSources(BaseItem item, CancellationToken cancellationToken); + /// <summary> + /// Whether the item supports media probe. + /// </summary> + /// <param name="item">The item.</param> + /// <returns>Whether media probe should be enabled.</returns> bool EnableMediaProbe(BaseItem item); } } diff --git a/MediaBrowser.Controller/Channels/IDisableMediaSourceDisplay.cs b/MediaBrowser.Controller/Channels/IDisableMediaSourceDisplay.cs new file mode 100644 index 0000000000..0539b9048b --- /dev/null +++ b/MediaBrowser.Controller/Channels/IDisableMediaSourceDisplay.cs @@ -0,0 +1,12 @@ +namespace MediaBrowser.Controller.Channels +{ + /// <summary> + /// Disable media source display. + /// </summary> + /// <remarks> + /// <see cref="Channel"/> can inherit this interface to disable being displayed. + /// </remarks> + public interface IDisableMediaSourceDisplay + { + } +} diff --git a/MediaBrowser.Controller/Channels/IHasCacheKey.cs b/MediaBrowser.Controller/Channels/IHasCacheKey.cs index bf895a0eca..9fae43033e 100644 --- a/MediaBrowser.Controller/Channels/IHasCacheKey.cs +++ b/MediaBrowser.Controller/Channels/IHasCacheKey.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 namespace MediaBrowser.Controller.Channels diff --git a/MediaBrowser.Controller/Channels/IHasFolderAttributes.cs b/MediaBrowser.Controller/Channels/IHasFolderAttributes.cs new file mode 100644 index 0000000000..64af8496c7 --- /dev/null +++ b/MediaBrowser.Controller/Channels/IHasFolderAttributes.cs @@ -0,0 +1,9 @@ +#pragma warning disable CA1819, CS1591 + +namespace MediaBrowser.Controller.Channels +{ + public interface IHasFolderAttributes + { + string[] Attributes { get; } + } +} \ No newline at end of file diff --git a/MediaBrowser.Controller/Channels/IRequiresMediaInfoCallback.cs b/MediaBrowser.Controller/Channels/IRequiresMediaInfoCallback.cs index 589295543c..eeaa6b622e 100644 --- a/MediaBrowser.Controller/Channels/IRequiresMediaInfoCallback.cs +++ b/MediaBrowser.Controller/Channels/IRequiresMediaInfoCallback.cs @@ -1,5 +1,3 @@ -#pragma warning disable CS1591 - using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; @@ -7,11 +5,17 @@ using MediaBrowser.Model.Dto; namespace MediaBrowser.Controller.Channels { + /// <summary> + /// The channel requires a media info callback. + /// </summary> public interface IRequiresMediaInfoCallback { /// <summary> /// Gets the channel item media information. /// </summary> + /// <param name="id">The channel item id.</param> + /// <param name="cancellationToken">The cancellation token.</param> + /// <returns>The enumerable of media source info.</returns> Task<IEnumerable<MediaSourceInfo>> GetChannelItemMediaInfo(string id, CancellationToken cancellationToken); } } diff --git a/MediaBrowser.Controller/Channels/ISearchableChannel.cs b/MediaBrowser.Controller/Channels/ISearchableChannel.cs index b627ca1c25..b87943a6ed 100644 --- a/MediaBrowser.Controller/Channels/ISearchableChannel.cs +++ b/MediaBrowser.Controller/Channels/ISearchableChannel.cs @@ -1,9 +1,10 @@ +#nullable disable + #pragma warning disable CS1591 using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; -using MediaBrowser.Controller.Entities; namespace MediaBrowser.Controller.Channels { @@ -17,35 +18,4 @@ namespace MediaBrowser.Controller.Channels /// <returns>Task{IEnumerable{ChannelItemInfo}}.</returns> Task<IEnumerable<ChannelItemInfo>> Search(ChannelSearchInfo searchInfo, CancellationToken cancellationToken); } - - public interface ISupportsLatestMedia - { - /// <summary> - /// Gets the latest media. - /// </summary> - /// <param name="request">The request.</param> - /// <param name="cancellationToken">The cancellation token.</param> - /// <returns>Task{IEnumerable{ChannelItemInfo}}.</returns> - Task<IEnumerable<ChannelItemInfo>> GetLatestMedia(ChannelLatestMediaSearch request, CancellationToken cancellationToken); - } - - public interface ISupportsDelete - { - bool CanDelete(BaseItem item); - - Task DeleteItem(string id, CancellationToken cancellationToken); - } - - public interface IDisableMediaSourceDisplay - { - } - - public interface ISupportsMediaProbe - { - } - - public interface IHasFolderAttributes - { - string[] Attributes { get; } - } } diff --git a/MediaBrowser.Controller/Channels/ISupportsDelete.cs b/MediaBrowser.Controller/Channels/ISupportsDelete.cs new file mode 100644 index 0000000000..204054374e --- /dev/null +++ b/MediaBrowser.Controller/Channels/ISupportsDelete.cs @@ -0,0 +1,15 @@ +#pragma warning disable CS1591 + +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Controller.Entities; + +namespace MediaBrowser.Controller.Channels +{ + public interface ISupportsDelete + { + bool CanDelete(BaseItem item); + + Task DeleteItem(string id, CancellationToken cancellationToken); + } +} \ No newline at end of file diff --git a/MediaBrowser.Controller/Channels/ISupportsLatestMedia.cs b/MediaBrowser.Controller/Channels/ISupportsLatestMedia.cs new file mode 100644 index 0000000000..dbba7cba2d --- /dev/null +++ b/MediaBrowser.Controller/Channels/ISupportsLatestMedia.cs @@ -0,0 +1,21 @@ +#nullable disable + +#pragma warning disable CS1591 + +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace MediaBrowser.Controller.Channels +{ + public interface ISupportsLatestMedia + { + /// <summary> + /// Gets the latest media. + /// </summary> + /// <param name="request">The request.</param> + /// <param name="cancellationToken">The cancellation token.</param> + /// <returns>The latest media.</returns> + Task<IEnumerable<ChannelItemInfo>> GetLatestMedia(ChannelLatestMediaSearch request, CancellationToken cancellationToken); + } +} \ No newline at end of file diff --git a/MediaBrowser.Controller/Channels/ISupportsMediaProbe.cs b/MediaBrowser.Controller/Channels/ISupportsMediaProbe.cs new file mode 100644 index 0000000000..bc7683125b --- /dev/null +++ b/MediaBrowser.Controller/Channels/ISupportsMediaProbe.cs @@ -0,0 +1,9 @@ +namespace MediaBrowser.Controller.Channels +{ + /// <summary> + /// Channel supports media probe. + /// </summary> + public interface ISupportsMediaProbe + { + } +} diff --git a/MediaBrowser.Controller/Channels/InternalChannelFeatures.cs b/MediaBrowser.Controller/Channels/InternalChannelFeatures.cs index 137f5d095b..394996868e 100644 --- a/MediaBrowser.Controller/Channels/InternalChannelFeatures.cs +++ b/MediaBrowser.Controller/Channels/InternalChannelFeatures.cs @@ -1,4 +1,6 @@ -#pragma warning disable CS1591 +#nullable disable + +#pragma warning disable CA1002, CA2227, CS1591 using System.Collections.Generic; using MediaBrowser.Model.Channels; @@ -28,7 +30,7 @@ namespace MediaBrowser.Controller.Channels public List<ChannelMediaContentType> ContentTypes { get; set; } /// <summary> - /// Represents the maximum number of records the channel allows retrieving at a time. + /// Gets or sets the maximum number of records the channel allows retrieving at a time. /// </summary> public int? MaxPageSize { get; set; } @@ -39,7 +41,7 @@ namespace MediaBrowser.Controller.Channels public List<ChannelItemSortField> DefaultSortFields { get; set; } /// <summary> - /// Indicates if a sort ascending/descending toggle is supported or not. + /// Gets or sets a value indicating whether a sort ascending/descending toggle is supported or not. /// </summary> public bool SupportsSortOrderToggle { get; set; } diff --git a/MediaBrowser.Controller/Channels/InternalChannelItemQuery.cs b/MediaBrowser.Controller/Channels/InternalChannelItemQuery.cs index 7e9bb28ed6..0d837faca2 100644 --- a/MediaBrowser.Controller/Channels/InternalChannelItemQuery.cs +++ b/MediaBrowser.Controller/Channels/InternalChannelItemQuery.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System; diff --git a/MediaBrowser.Controller/Chapters/IChapterManager.cs b/MediaBrowser.Controller/Chapters/IChapterManager.cs index f82e5b41a2..c049bb97e7 100644 --- a/MediaBrowser.Controller/Chapters/IChapterManager.cs +++ b/MediaBrowser.Controller/Chapters/IChapterManager.cs @@ -12,6 +12,8 @@ namespace MediaBrowser.Controller.Chapters /// <summary> /// Saves the chapters. /// </summary> + /// <param name="itemId">The item.</param> + /// <param name="chapters">The set of chapters.</param> void SaveChapters(Guid itemId, IReadOnlyList<ChapterInfo> chapters); } } diff --git a/MediaBrowser.Controller/Collections/CollectionCreatedEventArgs.cs b/MediaBrowser.Controller/Collections/CollectionCreatedEventArgs.cs new file mode 100644 index 0000000000..82b3a49773 --- /dev/null +++ b/MediaBrowser.Controller/Collections/CollectionCreatedEventArgs.cs @@ -0,0 +1,24 @@ +#nullable disable + +#pragma warning disable CS1591 + +using System; +using MediaBrowser.Controller.Entities.Movies; + +namespace MediaBrowser.Controller.Collections +{ + public class CollectionCreatedEventArgs : EventArgs + { + /// <summary> + /// Gets or sets the collection. + /// </summary> + /// <value>The collection.</value> + public BoxSet Collection { get; set; } + + /// <summary> + /// Gets or sets the options. + /// </summary> + /// <value>The options.</value> + public CollectionCreationOptions Options { get; set; } + } +} \ No newline at end of file diff --git a/MediaBrowser.Controller/Collections/CollectionCreationOptions.cs b/MediaBrowser.Controller/Collections/CollectionCreationOptions.cs index f6037d05e1..30f5f4efa2 100644 --- a/MediaBrowser.Controller/Collections/CollectionCreationOptions.cs +++ b/MediaBrowser.Controller/Collections/CollectionCreationOptions.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System; @@ -23,8 +25,8 @@ namespace MediaBrowser.Controller.Collections public Dictionary<string, string> ProviderIds { get; set; } - public string[] ItemIdList { get; set; } + public IReadOnlyList<string> ItemIdList { get; set; } - public Guid[] UserIds { get; set; } + public IReadOnlyList<Guid> UserIds { get; set; } } } diff --git a/MediaBrowser.Controller/Collections/CollectionEvents.cs b/MediaBrowser.Controller/Collections/CollectionModifiedEventArgs.cs similarity index 55% rename from MediaBrowser.Controller/Collections/CollectionEvents.cs rename to MediaBrowser.Controller/Collections/CollectionModifiedEventArgs.cs index ce59b4ada2..e538fa4b3f 100644 --- a/MediaBrowser.Controller/Collections/CollectionEvents.cs +++ b/MediaBrowser.Controller/Collections/CollectionModifiedEventArgs.cs @@ -7,23 +7,14 @@ using MediaBrowser.Controller.Entities.Movies; namespace MediaBrowser.Controller.Collections { - public class CollectionCreatedEventArgs : EventArgs - { - /// <summary> - /// Gets or sets the collection. - /// </summary> - /// <value>The collection.</value> - public BoxSet Collection { get; set; } - - /// <summary> - /// Gets or sets the options. - /// </summary> - /// <value>The options.</value> - public CollectionCreationOptions Options { get; set; } - } - public class CollectionModifiedEventArgs : EventArgs { + public CollectionModifiedEventArgs(BoxSet collection, IReadOnlyCollection<BaseItem> itemsChanged) + { + Collection = collection; + ItemsChanged = itemsChanged; + } + /// <summary> /// Gets or sets the collection. /// </summary> @@ -34,6 +25,6 @@ namespace MediaBrowser.Controller.Collections /// Gets or sets the items changed. /// </summary> /// <value>The items changed.</value> - public List<BaseItem> ItemsChanged { get; set; } + public IReadOnlyCollection<BaseItem> ItemsChanged { get; set; } } } diff --git a/MediaBrowser.Controller/Collections/ICollectionManager.cs b/MediaBrowser.Controller/Collections/ICollectionManager.cs index a6991e2eac..b8c33ee5a0 100644 --- a/MediaBrowser.Controller/Collections/ICollectionManager.cs +++ b/MediaBrowser.Controller/Collections/ICollectionManager.cs @@ -14,22 +14,23 @@ namespace MediaBrowser.Controller.Collections /// <summary> /// Occurs when [collection created]. /// </summary> - event EventHandler<CollectionCreatedEventArgs> CollectionCreated; + event EventHandler<CollectionCreatedEventArgs>? CollectionCreated; /// <summary> /// Occurs when [items added to collection]. /// </summary> - event EventHandler<CollectionModifiedEventArgs> ItemsAddedToCollection; + event EventHandler<CollectionModifiedEventArgs>? ItemsAddedToCollection; /// <summary> /// Occurs when [items removed from collection]. /// </summary> - event EventHandler<CollectionModifiedEventArgs> ItemsRemovedFromCollection; + event EventHandler<CollectionModifiedEventArgs>? ItemsRemovedFromCollection; /// <summary> /// Creates the collection. /// </summary> /// <param name="options">The options.</param> + /// <returns>BoxSet wrapped in an awaitable task.</returns> Task<BoxSet> CreateCollectionAsync(CollectionCreationOptions options); /// <summary> diff --git a/MediaBrowser.Controller/Devices/IDeviceManager.cs b/MediaBrowser.Controller/Devices/IDeviceManager.cs index 8f0872dba9..8096be1bd3 100644 --- a/MediaBrowser.Controller/Devices/IDeviceManager.cs +++ b/MediaBrowser.Controller/Devices/IDeviceManager.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System; @@ -18,7 +20,6 @@ namespace MediaBrowser.Controller.Devices /// </summary> /// <param name="reportedId">The reported identifier.</param> /// <param name="capabilities">The capabilities.</param> - /// <returns>Task.</returns> void SaveCapabilities(string reportedId, ClientCapabilities capabilities); /// <summary> @@ -45,6 +46,9 @@ namespace MediaBrowser.Controller.Devices /// <summary> /// Determines whether this instance [can access device] the specified user identifier. /// </summary> + /// <param name="user">The user to test.</param> + /// <param name="deviceId">The device id to test.</param> + /// <returns>Whether the user can access the device.</returns> bool CanAccessDevice(User user, string deviceId); void UpdateDeviceOptions(string deviceId, DeviceOptions options); diff --git a/MediaBrowser.Controller/Dlna/IDlnaManager.cs b/MediaBrowser.Controller/Dlna/IDlnaManager.cs index dc2d5a356a..a64919700d 100644 --- a/MediaBrowser.Controller/Dlna/IDlnaManager.cs +++ b/MediaBrowser.Controller/Dlna/IDlnaManager.cs @@ -20,7 +20,7 @@ namespace MediaBrowser.Controller.Dlna /// </summary> /// <param name="headers">The headers.</param> /// <returns>DeviceProfile.</returns> - DeviceProfile GetProfile(IHeaderDictionary headers); + DeviceProfile? GetProfile(IHeaderDictionary headers); /// <summary> /// Gets the default profile. @@ -51,14 +51,14 @@ namespace MediaBrowser.Controller.Dlna /// </summary> /// <param name="id">The identifier.</param> /// <returns>DeviceProfile.</returns> - DeviceProfile GetProfile(string id); + DeviceProfile? GetProfile(string id); /// <summary> /// Gets the profile. /// </summary> /// <param name="deviceInfo">The device information.</param> /// <returns>DeviceProfile.</returns> - DeviceProfile GetProfile(DeviceIdentification deviceInfo); + DeviceProfile? GetProfile(DeviceIdentification deviceInfo); /// <summary> /// Gets the server description XML. diff --git a/MediaBrowser.Controller/Drawing/IImageEncoder.cs b/MediaBrowser.Controller/Drawing/IImageEncoder.cs index 770c6dc2d2..4e67cfee4f 100644 --- a/MediaBrowser.Controller/Drawing/IImageEncoder.cs +++ b/MediaBrowser.Controller/Drawing/IImageEncoder.cs @@ -1,5 +1,4 @@ #pragma warning disable CS1591 -#nullable enable using System; using System.Collections.Generic; @@ -58,6 +57,15 @@ namespace MediaBrowser.Controller.Drawing /// <summary> /// Encode an image. /// </summary> + /// <param name="inputPath">Input path of image.</param> + /// <param name="dateModified">Date modified.</param> + /// <param name="outputPath">Output path of image.</param> + /// <param name="autoOrient">Auto-orient image.</param> + /// <param name="orientation">Desired orientation of image.</param> + /// <param name="quality">Quality of encoded image.</param> + /// <param name="options">Image processing options.</param> + /// <param name="outputFormat">Image format of output.</param> + /// <returns>Path of encoded image.</returns> string EncodeImage(string inputPath, DateTime dateModified, string outputPath, bool autoOrient, ImageOrientation? orientation, int quality, ImageProcessingOptions options, ImageFormat outputFormat); /// <summary> diff --git a/MediaBrowser.Controller/Drawing/IImageProcessor.cs b/MediaBrowser.Controller/Drawing/IImageProcessor.cs index 935a790312..c7f61a90bb 100644 --- a/MediaBrowser.Controller/Drawing/IImageProcessor.cs +++ b/MediaBrowser.Controller/Drawing/IImageProcessor.cs @@ -1,5 +1,4 @@ #pragma warning disable CS1591 -#nullable enable using System; using System.Collections.Generic; @@ -61,7 +60,7 @@ namespace MediaBrowser.Controller.Drawing string GetImageCacheTag(BaseItem item, ChapterInfo info); - string GetImageCacheTag(User user); + string? GetImageCacheTag(User user); /// <summary> /// Processes the image. diff --git a/MediaBrowser.Controller/Drawing/ImageCollageOptions.cs b/MediaBrowser.Controller/Drawing/ImageCollageOptions.cs index fe0465d0d7..e9c88ffb56 100644 --- a/MediaBrowser.Controller/Drawing/ImageCollageOptions.cs +++ b/MediaBrowser.Controller/Drawing/ImageCollageOptions.cs @@ -1,3 +1,7 @@ +#nullable disable + +using System.Collections.Generic; + #pragma warning disable CS1591 namespace MediaBrowser.Controller.Drawing @@ -8,7 +12,7 @@ namespace MediaBrowser.Controller.Drawing /// Gets or sets the input paths. /// </summary> /// <value>The input paths.</value> - public string[] InputPaths { get; set; } + public IReadOnlyList<string> InputPaths { get; set; } /// <summary> /// Gets or sets the output path. diff --git a/MediaBrowser.Controller/Drawing/ImageHelper.cs b/MediaBrowser.Controller/Drawing/ImageHelper.cs index 87c28d5773..9ef92bc981 100644 --- a/MediaBrowser.Controller/Drawing/ImageHelper.cs +++ b/MediaBrowser.Controller/Drawing/ImageHelper.cs @@ -1,75 +1,17 @@ #pragma warning disable CS1591 -using System; -using MediaBrowser.Controller.Entities; using MediaBrowser.Model.Drawing; -using MediaBrowser.Model.Entities; namespace MediaBrowser.Controller.Drawing { public static class ImageHelper { - public static ImageDimensions GetNewImageSize(ImageProcessingOptions options, ImageDimensions? originalImageSize) + public static ImageDimensions GetNewImageSize(ImageProcessingOptions options, ImageDimensions originalImageSize) { - if (originalImageSize.HasValue) - { - // Determine the output size based on incoming parameters - var newSize = DrawingUtils.Resize(originalImageSize.Value, options.Width ?? 0, options.Height ?? 0, options.MaxWidth ?? 0, options.MaxHeight ?? 0); - - return newSize; - } - - return GetSizeEstimate(options); - } - - private static ImageDimensions GetSizeEstimate(ImageProcessingOptions options) - { - if (options.Width.HasValue && options.Height.HasValue) - { - return new ImageDimensions(options.Width.Value, options.Height.Value); - } - - double aspect = GetEstimatedAspectRatio(options.Image.Type, options.Item); - - int? width = options.Width ?? options.MaxWidth; - - if (width.HasValue) - { - int heightValue = Convert.ToInt32((double)width.Value / aspect); - return new ImageDimensions(width.Value, heightValue); - } - - var height = options.Height ?? options.MaxHeight ?? 200; - int widthValue = Convert.ToInt32(aspect * height); - return new ImageDimensions(widthValue, height); - } - - private static double GetEstimatedAspectRatio(ImageType type, BaseItem item) - { - switch (type) - { - case ImageType.Art: - case ImageType.Backdrop: - case ImageType.Chapter: - case ImageType.Screenshot: - case ImageType.Thumb: - return 1.78; - case ImageType.Banner: - return 5.4; - case ImageType.Box: - case ImageType.BoxRear: - case ImageType.Disc: - case ImageType.Menu: - case ImageType.Profile: - return 1; - case ImageType.Logo: - return 2.58; - case ImageType.Primary: - double defaultPrimaryImageAspectRatio = item.GetDefaultPrimaryImageAspectRatio(); - return defaultPrimaryImageAspectRatio > 0 ? defaultPrimaryImageAspectRatio : 2.0 / 3; - default: - return 1; - } + // Determine the output size based on incoming parameters + var newSize = DrawingUtils.Resize(originalImageSize, options.Width ?? 0, options.Height ?? 0, options.MaxWidth ?? 0, options.MaxHeight ?? 0); + newSize = DrawingUtils.ResizeFill(newSize, options.FillWidth, options.FillHeight); + return newSize; } } } diff --git a/MediaBrowser.Controller/Drawing/ImageProcessingOptions.cs b/MediaBrowser.Controller/Drawing/ImageProcessingOptions.cs index 22105b7d79..11e6633011 100644 --- a/MediaBrowser.Controller/Drawing/ImageProcessingOptions.cs +++ b/MediaBrowser.Controller/Drawing/ImageProcessingOptions.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System; @@ -24,8 +26,6 @@ namespace MediaBrowser.Controller.Drawing public int ImageIndex { get; set; } - public bool CropWhiteSpace { get; set; } - public int? Width { get; set; } public int? Height { get; set; } @@ -34,6 +34,10 @@ namespace MediaBrowser.Controller.Drawing public int? MaxHeight { get; set; } + public int? FillWidth { get; set; } + + public int? FillHeight { get; set; } + public int Quality { get; set; } public IReadOnlyCollection<ImageFormat> SupportedOutputFormats { get; set; } @@ -95,6 +99,11 @@ namespace MediaBrowser.Controller.Drawing return false; } + if (sizeValue.Width > FillWidth || sizeValue.Height > FillHeight) + { + return false; + } + return true; } @@ -106,7 +115,6 @@ namespace MediaBrowser.Controller.Drawing PercentPlayed.Equals(0) && !UnplayedCount.HasValue && !Blur.HasValue && - !CropWhiteSpace && string.IsNullOrEmpty(BackgroundColor) && string.IsNullOrEmpty(ForegroundLayer); } diff --git a/MediaBrowser.Controller/Drawing/ImageProcessorExtensions.cs b/MediaBrowser.Controller/Drawing/ImageProcessorExtensions.cs index d3a2b4dbf2..b036425ab3 100644 --- a/MediaBrowser.Controller/Drawing/ImageProcessorExtensions.cs +++ b/MediaBrowser.Controller/Drawing/ImageProcessorExtensions.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using MediaBrowser.Controller.Entities; diff --git a/MediaBrowser.Controller/Drawing/ImageStream.cs b/MediaBrowser.Controller/Drawing/ImageStream.cs index 46f58ec159..5d552170f9 100644 --- a/MediaBrowser.Controller/Drawing/ImageStream.cs +++ b/MediaBrowser.Controller/Drawing/ImageStream.cs @@ -1,4 +1,4 @@ -#pragma warning disable CS1591 +#pragma warning disable CA1711, CS1591 using System; using System.IO; @@ -12,7 +12,7 @@ namespace MediaBrowser.Controller.Drawing /// Gets or sets the stream. /// </summary> /// <value>The stream.</value> - public Stream Stream { get; set; } + public Stream? Stream { get; set; } /// <summary> /// Gets or sets the format. @@ -22,9 +22,15 @@ namespace MediaBrowser.Controller.Drawing public void Dispose() { - if (Stream != null) + Dispose(true); + GC.SuppressFinalize(this); + } + + protected virtual void Dispose(bool disposing) + { + if (disposing) { - Stream.Dispose(); + Stream?.Dispose(); } } } diff --git a/MediaBrowser.Controller/Dto/DtoOptions.cs b/MediaBrowser.Controller/Dto/DtoOptions.cs index 76f20ace2a..ecc833154d 100644 --- a/MediaBrowser.Controller/Dto/DtoOptions.cs +++ b/MediaBrowser.Controller/Dto/DtoOptions.cs @@ -1,6 +1,9 @@ +#nullable disable + #pragma warning disable CS1591 using System; +using System.Collections.Generic; using System.Linq; using MediaBrowser.Model.Entities; using MediaBrowser.Model.Querying; @@ -15,37 +18,17 @@ namespace MediaBrowser.Controller.Dto ItemFields.RefreshState }; - public ItemFields[] Fields { get; set; } - - public ImageType[] ImageTypes { get; set; } - - public int ImageTypeLimit { get; set; } - - public bool EnableImages { get; set; } - - public bool AddProgramRecordingInfo { get; set; } - - public bool EnableUserData { get; set; } + private static readonly ImageType[] AllImageTypes = Enum.GetValues<ImageType>(); - public bool AddCurrentProgram { get; set; } + private static readonly ItemFields[] AllItemFields = Enum.GetValues<ItemFields>() + .Except(DefaultExcludedFields) + .ToArray(); public DtoOptions() : this(true) { } - private static readonly ImageType[] AllImageTypes = Enum.GetNames(typeof(ImageType)) - .Select(i => (ImageType)Enum.Parse(typeof(ImageType), i, true)) - .ToArray(); - - private static readonly ItemFields[] AllItemFields = Enum.GetNames(typeof(ItemFields)) - .Select(i => (ItemFields)Enum.Parse(typeof(ItemFields), i, true)) - .Except(DefaultExcludedFields) - .ToArray(); - - public bool ContainsField(ItemFields field) - => Fields.Contains(field); - public DtoOptions(bool allFields) { ImageTypeLimit = int.MaxValue; @@ -57,6 +40,23 @@ namespace MediaBrowser.Controller.Dto ImageTypes = AllImageTypes; } + public IReadOnlyList<ItemFields> Fields { get; set; } + + public IReadOnlyList<ImageType> ImageTypes { get; set; } + + public int ImageTypeLimit { get; set; } + + public bool EnableImages { get; set; } + + public bool AddProgramRecordingInfo { get; set; } + + public bool EnableUserData { get; set; } + + public bool AddCurrentProgram { get; set; } + + public bool ContainsField(ItemFields field) + => Fields.Contains(field); + public int GetImageLimit(ImageType type) { if (EnableImages && ImageTypes.Contains(type)) diff --git a/MediaBrowser.Controller/Dto/IDtoService.cs b/MediaBrowser.Controller/Dto/IDtoService.cs index 988557f42c..89aafc84fb 100644 --- a/MediaBrowser.Controller/Dto/IDtoService.cs +++ b/MediaBrowser.Controller/Dto/IDtoService.cs @@ -1,3 +1,6 @@ +#nullable disable +#pragma warning disable CA1002 + using System.Collections.Generic; using Jellyfin.Data.Entities; using MediaBrowser.Controller.Entities; @@ -34,11 +37,17 @@ namespace MediaBrowser.Controller.Dto /// <param name="options">The options.</param> /// <param name="user">The user.</param> /// <param name="owner">The owner.</param> + /// <returns>The <see cref="IReadOnlyList{T}"/> of <see cref="BaseItemDto"/>.</returns> IReadOnlyList<BaseItemDto> GetBaseItemDtos(IReadOnlyList<BaseItem> items, DtoOptions options, User user = null, BaseItem owner = null); /// <summary> /// Gets the item by name dto. /// </summary> + /// <param name="item">The item.</param> + /// <param name="options">The dto options.</param> + /// <param name="taggedItems">The list of tagged items.</param> + /// <param name="user">The user.</param> + /// <returns>The item dto.</returns> BaseItemDto GetItemByNameDto(BaseItem item, DtoOptions options, List<BaseItem> taggedItems, User user = null); } } diff --git a/MediaBrowser.Controller/Entities/AggregateFolder.cs b/MediaBrowser.Controller/Entities/AggregateFolder.cs index 6ebea5f449..9589f52452 100644 --- a/MediaBrowser.Controller/Entities/AggregateFolder.cs +++ b/MediaBrowser.Controller/Entities/AggregateFolder.cs @@ -1,4 +1,6 @@ -#pragma warning disable CS1591 +#nullable disable + +#pragma warning disable CA1819, CS1591 using System; using System.Collections.Concurrent; @@ -16,30 +18,23 @@ namespace MediaBrowser.Controller.Entities { /// <summary> /// Specialized folder that can have items added to it's children by external entities. - /// Used for our RootFolder so plug-ins can add items. + /// Used for our RootFolder so plugins can add items. /// </summary> public class AggregateFolder : Folder { - public AggregateFolder() - { - PhysicalLocationsList = Array.Empty<string>(); - } - - [JsonIgnore] - public override bool IsPhysicalRoot => true; - - public override bool CanDelete() - { - return false; - } - - [JsonIgnore] - public override bool SupportsPlayedStatus => false; + private readonly object _childIdsLock = new object(); /// <summary> /// The _virtual children. /// </summary> private readonly ConcurrentBag<BaseItem> _virtualChildren = new ConcurrentBag<BaseItem>(); + private bool _requiresRefresh; + private Guid[] _childrenIds = null; + + public AggregateFolder() + { + PhysicalLocationsList = Array.Empty<string>(); + } /// <summary> /// Gets the virtual children. @@ -47,19 +42,27 @@ namespace MediaBrowser.Controller.Entities /// <value>The virtual children.</value> public ConcurrentBag<BaseItem> VirtualChildren => _virtualChildren; + [JsonIgnore] + public override bool IsPhysicalRoot => true; + + [JsonIgnore] + public override bool SupportsPlayedStatus => false; + [JsonIgnore] public override string[] PhysicalLocations => PhysicalLocationsList; public string[] PhysicalLocationsList { get; set; } + public override bool CanDelete() + { + return false; + } + protected override FileSystemMetadata[] GetFileSystemChildren(IDirectoryService directoryService) { return CreateResolveArgs(directoryService, true).FileSystemChildren; } - private Guid[] _childrenIds = null; - private readonly object _childIdsLock = new object(); - protected override List<BaseItem> LoadChildren() { lock (_childIdsLock) @@ -83,7 +86,6 @@ namespace MediaBrowser.Controller.Entities } } - private bool _requiresRefresh; public override bool RequiresRefresh() { var changed = base.RequiresRefresh() || _requiresRefresh; @@ -103,11 +105,11 @@ namespace MediaBrowser.Controller.Entities return changed; } - public override bool BeforeMetadataRefresh(bool replaceAllMetdata) + public override bool BeforeMetadataRefresh(bool replaceAllMetadata) { ClearCache(); - var changed = base.BeforeMetadataRefresh(replaceAllMetdata) || _requiresRefresh; + var changed = base.BeforeMetadataRefresh(replaceAllMetadata) || _requiresRefresh; _requiresRefresh = false; return changed; } @@ -120,8 +122,7 @@ namespace MediaBrowser.Controller.Entities var args = new ItemResolveArgs(ConfigurationManager.ApplicationPaths, directoryService) { - FileInfo = FileSystem.GetDirectoryInfo(path), - Path = path + FileInfo = FileSystem.GetDirectoryInfo(path) }; // Gather child folder and files @@ -153,11 +154,11 @@ namespace MediaBrowser.Controller.Entities return base.GetNonCachedChildren(directoryService).Concat(_virtualChildren); } - protected override async Task ValidateChildrenInternal(IProgress<double> progress, CancellationToken cancellationToken, bool recursive, bool refreshChildMetadata, MetadataRefreshOptions refreshOptions, IDirectoryService directoryService) + protected override async Task ValidateChildrenInternal(IProgress<double> progress, bool recursive, bool refreshChildMetadata, MetadataRefreshOptions refreshOptions, IDirectoryService directoryService, CancellationToken cancellationToken) { ClearCache(); - await base.ValidateChildrenInternal(progress, cancellationToken, recursive, refreshChildMetadata, refreshOptions, directoryService) + await base.ValidateChildrenInternal(progress, recursive, refreshChildMetadata, refreshOptions, directoryService, cancellationToken) .ConfigureAwait(false); ClearCache(); @@ -167,7 +168,7 @@ namespace MediaBrowser.Controller.Entities /// Adds the virtual child. /// </summary> /// <param name="child">The child.</param> - /// <exception cref="ArgumentNullException"></exception> + /// <exception cref="ArgumentNullException">Throws if child is null.</exception> public void AddVirtualChild(BaseItem child) { if (child == null) @@ -183,7 +184,7 @@ namespace MediaBrowser.Controller.Entities /// </summary> /// <param name="id">The id.</param> /// <returns>BaseItem.</returns> - /// <exception cref="ArgumentNullException">id</exception> + /// <exception cref="ArgumentNullException">The id is empty.</exception> public BaseItem FindVirtualChild(Guid id) { if (id.Equals(Guid.Empty)) diff --git a/MediaBrowser.Controller/Entities/Audio/Audio.cs b/MediaBrowser.Controller/Entities/Audio/Audio.cs index 8220464b39..7bf1219ec2 100644 --- a/MediaBrowser.Controller/Entities/Audio/Audio.cs +++ b/MediaBrowser.Controller/Entities/Audio/Audio.cs @@ -1,7 +1,10 @@ -#pragma warning disable CS1591 +#nullable disable + +#pragma warning disable CA1002, CA1724, CA1826, CS1591 using System; using System.Collections.Generic; +using System.Globalization; using System.Linq; using System.Text.Json.Serialization; using Jellyfin.Data.Enums; @@ -22,6 +25,12 @@ namespace MediaBrowser.Controller.Entities.Audio IHasLookupInfo<SongInfo>, IHasMediaSources { + public Audio() + { + Artists = Array.Empty<string>(); + AlbumArtists = Array.Empty<string>(); + } + /// <inheritdoc /> [JsonIgnore] public IReadOnlyList<string> Artists { get; set; } @@ -30,17 +39,6 @@ namespace MediaBrowser.Controller.Entities.Audio [JsonIgnore] public IReadOnlyList<string> AlbumArtists { get; set; } - public Audio() - { - Artists = Array.Empty<string>(); - AlbumArtists = Array.Empty<string>(); - } - - public override double GetDefaultPrimaryImageAspectRatio() - { - return 1; - } - [JsonIgnore] public override bool SupportsPlayedStatus => true; @@ -59,11 +57,6 @@ namespace MediaBrowser.Controller.Entities.Audio [JsonIgnore] public override Folder LatestItemsIndexContainer => AlbumEntity; - public override bool CanDownload() - { - return IsFileProtocol; - } - [JsonIgnore] public MusicAlbum AlbumEntity => FindParent<MusicAlbum>(); @@ -74,25 +67,35 @@ namespace MediaBrowser.Controller.Entities.Audio [JsonIgnore] public override string MediaType => Model.Entities.MediaType.Audio; + public override double GetDefaultPrimaryImageAspectRatio() + { + return 1; + } + + public override bool CanDownload() + { + return IsFileProtocol; + } + /// <summary> /// Creates the name of the sort. /// </summary> /// <returns>System.String.</returns> protected override string CreateSortName() { - return (ParentIndexNumber != null ? ParentIndexNumber.Value.ToString("0000 - ") : "") - + (IndexNumber != null ? IndexNumber.Value.ToString("0000 - ") : "") + Name; + return (ParentIndexNumber != null ? ParentIndexNumber.Value.ToString("0000 - ", CultureInfo.InvariantCulture) : string.Empty) + + (IndexNumber != null ? IndexNumber.Value.ToString("0000 - ", CultureInfo.InvariantCulture) : string.Empty) + Name; } public override List<string> GetUserDataKeys() { var list = base.GetUserDataKeys(); - var songKey = IndexNumber.HasValue ? IndexNumber.Value.ToString("0000") : string.Empty; + var songKey = IndexNumber.HasValue ? IndexNumber.Value.ToString("0000", CultureInfo.InvariantCulture) : string.Empty; if (ParentIndexNumber.HasValue) { - songKey = ParentIndexNumber.Value.ToString("0000") + "-" + songKey; + songKey = ParentIndexNumber.Value.ToString("0000", CultureInfo.InvariantCulture) + "-" + songKey; } songKey += Name; diff --git a/MediaBrowser.Controller/Entities/Audio/IHasAlbumArtist.cs b/MediaBrowser.Controller/Entities/Audio/IHasAlbumArtist.cs index f6d3cd6cc2..1625c748a8 100644 --- a/MediaBrowser.Controller/Entities/Audio/IHasAlbumArtist.cs +++ b/MediaBrowser.Controller/Entities/Audio/IHasAlbumArtist.cs @@ -1,6 +1,10 @@ +#nullable disable + #pragma warning disable CS1591 using System.Collections.Generic; +using System.Linq; +using MediaBrowser.Controller.Library; namespace MediaBrowser.Controller.Entities.Audio { @@ -23,15 +27,7 @@ namespace MediaBrowser.Controller.Entities.Audio public static IEnumerable<string> GetAllArtists<T>(this T item) where T : IHasArtist, IHasAlbumArtist { - foreach (var i in item.AlbumArtists) - { - yield return i; - } - - foreach (var i in item.Artists) - { - yield return i; - } + return item.AlbumArtists.Concat(item.Artists).DistinctNames(); } } } diff --git a/MediaBrowser.Controller/Entities/Audio/IHasMusicGenres.cs b/MediaBrowser.Controller/Entities/Audio/IHasMusicGenres.cs index ac4dd1688b..c2dae5a2dc 100644 --- a/MediaBrowser.Controller/Entities/Audio/IHasMusicGenres.cs +++ b/MediaBrowser.Controller/Entities/Audio/IHasMusicGenres.cs @@ -1,4 +1,6 @@ -#pragma warning disable CS1591 +#nullable disable + +#pragma warning disable CA1819, CS1591 namespace MediaBrowser.Controller.Entities.Audio { diff --git a/MediaBrowser.Controller/Entities/Audio/MusicAlbum.cs b/MediaBrowser.Controller/Entities/Audio/MusicAlbum.cs index 48cd9371a0..03d1f33043 100644 --- a/MediaBrowser.Controller/Entities/Audio/MusicAlbum.cs +++ b/MediaBrowser.Controller/Entities/Audio/MusicAlbum.cs @@ -1,4 +1,6 @@ -#pragma warning disable CS1591 +#nullable disable + +#pragma warning disable CA1721, CA1826, CS1591 using System; using System.Collections.Generic; @@ -21,18 +23,18 @@ namespace MediaBrowser.Controller.Entities.Audio /// </summary> public class MusicAlbum : Folder, IHasAlbumArtist, IHasArtist, IHasMusicGenres, IHasLookupInfo<AlbumInfo>, IMetadataContainer { - /// <inheritdoc /> - public IReadOnlyList<string> AlbumArtists { get; set; } - - /// <inheritdoc /> - public IReadOnlyList<string> Artists { get; set; } - public MusicAlbum() { Artists = Array.Empty<string>(); AlbumArtists = Array.Empty<string>(); } + /// <inheritdoc /> + public IReadOnlyList<string> AlbumArtists { get; set; } + + /// <inheritdoc /> + public IReadOnlyList<string> Artists { get; set; } + [JsonIgnore] public override bool SupportsAddingToPlaylist => true; @@ -42,6 +44,25 @@ namespace MediaBrowser.Controller.Entities.Audio [JsonIgnore] public MusicArtist MusicArtist => GetMusicArtist(new DtoOptions(true)); + [JsonIgnore] + public override bool SupportsPlayedStatus => false; + + [JsonIgnore] + public override bool SupportsCumulativeRunTimeTicks => true; + + [JsonIgnore] + public string AlbumArtist => AlbumArtists.FirstOrDefault(); + + [JsonIgnore] + public override bool SupportsPeople => false; + + /// <summary> + /// Gets the tracks. + /// </summary> + /// <value>The tracks.</value> + [JsonIgnore] + public IEnumerable<Audio> Tracks => GetRecursiveChildren(i => i is Audio).Cast<Audio>(); + public MusicArtist GetMusicArtist(DtoOptions options) { var parents = GetParents(); @@ -62,25 +83,6 @@ namespace MediaBrowser.Controller.Entities.Audio return null; } - [JsonIgnore] - public override bool SupportsPlayedStatus => false; - - [JsonIgnore] - public override bool SupportsCumulativeRunTimeTicks => true; - - [JsonIgnore] - public string AlbumArtist => AlbumArtists.FirstOrDefault(); - - [JsonIgnore] - public override bool SupportsPeople => false; - - /// <summary> - /// Gets the tracks. - /// </summary> - /// <value>The tracks.</value> - [JsonIgnore] - public IEnumerable<Audio> Tracks => GetRecursiveChildren(i => i is Audio).Cast<Audio>(); - protected override IEnumerable<BaseItem> GetEligibleChildrenForRecursiveChildren(User user) { return Tracks; @@ -120,7 +122,7 @@ namespace MediaBrowser.Controller.Entities.Audio protected override bool GetBlockUnratedValue(User user) { - return user.GetPreference(PreferenceKind.BlockUnratedItems).Contains(UnratedItem.Music.ToString()); + return user.GetPreferenceValues<UnratedItem>(PreferenceKind.BlockUnratedItems).Contains(UnratedItem.Music); } public override UnratedItem GetBlockUnratedType() diff --git a/MediaBrowser.Controller/Entities/Audio/MusicArtist.cs b/MediaBrowser.Controller/Entities/Audio/MusicArtist.cs index 397a68ff7e..f30f8ce7f2 100644 --- a/MediaBrowser.Controller/Entities/Audio/MusicArtist.cs +++ b/MediaBrowser.Controller/Entities/Audio/MusicArtist.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System; @@ -6,9 +8,9 @@ using System.Linq; using System.Text.Json.Serialization; using System.Threading; using System.Threading.Tasks; +using Diacritics.Extensions; using Jellyfin.Data.Entities; using Jellyfin.Data.Enums; -using MediaBrowser.Controller.Extensions; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Entities; using Microsoft.Extensions.Logging; @@ -42,6 +44,36 @@ namespace MediaBrowser.Controller.Entities.Audio [JsonIgnore] public override bool SupportsPlayedStatus => false; + /// <summary> + /// Gets the folder containing the item. + /// If the item is a folder, it returns the folder itself. + /// </summary> + /// <value>The containing folder path.</value> + [JsonIgnore] + public override string ContainingFolderPath => Path; + + [JsonIgnore] + public override IEnumerable<BaseItem> Children + { + get + { + if (IsAccessedByName) + { + return new List<BaseItem>(); + } + + return base.Children; + } + } + + [JsonIgnore] + public override bool SupportsPeople => false; + + public static string GetPath(string name) + { + return GetPath(name, true); + } + public override double GetDefaultPrimaryImageAspectRatio() { return 1; @@ -56,27 +88,13 @@ namespace MediaBrowser.Controller.Entities.Audio { if (query.IncludeItemTypes.Length == 0) { - query.IncludeItemTypes = new[] { typeof(Audio).Name, typeof(MusicVideo).Name, typeof(MusicAlbum).Name }; + query.IncludeItemTypes = new[] { nameof(Audio), nameof(MusicVideo), nameof(MusicAlbum) }; query.ArtistIds = new[] { Id }; } return LibraryManager.GetItemList(query); } - [JsonIgnore] - public override IEnumerable<BaseItem> Children - { - get - { - if (IsAccessedByName) - { - return new List<BaseItem>(); - } - - return base.Children; - } - } - public override int GetChildCount(User user) { return IsAccessedByName ? 0 : base.GetChildCount(user); @@ -92,7 +110,7 @@ namespace MediaBrowser.Controller.Entities.Audio return base.IsSaveLocalMetadataEnabled(); } - protected override Task ValidateChildrenInternal(IProgress<double> progress, CancellationToken cancellationToken, bool recursive, bool refreshChildMetadata, MetadataRefreshOptions refreshOptions, IDirectoryService directoryService) + protected override Task ValidateChildrenInternal(IProgress<double> progress, bool recursive, bool refreshChildMetadata, MetadataRefreshOptions refreshOptions, IDirectoryService directoryService, CancellationToken cancellationToken) { if (IsAccessedByName) { @@ -100,7 +118,7 @@ namespace MediaBrowser.Controller.Entities.Audio return Task.CompletedTask; } - return base.ValidateChildrenInternal(progress, cancellationToken, recursive, refreshChildMetadata, refreshOptions, directoryService); + return base.ValidateChildrenInternal(progress, recursive, refreshChildMetadata, refreshOptions, directoryService, cancellationToken); } public override List<string> GetUserDataKeys() @@ -111,14 +129,6 @@ namespace MediaBrowser.Controller.Entities.Audio return list; } - /// <summary> - /// Returns the folder containing the item. - /// If the item is a folder, it returns the folder itself. - /// </summary> - /// <value>The containing folder path.</value> - [JsonIgnore] - public override string ContainingFolderPath => Path; - /// <summary> /// Gets the user data key. /// </summary> @@ -145,7 +155,7 @@ namespace MediaBrowser.Controller.Entities.Audio protected override bool GetBlockUnratedValue(User user) { - return user.GetPreference(PreferenceKind.BlockUnratedItems).Contains(UnratedItem.Music.ToString()); + return user.GetPreferenceValues<UnratedItem>(PreferenceKind.BlockUnratedItems).Contains(UnratedItem.Music); } public override UnratedItem GetBlockUnratedType() @@ -165,14 +175,6 @@ namespace MediaBrowser.Controller.Entities.Audio return info; } - [JsonIgnore] - public override bool SupportsPeople => false; - - public static string GetPath(string name) - { - return GetPath(name, true); - } - public static string GetPath(string name, bool normalizeName) { // Trim the period at the end because windows will have a hard time with that @@ -206,9 +208,11 @@ namespace MediaBrowser.Controller.Entities.Audio /// <summary> /// This is called before any metadata refresh and returns true or false indicating if changes were made. /// </summary> - public override bool BeforeMetadataRefresh(bool replaceAllMetdata) + /// <param name="replaceAllMetadata">Option to replace metadata.</param> + /// <returns>True if metadata changed.</returns> + public override bool BeforeMetadataRefresh(bool replaceAllMetadata) { - var hasChanges = base.BeforeMetadataRefresh(replaceAllMetdata); + var hasChanges = base.BeforeMetadataRefresh(replaceAllMetadata); if (IsAccessedByName) { diff --git a/MediaBrowser.Controller/Entities/Audio/MusicGenre.cs b/MediaBrowser.Controller/Entities/Audio/MusicGenre.cs index 5a117a6b15..dc6fcc55a5 100644 --- a/MediaBrowser.Controller/Entities/Audio/MusicGenre.cs +++ b/MediaBrowser.Controller/Entities/Audio/MusicGenre.cs @@ -1,9 +1,11 @@ +#nullable disable + #pragma warning disable CS1591 using System; using System.Collections.Generic; using System.Text.Json.Serialization; -using MediaBrowser.Controller.Extensions; +using Diacritics.Extensions; using Microsoft.Extensions.Logging; namespace MediaBrowser.Controller.Entities.Audio @@ -13,19 +15,6 @@ namespace MediaBrowser.Controller.Entities.Audio /// </summary> public class MusicGenre : BaseItem, IItemByName { - public override List<string> GetUserDataKeys() - { - var list = base.GetUserDataKeys(); - - list.Insert(0, GetType().Name + "-" + (Name ?? string.Empty).RemoveDiacritics()); - return list; - } - - public override string CreatePresentationUniqueKey() - { - return GetUserDataKeys()[0]; - } - [JsonIgnore] public override bool SupportsAddingToPlaylist => true; @@ -36,13 +25,29 @@ namespace MediaBrowser.Controller.Entities.Audio public override bool IsDisplayedAsFolder => true; /// <summary> - /// Returns the folder containing the item. + /// Gets the folder containing the item. /// If the item is a folder, it returns the folder itself. /// </summary> /// <value>The containing folder path.</value> [JsonIgnore] public override string ContainingFolderPath => Path; + [JsonIgnore] + public override bool SupportsPeople => false; + + public override List<string> GetUserDataKeys() + { + var list = base.GetUserDataKeys(); + + list.Insert(0, GetType().Name + "-" + (Name ?? string.Empty).RemoveDiacritics()); + return list; + } + + public override string CreatePresentationUniqueKey() + { + return GetUserDataKeys()[0]; + } + public override double GetDefaultPrimaryImageAspectRatio() { return 1; @@ -58,13 +63,10 @@ namespace MediaBrowser.Controller.Entities.Audio return true; } - [JsonIgnore] - public override bool SupportsPeople => false; - public IList<BaseItem> GetTaggedItems(InternalItemsQuery query) { query.GenreIds = new[] { Id }; - query.IncludeItemTypes = new[] { typeof(MusicVideo).Name, typeof(Audio).Name, typeof(MusicAlbum).Name, typeof(MusicArtist).Name }; + query.IncludeItemTypes = new[] { nameof(MusicVideo), nameof(Audio), nameof(MusicAlbum), nameof(MusicArtist) }; return LibraryManager.GetItemList(query); } @@ -104,9 +106,11 @@ namespace MediaBrowser.Controller.Entities.Audio /// <summary> /// This is called before any metadata refresh and returns true or false indicating if changes were made. /// </summary> - public override bool BeforeMetadataRefresh(bool replaceAllMetdata) + /// <param name="replaceAllMetadata">Option to replace metadata.</param> + /// <returns>True if metadata changed.</returns> + public override bool BeforeMetadataRefresh(bool replaceAllMetadata) { - var hasChanges = base.BeforeMetadataRefresh(replaceAllMetdata); + var hasChanges = base.BeforeMetadataRefresh(replaceAllMetadata); var newPath = GetRebasedPath(); if (!string.Equals(Path, newPath, StringComparison.Ordinal)) diff --git a/MediaBrowser.Controller/Entities/AudioBook.cs b/MediaBrowser.Controller/Entities/AudioBook.cs index f4bd851e1b..782481fbcd 100644 --- a/MediaBrowser.Controller/Entities/AudioBook.cs +++ b/MediaBrowser.Controller/Entities/AudioBook.cs @@ -1,4 +1,6 @@ -#pragma warning disable CS1591 +#nullable disable + +#pragma warning disable CA1724, CS1591 using System; using System.Text.Json.Serialization; diff --git a/MediaBrowser.Controller/Entities/BaseItem.cs b/MediaBrowser.Controller/Entities/BaseItem.cs index 2fc7d45c90..067fecd878 100644 --- a/MediaBrowser.Controller/Entities/BaseItem.cs +++ b/MediaBrowser.Controller/Entities/BaseItem.cs @@ -1,4 +1,6 @@ -#pragma warning disable CS1591 +#nullable disable + +#pragma warning disable CS1591, SA1401 using System; using System.Collections.Generic; @@ -9,13 +11,14 @@ using System.Text; using System.Text.Json.Serialization; using System.Threading; using System.Threading.Tasks; +using Diacritics.Extensions; using Jellyfin.Data.Entities; using Jellyfin.Data.Enums; +using Jellyfin.Extensions; using MediaBrowser.Common.Extensions; using MediaBrowser.Controller.Channels; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Dto; -using MediaBrowser.Controller.Extensions; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Persistence; using MediaBrowser.Controller.Providers; @@ -37,6 +40,22 @@ namespace MediaBrowser.Controller.Entities /// </summary> public abstract class BaseItem : IHasProviderIds, IHasLookupInfo<ItemLookupInfo>, IEquatable<BaseItem> { + /// <summary> + /// The trailer folder name. + /// </summary> + public const string TrailerFolderName = "trailers"; + public const string ThemeSongsFolderName = "theme-music"; + public const string ThemeSongFilename = "theme"; + public const string ThemeVideosFolderName = "backdrops"; + public const string ExtrasFolderName = "extras"; + public const string BehindTheScenesFolderName = "behind the scenes"; + public const string DeletedScenesFolderName = "deleted scenes"; + public const string InterviewFolderName = "interviews"; + public const string SceneFolderName = "scenes"; + public const string SampleFolderName = "samples"; + public const string ShortsFolderName = "shorts"; + public const string FeaturettesFolderName = "featurettes"; + /// <summary> /// The supported image extensions. /// </summary> @@ -58,6 +77,43 @@ namespace MediaBrowser.Controller.Entities ".ttml" }; + /// <summary> + /// Extra types that should be counted and displayed as "Special Features" in the UI. + /// </summary> + public static readonly IReadOnlyCollection<ExtraType> DisplayExtraTypes = new HashSet<ExtraType> + { + Model.Entities.ExtraType.Unknown, + Model.Entities.ExtraType.BehindTheScenes, + Model.Entities.ExtraType.Clip, + Model.Entities.ExtraType.DeletedScene, + Model.Entities.ExtraType.Interview, + Model.Entities.ExtraType.Sample, + Model.Entities.ExtraType.Scene + }; + + public static readonly char[] SlugReplaceChars = { '?', '/', '&' }; + public static readonly string[] AllExtrasTypesFolderNames = + { + ExtrasFolderName, + BehindTheScenesFolderName, + DeletedScenesFolderName, + InterviewFolderName, + SceneFolderName, + SampleFolderName, + ShortsFolderName, + FeaturettesFolderName + }; + + private string _sortName; + private Guid[] _themeSongIds; + private Guid[] _themeVideoIds; + + private string _forcedSortName; + + private string _name; + + public static char SlugChar = '-'; + protected BaseItem() { Tags = Array.Empty<string>(); @@ -71,46 +127,15 @@ namespace MediaBrowser.Controller.Entities ExtraIds = Array.Empty<Guid>(); } - public static readonly char[] SlugReplaceChars = { '?', '/', '&' }; - public static char SlugChar = '-'; - - /// <summary> - /// The trailer folder name. - /// </summary> - public const string TrailerFolderName = "trailers"; - public const string ThemeSongsFolderName = "theme-music"; - public const string ThemeSongFilename = "theme"; - public const string ThemeVideosFolderName = "backdrops"; - public const string ExtrasFolderName = "extras"; - public const string BehindTheScenesFolderName = "behind the scenes"; - public const string DeletedScenesFolderName = "deleted scenes"; - public const string InterviewFolderName = "interviews"; - public const string SceneFolderName = "scenes"; - public const string SampleFolderName = "samples"; - - public static readonly string[] AllExtrasTypesFolderNames = { - ExtrasFolderName, - BehindTheScenesFolderName, - DeletedScenesFolderName, - InterviewFolderName, - SceneFolderName, - SampleFolderName - }; - [JsonIgnore] public Guid[] ThemeSongIds { get { - if (_themeSongIds == null) - { - _themeSongIds = GetExtras() - .Where(extra => extra.ExtraType == Model.Entities.ExtraType.ThemeSong) - .Select(song => song.Id) - .ToArray(); - } - - return _themeSongIds; + return _themeSongIds ??= GetExtras() + .Where(extra => extra.ExtraType == Model.Entities.ExtraType.ThemeSong) + .Select(song => song.Id) + .ToArray(); } private set @@ -124,15 +149,10 @@ namespace MediaBrowser.Controller.Entities { get { - if (_themeVideoIds == null) - { - _themeVideoIds = GetExtras() - .Where(extra => extra.ExtraType == Model.Entities.ExtraType.ThemeVideo) - .Select(song => song.Id) - .ToArray(); - } - - return _themeVideoIds; + return _themeVideoIds ??= GetExtras() + .Where(extra => extra.ExtraType == Model.Entities.ExtraType.ThemeVideo) + .Select(song => song.Id) + .ToArray(); } private set @@ -181,7 +201,7 @@ namespace MediaBrowser.Controller.Entities public virtual bool AlwaysScanInternalMetadataPath => false; /// <summary> - /// Gets a value indicating whether this instance is in mixed folder. + /// Gets or sets a value indicating whether this instance is in mixed folder. /// </summary> /// <value><c>true</c> if this instance is in mixed folder; otherwise, <c>false</c>.</value> [JsonIgnore] @@ -196,8 +216,6 @@ namespace MediaBrowser.Controller.Entities [JsonIgnore] public virtual bool SupportsRemoteImageDownloading => true; - private string _name; - /// <summary> /// Gets or sets the name. /// </summary> @@ -248,7 +266,7 @@ namespace MediaBrowser.Controller.Entities public ProgramAudio? Audio { get; set; } /// <summary> - /// Return the id that should be used to key display prefs for this item. + /// Gets the id that should be used to key display prefs for this item. /// Default is based on the type for everything except actual generic folders. /// </summary> /// <value>The display prefs id.</value> @@ -284,7 +302,7 @@ namespace MediaBrowser.Controller.Entities } /// <summary> - /// Returns the folder containing the item. + /// Gets the folder containing the item. /// If the item is a folder, it returns the folder itself. /// </summary> [JsonIgnore] @@ -309,8 +327,11 @@ namespace MediaBrowser.Controller.Entities public string ServiceName { get; set; } /// <summary> - /// If this content came from an external service, the id of the content on that service. + /// Gets or sets the external id. /// </summary> + /// <remarks> + /// If this content came from an external service, the id of the content on that service. + /// </remarks> [JsonIgnore] public string ExternalId { get; set; } @@ -327,14 +348,8 @@ namespace MediaBrowser.Controller.Entities [JsonIgnore] public virtual bool IsHidden => false; - public BaseItem GetOwner() - { - var ownerId = OwnerId; - return ownerId.Equals(Guid.Empty) ? null : LibraryManager.GetItemById(ownerId); - } - /// <summary> - /// Gets or sets the type of the location. + /// Gets the type of the location. /// </summary> /// <value>The type of the location.</value> [JsonIgnore] @@ -343,9 +358,9 @@ namespace MediaBrowser.Controller.Entities get { // if (IsOffline) - //{ + // { // return LocationType.Offline; - //} + // } var path = Path; if (string.IsNullOrEmpty(path)) @@ -378,13 +393,6 @@ namespace MediaBrowser.Controller.Entities } } - public bool IsPathProtocol(MediaProtocol protocol) - { - var current = PathProtocol; - - return current.HasValue && current.Value == protocol; - } - [JsonIgnore] public bool IsFileProtocol => IsPathProtocol(MediaProtocol.File); @@ -422,163 +430,28 @@ namespace MediaBrowser.Controller.Entities [JsonIgnore] public virtual bool EnableAlphaNumericSorting => true; - private List<Tuple<StringBuilder, bool>> GetSortChunks(string s1) - { - var list = new List<Tuple<StringBuilder, bool>>(); - - int thisMarker = 0; - - while (thisMarker < s1.Length) - { - char thisCh = s1[thisMarker]; + public virtual bool IsHD => Height >= 720; - var thisChunk = new StringBuilder(); - bool isNumeric = char.IsDigit(thisCh); + public bool IsShortcut { get; set; } - while (thisMarker < s1.Length && char.IsDigit(thisCh) == isNumeric) - { - thisChunk.Append(thisCh); - thisMarker++; + public string ShortcutPath { get; set; } - if (thisMarker < s1.Length) - { - thisCh = s1[thisMarker]; - } - } + public int Width { get; set; } - list.Add(new Tuple<StringBuilder, bool>(thisChunk, isNumeric)); - } + public int Height { get; set; } - return list; - } + public Guid[] ExtraIds { get; set; } /// <summary> - /// This is just a helper for convenience. + /// Gets the primary image path. /// </summary> + /// <remarks> + /// This is just a helper for convenience. + /// </remarks> /// <value>The primary image path.</value> [JsonIgnore] public string PrimaryImagePath => this.GetImagePath(ImageType.Primary); - public bool IsMetadataFetcherEnabled(LibraryOptions libraryOptions, string name) - { - if (SourceType == SourceType.Channel) - { - // hack alert - return !EnableMediaSourceDisplay; - } - - var typeOptions = libraryOptions.GetTypeOptions(GetType().Name); - if (typeOptions != null) - { - return typeOptions.MetadataFetchers.Contains(name, StringComparer.OrdinalIgnoreCase); - } - - if (!libraryOptions.EnableInternetProviders) - { - return false; - } - - var itemConfig = ConfigurationManager.Configuration.MetadataOptions.FirstOrDefault(i => string.Equals(i.ItemType, GetType().Name, StringComparison.OrdinalIgnoreCase)); - - return itemConfig == null || !itemConfig.DisabledMetadataFetchers.Contains(name, StringComparer.OrdinalIgnoreCase); - } - - public bool IsImageFetcherEnabled(LibraryOptions libraryOptions, string name) - { - if (this is Channel) - { - // hack alert - return true; - } - - if (SourceType == SourceType.Channel) - { - // hack alert - return !EnableMediaSourceDisplay; - } - - var typeOptions = libraryOptions.GetTypeOptions(GetType().Name); - if (typeOptions != null) - { - return typeOptions.ImageFetchers.Contains(name, StringComparer.OrdinalIgnoreCase); - } - - if (!libraryOptions.EnableInternetProviders) - { - return false; - } - - var itemConfig = ConfigurationManager.Configuration.MetadataOptions.FirstOrDefault(i => string.Equals(i.ItemType, GetType().Name, StringComparison.OrdinalIgnoreCase)); - - return itemConfig == null || !itemConfig.DisabledImageFetchers.Contains(name, StringComparer.OrdinalIgnoreCase); - } - - public virtual bool CanDelete() - { - if (SourceType == SourceType.Channel) - { - return ChannelManager.CanDelete(this); - } - - return IsFileProtocol; - } - - public virtual bool IsAuthorizedToDelete(User user, List<Folder> allCollectionFolders) - { - if (user.HasPermission(PermissionKind.EnableContentDeletion)) - { - return true; - } - - var allowed = user.GetPreference(PreferenceKind.EnableContentDeletionFromFolders); - - if (SourceType == SourceType.Channel) - { - return allowed.Contains(ChannelId.ToString(""), StringComparer.OrdinalIgnoreCase); - } - else - { - var collectionFolders = LibraryManager.GetCollectionFolders(this, allCollectionFolders); - - foreach (var folder in collectionFolders) - { - if (allowed.Contains(folder.Id.ToString("N", CultureInfo.InvariantCulture), StringComparer.OrdinalIgnoreCase)) - { - return true; - } - } - } - - return false; - } - - public bool CanDelete(User user, List<Folder> allCollectionFolders) - { - return CanDelete() && IsAuthorizedToDelete(user, allCollectionFolders); - } - - public bool CanDelete(User user) - { - var allCollectionFolders = LibraryManager.GetUserRootFolder().Children.OfType<Folder>().ToList(); - - return CanDelete(user, allCollectionFolders); - } - - public virtual bool CanDownload() - { - return false; - } - - public virtual bool IsAuthorizedToDownload(User user) - { - return user.HasPermission(PermissionKind.EnableContentDownloading); - } - - public bool CanDownload(User user) - { - return CanDownload() && IsAuthorizedToDownload(user); - } - /// <summary> /// Gets or sets the date created. /// </summary> @@ -598,38 +471,6 @@ namespace MediaBrowser.Controller.Entities [JsonIgnore] public DateTime DateLastRefreshed { get; set; } - /// <summary> - /// The logger. - /// </summary> - public static ILogger<BaseItem> Logger { get; set; } - - public static ILibraryManager LibraryManager { get; set; } - - public static IServerConfigurationManager ConfigurationManager { get; set; } - - public static IProviderManager ProviderManager { get; set; } - - public static ILocalizationManager LocalizationManager { get; set; } - - public static IItemRepository ItemRepository { get; set; } - - public static IFileSystem FileSystem { get; set; } - - public static IUserDataManager UserDataManager { get; set; } - - public static IChannelManager ChannelManager { get; set; } - - public static IMediaSourceManager MediaSourceManager { get; set; } - - /// <summary> - /// Returns a <see cref="string" /> that represents this instance. - /// </summary> - /// <returns>A <see cref="string" /> that represents this instance.</returns> - public override string ToString() - { - return Name; - } - [JsonIgnore] public bool IsLocked { get; set; } @@ -661,223 +502,87 @@ namespace MediaBrowser.Controller.Entities } } - private string _forcedSortName; - - /// <summary> - /// Gets or sets the name of the forced sort. - /// </summary> - /// <value>The name of the forced sort.</value> - [JsonIgnore] - public string ForcedSortName - { - get => _forcedSortName; - set { _forcedSortName = value; _sortName = null; } - } - - private string _sortName; - private Guid[] _themeSongIds; - private Guid[] _themeVideoIds; - - /// <summary> - /// Gets the name of the sort. - /// </summary> - /// <value>The name of the sort.</value> [JsonIgnore] - public string SortName + public bool EnableMediaSourceDisplay { get { - if (_sortName == null) + if (SourceType == SourceType.Channel) { - if (!string.IsNullOrEmpty(ForcedSortName)) - { - // Need the ToLower because that's what CreateSortName does - _sortName = ModifySortChunks(ForcedSortName).ToLowerInvariant(); - } - else - { - _sortName = CreateSortName(); - } + return ChannelManager.EnableMediaSourceDisplay(this); } - return _sortName; + return true; } - - set => _sortName = value; - } - - public string GetInternalMetadataPath() - { - var basePath = ConfigurationManager.ApplicationPaths.InternalMetadataPath; - - return GetInternalMetadataPath(basePath); } - protected virtual string GetInternalMetadataPath(string basePath) - { - if (SourceType == SourceType.Channel) - { - return System.IO.Path.Combine(basePath, "channels", ChannelId.ToString("N", CultureInfo.InvariantCulture), Id.ToString("N", CultureInfo.InvariantCulture)); - } - - ReadOnlySpan<char> idString = Id.ToString("N", CultureInfo.InvariantCulture); - - basePath = System.IO.Path.Combine(basePath, "library"); - - return System.IO.Path.Join(basePath, idString.Slice(0, 2), idString); - } + [JsonIgnore] + public Guid ParentId { get; set; } /// <summary> - /// Creates the name of the sort. + /// Gets or sets the logger. /// </summary> - /// <returns>System.String.</returns> - protected virtual string CreateSortName() - { - if (Name == null) - { - return null; // some items may not have name filled in properly - } - - if (!EnableAlphaNumericSorting) - { - return Name.TrimStart(); - } - - var sortable = Name.Trim().ToLowerInvariant(); - - foreach (var removeChar in ConfigurationManager.Configuration.SortRemoveCharacters) - { - sortable = sortable.Replace(removeChar, string.Empty, StringComparison.Ordinal); - } - - foreach (var replaceChar in ConfigurationManager.Configuration.SortReplaceCharacters) - { - sortable = sortable.Replace(replaceChar, " ", StringComparison.Ordinal); - } - - foreach (var search in ConfigurationManager.Configuration.SortRemoveWords) - { - // Remove from beginning if a space follows - if (sortable.StartsWith(search + " ", StringComparison.Ordinal)) - { - sortable = sortable.Remove(0, search.Length + 1); - } - - // Remove from middle if surrounded by spaces - sortable = sortable.Replace(" " + search + " ", " ", StringComparison.Ordinal); - - // Remove from end if followed by a space - if (sortable.EndsWith(" " + search, StringComparison.Ordinal)) - { - sortable = sortable.Remove(sortable.Length - (search.Length + 1)); - } - } - - return ModifySortChunks(sortable); - } + public static ILogger<BaseItem> Logger { get; set; } - private string ModifySortChunks(string name) - { - var chunks = GetSortChunks(name); + public static ILibraryManager LibraryManager { get; set; } - var builder = new StringBuilder(); + public static IServerConfigurationManager ConfigurationManager { get; set; } - foreach (var chunk in chunks) - { - var chunkBuilder = chunk.Item1; + public static IProviderManager ProviderManager { get; set; } - // This chunk is numeric - if (chunk.Item2) - { - while (chunkBuilder.Length < 10) - { - chunkBuilder.Insert(0, '0'); - } - } + public static ILocalizationManager LocalizationManager { get; set; } - builder.Append(chunkBuilder); - } + public static IItemRepository ItemRepository { get; set; } - // logger.LogDebug("ModifySortChunks Start: {0} End: {1}", name, builder.ToString()); - return builder.ToString().RemoveDiacritics(); - } + public static IFileSystem FileSystem { get; set; } - [JsonIgnore] - public bool EnableMediaSourceDisplay - { - get - { - if (SourceType == SourceType.Channel) - { - return ChannelManager.EnableMediaSourceDisplay(this); - } + public static IUserDataManager UserDataManager { get; set; } - return true; - } - } + public static IChannelManager ChannelManager { get; set; } - [JsonIgnore] - public Guid ParentId { get; set; } + public static IMediaSourceManager MediaSourceManager { get; set; } /// <summary> - /// Gets or sets the parent. + /// Gets or sets the name of the forced sort. /// </summary> - /// <value>The parent.</value> + /// <value>The name of the forced sort.</value> [JsonIgnore] - public Folder Parent + public string ForcedSortName { - get => GetParent() as Folder; + get => _forcedSortName; set { - } - } - - public void SetParent(Folder parent) - { - ParentId = parent == null ? Guid.Empty : parent.Id; - } - - public BaseItem GetParent() - { - var parentId = ParentId; - if (!parentId.Equals(Guid.Empty)) - { - return LibraryManager.GetItemById(parentId); - } - - return null; - } - - public IEnumerable<BaseItem> GetParents() - { - var parent = GetParent(); - - while (parent != null) - { - yield return parent; - - parent = parent.GetParent(); + _forcedSortName = value; + _sortName = null; } } /// <summary> - /// Finds a parent of a given type. + /// Gets or sets the name of the sort. /// </summary> - /// <typeparam name="T"></typeparam> - /// <returns>``0.</returns> - public T FindParent<T>() - where T : Folder + /// <value>The name of the sort.</value> + [JsonIgnore] + public string SortName { - foreach (var parent in GetParents()) + get { - var item = parent as T; - if (item != null) + if (_sortName == null) { - return item; + if (!string.IsNullOrEmpty(ForcedSortName)) + { + // Need the ToLower because that's what CreateSortName does + _sortName = ModifySortChunks(ForcedSortName).ToLowerInvariant(); + } + else + { + _sortName = CreateSortName(); + } } + + return _sortName; } - return null; + set => _sortName = value; } [JsonIgnore] @@ -906,7 +611,7 @@ namespace MediaBrowser.Controller.Entities } /// <summary> - /// When the item first debuted. For movies this could be premiere date, episodes would be first aired + /// Gets or sets the date that the item first debuted. For movies this could be premiere date, episodes would be first aired. /// </summary> /// <value>The premiere date.</value> [JsonIgnore] @@ -1003,7 +708,7 @@ namespace MediaBrowser.Controller.Entities public int? ProductionYear { get; set; } /// <summary> - /// If the item is part of a series, this is it's number in the series. + /// Gets or sets the index number. If the item is part of a series, this is it's number in the series. /// This could be episode number, album track number, etc. /// </summary> /// <value>The index number.</value> @@ -1011,55 +716,398 @@ namespace MediaBrowser.Controller.Entities public int? IndexNumber { get; set; } /// <summary> - /// For an episode this could be the season number, or for a song this could be the disc number. + /// Gets or sets the parent index number. For an episode this could be the season number, or for a song this could be the disc number. /// </summary> /// <value>The parent index number.</value> [JsonIgnore] public int? ParentIndexNumber { get; set; } - [JsonIgnore] - public virtual bool HasLocalAlternateVersions => false; + [JsonIgnore] + public virtual bool HasLocalAlternateVersions => false; + + [JsonIgnore] + public string OfficialRatingForComparison + { + get + { + var officialRating = OfficialRating; + if (!string.IsNullOrEmpty(officialRating)) + { + return officialRating; + } + + var parent = DisplayParent; + if (parent != null) + { + return parent.OfficialRatingForComparison; + } + + return null; + } + } + + [JsonIgnore] + public string CustomRatingForComparison + { + get + { + var customRating = CustomRating; + if (!string.IsNullOrEmpty(customRating)) + { + return customRating; + } + + var parent = DisplayParent; + if (parent != null) + { + return parent.CustomRatingForComparison; + } + + return null; + } + } + + /// <summary> + /// Gets or sets the provider ids. + /// </summary> + /// <value>The provider ids.</value> + [JsonIgnore] + public Dictionary<string, string> ProviderIds { get; set; } + + [JsonIgnore] + public virtual Folder LatestItemsIndexContainer => null; + + [JsonIgnore] + public string PresentationUniqueKey { get; set; } + + [JsonIgnore] + public virtual bool EnableRememberingTrackSelections => true; + + [JsonIgnore] + public virtual bool IsTopParent + { + get + { + if (this is BasePluginFolder || this is Channel) + { + return true; + } + + if (this is IHasCollectionType view) + { + if (string.Equals(view.CollectionType, CollectionType.LiveTv, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + + if (GetParent() is AggregateFolder) + { + return true; + } + + return false; + } + } + + [JsonIgnore] + public virtual bool SupportsAncestors => true; + + [JsonIgnore] + public virtual bool StopRefreshIfLocalMetadataFound => true; + + [JsonIgnore] + protected virtual bool SupportsOwnedItems => !ParentId.Equals(Guid.Empty) && IsFileProtocol; + + [JsonIgnore] + public virtual bool SupportsPeople => false; + + [JsonIgnore] + public virtual bool SupportsThemeMedia => false; + + [JsonIgnore] + public virtual bool SupportsInheritedParentImages => false; + + /// <summary> + /// Gets a value indicating whether this instance is folder. + /// </summary> + /// <value><c>true</c> if this instance is folder; otherwise, <c>false</c>.</value> + [JsonIgnore] + public virtual bool IsFolder => false; + + [JsonIgnore] + public virtual bool IsDisplayedAsFolder => false; + + /// <summary> + /// Gets or sets the remote trailers. + /// </summary> + /// <value>The remote trailers.</value> + public IReadOnlyList<MediaUrl> RemoteTrailers { get; set; } + + public virtual bool SupportsExternalTransfer => false; + + public virtual double GetDefaultPrimaryImageAspectRatio() + { + return 0; + } + + public virtual string CreatePresentationUniqueKey() + { + return Id.ToString("N", CultureInfo.InvariantCulture); + } + + public bool IsPathProtocol(MediaProtocol protocol) + { + var current = PathProtocol; + + return current.HasValue && current.Value == protocol; + } + + private List<Tuple<StringBuilder, bool>> GetSortChunks(string s1) + { + var list = new List<Tuple<StringBuilder, bool>>(); + + int thisMarker = 0; + + while (thisMarker < s1.Length) + { + char thisCh = s1[thisMarker]; + + var thisChunk = new StringBuilder(); + bool isNumeric = char.IsDigit(thisCh); + + while (thisMarker < s1.Length && char.IsDigit(thisCh) == isNumeric) + { + thisChunk.Append(thisCh); + thisMarker++; + + if (thisMarker < s1.Length) + { + thisCh = s1[thisMarker]; + } + } + + list.Add(new Tuple<StringBuilder, bool>(thisChunk, isNumeric)); + } + + return list; + } + + public virtual bool CanDelete() + { + if (SourceType == SourceType.Channel) + { + return ChannelManager.CanDelete(this); + } + + return IsFileProtocol; + } + + public virtual bool IsAuthorizedToDelete(User user, List<Folder> allCollectionFolders) + { + if (user.HasPermission(PermissionKind.EnableContentDeletion)) + { + return true; + } + + var allowed = user.GetPreferenceValues<Guid>(PreferenceKind.EnableContentDeletionFromFolders); + + if (SourceType == SourceType.Channel) + { + return allowed.Contains(ChannelId); + } + else + { + var collectionFolders = LibraryManager.GetCollectionFolders(this, allCollectionFolders); + + foreach (var folder in collectionFolders) + { + if (allowed.Contains(folder.Id)) + { + return true; + } + } + } + + return false; + } + + public BaseItem GetOwner() + { + var ownerId = OwnerId; + return ownerId.Equals(Guid.Empty) ? null : LibraryManager.GetItemById(ownerId); + } + + public bool CanDelete(User user, List<Folder> allCollectionFolders) + { + return CanDelete() && IsAuthorizedToDelete(user, allCollectionFolders); + } + + public bool CanDelete(User user) + { + var allCollectionFolders = LibraryManager.GetUserRootFolder().Children.OfType<Folder>().ToList(); + + return CanDelete(user, allCollectionFolders); + } + + public virtual bool CanDownload() + { + return false; + } + + public virtual bool IsAuthorizedToDownload(User user) + { + return user.HasPermission(PermissionKind.EnableContentDownloading); + } + + public bool CanDownload(User user) + { + return CanDownload() && IsAuthorizedToDownload(user); + } + + /// <summary> + /// Returns a <see cref="string" /> that represents this instance. + /// </summary> + /// <returns>A <see cref="string" /> that represents this instance.</returns> + public override string ToString() + { + return Name; + } + + public string GetInternalMetadataPath() + { + var basePath = ConfigurationManager.ApplicationPaths.InternalMetadataPath; + + return GetInternalMetadataPath(basePath); + } + + protected virtual string GetInternalMetadataPath(string basePath) + { + if (SourceType == SourceType.Channel) + { + return System.IO.Path.Join(basePath, "channels", ChannelId.ToString("N", CultureInfo.InvariantCulture), Id.ToString("N", CultureInfo.InvariantCulture)); + } + + ReadOnlySpan<char> idString = Id.ToString("N", CultureInfo.InvariantCulture); + + return System.IO.Path.Join(basePath, "library", idString.Slice(0, 2), idString); + } + + /// <summary> + /// Creates the name of the sort. + /// </summary> + /// <returns>System.String.</returns> + protected virtual string CreateSortName() + { + if (Name == null) + { + return null; // some items may not have name filled in properly + } + + if (!EnableAlphaNumericSorting) + { + return Name.TrimStart(); + } + + var sortable = Name.Trim().ToLowerInvariant(); + + foreach (var removeChar in ConfigurationManager.Configuration.SortRemoveCharacters) + { + sortable = sortable.Replace(removeChar, string.Empty, StringComparison.Ordinal); + } + + foreach (var replaceChar in ConfigurationManager.Configuration.SortReplaceCharacters) + { + sortable = sortable.Replace(replaceChar, " ", StringComparison.Ordinal); + } + + foreach (var search in ConfigurationManager.Configuration.SortRemoveWords) + { + // Remove from beginning if a space follows + if (sortable.StartsWith(search + " ", StringComparison.Ordinal)) + { + sortable = sortable.Remove(0, search.Length + 1); + } + + // Remove from middle if surrounded by spaces + sortable = sortable.Replace(" " + search + " ", " ", StringComparison.Ordinal); + + // Remove from end if followed by a space + if (sortable.EndsWith(" " + search, StringComparison.Ordinal)) + { + sortable = sortable.Remove(sortable.Length - (search.Length + 1)); + } + } + + return ModifySortChunks(sortable); + } + + private string ModifySortChunks(string name) + { + var chunks = GetSortChunks(name); + + var builder = new StringBuilder(); + + foreach (var chunk in chunks) + { + var chunkBuilder = chunk.Item1; + + // This chunk is numeric + if (chunk.Item2) + { + while (chunkBuilder.Length < 10) + { + chunkBuilder.Insert(0, '0'); + } + } + + builder.Append(chunkBuilder); + } + + // logger.LogDebug("ModifySortChunks Start: {0} End: {1}", name, builder.ToString()); + return builder.ToString().RemoveDiacritics(); + } - [JsonIgnore] - public string OfficialRatingForComparison + public BaseItem GetParent() { - get + var parentId = ParentId; + if (!parentId.Equals(Guid.Empty)) { - var officialRating = OfficialRating; - if (!string.IsNullOrEmpty(officialRating)) - { - return officialRating; - } + return LibraryManager.GetItemById(parentId); + } - var parent = DisplayParent; - if (parent != null) - { - return parent.OfficialRatingForComparison; - } + return null; + } - return null; + public IEnumerable<BaseItem> GetParents() + { + var parent = GetParent(); + + while (parent != null) + { + yield return parent; + + parent = parent.GetParent(); } } - [JsonIgnore] - public string CustomRatingForComparison + /// <summary> + /// Finds a parent of a given type. + /// </summary> + /// <typeparam name="T">Type of parent.</typeparam> + /// <returns>``0.</returns> + public T FindParent<T>() + where T : Folder { - get + foreach (var parent in GetParents()) { - var customRating = CustomRating; - if (!string.IsNullOrEmpty(customRating)) + if (parent is T item) { - return customRating; - } - - var parent = DisplayParent; - if (parent != null) - { - return parent.CustomRatingForComparison; + return item; } - - return null; } + + return null; } /// <summary> @@ -1075,9 +1123,9 @@ namespace MediaBrowser.Controller.Entities } // if (!user.IsParentalScheduleAllowed()) - //{ + // { // return PlayAccess.None; - //} + // } return PlayAccess.Full; } @@ -1293,7 +1341,7 @@ namespace MediaBrowser.Controller.Entities } } - return string.Join("/", terms.ToArray()); + return string.Join('/', terms.ToArray()); } /// <summary> @@ -1309,7 +1357,7 @@ namespace MediaBrowser.Controller.Entities // Support plex/xbmc convention files.AddRange(fileSystemChildren - .Where(i => !i.IsDirectory && string.Equals(FileSystem.GetFileNameWithoutExtension(i), ThemeSongFilename, StringComparison.OrdinalIgnoreCase))); + .Where(i => !i.IsDirectory && System.IO.Path.GetFileNameWithoutExtension(i.FullName.AsSpan()).Equals(ThemeSongFilename, StringComparison.OrdinalIgnoreCase))); return LibraryManager.ResolvePaths(files, directoryService, null, new LibraryOptions()) .OfType<Audio.Audio>() @@ -1370,14 +1418,16 @@ namespace MediaBrowser.Controller.Entities { var extras = new List<Video>(); - var folders = fileSystemChildren.Where(i => i.IsDirectory).ToArray(); + var libraryOptions = new LibraryOptions(); + var folders = fileSystemChildren.Where(i => i.IsDirectory).ToList(); foreach (var extraFolderName in AllExtrasTypesFolderNames) { var files = folders .Where(i => string.Equals(i.Name, extraFolderName, StringComparison.OrdinalIgnoreCase)) .SelectMany(i => FileSystem.GetFiles(i.FullName)); - extras.AddRange(LibraryManager.ResolvePaths(files, directoryService, null, new LibraryOptions()) + // Re-using the same instance of LibraryOptions since it looks like it's never being altered. + extras.AddRange(LibraryManager.ResolvePaths(files, directoryService, null, libraryOptions) .OfType<Video>() .Select(item => { @@ -1388,7 +1438,7 @@ namespace MediaBrowser.Controller.Entities } // Use some hackery to get the extra type based on foldername - item.ExtraType = Enum.TryParse(extraFolderName.Replace(" ", string.Empty), true, out ExtraType extraType) + item.ExtraType = Enum.TryParse(extraFolderName.Replace(" ", string.Empty, StringComparison.Ordinal), true, out ExtraType extraType) ? extraType : Model.Entities.ExtraType.Unknown; @@ -1465,23 +1515,55 @@ namespace MediaBrowser.Controller.Entities } } - [JsonIgnore] - protected virtual bool SupportsOwnedItems => !ParentId.Equals(Guid.Empty) && IsFileProtocol; + protected bool IsVisibleStandaloneInternal(User user, bool checkFolders) + { + if (!IsVisible(user)) + { + return false; + } - [JsonIgnore] - public virtual bool SupportsPeople => false; + if (GetParents().Any(i => !i.IsVisible(user))) + { + return false; + } - [JsonIgnore] - public virtual bool SupportsThemeMedia => false; + if (checkFolders) + { + var topParent = GetParents().LastOrDefault() ?? this; + + if (string.IsNullOrEmpty(topParent.Path)) + { + return true; + } + + var itemCollectionFolders = LibraryManager.GetCollectionFolders(this).Select(i => i.Id).ToList(); + + if (itemCollectionFolders.Count > 0) + { + var userCollectionFolders = LibraryManager.GetUserRootFolder().GetChildren(user, true).Select(i => i.Id).ToList(); + if (!itemCollectionFolders.Any(userCollectionFolders.Contains)) + { + return false; + } + } + } + + return true; + } + + public void SetParent(Folder parent) + { + ParentId = parent == null ? Guid.Empty : parent.Id; + } /// <summary> /// Refreshes owned items such as trailers, theme videos, special features, etc. /// Returns true or false indicating if changes were found. /// </summary> - /// <param name="options"></param> - /// <param name="fileSystemChildren"></param> - /// <param name="cancellationToken"></param> - /// <returns></returns> + /// <param name="options">The metadata refresh options.</param> + /// <param name="fileSystemChildren">The list of filesystem children.</param> + /// <param name="cancellationToken">The cancellation token.</param> + /// <returns><c>true</c> if any items have changed, else <c>false</c>.</returns> protected virtual async Task<bool> RefreshedOwnedItems(MetadataRefreshOptions options, List<FileSystemMetadata> fileSystemChildren, CancellationToken cancellationToken) { var themeSongsChanged = false; @@ -1669,29 +1751,6 @@ namespace MediaBrowser.Controller.Entities return themeSongsChanged; } - /// <summary> - /// Gets or sets the provider ids. - /// </summary> - /// <value>The provider ids.</value> - [JsonIgnore] - public Dictionary<string, string> ProviderIds { get; set; } - - [JsonIgnore] - public virtual Folder LatestItemsIndexContainer => null; - - public virtual double GetDefaultPrimaryImageAspectRatio() - { - return 0; - } - - public virtual string CreatePresentationUniqueKey() - { - return Id.ToString("N", CultureInfo.InvariantCulture); - } - - [JsonIgnore] - public string PresentationUniqueKey { get; set; } - public string GetPresentationUniqueKey() { return PresentationUniqueKey ?? CreatePresentationUniqueKey(); @@ -1823,7 +1882,7 @@ namespace MediaBrowser.Controller.Entities /// </summary> /// <param name="user">The user.</param> /// <returns><c>true</c> if [is parental allowed] [the specified user]; otherwise, <c>false</c>.</returns> - /// <exception cref="ArgumentNullException">user</exception> + /// <exception cref="ArgumentNullException">If user is null.</exception> public bool IsParentalAllowed(User user) { if (user == null) @@ -1959,7 +2018,7 @@ namespace MediaBrowser.Controller.Entities return false; } - return user.GetPreference(PreferenceKind.BlockUnratedItems).Contains(GetBlockUnratedType().ToString()); + return user.GetPreferenceValues<UnratedItem>(PreferenceKind.BlockUnratedItems).Contains(GetBlockUnratedType()); } /// <summary> @@ -1968,7 +2027,7 @@ namespace MediaBrowser.Controller.Entities /// </summary> /// <param name="user">The user.</param> /// <returns><c>true</c> if the specified user is visible; otherwise, <c>false</c>.</returns> - /// <exception cref="ArgumentNullException">user</exception> + /// <exception cref="ArgumentNullException"><paramref name="user" /> is <c>null</c>.</exception> public virtual bool IsVisible(User user) { if (user == null) @@ -1989,55 +2048,6 @@ namespace MediaBrowser.Controller.Entities return IsVisibleStandaloneInternal(user, true); } - [JsonIgnore] - public virtual bool SupportsInheritedParentImages => false; - - protected bool IsVisibleStandaloneInternal(User user, bool checkFolders) - { - if (!IsVisible(user)) - { - return false; - } - - if (GetParents().Any(i => !i.IsVisible(user))) - { - return false; - } - - if (checkFolders) - { - var topParent = GetParents().LastOrDefault() ?? this; - - if (string.IsNullOrEmpty(topParent.Path)) - { - return true; - } - - var itemCollectionFolders = LibraryManager.GetCollectionFolders(this).Select(i => i.Id).ToList(); - - if (itemCollectionFolders.Count > 0) - { - var userCollectionFolders = LibraryManager.GetUserRootFolder().GetChildren(user, true).Select(i => i.Id).ToList(); - if (!itemCollectionFolders.Any(userCollectionFolders.Contains)) - { - return false; - } - } - } - - return true; - } - - /// <summary> - /// Gets a value indicating whether this instance is folder. - /// </summary> - /// <value><c>true</c> if this instance is folder; otherwise, <c>false</c>.</value> - [JsonIgnore] - public virtual bool IsFolder => false; - - [JsonIgnore] - public virtual bool IsDisplayedAsFolder => false; - public virtual string GetClientTypeName() { if (IsFolder && SourceType == SourceType.Channel && !(this is Channel)) @@ -2048,6 +2058,11 @@ namespace MediaBrowser.Controller.Entities return GetType().Name; } + public BaseItemKind GetBaseItemKind() + { + return Enum.Parse<BaseItemKind>(GetClientTypeName()); + } + /// <summary> /// Gets the linked child. /// </summary> @@ -2121,14 +2136,11 @@ namespace MediaBrowser.Controller.Entities return null; } - [JsonIgnore] - public virtual bool EnableRememberingTrackSelections => true; - /// <summary> /// Adds a studio to the item. /// </summary> /// <param name="name">The name.</param> - /// <exception cref="ArgumentNullException"></exception> + /// <exception cref="ArgumentNullException">Throws if name is null.</exception> public void AddStudio(string name) { if (string.IsNullOrEmpty(name)) @@ -2164,7 +2176,7 @@ namespace MediaBrowser.Controller.Entities /// Adds a genre to the item. /// </summary> /// <param name="name">The name.</param> - /// <exception cref="ArgumentNullException"></exception> + /// <exception cref="ArgumentNullException">Throwns if name is null.</exception> public void AddGenre(string name) { if (string.IsNullOrEmpty(name)) @@ -2187,8 +2199,7 @@ namespace MediaBrowser.Controller.Entities /// <param name="user">The user.</param> /// <param name="datePlayed">The date played.</param> /// <param name="resetPosition">if set to <c>true</c> [reset position].</param> - /// <returns>Task.</returns> - /// <exception cref="ArgumentNullException"></exception> + /// <exception cref="ArgumentNullException">Throws if user is null.</exception> public virtual void MarkPlayed( User user, DateTime? datePlayed, @@ -2225,8 +2236,7 @@ namespace MediaBrowser.Controller.Entities /// Marks the unplayed. /// </summary> /// <param name="user">The user.</param> - /// <returns>Task.</returns> - /// <exception cref="ArgumentNullException"></exception> + /// <exception cref="ArgumentNullException">Throws if user is null.</exception> public virtual void MarkUnplayed(User user) { if (user == null) @@ -2261,7 +2271,7 @@ namespace MediaBrowser.Controller.Entities /// <param name="type">The type.</param> /// <param name="imageIndex">Index of the image.</param> /// <returns><c>true</c> if the specified type has image; otherwise, <c>false</c>.</returns> - /// <exception cref="ArgumentException">Backdrops should be accessed using Item.Backdrops</exception> + /// <exception cref="ArgumentException">Backdrops should be accessed using Item.Backdrops.</exception> public bool HasImage(ImageType type, int imageIndex) { return GetImageInfo(type, imageIndex) != null; @@ -2326,6 +2336,7 @@ namespace MediaBrowser.Controller.Entities /// </summary> /// <param name="type">The type.</param> /// <param name="index">The index.</param> + /// <returns>A task.</returns> public async Task DeleteImageAsync(ImageType type, int index) { var info = GetImageInfo(type, index); @@ -2363,13 +2374,15 @@ namespace MediaBrowser.Controller.Entities /// <summary> /// Validates that images within the item are still on the filesystem. /// </summary> + /// <param name="directoryService">The directory service to use.</param> + /// <returns><c>true</c> if the images validate, <c>false</c> if not.</returns> public bool ValidateImages(IDirectoryService directoryService) { var allFiles = ImageInfos .Where(i => i.IsLocalFile) .Select(i => System.IO.Path.GetDirectoryName(i.Path)) .Distinct(StringComparer.OrdinalIgnoreCase) - .SelectMany(i => directoryService.GetFilePaths(i)) + .SelectMany(path => directoryService.GetFilePaths(path)) .ToList(); var deletedImages = ImageInfos @@ -2390,9 +2403,7 @@ namespace MediaBrowser.Controller.Entities /// <param name="imageType">Type of the image.</param> /// <param name="imageIndex">Index of the image.</param> /// <returns>System.String.</returns> - /// <exception cref="InvalidOperationException"> - /// </exception> - /// <exception cref="ArgumentNullException">item</exception> + /// <exception cref="ArgumentNullException">Item is null.</exception> public string GetImagePath(ImageType imageType, int imageIndex) => GetImageInfo(imageType, imageIndex)?.Path; @@ -2479,7 +2490,15 @@ namespace MediaBrowser.Controller.Entities throw new ArgumentException("No image info for chapter images"); } - return ImageInfos.Where(i => i.Type == imageType); + // Yield return is more performant than LINQ Where on an Array + for (var i = 0; i < ImageInfos.Length; i++) + { + var imageInfo = ImageInfos[i]; + if (imageInfo.Type == imageType) + { + yield return imageInfo; + } + } } /// <summary> @@ -2488,7 +2507,7 @@ namespace MediaBrowser.Controller.Entities /// <param name="imageType">Type of the image.</param> /// <param name="images">The images.</param> /// <returns><c>true</c> if XXXX, <c>false</c> otherwise.</returns> - /// <exception cref="ArgumentException">Cannot call AddImages with chapter images</exception> + /// <exception cref="ArgumentException">Cannot call AddImages with chapter images.</exception> public bool AddImages(ImageType imageType, List<FileSystemMetadata> images) { if (imageType == ImageType.Chapter) @@ -2511,7 +2530,7 @@ namespace MediaBrowser.Controller.Entities } var existing = existingImages - .FirstOrDefault(i => string.Equals(i.Path, newImage.FullName, StringComparison.OrdinalIgnoreCase)); + .Find(i => string.Equals(i.Path, newImage.FullName, StringComparison.OrdinalIgnoreCase)); if (existing == null) { @@ -2542,8 +2561,7 @@ namespace MediaBrowser.Controller.Entities var newImagePaths = images.Select(i => i.FullName).ToList(); var deleted = existingImages - .Where(i => i.IsLocalFile && !newImagePaths.Contains(i.Path, StringComparer.OrdinalIgnoreCase) && !File.Exists(i.Path)) - .ToList(); + .FindAll(i => i.IsLocalFile && !newImagePaths.Contains(i.Path.AsSpan(), StringComparison.OrdinalIgnoreCase) && !File.Exists(i.Path)); if (deleted.Count > 0) { @@ -2572,10 +2590,11 @@ namespace MediaBrowser.Controller.Entities /// <summary> /// Gets the file system path to delete when the item is to be deleted. /// </summary> - /// <returns></returns> + /// <returns>The metadata for the deleted paths.</returns> public virtual IEnumerable<FileSystemMetadata> GetDeletePaths() { - return new[] { + return new[] + { new FileSystemMetadata { FullName = Path, @@ -2607,7 +2626,7 @@ namespace MediaBrowser.Controller.Entities { if (!AllowsMultipleImages(type)) { - throw new ArgumentException("The change index operation is only applicable to backdrops and screenshots"); + throw new ArgumentException("The change index operation is only applicable to backdrops and screen shots"); } var info1 = GetImageInfo(type, index1); @@ -2682,6 +2701,7 @@ namespace MediaBrowser.Controller.Entities MetadataCountryCode = GetPreferredMetadataCountryCode(), MetadataLanguage = GetPreferredMetadataLanguage(), Name = GetNameForMetadataLookup(), + OriginalTitle = OriginalTitle, ProviderIds = ProviderIds, IndexNumber = IndexNumber, ParentIndexNumber = ParentIndexNumber, @@ -2698,7 +2718,9 @@ namespace MediaBrowser.Controller.Entities /// <summary> /// This is called before any metadata refresh and returns true if changes were made. /// </summary> - public virtual bool BeforeMetadataRefresh(bool replaceAllMetdata) + /// <param name="replaceAllMetadata">Whether to replace all metadata.</param> + /// <returns>true if the item has change, else false.</returns> + public virtual bool BeforeMetadataRefresh(bool replaceAllMetadata) { _sortName = null; @@ -2822,11 +2844,11 @@ namespace MediaBrowser.Controller.Entities // var parentId = Id; // if (!video.IsOwnedItem || video.ParentId != parentId) - //{ + // { // video.IsOwnedItem = true; // video.ParentId = parentId; // newOptions.ForceSave = true; - //} + // } if (video == null) { @@ -2840,7 +2862,7 @@ namespace MediaBrowser.Controller.Entities { var list = GetEtagValues(user); - return string.Join("|", list).GetMD5().ToString("N", CultureInfo.InvariantCulture); + return string.Join('|', list).GetMD5().ToString("N", CultureInfo.InvariantCulture); } protected virtual List<string> GetEtagValues(User user) @@ -2866,39 +2888,6 @@ namespace MediaBrowser.Controller.Entities return GetParents().FirstOrDefault(parent => parent.IsTopParent); } - [JsonIgnore] - public virtual bool IsTopParent - { - get - { - if (this is BasePluginFolder || this is Channel) - { - return true; - } - - if (this is IHasCollectionType view) - { - if (string.Equals(view.CollectionType, CollectionType.LiveTv, StringComparison.OrdinalIgnoreCase)) - { - return true; - } - } - - if (GetParent() is AggregateFolder) - { - return true; - } - - return false; - } - } - - [JsonIgnore] - public virtual bool SupportsAncestors => true; - - [JsonIgnore] - public virtual bool StopRefreshIfLocalMetadataFound => true; - public virtual IEnumerable<Guid> GetIdsForAncestorQuery() { return new[] { Id }; @@ -2933,7 +2922,8 @@ namespace MediaBrowser.Controller.Entities /// <summary> /// Updates the official rating based on content and returns true or false indicating if it changed. /// </summary> - /// <returns></returns> + /// <param name="children">Media children.</param> + /// <returns><c>true</c> if the rating was updated; otherwise <c>false</c>.</returns> public bool UpdateRatingToItems(IList<BaseItem> children) { var currentOfficialRating = OfficialRating; @@ -2949,7 +2939,9 @@ namespace MediaBrowser.Controller.Entities OfficialRating = ratings.FirstOrDefault() ?? currentOfficialRating; - return !string.Equals(currentOfficialRating ?? string.Empty, OfficialRating ?? string.Empty, + return !string.Equals( + currentOfficialRating ?? string.Empty, + OfficialRating ?? string.Empty, StringComparison.OrdinalIgnoreCase); } @@ -2963,12 +2955,6 @@ namespace MediaBrowser.Controller.Entities return ThemeVideoIds.Select(LibraryManager.GetItemById); } - /// <summary> - /// Gets or sets the remote trailers. - /// </summary> - /// <value>The remote trailers.</value> - public IReadOnlyList<MediaUrl> RemoteTrailers { get; set; } - /// <summary> /// Get all extras associated with this item, sorted by <see cref="SortName"/>. /// </summary> @@ -3006,39 +2992,11 @@ namespace MediaBrowser.Controller.Entities } } - public virtual bool IsHD => Height >= 720; - - public bool IsShortcut { get; set; } - - public string ShortcutPath { get; set; } - - public int Width { get; set; } - - public int Height { get; set; } - - public Guid[] ExtraIds { get; set; } - public virtual long GetRunTimeTicksForPlayState() { return RunTimeTicks ?? 0; } - /// <summary> - /// Extra types that should be counted and displayed as "Special Features" in the UI. - /// </summary> - public static readonly IReadOnlyCollection<ExtraType> DisplayExtraTypes = new HashSet<ExtraType> - { - Model.Entities.ExtraType.Unknown, - Model.Entities.ExtraType.BehindTheScenes, - Model.Entities.ExtraType.Clip, - Model.Entities.ExtraType.DeletedScene, - Model.Entities.ExtraType.Interview, - Model.Entities.ExtraType.Sample, - Model.Entities.ExtraType.Scene - }; - - public virtual bool SupportsExternalTransfer => false; - /// <inheritdoc /> public override bool Equals(object obj) { @@ -3046,7 +3004,7 @@ namespace MediaBrowser.Controller.Entities } /// <inheritdoc /> - public bool Equals(BaseItem item) => Object.Equals(Id, item?.Id); + public bool Equals(BaseItem other) => object.Equals(Id, other?.Id); /// <inheritdoc /> public override int GetHashCode() => HashCode.Combine(Id); diff --git a/MediaBrowser.Controller/Entities/BaseItemExtensions.cs b/MediaBrowser.Controller/Entities/BaseItemExtensions.cs index c65477d39a..e88121212a 100644 --- a/MediaBrowser.Controller/Entities/BaseItemExtensions.cs +++ b/MediaBrowser.Controller/Entities/BaseItemExtensions.cs @@ -1,5 +1,6 @@ #pragma warning disable CS1591 +using System; using System.Linq; using MediaBrowser.Model.Entities; using MediaBrowser.Model.IO; @@ -63,10 +64,22 @@ namespace MediaBrowser.Controller.Entities /// </summary> /// <param name="source">The source object.</param> /// <param name="dest">The destination object.</param> + /// <typeparam name="T">Source type.</typeparam> + /// <typeparam name="TU">Destination type.</typeparam> public static void DeepCopy<T, TU>(this T source, TU dest) - where T : BaseItem - where TU : BaseItem + where T : BaseItem + where TU : BaseItem { + if (source == null) + { + throw new ArgumentNullException(nameof(source)); + } + + if (dest == null) + { + throw new ArgumentNullException(nameof(dest)); + } + var destProps = typeof(TU).GetProperties().Where(x => x.CanWrite).ToList(); foreach (var sourceProp in typeof(T).GetProperties()) @@ -98,9 +111,12 @@ namespace MediaBrowser.Controller.Entities /// Copies all properties on newly created object. Skips properties that do not exist. /// </summary> /// <param name="source">The source object.</param> + /// <typeparam name="T">Source type.</typeparam> + /// <typeparam name="TU">Destination type.</typeparam> + /// <returns>Destination object.</returns> public static TU DeepCopy<T, TU>(this T source) - where T : BaseItem - where TU : BaseItem, new() + where T : BaseItem + where TU : BaseItem, new() { var dest = new TU(); source.DeepCopy(dest); diff --git a/MediaBrowser.Controller/Entities/BasePluginFolder.cs b/MediaBrowser.Controller/Entities/BasePluginFolder.cs index ef5a5a734c..272a37df1b 100644 --- a/MediaBrowser.Controller/Entities/BasePluginFolder.cs +++ b/MediaBrowser.Controller/Entities/BasePluginFolder.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System.Text.Json.Serialization; @@ -13,6 +15,12 @@ namespace MediaBrowser.Controller.Entities [JsonIgnore] public virtual string CollectionType => null; + [JsonIgnore] + public override bool SupportsInheritedParentImages => false; + + [JsonIgnore] + public override bool SupportsPeople => false; + public override bool CanDelete() { return false; @@ -22,11 +30,5 @@ namespace MediaBrowser.Controller.Entities { return true; } - - [JsonIgnore] - public override bool SupportsInheritedParentImages => false; - - [JsonIgnore] - public override bool SupportsPeople => false; } } diff --git a/MediaBrowser.Controller/Entities/Book.cs b/MediaBrowser.Controller/Entities/Book.cs index 55945283c9..d75beb06da 100644 --- a/MediaBrowser.Controller/Entities/Book.cs +++ b/MediaBrowser.Controller/Entities/Book.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System; @@ -10,6 +12,11 @@ namespace MediaBrowser.Controller.Entities { public class Book : BaseItem, IHasLookupInfo<BookInfo>, IHasSeries { + public Book() + { + this.RunTimeTicks = TimeSpan.TicksPerSecond; + } + [JsonIgnore] public override string MediaType => Model.Entities.MediaType.Book; @@ -26,11 +33,6 @@ namespace MediaBrowser.Controller.Entities [JsonIgnore] public Guid SeriesId { get; set; } - public Book() - { - this.RunTimeTicks = TimeSpan.TicksPerSecond; - } - public string FindSeriesSortName() { return SeriesName; diff --git a/MediaBrowser.Controller/Entities/CollectionFolder.cs b/MediaBrowser.Controller/Entities/CollectionFolder.cs index d25545a2fc..0fb4771dd3 100644 --- a/MediaBrowser.Controller/Entities/CollectionFolder.cs +++ b/MediaBrowser.Controller/Entities/CollectionFolder.cs @@ -1,12 +1,16 @@ +#nullable disable + #pragma warning disable CS1591 using System; using System.Collections.Generic; using System.IO; using System.Linq; +using System.Text.Json; using System.Text.Json.Serialization; using System.Threading; using System.Threading.Tasks; +using Jellyfin.Extensions.Json; using MediaBrowser.Controller.IO; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Providers; @@ -24,32 +28,66 @@ namespace MediaBrowser.Controller.Entities /// </summary> public class CollectionFolder : Folder, ICollectionFolder { - public static IXmlSerializer XmlSerializer { get; set; } - - public static IJsonSerializer JsonSerializer { get; set; } - - public static IServerApplicationHost ApplicationHost { get; set; } + private static readonly JsonSerializerOptions _jsonOptions = JsonDefaults.Options; + private static readonly Dictionary<string, LibraryOptions> _libraryOptions = new Dictionary<string, LibraryOptions>(); + private bool _requiresRefresh; + /// <summary> + /// Initializes a new instance of the <see cref="CollectionFolder"/> class. + /// </summary> public CollectionFolder() { PhysicalLocationsList = Array.Empty<string>(); PhysicalFolderIds = Array.Empty<Guid>(); } + /// <summary> + /// Gets the display preferences id. + /// </summary> + /// <remarks> + /// Allow different display preferences for each collection folder. + /// </remarks> + /// <value>The display prefs id.</value> + [JsonIgnore] + public override Guid DisplayPreferencesId => Id; + + [JsonIgnore] + public override string[] PhysicalLocations => PhysicalLocationsList; + + public string[] PhysicalLocationsList { get; set; } + + public Guid[] PhysicalFolderIds { get; set; } + + public static IXmlSerializer XmlSerializer { get; set; } + + public static IServerApplicationHost ApplicationHost { get; set; } + [JsonIgnore] public override bool SupportsPlayedStatus => false; [JsonIgnore] public override bool SupportsInheritedParentImages => false; + public string CollectionType { get; set; } + + /// <summary> + /// Gets the item's children. + /// </summary> + /// <remarks> + /// Our children are actually just references to the ones in the physical root... + /// </remarks> + /// <value>The actual children.</value> + [JsonIgnore] + public override IEnumerable<BaseItem> Children => GetActualChildren(); + + [JsonIgnore] + public override bool SupportsPeople => false; + public override bool CanDelete() { return false; } - public string CollectionType { get; set; } - - private static readonly Dictionary<string, LibraryOptions> LibraryOptions = new Dictionary<string, LibraryOptions>(); public LibraryOptions GetLibraryOptions() { return GetLibraryOptions(Path); @@ -60,7 +98,6 @@ namespace MediaBrowser.Controller.Entities try { var result = XmlSerializer.DeserializeFromFile(typeof(LibraryOptions), GetLibraryOptionsPath(path)) as LibraryOptions; - if (result == null) { return new LibraryOptions(); @@ -104,12 +141,12 @@ namespace MediaBrowser.Controller.Entities public static LibraryOptions GetLibraryOptions(string path) { - lock (LibraryOptions) + lock (_libraryOptions) { - if (!LibraryOptions.TryGetValue(path, out var options)) + if (!_libraryOptions.TryGetValue(path, out var options)) { options = LoadLibraryOptions(path); - LibraryOptions[path] = options; + _libraryOptions[path] = options; } return options; @@ -118,11 +155,11 @@ namespace MediaBrowser.Controller.Entities public static void SaveLibraryOptions(string path, LibraryOptions options) { - lock (LibraryOptions) + lock (_libraryOptions) { - LibraryOptions[path] = options; + _libraryOptions[path] = options; - var clone = JsonSerializer.DeserializeFromString<LibraryOptions>(JsonSerializer.SerializeToString(options)); + var clone = JsonSerializer.Deserialize<LibraryOptions>(JsonSerializer.SerializeToUtf8Bytes(options, _jsonOptions), _jsonOptions); foreach (var mediaPath in clone.PathInfos) { if (!string.IsNullOrEmpty(mediaPath.Path)) @@ -137,37 +174,22 @@ namespace MediaBrowser.Controller.Entities public static void OnCollectionFolderChange() { - lock (LibraryOptions) + lock (_libraryOptions) { - LibraryOptions.Clear(); + _libraryOptions.Clear(); } } - /// <summary> - /// Allow different display preferences for each collection folder. - /// </summary> - /// <value>The display prefs id.</value> - [JsonIgnore] - public override Guid DisplayPreferencesId => Id; - - [JsonIgnore] - public override string[] PhysicalLocations => PhysicalLocationsList; - public override bool IsSaveLocalMetadataEnabled() { return true; } - public string[] PhysicalLocationsList { get; set; } - - public Guid[] PhysicalFolderIds { get; set; } - protected override FileSystemMetadata[] GetFileSystemChildren(IDirectoryService directoryService) { return CreateResolveArgs(directoryService, true).FileSystemChildren; } - private bool _requiresRefresh; public override bool RequiresRefresh() { var changed = base.RequiresRefresh() || _requiresRefresh; @@ -199,9 +221,9 @@ namespace MediaBrowser.Controller.Entities return changed; } - public override bool BeforeMetadataRefresh(bool replaceAllMetdata) + public override bool BeforeMetadataRefresh(bool replaceAllMetadata) { - var changed = base.BeforeMetadataRefresh(replaceAllMetdata) || _requiresRefresh; + var changed = base.BeforeMetadataRefresh(replaceAllMetadata) || _requiresRefresh; _requiresRefresh = false; return changed; } @@ -270,7 +292,6 @@ namespace MediaBrowser.Controller.Entities var args = new ItemResolveArgs(ConfigurationManager.ApplicationPaths, directoryService) { FileInfo = FileSystem.GetDirectoryInfo(path), - Path = path, Parent = GetParent() as Folder, CollectionType = CollectionType }; @@ -297,27 +318,20 @@ namespace MediaBrowser.Controller.Entities /// <summary> /// Compare our current children (presumably just read from the repo) with the current state of the file system and adjust for any changes - /// ***Currently does not contain logic to maintain items that are unavailable in the file system*** + /// ***Currently does not contain logic to maintain items that are unavailable in the file system***. /// </summary> /// <param name="progress">The progress.</param> - /// <param name="cancellationToken">The cancellation token.</param> /// <param name="recursive">if set to <c>true</c> [recursive].</param> /// <param name="refreshChildMetadata">if set to <c>true</c> [refresh child metadata].</param> /// <param name="refreshOptions">The refresh options.</param> /// <param name="directoryService">The directory service.</param> + /// <param name="cancellationToken">The cancellation token.</param> /// <returns>Task.</returns> - protected override Task ValidateChildrenInternal(IProgress<double> progress, CancellationToken cancellationToken, bool recursive, bool refreshChildMetadata, MetadataRefreshOptions refreshOptions, IDirectoryService directoryService) + protected override Task ValidateChildrenInternal(IProgress<double> progress, bool recursive, bool refreshChildMetadata, MetadataRefreshOptions refreshOptions, IDirectoryService directoryService, CancellationToken cancellationToken) { return Task.CompletedTask; } - /// <summary> - /// Our children are actually just references to the ones in the physical root... - /// </summary> - /// <value>The actual children.</value> - [JsonIgnore] - public override IEnumerable<BaseItem> Children => GetActualChildren(); - public IEnumerable<BaseItem> GetActualChildren() { return GetPhysicalFolders(true).SelectMany(c => c.Children); @@ -354,9 +368,7 @@ namespace MediaBrowser.Controller.Entities if (result.Count == 0) { - var folder = LibraryManager.FindByPath(path, true) as Folder; - - if (folder != null) + if (LibraryManager.FindByPath(path, true) is Folder folder) { result.Add(folder); } @@ -364,8 +376,5 @@ namespace MediaBrowser.Controller.Entities return result; } - - [JsonIgnore] - public override bool SupportsPeople => false; } } diff --git a/MediaBrowser.Controller/Entities/Extensions.cs b/MediaBrowser.Controller/Entities/Extensions.cs index 3a34c668cf..9ce8eebe34 100644 --- a/MediaBrowser.Controller/Entities/Extensions.cs +++ b/MediaBrowser.Controller/Entities/Extensions.cs @@ -1,6 +1,8 @@ +#nullable disable + using System; using System.Linq; -using MediaBrowser.Common.Extensions; +using Jellyfin.Extensions; using MediaBrowser.Model.Entities; namespace MediaBrowser.Controller.Entities @@ -13,6 +15,8 @@ namespace MediaBrowser.Controller.Entities /// <summary> /// Adds the trailer URL. /// </summary> + /// <param name="item">Media item.</param> + /// <param name="url">Trailer URL.</param> public static void AddTrailerUrl(this BaseItem item, string url) { if (string.IsNullOrEmpty(url)) diff --git a/MediaBrowser.Controller/Entities/Folder.cs b/MediaBrowser.Controller/Entities/Folder.cs index 35ddaffadc..d45a02cf2d 100644 --- a/MediaBrowser.Controller/Entities/Folder.cs +++ b/MediaBrowser.Controller/Entities/Folder.cs @@ -1,13 +1,15 @@ -#pragma warning disable CS1591 +#nullable disable + +#pragma warning disable CA1002, CA1721, CA1819, CS1591 using System; using System.Collections.Generic; -using System.Globalization; using System.IO; using System.Linq; using System.Text.Json.Serialization; using System.Threading; using System.Threading.Tasks; +using System.Threading.Tasks.Dataflow; using Jellyfin.Data.Entities; using Jellyfin.Data.Enums; using MediaBrowser.Common.Progress; @@ -35,6 +37,11 @@ namespace MediaBrowser.Controller.Entities /// </summary> public class Folder : BaseItem { + public Folder() + { + LinkedChildren = Array.Empty<LinkedChild>(); + } + public static IUserViewManager UserViewManager { get; set; } /// <summary> @@ -48,11 +55,6 @@ namespace MediaBrowser.Controller.Entities [JsonIgnore] public DateTime? DateLastMediaAdded { get; set; } - public Folder() - { - LinkedChildren = Array.Empty<LinkedChild>(); - } - [JsonIgnore] public override bool SupportsThemeMedia => true; @@ -84,6 +86,87 @@ namespace MediaBrowser.Controller.Entities [JsonIgnore] public virtual bool SupportsDateLastMediaAdded => false; + [JsonIgnore] + public override string FileNameWithoutExtension + { + get + { + if (IsFileProtocol) + { + return System.IO.Path.GetFileName(Path); + } + + return null; + } + } + + /// <summary> + /// Gets the actual children. + /// </summary> + /// <value>The actual children.</value> + [JsonIgnore] + public virtual IEnumerable<BaseItem> Children => LoadChildren(); + + /// <summary> + /// Gets thread-safe access to all recursive children of this folder - without regard to user. + /// </summary> + /// <value>The recursive children.</value> + [JsonIgnore] + public IEnumerable<BaseItem> RecursiveChildren => GetRecursiveChildren(); + + [JsonIgnore] + protected virtual bool SupportsShortcutChildren => false; + + protected virtual bool FilterLinkedChildrenPerUser => false; + + [JsonIgnore] + protected override bool SupportsOwnedItems => base.SupportsOwnedItems || SupportsShortcutChildren; + + [JsonIgnore] + public virtual bool SupportsUserDataFromChildren + { + get + { + // These are just far too slow. + if (this is ICollectionFolder) + { + return false; + } + + if (this is UserView) + { + return false; + } + + if (this is UserRootFolder) + { + return false; + } + + if (this is Channel) + { + return false; + } + + if (SourceType != SourceType.Library) + { + return false; + } + + if (this is IItemByName) + { + if (this is not IHasDualAccess hasDualAccess || hasDualAccess.IsAccessedByName) + { + return false; + } + } + + return true; + } + } + + public static ICollectionManager CollectionManager { get; set; } + public override bool CanDelete() { if (IsRoot) @@ -106,20 +189,6 @@ namespace MediaBrowser.Controller.Entities return baseResult; } - [JsonIgnore] - public override string FileNameWithoutExtension - { - get - { - if (IsFileProtocol) - { - return System.IO.Path.GetFileName(Path); - } - - return null; - } - } - protected override bool IsAllowTagFilterEnforced() { if (this is ICollectionFolder) @@ -135,17 +204,12 @@ namespace MediaBrowser.Controller.Entities return true; } - [JsonIgnore] - protected virtual bool SupportsShortcutChildren => false; - /// <summary> /// Adds the child. /// </summary> /// <param name="item">The item.</param> - /// <param name="cancellationToken">The cancellation token.</param> - /// <returns>Task.</returns> - /// <exception cref="InvalidOperationException">Unable to add + item.Name</exception> - public void AddChild(BaseItem item, CancellationToken cancellationToken) + /// <exception cref="InvalidOperationException">Unable to add + item.Name.</exception> + public void AddChild(BaseItem item) { item.SetParent(this); @@ -167,31 +231,14 @@ namespace MediaBrowser.Controller.Entities LibraryManager.CreateItem(item, this); } - /// <summary> - /// Gets the actual children. - /// </summary> - /// <value>The actual children.</value> - [JsonIgnore] - public virtual IEnumerable<BaseItem> Children => LoadChildren(); - - /// <summary> - /// thread-safe access to all recursive children of this folder - without regard to user. - /// </summary> - /// <value>The recursive children.</value> - [JsonIgnore] - public IEnumerable<BaseItem> RecursiveChildren => GetRecursiveChildren(); - public override bool IsVisible(User user) { if (this is ICollectionFolder && !(this is BasePluginFolder)) { - var blockedMediaFolders = user.GetPreference(PreferenceKind.BlockedMediaFolders); + var blockedMediaFolders = user.GetPreferenceValues<Guid>(PreferenceKind.BlockedMediaFolders); if (blockedMediaFolders.Length > 0) { - if (blockedMediaFolders.Contains(Id.ToString("N", CultureInfo.InvariantCulture), StringComparer.OrdinalIgnoreCase) || - - // Backwards compatibility - blockedMediaFolders.Contains(Name, StringComparer.OrdinalIgnoreCase)) + if (blockedMediaFolders.Contains(Id)) { return false; } @@ -199,8 +246,7 @@ namespace MediaBrowser.Controller.Entities else { if (!user.HasPermission(PermissionKind.EnableAllFolders) - && !user.GetPreference(PreferenceKind.EnabledFolders) - .Contains(Id.ToString("N", CultureInfo.InvariantCulture), StringComparer.OrdinalIgnoreCase)) + && !user.GetPreferenceValues<Guid>(PreferenceKind.EnabledFolders).Contains(Id)) { return false; } @@ -212,8 +258,9 @@ namespace MediaBrowser.Controller.Entities /// <summary> /// Loads our children. Validation will occur externally. - /// We want this sychronous. + /// We want this synchronous. /// </summary> + /// <returns>Returns children.</returns> protected virtual List<BaseItem> LoadChildren() { // logger.LogDebug("Loading children from {0} {1} {2}", GetType().Name, Id, Path); @@ -228,20 +275,20 @@ namespace MediaBrowser.Controller.Entities public Task ValidateChildren(IProgress<double> progress, CancellationToken cancellationToken) { - return ValidateChildren(progress, cancellationToken, new MetadataRefreshOptions(new DirectoryService(FileSystem))); + return ValidateChildren(progress, new MetadataRefreshOptions(new DirectoryService(FileSystem)), cancellationToken: cancellationToken); } /// <summary> /// Validates that the children of the folder still exist. /// </summary> /// <param name="progress">The progress.</param> - /// <param name="cancellationToken">The cancellation token.</param> /// <param name="metadataRefreshOptions">The metadata refresh options.</param> /// <param name="recursive">if set to <c>true</c> [recursive].</param> + /// <param name="cancellationToken">The cancellation token.</param> /// <returns>Task.</returns> - public Task ValidateChildren(IProgress<double> progress, CancellationToken cancellationToken, MetadataRefreshOptions metadataRefreshOptions, bool recursive = true) + public Task ValidateChildren(IProgress<double> progress, MetadataRefreshOptions metadataRefreshOptions, bool recursive = true, CancellationToken cancellationToken = default) { - return ValidateChildrenInternal(progress, cancellationToken, recursive, true, metadataRefreshOptions, metadataRefreshOptions.DirectoryService); + return ValidateChildrenInternal(progress, recursive, true, metadataRefreshOptions, metadataRefreshOptions.DirectoryService, cancellationToken); } private Dictionary<Guid, BaseItem> GetActualChildrenDictionary() @@ -281,13 +328,13 @@ namespace MediaBrowser.Controller.Entities /// Validates the children internal. /// </summary> /// <param name="progress">The progress.</param> - /// <param name="cancellationToken">The cancellation token.</param> /// <param name="recursive">if set to <c>true</c> [recursive].</param> /// <param name="refreshChildMetadata">if set to <c>true</c> [refresh child metadata].</param> /// <param name="refreshOptions">The refresh options.</param> /// <param name="directoryService">The directory service.</param> + /// <param name="cancellationToken">The cancellation token.</param> /// <returns>Task.</returns> - protected virtual async Task ValidateChildrenInternal(IProgress<double> progress, CancellationToken cancellationToken, bool recursive, bool refreshChildMetadata, MetadataRefreshOptions refreshOptions, IDirectoryService directoryService) + protected virtual async Task ValidateChildrenInternal(IProgress<double> progress, bool recursive, bool refreshChildMetadata, MetadataRefreshOptions refreshOptions, IDirectoryService directoryService, CancellationToken cancellationToken) { if (recursive) { @@ -296,7 +343,7 @@ namespace MediaBrowser.Controller.Entities try { - await ValidateChildrenInternal2(progress, cancellationToken, recursive, refreshChildMetadata, refreshOptions, directoryService).ConfigureAwait(false); + await ValidateChildrenInternal2(progress, recursive, refreshChildMetadata, refreshOptions, directoryService, cancellationToken).ConfigureAwait(false); } finally { @@ -307,7 +354,7 @@ namespace MediaBrowser.Controller.Entities } } - private async Task ValidateChildrenInternal2(IProgress<double> progress, CancellationToken cancellationToken, bool recursive, bool refreshChildMetadata, MetadataRefreshOptions refreshOptions, IDirectoryService directoryService) + private async Task ValidateChildrenInternal2(IProgress<double> progress, bool recursive, bool refreshChildMetadata, MetadataRefreshOptions refreshOptions, IDirectoryService directoryService, CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); @@ -328,11 +375,11 @@ namespace MediaBrowser.Controller.Entities return; } - progress.Report(5); + progress.Report(ProgressHelpers.RetrievedChildren); if (recursive) { - ProviderManager.OnRefreshProgress(this, 5); + ProviderManager.OnRefreshProgress(this, ProgressHelpers.RetrievedChildren); } // Build a dictionary of the current children we have now by Id so we can compare quickly and easily @@ -393,11 +440,11 @@ namespace MediaBrowser.Controller.Entities validChildrenNeedGeneration = true; } - progress.Report(10); + progress.Report(ProgressHelpers.UpdatedChildItems); if (recursive) { - ProviderManager.OnRefreshProgress(this, 10); + ProviderManager.OnRefreshProgress(this, ProgressHelpers.UpdatedChildItems); } cancellationToken.ThrowIfCancellationRequested(); @@ -407,11 +454,13 @@ namespace MediaBrowser.Controller.Entities var innerProgress = new ActionableProgress<double>(); var folder = this; - innerProgress.RegisterAction(p => + innerProgress.RegisterAction(innerPercent => { - double newPct = 0.80 * p + 10; - progress.Report(newPct); - ProviderManager.OnRefreshProgress(folder, newPct); + var percent = ProgressHelpers.GetProgress(ProgressHelpers.UpdatedChildItems, ProgressHelpers.ScannedSubfolders, innerPercent); + + progress.Report(percent); + + ProviderManager.OnRefreshProgress(folder, percent); }); if (validChildrenNeedGeneration) @@ -425,11 +474,11 @@ namespace MediaBrowser.Controller.Entities if (refreshChildMetadata) { - progress.Report(90); + progress.Report(ProgressHelpers.ScannedSubfolders); if (recursive) { - ProviderManager.OnRefreshProgress(this, 90); + ProviderManager.OnRefreshProgress(this, ProgressHelpers.ScannedSubfolders); } var container = this as IMetadataContainer; @@ -437,13 +486,15 @@ namespace MediaBrowser.Controller.Entities var innerProgress = new ActionableProgress<double>(); var folder = this; - innerProgress.RegisterAction(p => + innerProgress.RegisterAction(innerPercent => { - double newPct = 0.10 * p + 90; - progress.Report(newPct); + var percent = ProgressHelpers.GetProgress(ProgressHelpers.ScannedSubfolders, ProgressHelpers.RefreshedMetadata, innerPercent); + + progress.Report(percent); + if (recursive) { - ProviderManager.OnRefreshProgress(folder, newPct); + ProviderManager.OnRefreshProgress(folder, percent); } }); @@ -458,55 +509,35 @@ namespace MediaBrowser.Controller.Entities validChildren = Children.ToList(); } - await RefreshMetadataRecursive(validChildren, refreshOptions, recursive, innerProgress, cancellationToken); + await RefreshMetadataRecursive(validChildren, refreshOptions, recursive, innerProgress, cancellationToken).ConfigureAwait(false); } } } - private async Task RefreshMetadataRecursive(List<BaseItem> children, MetadataRefreshOptions refreshOptions, bool recursive, IProgress<double> progress, CancellationToken cancellationToken) + private Task RefreshMetadataRecursive(IList<BaseItem> children, MetadataRefreshOptions refreshOptions, bool recursive, IProgress<double> progress, CancellationToken cancellationToken) { - var numComplete = 0; - var count = children.Count; - double currentPercent = 0; - - foreach (var child in children) - { - cancellationToken.ThrowIfCancellationRequested(); - - var innerProgress = new ActionableProgress<double>(); - - // Avoid implicitly captured closure - var currentInnerPercent = currentPercent; - - innerProgress.RegisterAction(p => - { - double innerPercent = currentInnerPercent; - innerPercent += p / count; - progress.Report(innerPercent); - }); - - await RefreshChildMetadata(child, refreshOptions, recursive && child.IsFolder, innerProgress, cancellationToken) - .ConfigureAwait(false); - - numComplete++; - double percent = numComplete; - percent /= count; - percent *= 100; - currentPercent = percent; - - progress.Report(percent); - } + return RunTasks( + (baseItem, innerProgress) => RefreshChildMetadata(baseItem, refreshOptions, recursive && baseItem.IsFolder, innerProgress, cancellationToken), + children, + progress, + cancellationToken); } private async Task RefreshAllMetadataForContainer(IMetadataContainer container, MetadataRefreshOptions refreshOptions, IProgress<double> progress, CancellationToken cancellationToken) { - var series = container as Series; - if (series != null) - { - await series.RefreshMetadata(refreshOptions, cancellationToken).ConfigureAwait(false); - } + // limit the amount of concurrent metadata refreshes + await ProviderManager.RunMetadataRefresh( + async () => + { + var series = container as Series; + if (series != null) + { + await series.RefreshMetadata(refreshOptions, cancellationToken).ConfigureAwait(false); + } - await container.RefreshAllMetadata(refreshOptions, progress, cancellationToken).ConfigureAwait(false); + await container.RefreshAllMetadata(refreshOptions, progress, cancellationToken).ConfigureAwait(false); + }, + cancellationToken).ConfigureAwait(false); } private async Task RefreshChildMetadata(BaseItem child, MetadataRefreshOptions refreshOptions, bool recursive, IProgress<double> progress, CancellationToken cancellationToken) @@ -521,12 +552,15 @@ namespace MediaBrowser.Controller.Entities { if (refreshOptions.RefreshItem(child)) { - await child.RefreshMetadata(refreshOptions, cancellationToken).ConfigureAwait(false); + // limit the amount of concurrent metadata refreshes + await ProviderManager.RunMetadataRefresh( + async () => await child.RefreshMetadata(refreshOptions, cancellationToken).ConfigureAwait(false), + cancellationToken).ConfigureAwait(false); } if (recursive && child is Folder folder) { - await folder.RefreshMetadataRecursive(folder.Children.ToList(), refreshOptions, true, progress, cancellationToken); + await folder.RefreshMetadataRecursive(folder.Children.ToList(), refreshOptions, true, progress, cancellationToken).ConfigureAwait(false); } } } @@ -539,45 +573,80 @@ namespace MediaBrowser.Controller.Entities /// <param name="progress">The progress.</param> /// <param name="cancellationToken">The cancellation token.</param> /// <returns>Task.</returns> - private async Task ValidateSubFolders(IList<Folder> children, IDirectoryService directoryService, IProgress<double> progress, CancellationToken cancellationToken) + private Task ValidateSubFolders(IList<Folder> children, IDirectoryService directoryService, IProgress<double> progress, CancellationToken cancellationToken) + { + return RunTasks( + (folder, innerProgress) => folder.ValidateChildrenInternal(innerProgress, true, false, null, directoryService, cancellationToken), + children, + progress, + cancellationToken); + } + + /// <summary> + /// Runs an action block on a list of children. + /// </summary> + /// <param name="task">The task to run for each child.</param> + /// <param name="children">The list of children.</param> + /// <param name="progress">The progress.</param> + /// <param name="cancellationToken">The cancellation token.</param> + /// <returns>Task.</returns> + private async Task RunTasks<T>(Func<T, IProgress<double>, Task> task, IList<T> children, IProgress<double> progress, CancellationToken cancellationToken) { - var numComplete = 0; - var count = children.Count; - double currentPercent = 0; + var childrenCount = children.Count; + var childrenProgress = new double[childrenCount]; - foreach (var child in children) + void UpdateProgress() { - cancellationToken.ThrowIfCancellationRequested(); + progress.Report(childrenProgress.Average()); + } - var innerProgress = new ActionableProgress<double>(); + var fanoutConcurrency = ConfigurationManager.Configuration.LibraryScanFanoutConcurrency; + var parallelism = fanoutConcurrency == 0 ? Environment.ProcessorCount : fanoutConcurrency; + + var actionBlock = new ActionBlock<int>( + async i => + { + var innerProgress = new ActionableProgress<double>(); + + innerProgress.RegisterAction(innerPercent => + { + // round the percent and only update progress if it changed to prevent excessive UpdateProgress calls + var innerPercentRounded = Math.Round(innerPercent); + if (childrenProgress[i] != innerPercentRounded) + { + childrenProgress[i] = innerPercentRounded; + UpdateProgress(); + } + }); + + await task(children[i], innerProgress).ConfigureAwait(false); - // Avoid implicitly captured closure - var currentInnerPercent = currentPercent; + childrenProgress[i] = 100; - innerProgress.RegisterAction(p => + UpdateProgress(); + }, + new ExecutionDataflowBlockOptions { - double innerPercent = currentInnerPercent; - innerPercent += p / count; - progress.Report(innerPercent); + MaxDegreeOfParallelism = parallelism, + CancellationToken = cancellationToken, }); - await child.ValidateChildrenInternal(innerProgress, cancellationToken, true, false, null, directoryService) - .ConfigureAwait(false); + for (var i = 0; i < childrenCount; i++) + { + actionBlock.Post(i); + } - numComplete++; - double percent = numComplete; - percent /= count; - percent *= 100; - currentPercent = percent; + actionBlock.Complete(); - progress.Report(percent); - } + await actionBlock.Completion.ConfigureAwait(false); } /// <summary> /// Get the children of this folder from the actual file system. /// </summary> /// <returns>IEnumerable{BaseItem}.</returns> + /// <param name="directoryService">The directory service to use for operation.</param> + /// <returns>Returns set of base items.</returns> protected virtual IEnumerable<BaseItem> GetNonCachedChildren(IDirectoryService directoryService) { var collectionType = LibraryManager.GetContentType(this); @@ -723,7 +792,7 @@ namespace MediaBrowser.Controller.Entities private bool RequiresPostFiltering2(InternalItemsQuery query) { - if (query.IncludeItemTypes.Length == 1 && string.Equals(query.IncludeItemTypes[0], typeof(BoxSet).Name, StringComparison.OrdinalIgnoreCase)) + if (query.IncludeItemTypes.Length == 1 && string.Equals(query.IncludeItemTypes[0], nameof(BoxSet), StringComparison.OrdinalIgnoreCase)) { Logger.LogDebug("Query requires post-filtering due to BoxSet query"); return true; @@ -813,7 +882,7 @@ namespace MediaBrowser.Controller.Entities if (query.IsPlayed.HasValue) { - if (query.IncludeItemTypes.Length == 1 && query.IncludeItemTypes.Contains(typeof(Series).Name)) + if (query.IncludeItemTypes.Length == 1 && query.IncludeItemTypes.Contains(nameof(Series))) { Logger.LogDebug("Query requires post-filtering due to IsPlayed"); return true; @@ -922,14 +991,18 @@ namespace MediaBrowser.Controller.Entities } else { - items = GetChildren(user, true).Where(filter); + // need to pass this param to the children. + var childQuery = new InternalItemsQuery + { + DisplayAlbumFolders = query.DisplayAlbumFolders + }; + + items = GetChildren(user, true, childQuery).Where(filter); } return PostFilterAndSort(items, query, true); } - public static ICollectionManager CollectionManager { get; set; } - protected QueryResult<BaseItem> PostFilterAndSort(IEnumerable<BaseItem> items, InternalItemsQuery query, bool enableSorting) { var user = query.User; @@ -947,7 +1020,7 @@ namespace MediaBrowser.Controller.Entities if (!string.IsNullOrEmpty(query.NameStartsWith)) { - items = items.Where(i => i.SortName.StartsWith(query.NameStartsWith, StringComparison.OrdinalIgnoreCase)); + items = items.Where(i => i.SortName.StartsWith(query.NameStartsWith, StringComparison.CurrentCultureIgnoreCase)); } if (!string.IsNullOrEmpty(query.NameLessThan)) @@ -1067,12 +1140,12 @@ namespace MediaBrowser.Controller.Entities return false; } - if (request.Genres.Length > 0) + if (request.Genres.Count > 0) { return false; } - if (request.GenreIds.Length > 0) + if (request.GenreIds.Count > 0) { return false; } @@ -1177,7 +1250,7 @@ namespace MediaBrowser.Controller.Entities return false; } - if (request.GenreIds.Length > 0) + if (request.GenreIds.Count > 0) { return false; } @@ -1258,10 +1331,23 @@ namespace MediaBrowser.Controller.Entities /// <summary> /// Adds the children to list. /// </summary> - /// <returns><c>true</c> if XXXX, <c>false</c> otherwise</returns> private void AddChildren(User user, bool includeLinkedChildren, Dictionary<Guid, BaseItem> result, bool recursive, InternalItemsQuery query) { - foreach (var child in GetEligibleChildrenForRecursiveChildren(user)) + // If Query.AlbumFolders is set, then enforce the format as per the db in that it permits sub-folders in music albums. + IEnumerable<BaseItem> children = null; + if ((query?.DisplayAlbumFolders ?? false) && (this is MusicAlbum)) + { + children = Children; + query = null; + } + + // If there are not sub-folders, proceed as normal. + if (children == null) + { + children = GetEligibleChildrenForRecursiveChildren(user); + } + + foreach (var child in children) { bool? isVisibleToUser = null; @@ -1301,18 +1387,6 @@ namespace MediaBrowser.Controller.Entities } } - /// <summary> - /// Gets allowed recursive children of an item. - /// </summary> - /// <param name="user">The user.</param> - /// <param name="includeLinkedChildren">if set to <c>true</c> [include linked children].</param> - /// <returns>IEnumerable{BaseItem}.</returns> - /// <exception cref="ArgumentNullException"></exception> - public IEnumerable<BaseItem> GetRecursiveChildren(User user, bool includeLinkedChildren = true) - { - return GetRecursiveChildren(user, null); - } - public virtual IEnumerable<BaseItem> GetRecursiveChildren(User user, InternalItemsQuery query) { if (user == null) @@ -1410,16 +1484,19 @@ namespace MediaBrowser.Controller.Entities return list; } - protected virtual bool FilterLinkedChildrenPerUser => false; - public bool ContainsLinkedChildByItemId(Guid itemId) { var linkedChildren = LinkedChildren; foreach (var i in linkedChildren) { - if (i.ItemId.HasValue && i.ItemId.Value == itemId) + if (i.ItemId.HasValue) { - return true; + if (i.ItemId.Value == itemId) + { + return true; + } + + continue; } var child = GetLinkedChild(i); @@ -1507,9 +1584,6 @@ namespace MediaBrowser.Controller.Entities .Where(i => i.Item2 != null); } - [JsonIgnore] - protected override bool SupportsOwnedItems => base.SupportsOwnedItems || SupportsShortcutChildren; - protected override async Task<bool> RefreshedOwnedItems(MetadataRefreshOptions options, List<FileSystemMetadata> fileSystemChildren, CancellationToken cancellationToken) { var changesFound = false; @@ -1530,7 +1604,8 @@ namespace MediaBrowser.Controller.Entities /// <summary> /// Refreshes the linked children. /// </summary> - /// <returns><c>true</c> if XXXX, <c>false</c> otherwise</returns> + /// <param name="fileSystemChildren">The enumerable of file system metadata.</param> + /// <returns><c>true</c> if the linked children were updated, <c>false</c> otherwise.</returns> protected virtual bool RefreshLinkedChildren(IEnumerable<FileSystemMetadata> fileSystemChildren) { if (SupportsShortcutChildren) @@ -1594,7 +1669,6 @@ namespace MediaBrowser.Controller.Entities /// <param name="user">The user.</param> /// <param name="datePlayed">The date played.</param> /// <param name="resetPosition">if set to <c>true</c> [reset position].</param> - /// <returns>Task.</returns> public override void MarkPlayed( User user, DateTime? datePlayed, @@ -1636,7 +1710,6 @@ namespace MediaBrowser.Controller.Entities /// Marks the unplayed. /// </summary> /// <param name="user">The user.</param> - /// <returns>Task.</returns> public override void MarkUnplayed(User user) { var itemsResult = GetItemList(new InternalItemsQuery @@ -1673,51 +1746,6 @@ namespace MediaBrowser.Controller.Entities return !IsPlayed(user); } - [JsonIgnore] - public virtual bool SupportsUserDataFromChildren - { - get - { - // These are just far too slow. - if (this is ICollectionFolder) - { - return false; - } - - if (this is UserView) - { - return false; - } - - if (this is UserRootFolder) - { - return false; - } - - if (this is Channel) - { - return false; - } - - if (SourceType != SourceType.Library) - { - return false; - } - - var iItemByName = this as IItemByName; - if (iItemByName != null) - { - var hasDualAccess = this as IHasDualAccess; - if (hasDualAccess == null || hasDualAccess.IsAccessedByName) - { - return false; - } - } - - return true; - } - } - public override void FillUserDataDtoValues(UserItemDataDto dto, UserItemData userData, BaseItemDto itemDto, User user, DtoOptions fields) { if (!SupportsUserDataFromChildren) @@ -1747,20 +1775,15 @@ namespace MediaBrowser.Controller.Entities { EnableImages = false } - }); + }).TotalRecordCount; - double unplayedCount = unplayedQueryResult.TotalRecordCount; + dto.UnplayedItemCount = unplayedQueryResult; - dto.UnplayedItemCount = unplayedQueryResult.TotalRecordCount; - - if (itemDto != null && itemDto.RecursiveItemCount.HasValue) + if (itemDto?.RecursiveItemCount > 0) { - if (itemDto.RecursiveItemCount.Value > 0) - { - var unplayedPercentage = (unplayedCount / itemDto.RecursiveItemCount.Value) * 100; - dto.PlayedPercentage = 100 - unplayedPercentage; - dto.Played = dto.PlayedPercentage.Value >= 100; - } + var unplayedPercentage = ((double)unplayedQueryResult / itemDto.RecursiveItemCount.Value) * 100; + dto.PlayedPercentage = 100 - unplayedPercentage; + dto.Played = dto.PlayedPercentage.Value >= 100; } else { @@ -1768,5 +1791,45 @@ namespace MediaBrowser.Controller.Entities } } } + + /// <summary> + /// Contains constants used when reporting scan progress. + /// </summary> + private static class ProgressHelpers + { + /// <summary> + /// Reported after the folders immediate children are retrieved. + /// </summary> + public const int RetrievedChildren = 5; + + /// <summary> + /// Reported after add, updating, or deleting child items from the LibraryManager. + /// </summary> + public const int UpdatedChildItems = 10; + + /// <summary> + /// Reported once subfolders are scanned. + /// When scanning subfolders, the progress will be between [UpdatedItems, ScannedSubfolders]. + /// </summary> + public const int ScannedSubfolders = 50; + + /// <summary> + /// Reported once metadata is refreshed. + /// When refreshing metadata, the progress will be between [ScannedSubfolders, MetadataRefreshed]. + /// </summary> + public const int RefreshedMetadata = 100; + + /// <summary> + /// Gets the current progress given the previous step, next step, and progress in between. + /// </summary> + /// <param name="previousProgressStep">The previous progress step.</param> + /// <param name="nextProgressStep">The next progress step.</param> + /// <param name="currentProgress">The current progress step.</param> + /// <returns>The progress.</returns> + public static double GetProgress(int previousProgressStep, int nextProgressStep, double currentProgress) + { + return previousProgressStep + ((nextProgressStep - previousProgressStep) * (currentProgress / 100)); + } + } } } diff --git a/MediaBrowser.Controller/Entities/Genre.cs b/MediaBrowser.Controller/Entities/Genre.cs index db6c85caf7..338f96204d 100644 --- a/MediaBrowser.Controller/Entities/Genre.cs +++ b/MediaBrowser.Controller/Entities/Genre.cs @@ -1,10 +1,12 @@ +#nullable disable + #pragma warning disable CS1591 using System; using System.Collections.Generic; using System.Text.Json.Serialization; +using Diacritics.Extensions; using MediaBrowser.Controller.Entities.Audio; -using MediaBrowser.Controller.Extensions; using Microsoft.Extensions.Logging; namespace MediaBrowser.Controller.Entities @@ -14,6 +16,23 @@ namespace MediaBrowser.Controller.Entities /// </summary> public class Genre : BaseItem, IItemByName { + /// <summary> + /// Gets the folder containing the item. + /// If the item is a folder, it returns the folder itself. + /// </summary> + /// <value>The containing folder path.</value> + [JsonIgnore] + public override string ContainingFolderPath => Path; + + [JsonIgnore] + public override bool IsDisplayedAsFolder => true; + + [JsonIgnore] + public override bool SupportsAncestors => false; + + [JsonIgnore] + public override bool SupportsPeople => false; + public override List<string> GetUserDataKeys() { var list = base.GetUserDataKeys(); @@ -32,20 +51,6 @@ namespace MediaBrowser.Controller.Entities return 1; } - /// <summary> - /// Returns the folder containing the item. - /// If the item is a folder, it returns the folder itself. - /// </summary> - /// <value>The containing folder path.</value> - [JsonIgnore] - public override string ContainingFolderPath => Path; - - [JsonIgnore] - public override bool IsDisplayedAsFolder => true; - - [JsonIgnore] - public override bool SupportsAncestors => false; - public override bool IsSaveLocalMetadataEnabled() { return true; @@ -59,14 +64,17 @@ namespace MediaBrowser.Controller.Entities public IList<BaseItem> GetTaggedItems(InternalItemsQuery query) { query.GenreIds = new[] { Id }; - query.ExcludeItemTypes = new[] { typeof(MusicVideo).Name, typeof(Audio.Audio).Name, typeof(MusicAlbum).Name, typeof(MusicArtist).Name }; + query.ExcludeItemTypes = new[] + { + nameof(MusicVideo), + nameof(Entities.Audio.Audio), + nameof(MusicAlbum), + nameof(MusicArtist) + }; return LibraryManager.GetItemList(query); } - [JsonIgnore] - public override bool SupportsPeople => false; - public static string GetPath(string name) { return GetPath(name, true); @@ -100,11 +108,13 @@ namespace MediaBrowser.Controller.Entities } /// <summary> - /// This is called before any metadata refresh and returns true or false indicating if changes were made. + /// This is called before any metadata refresh and returns true if changes were made. /// </summary> - public override bool BeforeMetadataRefresh(bool replaceAllMetdata) + /// <param name="replaceAllMetadata">Whether to replace all metadata.</param> + /// <returns>true if the item has change, else false.</returns> + public override bool BeforeMetadataRefresh(bool replaceAllMetadata) { - var hasChanges = base.BeforeMetadataRefresh(replaceAllMetdata); + var hasChanges = base.BeforeMetadataRefresh(replaceAllMetadata); var newPath = GetRebasedPath(); if (!string.Equals(Path, newPath, StringComparison.Ordinal)) diff --git a/MediaBrowser.Controller/Entities/ICollectionFolder.cs b/MediaBrowser.Controller/Entities/ICollectionFolder.cs index b84a9fa6f1..89e494ebc3 100644 --- a/MediaBrowser.Controller/Entities/ICollectionFolder.cs +++ b/MediaBrowser.Controller/Entities/ICollectionFolder.cs @@ -1,4 +1,6 @@ -#pragma warning disable CS1591 +#nullable disable + +#pragma warning disable CA1819, CS1591 using System; diff --git a/MediaBrowser.Controller/Entities/IHasAspectRatio.cs b/MediaBrowser.Controller/Entities/IHasAspectRatio.cs index d7d0076681..3aeb7468f2 100644 --- a/MediaBrowser.Controller/Entities/IHasAspectRatio.cs +++ b/MediaBrowser.Controller/Entities/IHasAspectRatio.cs @@ -1,3 +1,5 @@ +#nullable disable + namespace MediaBrowser.Controller.Entities { /// <summary> diff --git a/MediaBrowser.Controller/Entities/IHasDisplayOrder.cs b/MediaBrowser.Controller/Entities/IHasDisplayOrder.cs index 13226b2346..14459624e9 100644 --- a/MediaBrowser.Controller/Entities/IHasDisplayOrder.cs +++ b/MediaBrowser.Controller/Entities/IHasDisplayOrder.cs @@ -1,3 +1,5 @@ +#nullable disable + namespace MediaBrowser.Controller.Entities { /// <summary> diff --git a/MediaBrowser.Controller/Entities/IHasMediaSources.cs b/MediaBrowser.Controller/Entities/IHasMediaSources.cs index 0f612262a8..90d9bdd2d3 100644 --- a/MediaBrowser.Controller/Entities/IHasMediaSources.cs +++ b/MediaBrowser.Controller/Entities/IHasMediaSources.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System; @@ -18,6 +20,8 @@ namespace MediaBrowser.Controller.Entities /// <summary> /// Gets the media sources. /// </summary> + /// <param name="enablePathSubstitution"><c>true</c> to enable path substitution, <c>false</c> to not.</param> + /// <returns>A list of media sources.</returns> List<MediaSourceInfo> GetMediaSources(bool enablePathSubstitution); List<MediaStream> GetMediaStreams(); diff --git a/MediaBrowser.Controller/Entities/IHasProgramAttributes.cs b/MediaBrowser.Controller/Entities/IHasProgramAttributes.cs index f747b5149a..f80f7c304c 100644 --- a/MediaBrowser.Controller/Entities/IHasProgramAttributes.cs +++ b/MediaBrowser.Controller/Entities/IHasProgramAttributes.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using MediaBrowser.Model.LiveTv; diff --git a/MediaBrowser.Controller/Entities/IHasScreenshots.cs b/MediaBrowser.Controller/Entities/IHasScreenshots.cs index b027a0cb13..ae01c223ed 100644 --- a/MediaBrowser.Controller/Entities/IHasScreenshots.cs +++ b/MediaBrowser.Controller/Entities/IHasScreenshots.cs @@ -1,7 +1,7 @@ namespace MediaBrowser.Controller.Entities { /// <summary> - /// Interface IHasScreenshots. + /// The item has screenshots. /// </summary> public interface IHasScreenshots { diff --git a/MediaBrowser.Controller/Entities/IHasSeries.cs b/MediaBrowser.Controller/Entities/IHasSeries.cs index 5444f1f523..5f774bbdee 100644 --- a/MediaBrowser.Controller/Entities/IHasSeries.cs +++ b/MediaBrowser.Controller/Entities/IHasSeries.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System; @@ -7,7 +9,7 @@ namespace MediaBrowser.Controller.Entities public interface IHasSeries { /// <summary> - /// Gets the name of the series. + /// Gets or sets the name of the series. /// </summary> /// <value>The name of the series.</value> string SeriesName { get; set; } diff --git a/MediaBrowser.Controller/Entities/IHasShares.cs b/MediaBrowser.Controller/Entities/IHasShares.cs new file mode 100644 index 0000000000..dca5af873f --- /dev/null +++ b/MediaBrowser.Controller/Entities/IHasShares.cs @@ -0,0 +1,11 @@ +#nullable disable + +#pragma warning disable CA1819, CS1591 + +namespace MediaBrowser.Controller.Entities +{ + public interface IHasShares + { + Share[] Shares { get; set; } + } +} \ No newline at end of file diff --git a/MediaBrowser.Controller/Entities/IHasSpecialFeatures.cs b/MediaBrowser.Controller/Entities/IHasSpecialFeatures.cs index 6a350212b1..f317a02ff3 100644 --- a/MediaBrowser.Controller/Entities/IHasSpecialFeatures.cs +++ b/MediaBrowser.Controller/Entities/IHasSpecialFeatures.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System; diff --git a/MediaBrowser.Controller/Entities/IHasTrailers.cs b/MediaBrowser.Controller/Entities/IHasTrailers.cs index d1f6f2b7e2..f4271678d4 100644 --- a/MediaBrowser.Controller/Entities/IHasTrailers.cs +++ b/MediaBrowser.Controller/Entities/IHasTrailers.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System; @@ -37,6 +39,7 @@ namespace MediaBrowser.Controller.Entities /// <summary> /// Gets the trailer count. /// </summary> + /// <param name="item">Media item.</param> /// <returns><see cref="IReadOnlyList{Guid}" />.</returns> public static int GetTrailerCount(this IHasTrailers item) => item.LocalTrailerIds.Count + item.RemoteTrailerIds.Count; @@ -44,6 +47,7 @@ namespace MediaBrowser.Controller.Entities /// <summary> /// Gets the trailer ids. /// </summary> + /// <param name="item">Media item.</param> /// <returns><see cref="IReadOnlyList{Guid}" />.</returns> public static IReadOnlyList<Guid> GetTrailerIds(this IHasTrailers item) { @@ -68,6 +72,7 @@ namespace MediaBrowser.Controller.Entities /// <summary> /// Gets the trailers. /// </summary> + /// <param name="item">Media item.</param> /// <returns><see cref="IReadOnlyList{BaseItem}" />.</returns> public static IReadOnlyList<BaseItem> GetTrailers(this IHasTrailers item) { diff --git a/MediaBrowser.Controller/Entities/InternalItemsQuery.cs b/MediaBrowser.Controller/Entities/InternalItemsQuery.cs index 904752a229..0baa7725e1 100644 --- a/MediaBrowser.Controller/Entities/InternalItemsQuery.cs +++ b/MediaBrowser.Controller/Entities/InternalItemsQuery.cs @@ -1,4 +1,4 @@ -#pragma warning disable CS1591 +#pragma warning disable CA1044, CA1819, CA2227, CS1591 using System; using System.Collections.Generic; @@ -12,15 +12,64 @@ namespace MediaBrowser.Controller.Entities { public class InternalItemsQuery { + public InternalItemsQuery() + { + AlbumArtistIds = Array.Empty<Guid>(); + AlbumIds = Array.Empty<Guid>(); + AncestorIds = Array.Empty<Guid>(); + ArtistIds = Array.Empty<Guid>(); + BlockUnratedItems = Array.Empty<UnratedItem>(); + BoxSetLibraryFolders = Array.Empty<Guid>(); + ChannelIds = Array.Empty<Guid>(); + ContributingArtistIds = Array.Empty<Guid>(); + DtoOptions = new DtoOptions(); + EnableTotalRecordCount = true; + ExcludeArtistIds = Array.Empty<Guid>(); + ExcludeInheritedTags = Array.Empty<string>(); + ExcludeItemIds = Array.Empty<Guid>(); + ExcludeItemTypes = Array.Empty<string>(); + ExcludeTags = Array.Empty<string>(); + GenreIds = Array.Empty<Guid>(); + Genres = Array.Empty<string>(); + GroupByPresentationUniqueKey = true; + ImageTypes = Array.Empty<ImageType>(); + IncludeItemTypes = Array.Empty<string>(); + ItemIds = Array.Empty<Guid>(); + MediaTypes = Array.Empty<string>(); + MinSimilarityScore = 20; + OfficialRatings = Array.Empty<string>(); + OrderBy = Array.Empty<ValueTuple<string, SortOrder>>(); + PersonIds = Array.Empty<Guid>(); + PersonTypes = Array.Empty<string>(); + PresetViews = Array.Empty<string>(); + SeriesStatuses = Array.Empty<SeriesStatus>(); + SourceTypes = Array.Empty<SourceType>(); + StudioIds = Array.Empty<Guid>(); + Tags = Array.Empty<string>(); + TopParentIds = Array.Empty<Guid>(); + TrailerTypes = Array.Empty<TrailerType>(); + VideoTypes = Array.Empty<VideoType>(); + Years = Array.Empty<int>(); + } + + public InternalItemsQuery(User? user) + : this() + { + if (user != null) + { + SetUser(user); + } + } + public bool Recursive { get; set; } public int? StartIndex { get; set; } public int? Limit { get; set; } - public User User { get; set; } + public User? User { get; set; } - public BaseItem SimilarTo { get; set; } + public BaseItem? SimilarTo { get; set; } public bool? IsFolder { get; set; } @@ -46,7 +95,7 @@ namespace MediaBrowser.Controller.Entities public string[] ExcludeInheritedTags { get; set; } - public string[] Genres { get; set; } + public IReadOnlyList<string> Genres { get; set; } public bool? IsSpecialSeason { get; set; } @@ -56,23 +105,23 @@ namespace MediaBrowser.Controller.Entities public bool? CollapseBoxSetItems { get; set; } - public string NameStartsWithOrGreater { get; set; } + public string? NameStartsWithOrGreater { get; set; } - public string NameStartsWith { get; set; } + public string? NameStartsWith { get; set; } - public string NameLessThan { get; set; } + public string? NameLessThan { get; set; } - public string NameContains { get; set; } + public string? NameContains { get; set; } - public string MinSortName { get; set; } + public string? MinSortName { get; set; } - public string PresentationUniqueKey { get; set; } + public string? PresentationUniqueKey { get; set; } - public string Path { get; set; } + public string? Path { get; set; } - public string Name { get; set; } + public string? Name { get; set; } - public string Person { get; set; } + public string? Person { get; set; } public Guid[] PersonIds { get; set; } @@ -80,7 +129,7 @@ namespace MediaBrowser.Controller.Entities public Guid[] ExcludeItemIds { get; set; } - public string AdjacentTo { get; set; } + public string? AdjacentTo { get; set; } public string[] PersonTypes { get; set; } @@ -116,7 +165,7 @@ namespace MediaBrowser.Controller.Entities public Guid[] StudioIds { get; set; } - public Guid[] GenreIds { get; set; } + public IReadOnlyList<Guid> GenreIds { get; set; } public ImageType[] ImageTypes { get; set; } @@ -162,7 +211,7 @@ namespace MediaBrowser.Controller.Entities public double? MinCommunityRating { get; set; } - public Guid[] ChannelIds { get; set; } + public IReadOnlyList<Guid> ChannelIds { get; set; } public int? ParentIndexNumber { get; set; } @@ -180,29 +229,12 @@ namespace MediaBrowser.Controller.Entities public Guid ParentId { get; set; } - public string ParentType { get; set; } + public string? ParentType { get; set; } public Guid[] AncestorIds { get; set; } public Guid[] TopParentIds { get; set; } - public BaseItem Parent - { - set - { - if (value == null) - { - ParentId = Guid.Empty; - ParentType = null; - } - else - { - ParentId = value.Id; - ParentType = value.GetType().Name; - } - } - } - public string[] PresetViews { get; set; } public TrailerType[] TrailerTypes { get; set; } @@ -211,9 +243,9 @@ namespace MediaBrowser.Controller.Entities public SeriesStatus[] SeriesStatuses { get; set; } - public string ExternalSeriesId { get; set; } + public string? ExternalSeriesId { get; set; } - public string ExternalId { get; set; } + public string? ExternalId { get; set; } public Guid[] AlbumIds { get; set; } @@ -221,9 +253,9 @@ namespace MediaBrowser.Controller.Entities public Guid[] ExcludeArtistIds { get; set; } - public string AncestorWithPresentationUniqueKey { get; set; } + public string? AncestorWithPresentationUniqueKey { get; set; } - public string SeriesPresentationUniqueKey { get; set; } + public string? SeriesPresentationUniqueKey { get; set; } public bool GroupByPresentationUniqueKey { get; set; } @@ -233,7 +265,7 @@ namespace MediaBrowser.Controller.Entities public bool ForceDirect { get; set; } - public Dictionary<string, string> ExcludeProviderIds { get; set; } + public Dictionary<string, string>? ExcludeProviderIds { get; set; } public bool EnableGroupByMetadataKey { get; set; } @@ -251,13 +283,13 @@ namespace MediaBrowser.Controller.Entities public int MinSimilarityScore { get; set; } - public string HasNoAudioTrackWithLanguage { get; set; } + public string? HasNoAudioTrackWithLanguage { get; set; } - public string HasNoInternalSubtitleTrackWithLanguage { get; set; } + public string? HasNoInternalSubtitleTrackWithLanguage { get; set; } - public string HasNoExternalSubtitleTrackWithLanguage { get; set; } + public string? HasNoExternalSubtitleTrackWithLanguage { get; set; } - public string HasNoSubtitleTrackWithLanguage { get; set; } + public string? HasNoSubtitleTrackWithLanguage { get; set; } public bool? IsDeadArtist { get; set; } @@ -265,74 +297,29 @@ namespace MediaBrowser.Controller.Entities public bool? IsDeadPerson { get; set; } - public InternalItemsQuery() - { - AlbumArtistIds = Array.Empty<Guid>(); - AlbumIds = Array.Empty<Guid>(); - AncestorIds = Array.Empty<Guid>(); - ArtistIds = Array.Empty<Guid>(); - BlockUnratedItems = Array.Empty<UnratedItem>(); - BoxSetLibraryFolders = Array.Empty<Guid>(); - ChannelIds = Array.Empty<Guid>(); - ContributingArtistIds = Array.Empty<Guid>(); - DtoOptions = new DtoOptions(); - EnableTotalRecordCount = true; - ExcludeArtistIds = Array.Empty<Guid>(); - ExcludeInheritedTags = Array.Empty<string>(); - ExcludeItemIds = Array.Empty<Guid>(); - ExcludeItemTypes = Array.Empty<string>(); - ExcludeProviderIds = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase); - ExcludeTags = Array.Empty<string>(); - GenreIds = Array.Empty<Guid>(); - Genres = Array.Empty<string>(); - GroupByPresentationUniqueKey = true; - HasAnyProviderId = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase); - ImageTypes = Array.Empty<ImageType>(); - IncludeItemTypes = Array.Empty<string>(); - ItemIds = Array.Empty<Guid>(); - MediaTypes = Array.Empty<string>(); - MinSimilarityScore = 20; - OfficialRatings = Array.Empty<string>(); - OrderBy = Array.Empty<ValueTuple<string, SortOrder>>(); - PersonIds = Array.Empty<Guid>(); - PersonTypes = Array.Empty<string>(); - PresetViews = Array.Empty<string>(); - SeriesStatuses = Array.Empty<SeriesStatus>(); - SourceTypes = Array.Empty<SourceType>(); - StudioIds = Array.Empty<Guid>(); - Tags = Array.Empty<string>(); - TopParentIds = Array.Empty<Guid>(); - TrailerTypes = Array.Empty<TrailerType>(); - VideoTypes = Array.Empty<VideoType>(); - Years = Array.Empty<int>(); - } - - public InternalItemsQuery(User user) - : this() - { - SetUser(user); - } + /// <summary> + /// Gets or sets a value indicating whether album sub-folders should be returned if they exist. + /// </summary> + public bool? DisplayAlbumFolders { get; set; } - public void SetUser(User user) + public BaseItem? Parent { - if (user != null) + set { - MaxParentalRating = user.MaxParentalAgeRating; - - if (MaxParentalRating.HasValue) + if (value == null) + { + ParentId = Guid.Empty; + ParentType = null; + } + else { - BlockUnratedItems = user.GetPreference(PreferenceKind.BlockUnratedItems) - .Where(i => i != UnratedItem.Other.ToString()) - .Select(e => Enum.Parse<UnratedItem>(e, true)).ToArray(); + ParentId = value.Id; + ParentType = value.GetType().Name; } - - ExcludeInheritedTags = user.GetPreference(PreferenceKind.BlockedTags); - - User = user; } } - public Dictionary<string, string> HasAnyProviderId { get; set; } + public Dictionary<string, string>? HasAnyProviderId { get; set; } public Guid[] AlbumArtistIds { get; set; } @@ -354,8 +341,25 @@ namespace MediaBrowser.Controller.Entities public int? MinWidth { get; set; } - public string SearchTerm { get; set; } + public string? SearchTerm { get; set; } + + public string? SeriesTimerId { get; set; } - public string SeriesTimerId { get; set; } + public void SetUser(User user) + { + MaxParentalRating = user.MaxParentalAgeRating; + + if (MaxParentalRating.HasValue) + { + string other = UnratedItem.Other.ToString(); + BlockUnratedItems = user.GetPreference(PreferenceKind.BlockUnratedItems) + .Where(i => i != other) + .Select(e => Enum.Parse<UnratedItem>(e, true)).ToArray(); + } + + ExcludeInheritedTags = user.GetPreference(PreferenceKind.BlockedTags); + + User = user; + } } } diff --git a/MediaBrowser.Controller/Entities/InternalPeopleQuery.cs b/MediaBrowser.Controller/Entities/InternalPeopleQuery.cs index 4e09ee5736..3e1d892748 100644 --- a/MediaBrowser.Controller/Entities/InternalPeopleQuery.cs +++ b/MediaBrowser.Controller/Entities/InternalPeopleQuery.cs @@ -1,11 +1,26 @@ +#nullable disable + #pragma warning disable CS1591 using System; +using System.Collections.Generic; +using Jellyfin.Data.Entities; namespace MediaBrowser.Controller.Entities { public class InternalPeopleQuery { + public InternalPeopleQuery() + : this(Array.Empty<string>(), Array.Empty<string>()) + { + } + + public InternalPeopleQuery(IReadOnlyList<string> personTypes, IReadOnlyList<string> excludePersonTypes) + { + PersonTypes = personTypes; + ExcludePersonTypes = excludePersonTypes; + } + /// <summary> /// Gets or sets the maximum number of items the query should return. /// </summary> @@ -13,9 +28,9 @@ namespace MediaBrowser.Controller.Entities public Guid ItemId { get; set; } - public string[] PersonTypes { get; set; } + public IReadOnlyList<string> PersonTypes { get; } - public string[] ExcludePersonTypes { get; set; } + public IReadOnlyList<string> ExcludePersonTypes { get; } public int? MaxListOrder { get; set; } @@ -23,10 +38,8 @@ namespace MediaBrowser.Controller.Entities public string NameContains { get; set; } - public InternalPeopleQuery() - { - PersonTypes = Array.Empty<string>(); - ExcludePersonTypes = Array.Empty<string>(); - } + public User User { get; set; } + + public bool? IsFavorite { get; set; } } } diff --git a/MediaBrowser.Controller/Entities/ItemImageInfo.cs b/MediaBrowser.Controller/Entities/ItemImageInfo.cs index 570d8eec0c..ea8555dbfe 100644 --- a/MediaBrowser.Controller/Entities/ItemImageInfo.cs +++ b/MediaBrowser.Controller/Entities/ItemImageInfo.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System; diff --git a/MediaBrowser.Controller/Entities/LinkedChild.cs b/MediaBrowser.Controller/Entities/LinkedChild.cs index 8e0f721e77..fd5fef3dc5 100644 --- a/MediaBrowser.Controller/Entities/LinkedChild.cs +++ b/MediaBrowser.Controller/Entities/LinkedChild.cs @@ -1,15 +1,20 @@ +#nullable disable + #pragma warning disable CS1591 using System; -using System.Collections.Generic; using System.Globalization; using System.Text.Json.Serialization; -using MediaBrowser.Model.IO; namespace MediaBrowser.Controller.Entities { public class LinkedChild { + public LinkedChild() + { + Id = Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture); + } + public string Path { get; set; } public LinkedChildType Type { get; set; } @@ -20,7 +25,7 @@ namespace MediaBrowser.Controller.Entities public string Id { get; set; } /// <summary> - /// Serves as a cache. + /// Gets or sets the linked item id. /// </summary> public Guid? ItemId { get; set; } @@ -39,41 +44,5 @@ namespace MediaBrowser.Controller.Entities return child; } - - public LinkedChild() - { - Id = Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture); - } - } - - public enum LinkedChildType - { - Manual = 0, - Shortcut = 1 - } - - public class LinkedChildComparer : IEqualityComparer<LinkedChild> - { - private readonly IFileSystem _fileSystem; - - public LinkedChildComparer(IFileSystem fileSystem) - { - _fileSystem = fileSystem; - } - - public bool Equals(LinkedChild x, LinkedChild y) - { - if (x.Type == y.Type) - { - return _fileSystem.AreEqual(x.Path, y.Path); - } - - return false; - } - - public int GetHashCode(LinkedChild obj) - { - return ((obj.Path ?? string.Empty) + (obj.LibraryItemId ?? string.Empty) + obj.Type).GetHashCode(); - } } } diff --git a/MediaBrowser.Controller/Entities/LinkedChildComparer.cs b/MediaBrowser.Controller/Entities/LinkedChildComparer.cs new file mode 100644 index 0000000000..4e58e29429 --- /dev/null +++ b/MediaBrowser.Controller/Entities/LinkedChildComparer.cs @@ -0,0 +1,35 @@ +#nullable disable + +#pragma warning disable CS1591 + +using System; +using System.Collections.Generic; +using MediaBrowser.Model.IO; + +namespace MediaBrowser.Controller.Entities +{ + public class LinkedChildComparer : IEqualityComparer<LinkedChild> + { + private readonly IFileSystem _fileSystem; + + public LinkedChildComparer(IFileSystem fileSystem) + { + _fileSystem = fileSystem; + } + + public bool Equals(LinkedChild x, LinkedChild y) + { + if (x.Type == y.Type) + { + return _fileSystem.AreEqual(x.Path, y.Path); + } + + return false; + } + + public int GetHashCode(LinkedChild obj) + { + return ((obj.Path ?? string.Empty) + (obj.LibraryItemId ?? string.Empty) + obj.Type).GetHashCode(StringComparison.Ordinal); + } + } +} \ No newline at end of file diff --git a/MediaBrowser.Controller/Entities/LinkedChildType.cs b/MediaBrowser.Controller/Entities/LinkedChildType.cs new file mode 100644 index 0000000000..9ddb7b6202 --- /dev/null +++ b/MediaBrowser.Controller/Entities/LinkedChildType.cs @@ -0,0 +1,18 @@ +namespace MediaBrowser.Controller.Entities +{ + /// <summary> + /// The linked child type. + /// </summary> + public enum LinkedChildType + { + /// <summary> + /// Manually linked child. + /// </summary> + Manual = 0, + + /// <summary> + /// Shortcut linked child. + /// </summary> + Shortcut = 1 + } +} \ No newline at end of file diff --git a/MediaBrowser.Controller/Entities/Movies/BoxSet.cs b/MediaBrowser.Controller/Entities/Movies/BoxSet.cs index 8de88cc1b1..e46f99cd57 100644 --- a/MediaBrowser.Controller/Entities/Movies/BoxSet.cs +++ b/MediaBrowser.Controller/Entities/Movies/BoxSet.cs @@ -1,4 +1,6 @@ -#pragma warning disable CS1591 +#nullable disable + +#pragma warning disable CA1721, CA1819, CS1591 using System; using System.Collections.Generic; @@ -47,9 +49,33 @@ namespace MediaBrowser.Controller.Entities.Movies /// <value>The display order.</value> public string DisplayOrder { get; set; } + [JsonIgnore] + private bool IsLegacyBoxSet + { + get + { + if (string.IsNullOrEmpty(Path)) + { + return false; + } + + if (LinkedChildren.Length > 0) + { + return false; + } + + return !FileSystem.ContainsSubPath(ConfigurationManager.ApplicationPaths.DataPath, Path); + } + } + + [JsonIgnore] + public override bool IsPreSorted => true; + + public Guid[] LibraryFolderIds { get; set; } + protected override bool GetBlockUnratedValue(User user) { - return user.GetPreference(PreferenceKind.BlockUnratedItems).Contains(UnratedItem.Movie.ToString()); + return user.GetPreferenceValues<UnratedItem>(PreferenceKind.BlockUnratedItems).Contains(UnratedItem.Movie); } public override double GetDefaultPrimaryImageAspectRatio() @@ -81,28 +107,6 @@ namespace MediaBrowser.Controller.Entities.Movies return new List<BaseItem>(); } - [JsonIgnore] - private bool IsLegacyBoxSet - { - get - { - if (string.IsNullOrEmpty(Path)) - { - return false; - } - - if (LinkedChildren.Length > 0) - { - return false; - } - - return !FileSystem.ContainsSubPath(ConfigurationManager.ApplicationPaths.DataPath, Path); - } - } - - [JsonIgnore] - public override bool IsPreSorted => true; - public override bool IsAuthorizedToDelete(User user, List<Folder> allCollectionFolders) { return true; @@ -189,8 +193,6 @@ namespace MediaBrowser.Controller.Entities.Movies return IsVisible(user); } - public Guid[] LibraryFolderIds { get; set; } - private Guid[] GetLibraryFolderIds(User user) { return LibraryManager.GetUserRootFolder().GetChildren(user, true) @@ -217,8 +219,7 @@ namespace MediaBrowser.Controller.Entities.Movies private IEnumerable<BaseItem> FlattenItems(BaseItem item, List<Guid> expandedFolders) { - var boxset = item as BoxSet; - if (boxset != null) + if (item is BoxSet boxset) { if (!expandedFolders.Contains(item.Id)) { diff --git a/MediaBrowser.Controller/Entities/Movies/Movie.cs b/MediaBrowser.Controller/Entities/Movies/Movie.cs index 8b67aaccc6..b54bbf5eb9 100644 --- a/MediaBrowser.Controller/Entities/Movies/Movie.cs +++ b/MediaBrowser.Controller/Entities/Movies/Movie.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System; @@ -142,9 +144,9 @@ namespace MediaBrowser.Controller.Entities.Movies } /// <inheritdoc /> - public override bool BeforeMetadataRefresh(bool replaceAllMetdata) + public override bool BeforeMetadataRefresh(bool replaceAllMetadata) { - var hasChanges = base.BeforeMetadataRefresh(replaceAllMetdata); + var hasChanges = base.BeforeMetadataRefresh(replaceAllMetadata); if (!ProductionYear.HasValue) { diff --git a/MediaBrowser.Controller/Entities/MusicVideo.cs b/MediaBrowser.Controller/Entities/MusicVideo.cs index b278a01423..237ad5198c 100644 --- a/MediaBrowser.Controller/Entities/MusicVideo.cs +++ b/MediaBrowser.Controller/Entities/MusicVideo.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System; @@ -11,15 +13,15 @@ namespace MediaBrowser.Controller.Entities { public class MusicVideo : Video, IHasArtist, IHasMusicGenres, IHasLookupInfo<MusicVideoInfo> { - /// <inheritdoc /> - [JsonIgnore] - public IReadOnlyList<string> Artists { get; set; } - public MusicVideo() { Artists = Array.Empty<string>(); } + /// <inheritdoc /> + [JsonIgnore] + public IReadOnlyList<string> Artists { get; set; } + public override UnratedItem GetBlockUnratedType() { return UnratedItem.Music; @@ -34,9 +36,9 @@ namespace MediaBrowser.Controller.Entities return info; } - public override bool BeforeMetadataRefresh(bool replaceAllMetdata) + public override bool BeforeMetadataRefresh(bool replaceAllMetadata) { - var hasChanges = base.BeforeMetadataRefresh(replaceAllMetdata); + var hasChanges = base.BeforeMetadataRefresh(replaceAllMetadata); if (!ProductionYear.HasValue) { diff --git a/MediaBrowser.Controller/Entities/PeopleHelper.cs b/MediaBrowser.Controller/Entities/PeopleHelper.cs index 1f3758a73a..687ce1ec8d 100644 --- a/MediaBrowser.Controller/Entities/PeopleHelper.cs +++ b/MediaBrowser.Controller/Entities/PeopleHelper.cs @@ -100,23 +100,5 @@ namespace MediaBrowser.Controller.Entities existing.SetProviderId(id.Key, id.Value); } } - - public static bool ContainsPerson(List<PersonInfo> people, string name) - { - if (string.IsNullOrEmpty(name)) - { - throw new ArgumentNullException(nameof(name)); - } - - foreach (var i in people) - { - if (string.Equals(i.Name, name, StringComparison.OrdinalIgnoreCase)) - { - return true; - } - } - - return false; - } } } diff --git a/MediaBrowser.Controller/Entities/Person.cs b/MediaBrowser.Controller/Entities/Person.cs index c4fcb0267a..045c1b89fd 100644 --- a/MediaBrowser.Controller/Entities/Person.cs +++ b/MediaBrowser.Controller/Entities/Person.cs @@ -1,9 +1,11 @@ +#nullable disable + #pragma warning disable CS1591 using System; using System.Collections.Generic; using System.Text.Json.Serialization; -using MediaBrowser.Controller.Extensions; +using Diacritics.Extensions; using MediaBrowser.Controller.Providers; using Microsoft.Extensions.Logging; @@ -14,6 +16,26 @@ namespace MediaBrowser.Controller.Entities /// </summary> public class Person : BaseItem, IItemByName, IHasLookupInfo<PersonLookupInfo> { + /// <summary> + /// Gets the folder containing the item. + /// If the item is a folder, it returns the folder itself. + /// </summary> + /// <value>The containing folder path.</value> + [JsonIgnore] + public override string ContainingFolderPath => Path; + + /// <summary> + /// Gets a value indicating whether to enable alpha numeric sorting. + /// </summary> + [JsonIgnore] + public override bool EnableAlphaNumericSorting => false; + + [JsonIgnore] + public override bool SupportsPeople => false; + + [JsonIgnore] + public override bool SupportsAncestors => false; + public override List<string> GetUserDataKeys() { var list = base.GetUserDataKeys(); @@ -47,14 +69,6 @@ namespace MediaBrowser.Controller.Entities return LibraryManager.GetItemList(query); } - /// <summary> - /// Returns the folder containing the item. - /// If the item is a folder, it returns the folder itself. - /// </summary> - /// <value>The containing folder path.</value> - [JsonIgnore] - public override string ContainingFolderPath => Path; - public override bool CanDelete() { return false; @@ -65,15 +79,6 @@ namespace MediaBrowser.Controller.Entities return true; } - [JsonIgnore] - public override bool EnableAlphaNumericSorting => false; - - [JsonIgnore] - public override bool SupportsPeople => false; - - [JsonIgnore] - public override bool SupportsAncestors => false; - public static string GetPath(string name) { return GetPath(name, true); @@ -124,9 +129,11 @@ namespace MediaBrowser.Controller.Entities /// <summary> /// This is called before any metadata refresh and returns true or false indicating if changes were made. /// </summary> - public override bool BeforeMetadataRefresh(bool replaceAllMetdata) + /// <param name="replaceAllMetadata"><c>true</c> to replace all metadata, <c>false</c> to not.</param> + /// <returns><c>true</c> if changes were made, <c>false</c> if not.</returns> + public override bool BeforeMetadataRefresh(bool replaceAllMetadata) { - var hasChanges = base.BeforeMetadataRefresh(replaceAllMetdata); + var hasChanges = base.BeforeMetadataRefresh(replaceAllMetadata); var newPath = GetRebasedPath(); if (!string.Equals(Path, newPath, StringComparison.Ordinal)) diff --git a/MediaBrowser.Controller/Entities/PersonInfo.cs b/MediaBrowser.Controller/Entities/PersonInfo.cs index 4ff9b0955c..2b689ae7e2 100644 --- a/MediaBrowser.Controller/Entities/PersonInfo.cs +++ b/MediaBrowser.Controller/Entities/PersonInfo.cs @@ -1,4 +1,6 @@ -#pragma warning disable CS1591 +#nullable disable + +#pragma warning disable CA2227, CS1591 using System; using System.Collections.Generic; diff --git a/MediaBrowser.Controller/Entities/Photo.cs b/MediaBrowser.Controller/Entities/Photo.cs index 2fc66176f6..ba6ce189ac 100644 --- a/MediaBrowser.Controller/Entities/Photo.cs +++ b/MediaBrowser.Controller/Entities/Photo.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System.Text.Json.Serialization; @@ -24,8 +26,7 @@ namespace MediaBrowser.Controller.Entities var parents = GetParents(); foreach (var parent in parents) { - var photoAlbum = parent as PhotoAlbum; - if (photoAlbum != null) + if (parent is PhotoAlbum photoAlbum) { return photoAlbum; } @@ -35,6 +36,30 @@ namespace MediaBrowser.Controller.Entities } } + public string CameraMake { get; set; } + + public string CameraModel { get; set; } + + public string Software { get; set; } + + public double? ExposureTime { get; set; } + + public double? FocalLength { get; set; } + + public ImageOrientation? Orientation { get; set; } + + public double? Aperture { get; set; } + + public double? ShutterSpeed { get; set; } + + public double? Latitude { get; set; } + + public double? Longitude { get; set; } + + public double? Altitude { get; set; } + + public int? IsoSpeedRating { get; set; } + public override bool CanDownload() { return true; @@ -68,29 +93,5 @@ namespace MediaBrowser.Controller.Entities return base.GetDefaultPrimaryImageAspectRatio(); } - - public string CameraMake { get; set; } - - public string CameraModel { get; set; } - - public string Software { get; set; } - - public double? ExposureTime { get; set; } - - public double? FocalLength { get; set; } - - public ImageOrientation? Orientation { get; set; } - - public double? Aperture { get; set; } - - public double? ShutterSpeed { get; set; } - - public double? Latitude { get; set; } - - public double? Longitude { get; set; } - - public double? Altitude { get; set; } - - public int? IsoSpeedRating { get; set; } } } diff --git a/MediaBrowser.Controller/Entities/Share.cs b/MediaBrowser.Controller/Entities/Share.cs index 50f1655f39..64f446eef2 100644 --- a/MediaBrowser.Controller/Entities/Share.cs +++ b/MediaBrowser.Controller/Entities/Share.cs @@ -1,12 +1,9 @@ +#nullable disable + #pragma warning disable CS1591 namespace MediaBrowser.Controller.Entities { - public interface IHasShares - { - Share[] Shares { get; set; } - } - public class Share { public string UserId { get; set; } diff --git a/MediaBrowser.Controller/Entities/Studio.cs b/MediaBrowser.Controller/Entities/Studio.cs index 9018ddb75e..c8feb1c946 100644 --- a/MediaBrowser.Controller/Entities/Studio.cs +++ b/MediaBrowser.Controller/Entities/Studio.cs @@ -1,9 +1,11 @@ +#nullable disable + #pragma warning disable CS1591 using System; using System.Collections.Generic; using System.Text.Json.Serialization; -using MediaBrowser.Controller.Extensions; +using Diacritics.Extensions; using Microsoft.Extensions.Logging; namespace MediaBrowser.Controller.Entities @@ -13,21 +15,8 @@ namespace MediaBrowser.Controller.Entities /// </summary> public class Studio : BaseItem, IItemByName { - public override List<string> GetUserDataKeys() - { - var list = base.GetUserDataKeys(); - - list.Insert(0, GetType().Name + "-" + (Name ?? string.Empty).RemoveDiacritics()); - return list; - } - - public override string CreatePresentationUniqueKey() - { - return GetUserDataKeys()[0]; - } - /// <summary> - /// Returns the folder containing the item. + /// Gets the folder containing the item. /// If the item is a folder, it returns the folder itself. /// </summary> /// <value>The containing folder path.</value> @@ -40,6 +29,22 @@ namespace MediaBrowser.Controller.Entities [JsonIgnore] public override bool SupportsAncestors => false; + [JsonIgnore] + public override bool SupportsPeople => false; + + public override List<string> GetUserDataKeys() + { + var list = base.GetUserDataKeys(); + + list.Insert(0, GetType().Name + "-" + (Name ?? string.Empty).RemoveDiacritics()); + return list; + } + + public override string CreatePresentationUniqueKey() + { + return GetUserDataKeys()[0]; + } + public override double GetDefaultPrimaryImageAspectRatio() { double value = 16; @@ -65,9 +70,6 @@ namespace MediaBrowser.Controller.Entities return LibraryManager.GetItemList(query); } - [JsonIgnore] - public override bool SupportsPeople => false; - public static string GetPath(string name) { return GetPath(name, true); @@ -103,9 +105,11 @@ namespace MediaBrowser.Controller.Entities /// <summary> /// This is called before any metadata refresh and returns true or false indicating if changes were made. /// </summary> - public override bool BeforeMetadataRefresh(bool replaceAllMetdata) + /// <param name="replaceAllMetadata"><c>true</c> to replace all metadata, <c>false</c> to not.</param> + /// <returns><c>true</c> if changes were made, <c>false</c> if not.</returns> + public override bool BeforeMetadataRefresh(bool replaceAllMetadata) { - var hasChanges = base.BeforeMetadataRefresh(replaceAllMetdata); + var hasChanges = base.BeforeMetadataRefresh(replaceAllMetadata); var newPath = GetRebasedPath(); if (!string.Equals(Path, newPath, StringComparison.Ordinal)) diff --git a/MediaBrowser.Controller/Entities/TV/Episode.cs b/MediaBrowser.Controller/Entities/TV/Episode.cs index dc12fbbead..27c3ff81bd 100644 --- a/MediaBrowser.Controller/Entities/TV/Episode.cs +++ b/MediaBrowser.Controller/Entities/TV/Episode.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System; @@ -32,7 +34,7 @@ namespace MediaBrowser.Controller.Entities.TV public IReadOnlyList<Guid> RemoteTrailerIds { get; set; } /// <summary> - /// Gets the season in which it aired. + /// Gets or sets the season in which it aired. /// </summary> /// <value>The aired season.</value> public int? AirsBeforeSeasonNumber { get; set; } @@ -42,17 +44,11 @@ namespace MediaBrowser.Controller.Entities.TV public int? AirsBeforeEpisodeNumber { get; set; } /// <summary> - /// This is the ending episode number for double episodes. + /// Gets or sets the ending episode number for double episodes. /// </summary> /// <value>The index number.</value> public int? IndexNumberEnd { get; set; } - public string FindSeriesSortName() - { - var series = Series; - return series == null ? SeriesName : series.SortName; - } - [JsonIgnore] protected override bool SupportsOwnedItems => IsStacked || MediaSourceCount > 1; @@ -74,39 +70,8 @@ namespace MediaBrowser.Controller.Entities.TV [JsonIgnore] protected override bool EnableDefaultVideoUserDataKeys => false; - public override double GetDefaultPrimaryImageAspectRatio() - { - // hack for tv plugins - if (SourceType == SourceType.Channel) - { - return 0; - } - - return 16.0 / 9; - } - - public override List<string> GetUserDataKeys() - { - var list = base.GetUserDataKeys(); - - var series = Series; - if (series != null && ParentIndexNumber.HasValue && IndexNumber.HasValue) - { - var seriesUserDataKeys = series.GetUserDataKeys(); - var take = seriesUserDataKeys.Count; - if (seriesUserDataKeys.Count > 1) - { - take--; - } - - list.InsertRange(0, seriesUserDataKeys.Take(take).Select(i => i + ParentIndexNumber.Value.ToString("000") + IndexNumber.Value.ToString("000"))); - } - - return list; - } - /// <summary> - /// This Episode's Series Instance. + /// Gets the Episode's Series Instance. /// </summary> /// <value>The series.</value> [JsonIgnore] @@ -151,6 +116,74 @@ namespace MediaBrowser.Controller.Entities.TV [JsonIgnore] public string SeasonName { get; set; } + [JsonIgnore] + public override bool SupportsRemoteImageDownloading + { + get + { + if (IsMissingEpisode) + { + return false; + } + + return true; + } + } + + [JsonIgnore] + public bool IsMissingEpisode => LocationType == LocationType.Virtual; + + [JsonIgnore] + public Guid SeasonId { get; set; } + + [JsonIgnore] + public Guid SeriesId { get; set; } + + public string FindSeriesSortName() + { + var series = Series; + return series == null ? SeriesName : series.SortName; + } + + public override double GetDefaultPrimaryImageAspectRatio() + { + // hack for tv plugins + if (SourceType == SourceType.Channel) + { + return 0; + } + + return 16.0 / 9; + } + + public override List<string> GetUserDataKeys() + { + var list = base.GetUserDataKeys(); + + var series = Series; + if (series != null && ParentIndexNumber.HasValue && IndexNumber.HasValue) + { + var seriesUserDataKeys = series.GetUserDataKeys(); + var take = seriesUserDataKeys.Count; + if (seriesUserDataKeys.Count > 1) + { + take--; + } + + var newList = seriesUserDataKeys.GetRange(0, take); + var suffix = ParentIndexNumber.Value.ToString("000", CultureInfo.InvariantCulture) + IndexNumber.Value.ToString("000", CultureInfo.InvariantCulture); + for (int i = 0; i < take; i++) + { + newList[i] = newList[i] + suffix; + } + + newList.AddRange(list); + list = newList; + } + + return list; + } + public string FindSeriesPresentationUniqueKey() { var series = Series; @@ -208,8 +241,8 @@ namespace MediaBrowser.Controller.Entities.TV /// <returns>System.String.</returns> protected override string CreateSortName() { - return (ParentIndexNumber != null ? ParentIndexNumber.Value.ToString("000 - ") : "") - + (IndexNumber != null ? IndexNumber.Value.ToString("0000 - ") : "") + Name; + return (ParentIndexNumber != null ? ParentIndexNumber.Value.ToString("000 - ", CultureInfo.InvariantCulture) : string.Empty) + + (IndexNumber != null ? IndexNumber.Value.ToString("0000 - ", CultureInfo.InvariantCulture) : string.Empty) + Name; } /// <summary> @@ -232,28 +265,6 @@ namespace MediaBrowser.Controller.Entities.TV return false; } - [JsonIgnore] - public override bool SupportsRemoteImageDownloading - { - get - { - if (IsMissingEpisode) - { - return false; - } - - return true; - } - } - - [JsonIgnore] - public bool IsMissingEpisode => LocationType == LocationType.Virtual; - - [JsonIgnore] - public Guid SeasonId { get; set; } - [JsonIgnore] - public Guid SeriesId { get; set; } - public Guid FindSeriesId() { var series = FindParent<Series>(); @@ -276,7 +287,8 @@ namespace MediaBrowser.Controller.Entities.TV public override IEnumerable<FileSystemMetadata> GetDeletePaths() { - return new[] { + return new[] + { new FileSystemMetadata { FullName = Path, @@ -308,9 +320,9 @@ namespace MediaBrowser.Controller.Entities.TV return id; } - public override bool BeforeMetadataRefresh(bool replaceAllMetdata) + public override bool BeforeMetadataRefresh(bool replaceAllMetadata) { - var hasChanges = base.BeforeMetadataRefresh(replaceAllMetdata); + var hasChanges = base.BeforeMetadataRefresh(replaceAllMetadata); if (!IsLocked) { @@ -318,7 +330,7 @@ namespace MediaBrowser.Controller.Entities.TV { try { - if (LibraryManager.FillMissingEpisodeNumbersFromPath(this, replaceAllMetdata)) + if (LibraryManager.FillMissingEpisodeNumbersFromPath(this, replaceAllMetadata)) { hasChanges = true; } diff --git a/MediaBrowser.Controller/Entities/TV/Season.cs b/MediaBrowser.Controller/Entities/TV/Season.cs index 93bdd6e706..926c7b0459 100644 --- a/MediaBrowser.Controller/Entities/TV/Season.cs +++ b/MediaBrowser.Controller/Entities/TV/Season.cs @@ -1,7 +1,10 @@ +#nullable disable + #pragma warning disable CS1591 using System; using System.Collections.Generic; +using System.Globalization; using System.Linq; using System.Text.Json.Serialization; using Jellyfin.Data.Entities; @@ -35,42 +38,8 @@ namespace MediaBrowser.Controller.Entities.TV [JsonIgnore] public override Guid DisplayParentId => SeriesId; - public override double GetDefaultPrimaryImageAspectRatio() - { - double value = 2; - value /= 3; - - return value; - } - - public string FindSeriesSortName() - { - var series = Series; - return series == null ? SeriesName : series.SortName; - } - - public override List<string> GetUserDataKeys() - { - var list = base.GetUserDataKeys(); - - var series = Series; - if (series != null) - { - list.InsertRange(0, series.GetUserDataKeys().Select(i => i + (IndexNumber ?? 0).ToString("000"))); - } - - return list; - } - - public override int GetChildCount(User user) - { - var result = GetChildren(user, true).Count; - - return result; - } - /// <summary> - /// This Episode's Series Instance. + /// Gets this Episode's Series Instance. /// </summary> /// <value>The series.</value> [JsonIgnore] @@ -104,6 +73,57 @@ namespace MediaBrowser.Controller.Entities.TV } } + [JsonIgnore] + public string SeriesPresentationUniqueKey { get; set; } + + [JsonIgnore] + public string SeriesName { get; set; } + + [JsonIgnore] + public Guid SeriesId { get; set; } + + public override double GetDefaultPrimaryImageAspectRatio() + { + double value = 2; + value /= 3; + + return value; + } + + public string FindSeriesSortName() + { + var series = Series; + return series == null ? SeriesName : series.SortName; + } + + public override List<string> GetUserDataKeys() + { + var list = base.GetUserDataKeys(); + + var series = Series; + if (series != null) + { + var newList = series.GetUserDataKeys(); + var suffix = (IndexNumber ?? 0).ToString("000", CultureInfo.InvariantCulture); + for (int i = 0; i < newList.Count; i++) + { + newList[i] = newList[i] + suffix; + } + + newList.AddRange(list); + list = newList; + } + + return list; + } + + public override int GetChildCount(User user) + { + var result = GetChildren(user, true).Count; + + return result; + } + public override string CreatePresentationUniqueKey() { if (IndexNumber.HasValue) @@ -111,7 +131,7 @@ namespace MediaBrowser.Controller.Entities.TV var series = Series; if (series != null) { - return series.PresentationUniqueKey + "-" + (IndexNumber ?? 0).ToString("000"); + return series.PresentationUniqueKey + "-" + (IndexNumber ?? 0).ToString("000", CultureInfo.InvariantCulture); } } @@ -124,7 +144,7 @@ namespace MediaBrowser.Controller.Entities.TV /// <returns>System.String.</returns> protected override string CreateSortName() { - return IndexNumber != null ? IndexNumber.Value.ToString("0000") : Name; + return IndexNumber != null ? IndexNumber.Value.ToString("0000", CultureInfo.InvariantCulture) : Name; } protected override QueryResult<BaseItem> GetItemsInternal(InternalItemsQuery query) @@ -146,6 +166,9 @@ namespace MediaBrowser.Controller.Entities.TV /// <summary> /// Gets the episodes. /// </summary> + /// <param name="user">The user.</param> + /// <param name="options">The options to use.</param> + /// <returns>Set of episodes.</returns> public List<BaseItem> GetEpisodes(User user, DtoOptions options) { return GetEpisodes(Series, user, options); @@ -182,15 +205,6 @@ namespace MediaBrowser.Controller.Entities.TV return UnratedItem.Series; } - [JsonIgnore] - public string SeriesPresentationUniqueKey { get; set; } - - [JsonIgnore] - public string SeriesName { get; set; } - - [JsonIgnore] - public Guid SeriesId { get; set; } - public string FindSeriesPresentationUniqueKey() { var series = Series; @@ -230,10 +244,11 @@ namespace MediaBrowser.Controller.Entities.TV /// <summary> /// This is called before any metadata refresh and returns true or false indicating if changes were made. /// </summary> + /// <param name="replaceAllMetadata"><c>true</c> to replace metdata, <c>false</c> to not.</param> /// <returns><c>true</c> if XXXX, <c>false</c> otherwise.</returns> - public override bool BeforeMetadataRefresh(bool replaceAllMetdata) + public override bool BeforeMetadataRefresh(bool replaceAllMetadata) { - var hasChanges = base.BeforeMetadataRefresh(replaceAllMetdata); + var hasChanges = base.BeforeMetadataRefresh(replaceAllMetadata); if (!IndexNumber.HasValue && !string.IsNullOrEmpty(Path)) { diff --git a/MediaBrowser.Controller/Entities/TV/Series.cs b/MediaBrowser.Controller/Entities/TV/Series.cs index 75a746bfb3..beda504b91 100644 --- a/MediaBrowser.Controller/Entities/TV/Series.cs +++ b/MediaBrowser.Controller/Entities/TV/Series.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System; @@ -57,8 +59,11 @@ namespace MediaBrowser.Controller.Entities.TV public IReadOnlyList<Guid> RemoteTrailerIds { get; set; } /// <summary> - /// airdate, dvd or absolute. + /// Gets or sets the display order. /// </summary> + /// <remarks> + /// Valid options are airdate, dvd or absolute. + /// </remarks> public string DisplayOrder { get; set; } /// <summary> @@ -67,6 +72,9 @@ namespace MediaBrowser.Controller.Entities.TV /// <value>The status.</value> public SeriesStatus? Status { get; set; } + [JsonIgnore] + public override bool StopRefreshIfLocalMetadataFound => false; + public override double GetDefaultPrimaryImageAspectRatio() { double value = 2; @@ -107,7 +115,7 @@ namespace MediaBrowser.Controller.Entities.TV return key; } - return key + "-" + string.Join("-", folders); + return key + "-" + string.Join('-', folders); } private static string GetUniqueSeriesKey(BaseItem series) @@ -151,7 +159,7 @@ namespace MediaBrowser.Controller.Entities.TV if (query.IncludeItemTypes.Length == 0) { - query.IncludeItemTypes = new[] { typeof(Episode).Name }; + query.IncludeItemTypes = new[] { nameof(Episode) }; } query.IsVirtualItem = false; @@ -169,14 +177,12 @@ namespace MediaBrowser.Controller.Entities.TV { var list = base.GetUserDataKeys(); - var key = this.GetProviderId(MetadataProvider.Imdb); - if (!string.IsNullOrEmpty(key)) + if (this.TryGetProviderId(MetadataProvider.Imdb, out var key)) { list.Insert(0, key); } - key = this.GetProviderId(MetadataProvider.Tvdb); - if (!string.IsNullOrEmpty(key)) + if (this.TryGetProviderId(MetadataProvider.Tvdb, out key)) { list.Insert(0, key); } @@ -207,8 +213,8 @@ namespace MediaBrowser.Controller.Entities.TV query.AncestorWithPresentationUniqueKey = null; query.SeriesPresentationUniqueKey = seriesKey; - query.IncludeItemTypes = new[] { typeof(Season).Name }; - query.OrderBy = new[] { ItemSortBy.SortName }.Select(i => new ValueTuple<string, SortOrder>(i, SortOrder.Ascending)).ToArray(); + query.IncludeItemTypes = new[] { nameof(Season) }; + query.OrderBy = new[] { (ItemSortBy.SortName, SortOrder.Ascending) }; if (user != null && !user.DisplayMissingEpisodes) { @@ -228,12 +234,12 @@ namespace MediaBrowser.Controller.Entities.TV query.SeriesPresentationUniqueKey = seriesKey; if (query.OrderBy.Count == 0) { - query.OrderBy = new[] { ItemSortBy.SortName }.Select(i => new ValueTuple<string, SortOrder>(i, SortOrder.Ascending)).ToArray(); + query.OrderBy = new[] { (ItemSortBy.SortName, SortOrder.Ascending) }; } if (query.IncludeItemTypes.Length == 0) { - query.IncludeItemTypes = new[] { typeof(Episode).Name, typeof(Season).Name }; + query.IncludeItemTypes = new[] { nameof(Episode), nameof(Season) }; } query.IsVirtualItem = false; @@ -253,8 +259,8 @@ namespace MediaBrowser.Controller.Entities.TV { AncestorWithPresentationUniqueKey = null, SeriesPresentationUniqueKey = seriesKey, - IncludeItemTypes = new[] { typeof(Episode).Name, typeof(Season).Name }, - OrderBy = new[] { ItemSortBy.SortName }.Select(i => new ValueTuple<string, SortOrder>(i, SortOrder.Ascending)).ToArray(), + IncludeItemTypes = new[] { nameof(Episode), nameof(Season) }, + OrderBy = new[] { (ItemSortBy.SortName, SortOrder.Ascending) }, DtoOptions = options }; @@ -318,20 +324,13 @@ namespace MediaBrowser.Controller.Entities.TV cancellationToken.ThrowIfCancellationRequested(); - var skipItem = false; - - var episode = item as Episode; - - if (episode != null + bool skipItem = item is Episode episode && refreshOptions.MetadataRefreshMode != MetadataRefreshMode.FullRefresh && !refreshOptions.ReplaceAllMetadata && episode.IsMissingEpisode && episode.LocationType == LocationType.Virtual && episode.PremiereDate.HasValue - && (DateTime.UtcNow - episode.PremiereDate.Value).TotalDays > 30) - { - skipItem = true; - } + && (DateTime.UtcNow - episode.PremiereDate.Value).TotalDays > 30; if (!skipItem) { @@ -364,8 +363,8 @@ namespace MediaBrowser.Controller.Entities.TV { AncestorWithPresentationUniqueKey = queryFromSeries ? null : seriesKey, SeriesPresentationUniqueKey = queryFromSeries ? seriesKey : null, - IncludeItemTypes = new[] { typeof(Episode).Name }, - OrderBy = new[] { ItemSortBy.SortName }.Select(i => new ValueTuple<string, SortOrder>(i, SortOrder.Ascending)).ToArray(), + IncludeItemTypes = new[] { nameof(Episode) }, + OrderBy = new[] { (ItemSortBy.SortName, SortOrder.Ascending) }, DtoOptions = options }; if (user != null) @@ -398,6 +397,10 @@ namespace MediaBrowser.Controller.Entities.TV /// <summary> /// Filters the episodes by season. /// </summary> + /// <param name="episodes">The episodes.</param> + /// <param name="parentSeason">The season.</param> + /// <param name="includeSpecials"><c>true</c> to include special, <c>false</c> to not.</param> + /// <returns>The set of episodes.</returns> public static IEnumerable<BaseItem> FilterEpisodesBySeason(IEnumerable<BaseItem> episodes, Season parentSeason, bool includeSpecials) { var seasonNumber = parentSeason.IndexNumber; @@ -428,6 +431,10 @@ namespace MediaBrowser.Controller.Entities.TV /// <summary> /// Filters the episodes by season. /// </summary> + /// <param name="episodes">The episodes.</param> + /// <param name="seasonNumber">The season.</param> + /// <param name="includeSpecials"><c>true</c> to include special, <c>false</c> to not.</param> + /// <returns>The set of episodes.</returns> public static IEnumerable<Episode> FilterEpisodesBySeason(IEnumerable<Episode> episodes, int seasonNumber, bool includeSpecials) { if (!includeSpecials || seasonNumber < 1) @@ -452,7 +459,7 @@ namespace MediaBrowser.Controller.Entities.TV protected override bool GetBlockUnratedValue(User user) { - return user.GetPreference(PreferenceKind.BlockUnratedItems).Contains(UnratedItem.Series.ToString()); + return user.GetPreferenceValues<UnratedItem>(PreferenceKind.BlockUnratedItems).Contains(UnratedItem.Series); } public override UnratedItem GetBlockUnratedType() @@ -503,8 +510,5 @@ namespace MediaBrowser.Controller.Entities.TV return list; } - - [JsonIgnore] - public override bool StopRefreshIfLocalMetadataFound => false; } } diff --git a/MediaBrowser.Controller/Entities/Trailer.cs b/MediaBrowser.Controller/Entities/Trailer.cs index 9ae8ad7087..1c558d4196 100644 --- a/MediaBrowser.Controller/Entities/Trailer.cs +++ b/MediaBrowser.Controller/Entities/Trailer.cs @@ -1,4 +1,6 @@ -#pragma warning disable CS1591 +#nullable disable + +#pragma warning disable CA1819, CS1591 using System; using System.Collections.Generic; @@ -21,6 +23,9 @@ namespace MediaBrowser.Controller.Entities TrailerTypes = Array.Empty<TrailerType>(); } + [JsonIgnore] + public override bool StopRefreshIfLocalMetadataFound => false; + public TrailerType[] TrailerTypes { get; set; } public override double GetDefaultPrimaryImageAspectRatio() @@ -43,9 +48,9 @@ namespace MediaBrowser.Controller.Entities return info; } - public override bool BeforeMetadataRefresh(bool replaceAllMetdata) + public override bool BeforeMetadataRefresh(bool replaceAllMetadata) { - var hasChanges = base.BeforeMetadataRefresh(replaceAllMetdata); + var hasChanges = base.BeforeMetadataRefresh(replaceAllMetadata); if (!ProductionYear.HasValue) { @@ -95,8 +100,5 @@ namespace MediaBrowser.Controller.Entities return list; } - - [JsonIgnore] - public override bool StopRefreshIfLocalMetadataFound => false; } } diff --git a/MediaBrowser.Controller/Entities/UserItemData.cs b/MediaBrowser.Controller/Entities/UserItemData.cs index db63c42e4c..50ba9ef308 100644 --- a/MediaBrowser.Controller/Entities/UserItemData.cs +++ b/MediaBrowser.Controller/Entities/UserItemData.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System; @@ -10,6 +12,13 @@ namespace MediaBrowser.Controller.Entities /// </summary> public class UserItemData { + public const double MinLikeValue = 6.5; + + /// <summary> + /// The _rating. + /// </summary> + private double? _rating; + /// <summary> /// Gets or sets the user id. /// </summary> @@ -22,11 +31,6 @@ namespace MediaBrowser.Controller.Entities /// <value>The key.</value> public string Key { get; set; } - /// <summary> - /// The _rating. - /// </summary> - private double? _rating; - /// <summary> /// Gets or sets the users 0-10 rating. /// </summary> @@ -91,10 +95,8 @@ namespace MediaBrowser.Controller.Entities /// <value>The index of the subtitle stream.</value> public int? SubtitleStreamIndex { get; set; } - public const double MinLikeValue = 6.5; - /// <summary> - /// This is an interpreted property to indicate likes or dislikes + /// Gets or sets a value indicating whether the item is liked or not. /// This should never be serialized. /// </summary> /// <value><c>null</c> if [likes] contains no value, <c>true</c> if [likes]; otherwise, <c>false</c>.</value> diff --git a/MediaBrowser.Controller/Entities/UserRootFolder.cs b/MediaBrowser.Controller/Entities/UserRootFolder.cs index 7f7224ae07..f3bf4749d2 100644 --- a/MediaBrowser.Controller/Entities/UserRootFolder.cs +++ b/MediaBrowser.Controller/Entities/UserRootFolder.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System; @@ -19,22 +21,8 @@ namespace MediaBrowser.Controller.Entities /// </summary> public class UserRootFolder : Folder { - private List<Guid> _childrenIds = null; private readonly object _childIdsLock = new object(); - protected override List<BaseItem> LoadChildren() - { - lock (_childIdsLock) - { - if (_childrenIds == null) - { - var list = base.LoadChildren(); - _childrenIds = list.Select(i => i.Id).ToList(); - return list; - } - - return _childrenIds.Select(LibraryManager.GetItemById).Where(i => i != null).ToList(); - } - } + private List<Guid> _childrenIds = null; [JsonIgnore] public override bool SupportsInheritedParentImages => false; @@ -42,6 +30,12 @@ namespace MediaBrowser.Controller.Entities [JsonIgnore] public override bool SupportsPlayedStatus => false; + [JsonIgnore] + protected override bool SupportsShortcutChildren => true; + + [JsonIgnore] + public override bool IsPreSorted => true; + private void ClearCache() { lock (_childIdsLock) @@ -50,6 +44,21 @@ namespace MediaBrowser.Controller.Entities } } + protected override List<BaseItem> LoadChildren() + { + lock (_childIdsLock) + { + if (_childrenIds == null) + { + var list = base.LoadChildren(); + _childrenIds = list.Select(i => i.Id).ToList(); + return list; + } + + return _childrenIds.Select(LibraryManager.GetItemById).Where(i => i != null).ToList(); + } + } + protected override QueryResult<BaseItem> GetItemsInternal(InternalItemsQuery query) { if (query.Recursive) @@ -71,12 +80,6 @@ namespace MediaBrowser.Controller.Entities return GetChildren(user, true).Count; } - [JsonIgnore] - protected override bool SupportsShortcutChildren => true; - - [JsonIgnore] - public override bool IsPreSorted => true; - protected override IEnumerable<BaseItem> GetEligibleChildrenForRecursiveChildren(User user) { var list = base.GetEligibleChildrenForRecursiveChildren(user).ToList(); @@ -85,10 +88,10 @@ namespace MediaBrowser.Controller.Entities return list; } - public override bool BeforeMetadataRefresh(bool replaceAllMetdata) + public override bool BeforeMetadataRefresh(bool replaceAllMetadata) { ClearCache(); - var hasChanges = base.BeforeMetadataRefresh(replaceAllMetdata); + var hasChanges = base.BeforeMetadataRefresh(replaceAllMetadata); if (string.Equals("default", Name, StringComparison.OrdinalIgnoreCase)) { @@ -106,11 +109,11 @@ namespace MediaBrowser.Controller.Entities return base.GetNonCachedChildren(directoryService); } - protected override async Task ValidateChildrenInternal(IProgress<double> progress, CancellationToken cancellationToken, bool recursive, bool refreshChildMetadata, MetadataRefreshOptions refreshOptions, IDirectoryService directoryService) + protected override async Task ValidateChildrenInternal(IProgress<double> progress, bool recursive, bool refreshChildMetadata, MetadataRefreshOptions refreshOptions, IDirectoryService directoryService, CancellationToken cancellationToken) { ClearCache(); - await base.ValidateChildrenInternal(progress, cancellationToken, recursive, refreshChildMetadata, refreshOptions, directoryService) + await base.ValidateChildrenInternal(progress, recursive, refreshChildMetadata, refreshOptions, directoryService, cancellationToken) .ConfigureAwait(false); ClearCache(); diff --git a/MediaBrowser.Controller/Entities/UserView.cs b/MediaBrowser.Controller/Entities/UserView.cs index b1da4d64cb..62f3c4b557 100644 --- a/MediaBrowser.Controller/Entities/UserView.cs +++ b/MediaBrowser.Controller/Entities/UserView.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System; @@ -13,21 +15,56 @@ namespace MediaBrowser.Controller.Entities { public class UserView : Folder, IHasCollectionType { - /// <inheritdoc /> + private static readonly string[] _viewTypesEligibleForGrouping = new string[] + { + Model.Entities.CollectionType.Movies, + Model.Entities.CollectionType.TvShows, + string.Empty + }; + + private static readonly string[] _originalFolderViewTypes = new string[] + { + Model.Entities.CollectionType.Books, + Model.Entities.CollectionType.MusicVideos, + Model.Entities.CollectionType.HomeVideos, + Model.Entities.CollectionType.Photos, + Model.Entities.CollectionType.Music, + Model.Entities.CollectionType.BoxSets + }; + + public static ITVSeriesManager TVSeriesManager { get; set; } + + /// <summary> + /// Gets or sets the view type. + /// </summary> public string ViewType { get; set; } - /// <inheritdoc /> + /// <summary> + /// Gets or sets the display parent id. + /// </summary> public new Guid DisplayParentId { get; set; } - /// <inheritdoc /> + /// <summary> + /// Gets or sets the user id. + /// </summary> public Guid? UserId { get; set; } - public static ITVSeriesManager TVSeriesManager; - /// <inheritdoc /> [JsonIgnore] public string CollectionType => ViewType; + /// <inheritdoc /> + [JsonIgnore] + public override bool SupportsInheritedParentImages => false; + + /// <inheritdoc /> + [JsonIgnore] + public override bool SupportsPlayedStatus => false; + + /// <inheritdoc /> + [JsonIgnore] + public override bool SupportsPeople => false; + /// <inheritdoc /> public override IEnumerable<Guid> GetIdsForAncestorQuery() { @@ -45,17 +82,13 @@ namespace MediaBrowser.Controller.Entities } } - [JsonIgnore] - public override bool SupportsInheritedParentImages => false; - - [JsonIgnore] - public override bool SupportsPlayedStatus => false; - + /// <inheritdoc /> public override int GetChildCount(User user) { return GetChildren(user, true).Count; } + /// <inheritdoc /> protected override QueryResult<BaseItem> GetItemsInternal(InternalItemsQuery query) { var parent = this as Folder; @@ -73,12 +106,10 @@ namespace MediaBrowser.Controller.Entities .GetUserItems(parent, this, CollectionType, query); } + /// <inheritdoc /> public override List<BaseItem> GetChildren(User user, bool includeLinkedChildren, InternalItemsQuery query) { - if (query == null) - { - query = new InternalItemsQuery(user); - } + query ??= new InternalItemsQuery(user); query.EnableTotalRecordCount = false; var result = GetItemList(query); @@ -86,16 +117,19 @@ namespace MediaBrowser.Controller.Entities return result.ToList(); } + /// <inheritdoc /> public override bool CanDelete() { return false; } + /// <inheritdoc /> public override bool IsSaveLocalMetadataEnabled() { return true; } + /// <inheritdoc /> public override IEnumerable<BaseItem> GetRecursiveChildren(User user, InternalItemsQuery query) { query.SetUser(user); @@ -106,32 +140,26 @@ namespace MediaBrowser.Controller.Entities return GetItemList(query); } + /// <inheritdoc /> protected override IEnumerable<BaseItem> GetEligibleChildrenForRecursiveChildren(User user) { return GetChildren(user, false); } - private static string[] UserSpecificViewTypes = new string[] - { - Model.Entities.CollectionType.Playlists - }; - public static bool IsUserSpecific(Folder folder) { - var collectionFolder = folder as ICollectionFolder; - - if (collectionFolder == null) + if (folder is not ICollectionFolder collectionFolder) { return false; } - var supportsUserSpecific = folder as ISupportsUserSpecificView; - if (supportsUserSpecific != null && supportsUserSpecific.EnableUserSpecificView) + if (folder is ISupportsUserSpecificView supportsUserSpecific + && supportsUserSpecific.EnableUserSpecificView) { return true; } - return UserSpecificViewTypes.Contains(collectionFolder.CollectionType ?? string.Empty, StringComparer.OrdinalIgnoreCase); + return string.Equals(Model.Entities.CollectionType.Playlists, collectionFolder.CollectionType, StringComparison.OrdinalIgnoreCase); } public static bool IsEligibleForGrouping(Folder folder) @@ -140,39 +168,19 @@ namespace MediaBrowser.Controller.Entities && IsEligibleForGrouping(collectionFolder.CollectionType); } - private static string[] ViewTypesEligibleForGrouping = new string[] - { - Model.Entities.CollectionType.Movies, - Model.Entities.CollectionType.TvShows, - string.Empty - }; - public static bool IsEligibleForGrouping(string viewType) { - return ViewTypesEligibleForGrouping.Contains(viewType ?? string.Empty, StringComparer.OrdinalIgnoreCase); + return _viewTypesEligibleForGrouping.Contains(viewType ?? string.Empty, StringComparer.OrdinalIgnoreCase); } - private static string[] OriginalFolderViewTypes = new string[] - { - Model.Entities.CollectionType.Books, - Model.Entities.CollectionType.MusicVideos, - Model.Entities.CollectionType.HomeVideos, - Model.Entities.CollectionType.Photos, - Model.Entities.CollectionType.Music, - Model.Entities.CollectionType.BoxSets - }; - public static bool EnableOriginalFolder(string viewType) { - return OriginalFolderViewTypes.Contains(viewType ?? string.Empty, StringComparer.OrdinalIgnoreCase); + return _originalFolderViewTypes.Contains(viewType ?? string.Empty, StringComparer.OrdinalIgnoreCase); } - protected override Task ValidateChildrenInternal(IProgress<double> progress, System.Threading.CancellationToken cancellationToken, bool recursive, bool refreshChildMetadata, Providers.MetadataRefreshOptions refreshOptions, Providers.IDirectoryService directoryService) + protected override Task ValidateChildrenInternal(IProgress<double> progress, bool recursive, bool refreshChildMetadata, Providers.MetadataRefreshOptions refreshOptions, Providers.IDirectoryService directoryService, System.Threading.CancellationToken cancellationToken) { return Task.CompletedTask; } - - [JsonIgnore] - public override bool SupportsPeople => false; } } diff --git a/MediaBrowser.Controller/Entities/UserViewBuilder.cs b/MediaBrowser.Controller/Entities/UserViewBuilder.cs index 7bb311900e..266fda767d 100644 --- a/MediaBrowser.Controller/Entities/UserViewBuilder.cs +++ b/MediaBrowser.Controller/Entities/UserViewBuilder.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System; @@ -53,17 +55,17 @@ namespace MediaBrowser.Controller.Entities // if (query.IncludeItemTypes != null && // query.IncludeItemTypes.Length == 1 && // string.Equals(query.IncludeItemTypes[0], "Playlist", StringComparison.OrdinalIgnoreCase)) - //{ + // { // if (!string.Equals(viewType, CollectionType.Playlists, StringComparison.OrdinalIgnoreCase)) // { // return await FindPlaylists(queryParent, user, query).ConfigureAwait(false); // } - //} + // } switch (viewType) { case CollectionType.Folders: - return GetResult(_libraryManager.GetUserRootFolder().GetChildren(user, true), queryParent, query); + return GetResult(_libraryManager.GetUserRootFolder().GetChildren(user, true), query); case CollectionType.TvShows: return GetTvView(queryParent, user, query); @@ -108,7 +110,7 @@ namespace MediaBrowser.Controller.Entities return GetMovieMovies(queryParent, user, query); case SpecialFolder.MovieCollections: - return GetMovieCollections(queryParent, user, query); + return GetMovieCollections(user, query); case SpecialFolder.TvFavoriteEpisodes: return GetFavoriteEpisodes(queryParent, user, query); @@ -120,7 +122,7 @@ namespace MediaBrowser.Controller.Entities { if (queryParent is UserView) { - return GetResult(GetMediaFolders(user).OfType<Folder>().SelectMany(i => i.GetChildren(user, true)), queryParent, query); + return GetResult(GetMediaFolders(user).OfType<Folder>().SelectMany(i => i.GetChildren(user, true)), query); } return queryParent.GetItems(query); @@ -142,7 +144,7 @@ namespace MediaBrowser.Controller.Entities if (query.IncludeItemTypes.Length == 0) { - query.IncludeItemTypes = new[] { typeof(Movie).Name }; + query.IncludeItemTypes = new[] { nameof(Movie) }; } return parent.QueryRecursive(query); @@ -158,7 +160,7 @@ namespace MediaBrowser.Controller.Entities GetUserView(SpecialFolder.MovieGenres, "Genres", "5", parent) }; - return GetResult(list, parent, query); + return GetResult(list, query); } private QueryResult<BaseItem> GetFavoriteMovies(Folder parent, User user, InternalItemsQuery query) @@ -167,7 +169,7 @@ namespace MediaBrowser.Controller.Entities query.Parent = parent; query.SetUser(user); query.IsFavorite = true; - query.IncludeItemTypes = new[] { typeof(Movie).Name }; + query.IncludeItemTypes = new[] { nameof(Movie) }; return _libraryManager.GetItemsResult(query); } @@ -178,7 +180,7 @@ namespace MediaBrowser.Controller.Entities query.Parent = parent; query.SetUser(user); query.IsFavorite = true; - query.IncludeItemTypes = new[] { typeof(Series).Name }; + query.IncludeItemTypes = new[] { nameof(Series) }; return _libraryManager.GetItemsResult(query); } @@ -189,7 +191,7 @@ namespace MediaBrowser.Controller.Entities query.Parent = parent; query.SetUser(user); query.IsFavorite = true; - query.IncludeItemTypes = new[] { typeof(Episode).Name }; + query.IncludeItemTypes = new[] { nameof(Episode) }; return _libraryManager.GetItemsResult(query); } @@ -200,15 +202,15 @@ namespace MediaBrowser.Controller.Entities query.Parent = parent; query.SetUser(user); - query.IncludeItemTypes = new[] { typeof(Movie).Name }; + query.IncludeItemTypes = new[] { nameof(Movie) }; return _libraryManager.GetItemsResult(query); } - private QueryResult<BaseItem> GetMovieCollections(Folder parent, User user, InternalItemsQuery query) + private QueryResult<BaseItem> GetMovieCollections(User user, InternalItemsQuery query) { query.Parent = null; - query.IncludeItemTypes = new[] { typeof(BoxSet).Name }; + query.IncludeItemTypes = new[] { nameof(BoxSet) }; query.SetUser(user); query.Recursive = true; @@ -217,26 +219,25 @@ namespace MediaBrowser.Controller.Entities private QueryResult<BaseItem> GetMovieLatest(Folder parent, User user, InternalItemsQuery query) { - query.OrderBy = new[] { ItemSortBy.DateCreated, ItemSortBy.SortName }.Select(i => new ValueTuple<string, SortOrder>(i, SortOrder.Descending)).ToArray(); - + query.OrderBy = new[] { (ItemSortBy.DateCreated, SortOrder.Descending), (ItemSortBy.SortName, SortOrder.Descending) }; query.Recursive = true; query.Parent = parent; query.SetUser(user); query.Limit = GetSpecialItemsLimit(); - query.IncludeItemTypes = new[] { typeof(Movie).Name }; + query.IncludeItemTypes = new[] { nameof(Movie) }; return ConvertToResult(_libraryManager.GetItemList(query)); } private QueryResult<BaseItem> GetMovieResume(Folder parent, User user, InternalItemsQuery query) { - query.OrderBy = new[] { ItemSortBy.DatePlayed, ItemSortBy.SortName }.Select(i => new ValueTuple<string, SortOrder>(i, SortOrder.Descending)).ToArray(); + query.OrderBy = new[] { (ItemSortBy.DatePlayed, SortOrder.Descending), (ItemSortBy.SortName, SortOrder.Descending) }; query.IsResumable = true; query.Recursive = true; query.Parent = parent; query.SetUser(user); query.Limit = GetSpecialItemsLimit(); - query.IncludeItemTypes = new[] { typeof(Movie).Name }; + query.IncludeItemTypes = new[] { nameof(Movie) }; return ConvertToResult(_libraryManager.GetItemList(query)); } @@ -255,7 +256,7 @@ namespace MediaBrowser.Controller.Entities { var genres = parent.QueryRecursive(new InternalItemsQuery(user) { - IncludeItemTypes = new[] { typeof(Movie).Name }, + IncludeItemTypes = new[] { nameof(Movie) }, Recursive = true, EnableTotalRecordCount = false }).Items @@ -274,9 +275,9 @@ namespace MediaBrowser.Controller.Entities } }) .Where(i => i != null) - .Select(i => GetUserViewWithName(i.Name, SpecialFolder.MovieGenre, i.SortName, parent)); + .Select(i => GetUserViewWithName(SpecialFolder.MovieGenre, i.SortName, parent)); - return GetResult(genres, parent, query); + return GetResult(genres, query); } private QueryResult<BaseItem> GetMovieGenreItems(Folder queryParent, Folder displayParent, User user, InternalItemsQuery query) @@ -286,7 +287,7 @@ namespace MediaBrowser.Controller.Entities query.GenreIds = new[] { displayParent.Id }; query.SetUser(user); - query.IncludeItemTypes = new[] { typeof(Movie).Name }; + query.IncludeItemTypes = new[] { nameof(Movie) }; return _libraryManager.GetItemsResult(query); } @@ -322,18 +323,17 @@ namespace MediaBrowser.Controller.Entities GetUserView(SpecialFolder.TvGenres, "Genres", "6", parent) }; - return GetResult(list, parent, query); + return GetResult(list, query); } private QueryResult<BaseItem> GetTvLatest(Folder parent, User user, InternalItemsQuery query) { - query.OrderBy = new[] { ItemSortBy.DateCreated, ItemSortBy.SortName }.Select(i => new ValueTuple<string, SortOrder>(i, SortOrder.Descending)).ToArray(); - + query.OrderBy = new[] { (ItemSortBy.DateCreated, SortOrder.Descending), (ItemSortBy.SortName, SortOrder.Descending) }; query.Recursive = true; query.Parent = parent; query.SetUser(user); query.Limit = GetSpecialItemsLimit(); - query.IncludeItemTypes = new[] { typeof(Episode).Name }; + query.IncludeItemTypes = new[] { nameof(Episode) }; query.IsVirtualItem = false; return ConvertToResult(_libraryManager.GetItemList(query)); @@ -344,25 +344,27 @@ namespace MediaBrowser.Controller.Entities var parentFolders = GetMediaFolders(parent, query.User, new[] { CollectionType.TvShows, string.Empty }); var result = _tvSeriesManager.GetNextUp( - new NextUpQuery - { - Limit = query.Limit, - StartIndex = query.StartIndex, - UserId = query.User.Id - }, parentFolders, query.DtoOptions); + new NextUpQuery + { + Limit = query.Limit, + StartIndex = query.StartIndex, + UserId = query.User.Id + }, + parentFolders, + query.DtoOptions); return result; } private QueryResult<BaseItem> GetTvResume(Folder parent, User user, InternalItemsQuery query) { - query.OrderBy = new[] { ItemSortBy.DatePlayed, ItemSortBy.SortName }.Select(i => new ValueTuple<string, SortOrder>(i, SortOrder.Descending)).ToArray(); + query.OrderBy = new[] { (ItemSortBy.DatePlayed, SortOrder.Descending), (ItemSortBy.SortName, SortOrder.Descending) }; query.IsResumable = true; query.Recursive = true; query.Parent = parent; query.SetUser(user); query.Limit = GetSpecialItemsLimit(); - query.IncludeItemTypes = new[] { typeof(Episode).Name }; + query.IncludeItemTypes = new[] { nameof(Episode) }; return ConvertToResult(_libraryManager.GetItemList(query)); } @@ -373,7 +375,7 @@ namespace MediaBrowser.Controller.Entities query.Parent = parent; query.SetUser(user); - query.IncludeItemTypes = new[] { typeof(Series).Name }; + query.IncludeItemTypes = new[] { nameof(Series) }; return _libraryManager.GetItemsResult(query); } @@ -382,7 +384,7 @@ namespace MediaBrowser.Controller.Entities { var genres = parent.QueryRecursive(new InternalItemsQuery(user) { - IncludeItemTypes = new[] { typeof(Series).Name }, + IncludeItemTypes = new[] { nameof(Series) }, Recursive = true, EnableTotalRecordCount = false }).Items @@ -401,9 +403,9 @@ namespace MediaBrowser.Controller.Entities } }) .Where(i => i != null) - .Select(i => GetUserViewWithName(i.Name, SpecialFolder.TvGenre, i.SortName, parent)); + .Select(i => GetUserViewWithName(SpecialFolder.TvGenre, i.SortName, parent)); - return GetResult(genres, parent, query); + return GetResult(genres, query); } private QueryResult<BaseItem> GetTvGenreItems(Folder queryParent, Folder displayParent, User user, InternalItemsQuery query) @@ -413,7 +415,7 @@ namespace MediaBrowser.Controller.Entities query.GenreIds = new[] { displayParent.Id }; query.SetUser(user); - query.IncludeItemTypes = new[] { typeof(Series).Name }; + query.IncludeItemTypes = new[] { nameof(Series) }; return _libraryManager.GetItemsResult(query); } @@ -430,13 +432,12 @@ namespace MediaBrowser.Controller.Entities private QueryResult<BaseItem> GetResult<T>( IEnumerable<T> items, - BaseItem queryParent, InternalItemsQuery query) where T : BaseItem { items = items.Where(i => Filter(i, query.User, query, _userDataManager, _libraryManager)); - return PostFilterAndSort(items, queryParent, null, query, _libraryManager, _config); + return PostFilterAndSort(items, null, query, _libraryManager); } public static bool FilterItem(BaseItem item, InternalItemsQuery query) @@ -446,11 +447,9 @@ namespace MediaBrowser.Controller.Entities public static QueryResult<BaseItem> PostFilterAndSort( IEnumerable<BaseItem> items, - BaseItem queryParent, int? totalRecordLimit, InternalItemsQuery query, - ILibraryManager libraryManager, - IServerConfigurationManager configurationManager) + ILibraryManager libraryManager) { var user = query.User; @@ -791,7 +790,7 @@ namespace MediaBrowser.Controller.Entities } // Apply genre filter - if (query.Genres.Length > 0 && !query.Genres.Any(v => item.Genres.Contains(v, StringComparer.OrdinalIgnoreCase))) + if (query.Genres.Count > 0 && !query.Genres.Any(v => item.Genres.Contains(v, StringComparer.OrdinalIgnoreCase))) { return false; } @@ -822,7 +821,7 @@ namespace MediaBrowser.Controller.Entities } // Apply genre filter - if (query.GenreIds.Length > 0 && !query.GenreIds.Any(id => + if (query.GenreIds.Count > 0 && !query.GenreIds.Any(id => { var genreItem = libraryManager.GetItemById(id); return genreItem != null && item.Genres.Contains(genreItem.Name, StringComparer.OrdinalIgnoreCase); @@ -999,7 +998,7 @@ namespace MediaBrowser.Controller.Entities return new BaseItem[] { parent }; } - private UserView GetUserViewWithName(string name, string type, string sortName, BaseItem parent) + private UserView GetUserViewWithName(string type, string sortName, BaseItem parent) { return _userViewManager.GetUserSubView(parent.Id, parent.Id.ToString("N", CultureInfo.InvariantCulture), type, sortName); } diff --git a/MediaBrowser.Controller/Entities/Video.cs b/MediaBrowser.Controller/Entities/Video.cs index 07f3818811..7dd95b85cf 100644 --- a/MediaBrowser.Controller/Entities/Video.cs +++ b/MediaBrowser.Controller/Entities/Video.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System; @@ -26,6 +28,14 @@ namespace MediaBrowser.Controller.Entities ISupportsPlaceHolders, IHasMediaSources { + public Video() + { + AdditionalParts = Array.Empty<string>(); + LocalAlternateVersions = Array.Empty<string>(); + SubtitleFiles = Array.Empty<string>(); + LinkedAlternateVersions = Array.Empty<LinkedChild>(); + } + [JsonIgnore] public string PrimaryVersionId { get; set; } @@ -72,30 +82,6 @@ namespace MediaBrowser.Controller.Entities } } - public void SetPrimaryVersionId(string id) - { - if (string.IsNullOrEmpty(id)) - { - PrimaryVersionId = null; - } - else - { - PrimaryVersionId = id; - } - - PresentationUniqueKey = CreatePresentationUniqueKey(); - } - - public override string CreatePresentationUniqueKey() - { - if (!string.IsNullOrEmpty(PrimaryVersionId)) - { - return PrimaryVersionId; - } - - return base.CreatePresentationUniqueKey(); - } - [JsonIgnore] public override bool SupportsThemeMedia => true; @@ -143,50 +129,12 @@ namespace MediaBrowser.Controller.Entities /// <value>The video3 D format.</value> public Video3DFormat? Video3DFormat { get; set; } - public string[] GetPlayableStreamFileNames() - { - var videoType = VideoType; - - if (videoType == VideoType.Iso && IsoType == Model.Entities.IsoType.BluRay) - { - videoType = VideoType.BluRay; - } - else if (videoType == VideoType.Iso && IsoType == Model.Entities.IsoType.Dvd) - { - videoType = VideoType.Dvd; - } - else - { - return Array.Empty<string>(); - } - - throw new NotImplementedException(); - } - /// <summary> /// Gets or sets the aspect ratio. /// </summary> /// <value>The aspect ratio.</value> public string AspectRatio { get; set; } - public Video() - { - AdditionalParts = Array.Empty<string>(); - LocalAlternateVersions = Array.Empty<string>(); - SubtitleFiles = Array.Empty<string>(); - LinkedAlternateVersions = Array.Empty<LinkedChild>(); - } - - public override bool CanDownload() - { - if (VideoType == VideoType.Dvd || VideoType == VideoType.BluRay) - { - return false; - } - - return IsFileProtocol; - } - [JsonIgnore] public override bool SupportsAddingToPlaylist => true; @@ -214,16 +162,6 @@ namespace MediaBrowser.Controller.Entities [JsonIgnore] public override bool HasLocalAlternateVersions => LocalAlternateVersions.Length > 0; - public IEnumerable<Guid> GetAdditionalPartIds() - { - return AdditionalParts.Select(i => LibraryManager.GetNewItemId(i, typeof(Video))); - } - - public IEnumerable<Guid> GetLocalAlternateVersionIds() - { - return LocalAlternateVersions.Select(i => LibraryManager.GetNewItemId(i, typeof(Video))); - } - public static ILiveTvManager LiveTvManager { get; set; } [JsonIgnore] @@ -240,37 +178,77 @@ namespace MediaBrowser.Controller.Entities } } - protected override bool IsActiveRecording() + [JsonIgnore] + public bool IsCompleteMedia { - return LiveTvManager.GetActiveRecordingInfo(Path) != null; + get + { + if (SourceType == SourceType.Channel) + { + return !Tags.Contains("livestream", StringComparer.OrdinalIgnoreCase); + } + + return !IsActiveRecording(); + } } - public override bool CanDelete() + [JsonIgnore] + protected virtual bool EnableDefaultVideoUserDataKeys => true; + + [JsonIgnore] + public override string ContainingFolderPath { - if (IsActiveRecording()) + get { - return false; - } + if (IsStacked) + { + return System.IO.Path.GetDirectoryName(Path); + } - return base.CanDelete(); + if (!IsPlaceHolder) + { + if (VideoType == VideoType.BluRay || VideoType == VideoType.Dvd) + { + return Path; + } + } + + return base.ContainingFolderPath; + } } [JsonIgnore] - public bool IsCompleteMedia + public override string FileNameWithoutExtension { get { - if (SourceType == SourceType.Channel) + if (IsFileProtocol) { - return !Tags.Contains("livestream", StringComparer.OrdinalIgnoreCase); + if (VideoType == VideoType.BluRay || VideoType == VideoType.Dvd) + { + return System.IO.Path.GetFileName(Path); + } + + return System.IO.Path.GetFileNameWithoutExtension(Path); } - return !IsActiveRecording(); + return null; } } + /// <summary> + /// Gets a value indicating whether [is3 D]. + /// </summary> + /// <value><c>true</c> if [is3 D]; otherwise, <c>false</c>.</value> [JsonIgnore] - protected virtual bool EnableDefaultVideoUserDataKeys => true; + public bool Is3D => Video3DFormat.HasValue; + + /// <summary> + /// Gets the type of the media. + /// </summary> + /// <value>The type of the media.</value> + [JsonIgnore] + public override string MediaType => Model.Entities.MediaType.Video; public override List<string> GetUserDataKeys() { @@ -311,6 +289,65 @@ namespace MediaBrowser.Controller.Entities return list; } + public void SetPrimaryVersionId(string id) + { + if (string.IsNullOrEmpty(id)) + { + PrimaryVersionId = null; + } + else + { + PrimaryVersionId = id; + } + + PresentationUniqueKey = CreatePresentationUniqueKey(); + } + + public override string CreatePresentationUniqueKey() + { + if (!string.IsNullOrEmpty(PrimaryVersionId)) + { + return PrimaryVersionId; + } + + return base.CreatePresentationUniqueKey(); + } + + public override bool CanDownload() + { + if (VideoType == VideoType.Dvd || VideoType == VideoType.BluRay) + { + return false; + } + + return IsFileProtocol; + } + + protected override bool IsActiveRecording() + { + return LiveTvManager.GetActiveRecordingInfo(Path) != null; + } + + public override bool CanDelete() + { + if (IsActiveRecording()) + { + return false; + } + + return base.CanDelete(); + } + + public IEnumerable<Guid> GetAdditionalPartIds() + { + return AdditionalParts.Select(i => LibraryManager.GetNewItemId(i, typeof(Video))); + } + + public IEnumerable<Guid> GetLocalAlternateVersionIds() + { + return LocalAlternateVersions.Select(i => LibraryManager.GetNewItemId(i, typeof(Video))); + } + private string GetUserDataKey(string providerId) { var key = providerId + "-" + ExtraType.ToString().ToLowerInvariant(); @@ -346,47 +383,6 @@ namespace MediaBrowser.Controller.Entities .OrderBy(i => i.SortName); } - [JsonIgnore] - public override string ContainingFolderPath - { - get - { - if (IsStacked) - { - return System.IO.Path.GetDirectoryName(Path); - } - - if (!IsPlaceHolder) - { - if (VideoType == VideoType.BluRay || VideoType == VideoType.Dvd) - { - return Path; - } - } - - return base.ContainingFolderPath; - } - } - - [JsonIgnore] - public override string FileNameWithoutExtension - { - get - { - if (IsFileProtocol) - { - if (VideoType == VideoType.BluRay || VideoType == VideoType.Dvd) - { - return System.IO.Path.GetFileName(Path); - } - - return System.IO.Path.GetFileNameWithoutExtension(Path); - } - - return null; - } - } - internal override ItemUpdateType UpdateFromResolvedItem(BaseItem newItem) { var updateType = base.UpdateFromResolvedItem(newItem); @@ -415,45 +411,6 @@ namespace MediaBrowser.Controller.Entities return updateType; } - public static string[] QueryPlayableStreamFiles(string rootPath, VideoType videoType) - { - if (videoType == VideoType.Dvd) - { - return FileSystem.GetFiles(rootPath, new[] { ".vob" }, false, true) - .OrderByDescending(i => i.Length) - .ThenBy(i => i.FullName) - .Take(1) - .Select(i => i.FullName) - .ToArray(); - } - - if (videoType == VideoType.BluRay) - { - return FileSystem.GetFiles(rootPath, new[] { ".m2ts" }, false, true) - .OrderByDescending(i => i.Length) - .ThenBy(i => i.FullName) - .Take(1) - .Select(i => i.FullName) - .ToArray(); - } - - return Array.Empty<string>(); - } - - /// <summary> - /// Gets a value indicating whether [is3 D]. - /// </summary> - /// <value><c>true</c> if [is3 D]; otherwise, <c>false</c>.</value> - [JsonIgnore] - public bool Is3D => Video3DFormat.HasValue; - - /// <summary> - /// Gets the type of the media. - /// </summary> - /// <value>The type of the media.</value> - [JsonIgnore] - public override string MediaType => Model.Entities.MediaType.Video; - protected override async Task<bool> RefreshedOwnedItems(MetadataRefreshOptions options, List<FileSystemMetadata> fileSystemChildren, CancellationToken cancellationToken) { var hasChanges = await base.RefreshedOwnedItems(options, fileSystemChildren, cancellationToken).ConfigureAwait(false); @@ -525,7 +482,8 @@ namespace MediaBrowser.Controller.Entities { if (!IsInMixedFolder) { - return new[] { + return new[] + { new FileSystemMetadata { FullName = ContainingFolderPath, diff --git a/MediaBrowser.Controller/Entities/Year.cs b/MediaBrowser.Controller/Entities/Year.cs index b2e4d307a6..0853200dd1 100644 --- a/MediaBrowser.Controller/Entities/Year.cs +++ b/MediaBrowser.Controller/Entities/Year.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System; @@ -13,22 +15,33 @@ namespace MediaBrowser.Controller.Entities /// </summary> public class Year : BaseItem, IItemByName { - public override List<string> GetUserDataKeys() - { - var list = base.GetUserDataKeys(); + [JsonIgnore] + public override bool SupportsAncestors => false; - list.Insert(0, "Year-" + Name); - return list; - } + [JsonIgnore] + public override bool SupportsPeople => false; /// <summary> - /// Returns the folder containing the item. + /// Gets the folder containing the item. /// If the item is a folder, it returns the folder itself. /// </summary> /// <value>The containing folder path.</value> [JsonIgnore] public override string ContainingFolderPath => Path; + public override bool CanDelete() + { + return false; + } + + public override List<string> GetUserDataKeys() + { + var list = base.GetUserDataKeys(); + + list.Insert(0, "Year-" + Name); + return list; + } + public override double GetDefaultPrimaryImageAspectRatio() { double value = 2; @@ -37,14 +50,6 @@ namespace MediaBrowser.Controller.Entities return value; } - [JsonIgnore] - public override bool SupportsAncestors => false; - - public override bool CanDelete() - { - return false; - } - public override bool IsSaveLocalMetadataEnabled() { return true; @@ -74,9 +79,6 @@ namespace MediaBrowser.Controller.Entities return null; } - [JsonIgnore] - public override bool SupportsPeople => false; - public static string GetPath(string name) { return GetPath(name, true); @@ -110,11 +112,13 @@ namespace MediaBrowser.Controller.Entities } /// <summary> - /// This is called before any metadata refresh and returns true or false indicating if changes were made. + /// This is called before any metadata refresh and returns true if changes were made. /// </summary> - public override bool BeforeMetadataRefresh(bool replaceAllMetdata) + /// <param name="replaceAllMetadata">Whether to replace all metadata.</param> + /// <returns>true if the item has change, else false.</returns> + public override bool BeforeMetadataRefresh(bool replaceAllMetadata) { - var hasChanges = base.BeforeMetadataRefresh(replaceAllMetdata); + var hasChanges = base.BeforeMetadataRefresh(replaceAllMetadata); var newPath = GetRebasedPath(); if (!string.Equals(Path, newPath, StringComparison.Ordinal)) diff --git a/MediaBrowser.Controller/Events/IEventConsumer.cs b/MediaBrowser.Controller/Events/IEventConsumer.cs index 5c4ab5d8dd..93005134a7 100644 --- a/MediaBrowser.Controller/Events/IEventConsumer.cs +++ b/MediaBrowser.Controller/Events/IEventConsumer.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Threading.Tasks; namespace MediaBrowser.Controller.Events diff --git a/MediaBrowser.Controller/Events/IEventManager.cs b/MediaBrowser.Controller/Events/IEventManager.cs index a1f40b3a6d..074e3f1fe4 100644 --- a/MediaBrowser.Controller/Events/IEventManager.cs +++ b/MediaBrowser.Controller/Events/IEventManager.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Threading.Tasks; namespace MediaBrowser.Controller.Events diff --git a/MediaBrowser.Controller/Events/Session/SessionEndedEventArgs.cs b/MediaBrowser.Controller/Events/Session/SessionEndedEventArgs.cs index 46d7e5a17a..3a331ad00f 100644 --- a/MediaBrowser.Controller/Events/Session/SessionEndedEventArgs.cs +++ b/MediaBrowser.Controller/Events/Session/SessionEndedEventArgs.cs @@ -1,4 +1,4 @@ -using Jellyfin.Data.Events; +using Jellyfin.Data.Events; using MediaBrowser.Controller.Session; namespace MediaBrowser.Controller.Events.Session diff --git a/MediaBrowser.Controller/Events/Session/SessionStartedEventArgs.cs b/MediaBrowser.Controller/Events/Session/SessionStartedEventArgs.cs index aab19cc46a..deeaaf55de 100644 --- a/MediaBrowser.Controller/Events/Session/SessionStartedEventArgs.cs +++ b/MediaBrowser.Controller/Events/Session/SessionStartedEventArgs.cs @@ -1,4 +1,4 @@ -using Jellyfin.Data.Events; +using Jellyfin.Data.Events; using MediaBrowser.Controller.Session; namespace MediaBrowser.Controller.Events.Session diff --git a/MediaBrowser.Controller/Events/Updates/PluginInstallationCancelledEventArgs.cs b/MediaBrowser.Controller/Events/Updates/PluginInstallationCancelledEventArgs.cs index b06046c05a..0dd8b0dbfd 100644 --- a/MediaBrowser.Controller/Events/Updates/PluginInstallationCancelledEventArgs.cs +++ b/MediaBrowser.Controller/Events/Updates/PluginInstallationCancelledEventArgs.cs @@ -1,4 +1,4 @@ -using Jellyfin.Data.Events; +using Jellyfin.Data.Events; using MediaBrowser.Model.Updates; namespace MediaBrowser.Controller.Events.Updates diff --git a/MediaBrowser.Controller/Events/Updates/PluginInstalledEventArgs.cs b/MediaBrowser.Controller/Events/Updates/PluginInstalledEventArgs.cs index dfadc9f61f..c1d503a7eb 100644 --- a/MediaBrowser.Controller/Events/Updates/PluginInstalledEventArgs.cs +++ b/MediaBrowser.Controller/Events/Updates/PluginInstalledEventArgs.cs @@ -1,4 +1,4 @@ -using Jellyfin.Data.Events; +using Jellyfin.Data.Events; using MediaBrowser.Model.Updates; namespace MediaBrowser.Controller.Events.Updates diff --git a/MediaBrowser.Controller/Events/Updates/PluginInstallingEventArgs.cs b/MediaBrowser.Controller/Events/Updates/PluginInstallingEventArgs.cs index 045a600272..7a9866834a 100644 --- a/MediaBrowser.Controller/Events/Updates/PluginInstallingEventArgs.cs +++ b/MediaBrowser.Controller/Events/Updates/PluginInstallingEventArgs.cs @@ -1,4 +1,4 @@ -using Jellyfin.Data.Events; +using Jellyfin.Data.Events; using MediaBrowser.Model.Updates; namespace MediaBrowser.Controller.Events.Updates diff --git a/MediaBrowser.Controller/Events/Updates/PluginUninstalledEventArgs.cs b/MediaBrowser.Controller/Events/Updates/PluginUninstalledEventArgs.cs index 7510b62b88..0f27be9bb7 100644 --- a/MediaBrowser.Controller/Events/Updates/PluginUninstalledEventArgs.cs +++ b/MediaBrowser.Controller/Events/Updates/PluginUninstalledEventArgs.cs @@ -1,18 +1,18 @@ -using Jellyfin.Data.Events; -using MediaBrowser.Common.Plugins; +using Jellyfin.Data.Events; +using MediaBrowser.Model.Plugins; namespace MediaBrowser.Controller.Events.Updates { /// <summary> /// An event that occurs when a plugin is uninstalled. /// </summary> - public class PluginUninstalledEventArgs : GenericEventArgs<IPlugin> + public class PluginUninstalledEventArgs : GenericEventArgs<PluginInfo> { /// <summary> /// Initializes a new instance of the <see cref="PluginUninstalledEventArgs"/> class. /// </summary> /// <param name="arg">The plugin.</param> - public PluginUninstalledEventArgs(IPlugin arg) : base(arg) + public PluginUninstalledEventArgs(PluginInfo arg) : base(arg) { } } diff --git a/MediaBrowser.Controller/Events/Updates/PluginUpdatedEventArgs.cs b/MediaBrowser.Controller/Events/Updates/PluginUpdatedEventArgs.cs index 661ca066a8..b078e06dc8 100644 --- a/MediaBrowser.Controller/Events/Updates/PluginUpdatedEventArgs.cs +++ b/MediaBrowser.Controller/Events/Updates/PluginUpdatedEventArgs.cs @@ -1,4 +1,4 @@ -using Jellyfin.Data.Events; +using Jellyfin.Data.Events; using MediaBrowser.Model.Updates; namespace MediaBrowser.Controller.Events.Updates diff --git a/MediaBrowser.Controller/Extensions/StringExtensions.cs b/MediaBrowser.Controller/Extensions/StringExtensions.cs deleted file mode 100644 index 182c8ef658..0000000000 --- a/MediaBrowser.Controller/Extensions/StringExtensions.cs +++ /dev/null @@ -1,53 +0,0 @@ -#nullable enable -#pragma warning disable CS1591 - -using System; -using System.Globalization; -using System.Linq; -using System.Text; -using System.Text.RegularExpressions; - -namespace MediaBrowser.Controller.Extensions -{ - /// <summary> - /// Class BaseExtensions. - /// </summary> - public static class StringExtensions - { - public static string RemoveDiacritics(this string text) - { - var chars = Normalize(text, NormalizationForm.FormD) - .Where(ch => CharUnicodeInfo.GetUnicodeCategory(ch) != UnicodeCategory.NonSpacingMark); - - return Normalize(string.Concat(chars), NormalizationForm.FormC); - } - - private static string Normalize(string text, NormalizationForm form, bool stripStringOnFailure = true) - { - if (stripStringOnFailure) - { - try - { - return text.Normalize(form); - } - catch (ArgumentException) - { - // will throw if input contains invalid unicode chars - // https://mnaoumov.wordpress.com/2014/06/14/stripping-invalid-characters-from-utf-16-strings/ - text = Regex.Replace(text, "([\ud800-\udbff](?![\udc00-\udfff]))|((?<![\ud800-\udbff])[\udc00-\udfff])", ""); - return Normalize(text, form, false); - } - } - - try - { - return text.Normalize(form); - } - catch (ArgumentException) - { - // if it still fails, return the original text - return text; - } - } - } -} diff --git a/MediaBrowser.Controller/IDisplayPreferencesManager.cs b/MediaBrowser.Controller/IDisplayPreferencesManager.cs index b35f830960..1678d50675 100644 --- a/MediaBrowser.Controller/IDisplayPreferencesManager.cs +++ b/MediaBrowser.Controller/IDisplayPreferencesManager.cs @@ -1,3 +1,5 @@ +#nullable disable + using System; using System.Collections.Generic; using Jellyfin.Data.Entities; @@ -12,14 +14,21 @@ namespace MediaBrowser.Controller /// <summary> /// Gets the display preferences for the user and client. /// </summary> + /// <remarks> + /// This will create the display preferences if it does not exist, but it will not save automatically. + /// </remarks> /// <param name="userId">The user's id.</param> + /// <param name="itemId">The item id.</param> /// <param name="client">The client string.</param> /// <returns>The associated display preferences.</returns> - DisplayPreferences GetDisplayPreferences(Guid userId, string client); + DisplayPreferences GetDisplayPreferences(Guid userId, Guid itemId, string client); /// <summary> /// Gets the default item display preferences for the user and client. /// </summary> + /// <remarks> + /// This will create the item display preferences if it does not exist, but it will not save automatically. + /// </remarks> /// <param name="userId">The user id.</param> /// <param name="itemId">The item id.</param> /// <param name="client">The client string.</param> @@ -34,6 +43,24 @@ namespace MediaBrowser.Controller /// <returns>A list of item display preferences.</returns> IList<ItemDisplayPreferences> ListItemDisplayPreferences(Guid userId, string client); + /// <summary> + /// Gets all of the custom item display preferences for the user and client. + /// </summary> + /// <param name="userId">The user id.</param> + /// <param name="itemId">The item id.</param> + /// <param name="client">The client string.</param> + /// <returns>The dictionary of custom item display preferences.</returns> + Dictionary<string, string> ListCustomItemDisplayPreferences(Guid userId, Guid itemId, string client); + + /// <summary> + /// Sets the custom item display preference for the user and client. + /// </summary> + /// <param name="userId">The user id.</param> + /// <param name="itemId">The item id.</param> + /// <param name="client">The client id.</param> + /// <param name="customPreferences">A dictionary of custom item display preferences.</param> + void SetCustomItemDisplayPreferences(Guid userId, Guid itemId, string client, Dictionary<string, string> customPreferences); + /// <summary> /// Saves changes made to the database. /// </summary> diff --git a/MediaBrowser.Controller/IO/FileData.cs b/MediaBrowser.Controller/IO/FileData.cs index 3db60ae0bb..b8a0bf3315 100644 --- a/MediaBrowser.Controller/IO/FileData.cs +++ b/MediaBrowser.Controller/IO/FileData.cs @@ -24,7 +24,7 @@ namespace MediaBrowser.Controller.IO /// <param name="flattenFolderDepth">The flatten folder depth.</param> /// <param name="resolveShortcuts">if set to <c>true</c> [resolve shortcuts].</param> /// <returns>Dictionary{System.StringFileSystemInfo}.</returns> - /// <exception cref="ArgumentNullException">path</exception> + /// <exception cref="ArgumentNullException"><paramref name="path" /> is <c>null</c> or empty.</exception> public static FileSystemMetadata[] GetFilteredFileSystemEntries( IDirectoryService directoryService, string path, diff --git a/MediaBrowser.Controller/IResourceFileManager.cs b/MediaBrowser.Controller/IResourceFileManager.cs deleted file mode 100644 index 26f0424b7a..0000000000 --- a/MediaBrowser.Controller/IResourceFileManager.cs +++ /dev/null @@ -1,9 +0,0 @@ -#pragma warning disable CS1591 - -namespace MediaBrowser.Controller -{ - public interface IResourceFileManager - { - string GetResourcePath(string basePath, string virtualPath); - } -} diff --git a/MediaBrowser.Controller/IServerApplicationHost.cs b/MediaBrowser.Controller/IServerApplicationHost.cs index cfad17fb72..753c18bc7e 100644 --- a/MediaBrowser.Controller/IServerApplicationHost.cs +++ b/MediaBrowser.Controller/IServerApplicationHost.cs @@ -1,10 +1,10 @@ +#nullable disable + #pragma warning disable CS1591 using System; using System.Collections.Generic; using System.Net; -using System.Threading; -using System.Threading.Tasks; using MediaBrowser.Common; using MediaBrowser.Model.System; using Microsoft.AspNetCore.Http; @@ -16,10 +16,6 @@ namespace MediaBrowser.Controller /// </summary> public interface IServerApplicationHost : IApplicationHost { - event EventHandler HasUpdateAvailableChanged; - - IServiceProvider ServiceProvider { get; } - bool CoreStartupHasCompleted { get; } bool CanLaunchWebBrowser { get; } @@ -41,55 +37,56 @@ namespace MediaBrowser.Controller /// </summary> bool ListenWithHttps { get; } - /// <summary> - /// Gets a value indicating whether this instance has update available. - /// </summary> - /// <value><c>true</c> if this instance has update available; otherwise, <c>false</c>.</value> - bool HasUpdateAvailable { get; } - /// <summary> /// Gets the name of the friendly. /// </summary> /// <value>The name of the friendly.</value> string FriendlyName { get; } + /// <summary> + /// Gets the configured published server url. + /// </summary> + string PublishedServerUrl { get; } + /// <summary> /// Gets the system info. /// </summary> + /// <param name="source">The originator of the request.</param> /// <returns>SystemInfo.</returns> - Task<SystemInfo> GetSystemInfo(CancellationToken cancellationToken); + SystemInfo GetSystemInfo(IPAddress source); - Task<PublicSystemInfo> GetPublicSystemInfo(CancellationToken cancellationToken); + PublicSystemInfo GetPublicSystemInfo(IPAddress address); /// <summary> - /// Gets all the local IP addresses of this API instance. Each address is validated by sending a 'ping' request - /// to the API that should exist at the address. + /// Gets a URL specific for the request. /// </summary> - /// <param name="cancellationToken">A cancellation token that can be used to cancel the task.</param> - /// <returns>A list containing all the local IP addresses of the server.</returns> - Task<List<IPAddress>> GetLocalIpAddresses(CancellationToken cancellationToken); + /// <param name="request">The <see cref="HttpRequest"/> instance.</param> + /// <param name="port">Optional port number.</param> + /// <returns>An accessible URL.</returns> + string GetSmartApiUrl(HttpRequest request, int? port = null); /// <summary> - /// Gets a local (LAN) URL that can be used to access the API. The hostname used is the first valid configured - /// IP address that can be found via <see cref="GetLocalIpAddresses"/>. HTTPS will be preferred when available. + /// Gets a URL specific for the request. /// </summary> - /// <param name="cancellationToken">A cancellation token that can be used to cancel the task.</param> - /// <returns>The server URL.</returns> - Task<string> GetLocalApiUrl(CancellationToken cancellationToken); + /// <param name="remoteAddr">The remote <see cref="IPAddress"/> of the connection.</param> + /// <param name="port">Optional port number.</param> + /// <returns>An accessible URL.</returns> + string GetSmartApiUrl(IPAddress remoteAddr, int? port = null); /// <summary> - /// Gets a localhost URL that can be used to access the API using the loop-back IP address (127.0.0.1) - /// over HTTP (not HTTPS). + /// Gets a URL specific for the request. /// </summary> - /// <returns>The API URL.</returns> - string GetLoopbackHttpApiUrl(); + /// <param name="hostname">The hostname used in the connection.</param> + /// <param name="port">Optional port number.</param> + /// <returns>An accessible URL.</returns> + string GetSmartApiUrl(string hostname, int? port = null); /// <summary> - /// Gets a local (LAN) URL that can be used to access the API. HTTPS will be preferred when available. + /// Gets a localhost URL that can be used to access the API using the loop-back IP address. + /// over HTTP (not HTTPS). /// </summary> - /// <param name="address">The IP address to use as the hostname in the URL.</param> /// <returns>The API URL.</returns> - string GetLocalApiUrl(IPAddress address); + string GetLoopbackHttpApiUrl(); /// <summary> /// Gets a local (LAN) URL that can be used to access the API. @@ -105,7 +102,7 @@ namespace MediaBrowser.Controller /// preferring the HTTPS port, if available. /// </param> /// <returns>The API URL.</returns> - string GetLocalApiUrl(ReadOnlySpan<char> hostname, string scheme = null, int? port = null); + string GetLocalApiUrl(string hostname, string scheme = null, int? port = null); /// <summary> /// Open a URL in an external browser window. diff --git a/MediaBrowser.Controller/IServerApplicationPaths.cs b/MediaBrowser.Controller/IServerApplicationPaths.cs index be57d6bcae..1890dbb360 100644 --- a/MediaBrowser.Controller/IServerApplicationPaths.cs +++ b/MediaBrowser.Controller/IServerApplicationPaths.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using MediaBrowser.Common.Configuration; diff --git a/MediaBrowser.Controller/Library/DeleteOptions.cs b/MediaBrowser.Controller/Library/DeleteOptions.cs index b7417efcb5..408e702845 100644 --- a/MediaBrowser.Controller/Library/DeleteOptions.cs +++ b/MediaBrowser.Controller/Library/DeleteOptions.cs @@ -4,13 +4,13 @@ namespace MediaBrowser.Controller.Library { public class DeleteOptions { - public bool DeleteFileLocation { get; set; } - - public bool DeleteFromExternalProvider { get; set; } - public DeleteOptions() { DeleteFromExternalProvider = true; } + + public bool DeleteFileLocation { get; set; } + + public bool DeleteFromExternalProvider { get; set; } } } diff --git a/MediaBrowser.Controller/Library/IIntroProvider.cs b/MediaBrowser.Controller/Library/IIntroProvider.cs index d45493d404..a74d1b9f0b 100644 --- a/MediaBrowser.Controller/Library/IIntroProvider.cs +++ b/MediaBrowser.Controller/Library/IIntroProvider.cs @@ -1,3 +1,5 @@ +#nullable disable + using System.Collections.Generic; using System.Threading.Tasks; using MediaBrowser.Controller.Entities; @@ -9,6 +11,12 @@ namespace MediaBrowser.Controller.Library /// </summary> public interface IIntroProvider { + /// <summary> + /// Gets the name. + /// </summary> + /// <value>The name.</value> + string Name { get; } + /// <summary> /// Gets the intros. /// </summary> @@ -22,11 +30,5 @@ namespace MediaBrowser.Controller.Library /// </summary> /// <returns>IEnumerable{System.String}.</returns> IEnumerable<string> GetAllIntroFiles(); - - /// <summary> - /// Gets the name. - /// </summary> - /// <value>The name.</value> - string Name { get; } } } diff --git a/MediaBrowser.Controller/Library/ILibraryManager.cs b/MediaBrowser.Controller/Library/ILibraryManager.cs index 32703c2fd1..604960d8bc 100644 --- a/MediaBrowser.Controller/Library/ILibraryManager.cs +++ b/MediaBrowser.Controller/Library/ILibraryManager.cs @@ -1,9 +1,12 @@ -#pragma warning disable CS1591 +#nullable disable + +#pragma warning disable CA1002, CS1591 using System; using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; +using Emby.Naming.Common; using Jellyfin.Data.Entities; using Jellyfin.Data.Enums; using MediaBrowser.Controller.Dto; @@ -28,6 +31,29 @@ namespace MediaBrowser.Controller.Library /// </summary> public interface ILibraryManager { + /// <summary> + /// Occurs when [item added]. + /// </summary> + event EventHandler<ItemChangeEventArgs> ItemAdded; + + /// <summary> + /// Occurs when [item updated]. + /// </summary> + event EventHandler<ItemChangeEventArgs> ItemUpdated; + + /// <summary> + /// Occurs when [item removed]. + /// </summary> + event EventHandler<ItemChangeEventArgs> ItemRemoved; + + /// <summary> + /// Gets the root folder. + /// </summary> + /// <value>The root folder.</value> + AggregateFolder RootFolder { get; } + + bool IsScanRunning { get; } + /// <summary> /// Resolves the path. /// </summary> @@ -41,6 +67,12 @@ namespace MediaBrowser.Controller.Library /// <summary> /// Resolves a set of files into a list of BaseItem. /// </summary> + /// <param name="files">The list of tiles.</param> + /// <param name="directoryService">Instance of the <see cref="IDirectoryService"/> interface.</param> + /// <param name="parent">The parent folder.</param> + /// <param name="libraryOptions">The library options.</param> + /// <param name="collectionType">The collection type.</param> + /// <returns>The items resolved from the paths.</returns> IEnumerable<BaseItem> ResolvePaths( IEnumerable<FileSystemMetadata> files, IDirectoryService directoryService, @@ -48,16 +80,10 @@ namespace MediaBrowser.Controller.Library LibraryOptions libraryOptions, string collectionType = null); - /// <summary> - /// Gets the root folder. - /// </summary> - /// <value>The root folder.</value> - AggregateFolder RootFolder { get; } - /// <summary> /// Gets a Person. /// </summary> - /// <param name="name">The name.</param> + /// <param name="name">The name of the person.</param> /// <returns>Task{Person}.</returns> Person GetPerson(string name); @@ -72,7 +98,7 @@ namespace MediaBrowser.Controller.Library /// <summary> /// Gets the artist. /// </summary> - /// <param name="name">The name.</param> + /// <param name="name">The name of the artist.</param> /// <returns>Task{Artist}.</returns> MusicArtist GetArtist(string name); @@ -81,21 +107,21 @@ namespace MediaBrowser.Controller.Library /// <summary> /// Gets a Studio. /// </summary> - /// <param name="name">The name.</param> + /// <param name="name">The name of the studio.</param> /// <returns>Task{Studio}.</returns> Studio GetStudio(string name); /// <summary> /// Gets a Genre. /// </summary> - /// <param name="name">The name.</param> + /// <param name="name">The name of the genre.</param> /// <returns>Task{Genre}.</returns> Genre GetGenre(string name); /// <summary> /// Gets the genre. /// </summary> - /// <param name="name">The name.</param> + /// <param name="name">The name of the music genre.</param> /// <returns>Task{MusicGenre}.</returns> MusicGenre GetMusicGenre(string name); @@ -104,7 +130,7 @@ namespace MediaBrowser.Controller.Library /// </summary> /// <param name="value">The value.</param> /// <returns>Task{Year}.</returns> - /// <exception cref="ArgumentOutOfRangeException"></exception> + /// <exception cref="ArgumentOutOfRangeException">Throws if year is invalid.</exception> Year GetYear(int value); /// <summary> @@ -196,16 +222,26 @@ namespace MediaBrowser.Controller.Library /// <summary> /// Creates the item. /// </summary> + /// <param name="item">Item to create.</param> + /// <param name="parent">Parent of new item.</param> void CreateItem(BaseItem item, BaseItem parent); /// <summary> /// Creates the items. /// </summary> + /// <param name="items">Items to create.</param> + /// <param name="parent">Parent of new items.</param> + /// <param name="cancellationToken">CancellationToken to use for operation.</param> void CreateItems(IReadOnlyList<BaseItem> items, BaseItem parent, CancellationToken cancellationToken); /// <summary> /// Updates the item. /// </summary> + /// <param name="items">Items to update.</param> + /// <param name="parent">Parent of updated items.</param> + /// <param name="updateReason">Reason for update.</param> + /// <param name="cancellationToken">CancellationToken to use for operation.</param> + /// <returns>Returns a Task that can be awaited.</returns> Task UpdateItemsAsync(IReadOnlyList<BaseItem> items, BaseItem parent, ItemUpdateType updateReason, CancellationToken cancellationToken); /// <summary> @@ -215,6 +251,7 @@ namespace MediaBrowser.Controller.Library /// <param name="parent">The parent item.</param> /// <param name="updateReason">The update reason.</param> /// <param name="cancellationToken">The cancellation token.</param> + /// <returns>Returns a Task that can be awaited.</returns> Task UpdateItemAsync(BaseItem item, BaseItem parent, ItemUpdateType updateReason, CancellationToken cancellationToken); /// <summary> @@ -224,23 +261,6 @@ namespace MediaBrowser.Controller.Library /// <returns>BaseItem.</returns> BaseItem RetrieveItem(Guid id); - bool IsScanRunning { get; } - - /// <summary> - /// Occurs when [item added]. - /// </summary> - event EventHandler<ItemChangeEventArgs> ItemAdded; - - /// <summary> - /// Occurs when [item updated]. - /// </summary> - event EventHandler<ItemChangeEventArgs> ItemUpdated; - - /// <summary> - /// Occurs when [item removed]. - /// </summary> - event EventHandler<ItemChangeEventArgs> ItemRemoved; - /// <summary> /// Finds the type of the collection. /// </summary> @@ -285,16 +305,25 @@ namespace MediaBrowser.Controller.Library /// <summary> /// Deletes the item. /// </summary> + /// <param name="item">Item to delete.</param> + /// <param name="options">Options to use for deletion.</param> void DeleteItem(BaseItem item, DeleteOptions options); /// <summary> /// Deletes the item. /// </summary> + /// <param name="item">Item to delete.</param> + /// <param name="options">Options to use for deletion.</param> + /// <param name="notifyParentItem">Notify parent of deletion.</param> void DeleteItem(BaseItem item, DeleteOptions options, bool notifyParentItem); /// <summary> /// Deletes the item. /// </summary> + /// <param name="item">Item to delete.</param> + /// <param name="options">Options to use for deletion.</param> + /// <param name="parent">Parent of item.</param> + /// <param name="notifyParentItem">Notify parent of deletion.</param> void DeleteItem(BaseItem item, DeleteOptions options, BaseItem parent, bool notifyParentItem); /// <summary> @@ -305,6 +334,7 @@ namespace MediaBrowser.Controller.Library /// <param name="parentId">The parent identifier.</param> /// <param name="viewType">Type of the view.</param> /// <param name="sortName">Name of the sort.</param> + /// <returns>The named view.</returns> UserView GetNamedView( User user, string name, @@ -319,6 +349,7 @@ namespace MediaBrowser.Controller.Library /// <param name="name">The name.</param> /// <param name="viewType">Type of the view.</param> /// <param name="sortName">Name of the sort.</param> + /// <returns>The named view.</returns> UserView GetNamedView( User user, string name, @@ -331,6 +362,7 @@ namespace MediaBrowser.Controller.Library /// <param name="name">The name.</param> /// <param name="viewType">Type of the view.</param> /// <param name="sortName">Name of the sort.</param> + /// <returns>The named view.</returns> UserView GetNamedView( string name, string viewType, @@ -344,6 +376,7 @@ namespace MediaBrowser.Controller.Library /// <param name="viewType">Type of the view.</param> /// <param name="sortName">Name of the sort.</param> /// <param name="uniqueId">The unique identifier.</param> + /// <returns>The named view.</returns> UserView GetNamedView( string name, Guid parentId, @@ -357,10 +390,11 @@ namespace MediaBrowser.Controller.Library /// <param name="parent">The parent.</param> /// <param name="viewType">Type of the view.</param> /// <param name="sortName">Name of the sort.</param> + /// <returns>The shadow view.</returns> UserView GetShadowView( BaseItem parent, - string viewType, - string sortName); + string viewType, + string sortName); /// <summary> /// Determines whether [is video file] [the specified path]. @@ -386,6 +420,9 @@ namespace MediaBrowser.Controller.Library /// <summary> /// Fills the missing episode numbers from path. /// </summary> + /// <param name="episode">Episode to use.</param> + /// <param name="forceRefresh">Option to force refresh of episode numbers.</param> + /// <returns>True if successful.</returns> bool FillMissingEpisodeNumbersFromPath(Episode episode, bool forceRefresh); /// <summary> @@ -466,6 +503,15 @@ namespace MediaBrowser.Controller.Library /// <param name="people">The people.</param> void UpdatePeople(BaseItem item, List<PersonInfo> people); + /// <summary> + /// Asynchronously updates the people. + /// </summary> + /// <param name="item">The item.</param> + /// <param name="people">The people.</param> + /// <param name="cancellationToken">The cancellation token.</param> + /// <returns>The async task.</returns> + Task UpdatePeopleAsync(BaseItem item, List<PersonInfo> people, CancellationToken cancellationToken); + /// <summary> /// Gets the item ids. /// </summary> @@ -519,6 +565,9 @@ namespace MediaBrowser.Controller.Library /// <summary> /// Gets the items. /// </summary> + /// <param name="query">The query to use.</param> + /// <param name="parents">Items to use for query.</param> + /// <returns>List of items.</returns> List<BaseItem> GetItemList(InternalItemsQuery query, List<BaseItem> parents); /// <summary> @@ -542,7 +591,7 @@ namespace MediaBrowser.Controller.Library Guid GetMusicGenreId(string name); - Task AddVirtualFolder(string name, string collectionType, LibraryOptions options, bool refreshLibrary); + Task AddVirtualFolder(string name, CollectionTypeOptions? collectionType, LibraryOptions options, bool refreshLibrary); Task RemoveVirtualFolder(string name, bool refreshLibrary); @@ -570,5 +619,17 @@ namespace MediaBrowser.Controller.Library List<MediaStream> streams, string videoPath, string[] files); + + Task RunMetadataSavers(BaseItem item, ItemUpdateType updateReason); + + BaseItem GetParentItem(string parentId, Guid? userId); + + BaseItem GetParentItem(Guid? parentId, Guid? userId); + + /// <summary> + /// Gets or creates a static instance of <see cref="NamingOptions"/>. + /// </summary> + /// <returns>An instance of the <see cref="NamingOptions"/> class.</returns> + NamingOptions GetNamingOptions(); } } diff --git a/MediaBrowser.Controller/Library/ILiveStream.cs b/MediaBrowser.Controller/Library/ILiveStream.cs index ff25be6577..323aa48768 100644 --- a/MediaBrowser.Controller/Library/ILiveStream.cs +++ b/MediaBrowser.Controller/Library/ILiveStream.cs @@ -1,4 +1,6 @@ -#pragma warning disable CS1591 +#nullable disable + +#pragma warning disable CA1711, CS1591 using System.Threading; using System.Threading.Tasks; diff --git a/MediaBrowser.Controller/Library/IMediaSourceManager.cs b/MediaBrowser.Controller/Library/IMediaSourceManager.cs index 22bf9488f7..fd3631da9c 100644 --- a/MediaBrowser.Controller/Library/IMediaSourceManager.cs +++ b/MediaBrowser.Controller/Library/IMediaSourceManager.cs @@ -1,4 +1,6 @@ -#pragma warning disable CS1591 +#nullable disable + +#pragma warning disable CA1002, CS1591 using System; using System.Collections.Generic; @@ -60,16 +62,32 @@ namespace MediaBrowser.Controller.Library /// <summary> /// Gets the playack media sources. /// </summary> + /// <param name="item">Item to use.</param> + /// <param name="user">User to use for operation.</param> + /// <param name="allowMediaProbe">Option to allow media probe.</param> + /// <param name="enablePathSubstitution">Option to enable path substitution.</param> + /// <param name="cancellationToken">CancellationToken to use for operation.</param> + /// <returns>List of media sources wrapped in an awaitable task.</returns> Task<List<MediaSourceInfo>> GetPlaybackMediaSources(BaseItem item, User user, bool allowMediaProbe, bool enablePathSubstitution, CancellationToken cancellationToken); /// <summary> /// Gets the static media sources. /// </summary> + /// <param name="item">Item to use.</param> + /// <param name="enablePathSubstitution">Option to enable path substitution.</param> + /// <param name="user">User to use for operation.</param> + /// <returns>List of media sources.</returns> List<MediaSourceInfo> GetStaticMediaSources(BaseItem item, bool enablePathSubstitution, User user = null); /// <summary> /// Gets the static media source. /// </summary> + /// <param name="item">Item to use.</param> + /// <param name="mediaSourceId">Media source to get.</param> + /// <param name="liveStreamId">Live stream to use.</param> + /// <param name="enablePathSubstitution">Option to enable path substitution.</param> + /// <param name="cancellationToken">CancellationToken to use for operation.</param> + /// <returns>The static media source wrapped in an awaitable task.</returns> Task<MediaSourceInfo> GetMediaSource(BaseItem item, string mediaSourceId, string liveStreamId, bool enablePathSubstitution, CancellationToken cancellationToken); /// <summary> @@ -115,5 +133,7 @@ namespace MediaBrowser.Controller.Library public interface IDirectStreamProvider { Task CopyToAsync(Stream stream, CancellationToken cancellationToken); + + string GetFilePath(); } } diff --git a/MediaBrowser.Controller/Library/IMediaSourceProvider.cs b/MediaBrowser.Controller/Library/IMediaSourceProvider.cs index 5bf4acebb4..ca4b53fbee 100644 --- a/MediaBrowser.Controller/Library/IMediaSourceProvider.cs +++ b/MediaBrowser.Controller/Library/IMediaSourceProvider.cs @@ -1,4 +1,4 @@ -#pragma warning disable CS1591 +#pragma warning disable CA1002, CS1591 using System.Collections.Generic; using System.Threading; @@ -21,6 +21,10 @@ namespace MediaBrowser.Controller.Library /// <summary> /// Opens the media source. /// </summary> + /// <param name="openToken">Token to use.</param> + /// <param name="currentLiveStreams">List of live streams.</param> + /// <param name="cancellationToken">CancellationToken to use for operation.</param> + /// <returns>The media source wrapped as an awaitable task.</returns> Task<ILiveStream> OpenMediaSource(string openToken, List<ILiveStream> currentLiveStreams, CancellationToken cancellationToken); } } diff --git a/MediaBrowser.Controller/Library/IMetadataSaver.cs b/MediaBrowser.Controller/Library/IMetadataSaver.cs index 027cc5b40e..d963fd2491 100644 --- a/MediaBrowser.Controller/Library/IMetadataSaver.cs +++ b/MediaBrowser.Controller/Library/IMetadataSaver.cs @@ -1,3 +1,5 @@ +#nullable disable + using System.Threading; using MediaBrowser.Controller.Entities; @@ -27,7 +29,6 @@ namespace MediaBrowser.Controller.Library /// </summary> /// <param name="item">The item.</param> /// <param name="cancellationToken">The cancellation token.</param> - /// <returns>Task.</returns> void Save(BaseItem item, CancellationToken cancellationToken); } } diff --git a/MediaBrowser.Controller/Library/IMusicManager.cs b/MediaBrowser.Controller/Library/IMusicManager.cs index d12f008e77..ec34a868b3 100644 --- a/MediaBrowser.Controller/Library/IMusicManager.cs +++ b/MediaBrowser.Controller/Library/IMusicManager.cs @@ -1,4 +1,6 @@ -#pragma warning disable CS1591 +#nullable disable + +#pragma warning disable CA1002, CS1591 using System.Collections.Generic; using Jellyfin.Data.Entities; @@ -13,16 +15,28 @@ namespace MediaBrowser.Controller.Library /// <summary> /// Gets the instant mix from song. /// </summary> + /// <param name="item">The item to use.</param> + /// <param name="user">The user to use.</param> + /// <param name="dtoOptions">The options to use.</param> + /// <returns>List of items.</returns> List<BaseItem> GetInstantMixFromItem(BaseItem item, User user, DtoOptions dtoOptions); /// <summary> /// Gets the instant mix from artist. /// </summary> + /// <param name="artist">The artist to use.</param> + /// <param name="user">The user to use.</param> + /// <param name="dtoOptions">The options to use.</param> + /// <returns>List of items.</returns> List<BaseItem> GetInstantMixFromArtist(MusicArtist artist, User user, DtoOptions dtoOptions); /// <summary> /// Gets the instant mix from genre. /// </summary> + /// <param name="genres">The genres to use.</param> + /// <param name="user">The user to use.</param> + /// <param name="dtoOptions">The options to use.</param> + /// <returns>List of items.</returns> List<BaseItem> GetInstantMixFromGenres(IEnumerable<string> genres, User user, DtoOptions dtoOptions); } } diff --git a/MediaBrowser.Controller/Library/IUserDataManager.cs b/MediaBrowser.Controller/Library/IUserDataManager.cs index c6a83e4dc4..cf35b48dba 100644 --- a/MediaBrowser.Controller/Library/IUserDataManager.cs +++ b/MediaBrowser.Controller/Library/IUserDataManager.cs @@ -1,4 +1,6 @@ -#pragma warning disable CS1591 +#nullable disable + +#pragma warning disable CA1002, CA1707, CS1591 using System; using System.Collections.Generic; @@ -40,6 +42,9 @@ namespace MediaBrowser.Controller.Library /// <summary> /// Gets the user data dto. /// </summary> + /// <param name="item">Item to use.</param> + /// <param name="user">User to use.</param> + /// <returns>User data dto.</returns> UserItemDataDto GetUserDataDto(BaseItem item, User user); UserItemDataDto GetUserDataDto(BaseItem item, BaseItemDto itemDto, User user, DtoOptions dto_options); @@ -47,22 +52,25 @@ namespace MediaBrowser.Controller.Library /// <summary> /// Get all user data for the given user. /// </summary> - /// <param name="userId"></param> - /// <returns></returns> + /// <param name="userId">The user id.</param> + /// <returns>The user item data.</returns> List<UserItemData> GetAllUserData(Guid userId); /// <summary> /// Save the all provided user data for the given user. /// </summary> - /// <param name="userId"></param> - /// <param name="userData"></param> - /// <param name="cancellationToken"></param> - /// <returns></returns> + /// <param name="userId">The user id.</param> + /// <param name="userData">The array of user data.</param> + /// <param name="cancellationToken">The cancellation token.</param> void SaveAllUserData(Guid userId, UserItemData[] userData, CancellationToken cancellationToken); /// <summary> /// Updates playstate for an item and returns true or false indicating if it was played to completion. /// </summary> + /// <param name="item">Item to update.</param> + /// <param name="data">Data to update.</param> + /// <param name="positionTicks">New playstate.</param> + /// <returns>True if playstate was updated.</returns> bool UpdatePlayState(BaseItem item, UserItemData data, long? positionTicks); } } diff --git a/MediaBrowser.Controller/Library/IUserManager.cs b/MediaBrowser.Controller/Library/IUserManager.cs index 6a4f5cf679..21776f8915 100644 --- a/MediaBrowser.Controller/Library/IUserManager.cs +++ b/MediaBrowser.Controller/Library/IUserManager.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System; @@ -36,6 +38,7 @@ namespace MediaBrowser.Controller.Library /// <summary> /// Initializes the user manager and ensures that a user exists. /// </summary> + /// <returns>Awaitable task.</returns> Task InitializeAsync(); /// <summary> @@ -59,16 +62,16 @@ namespace MediaBrowser.Controller.Library /// <param name="user">The user.</param> /// <param name="newName">The new name.</param> /// <returns>Task.</returns> - /// <exception cref="ArgumentNullException">user</exception> - /// <exception cref="ArgumentException"></exception> + /// <exception cref="ArgumentNullException">If user is <c>null</c>.</exception> + /// <exception cref="ArgumentException">If the provided user doesn't exist.</exception> Task RenameUser(User user, string newName); /// <summary> /// Updates the user. /// </summary> /// <param name="user">The user.</param> - /// <exception cref="ArgumentNullException">user</exception> - /// <exception cref="ArgumentException"></exception> + /// <exception cref="ArgumentNullException">If user is <c>null</c>.</exception> + /// <exception cref="ArgumentException">If the provided user doesn't exist.</exception> void UpdateUser(User user); /// <summary> @@ -85,15 +88,16 @@ namespace MediaBrowser.Controller.Library /// </summary> /// <param name="name">The name of the new user.</param> /// <returns>The created user.</returns> - /// <exception cref="ArgumentNullException">name</exception> - /// <exception cref="ArgumentException"></exception> + /// <exception cref="ArgumentNullException"><paramref name="name"/> is <c>null</c> or empty.</exception> + /// <exception cref="ArgumentException"><paramref name="name"/> already exists.</exception> Task<User> CreateUserAsync(string name); /// <summary> /// Deletes the specified user. /// </summary> /// <param name="userId">The id of the user to be deleted.</param> - void DeleteUser(Guid userId); + /// <returns>A task representing the deletion of the user.</returns> + Task DeleteUserAsync(Guid userId); /// <summary> /// Resets the password. @@ -106,17 +110,22 @@ namespace MediaBrowser.Controller.Library /// Resets the easy password. /// </summary> /// <param name="user">The user.</param> - /// <returns>Task.</returns> void ResetEasyPassword(User user); /// <summary> /// Changes the password. /// </summary> + /// <param name="user">The user.</param> + /// <param name="newPassword">New password to use.</param> + /// <returns>Awaitable task.</returns> Task ChangePassword(User user, string newPassword); /// <summary> /// Changes the easy password. /// </summary> + /// <param name="user">The user.</param> + /// <param name="newPassword">New password to use.</param> + /// <param name="newPasswordSha1">Hash of new password.</param> void ChangeEasyPassword(User user, string newPassword, string newPasswordSha1); /// <summary> @@ -130,6 +139,12 @@ namespace MediaBrowser.Controller.Library /// <summary> /// Authenticates the user. /// </summary> + /// <param name="username">The user.</param> + /// <param name="password">The password to use.</param> + /// <param name="passwordSha1">Hash of password.</param> + /// <param name="remoteEndPoint">Remove endpoint to use.</param> + /// <param name="isUserSession">Specifies if a user session.</param> + /// <returns>User wrapped in awaitable task.</returns> Task<User> AuthenticateUser(string username, string password, string passwordSha1, string remoteEndPoint, bool isUserSession); /// <summary> @@ -158,7 +173,8 @@ namespace MediaBrowser.Controller.Library /// </summary> /// <param name="userId">The user's Id.</param> /// <param name="config">The request containing the new user configuration.</param> - void UpdateConfiguration(Guid userId, UserConfiguration config); + /// <returns>A task representing the update.</returns> + Task UpdateConfigurationAsync(Guid userId, UserConfiguration config); /// <summary> /// This method updates the user's policy. @@ -167,12 +183,14 @@ namespace MediaBrowser.Controller.Library /// </summary> /// <param name="userId">The user's Id.</param> /// <param name="policy">The request containing the new user policy.</param> - void UpdatePolicy(Guid userId, UserPolicy policy); + /// <returns>A task representing the update.</returns> + Task UpdatePolicyAsync(Guid userId, UserPolicy policy); /// <summary> /// Clears the user's profile image. /// </summary> /// <param name="user">The user.</param> - void ClearProfileImage(User user); + /// <returns>A task representing the clearing of the profile image.</returns> + Task ClearProfileImageAsync(User user); } } diff --git a/MediaBrowser.Controller/Library/IUserViewManager.cs b/MediaBrowser.Controller/Library/IUserViewManager.cs index 8d541e8b68..055627d3e3 100644 --- a/MediaBrowser.Controller/Library/IUserViewManager.cs +++ b/MediaBrowser.Controller/Library/IUserViewManager.cs @@ -1,4 +1,6 @@ -#pragma warning disable CS1591 +#nullable disable + +#pragma warning disable CA1002, CS1591 using System; using System.Collections.Generic; @@ -11,10 +13,29 @@ namespace MediaBrowser.Controller.Library { public interface IUserViewManager { + /// <summary> + /// Gets user views. + /// </summary> + /// <param name="query">Query to use.</param> + /// <returns>Set of folders.</returns> Folder[] GetUserViews(UserViewQuery query); + /// <summary> + /// Gets user sub views. + /// </summary> + /// <param name="parentId">Parent to use.</param> + /// <param name="type">Type to use.</param> + /// <param name="localizationKey">Localization key to use.</param> + /// <param name="sortName">Sort to use.</param> + /// <returns>User view.</returns> UserView GetUserSubView(Guid parentId, string type, string localizationKey, string sortName); + /// <summary> + /// Gets latest items. + /// </summary> + /// <param name="request">Query to use.</param> + /// <param name="options">Options to use.</param> + /// <returns>Set of items.</returns> List<Tuple<BaseItem, List<BaseItem>>> GetLatestItems(LatestItemsQuery request, DtoOptions options); } } diff --git a/MediaBrowser.Controller/Library/IntroInfo.cs b/MediaBrowser.Controller/Library/IntroInfo.cs index 283cc631ca..90786786b2 100644 --- a/MediaBrowser.Controller/Library/IntroInfo.cs +++ b/MediaBrowser.Controller/Library/IntroInfo.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System; diff --git a/MediaBrowser.Controller/Library/ItemChangeEventArgs.cs b/MediaBrowser.Controller/Library/ItemChangeEventArgs.cs index 1798a4fada..3586dc69d6 100644 --- a/MediaBrowser.Controller/Library/ItemChangeEventArgs.cs +++ b/MediaBrowser.Controller/Library/ItemChangeEventArgs.cs @@ -1,4 +1,6 @@ -#pragma warning disable CS1591 +#nullable disable + +#pragma warning disable CA1711, CS1591 using MediaBrowser.Controller.Entities; diff --git a/MediaBrowser.Controller/Library/ItemResolveArgs.cs b/MediaBrowser.Controller/Library/ItemResolveArgs.cs index 12a311dc33..bfc1e4857f 100644 --- a/MediaBrowser.Controller/Library/ItemResolveArgs.cs +++ b/MediaBrowser.Controller/Library/ItemResolveArgs.cs @@ -1,4 +1,6 @@ -#pragma warning disable CS1591 +#nullable disable + +#pragma warning disable CA1721, CA1819, CS1591 using System; using System.Collections.Generic; @@ -14,14 +16,14 @@ namespace MediaBrowser.Controller.Library /// These are arguments relating to the file system that are collected once and then referred to /// whenever needed. Primarily for entity resolution. /// </summary> - public class ItemResolveArgs : EventArgs + public class ItemResolveArgs { /// <summary> /// The _app paths. /// </summary> private readonly IServerApplicationPaths _appPaths; - public IDirectoryService DirectoryService { get; private set; } + private LibraryOptions _libraryOptions; /// <summary> /// Initializes a new instance of the <see cref="ItemResolveArgs" /> class. @@ -34,17 +36,18 @@ namespace MediaBrowser.Controller.Library DirectoryService = directoryService; } + public IDirectoryService DirectoryService { get; } + /// <summary> - /// Gets the file system children. + /// Gets or sets the file system children. /// </summary> /// <value>The file system children.</value> public FileSystemMetadata[] FileSystemChildren { get; set; } - public LibraryOptions LibraryOptions { get; set; } - - public LibraryOptions GetLibraryOptions() + public LibraryOptions LibraryOptions { - return LibraryOptions ?? (LibraryOptions = Parent == null ? new LibraryOptions() : BaseItem.LibraryManager.GetLibraryOptions(Parent)); + get => _libraryOptions ??= Parent == null ? new LibraryOptions() : BaseItem.LibraryManager.GetLibraryOptions(Parent); + set => _libraryOptions = value; } /// <summary> @@ -60,10 +63,10 @@ namespace MediaBrowser.Controller.Library public FileSystemMetadata FileInfo { get; set; } /// <summary> - /// Gets or sets the path. + /// Gets the path. /// </summary> /// <value>The path.</value> - public string Path { get; set; } + public string Path => FileInfo.FullName; /// <summary> /// Gets a value indicating whether this instance is directory. @@ -106,6 +109,21 @@ namespace MediaBrowser.Controller.Library /// <value>The additional locations.</value> private List<string> AdditionalLocations { get; set; } + /// <summary> + /// Gets the physical locations. + /// </summary> + /// <value>The physical locations.</value> + public string[] PhysicalLocations + { + get + { + var paths = string.IsNullOrEmpty(Path) ? Array.Empty<string>() : new[] { Path }; + return AdditionalLocations == null ? paths : paths.Concat(AdditionalLocations).ToArray(); + } + } + + public string CollectionType { get; set; } + public bool HasParent<T>() where T : Folder { @@ -135,11 +153,21 @@ namespace MediaBrowser.Controller.Library return false; } + /// <summary> + /// Determines whether the specified <see cref="object" /> is equal to this instance. + /// </summary> + /// <param name="obj">The object to compare with the current object.</param> + /// <returns><c>true</c> if the specified <see cref="object" /> is equal to this instance; otherwise, <c>false</c>.</returns> + public override bool Equals(object obj) + { + return Equals(obj as ItemResolveArgs); + } + /// <summary> /// Adds the additional location. /// </summary> /// <param name="path">The path.</param> - /// <exception cref="ArgumentNullException"></exception> + /// <exception cref="ArgumentNullException"><paramref name="path"/> is <c>null</c> or empty.</exception> public void AddAdditionalLocation(string path) { if (string.IsNullOrEmpty(path)) @@ -147,35 +175,18 @@ namespace MediaBrowser.Controller.Library throw new ArgumentException("The path was empty or null.", nameof(path)); } - if (AdditionalLocations == null) - { - AdditionalLocations = new List<string>(); - } - + AdditionalLocations ??= new List<string>(); AdditionalLocations.Add(path); } // REVIEW: @bond - /// <summary> - /// Gets the physical locations. - /// </summary> - /// <value>The physical locations.</value> - public string[] PhysicalLocations - { - get - { - var paths = string.IsNullOrEmpty(Path) ? Array.Empty<string>() : new[] { Path }; - return AdditionalLocations == null ? paths : paths.Concat(AdditionalLocations).ToArray(); - } - } - /// <summary> /// Gets the name of the file system entry by. /// </summary> /// <param name="name">The name.</param> /// <returns>FileSystemInfo.</returns> - /// <exception cref="ArgumentNullException"></exception> + /// <exception cref="ArgumentNullException"><paramref name="name"/> is <c>null</c> or empty.</exception> public FileSystemMetadata GetFileSystemEntryByName(string name) { if (string.IsNullOrEmpty(name)) @@ -191,7 +202,7 @@ namespace MediaBrowser.Controller.Library /// </summary> /// <param name="path">The path.</param> /// <returns>FileSystemInfo.</returns> - /// <exception cref="ArgumentNullException"></exception> + /// <exception cref="ArgumentNullException">Throws if path is invalid.</exception> public FileSystemMetadata GetFileSystemEntryByPath(string path) { if (string.IsNullOrEmpty(path)) @@ -225,32 +236,20 @@ namespace MediaBrowser.Controller.Library return CollectionType; } - public string CollectionType { get; set; } - - /// <summary> - /// Determines whether the specified <see cref="object" /> is equal to this instance. - /// </summary> - /// <param name="obj">The object to compare with the current object.</param> - /// <returns><c>true</c> if the specified <see cref="object" /> is equal to this instance; otherwise, <c>false</c>.</returns> - public override bool Equals(object obj) - { - return Equals(obj as ItemResolveArgs); - } - /// <summary> /// Returns a hash code for this instance. /// </summary> /// <returns>A hash code for this instance, suitable for use in hashing algorithms and data structures like a hash table.</returns> public override int GetHashCode() { - return Path.GetHashCode(); + return Path.GetHashCode(StringComparison.Ordinal); } /// <summary> /// Equals the specified args. /// </summary> /// <param name="args">The args.</param> - /// <returns><c>true</c> if XXXX, <c>false</c> otherwise</returns> + /// <returns><c>true</c> if the arguments are the same, <c>false</c> otherwise.</returns> protected bool Equals(ItemResolveArgs args) { if (args != null) diff --git a/MediaBrowser.Controller/Library/LibraryManagerExtensions.cs b/MediaBrowser.Controller/Library/LibraryManagerExtensions.cs index 9581603f04..7bc8fa5abd 100644 --- a/MediaBrowser.Controller/Library/LibraryManagerExtensions.cs +++ b/MediaBrowser.Controller/Library/LibraryManagerExtensions.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System; diff --git a/MediaBrowser.Controller/Library/MetadataConfigurationExtensions.cs b/MediaBrowser.Controller/Library/MetadataConfigurationExtensions.cs new file mode 100644 index 0000000000..41cfcae163 --- /dev/null +++ b/MediaBrowser.Controller/Library/MetadataConfigurationExtensions.cs @@ -0,0 +1,17 @@ +#nullable disable + +#pragma warning disable CS1591 + +using MediaBrowser.Common.Configuration; +using MediaBrowser.Model.Configuration; + +namespace MediaBrowser.Controller.Library +{ + public static class MetadataConfigurationExtensions + { + public static MetadataConfiguration GetMetadataConfiguration(this IConfigurationManager config) + { + return config.GetConfiguration<MetadataConfiguration>("metadata"); + } + } +} diff --git a/MediaBrowser.Controller/Library/MetadataConfigurationStore.cs b/MediaBrowser.Controller/Library/MetadataConfigurationStore.cs index f16304db01..a6be6c0d3c 100644 --- a/MediaBrowser.Controller/Library/MetadataConfigurationStore.cs +++ b/MediaBrowser.Controller/Library/MetadataConfigurationStore.cs @@ -14,18 +14,10 @@ namespace MediaBrowser.Controller.Library { new ConfigurationStore { - Key = "metadata", - ConfigurationType = typeof(MetadataConfiguration) + Key = "metadata", + ConfigurationType = typeof(MetadataConfiguration) } }; } } - - public static class MetadataConfigurationExtensions - { - public static MetadataConfiguration GetMetadataConfiguration(this IConfigurationManager config) - { - return config.GetConfiguration<MetadataConfiguration>("metadata"); - } - } } diff --git a/MediaBrowser.Controller/Library/NameExtensions.cs b/MediaBrowser.Controller/Library/NameExtensions.cs index 1c90bb4e02..a49dcacc1d 100644 --- a/MediaBrowser.Controller/Library/NameExtensions.cs +++ b/MediaBrowser.Controller/Library/NameExtensions.cs @@ -1,15 +1,19 @@ -#nullable enable #pragma warning disable CS1591 using System; using System.Collections.Generic; using System.Linq; +using Diacritics.Extensions; using MediaBrowser.Controller.Extensions; namespace MediaBrowser.Controller.Library { public static class NameExtensions { + public static IEnumerable<string> DistinctNames(this IEnumerable<string> names) + => names.GroupBy(RemoveDiacritics, StringComparer.OrdinalIgnoreCase) + .Select(x => x.First()); + private static string RemoveDiacritics(string? name) { if (name == null) @@ -19,9 +23,5 @@ namespace MediaBrowser.Controller.Library return name.RemoveDiacritics(); } - - public static IEnumerable<string> DistinctNames(this IEnumerable<string> names) - => names.GroupBy(RemoveDiacritics, StringComparer.OrdinalIgnoreCase) - .Select(x => x.First()); } } diff --git a/MediaBrowser.Controller/Library/PlaybackProgressEventArgs.cs b/MediaBrowser.Controller/Library/PlaybackProgressEventArgs.cs index a2be3a42ab..76e9eb1f54 100644 --- a/MediaBrowser.Controller/Library/PlaybackProgressEventArgs.cs +++ b/MediaBrowser.Controller/Library/PlaybackProgressEventArgs.cs @@ -1,4 +1,6 @@ -#pragma warning disable CS1591 +#nullable disable + +#pragma warning disable CA1002, CA2227, CS1591 using System; using System.Collections.Generic; diff --git a/MediaBrowser.Controller/Library/PlaybackStartEventArgs.cs b/MediaBrowser.Controller/Library/PlaybackStartEventArgs.cs index ac372bceba..2138fef580 100644 --- a/MediaBrowser.Controller/Library/PlaybackStartEventArgs.cs +++ b/MediaBrowser.Controller/Library/PlaybackStartEventArgs.cs @@ -1,4 +1,4 @@ -namespace MediaBrowser.Controller.Library +namespace MediaBrowser.Controller.Library { /// <summary> /// An event that occurs when playback is started. diff --git a/MediaBrowser.Controller/Library/Profiler.cs b/MediaBrowser.Controller/Library/Profiler.cs deleted file mode 100644 index 5efdc6a481..0000000000 --- a/MediaBrowser.Controller/Library/Profiler.cs +++ /dev/null @@ -1,83 +0,0 @@ -using System; -using System.Diagnostics; -using System.Globalization; -using Microsoft.Extensions.Logging; - -namespace MediaBrowser.Controller.Library -{ - /// <summary> - /// Class Profiler. - /// </summary> - public class Profiler : IDisposable - { - /// <summary> - /// The name. - /// </summary> - readonly string _name; - - /// <summary> - /// The stopwatch. - /// </summary> - readonly Stopwatch _stopwatch; - - /// <summary> - /// The _logger. - /// </summary> - private readonly ILogger<Profiler> _logger; - - /// <summary> - /// Initializes a new instance of the <see cref="Profiler" /> class. - /// </summary> - /// <param name="name">The name.</param> - /// <param name="logger">The logger.</param> - public Profiler(string name, ILogger<Profiler> logger) - { - this._name = name; - - _logger = logger; - - _stopwatch = new Stopwatch(); - _stopwatch.Start(); - } - - /// <summary> - /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources. - /// </summary> - public void Dispose() - { - Dispose(true); - GC.SuppressFinalize(this); - } - - /// <summary> - /// Releases unmanaged and - optionally - managed resources. - /// </summary> - /// <param name="dispose"><c>true</c> to release both managed and unmanaged resources; <c>false</c> to release only unmanaged resources.</param> - protected virtual void Dispose(bool dispose) - { - if (dispose) - { - _stopwatch.Stop(); - string message; - if (_stopwatch.ElapsedMilliseconds > 300000) - { - message = string.Format( - CultureInfo.InvariantCulture, - "{0} took {1} minutes.", - _name, - ((float)_stopwatch.ElapsedMilliseconds / 60000).ToString("F", CultureInfo.InvariantCulture)); - } - else - { - message = string.Format( - CultureInfo.InvariantCulture, - "{0} took {1} seconds.", - _name, - ((float)_stopwatch.ElapsedMilliseconds / 1000).ToString("#0.000", CultureInfo.InvariantCulture)); - } - - _logger.LogInformation(message); - } - } - } -} diff --git a/MediaBrowser.Controller/Library/SearchHintInfo.cs b/MediaBrowser.Controller/Library/SearchHintInfo.cs index 897c2b7f49..de7806adc3 100644 --- a/MediaBrowser.Controller/Library/SearchHintInfo.cs +++ b/MediaBrowser.Controller/Library/SearchHintInfo.cs @@ -1,3 +1,5 @@ +#nullable disable + using MediaBrowser.Controller.Entities; namespace MediaBrowser.Controller.Library diff --git a/MediaBrowser.Controller/Library/TVUtils.cs b/MediaBrowser.Controller/Library/TVUtils.cs index a3aa6019ec..968338dc6a 100644 --- a/MediaBrowser.Controller/Library/TVUtils.cs +++ b/MediaBrowser.Controller/Library/TVUtils.cs @@ -1,4 +1,5 @@ using System; +using System.Diagnostics.CodeAnalysis; namespace MediaBrowser.Controller.Library { @@ -12,7 +13,8 @@ namespace MediaBrowser.Controller.Library /// </summary> /// <param name="day">The day.</param> /// <returns>List{DayOfWeek}.</returns> - public static DayOfWeek[] GetAirDays(string day) + [return: NotNullIfNotNull("day")] + public static DayOfWeek[]? GetAirDays(string? day) { if (!string.IsNullOrEmpty(day)) { diff --git a/MediaBrowser.Controller/Library/UserDataSaveEventArgs.cs b/MediaBrowser.Controller/Library/UserDataSaveEventArgs.cs index cd91097535..4d90346f29 100644 --- a/MediaBrowser.Controller/Library/UserDataSaveEventArgs.cs +++ b/MediaBrowser.Controller/Library/UserDataSaveEventArgs.cs @@ -1,4 +1,6 @@ -#pragma warning disable CS1591 +#nullable disable + +#pragma warning disable CA1002, CA2227, CS1591 using System; using System.Collections.Generic; diff --git a/MediaBrowser.Controller/LiveTv/ActiveRecordingInfo.cs b/MediaBrowser.Controller/LiveTv/ActiveRecordingInfo.cs new file mode 100644 index 0000000000..463061e686 --- /dev/null +++ b/MediaBrowser.Controller/LiveTv/ActiveRecordingInfo.cs @@ -0,0 +1,19 @@ +#nullable disable + +#pragma warning disable CS1591 + +using System.Threading; + +namespace MediaBrowser.Controller.LiveTv +{ + public class ActiveRecordingInfo + { + public string Id { get; set; } + + public string Path { get; set; } + + public TimerInfo Timer { get; set; } + + public CancellationTokenSource CancellationTokenSource { get; set; } + } +} \ No newline at end of file diff --git a/MediaBrowser.Controller/LiveTv/ChannelInfo.cs b/MediaBrowser.Controller/LiveTv/ChannelInfo.cs index 44bd38b54f..699c15f934 100644 --- a/MediaBrowser.Controller/LiveTv/ChannelInfo.cs +++ b/MediaBrowser.Controller/LiveTv/ChannelInfo.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using MediaBrowser.Model.LiveTv; @@ -22,7 +24,7 @@ namespace MediaBrowser.Controller.LiveTv public string Number { get; set; } /// <summary> - /// Get or sets the Id. + /// Gets or sets the Id. /// </summary> /// <value>The id of the channel.</value> public string Id { get; set; } @@ -46,13 +48,19 @@ namespace MediaBrowser.Controller.LiveTv public ChannelType ChannelType { get; set; } /// <summary> - /// Supply the image path if it can be accessed directly from the file system. + /// Gets or sets the group of the channel. + /// </summary> + /// <value>The group of the channel.</value> + public string ChannelGroup { get; set; } + + /// <summary> + /// Gets or sets the the image path if it can be accessed directly from the file system. /// </summary> /// <value>The image path.</value> public string ImagePath { get; set; } /// <summary> - /// Supply the image url if it can be downloaded. + /// Gets or sets the image url if it can be downloaded. /// </summary> /// <value>The image URL.</value> public string ImageUrl { get; set; } diff --git a/MediaBrowser.Controller/LiveTv/IListingsProvider.cs b/MediaBrowser.Controller/LiveTv/IListingsProvider.cs index 038ff2eaeb..2bd4b20e8c 100644 --- a/MediaBrowser.Controller/LiveTv/IListingsProvider.cs +++ b/MediaBrowser.Controller/LiveTv/IListingsProvider.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System; diff --git a/MediaBrowser.Controller/LiveTv/ILiveTvManager.cs b/MediaBrowser.Controller/LiveTv/ILiveTvManager.cs index 6c365caa46..bd097c90ac 100644 --- a/MediaBrowser.Controller/LiveTv/ILiveTvManager.cs +++ b/MediaBrowser.Controller/LiveTv/ILiveTvManager.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System; @@ -20,12 +22,22 @@ namespace MediaBrowser.Controller.LiveTv /// </summary> public interface ILiveTvManager { + event EventHandler<GenericEventArgs<TimerEventInfo>> SeriesTimerCancelled; + + event EventHandler<GenericEventArgs<TimerEventInfo>> TimerCancelled; + + event EventHandler<GenericEventArgs<TimerEventInfo>> TimerCreated; + + event EventHandler<GenericEventArgs<TimerEventInfo>> SeriesTimerCreated; + /// <summary> /// Gets the services. /// </summary> /// <value>The services.</value> IReadOnlyList<ILiveTvService> Services { get; } + IListingsProvider[] ListingProviders { get; } + /// <summary> /// Gets the new timer defaults asynchronous. /// </summary> @@ -84,6 +96,7 @@ namespace MediaBrowser.Controller.LiveTv /// </summary> /// <param name="query">The query.</param> /// <param name="options">The options.</param> + /// <returns>A recording.</returns> QueryResult<BaseItemDto> GetRecordings(RecordingQuery query, DtoOptions options); /// <summary> @@ -174,11 +187,16 @@ namespace MediaBrowser.Controller.LiveTv /// <param name="query">The query.</param> /// <param name="options">The options.</param> /// <param name="cancellationToken">The cancellation token.</param> + /// <returns>Recommended programs.</returns> QueryResult<BaseItemDto> GetRecommendedPrograms(InternalItemsQuery query, DtoOptions options, CancellationToken cancellationToken); /// <summary> /// Gets the recommended programs internal. /// </summary> + /// <param name="query">The query.</param> + /// <param name="options">The options.</param> + /// <param name="cancellationToken">The cancellation token.</param> + /// <returns>Recommended programs.</returns> QueryResult<BaseItem> GetRecommendedProgramsInternal(InternalItemsQuery query, DtoOptions options, CancellationToken cancellationToken); /// <summary> @@ -200,6 +218,7 @@ namespace MediaBrowser.Controller.LiveTv /// Gets the live tv folder. /// </summary> /// <param name="cancellationToken">The cancellation token.</param> + /// <returns>Live TV folder.</returns> Folder GetInternalLiveTvFolder(CancellationToken cancellationToken); /// <summary> @@ -211,11 +230,18 @@ namespace MediaBrowser.Controller.LiveTv /// <summary> /// Gets the internal channels. /// </summary> + /// <param name="query">The query.</param> + /// <param name="dtoOptions">The options.</param> + /// <param name="cancellationToken">The cancellation token.</param> + /// <returns>Internal channels.</returns> QueryResult<BaseItem> GetInternalChannels(LiveTvChannelQuery query, DtoOptions dtoOptions, CancellationToken cancellationToken); /// <summary> /// Gets the channel media sources. /// </summary> + /// <param name="item">Item to search for.</param> + /// <param name="cancellationToken">CancellationToken to use for operation.</param> + /// <returns>Channel media sources wrapped in a task.</returns> Task<IEnumerable<MediaSourceInfo>> GetChannelMediaSources(BaseItem item, CancellationToken cancellationToken); /// <summary> @@ -225,11 +251,14 @@ namespace MediaBrowser.Controller.LiveTv /// <param name="fields">The fields.</param> /// <param name="user">The user.</param> /// <returns>Task.</returns> - Task AddInfoToProgramDto(IReadOnlyCollection<(BaseItem, BaseItemDto)> programs, ItemFields[] fields, User user = null); + Task AddInfoToProgramDto(IReadOnlyCollection<(BaseItem, BaseItemDto)> programs, IReadOnlyList<ItemFields> fields, User user = null); /// <summary> /// Saves the tuner host. /// </summary> + /// <param name="info">Turner host to save.</param> + /// <param name="dataSourceChanged">Option to specify that data source has changed.</param> + /// <returns>Tuner host information wrapped in a task.</returns> Task<TunerHostInfo> SaveTunerHost(TunerHostInfo info, bool dataSourceChanged = true); /// <summary> @@ -266,17 +295,12 @@ namespace MediaBrowser.Controller.LiveTv void AddChannelInfo(IReadOnlyCollection<(BaseItemDto, LiveTvChannel)> items, DtoOptions options, User user); Task<List<ChannelInfo>> GetChannelsForListingsProvider(string id, CancellationToken cancellationToken); - Task<List<ChannelInfo>> GetChannelsFromListingsProviderData(string id, CancellationToken cancellationToken); - IListingsProvider[] ListingProviders { get; } + Task<List<ChannelInfo>> GetChannelsFromListingsProviderData(string id, CancellationToken cancellationToken); List<NameIdPair> GetTunerHostTypes(); - Task<List<TunerHostInfo>> DiscoverTuners(bool newDevicesOnly, CancellationToken cancellationToken); - event EventHandler<GenericEventArgs<TimerEventInfo>> SeriesTimerCancelled; - event EventHandler<GenericEventArgs<TimerEventInfo>> TimerCancelled; - event EventHandler<GenericEventArgs<TimerEventInfo>> TimerCreated; - event EventHandler<GenericEventArgs<TimerEventInfo>> SeriesTimerCreated; + Task<List<TunerHostInfo>> DiscoverTuners(bool newDevicesOnly, CancellationToken cancellationToken); string GetEmbyTvActiveRecordingPath(string id); @@ -286,15 +310,4 @@ namespace MediaBrowser.Controller.LiveTv List<BaseItem> GetRecordingFolders(User user); } - - public class ActiveRecordingInfo - { - public string Id { get; set; } - - public string Path { get; set; } - - public TimerInfo Timer { get; set; } - - public CancellationTokenSource CancellationTokenSource { get; set; } - } } diff --git a/MediaBrowser.Controller/LiveTv/ILiveTvService.cs b/MediaBrowser.Controller/LiveTv/ILiveTvService.cs index 3ca1d165ef..897f263f3d 100644 --- a/MediaBrowser.Controller/LiveTv/ILiveTvService.cs +++ b/MediaBrowser.Controller/LiveTv/ILiveTvService.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System; diff --git a/MediaBrowser.Controller/LiveTv/ITunerHost.cs b/MediaBrowser.Controller/LiveTv/ITunerHost.cs index abca8f2390..24820abb90 100644 --- a/MediaBrowser.Controller/LiveTv/ITunerHost.cs +++ b/MediaBrowser.Controller/LiveTv/ITunerHost.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System.Collections.Generic; @@ -28,6 +30,8 @@ namespace MediaBrowser.Controller.LiveTv /// <summary> /// Gets the channels. /// </summary> + /// <param name="enableCache">Option to enable using cache.</param> + /// <param name="cancellationToken">The CancellationToken for this operation.</param> /// <returns>Task<IEnumerable<ChannelInfo>>.</returns> Task<List<ChannelInfo>> GetChannels(bool enableCache, CancellationToken cancellationToken); @@ -45,6 +49,7 @@ namespace MediaBrowser.Controller.LiveTv /// <param name="streamId">The stream identifier.</param> /// <param name="currentLiveStreams">The current live streams.</param> /// <param name="cancellationToken">The cancellation token to cancel operation.</param> + /// <returns>Live stream wrapped in a task.</returns> Task<ILiveStream> GetChannelStream(string channelId, string streamId, List<ILiveStream> currentLiveStreams, CancellationToken cancellationToken); /// <summary> diff --git a/MediaBrowser.Controller/LiveTv/LiveTvChannel.cs b/MediaBrowser.Controller/LiveTv/LiveTvChannel.cs index ec933caf34..074e023e8d 100644 --- a/MediaBrowser.Controller/LiveTv/LiveTvChannel.cs +++ b/MediaBrowser.Controller/LiveTv/LiveTvChannel.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System; @@ -16,23 +18,6 @@ namespace MediaBrowser.Controller.LiveTv { public class LiveTvChannel : BaseItem, IHasMediaSources, IHasProgramAttributes { - public override List<string> GetUserDataKeys() - { - var list = base.GetUserDataKeys(); - - if (!ConfigurationManager.Configuration.DisableLiveTvChannelUserDataName) - { - list.Insert(0, GetClientTypeName() + "-" + Name); - } - - return list; - } - - public override UnratedItem GetBlockUnratedType() - { - return UnratedItem.LiveTvChannel; - } - [JsonIgnore] public override bool SupportsPositionTicksResume => false; @@ -57,6 +42,67 @@ namespace MediaBrowser.Controller.LiveTv [JsonIgnore] public override LocationType LocationType => LocationType.Remote; + [JsonIgnore] + public override string MediaType => ChannelType == ChannelType.Radio ? Model.Entities.MediaType.Audio : Model.Entities.MediaType.Video; + + [JsonIgnore] + public bool IsMovie { get; set; } + + /// <summary> + /// Gets or sets a value indicating whether this instance is sports. + /// </summary> + /// <value><c>true</c> if this instance is sports; otherwise, <c>false</c>.</value> + [JsonIgnore] + public bool IsSports { get; set; } + + /// <summary> + /// Gets or sets a value indicating whether this instance is series. + /// </summary> + /// <value><c>true</c> if this instance is series; otherwise, <c>false</c>.</value> + [JsonIgnore] + public bool IsSeries { get; set; } + + /// <summary> + /// Gets or sets a value indicating whether this instance is news. + /// </summary> + /// <value><c>true</c> if this instance is news; otherwise, <c>false</c>.</value> + [JsonIgnore] + public bool IsNews { get; set; } + + /// <summary> + /// Gets a value indicating whether this instance is kids. + /// </summary> + /// <value><c>true</c> if this instance is kids; otherwise, <c>false</c>.</value> + [JsonIgnore] + public bool IsKids => Tags.Contains("Kids", StringComparer.OrdinalIgnoreCase); + + [JsonIgnore] + public bool IsRepeat { get; set; } + + /// <summary> + /// Gets or sets the episode title. + /// </summary> + /// <value>The episode title.</value> + [JsonIgnore] + public string EpisodeTitle { get; set; } + + public override List<string> GetUserDataKeys() + { + var list = base.GetUserDataKeys(); + + if (!ConfigurationManager.Configuration.DisableLiveTvChannelUserDataName) + { + list.Insert(0, GetClientTypeName() + "-" + Name); + } + + return list; + } + + public override UnratedItem GetBlockUnratedType() + { + return UnratedItem.LiveTvChannel; + } + protected override string CreateSortName() { if (!string.IsNullOrEmpty(Number)) @@ -72,15 +118,12 @@ namespace MediaBrowser.Controller.LiveTv return (Number ?? string.Empty) + "-" + (Name ?? string.Empty); } - [JsonIgnore] - public override string MediaType => ChannelType == ChannelType.Radio ? Model.Entities.MediaType.Audio : Model.Entities.MediaType.Video; - public override string GetClientTypeName() { return "TvChannel"; } - public IEnumerable<BaseItem> GetTaggedItems(IEnumerable<BaseItem> inputItems) + public IEnumerable<BaseItem> GetTaggedItems() { return new List<BaseItem>(); } @@ -120,46 +163,5 @@ namespace MediaBrowser.Controller.LiveTv { return false; } - - [JsonIgnore] - public bool IsMovie { get; set; } - - /// <summary> - /// Gets or sets a value indicating whether this instance is sports. - /// </summary> - /// <value><c>true</c> if this instance is sports; otherwise, <c>false</c>.</value> - [JsonIgnore] - public bool IsSports { get; set; } - - /// <summary> - /// Gets or sets a value indicating whether this instance is series. - /// </summary> - /// <value><c>true</c> if this instance is series; otherwise, <c>false</c>.</value> - [JsonIgnore] - public bool IsSeries { get; set; } - - /// <summary> - /// Gets or sets a value indicating whether this instance is news. - /// </summary> - /// <value><c>true</c> if this instance is news; otherwise, <c>false</c>.</value> - [JsonIgnore] - public bool IsNews { get; set; } - - /// <summary> - /// Gets or sets a value indicating whether this instance is kids. - /// </summary> - /// <value><c>true</c> if this instance is kids; otherwise, <c>false</c>.</value> - [JsonIgnore] - public bool IsKids => Tags.Contains("Kids", StringComparer.OrdinalIgnoreCase); - - [JsonIgnore] - public bool IsRepeat { get; set; } - - /// <summary> - /// Gets or sets the episode title. - /// </summary> - /// <value>The episode title.</value> - [JsonIgnore] - public string EpisodeTitle { get; set; } } } diff --git a/MediaBrowser.Controller/LiveTv/LiveTvProgram.cs b/MediaBrowser.Controller/LiveTv/LiveTvProgram.cs index 43af495dd6..111dc0d275 100644 --- a/MediaBrowser.Controller/LiveTv/LiveTvProgram.cs +++ b/MediaBrowser.Controller/LiveTv/LiveTvProgram.cs @@ -1,4 +1,6 @@ -#pragma warning disable CS1591 +#nullable disable + +#pragma warning disable CS1591, SA1306 using System; using System.Collections.Generic; @@ -17,59 +19,20 @@ namespace MediaBrowser.Controller.LiveTv { public class LiveTvProgram : BaseItem, IHasLookupInfo<ItemLookupInfo>, IHasStartDate, IHasProgramAttributes { + private static string EmbyServiceName = "Emby"; + public LiveTvProgram() { IsVirtualItem = true; } - public override List<string> GetUserDataKeys() - { - var list = base.GetUserDataKeys(); - - if (!IsSeries) - { - var key = this.GetProviderId(MetadataProvider.Imdb); - if (!string.IsNullOrEmpty(key)) - { - list.Insert(0, key); - } - - key = this.GetProviderId(MetadataProvider.Tmdb); - if (!string.IsNullOrEmpty(key)) - { - list.Insert(0, key); - } - } - else if (!string.IsNullOrEmpty(EpisodeTitle)) - { - var name = GetClientTypeName(); - - list.Insert(0, name + "-" + Name + (EpisodeTitle ?? string.Empty)); - } - - return list; - } - - private static string EmbyServiceName = "Emby"; - public override double GetDefaultPrimaryImageAspectRatio() - { - var serviceName = ServiceName; - - if (string.Equals(serviceName, EmbyServiceName, StringComparison.OrdinalIgnoreCase) || string.Equals(serviceName, "Next Pvr", StringComparison.OrdinalIgnoreCase)) - { - return 2.0 / 3; - } - else - { - return 16.0 / 9; - } - } + public string SeriesName { get; set; } [JsonIgnore] public override SourceType SourceType => SourceType.LiveTV; /// <summary> - /// The start date of the program, in UTC. + /// Gets or sets start date of the program, in UTC. /// </summary> [JsonIgnore] public DateTime StartDate { get; set; } @@ -99,7 +62,7 @@ namespace MediaBrowser.Controller.LiveTv public bool IsMovie { get; set; } /// <summary> - /// Gets or sets a value indicating whether this instance is sports. + /// Gets a value indicating whether this instance is sports. /// </summary> /// <value><c>true</c> if this instance is sports; otherwise, <c>false</c>.</value> [JsonIgnore] @@ -113,49 +76,49 @@ namespace MediaBrowser.Controller.LiveTv public bool IsSeries { get; set; } /// <summary> - /// Gets or sets a value indicating whether this instance is live. + /// Gets a value indicating whether this instance is live. /// </summary> /// <value><c>true</c> if this instance is live; otherwise, <c>false</c>.</value> [JsonIgnore] public bool IsLive => Tags.Contains("Live", StringComparer.OrdinalIgnoreCase); /// <summary> - /// Gets or sets a value indicating whether this instance is news. + /// Gets a value indicating whether this instance is news. /// </summary> /// <value><c>true</c> if this instance is news; otherwise, <c>false</c>.</value> [JsonIgnore] public bool IsNews => Tags.Contains("News", StringComparer.OrdinalIgnoreCase); /// <summary> - /// Gets or sets a value indicating whether this instance is kids. + /// Gets a value indicating whether this instance is kids. /// </summary> /// <value><c>true</c> if this instance is kids; otherwise, <c>false</c>.</value> [JsonIgnore] public bool IsKids => Tags.Contains("Kids", StringComparer.OrdinalIgnoreCase); /// <summary> - /// Gets or sets a value indicating whether this instance is premiere. + /// Gets a value indicating whether this instance is premiere. /// </summary> /// <value><c>true</c> if this instance is premiere; otherwise, <c>false</c>.</value> [JsonIgnore] public bool IsPremiere => Tags.Contains("Premiere", StringComparer.OrdinalIgnoreCase); /// <summary> - /// Returns the folder containing the item. + /// Gets the folder containing the item. /// If the item is a folder, it returns the folder itself. /// </summary> /// <value>The containing folder path.</value> [JsonIgnore] public override string ContainingFolderPath => Path; - //[JsonIgnore] + // [JsonIgnore] // public override string MediaType - //{ + // { // get // { // return ChannelType == ChannelType.TV ? Model.Entities.MediaType.Video : Model.Entities.MediaType.Audio; // } - //} + // } [JsonIgnore] public bool IsAiring @@ -179,6 +142,66 @@ namespace MediaBrowser.Controller.LiveTv } } + [JsonIgnore] + public override bool SupportsPeople + { + get + { + // Optimization + if (IsNews || IsSports) + { + return false; + } + + return base.SupportsPeople; + } + } + + [JsonIgnore] + public override bool SupportsAncestors => false; + + public override List<string> GetUserDataKeys() + { + var list = base.GetUserDataKeys(); + + if (!IsSeries) + { + var key = this.GetProviderId(MetadataProvider.Imdb); + if (!string.IsNullOrEmpty(key)) + { + list.Insert(0, key); + } + + key = this.GetProviderId(MetadataProvider.Tmdb); + if (!string.IsNullOrEmpty(key)) + { + list.Insert(0, key); + } + } + else if (!string.IsNullOrEmpty(EpisodeTitle)) + { + var name = GetClientTypeName(); + + list.Insert(0, name + "-" + Name + (EpisodeTitle ?? string.Empty)); + } + + return list; + } + + public override double GetDefaultPrimaryImageAspectRatio() + { + var serviceName = ServiceName; + + if (string.Equals(serviceName, EmbyServiceName, StringComparison.OrdinalIgnoreCase) || string.Equals(serviceName, "Next Pvr", StringComparison.OrdinalIgnoreCase)) + { + return 2.0 / 3; + } + else + { + return 16.0 / 9; + } + } + public override string GetClientTypeName() { return "Program"; @@ -199,24 +222,6 @@ namespace MediaBrowser.Controller.LiveTv return false; } - [JsonIgnore] - public override bool SupportsPeople - { - get - { - // Optimization - if (IsNews || IsSports) - { - return false; - } - - return base.SupportsPeople; - } - } - - [JsonIgnore] - public override bool SupportsAncestors => false; - private LiveTvOptions GetConfiguration() { return ConfigurationManager.GetConfiguration<LiveTvOptions>("livetv"); @@ -270,7 +275,5 @@ namespace MediaBrowser.Controller.LiveTv return list; } - - public string SeriesName { get; set; } } } diff --git a/MediaBrowser.Controller/LiveTv/LiveTvServiceStatusInfo.cs b/MediaBrowser.Controller/LiveTv/LiveTvServiceStatusInfo.cs index b629749049..eb3babc180 100644 --- a/MediaBrowser.Controller/LiveTv/LiveTvServiceStatusInfo.cs +++ b/MediaBrowser.Controller/LiveTv/LiveTvServiceStatusInfo.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System.Collections.Generic; diff --git a/MediaBrowser.Controller/LiveTv/LiveTvTunerInfo.cs b/MediaBrowser.Controller/LiveTv/LiveTvTunerInfo.cs index 739978e7ce..aa5eb59d16 100644 --- a/MediaBrowser.Controller/LiveTv/LiveTvTunerInfo.cs +++ b/MediaBrowser.Controller/LiveTv/LiveTvTunerInfo.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System.Collections.Generic; diff --git a/MediaBrowser.Controller/LiveTv/ProgramInfo.cs b/MediaBrowser.Controller/LiveTv/ProgramInfo.cs index f9f559ee96..3c3ac2471f 100644 --- a/MediaBrowser.Controller/LiveTv/ProgramInfo.cs +++ b/MediaBrowser.Controller/LiveTv/ProgramInfo.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System; @@ -8,8 +10,16 @@ namespace MediaBrowser.Controller.LiveTv { public class ProgramInfo { + public ProgramInfo() + { + Genres = new List<string>(); + + ProviderIds = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase); + SeriesProviderIds = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase); + } + /// <summary> - /// Id of the program. + /// Gets or sets the id of the program. /// </summary> public string Id { get; set; } @@ -20,7 +30,7 @@ namespace MediaBrowser.Controller.LiveTv public string ChannelId { get; set; } /// <summary> - /// Name of the program. + /// Gets or sets the name of the program. /// </summary> public string Name { get; set; } @@ -43,17 +53,17 @@ namespace MediaBrowser.Controller.LiveTv public string ShortOverview { get; set; } /// <summary> - /// The start date of the program, in UTC. + /// Gets or sets the start date of the program, in UTC. /// </summary> public DateTime StartDate { get; set; } /// <summary> - /// The end date of the program, in UTC. + /// Gets or sets the end date of the program, in UTC. /// </summary> public DateTime EndDate { get; set; } /// <summary> - /// Genre of the program. + /// Gets or sets the genre of the program. /// </summary> public List<string> Genres { get; set; } @@ -69,6 +79,9 @@ namespace MediaBrowser.Controller.LiveTv /// <value><c>true</c> if this instance is hd; otherwise, <c>false</c>.</value> public bool? IsHD { get; set; } + /// <summary> + /// Gets or sets a value indicating whether this instance is 3d. + /// </summary> public bool? Is3D { get; set; } /// <summary> @@ -98,13 +111,13 @@ namespace MediaBrowser.Controller.LiveTv public string EpisodeTitle { get; set; } /// <summary> - /// Supply the image path if it can be accessed directly from the file system. + /// Gets or sets the image path if it can be accessed directly from the file system. /// </summary> /// <value>The image path.</value> public string ImagePath { get; set; } /// <summary> - /// Supply the image url if it can be downloaded. + /// Gets or sets the image url if it can be downloaded. /// </summary> /// <value>The image URL.</value> public string ImageUrl { get; set; } @@ -210,13 +223,5 @@ namespace MediaBrowser.Controller.LiveTv public Dictionary<string, string> ProviderIds { get; set; } public Dictionary<string, string> SeriesProviderIds { get; set; } - - public ProgramInfo() - { - Genres = new List<string>(); - - ProviderIds = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase); - SeriesProviderIds = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase); - } } } diff --git a/MediaBrowser.Controller/LiveTv/RecordingInfo.cs b/MediaBrowser.Controller/LiveTv/RecordingInfo.cs index 69190694ff..1dcf7a58fe 100644 --- a/MediaBrowser.Controller/LiveTv/RecordingInfo.cs +++ b/MediaBrowser.Controller/LiveTv/RecordingInfo.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System; @@ -8,8 +10,13 @@ namespace MediaBrowser.Controller.LiveTv { public class RecordingInfo { + public RecordingInfo() + { + Genres = new List<string>(); + } + /// <summary> - /// Id of the recording. + /// Gets or sets the id of the recording. /// </summary> public string Id { get; set; } @@ -26,7 +33,7 @@ namespace MediaBrowser.Controller.LiveTv public string TimerId { get; set; } /// <summary> - /// ChannelId of the recording. + /// Gets or sets the channelId of the recording. /// </summary> public string ChannelId { get; set; } @@ -37,7 +44,7 @@ namespace MediaBrowser.Controller.LiveTv public ChannelType ChannelType { get; set; } /// <summary> - /// Name of the recording. + /// Gets or sets the name of the recording. /// </summary> public string Name { get; set; } @@ -60,12 +67,12 @@ namespace MediaBrowser.Controller.LiveTv public string Overview { get; set; } /// <summary> - /// The start date of the recording, in UTC. + /// Gets or sets the start date of the recording, in UTC. /// </summary> public DateTime StartDate { get; set; } /// <summary> - /// The end date of the recording, in UTC. + /// Gets or sets the end date of the recording, in UTC. /// </summary> public DateTime EndDate { get; set; } @@ -82,7 +89,7 @@ namespace MediaBrowser.Controller.LiveTv public RecordingStatus Status { get; set; } /// <summary> - /// Genre of the program. + /// Gets or sets the genre of the program. /// </summary> public List<string> Genres { get; set; } @@ -171,13 +178,13 @@ namespace MediaBrowser.Controller.LiveTv public float? CommunityRating { get; set; } /// <summary> - /// Supply the image path if it can be accessed directly from the file system. + /// Gets or sets the image path if it can be accessed directly from the file system. /// </summary> /// <value>The image path.</value> public string ImagePath { get; set; } /// <summary> - /// Supply the image url if it can be downloaded. + /// Gets or sets the image url if it can be downloaded. /// </summary> /// <value>The image URL.</value> public string ImageUrl { get; set; } @@ -199,10 +206,5 @@ namespace MediaBrowser.Controller.LiveTv /// </summary> /// <value>The date last updated.</value> public DateTime DateLastUpdated { get; set; } - - public RecordingInfo() - { - Genres = new List<string>(); - } } } diff --git a/MediaBrowser.Controller/LiveTv/RecordingStatusChangedEventArgs.cs b/MediaBrowser.Controller/LiveTv/RecordingStatusChangedEventArgs.cs index 847c0ea8c0..0b943c9396 100644 --- a/MediaBrowser.Controller/LiveTv/RecordingStatusChangedEventArgs.cs +++ b/MediaBrowser.Controller/LiveTv/RecordingStatusChangedEventArgs.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System; diff --git a/MediaBrowser.Controller/LiveTv/SeriesTimerInfo.cs b/MediaBrowser.Controller/LiveTv/SeriesTimerInfo.cs index 1343ecd982..d6811fe14e 100644 --- a/MediaBrowser.Controller/LiveTv/SeriesTimerInfo.cs +++ b/MediaBrowser.Controller/LiveTv/SeriesTimerInfo.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System; @@ -8,13 +10,20 @@ namespace MediaBrowser.Controller.LiveTv { public class SeriesTimerInfo { + public SeriesTimerInfo() + { + Days = new List<DayOfWeek>(); + SkipEpisodesInLibrary = true; + KeepUntil = KeepUntil.UntilDeleted; + } + /// <summary> - /// Id of the recording. + /// Gets or sets the id of the recording. /// </summary> public string Id { get; set; } /// <summary> - /// ChannelId of the recording. + /// Gets or sets the channelId of the recording. /// </summary> public string ChannelId { get; set; } @@ -25,24 +34,27 @@ namespace MediaBrowser.Controller.LiveTv public string ProgramId { get; set; } /// <summary> - /// Name of the recording. + /// Gets or sets the name of the recording. /// </summary> public string Name { get; set; } + /// <summary> + /// Gets or sets the service name. + /// </summary> public string ServiceName { get; set; } /// <summary> - /// Description of the recording. + /// Gets or sets the description of the recording. /// </summary> public string Overview { get; set; } /// <summary> - /// The start date of the recording, in UTC. + /// Gets or sets the start date of the recording, in UTC. /// </summary> public DateTime StartDate { get; set; } /// <summary> - /// The end date of the recording, in UTC. + /// Gets or sets the end date of the recording, in UTC. /// </summary> public DateTime EndDate { get; set; } @@ -111,12 +123,5 @@ namespace MediaBrowser.Controller.LiveTv /// </summary> /// <value>The series identifier.</value> public string SeriesId { get; set; } - - public SeriesTimerInfo() - { - Days = new List<DayOfWeek>(); - SkipEpisodesInLibrary = true; - KeepUntil = KeepUntil.UntilDeleted; - } } } diff --git a/MediaBrowser.Controller/LiveTv/TimerEventInfo.cs b/MediaBrowser.Controller/LiveTv/TimerEventInfo.cs index 1b8f41db69..92eb0be9c0 100644 --- a/MediaBrowser.Controller/LiveTv/TimerEventInfo.cs +++ b/MediaBrowser.Controller/LiveTv/TimerEventInfo.cs @@ -1,4 +1,3 @@ -#nullable enable #pragma warning disable CS1591 using System; diff --git a/MediaBrowser.Controller/LiveTv/TimerInfo.cs b/MediaBrowser.Controller/LiveTv/TimerInfo.cs index aa5170617d..1a2e8acb31 100644 --- a/MediaBrowser.Controller/LiveTv/TimerInfo.cs +++ b/MediaBrowser.Controller/LiveTv/TimerInfo.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System; @@ -26,18 +28,17 @@ namespace MediaBrowser.Controller.LiveTv public string[] Tags { get; set; } /// <summary> - /// Id of the recording. + /// Gets or sets the id of the recording. /// </summary> public string Id { get; set; } /// <summary> /// Gets or sets the series timer identifier. /// </summary> - /// <value>The series timer identifier.</value> public string SeriesTimerId { get; set; } /// <summary> - /// ChannelId of the recording. + /// Gets or sets the channelId of the recording. /// </summary> public string ChannelId { get; set; } @@ -50,24 +51,24 @@ namespace MediaBrowser.Controller.LiveTv public string ShowId { get; set; } /// <summary> - /// Name of the recording. + /// Gets or sets the name of the recording. /// </summary> public string Name { get; set; } /// <summary> - /// Description of the recording. + /// Gets or sets the description of the recording. /// </summary> public string Overview { get; set; } public string SeriesId { get; set; } /// <summary> - /// The start date of the recording, in UTC. + /// Gets or sets the start date of the recording, in UTC. /// </summary> public DateTime StartDate { get; set; } /// <summary> - /// The end date of the recording, in UTC. + /// Gets or sets the end date of the recording, in UTC. /// </summary> public DateTime EndDate { get; set; } @@ -131,7 +132,7 @@ namespace MediaBrowser.Controller.LiveTv public bool IsSeries { get; set; } /// <summary> - /// Gets or sets a value indicating whether this instance is live. + /// Gets a value indicating whether this instance is live. /// </summary> /// <value><c>true</c> if this instance is live; otherwise, <c>false</c>.</value> [JsonIgnore] diff --git a/MediaBrowser.Controller/LiveTv/TunerChannelMapping.cs b/MediaBrowser.Controller/LiveTv/TunerChannelMapping.cs index 2759b314f5..1c1a4417dc 100644 --- a/MediaBrowser.Controller/LiveTv/TunerChannelMapping.cs +++ b/MediaBrowser.Controller/LiveTv/TunerChannelMapping.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 namespace MediaBrowser.Controller.LiveTv diff --git a/MediaBrowser.Controller/MediaBrowser.Controller.csproj b/MediaBrowser.Controller/MediaBrowser.Controller.csproj index 4374317d67..0f697bcccd 100644 --- a/MediaBrowser.Controller/MediaBrowser.Controller.csproj +++ b/MediaBrowser.Controller/MediaBrowser.Controller.csproj @@ -8,20 +8,23 @@ <PropertyGroup> <Authors>Jellyfin Contributors</Authors> <PackageId>Jellyfin.Controller</PackageId> - <VersionPrefix>10.7.0</VersionPrefix> + <VersionPrefix>10.8.0</VersionPrefix> <RepositoryUrl>https://github.com/jellyfin/jellyfin</RepositoryUrl> <PackageLicenseExpression>GPL-3.0-only</PackageLicenseExpression> </PropertyGroup> <ItemGroup> - <PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="3.1.9" /> - <PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="3.1.9" /> - <PackageReference Include="Microsoft.SourceLink.GitHub" Version="1.0.0" PrivateAssets="All"/> + <PackageReference Include="Diacritics" Version="2.1.20036.1" /> + <PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="5.0.0" /> + <PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="5.0.0" /> + <PackageReference Include="Microsoft.SourceLink.GitHub" Version="1.0.0" PrivateAssets="All" /> + <PackageReference Include="System.Threading.Tasks.Dataflow" Version="5.0.0" /> </ItemGroup> <ItemGroup> - <ProjectReference Include="..\MediaBrowser.Model\MediaBrowser.Model.csproj" /> - <ProjectReference Include="..\MediaBrowser.Common\MediaBrowser.Common.csproj" /> + <ProjectReference Include="../Emby.Naming/Emby.Naming.csproj" /> + <ProjectReference Include="../MediaBrowser.Model/MediaBrowser.Model.csproj" /> + <ProjectReference Include="../MediaBrowser.Common/MediaBrowser.Common.csproj" /> </ItemGroup> <ItemGroup> @@ -29,14 +32,18 @@ </ItemGroup> <PropertyGroup> - <TargetFramework>netstandard2.1</TargetFramework> + <TargetFramework>net5.0</TargetFramework> <GenerateAssemblyInfo>false</GenerateAssemblyInfo> <GenerateDocumentationFile>true</GenerateDocumentationFile> - <TreatWarningsAsErrors Condition=" '$(Configuration)' == 'Release' ">true</TreatWarningsAsErrors> <PublishRepositoryUrl>true</PublishRepositoryUrl> <EmbedUntrackedSources>true</EmbedUntrackedSources> <IncludeSymbols>true</IncludeSymbols> <SymbolPackageFormat>snupkg</SymbolPackageFormat> + <TreatWarningsAsErrors>false</TreatWarningsAsErrors> + </PropertyGroup> + + <PropertyGroup Condition=" '$(Configuration)' == 'Release'"> + <TreatWarningsAsErrors>true</TreatWarningsAsErrors> </PropertyGroup> <PropertyGroup Condition=" '$(Stability)'=='Unstable'"> @@ -46,14 +53,9 @@ <!-- Code Analyzers--> <ItemGroup Condition=" '$(Configuration)' == 'Debug' "> - <PackageReference Include="Microsoft.CodeAnalysis.FxCopAnalyzers" Version="2.9.8" PrivateAssets="All" /> <PackageReference Include="SerilogAnalyzer" Version="0.15.0" PrivateAssets="All" /> <PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="All" /> <PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" PrivateAssets="All" /> </ItemGroup> - <PropertyGroup Condition=" '$(Configuration)' == 'Debug' "> - <CodeAnalysisRuleSet>../jellyfin.ruleset</CodeAnalysisRuleSet> - </PropertyGroup> - </Project> diff --git a/MediaBrowser.Controller/MediaEncoding/EncodingJobOptions.cs b/MediaBrowser.Controller/MediaEncoding/BaseEncodingJobOptions.cs similarity index 74% rename from MediaBrowser.Controller/MediaEncoding/EncodingJobOptions.cs rename to MediaBrowser.Controller/MediaEncoding/BaseEncodingJobOptions.cs index 1f3abe8f43..dd6f468dab 100644 --- a/MediaBrowser.Controller/MediaEncoding/EncodingJobOptions.cs +++ b/MediaBrowser.Controller/MediaEncoding/BaseEncodingJobOptions.cs @@ -1,63 +1,24 @@ +#nullable disable + #pragma warning disable CS1591 using System; using System.Collections.Generic; -using System.Linq; using MediaBrowser.Model.Dlna; namespace MediaBrowser.Controller.MediaEncoding { - public class EncodingJobOptions : BaseEncodingJobOptions + public class BaseEncodingJobOptions { - public string OutputDirectory { get; set; } - - public string ItemId { get; set; } - - public string TempDirectory { get; set; } - - public bool ReadInputAtNativeFramerate { get; set; } - - /// <summary> - /// Gets a value indicating whether this instance has fixed resolution. - /// </summary> - /// <value><c>true</c> if this instance has fixed resolution; otherwise, <c>false</c>.</value> - public bool HasFixedResolution => Width.HasValue || Height.HasValue; - - public DeviceProfile DeviceProfile { get; set; } - - public EncodingJobOptions(StreamInfo info, DeviceProfile deviceProfile) + public BaseEncodingJobOptions() { - Container = info.Container; - StartTimeTicks = info.StartPositionTicks; - MaxWidth = info.MaxWidth; - MaxHeight = info.MaxHeight; - MaxFramerate = info.MaxFramerate; - Id = info.ItemId; - MediaSourceId = info.MediaSourceId; - AudioCodec = info.TargetAudioCodec.FirstOrDefault(); - MaxAudioChannels = info.GlobalMaxAudioChannels; - AudioBitRate = info.AudioBitrate; - AudioSampleRate = info.TargetAudioSampleRate; - DeviceProfile = deviceProfile; - VideoCodec = info.TargetVideoCodec.FirstOrDefault(); - VideoBitRate = info.VideoBitrate; - AudioStreamIndex = info.AudioStreamIndex; - SubtitleMethod = info.SubtitleDeliveryMethod; - Context = info.Context; - TranscodingMaxAudioChannels = info.TranscodingMaxAudioChannels; - - if (info.SubtitleDeliveryMethod != SubtitleDeliveryMethod.External) - { - SubtitleStreamIndex = info.SubtitleStreamIndex; - } - - StreamOptions = info.StreamOptions; + EnableAutoStreamCopy = true; + AllowVideoStreamCopy = true; + AllowAudioStreamCopy = true; + Context = EncodingContext.Streaming; + StreamOptions = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase); } - } - // For now until api and media encoding layers are unified - public class BaseEncodingJobOptions - { /// <summary> /// Gets or sets the id. /// </summary> @@ -239,14 +200,5 @@ namespace MediaBrowser.Controller.MediaEncoding return null; } - - public BaseEncodingJobOptions() - { - EnableAutoStreamCopy = true; - AllowVideoStreamCopy = true; - AllowAudioStreamCopy = true; - Context = EncodingContext.Streaming; - StreamOptions = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase); - } } -} +} \ No newline at end of file diff --git a/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs b/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs index 61340cd242..141bb91c57 100644 --- a/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs +++ b/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System; @@ -5,30 +7,24 @@ using System.Collections.Generic; using System.Globalization; using System.IO; using System.Linq; -using System.Runtime.InteropServices; using System.Text; +using System.Text.RegularExpressions; using System.Threading; using Jellyfin.Data.Enums; -using MediaBrowser.Controller.Entities; -using MediaBrowser.Controller.Extensions; using MediaBrowser.Model.Configuration; using MediaBrowser.Model.Dlna; using MediaBrowser.Model.Dto; using MediaBrowser.Model.Entities; -using MediaBrowser.Model.IO; using MediaBrowser.Model.MediaInfo; -using Microsoft.Extensions.Configuration; namespace MediaBrowser.Controller.MediaEncoding { public class EncodingHelper { - private readonly CultureInfo _usCulture = new CultureInfo("en-US"); + private static readonly CultureInfo _usCulture = new CultureInfo("en-US"); private readonly IMediaEncoder _mediaEncoder; - private readonly IFileSystem _fileSystem; private readonly ISubtitleEncoder _subtitleEncoder; - private readonly IConfiguration _configuration; private static readonly string[] _videoProfiles = new[] { @@ -43,14 +39,10 @@ namespace MediaBrowser.Controller.MediaEncoding public EncodingHelper( IMediaEncoder mediaEncoder, - IFileSystem fileSystem, - ISubtitleEncoder subtitleEncoder, - IConfiguration configuration) + ISubtitleEncoder subtitleEncoder) { _mediaEncoder = mediaEncoder; - _fileSystem = fileSystem; _subtitleEncoder = subtitleEncoder; - _configuration = configuration; } public string GetH264Encoder(EncodingJobInfo state, EncodingOptions encodingOptions) @@ -63,7 +55,7 @@ namespace MediaBrowser.Controller.MediaEncoding { // Only use alternative encoders for video files. // When using concat with folder rips, if the mfx session fails to initialize, ffmpeg will be stuck retrying and will not exit gracefully - // Since transcoding of folder rips is expiremental anyway, it's not worth adding additional variables such as this. + // Since transcoding of folder rips is experimental anyway, it's not worth adding additional variables such as this. if (state.VideoType == VideoType.VideoFile) { var hwType = encodingOptions.HardwareAccelerationType; @@ -111,9 +103,64 @@ namespace MediaBrowser.Controller.MediaEncoding return _mediaEncoder.SupportsHwaccel("vaapi"); } + private bool IsCudaSupported() + { + return _mediaEncoder.SupportsHwaccel("cuda") + && _mediaEncoder.SupportsFilter("scale_cuda", null) + && _mediaEncoder.SupportsFilter("yadif_cuda", null); + } + + private bool IsTonemappingSupported(EncodingJobInfo state, EncodingOptions options) + { + var videoStream = state.VideoStream; + return IsColorDepth10(state) + && _mediaEncoder.SupportsHwaccel("opencl") + && options.EnableTonemapping + && string.Equals(videoStream.VideoRange, "HDR", StringComparison.OrdinalIgnoreCase); + } + + private bool IsVppTonemappingSupported(EncodingJobInfo state, EncodingOptions options) + { + var videoStream = state.VideoStream; + if (videoStream == null) + { + // Remote stream doesn't have media info, disable vpp tonemapping. + return false; + } + + var codec = videoStream.Codec; + if (string.Equals(options.HardwareAccelerationType, "vaapi", StringComparison.OrdinalIgnoreCase)) + { + // Limited to HEVC for now since the filter doesn't accept master data from VP9. + return IsColorDepth10(state) + && string.Equals(codec, "hevc", StringComparison.OrdinalIgnoreCase) + && _mediaEncoder.SupportsHwaccel("vaapi") + && options.EnableVppTonemapping + && string.Equals(videoStream.ColorTransfer, "smpte2084", StringComparison.OrdinalIgnoreCase); + } + + // Hybrid VPP tonemapping for QSV with VAAPI + if (OperatingSystem.IsLinux() && string.Equals(options.HardwareAccelerationType, "qsv", StringComparison.OrdinalIgnoreCase)) + { + // Limited to HEVC for now since the filter doesn't accept master data from VP9. + return IsColorDepth10(state) + && string.Equals(codec, "hevc", StringComparison.OrdinalIgnoreCase) + && _mediaEncoder.SupportsHwaccel("vaapi") + && _mediaEncoder.SupportsHwaccel("qsv") + && options.EnableVppTonemapping + && string.Equals(videoStream.ColorTransfer, "smpte2084", StringComparison.OrdinalIgnoreCase); + } + + // Native VPP tonemapping may come to QSV in the future. + return false; + } + /// <summary> /// Gets the name of the output video codec. /// </summary> + /// <param name="state">Encording state.</param> + /// <param name="encodingOptions">Encoding options.</param> + /// <returns>Encoder string.</returns> public string GetVideoEncoder(EncodingJobInfo state, EncodingOptions encodingOptions) { var codec = state.OutputVideoCodec; @@ -247,7 +294,7 @@ namespace MediaBrowser.Controller.MediaEncoding return null; } - // Seeing reported failures here, not sure yet if this is related to specfying input format + // Seeing reported failures here, not sure yet if this is related to specifying input format if (string.Equals(container, "m4v", StringComparison.OrdinalIgnoreCase)) { return null; @@ -259,9 +306,20 @@ namespace MediaBrowser.Controller.MediaEncoding return null; } + // ISO files don't have an ffmpeg format + if (string.Equals(container, "iso", StringComparison.OrdinalIgnoreCase)) + { + return null; + } + return container; } + /// <summary> + /// Gets decoder from a codec. + /// </summary> + /// <param name="codec">Codec to use.</param> + /// <returns>Decoder string.</returns> public string GetDecoderFromCodec(string codec) { // For these need to find out the ffmpeg names @@ -291,6 +349,8 @@ namespace MediaBrowser.Controller.MediaEncoding /// <summary> /// Infers the audio codec based on the url. /// </summary> + /// <param name="container">Container to use.</param> + /// <returns>Codec string.</returns> public string InferAudioCodec(string container) { var ext = "." + (container ?? string.Empty); @@ -379,25 +439,9 @@ namespace MediaBrowser.Controller.MediaEncoding public string GetInputPathArgument(EncodingJobInfo state) { - var protocol = state.InputProtocol; var mediaPath = state.MediaPath ?? string.Empty; - string[] inputPath; - if (state.IsInputVideo - && !(state.VideoType == VideoType.Iso && state.IsoMount == null)) - { - inputPath = MediaEncoderHelpers.GetInputArgument( - _fileSystem, - mediaPath, - state.IsoMount, - state.PlayableStreamFileNames); - } - else - { - inputPath = new[] { mediaPath }; - } - - return _mediaEncoder.GetInputArgument(inputPath, protocol); + return _mediaEncoder.GetInputArgument(mediaPath, state.MediaSource); } /// <summary> @@ -440,12 +484,21 @@ namespace MediaBrowser.Controller.MediaEncoding return "libopus"; } + if (string.Equals(codec, "flac", StringComparison.OrdinalIgnoreCase)) + { + // flac is experimental in mp4 muxer + return "flac -strict -2"; + } + return codec.ToLowerInvariant(); } /// <summary> /// Gets the input argument. /// </summary> + /// <param name="state">Encoding state.</param> + /// <param name="encodingOptions">Encoding options.</param> + /// <returns>Input arguments.</returns> public string GetInputArgument(EncodingJobInfo state, EncodingOptions encodingOptions) { var arg = new StringBuilder(); @@ -457,10 +510,13 @@ namespace MediaBrowser.Controller.MediaEncoding var isVaapiEncoder = outputVideoCodec.IndexOf("vaapi", StringComparison.OrdinalIgnoreCase) != -1; var isQsvDecoder = videoDecoder.IndexOf("qsv", StringComparison.OrdinalIgnoreCase) != -1; var isQsvEncoder = outputVideoCodec.IndexOf("qsv", StringComparison.OrdinalIgnoreCase) != -1; - var isNvdecHevcDecoder = videoDecoder.IndexOf("hevc_cuvid", StringComparison.OrdinalIgnoreCase) != -1; - var isWindows = RuntimeInformation.IsOSPlatform(OSPlatform.Windows); - var isLinux = RuntimeInformation.IsOSPlatform(OSPlatform.Linux); - var isMacOS = RuntimeInformation.IsOSPlatform(OSPlatform.OSX); + var isNvdecDecoder = videoDecoder.Contains("cuda", StringComparison.OrdinalIgnoreCase); + var isCuvidHevcDecoder = videoDecoder.Contains("hevc_cuvid", StringComparison.OrdinalIgnoreCase); + var isWindows = OperatingSystem.IsWindows(); + var isLinux = OperatingSystem.IsLinux(); + var isMacOS = OperatingSystem.IsMacOS(); + var isTonemappingSupported = IsTonemappingSupported(state, encodingOptions); + var isVppTonemappingSupported = IsVppTonemappingSupported(state, encodingOptions); if (!IsCopyCodec(outputVideoCodec)) { @@ -470,10 +526,23 @@ namespace MediaBrowser.Controller.MediaEncoding { if (isVaapiDecoder) { - arg.Append("-hwaccel_output_format vaapi ") - .Append("-vaapi_device ") - .Append(encodingOptions.VaapiDevice) - .Append(' '); + if (isTonemappingSupported && !isVppTonemappingSupported) + { + arg.Append("-init_hw_device vaapi=va:") + .Append(encodingOptions.VaapiDevice) + .Append(' ') + .Append("-init_hw_device opencl=ocl@va ") + .Append("-hwaccel_device va ") + .Append("-hwaccel_output_format vaapi ") + .Append("-filter_hw_device ocl "); + } + else + { + arg.Append("-hwaccel_output_format vaapi ") + .Append("-vaapi_device ") + .Append(encodingOptions.VaapiDevice) + .Append(' '); + } } else if (!isVaapiDecoder && isVaapiEncoder) { @@ -481,6 +550,8 @@ namespace MediaBrowser.Controller.MediaEncoding .Append(encodingOptions.VaapiDevice) .Append(' '); } + + arg.Append("-autorotate 0 "); } if (state.IsVideoRequest @@ -511,24 +582,40 @@ namespace MediaBrowser.Controller.MediaEncoding } // While using SW decoder - else + else if (isSwDecoder) { arg.Append("-init_hw_device qsv=hw -filter_hw_device hw "); } + + // Hybrid VPP tonemapping with VAAPI + else if (isVaapiDecoder && isVppTonemappingSupported) + { + arg.Append("-init_hw_device vaapi=va:") + .Append(encodingOptions.VaapiDevice) + .Append(' ') + .Append("-init_hw_device qsv@va ") + .Append("-hwaccel_output_format vaapi "); + } + + arg.Append("-autorotate 0 "); } } if (state.IsVideoRequest - && (string.Equals(encodingOptions.HardwareAccelerationType, "nvenc", StringComparison.OrdinalIgnoreCase) && isNvdecHevcDecoder || isSwDecoder) - || (string.Equals(encodingOptions.HardwareAccelerationType, "amf", StringComparison.OrdinalIgnoreCase) && isD3d11vaDecoder || isSwDecoder)) + && string.Equals(encodingOptions.HardwareAccelerationType, "nvenc", StringComparison.OrdinalIgnoreCase) + && isNvdecDecoder) { - var isColorDepth10 = IsColorDepth10(state); + // Fix for 'No decoder surfaces left' error. https://trac.ffmpeg.org/ticket/7562 + arg.Append("-hwaccel_output_format cuda -extra_hw_frames 3 -autorotate 0 "); + } - if (isColorDepth10 - && _mediaEncoder.SupportsHwaccel("opencl") - && encodingOptions.EnableTonemapping - && !string.IsNullOrEmpty(state.VideoStream.VideoRange) - && state.VideoStream.VideoRange.Contains("HDR", StringComparison.OrdinalIgnoreCase)) + if (state.IsVideoRequest + && ((string.Equals(encodingOptions.HardwareAccelerationType, "nvenc", StringComparison.OrdinalIgnoreCase) + && (isNvdecDecoder || isCuvidHevcDecoder || isSwDecoder)) + || (string.Equals(encodingOptions.HardwareAccelerationType, "amf", StringComparison.OrdinalIgnoreCase) + && (isD3d11vaDecoder || isSwDecoder)))) + { + if (isTonemappingSupported) { arg.Append("-init_hw_device opencl=ocl:") .Append(encodingOptions.OpenclDevice) @@ -573,7 +660,7 @@ namespace MediaBrowser.Controller.MediaEncoding /// </summary> /// <param name="stream">The stream.</param> /// <returns><c>true</c> if the specified stream is H264; otherwise, <c>false</c>.</returns> - public bool IsH264(MediaStream stream) + public static bool IsH264(MediaStream stream) { var codec = stream.Codec ?? string.Empty; @@ -581,7 +668,7 @@ namespace MediaBrowser.Controller.MediaEncoding || codec.IndexOf("avc", StringComparison.OrdinalIgnoreCase) != -1; } - public bool IsH265(MediaStream stream) + public static bool IsH265(MediaStream stream) { var codec = stream.Codec ?? string.Empty; @@ -589,10 +676,17 @@ namespace MediaBrowser.Controller.MediaEncoding || codec.IndexOf("hevc", StringComparison.OrdinalIgnoreCase) != -1; } - // TODO This is auto inserted into the mpegts mux so it might not be needed - // https://www.ffmpeg.org/ffmpeg-bitstream-filters.html#h264_005fmp4toannexb - public string GetBitStreamArgs(MediaStream stream) + public static bool IsAAC(MediaStream stream) + { + var codec = stream.Codec ?? string.Empty; + + return codec.IndexOf("aac", StringComparison.OrdinalIgnoreCase) != -1; + } + + public static string GetBitStreamArgs(MediaStream stream) { + // TODO This is auto inserted into the mpegts mux so it might not be needed. + // https://www.ffmpeg.org/ffmpeg-bitstream-filters.html#h264_005fmp4toannexb if (IsH264(stream)) { return "-bsf:v h264_mp4toannexb"; @@ -601,12 +695,44 @@ namespace MediaBrowser.Controller.MediaEncoding { return "-bsf:v hevc_mp4toannexb"; } + else if (IsAAC(stream)) + { + // Convert adts header(mpegts) to asc header(mp4). + return "-bsf:a aac_adtstoasc"; + } else { return null; } } + public static string GetAudioBitStreamArguments(EncodingJobInfo state, string segmentContainer, string mediaSourceContainer) + { + var bitStreamArgs = string.Empty; + var segmentFormat = GetSegmentFileExtension(segmentContainer).TrimStart('.'); + + // Apply aac_adtstoasc bitstream filter when media source is in mpegts. + if (string.Equals(segmentFormat, "mp4", StringComparison.OrdinalIgnoreCase) + && (string.Equals(mediaSourceContainer, "mpegts", StringComparison.OrdinalIgnoreCase) + || string.Equals(mediaSourceContainer, "hls", StringComparison.OrdinalIgnoreCase))) + { + bitStreamArgs = GetBitStreamArgs(state.AudioStream); + bitStreamArgs = string.IsNullOrEmpty(bitStreamArgs) ? string.Empty : " " + bitStreamArgs; + } + + return bitStreamArgs; + } + + public static string GetSegmentFileExtension(string segmentContainer) + { + if (!string.IsNullOrWhiteSpace(segmentContainer)) + { + return "." + segmentContainer; + } + + return ".ts"; + } + public string GetVideoBitrateParam(EncodingJobInfo state, string videoCodec) { var bitrate = state.OutputVideoBitrate; @@ -654,16 +780,30 @@ namespace MediaBrowser.Controller.MediaEncoding return string.Empty; } - public string NormalizeTranscodingLevel(string videoCodec, string level) + public static string NormalizeTranscodingLevel(EncodingJobInfo state, string level) { - // Clients may direct play higher than level 41, but there's no reason to transcode higher - if (double.TryParse(level, NumberStyles.Any, _usCulture, out double requestLevel) - && requestLevel > 41 - && (string.Equals(videoCodec, "h264", StringComparison.OrdinalIgnoreCase) - || string.Equals(videoCodec, "h265", StringComparison.OrdinalIgnoreCase) - || string.Equals(videoCodec, "hevc", StringComparison.OrdinalIgnoreCase))) + if (double.TryParse(level, NumberStyles.Any, _usCulture, out double requestLevel)) { - return "41"; + if (string.Equals(state.ActualOutputVideoCodec, "hevc", StringComparison.OrdinalIgnoreCase) + || string.Equals(state.ActualOutputVideoCodec, "h265", StringComparison.OrdinalIgnoreCase)) + { + // Transcode to level 5.0 and lower for maximum compatibility. + // Level 5.0 is suitable for up to 4k 30fps hevc encoding, otherwise let the encoder to handle it. + // https://en.wikipedia.org/wiki/High_Efficiency_Video_Coding_tiers_and_levels + // MaxLumaSampleRate = 3840*2160*30 = 248832000 < 267386880. + if (requestLevel >= 150) + { + return "150"; + } + } + else if (string.Equals(state.ActualOutputVideoCodec, "h264", StringComparison.OrdinalIgnoreCase)) + { + // Clients may direct play higher than level 41, but there's no reason to transcode higher. + if (requestLevel >= 41) + { + return "41"; + } + } } return level; @@ -766,13 +906,130 @@ namespace MediaBrowser.Controller.MediaEncoding return null; } + public string GetHlsVideoKeyFrameArguments( + EncodingJobInfo state, + string codec, + int segmentLength, + bool isEventPlaylist, + int? startNumber) + { + var args = string.Empty; + var gopArg = string.Empty; + var keyFrameArg = string.Empty; + if (isEventPlaylist) + { + keyFrameArg = string.Format( + CultureInfo.InvariantCulture, + " -force_key_frames:0 \"expr:gte(t,n_forced*{0})\"", + segmentLength); + } + else if (startNumber.HasValue) + { + keyFrameArg = string.Format( + CultureInfo.InvariantCulture, + " -force_key_frames:0 \"expr:gte(t,{0}+n_forced*{1})\"", + startNumber.Value * segmentLength, + segmentLength); + } + + var framerate = state.VideoStream?.RealFrameRate; + if (framerate.HasValue) + { + // This is to make sure keyframe interval is limited to our segment, + // as forcing keyframes is not enough. + // Example: we encoded half of desired length, then codec detected + // scene cut and inserted a keyframe; next forced keyframe would + // be created outside of segment, which breaks seeking. + // -sc_threshold 0 is used to prevent the hardware encoder from post processing to break the set keyframe. + gopArg = string.Format( + CultureInfo.InvariantCulture, + " -g:v:0 {0} -keyint_min:v:0 {0} -sc_threshold:v:0 0", + Math.Ceiling(segmentLength * framerate.Value)); + } + + // Unable to force key frames using these encoders, set key frames by GOP. + if (string.Equals(codec, "h264_qsv", StringComparison.OrdinalIgnoreCase) + || string.Equals(codec, "h264_nvenc", StringComparison.OrdinalIgnoreCase) + || string.Equals(codec, "h264_amf", StringComparison.OrdinalIgnoreCase) + || string.Equals(codec, "hevc_qsv", StringComparison.OrdinalIgnoreCase) + || string.Equals(codec, "hevc_nvenc", StringComparison.OrdinalIgnoreCase) + || string.Equals(codec, "hevc_amf", StringComparison.OrdinalIgnoreCase)) + { + args += gopArg; + } + else if (string.Equals(codec, "libx264", StringComparison.OrdinalIgnoreCase) + || string.Equals(codec, "libx265", StringComparison.OrdinalIgnoreCase) + || string.Equals(codec, "h264_vaapi", StringComparison.OrdinalIgnoreCase) + || string.Equals(codec, "hevc_vaapi", StringComparison.OrdinalIgnoreCase)) + { + args += " " + keyFrameArg; + } + else + { + args += " " + keyFrameArg + gopArg; + } + + return args; + } + /// <summary> /// Gets the video bitrate to specify on the command line. /// </summary> + /// <param name="state">Encoding state.</param> + /// <param name="videoEncoder">Video encoder to use.</param> + /// <param name="encodingOptions">Encoding options.</param> + /// <param name="defaultPreset">Default present to use for encoding.</param> + /// <returns>Video bitrate.</returns> public string GetVideoQualityParam(EncodingJobInfo state, string videoEncoder, EncodingOptions encodingOptions, string defaultPreset) { var param = string.Empty; + if (!string.Equals(videoEncoder, "h264_omx", StringComparison.OrdinalIgnoreCase) + && !string.Equals(videoEncoder, "h264_qsv", StringComparison.OrdinalIgnoreCase) + && !string.Equals(videoEncoder, "h264_vaapi", StringComparison.OrdinalIgnoreCase) + && !string.Equals(videoEncoder, "h264_nvenc", StringComparison.OrdinalIgnoreCase) + && !string.Equals(videoEncoder, "h264_amf", StringComparison.OrdinalIgnoreCase) + && !string.Equals(videoEncoder, "h264_v4l2m2m", StringComparison.OrdinalIgnoreCase) + && !string.Equals(videoEncoder, "hevc_qsv", StringComparison.OrdinalIgnoreCase) + && !string.Equals(videoEncoder, "hevc_vaapi", StringComparison.OrdinalIgnoreCase) + && !string.Equals(videoEncoder, "hevc_nvenc", StringComparison.OrdinalIgnoreCase) + && !string.Equals(videoEncoder, "hevc_amf", StringComparison.OrdinalIgnoreCase)) + { + param += " -pix_fmt yuv420p"; + } + + if (string.Equals(videoEncoder, "h264_nvenc", StringComparison.OrdinalIgnoreCase) + || string.Equals(videoEncoder, "h264_amf", StringComparison.OrdinalIgnoreCase) + || string.Equals(videoEncoder, "hevc_nvenc", StringComparison.OrdinalIgnoreCase) + || string.Equals(videoEncoder, "hevc_amf", StringComparison.OrdinalIgnoreCase)) + { + var videoStream = state.VideoStream; + var isColorDepth10 = IsColorDepth10(state); + var videoDecoder = GetHardwareAcceleratedVideoDecoder(state, encodingOptions) ?? string.Empty; + var isNvdecDecoder = videoDecoder.Contains("cuda", StringComparison.OrdinalIgnoreCase); + + if (!isNvdecDecoder) + { + if (isColorDepth10 + && _mediaEncoder.SupportsHwaccel("opencl") + && encodingOptions.EnableTonemapping + && !string.IsNullOrEmpty(videoStream.VideoRange) + && videoStream.VideoRange.Contains("HDR", StringComparison.OrdinalIgnoreCase)) + { + param += " -pix_fmt nv12"; + } + else + { + param += " -pix_fmt yuv420p"; + } + } + } + + if (string.Equals(videoEncoder, "h264_v4l2m2m", StringComparison.OrdinalIgnoreCase)) + { + param += " -pix_fmt nv21"; + } + var isVc1 = state.VideoStream != null && string.Equals(state.VideoStream.Codec, "vc1", StringComparison.OrdinalIgnoreCase); var isLibX265 = string.Equals(videoEncoder, "libx265", StringComparison.OrdinalIgnoreCase); @@ -781,11 +1038,11 @@ namespace MediaBrowser.Controller.MediaEncoding { if (!string.IsNullOrEmpty(encodingOptions.EncoderPreset)) { - param += "-preset " + encodingOptions.EncoderPreset; + param += " -preset " + encodingOptions.EncoderPreset; } else { - param += "-preset " + defaultPreset; + param += " -preset " + defaultPreset; } int encodeCrf = encodingOptions.H264Crf; @@ -809,38 +1066,39 @@ namespace MediaBrowser.Controller.MediaEncoding param += " -crf " + defaultCrf; } } - else if (string.Equals(videoEncoder, "h264_qsv", StringComparison.OrdinalIgnoreCase)) // h264 (h264_qsv) + else if (string.Equals(videoEncoder, "h264_qsv", StringComparison.OrdinalIgnoreCase) // h264 (h264_qsv) + || string.Equals(videoEncoder, "hevc_qsv", StringComparison.OrdinalIgnoreCase)) // hevc (hevc_qsv) { string[] valid_h264_qsv = { "veryslow", "slower", "slow", "medium", "fast", "faster", "veryfast" }; if (valid_h264_qsv.Contains(encodingOptions.EncoderPreset, StringComparer.OrdinalIgnoreCase)) { - param += "-preset " + encodingOptions.EncoderPreset; + param += " -preset " + encodingOptions.EncoderPreset; } else { - param += "-preset 7"; + param += " -preset 7"; } param += " -look_ahead 0"; } else if (string.Equals(videoEncoder, "h264_nvenc", StringComparison.OrdinalIgnoreCase) // h264 (h264_nvenc) - || string.Equals(videoEncoder, "hevc_nvenc", StringComparison.OrdinalIgnoreCase)) + || string.Equals(videoEncoder, "hevc_nvenc", StringComparison.OrdinalIgnoreCase)) // hevc (hevc_nvenc) { switch (encodingOptions.EncoderPreset) { case "veryslow": - param += "-preset slow"; // lossless is only supported on maxwell and newer(2014+) + param += " -preset slow"; // lossless is only supported on maxwell and newer(2014+) break; case "slow": case "slower": - param += "-preset slow"; + param += " -preset slow"; break; case "medium": - param += "-preset medium"; + param += " -preset medium"; break; case "fast": @@ -848,27 +1106,27 @@ namespace MediaBrowser.Controller.MediaEncoding case "veryfast": case "superfast": case "ultrafast": - param += "-preset fast"; + param += " -preset fast"; break; default: - param += "-preset default"; + param += " -preset default"; break; } } - else if (string.Equals(videoEncoder, "h264_amf", StringComparison.OrdinalIgnoreCase) - || string.Equals(videoEncoder, "hevc_amf", StringComparison.OrdinalIgnoreCase)) + else if (string.Equals(videoEncoder, "h264_amf", StringComparison.OrdinalIgnoreCase) // h264 (h264_amf) + || string.Equals(videoEncoder, "hevc_amf", StringComparison.OrdinalIgnoreCase)) // hevc (hevc_amf) { switch (encodingOptions.EncoderPreset) { case "veryslow": case "slow": case "slower": - param += "-quality quality"; + param += " -quality quality"; break; case "medium": - param += "-quality balanced"; + param += " -quality balanced"; break; case "fast": @@ -876,11 +1134,11 @@ namespace MediaBrowser.Controller.MediaEncoding case "veryfast": case "superfast": case "ultrafast": - param += "-quality speed"; + param += " -quality speed"; break; default: - param += "-quality speed"; + param += " -quality speed"; break; } @@ -896,6 +1154,11 @@ namespace MediaBrowser.Controller.MediaEncoding // Enhance workload when tone mapping with AMF on some APUs param += " -preanalysis true"; } + + if (string.Equals(videoEncoder, "hevc_amf", StringComparison.OrdinalIgnoreCase)) + { + param += " -header_insertion_mode gop -gops_per_idr 1"; + } } else if (string.Equals(videoEncoder, "libvpx", StringComparison.OrdinalIgnoreCase)) // webm { @@ -917,7 +1180,9 @@ namespace MediaBrowser.Controller.MediaEncoding profileScore = Math.Min(profileScore, 2); // http://www.webmproject.org/docs/encoder-parameters/ - param += string.Format(CultureInfo.InvariantCulture, "-speed 16 -quality good -profile:v {0} -slices 8 -crf {1} -qmin {2} -qmax {3}", + param += string.Format( + CultureInfo.InvariantCulture, + " -speed 16 -quality good -profile:v {0} -slices 8 -crf {1} -qmin {2} -qmax {3}", profileScore.ToString(_usCulture), crf, qmin, @@ -925,15 +1190,15 @@ namespace MediaBrowser.Controller.MediaEncoding } else if (string.Equals(videoEncoder, "mpeg4", StringComparison.OrdinalIgnoreCase)) { - param += "-mbd rd -flags +mv4+aic -trellis 2 -cmp 2 -subcmp 2 -bf 2"; + param += " -mbd rd -flags +mv4+aic -trellis 2 -cmp 2 -subcmp 2 -bf 2"; } else if (string.Equals(videoEncoder, "wmv2", StringComparison.OrdinalIgnoreCase)) // asf/wmv { - param += "-qmin 2"; + param += " -qmin 2"; } else if (string.Equals(videoEncoder, "msmpeg4", StringComparison.OrdinalIgnoreCase)) { - param += "-mbd 2"; + param += " -mbd 2"; } param += GetVideoBitrateParam(state, videoEncoder); @@ -945,25 +1210,88 @@ namespace MediaBrowser.Controller.MediaEncoding } var targetVideoCodec = state.ActualOutputVideoCodec; + if (string.Equals(targetVideoCodec, "h265", StringComparison.OrdinalIgnoreCase) + || string.Equals(targetVideoCodec, "hevc", StringComparison.OrdinalIgnoreCase)) + { + targetVideoCodec = "hevc"; + } + + var profile = state.GetRequestedProfiles(targetVideoCodec).FirstOrDefault() ?? string.Empty; + profile = Regex.Replace(profile, @"\s+", string.Empty); + + // We only transcode to HEVC 8-bit for now, force Main Profile. + if (profile.Contains("main10", StringComparison.OrdinalIgnoreCase) + || profile.Contains("mainstill", StringComparison.OrdinalIgnoreCase)) + { + profile = "main"; + } + + // Extended Profile is not supported by any known h264 encoders, force Main Profile. + if (profile.Contains("extended", StringComparison.OrdinalIgnoreCase)) + { + profile = "main"; + } - var profile = state.GetRequestedProfiles(targetVideoCodec).FirstOrDefault(); + // Only libx264 support encoding H264 High 10 Profile, otherwise force High Profile. + if (!string.Equals(videoEncoder, "libx264", StringComparison.OrdinalIgnoreCase) + && profile.Contains("high10", StringComparison.OrdinalIgnoreCase)) + { + profile = "high"; + } - // vaapi does not support Baseline profile, force Constrained Baseline in this case, - // which is compatible (and ugly) + // h264_vaapi does not support Baseline profile, force Constrained Baseline in this case, + // which is compatible (and ugly). if (string.Equals(videoEncoder, "h264_vaapi", StringComparison.OrdinalIgnoreCase) - && profile != null - && profile.IndexOf("baseline", StringComparison.OrdinalIgnoreCase) != -1) + && profile.Contains("baseline", StringComparison.OrdinalIgnoreCase)) + { + profile = "constrained_baseline"; + } + + // libx264, h264_qsv and h264_nvenc does not support Constrained Baseline profile, force Baseline in this case. + if ((string.Equals(videoEncoder, "libx264", StringComparison.OrdinalIgnoreCase) + || string.Equals(videoEncoder, "h264_qsv", StringComparison.OrdinalIgnoreCase) + || string.Equals(videoEncoder, "h264_nvenc", StringComparison.OrdinalIgnoreCase)) + && profile.Contains("baseline", StringComparison.OrdinalIgnoreCase)) + { + profile = "baseline"; + } + + // libx264, h264_qsv, h264_nvenc and h264_vaapi does not support Constrained High profile, force High in this case. + if ((string.Equals(videoEncoder, "libx264", StringComparison.OrdinalIgnoreCase) + || string.Equals(videoEncoder, "h264_qsv", StringComparison.OrdinalIgnoreCase) + || string.Equals(videoEncoder, "h264_nvenc", StringComparison.OrdinalIgnoreCase) + || string.Equals(videoEncoder, "h264_vaapi", StringComparison.OrdinalIgnoreCase)) + && profile.Contains("high", StringComparison.OrdinalIgnoreCase)) + { + profile = "high"; + } + + if (string.Equals(videoEncoder, "h264_amf", StringComparison.OrdinalIgnoreCase) + && profile.Contains("baseline", StringComparison.OrdinalIgnoreCase)) { profile = "constrained_baseline"; } + if (string.Equals(videoEncoder, "h264_amf", StringComparison.OrdinalIgnoreCase) + && profile.Contains("constrainedhigh", StringComparison.OrdinalIgnoreCase)) + { + profile = "constrained_high"; + } + + // Currently hevc_amf only support encoding HEVC Main Profile, otherwise force Main Profile. + if (string.Equals(videoEncoder, "hevc_amf", StringComparison.OrdinalIgnoreCase) + && profile.Contains("main10", StringComparison.OrdinalIgnoreCase)) + { + profile = "main"; + } + if (!string.IsNullOrEmpty(profile)) { if (!string.Equals(videoEncoder, "h264_omx", StringComparison.OrdinalIgnoreCase) && !string.Equals(videoEncoder, "h264_v4l2m2m", StringComparison.OrdinalIgnoreCase)) { // not supported by h264_omx - param += " -profile:v " + profile; + param += " -profile:v:0 " + profile; } } @@ -971,55 +1299,35 @@ namespace MediaBrowser.Controller.MediaEncoding if (!string.IsNullOrEmpty(level)) { - level = NormalizeTranscodingLevel(state.OutputVideoCodec, level); + level = NormalizeTranscodingLevel(state, level); - // h264_qsv and h264_nvenc expect levels to be expressed as a decimal. libx264 supports decimal and non-decimal format - // also needed for libx264 due to https://trac.ffmpeg.org/ticket/3307 + // libx264, QSV, AMF, VAAPI can adjust the given level to match the output. if (string.Equals(videoEncoder, "h264_qsv", StringComparison.OrdinalIgnoreCase) - || string.Equals(videoEncoder, "libx264", StringComparison.OrdinalIgnoreCase) - || string.Equals(videoEncoder, "libx265", StringComparison.OrdinalIgnoreCase)) - { - switch (level) - { - case "30": - param += " -level 3.0"; - break; - case "31": - param += " -level 3.1"; - break; - case "32": - param += " -level 3.2"; - break; - case "40": - param += " -level 4.0"; - break; - case "41": - param += " -level 4.1"; - break; - case "42": - param += " -level 4.2"; - break; - case "50": - param += " -level 5.0"; - break; - case "51": - param += " -level 5.1"; - break; - case "52": - param += " -level 5.2"; - break; - default: - param += " -level " + level; - break; + || string.Equals(videoEncoder, "libx264", StringComparison.OrdinalIgnoreCase)) + { + param += " -level " + level; + } + else if (string.Equals(videoEncoder, "hevc_qsv", StringComparison.OrdinalIgnoreCase)) + { + // hevc_qsv use -level 51 instead of -level 153. + if (double.TryParse(level, NumberStyles.Any, _usCulture, out double hevcLevel)) + { + param += " -level " + (hevcLevel / 3); } } + else if (string.Equals(videoEncoder, "h264_amf", StringComparison.OrdinalIgnoreCase) + || string.Equals(videoEncoder, "hevc_amf", StringComparison.OrdinalIgnoreCase)) + { + param += " -level " + level; + } else if (string.Equals(videoEncoder, "h264_nvenc", StringComparison.OrdinalIgnoreCase) - || string.Equals(videoEncoder, "hevc_nvenc", StringComparison.OrdinalIgnoreCase)) + || string.Equals(videoEncoder, "hevc_nvenc", StringComparison.OrdinalIgnoreCase)) { - // nvenc doesn't decode with param -level set ?! - // TODO: + // level option may cause NVENC to fail. + // NVENC cannot adjust the given level, just throw an error. } - else if (!string.Equals(videoEncoder, "h264_omx", StringComparison.OrdinalIgnoreCase)) + else if (!string.Equals(videoEncoder, "h264_omx", StringComparison.OrdinalIgnoreCase) + || !string.Equals(videoEncoder, "libx265", StringComparison.OrdinalIgnoreCase)) { param += " -level " + level; } @@ -1032,42 +1340,11 @@ namespace MediaBrowser.Controller.MediaEncoding if (string.Equals(videoEncoder, "libx265", StringComparison.OrdinalIgnoreCase)) { - // todo - } - - if (!string.Equals(videoEncoder, "h264_omx", StringComparison.OrdinalIgnoreCase) - && !string.Equals(videoEncoder, "h264_qsv", StringComparison.OrdinalIgnoreCase) - && !string.Equals(videoEncoder, "h264_vaapi", StringComparison.OrdinalIgnoreCase) - && !string.Equals(videoEncoder, "h264_nvenc", StringComparison.OrdinalIgnoreCase) - && !string.Equals(videoEncoder, "h264_amf", StringComparison.OrdinalIgnoreCase) - && !string.Equals(videoEncoder, "h264_v4l2m2m", StringComparison.OrdinalIgnoreCase)) - { - param = "-pix_fmt yuv420p " + param; - } - - if (string.Equals(videoEncoder, "h264_nvenc", StringComparison.OrdinalIgnoreCase) - || string.Equals(videoEncoder, "h264_amf", StringComparison.OrdinalIgnoreCase)) - { - var videoStream = state.VideoStream; - var isColorDepth10 = IsColorDepth10(state); - - if (isColorDepth10 - && _mediaEncoder.SupportsHwaccel("opencl") - && encodingOptions.EnableTonemapping - && !string.IsNullOrEmpty(videoStream.VideoRange) - && videoStream.VideoRange.Contains("HDR", StringComparison.OrdinalIgnoreCase)) - { - param = "-pix_fmt nv12 " + param; - } - else - { - param = "-pix_fmt yuv420p " + param; - } - } - - if (string.Equals(videoEncoder, "h264_v4l2m2m", StringComparison.OrdinalIgnoreCase)) - { - param = "-pix_fmt nv21 " + param; + // libx265 only accept level option in -x265-params. + // level option may cause libx265 to fail. + // libx265 cannot adjust the given level, just throw an error. + // TODO: set fine tuned params. + param += " -x265-params:0 no-info=1"; } return param; @@ -1130,7 +1407,8 @@ namespace MediaBrowser.Controller.MediaEncoding var requestedProfile = requestedProfiles[0]; // strip spaces because they may be stripped out on the query string as well - if (!string.IsNullOrEmpty(videoStream.Profile) && !requestedProfiles.Contains(videoStream.Profile.Replace(" ", ""), StringComparer.OrdinalIgnoreCase)) + if (!string.IsNullOrEmpty(videoStream.Profile) + && !requestedProfiles.Contains(videoStream.Profile.Replace(" ", string.Empty, StringComparison.Ordinal), StringComparer.OrdinalIgnoreCase)) { var currentScore = GetVideoProfileScore(videoStream.Profile); var requestedScore = GetVideoProfileScore(requestedProfile); @@ -1346,7 +1624,7 @@ namespace MediaBrowser.Controller.MediaEncoding || string.Equals(codec, "hevc", StringComparison.OrdinalIgnoreCase) || string.Equals(codec, "vp9", StringComparison.OrdinalIgnoreCase)) { - return .5; + return .6; } return 1; @@ -1380,27 +1658,55 @@ namespace MediaBrowser.Controller.MediaEncoding public int? GetAudioBitrateParam(BaseEncodingJobOptions request, MediaStream audioStream) { - if (request.AudioBitRate.HasValue) - { - // Don't encode any higher than this - return Math.Min(384000, request.AudioBitRate.Value); - } - - return null; + return GetAudioBitrateParam(request.AudioBitRate, request.AudioCodec, audioStream); } - public int? GetAudioBitrateParam(int? audioBitRate, MediaStream audioStream) + public int? GetAudioBitrateParam(int? audioBitRate, string audioCodec, MediaStream audioStream) { - if (audioBitRate.HasValue) + if (audioStream == null) + { + return null; + } + + if (audioBitRate.HasValue && string.IsNullOrEmpty(audioCodec)) { - // Don't encode any higher than this return Math.Min(384000, audioBitRate.Value); } - return null; + if (audioBitRate.HasValue && !string.IsNullOrEmpty(audioCodec)) + { + if (string.Equals(audioCodec, "aac", StringComparison.OrdinalIgnoreCase) + || string.Equals(audioCodec, "mp3", StringComparison.OrdinalIgnoreCase) + || string.Equals(audioCodec, "ac3", StringComparison.OrdinalIgnoreCase) + || string.Equals(audioCodec, "eac3", StringComparison.OrdinalIgnoreCase)) + { + if ((audioStream.Channels ?? 0) >= 6) + { + return Math.Min(640000, audioBitRate.Value); + } + + return Math.Min(384000, audioBitRate.Value); + } + + if (string.Equals(audioCodec, "flac", StringComparison.OrdinalIgnoreCase) + || string.Equals(audioCodec, "alac", StringComparison.OrdinalIgnoreCase)) + { + if ((audioStream.Channels ?? 0) >= 6) + { + return Math.Min(3584000, audioBitRate.Value); + } + + return Math.Min(1536000, audioBitRate.Value); + } + } + + // Empty bitrate area is not allow on iOS + // Default audio bitrate to 128K if it is not being requested + // https://ffmpeg.org/ffmpeg-codecs.html#toc-Codec-Options + return 128000; } - public string GetAudioFilterParam(EncodingJobInfo state, EncodingOptions encodingOptions, bool isHls) + public string GetAudioFilterParam(EncodingJobInfo state, EncodingOptions encodingOptions) { var channels = state.OutputAudioChannels; @@ -1431,7 +1737,7 @@ namespace MediaBrowser.Controller.MediaEncoding if (filters.Count > 0) { - return "-af \"" + string.Join(",", filters) + "\""; + return " -af \"" + string.Join(',', filters) + "\""; } return string.Empty; @@ -1446,6 +1752,11 @@ namespace MediaBrowser.Controller.MediaEncoding /// <returns>System.Nullable{System.Int32}.</returns> public int? GetNumAudioChannelsParam(EncodingJobInfo state, MediaStream audioStream, string outputAudioCodec) { + if (audioStream == null) + { + return null; + } + var request = state.BaseRequest; var inputChannels = audioStream?.Channels; @@ -1468,6 +1779,11 @@ namespace MediaBrowser.Controller.MediaEncoding // libmp3lame currently only supports two channel output transcoderChannelLimit = 2; } + else if (codec.IndexOf("aac", StringComparison.OrdinalIgnoreCase) != -1) + { + // aac is able to handle 8ch(7.1 layout) + transcoderChannelLimit = 8; + } else { // If we don't have any media info then limit it to 6 to prevent encoding errors due to asking for too many channels @@ -1496,6 +1812,16 @@ namespace MediaBrowser.Controller.MediaEncoding : transcoderChannelLimit.Value; } + // Avoid transcoding to audio channels other than 1ch, 2ch, 6ch (5.1 layout) and 8ch (7.1 layout). + // https://developer.apple.com/documentation/http_live_streaming/hls_authoring_specification_for_apple_devices + if (isTranscodingAudio + && state.TranscodingType != TranscodingJobType.Progressive + && resultChannels.HasValue + && ((resultChannels.Value > 2 && resultChannels.Value < 6) || resultChannels.Value == 7)) + { + resultChannels = 2; + } + return resultChannels; } @@ -1655,8 +1981,12 @@ namespace MediaBrowser.Controller.MediaEncoding } /// <summary> - /// Gets the graphical subtitle param. + /// Gets the graphical subtitle parameter. /// </summary> + /// <param name="state">Encoding state.</param> + /// <param name="options">Encoding options.</param> + /// <param name="outputVideoCodec">Video codec to use.</param> + /// <returns>Graphical subtitle parameter.</returns> public string GetGraphicalSubtitleParam( EncodingJobInfo state, EncodingOptions options, @@ -1671,7 +2001,26 @@ namespace MediaBrowser.Controller.MediaEncoding var videoSizeParam = string.Empty; var videoDecoder = GetHardwareAcceleratedVideoDecoder(state, options) ?? string.Empty; - var isLinux = RuntimeInformation.IsOSPlatform(OSPlatform.Linux); + var isLinux = OperatingSystem.IsLinux(); + + var isVaapiDecoder = videoDecoder.IndexOf("vaapi", StringComparison.OrdinalIgnoreCase) != -1; + var isVaapiH264Encoder = outputVideoCodec.IndexOf("h264_vaapi", StringComparison.OrdinalIgnoreCase) != -1; + var isVaapiHevcEncoder = outputVideoCodec.IndexOf("hevc_vaapi", StringComparison.OrdinalIgnoreCase) != -1; + var isQsvH264Encoder = outputVideoCodec.Contains("h264_qsv", StringComparison.OrdinalIgnoreCase); + var isQsvHevcEncoder = outputVideoCodec.Contains("hevc_qsv", StringComparison.OrdinalIgnoreCase); + var isNvdecDecoder = videoDecoder.Contains("cuda", StringComparison.OrdinalIgnoreCase); + var isNvencEncoder = outputVideoCodec.Contains("nvenc", StringComparison.OrdinalIgnoreCase); + var isTonemappingSupported = IsTonemappingSupported(state, options); + var isVppTonemappingSupported = IsVppTonemappingSupported(state, options); + var isTonemappingSupportedOnVaapi = string.Equals(options.HardwareAccelerationType, "vaapi", StringComparison.OrdinalIgnoreCase) && isVaapiDecoder && (isVaapiH264Encoder || isVaapiHevcEncoder); + var isTonemappingSupportedOnQsv = string.Equals(options.HardwareAccelerationType, "qsv", StringComparison.OrdinalIgnoreCase) && isVaapiDecoder && (isQsvH264Encoder || isQsvHevcEncoder); + + // Tonemapping and burn-in graphical subtitles requires overlay_vaapi. + // But it's still in ffmpeg mailing list. Disable it for now. + if (isTonemappingSupportedOnVaapi && isTonemappingSupported && !isVppTonemappingSupported) + { + return GetOutputSizeParam(state, options, outputVideoCodec); + } // Setup subtitle scaling if (state.VideoStream != null && state.VideoStream.Width.HasValue && state.VideoStream.Height.HasValue) @@ -1691,10 +2040,15 @@ namespace MediaBrowser.Controller.MediaEncoding height.Value); } - // For QSV, feed it into hardware encoder now - if (isLinux && string.Equals(outputVideoCodec, "h264_qsv", StringComparison.OrdinalIgnoreCase)) + if (!string.IsNullOrEmpty(videoSizeParam) + && !(isTonemappingSupportedOnQsv && isVppTonemappingSupported)) { - videoSizeParam += ",hwupload=extra_hw_frames=64"; + // For QSV, feed it into hardware encoder now + if (isLinux && (string.Equals(outputVideoCodec, "h264_qsv", StringComparison.OrdinalIgnoreCase) + || string.Equals(outputVideoCodec, "hevc_qsv", StringComparison.OrdinalIgnoreCase))) + { + videoSizeParam += ",hwupload=extra_hw_frames=64"; + } } } @@ -1713,28 +2067,35 @@ namespace MediaBrowser.Controller.MediaEncoding : " -filter_complex \"[{0}:{1}]{4}[sub];[0:{2}][sub]overlay\""; // When the input may or may not be hardware VAAPI decodable - if (string.Equals(outputVideoCodec, "h264_vaapi", StringComparison.OrdinalIgnoreCase)) + if (string.Equals(outputVideoCodec, "h264_vaapi", StringComparison.OrdinalIgnoreCase) + || string.Equals(outputVideoCodec, "hevc_vaapi", StringComparison.OrdinalIgnoreCase)) { /* [base]: HW scaling video to OutputSize [sub]: SW scaling subtitle to FixedOutputSize [base][sub]: SW overlay */ - retStr = " -filter_complex \"[{0}:{1}]{4}[sub];[0:{2}]{3},hwdownload[base];[base][sub]overlay,format=nv12,hwupload\""; + retStr = !outputSizeParam.IsEmpty + ? " -filter_complex \"[{0}:{1}]{4}[sub];[0:{2}]{3},hwdownload[base];[base][sub]overlay,format=nv12,hwupload\"" + : " -filter_complex \"[{0}:{1}]{4}[sub];[0:{2}]hwdownload[base];[base][sub]overlay,format=nv12,hwupload\""; } // If we're hardware VAAPI decoding and software encoding, download frames from the decoder first else if (_mediaEncoder.SupportsHwaccel("vaapi") && videoDecoder.IndexOf("vaapi", StringComparison.OrdinalIgnoreCase) != -1 - && string.Equals(outputVideoCodec, "libx264", StringComparison.OrdinalIgnoreCase)) + && (string.Equals(outputVideoCodec, "libx264", StringComparison.OrdinalIgnoreCase) + || string.Equals(outputVideoCodec, "libx265", StringComparison.OrdinalIgnoreCase))) { /* [base]: SW scaling video to OutputSize [sub]: SW scaling subtitle to FixedOutputSize [base][sub]: SW overlay */ - retStr = " -filter_complex \"[{0}:{1}]{4}[sub];[0:{2}]{3}[base];[base][sub]overlay\""; + retStr = !outputSizeParam.IsEmpty + ? " -filter_complex \"[{0}:{1}]{4}[sub];[0:{2}]{3}[base];[base][sub]overlay\"" + : " -filter_complex \"[{0}:{1}]{4}[sub];[0:{2}][sub]overlay\""; } - else if (string.Equals(outputVideoCodec, "h264_qsv", StringComparison.OrdinalIgnoreCase)) + else if (string.Equals(outputVideoCodec, "h264_qsv", StringComparison.OrdinalIgnoreCase) + || string.Equals(outputVideoCodec, "hevc_qsv", StringComparison.OrdinalIgnoreCase)) { /* QSV in FFMpeg can now setup hardware overlay for transcodes. @@ -1742,13 +2103,23 @@ namespace MediaBrowser.Controller.MediaEncoding with fixed frame size. Currently only supports linux. */ - if (isLinux) + if (isTonemappingSupportedOnQsv && isVppTonemappingSupported) + { + retStr = " -filter_complex \"[{0}:{1}]{4}[sub];[0:{2}]{3},hwdownload,format=nv12[base];[base][sub]overlay\""; + } + else if (isLinux) { retStr = !outputSizeParam.IsEmpty ? " -filter_complex \"[{0}:{1}]{4}[sub];[0:{2}]{3}[base];[base][sub]overlay_qsv\"" : " -filter_complex \"[{0}:{1}]{4}[sub];[0:{2}][sub]overlay_qsv\""; } } + else if (isNvdecDecoder && isNvencEncoder) + { + retStr = !outputSizeParam.IsEmpty + ? " -filter_complex \"[{0}:{1}]{4}[sub];[0:{2}]{3}[base];[base][sub]overlay,format=nv12|yuv420p,hwupload_cuda\"" + : " -filter_complex \"[{0}:{1}]{4}[sub];[0:{2}][sub]overlay,format=nv12|yuv420p,hwupload_cuda\""; + } return string.Format( CultureInfo.InvariantCulture, @@ -1760,7 +2131,7 @@ namespace MediaBrowser.Controller.MediaEncoding videoSizeParam); } - private (int? width, int? height) GetFixedOutputSize( + public static (int? width, int? height) GetFixedOutputSize( int? videoWidth, int? videoHeight, int? requestedWidth, @@ -1778,8 +2149,8 @@ namespace MediaBrowser.Controller.MediaEncoding return (null, null); } - decimal inputWidth = Convert.ToDecimal(videoWidth ?? requestedWidth); - decimal inputHeight = Convert.ToDecimal(videoHeight ?? requestedHeight); + decimal inputWidth = Convert.ToDecimal(videoWidth ?? requestedWidth, CultureInfo.InvariantCulture); + decimal inputHeight = Convert.ToDecimal(videoHeight ?? requestedHeight, CultureInfo.InvariantCulture); decimal outputWidth = requestedWidth.HasValue ? Convert.ToDecimal(requestedWidth.Value) : inputWidth; decimal outputHeight = requestedHeight.HasValue ? Convert.ToDecimal(requestedHeight.Value) : inputHeight; decimal maximumWidth = requestedMaxWidth.HasValue ? Convert.ToDecimal(requestedMaxWidth.Value) : outputWidth; @@ -1800,6 +2171,7 @@ namespace MediaBrowser.Controller.MediaEncoding public List<string> GetScalingFilters( EncodingJobInfo state, + EncodingOptions options, int? videoWidth, int? videoHeight, Video3DFormat? threedFormat, @@ -1820,7 +2192,9 @@ namespace MediaBrowser.Controller.MediaEncoding requestedMaxHeight); if ((string.Equals(videoEncoder, "h264_vaapi", StringComparison.OrdinalIgnoreCase) - || string.Equals(videoEncoder, "h264_qsv", StringComparison.OrdinalIgnoreCase)) + || string.Equals(videoEncoder, "h264_qsv", StringComparison.OrdinalIgnoreCase) + || string.Equals(videoEncoder, "hevc_vaapi", StringComparison.OrdinalIgnoreCase) + || string.Equals(videoEncoder, "hevc_qsv", StringComparison.OrdinalIgnoreCase)) && width.HasValue && height.HasValue) { @@ -1829,12 +2203,36 @@ namespace MediaBrowser.Controller.MediaEncoding // output dimensions. Output dimensions are guaranteed to be even. var outputWidth = width.Value; var outputHeight = height.Value; - var qsv_or_vaapi = string.Equals(videoEncoder, "h264_qsv", StringComparison.OrdinalIgnoreCase); + var qsv_or_vaapi = string.Equals(videoEncoder, "h264_qsv", StringComparison.OrdinalIgnoreCase) + || string.Equals(videoEncoder, "hevc_qsv", StringComparison.OrdinalIgnoreCase); var isDeintEnabled = state.DeInterlace("h264", true) || state.DeInterlace("avc", true) || state.DeInterlace("h265", true) || state.DeInterlace("hevc", true); + var isVaapiDecoder = videoDecoder.Contains("vaapi", StringComparison.OrdinalIgnoreCase); + var isVaapiH264Encoder = videoEncoder.Contains("h264_vaapi", StringComparison.OrdinalIgnoreCase); + var isVaapiHevcEncoder = videoEncoder.Contains("hevc_vaapi", StringComparison.OrdinalIgnoreCase); + var isQsvH264Encoder = videoEncoder.Contains("h264_qsv", StringComparison.OrdinalIgnoreCase); + var isQsvHevcEncoder = videoEncoder.Contains("hevc_qsv", StringComparison.OrdinalIgnoreCase); + var isTonemappingSupported = IsTonemappingSupported(state, options); + var isVppTonemappingSupported = IsVppTonemappingSupported(state, options); + var isTonemappingSupportedOnVaapi = string.Equals(options.HardwareAccelerationType, "vaapi", StringComparison.OrdinalIgnoreCase) && isVaapiDecoder && (isVaapiH264Encoder || isVaapiHevcEncoder); + var isTonemappingSupportedOnQsv = string.Equals(options.HardwareAccelerationType, "qsv", StringComparison.OrdinalIgnoreCase) && isVaapiDecoder && (isQsvH264Encoder || isQsvHevcEncoder); + var isP010PixFmtRequired = (isTonemappingSupportedOnVaapi && (isTonemappingSupported || isVppTonemappingSupported)) + || (isTonemappingSupportedOnQsv && isVppTonemappingSupported); + + var outputPixFmt = "format=nv12"; + if (isP010PixFmtRequired) + { + outputPixFmt = "format=p010"; + } + + if (isTonemappingSupportedOnQsv && isVppTonemappingSupported) + { + qsv_or_vaapi = false; + } + if (!videoWidth.HasValue || outputWidth != videoWidth.Value || !videoHeight.HasValue @@ -1845,22 +2243,69 @@ namespace MediaBrowser.Controller.MediaEncoding filters.Add( string.Format( CultureInfo.InvariantCulture, - "{0}=w={1}:h={2}:format=nv12{3}", + "{0}=w={1}:h={2}{3}{4}", qsv_or_vaapi ? "vpp_qsv" : "scale_vaapi", outputWidth, outputHeight, + ":" + outputPixFmt, (qsv_or_vaapi && isDeintEnabled) ? ":deinterlace=1" : string.Empty)); } - else + + // Assert 10-bit is P010 so as we can avoid the extra scaler to get a bit more fps on high res HDR videos. + else if (!isP010PixFmtRequired) { filters.Add( string.Format( CultureInfo.InvariantCulture, - "{0}=format=nv12{1}", + "{0}={1}{2}", qsv_or_vaapi ? "vpp_qsv" : "scale_vaapi", + outputPixFmt, (qsv_or_vaapi && isDeintEnabled) ? ":deinterlace=1" : string.Empty)); } } + else if ((videoDecoder ?? string.Empty).Contains("cuda", StringComparison.OrdinalIgnoreCase) + && width.HasValue + && height.HasValue) + { + var outputWidth = width.Value; + var outputHeight = height.Value; + + var isTonemappingSupported = IsTonemappingSupported(state, options); + var isTonemappingSupportedOnNvenc = string.Equals(options.HardwareAccelerationType, "nvenc", StringComparison.OrdinalIgnoreCase); + var isCudaFormatConversionSupported = _mediaEncoder.SupportsFilter("scale_cuda", "Output format (default \"same\")"); + + var outputPixFmt = string.Empty; + if (isCudaFormatConversionSupported) + { + outputPixFmt = "format=nv12"; + if (isTonemappingSupported && isTonemappingSupportedOnNvenc) + { + outputPixFmt = "format=p010"; + } + } + + if (!videoWidth.HasValue + || outputWidth != videoWidth.Value + || !videoHeight.HasValue + || outputHeight != videoHeight.Value) + { + filters.Add( + string.Format( + CultureInfo.InvariantCulture, + "scale_cuda=w={0}:h={1}{2}", + outputWidth, + outputHeight, + isCudaFormatConversionSupported ? (":" + outputPixFmt) : string.Empty)); + } + else if (isCudaFormatConversionSupported) + { + filters.Add( + string.Format( + CultureInfo.InvariantCulture, + "scale_cuda={0}", + outputPixFmt)); + } + } else if ((videoDecoder ?? string.Empty).IndexOf("cuvid", StringComparison.OrdinalIgnoreCase) != -1 && width.HasValue && height.HasValue) @@ -2059,6 +2504,13 @@ namespace MediaBrowser.Controller.MediaEncoding return string.Format(CultureInfo.InvariantCulture, filter, widthParam, heightParam); } + /// <summary> + /// Gets the output size parameter. + /// </summary> + /// <param name="state">Encoding state.</param> + /// <param name="options">Encoding options.</param> + /// <param name="outputVideoCodec">Video codec to use.</param> + /// <returns>The output size parameter.</returns> public string GetOutputSizeParam( EncodingJobInfo state, EncodingOptions options, @@ -2069,8 +2521,13 @@ namespace MediaBrowser.Controller.MediaEncoding } /// <summary> + /// Gets the output size parameter. /// If we're going to put a fixed size on the command line, this will calculate it. /// </summary> + /// <param name="state">Encoding state.</param> + /// <param name="options">Encoding options.</param> + /// <param name="outputVideoCodec">Video codec to use.</param> + /// <returns>The output size parameter.</returns> public string GetOutputSizeParamInternal( EncodingJobInfo state, EncodingOptions options, @@ -2090,36 +2547,48 @@ namespace MediaBrowser.Controller.MediaEncoding var isSwDecoder = string.IsNullOrEmpty(videoDecoder); var isD3d11vaDecoder = videoDecoder.IndexOf("d3d11va", StringComparison.OrdinalIgnoreCase) != -1; var isVaapiDecoder = videoDecoder.IndexOf("vaapi", StringComparison.OrdinalIgnoreCase) != -1; + var isVaapiEncoder = outputVideoCodec.IndexOf("vaapi", StringComparison.OrdinalIgnoreCase) != -1; var isVaapiH264Encoder = outputVideoCodec.IndexOf("h264_vaapi", StringComparison.OrdinalIgnoreCase) != -1; + var isVaapiHevcEncoder = outputVideoCodec.IndexOf("hevc_vaapi", StringComparison.OrdinalIgnoreCase) != -1; var isQsvH264Encoder = outputVideoCodec.IndexOf("h264_qsv", StringComparison.OrdinalIgnoreCase) != -1; - var isNvdecH264Decoder = videoDecoder.IndexOf("h264_cuvid", StringComparison.OrdinalIgnoreCase) != -1; - var isNvdecHevcDecoder = videoDecoder.IndexOf("hevc_cuvid", StringComparison.OrdinalIgnoreCase) != -1; + var isQsvHevcEncoder = outputVideoCodec.IndexOf("hevc_qsv", StringComparison.OrdinalIgnoreCase) != -1; + var isNvdecDecoder = videoDecoder.Contains("cuda", StringComparison.OrdinalIgnoreCase); + var isNvencEncoder = outputVideoCodec.Contains("nvenc", StringComparison.OrdinalIgnoreCase); + var isCuvidH264Decoder = videoDecoder.Contains("h264_cuvid", StringComparison.OrdinalIgnoreCase); + var isCuvidHevcDecoder = videoDecoder.Contains("hevc_cuvid", StringComparison.OrdinalIgnoreCase); var isLibX264Encoder = outputVideoCodec.IndexOf("libx264", StringComparison.OrdinalIgnoreCase) != -1; - var isLinux = RuntimeInformation.IsOSPlatform(OSPlatform.Linux); + var isLibX265Encoder = outputVideoCodec.IndexOf("libx265", StringComparison.OrdinalIgnoreCase) != -1; + var isLinux = OperatingSystem.IsLinux(); var isColorDepth10 = IsColorDepth10(state); - + var isTonemappingSupported = IsTonemappingSupported(state, options); + var isVppTonemappingSupported = IsVppTonemappingSupported(state, options); + var isTonemappingSupportedOnNvenc = string.Equals(options.HardwareAccelerationType, "nvenc", StringComparison.OrdinalIgnoreCase) && (isNvdecDecoder || isCuvidHevcDecoder || isSwDecoder); + var isTonemappingSupportedOnAmf = string.Equals(options.HardwareAccelerationType, "amf", StringComparison.OrdinalIgnoreCase) && (isD3d11vaDecoder || isSwDecoder); + var isTonemappingSupportedOnVaapi = string.Equals(options.HardwareAccelerationType, "vaapi", StringComparison.OrdinalIgnoreCase) && isVaapiDecoder && (isVaapiH264Encoder || isVaapiHevcEncoder); + var isTonemappingSupportedOnQsv = string.Equals(options.HardwareAccelerationType, "qsv", StringComparison.OrdinalIgnoreCase) && isVaapiDecoder && (isQsvH264Encoder || isQsvHevcEncoder); + + var hasSubs = state.SubtitleStream != null && state.SubtitleDeliveryMethod == SubtitleDeliveryMethod.Encode; var hasTextSubs = state.SubtitleStream != null && state.SubtitleStream.IsTextSubtitleStream && state.SubtitleDeliveryMethod == SubtitleDeliveryMethod.Encode; var hasGraphicalSubs = state.SubtitleStream != null && !state.SubtitleStream.IsTextSubtitleStream && state.SubtitleDeliveryMethod == SubtitleDeliveryMethod.Encode; // If double rate deinterlacing is enabled and the input framerate is 30fps or below, otherwise the output framerate will be too high for many devices - var doubleRateDeinterlace = options.DeinterlaceDoubleRate && (videoStream?.RealFrameRate ?? 60) <= 30; + var doubleRateDeinterlace = options.DeinterlaceDoubleRate && (videoStream?.AverageFrameRate ?? 60) <= 30; var isScalingInAdvance = false; + var isCudaDeintInAdvance = false; + var isHwuploadCudaRequired = false; var isDeinterlaceH264 = state.DeInterlace("h264", true) || state.DeInterlace("avc", true); var isDeinterlaceHevc = state.DeInterlace("h265", true) || state.DeInterlace("hevc", true); - if ((string.Equals(options.HardwareAccelerationType, "nvenc", StringComparison.OrdinalIgnoreCase) && isNvdecHevcDecoder || isSwDecoder) - || (string.Equals(options.HardwareAccelerationType, "amf", StringComparison.OrdinalIgnoreCase) && isD3d11vaDecoder || isSwDecoder)) + // Add OpenCL tonemapping filter for NVENC/AMF/VAAPI. + if (isTonemappingSupportedOnNvenc || isTonemappingSupportedOnAmf || (isTonemappingSupportedOnVaapi && !isVppTonemappingSupported)) { // Currently only with the use of NVENC decoder can we get a decent performance. // Currently only the HEVC/H265 format is supported with NVDEC decoder. // NVIDIA Pascal and Turing or higher are recommended. // AMD Polaris and Vega or higher are recommended. - if (isColorDepth10 - && _mediaEncoder.SupportsHwaccel("opencl") - && options.EnableTonemapping - && !string.IsNullOrEmpty(videoStream.VideoRange) - && videoStream.VideoRange.Contains("HDR", StringComparison.OrdinalIgnoreCase)) + // Intel Kaby Lake or newer is required. + if (isTonemappingSupported) { var parameters = "tonemap_opencl=format=nv12:primaries=bt709:transfer=bt709:matrix=bt709:tonemap={0}:desat={1}:threshold={2}:peak={3}"; @@ -2152,10 +2621,54 @@ namespace MediaBrowser.Controller.MediaEncoding filters.Add("format=p010"); } - // Upload the HDR10 or HLG data to the OpenCL device, - // use tonemap_opencl filter for tone mapping, - // and then download the SDR data to memory. - filters.Add("hwupload"); + if ((isDeinterlaceH264 || isDeinterlaceHevc) && isNvdecDecoder) + { + isCudaDeintInAdvance = true; + filters.Add( + string.Format( + CultureInfo.InvariantCulture, + "yadif_cuda={0}:-1:0", + doubleRateDeinterlace ? "1" : "0")); + } + + if (isVaapiDecoder || isNvdecDecoder) + { + isScalingInAdvance = true; + filters.AddRange( + GetScalingFilters( + state, + options, + inputWidth, + inputHeight, + threeDFormat, + videoDecoder, + outputVideoCodec, + request.Width, + request.Height, + request.MaxWidth, + request.MaxHeight)); + } + + // hwmap the HDR data to opencl device by cl-va p010 interop. + if (isVaapiDecoder) + { + filters.Add("hwmap"); + } + + // convert cuda device data to p010 host data. + if (isNvdecDecoder) + { + filters.Add("hwdownload,format=p010"); + } + + if (isNvdecDecoder || isCuvidHevcDecoder || isSwDecoder || isD3d11vaDecoder) + { + // Upload the HDR10 or HLG data to the OpenCL device, + // use tonemap_opencl filter for tone mapping, + // and then download the SDR data to memory. + filters.Add("hwupload"); + } + filters.Add( string.Format( CultureInfo.InvariantCulture, @@ -2166,35 +2679,46 @@ namespace MediaBrowser.Controller.MediaEncoding options.TonemappingPeak, options.TonemappingParam, options.TonemappingRange)); - filters.Add("hwdownload"); - if (isLibX264Encoder - || hasGraphicalSubs - || (isNvdecHevcDecoder && isDeinterlaceHevc) - || (!isNvdecHevcDecoder && isDeinterlaceH264 || isDeinterlaceHevc)) + if (isNvdecDecoder || isCuvidHevcDecoder || isSwDecoder || isD3d11vaDecoder) { + filters.Add("hwdownload"); filters.Add("format=nv12"); } + + if (isNvdecDecoder && isNvencEncoder) + { + isHwuploadCudaRequired = true; + } + + if (isVaapiDecoder) + { + // Reverse the data route from opencl to vaapi. + filters.Add("hwmap=derive_device=vaapi:reverse=1"); + } } } - // When the input may or may not be hardware VAAPI decodable - if (isVaapiH264Encoder) + // When the input may or may not be hardware VAAPI decodable. + if ((isVaapiH264Encoder || isVaapiHevcEncoder) + && !(isTonemappingSupportedOnVaapi && (isTonemappingSupported || isVppTonemappingSupported))) { filters.Add("format=nv12|vaapi"); filters.Add("hwupload"); } - // When burning in graphical subtitles using overlay_qsv, upload videostream to the same qsv context - else if (isLinux && hasGraphicalSubs && isQsvH264Encoder) + // When burning in graphical subtitles using overlay_qsv, upload videostream to the same qsv context. + else if (isLinux && hasGraphicalSubs && (isQsvH264Encoder || isQsvHevcEncoder) + && !(isTonemappingSupportedOnQsv && isVppTonemappingSupported)) { filters.Add("hwupload=extra_hw_frames=64"); } - // If we're hardware VAAPI decoding and software encoding, download frames from the decoder first - else if (IsVaapiSupported(state) && isVaapiDecoder && isLibX264Encoder) + // If we're hardware VAAPI decoding and software encoding, download frames from the decoder first. + else if ((IsVaapiSupported(state) && isVaapiDecoder) && (isLibX264Encoder || isLibX265Encoder) + && !(isTonemappingSupportedOnQsv && isVppTonemappingSupported)) { - var codec = videoStream.Codec.ToLowerInvariant(); + var codec = videoStream.Codec; // Assert 10-bit hardware VAAPI decodable if (isColorDepth10 && (string.Equals(codec, "hevc", StringComparison.OrdinalIgnoreCase) @@ -2218,10 +2742,11 @@ namespace MediaBrowser.Controller.MediaEncoding } } - // Add hardware deinterlace filter before scaling filter - if (isDeinterlaceH264) + // Add hardware deinterlace filter before scaling filter. + if (isDeinterlaceH264 || isDeinterlaceHevc) { - if (isVaapiH264Encoder) + if (isVaapiEncoder + || (isTonemappingSupportedOnQsv && isVppTonemappingSupported)) { filters.Add( string.Format( @@ -2229,13 +2754,24 @@ namespace MediaBrowser.Controller.MediaEncoding "deinterlace_vaapi=rate={0}", doubleRateDeinterlace ? "field" : "frame")); } + else if (isNvdecDecoder && !isCudaDeintInAdvance) + { + filters.Add( + string.Format( + CultureInfo.InvariantCulture, + "yadif_cuda={0}:-1:0", + doubleRateDeinterlace ? "1" : "0")); + } } - // Add software deinterlace filter before scaling filter + // Add software deinterlace filter before scaling filter. if ((isDeinterlaceH264 || isDeinterlaceHevc) && !isVaapiH264Encoder + && !isVaapiHevcEncoder && !isQsvH264Encoder - && !isNvdecH264Decoder) + && !isQsvHevcEncoder + && !isNvdecDecoder + && !isCuvidH264Decoder) { if (string.Equals(options.DeinterlaceMethod, "bwdif", StringComparison.OrdinalIgnoreCase)) { @@ -2261,6 +2797,7 @@ namespace MediaBrowser.Controller.MediaEncoding filters.AddRange( GetScalingFilters( state, + options, inputWidth, inputHeight, threeDFormat, @@ -2272,19 +2809,86 @@ namespace MediaBrowser.Controller.MediaEncoding request.MaxHeight)); } + // Add VPP tonemapping filter for VAAPI. + // Full hardware based video post processing, faster than OpenCL but lacks fine tuning options. + if ((isTonemappingSupportedOnVaapi || isTonemappingSupportedOnQsv) + && isVppTonemappingSupported) + { + filters.Add("tonemap_vaapi=format=nv12:transfer=bt709:matrix=bt709:primaries=bt709"); + } + + // Another case is when using Nvenc decoder. + if (isNvdecDecoder && !isTonemappingSupported) + { + var codec = videoStream.Codec; + var isCudaFormatConversionSupported = _mediaEncoder.SupportsFilter("scale_cuda", "Output format (default \"same\")"); + + // Assert 10-bit hardware decodable + if (isColorDepth10 && (string.Equals(codec, "hevc", StringComparison.OrdinalIgnoreCase) + || string.Equals(codec, "h265", StringComparison.OrdinalIgnoreCase) + || string.Equals(codec, "vp9", StringComparison.OrdinalIgnoreCase))) + { + if (isCudaFormatConversionSupported) + { + if (isLibX264Encoder || isLibX265Encoder || hasSubs) + { + if (isNvencEncoder) + { + isHwuploadCudaRequired = true; + } + + filters.Add("hwdownload"); + filters.Add("format=nv12"); + } + } + else + { + // Download data from GPU to CPU as p010 format. + filters.Add("hwdownload"); + filters.Add("format=p010"); + + // Cuda lacks of a pixel format converter. + if (isNvencEncoder) + { + isHwuploadCudaRequired = true; + filters.Add("format=yuv420p"); + } + } + } + + // Assert 8-bit hardware decodable + else if (!isColorDepth10 && (isLibX264Encoder || isLibX265Encoder || hasSubs)) + { + if (isNvencEncoder) + { + isHwuploadCudaRequired = true; + } + + filters.Add("hwdownload"); + filters.Add("format=nv12"); + } + } + // Add parameters to use VAAPI with burn-in text subtitles (GH issue #642) - if (isVaapiH264Encoder) + if (isVaapiH264Encoder + || isVaapiHevcEncoder + || (isTonemappingSupportedOnQsv && isVppTonemappingSupported)) { if (hasTextSubs) { + // Convert hw context from ocl to va. + // For tonemapping and text subs burn-in. + if (isTonemappingSupportedOnVaapi && isTonemappingSupported && !isVppTonemappingSupported) + { + filters.Add("scale_vaapi"); + } + // Test passed on Intel and AMD gfx filters.Add("hwmap=mode=read+write"); filters.Add("format=nv12"); } } - var output = string.Empty; - if (hasTextSubs) { var subParam = GetTextSubtitleParam(state); @@ -2293,18 +2897,40 @@ namespace MediaBrowser.Controller.MediaEncoding // Ensure proper filters are passed to ffmpeg in case of hardware acceleration via VA-API // Reference: https://trac.ffmpeg.org/wiki/Hardware/VAAPI - if (isVaapiH264Encoder) + if (isVaapiH264Encoder || isVaapiHevcEncoder) { filters.Add("hwmap"); } + + if (isTonemappingSupportedOnQsv && isVppTonemappingSupported) + { + filters.Add("hwmap,format=vaapi"); + } + + if (isNvdecDecoder && isNvencEncoder) + { + isHwuploadCudaRequired = true; + } } + // Interop the VAAPI data to QSV for hybrid tonemapping + if (isTonemappingSupportedOnQsv && isVppTonemappingSupported && !hasGraphicalSubs) + { + filters.Add("hwmap=derive_device=qsv,scale_qsv"); + } + + if (isHwuploadCudaRequired && !hasGraphicalSubs) + { + filters.Add("hwupload_cuda"); + } + + var output = string.Empty; if (filters.Count > 0) { output += string.Format( CultureInfo.InvariantCulture, "{0}", - string.Join(",", filters)); + string.Join(',', filters)); } return output; @@ -2313,7 +2939,12 @@ namespace MediaBrowser.Controller.MediaEncoding /// <summary> /// Gets the number of threads. /// </summary> - public int GetNumberOfThreads(EncodingJobInfo state, EncodingOptions encodingOptions, string outputVideoCodec) + /// <param name="state">Encoding state.</param> + /// <param name="encodingOptions">Encoding options.</param> + /// <param name="outputVideoCodec">Video codec to use.</param> + /// <returns>Number of threads.</returns> +#nullable enable + public static int GetNumberOfThreads(EncodingJobInfo? state, EncodingOptions encodingOptions, string? outputVideoCodec) { if (string.Equals(outputVideoCodec, "libvpx", StringComparison.OrdinalIgnoreCase)) { @@ -2323,17 +2954,22 @@ namespace MediaBrowser.Controller.MediaEncoding return Math.Max(Environment.ProcessorCount - 1, 1); } - var threads = state.BaseRequest.CpuCoreLimit ?? encodingOptions.EncodingThreadCount; + var threads = state?.BaseRequest.CpuCoreLimit ?? encodingOptions.EncodingThreadCount; // Automatic - if (threads <= 0 || threads >= Environment.ProcessorCount) + if (threads <= 0) { return 0; } + else if (threads >= Environment.ProcessorCount) + { + return Environment.ProcessorCount; + } return threads; } +#nullable disable public void TryStreamCopy(EncodingJobInfo state) { if (state.VideoStream != null && CanStreamCopyVideo(state, state.VideoStream)) @@ -2368,18 +3004,10 @@ namespace MediaBrowser.Controller.MediaEncoding } } - public string GetProbeSizeArgument(int numInputFiles) - => numInputFiles > 1 ? "-probesize " + _configuration.GetFFmpegProbeSize() : string.Empty; - - public string GetAnalyzeDurationArgument(int numInputFiles) - => numInputFiles > 1 ? "-analyzeduration " + _configuration.GetFFmpegAnalyzeDuration() : string.Empty; - public string GetInputModifier(EncodingJobInfo state, EncodingOptions encodingOptions) { var inputModifier = string.Empty; - - var numInputFiles = state.PlayableStreamFileNames.Length > 0 ? state.PlayableStreamFileNames.Length : 1; - var probeSizeArgument = GetProbeSizeArgument(numInputFiles); + var probeSizeArgument = string.Empty; string analyzeDurationArgument; if (state.MediaSource.AnalyzeDurationMs.HasValue) @@ -2388,7 +3016,7 @@ namespace MediaBrowser.Controller.MediaEncoding } else { - analyzeDurationArgument = GetAnalyzeDurationArgument(numInputFiles); + analyzeDurationArgument = string.Empty; } if (!string.IsNullOrEmpty(probeSizeArgument)) @@ -2499,7 +3127,7 @@ namespace MediaBrowser.Controller.MediaEncoding { inputModifier += " -deint 1"; - if (!encodingOptions.DeinterlaceDoubleRate || (videoStream?.RealFrameRate ?? 60) > 30) + if (!encodingOptions.DeinterlaceDoubleRate || (videoStream?.AverageFrameRate ?? 60) > 30) { inputModifier += " -drop_second_field 1"; } @@ -2541,6 +3169,7 @@ namespace MediaBrowser.Controller.MediaEncoding public void AttachMediaSourceInfo( EncodingJobInfo state, + EncodingOptions encodingOptions, MediaSourceInfo mediaSource, string requestedUrl) { @@ -2571,32 +3200,6 @@ namespace MediaBrowser.Controller.MediaEncoding state.IsoType = mediaSource.IsoType; - if (mediaSource.VideoType.HasValue) - { - state.VideoType = mediaSource.VideoType.Value; - - if (mediaSource.VideoType.Value == VideoType.BluRay || mediaSource.VideoType.Value == VideoType.Dvd) - { - state.PlayableStreamFileNames = Video.QueryPlayableStreamFiles(state.MediaPath, mediaSource.VideoType.Value).Select(Path.GetFileName).ToArray(); - } - else if (mediaSource.VideoType.Value == VideoType.Iso && state.IsoType == IsoType.BluRay) - { - state.PlayableStreamFileNames = Video.QueryPlayableStreamFiles(state.MediaPath, VideoType.BluRay).Select(Path.GetFileName).ToArray(); - } - else if (mediaSource.VideoType.Value == VideoType.Iso && state.IsoType == IsoType.Dvd) - { - state.PlayableStreamFileNames = Video.QueryPlayableStreamFiles(state.MediaPath, VideoType.Dvd).Select(Path.GetFileName).ToArray(); - } - else - { - state.PlayableStreamFileNames = Array.Empty<string>(); - } - } - else - { - state.PlayableStreamFileNames = Array.Empty<string>(); - } - if (mediaSource.Timestamp.HasValue) { state.InputTimestamp = mediaSource.Timestamp.Value; @@ -2607,8 +3210,8 @@ namespace MediaBrowser.Controller.MediaEncoding state.ReadInputAtNativeFramerate = mediaSource.ReadAtNativeFramerate; if (state.ReadInputAtNativeFramerate - || mediaSource.Protocol == MediaProtocol.File - && string.Equals(mediaSource.Container, "wtv", StringComparison.OrdinalIgnoreCase)) + || (mediaSource.Protocol == MediaProtocol.File + && string.Equals(mediaSource.Container, "wtv", StringComparison.OrdinalIgnoreCase))) { state.InputVideoSync = "-1"; state.InputAudioSync = "1"; @@ -2659,9 +3262,10 @@ namespace MediaBrowser.Controller.MediaEncoding state.MediaSource = mediaSource; var request = state.BaseRequest; - if (!string.IsNullOrWhiteSpace(request.AudioCodec)) + var supportedAudioCodecs = state.SupportedAudioCodecs; + if (request != null && supportedAudioCodecs != null && supportedAudioCodecs.Length > 0) { - var supportedAudioCodecsList = request.AudioCodec.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries).ToList(); + var supportedAudioCodecsList = supportedAudioCodecs.ToList(); ShiftAudioCodecsIfNeeded(supportedAudioCodecsList, state.AudioStream); @@ -2670,11 +3274,23 @@ namespace MediaBrowser.Controller.MediaEncoding request.AudioCodec = state.SupportedAudioCodecs.FirstOrDefault(i => _mediaEncoder.CanEncodeToAudioCodec(i)) ?? state.SupportedAudioCodecs.FirstOrDefault(); } + + var supportedVideoCodecs = state.SupportedVideoCodecs; + if (request != null && supportedVideoCodecs != null && supportedVideoCodecs.Length > 0) + { + var supportedVideoCodecsList = supportedVideoCodecs.ToList(); + + ShiftVideoCodecsIfNeeded(supportedVideoCodecsList, encodingOptions); + + state.SupportedVideoCodecs = supportedVideoCodecsList.ToArray(); + + request.VideoCodec = state.SupportedVideoCodecs.FirstOrDefault(); + } } private void ShiftAudioCodecsIfNeeded(List<string> audioCodecs, MediaStream audioStream) { - // Nothing to do here + // No need to shift if there is only one supported audio codec. if (audioCodecs.Count < 2) { return; @@ -2702,6 +3318,34 @@ namespace MediaBrowser.Controller.MediaEncoding } } + private void ShiftVideoCodecsIfNeeded(List<string> videoCodecs, EncodingOptions encodingOptions) + { + // Shift hevc/h265 to the end of list if hevc encoding is not allowed. + if (encodingOptions.AllowHevcEncoding) + { + return; + } + + // No need to shift if there is only one supported video codec. + if (videoCodecs.Count < 2) + { + return; + } + + var shiftVideoCodecs = new[] { "hevc", "h265" }; + if (videoCodecs.All(i => shiftVideoCodecs.Contains(i, StringComparer.OrdinalIgnoreCase))) + { + return; + } + + while (shiftVideoCodecs.Contains(videoCodecs[0], StringComparer.OrdinalIgnoreCase)) + { + var removed = shiftVideoCodecs[0]; + videoCodecs.RemoveAt(0); + videoCodecs.Add(removed); + } + } + private void NormalizeSubtitleEmbed(EncodingJobInfo state) { if (state.SubtitleStream == null || state.SubtitleDeliveryMethod != SubtitleDeliveryMethod.Embed) @@ -2735,7 +3379,7 @@ namespace MediaBrowser.Controller.MediaEncoding var videoType = state.MediaSource.VideoType ?? VideoType.VideoFile; // Only use alternative encoders for video files. // When using concat with folder rips, if the mfx session fails to initialize, ffmpeg will be stuck retrying and will not exit gracefully - // Since transcoding of folder rips is expiremental anyway, it's not worth adding additional variables such as this. + // Since transcoding of folder rips is experimental anyway, it's not worth adding additional variables such as this. if (videoType != VideoType.VideoFile) { return null; @@ -2758,63 +3402,32 @@ namespace MediaBrowser.Controller.MediaEncoding return null; } + // Hybrid VPP tonemapping with VAAPI + if (string.Equals(encodingOptions.HardwareAccelerationType, "qsv", StringComparison.OrdinalIgnoreCase) + && IsVppTonemappingSupported(state, encodingOptions)) + { + // Since tonemap_vaapi only support HEVC for now, no need to check the codec again. + return GetHwaccelType(state, encodingOptions, "hevc", isColorDepth10); + } + if (string.Equals(encodingOptions.HardwareAccelerationType, "qsv", StringComparison.OrdinalIgnoreCase)) { switch (videoStream.Codec.ToLowerInvariant()) { case "avc": case "h264": - if (_mediaEncoder.SupportsDecoder("h264_qsv") && encodingOptions.HardwareDecodingCodecs.Contains("h264", StringComparer.OrdinalIgnoreCase)) - { - // qsv decoder does not support 10-bit input - if ((videoStream.BitDepth ?? 8) > 8) - { - encodingOptions.HardwareDecodingCodecs = Array.Empty<string>(); - return null; - } - - return "-c:v h264_qsv"; - } - - break; + return GetHwDecoderName(encodingOptions, "h264_qsv", "h264", isColorDepth10); case "hevc": case "h265": - if (_mediaEncoder.SupportsDecoder("hevc_qsv") && encodingOptions.HardwareDecodingCodecs.Contains("hevc", StringComparer.OrdinalIgnoreCase)) - { - return (isColorDepth10 && - !encodingOptions.EnableDecodingColorDepth10Hevc) ? null : "-c:v hevc_qsv"; - } - - break; + return GetHwDecoderName(encodingOptions, "hevc_qsv", "hevc", isColorDepth10); case "mpeg2video": - if (_mediaEncoder.SupportsDecoder("mpeg2_qsv") && encodingOptions.HardwareDecodingCodecs.Contains("mpeg2video", StringComparer.OrdinalIgnoreCase)) - { - return "-c:v mpeg2_qsv"; - } - - break; + return GetHwDecoderName(encodingOptions, "mpeg2_qsv", "mpeg2video", isColorDepth10); case "vc1": - if (_mediaEncoder.SupportsDecoder("vc1_qsv") && encodingOptions.HardwareDecodingCodecs.Contains("vc1", StringComparer.OrdinalIgnoreCase)) - { - return "-c:v vc1_qsv"; - } - - break; + return GetHwDecoderName(encodingOptions, "vc1_qsv", "vc1", isColorDepth10); case "vp8": - if (_mediaEncoder.SupportsDecoder("vp8_qsv") && encodingOptions.HardwareDecodingCodecs.Contains("vp8", StringComparer.OrdinalIgnoreCase)) - { - return "-c:v vp8_qsv"; - } - - break; + return GetHwDecoderName(encodingOptions, "vp8_qsv", "vp8", isColorDepth10); case "vp9": - if (_mediaEncoder.SupportsDecoder("vp9_qsv") && encodingOptions.HardwareDecodingCodecs.Contains("vp9", StringComparer.OrdinalIgnoreCase)) - { - return (isColorDepth10 && - !encodingOptions.EnableDecodingColorDepth10Vp9) ? null : "-c:v vp9_qsv"; - } - - break; + return GetHwDecoderName(encodingOptions, "vp9_qsv", "vp9", isColorDepth10); } } else if (string.Equals(encodingOptions.HardwareAccelerationType, "nvenc", StringComparison.OrdinalIgnoreCase)) @@ -2823,57 +3436,34 @@ namespace MediaBrowser.Controller.MediaEncoding { case "avc": case "h264": - if (_mediaEncoder.SupportsDecoder("h264_cuvid") && encodingOptions.HardwareDecodingCodecs.Contains("h264", StringComparer.OrdinalIgnoreCase)) - { - return "-c:v h264_cuvid"; - } - - break; + return encodingOptions.EnableEnhancedNvdecDecoder && IsCudaSupported() + ? GetHwaccelType(state, encodingOptions, "h264", isColorDepth10) + : GetHwDecoderName(encodingOptions, "h264_cuvid", "h264", isColorDepth10); case "hevc": case "h265": - if (_mediaEncoder.SupportsDecoder("hevc_cuvid") && encodingOptions.HardwareDecodingCodecs.Contains("hevc", StringComparer.OrdinalIgnoreCase)) - { - return (isColorDepth10 && - !encodingOptions.EnableDecodingColorDepth10Hevc) ? null : "-c:v hevc_cuvid"; - } - - break; + return encodingOptions.EnableEnhancedNvdecDecoder && IsCudaSupported() + ? GetHwaccelType(state, encodingOptions, "hevc", isColorDepth10) + : GetHwDecoderName(encodingOptions, "hevc_cuvid", "hevc", isColorDepth10); case "mpeg2video": - if (_mediaEncoder.SupportsDecoder("mpeg2_cuvid") && encodingOptions.HardwareDecodingCodecs.Contains("mpeg2video", StringComparer.OrdinalIgnoreCase)) - { - return "-c:v mpeg2_cuvid"; - } - - break; + return encodingOptions.EnableEnhancedNvdecDecoder && IsCudaSupported() + ? GetHwaccelType(state, encodingOptions, "mpeg2video", isColorDepth10) + : GetHwDecoderName(encodingOptions, "mpeg2_cuvid", "mpeg2video", isColorDepth10); case "vc1": - if (_mediaEncoder.SupportsDecoder("vc1_cuvid") && encodingOptions.HardwareDecodingCodecs.Contains("vc1", StringComparer.OrdinalIgnoreCase)) - { - return "-c:v vc1_cuvid"; - } - - break; + return encodingOptions.EnableEnhancedNvdecDecoder && IsCudaSupported() + ? GetHwaccelType(state, encodingOptions, "vc1", isColorDepth10) + : GetHwDecoderName(encodingOptions, "vc1_cuvid", "vc1", isColorDepth10); case "mpeg4": - if (_mediaEncoder.SupportsDecoder("mpeg4_cuvid") && encodingOptions.HardwareDecodingCodecs.Contains("mpeg4", StringComparer.OrdinalIgnoreCase)) - { - return "-c:v mpeg4_cuvid"; - } - - break; + return encodingOptions.EnableEnhancedNvdecDecoder && IsCudaSupported() + ? GetHwaccelType(state, encodingOptions, "mpeg4", isColorDepth10) + : GetHwDecoderName(encodingOptions, "mpeg4_cuvid", "mpeg4", isColorDepth10); case "vp8": - if (_mediaEncoder.SupportsDecoder("vp8_cuvid") && encodingOptions.HardwareDecodingCodecs.Contains("vp8", StringComparer.OrdinalIgnoreCase)) - { - return "-c:v vp8_cuvid"; - } - - break; + return encodingOptions.EnableEnhancedNvdecDecoder && IsCudaSupported() + ? GetHwaccelType(state, encodingOptions, "vp8", isColorDepth10) + : GetHwDecoderName(encodingOptions, "vp8_cuvid", "vp8", isColorDepth10); case "vp9": - if (_mediaEncoder.SupportsDecoder("vp9_cuvid") && encodingOptions.HardwareDecodingCodecs.Contains("vp9", StringComparer.OrdinalIgnoreCase)) - { - return (isColorDepth10 && - !encodingOptions.EnableDecodingColorDepth10Vp9) ? null : "-c:v vp9_cuvid"; - } - - break; + return encodingOptions.EnableEnhancedNvdecDecoder && IsCudaSupported() + ? GetHwaccelType(state, encodingOptions, "vp9", isColorDepth10) + : GetHwDecoderName(encodingOptions, "vp9_cuvid", "vp9", isColorDepth10); } } else if (string.Equals(encodingOptions.HardwareAccelerationType, "mediacodec", StringComparison.OrdinalIgnoreCase)) @@ -2882,50 +3472,18 @@ namespace MediaBrowser.Controller.MediaEncoding { case "avc": case "h264": - if (_mediaEncoder.SupportsDecoder("h264_mediacodec") && encodingOptions.HardwareDecodingCodecs.Contains("h264", StringComparer.OrdinalIgnoreCase)) - { - return "-c:v h264_mediacodec"; - } - - break; + return GetHwDecoderName(encodingOptions, "h264_mediacodec", "h264", isColorDepth10); case "hevc": case "h265": - if (_mediaEncoder.SupportsDecoder("hevc_mediacodec") && encodingOptions.HardwareDecodingCodecs.Contains("hevc", StringComparer.OrdinalIgnoreCase)) - { - return (isColorDepth10 && - !encodingOptions.EnableDecodingColorDepth10Hevc) ? null : "-c:v hevc_mediacodec"; - } - - break; + return GetHwDecoderName(encodingOptions, "hevc_mediacodec", "hevc", isColorDepth10); case "mpeg2video": - if (_mediaEncoder.SupportsDecoder("mpeg2_mediacodec") && encodingOptions.HardwareDecodingCodecs.Contains("mpeg2video", StringComparer.OrdinalIgnoreCase)) - { - return "-c:v mpeg2_mediacodec"; - } - - break; + return GetHwDecoderName(encodingOptions, "mpeg2_mediacodec", "mpeg2video", isColorDepth10); case "mpeg4": - if (_mediaEncoder.SupportsDecoder("mpeg4_mediacodec") && encodingOptions.HardwareDecodingCodecs.Contains("mpeg4", StringComparer.OrdinalIgnoreCase)) - { - return "-c:v mpeg4_mediacodec"; - } - - break; + return GetHwDecoderName(encodingOptions, "mpeg4_mediacodec", "mpeg4", isColorDepth10); case "vp8": - if (_mediaEncoder.SupportsDecoder("vp8_mediacodec") && encodingOptions.HardwareDecodingCodecs.Contains("vp8", StringComparer.OrdinalIgnoreCase)) - { - return "-c:v vp8_mediacodec"; - } - - break; + return GetHwDecoderName(encodingOptions, "vp8_mediacodec", "vp8", isColorDepth10); case "vp9": - if (_mediaEncoder.SupportsDecoder("vp9_mediacodec") && encodingOptions.HardwareDecodingCodecs.Contains("vp9", StringComparer.OrdinalIgnoreCase)) - { - return (isColorDepth10 && - !encodingOptions.EnableDecodingColorDepth10Vp9) ? null : "-c:v vp9_mediacodec"; - } - - break; + return GetHwDecoderName(encodingOptions, "vp9_mediacodec", "vp9", isColorDepth10); } } else if (string.Equals(encodingOptions.HardwareAccelerationType, "omx", StringComparison.OrdinalIgnoreCase)) @@ -2934,33 +3492,13 @@ namespace MediaBrowser.Controller.MediaEncoding { case "avc": case "h264": - if (_mediaEncoder.SupportsDecoder("h264_mmal") && encodingOptions.HardwareDecodingCodecs.Contains("h264", StringComparer.OrdinalIgnoreCase)) - { - return "-c:v h264_mmal"; - } - - break; + return GetHwDecoderName(encodingOptions, "h264_mmal", "h264", isColorDepth10); case "mpeg2video": - if (_mediaEncoder.SupportsDecoder("mpeg2_mmal") && encodingOptions.HardwareDecodingCodecs.Contains("mpeg2video", StringComparer.OrdinalIgnoreCase)) - { - return "-c:v mpeg2_mmal"; - } - - break; + return GetHwDecoderName(encodingOptions, "mpeg2_mmal", "mpeg2video", isColorDepth10); case "mpeg4": - if (_mediaEncoder.SupportsDecoder("mpeg4_mmal") && encodingOptions.HardwareDecodingCodecs.Contains("mpeg4", StringComparer.OrdinalIgnoreCase)) - { - return "-c:v mpeg4_mmal"; - } - - break; + return GetHwDecoderName(encodingOptions, "mpeg4_mmal", "mpeg4", isColorDepth10); case "vc1": - if (_mediaEncoder.SupportsDecoder("vc1_mmal") && encodingOptions.HardwareDecodingCodecs.Contains("vc1", StringComparer.OrdinalIgnoreCase)) - { - return "-c:v vc1_mmal"; - } - - break; + return GetHwDecoderName(encodingOptions, "vc1_mmal", "vc1", isColorDepth10); } } else if (string.Equals(encodingOptions.HardwareAccelerationType, "amf", StringComparison.OrdinalIgnoreCase)) @@ -2969,20 +3507,18 @@ namespace MediaBrowser.Controller.MediaEncoding { case "avc": case "h264": - return GetHwaccelType(state, encodingOptions, "h264"); + return GetHwaccelType(state, encodingOptions, "h264", isColorDepth10); case "hevc": case "h265": - return (isColorDepth10 && - !encodingOptions.EnableDecodingColorDepth10Hevc) ? null : GetHwaccelType(state, encodingOptions, "hevc"); + return GetHwaccelType(state, encodingOptions, "hevc", isColorDepth10); case "mpeg2video": - return GetHwaccelType(state, encodingOptions, "mpeg2video"); + return GetHwaccelType(state, encodingOptions, "mpeg2video", isColorDepth10); case "vc1": - return GetHwaccelType(state, encodingOptions, "vc1"); + return GetHwaccelType(state, encodingOptions, "vc1", isColorDepth10); case "mpeg4": - return GetHwaccelType(state, encodingOptions, "mpeg4"); + return GetHwaccelType(state, encodingOptions, "mpeg4", isColorDepth10); case "vp9": - return (isColorDepth10 && - !encodingOptions.EnableDecodingColorDepth10Vp9) ? null : GetHwaccelType(state, encodingOptions, "vp9"); + return GetHwaccelType(state, encodingOptions, "vp9", isColorDepth10); } } else if (string.Equals(encodingOptions.HardwareAccelerationType, "vaapi", StringComparison.OrdinalIgnoreCase)) @@ -2991,20 +3527,18 @@ namespace MediaBrowser.Controller.MediaEncoding { case "avc": case "h264": - return GetHwaccelType(state, encodingOptions, "h264"); + return GetHwaccelType(state, encodingOptions, "h264", isColorDepth10); case "hevc": case "h265": - return (isColorDepth10 && - !encodingOptions.EnableDecodingColorDepth10Hevc) ? null : GetHwaccelType(state, encodingOptions, "hevc"); + return GetHwaccelType(state, encodingOptions, "hevc", isColorDepth10); case "mpeg2video": - return GetHwaccelType(state, encodingOptions, "mpeg2video"); + return GetHwaccelType(state, encodingOptions, "mpeg2video", isColorDepth10); case "vc1": - return GetHwaccelType(state, encodingOptions, "vc1"); + return GetHwaccelType(state, encodingOptions, "vc1", isColorDepth10); case "vp8": - return GetHwaccelType(state, encodingOptions, "vp8"); + return GetHwaccelType(state, encodingOptions, "vp8", isColorDepth10); case "vp9": - return (isColorDepth10 && - !encodingOptions.EnableDecodingColorDepth10Vp9) ? null : GetHwaccelType(state, encodingOptions, "vp9"); + return GetHwaccelType(state, encodingOptions, "vp9", isColorDepth10); } } else if (string.Equals(encodingOptions.HardwareAccelerationType, "videotoolbox", StringComparison.OrdinalIgnoreCase)) @@ -3013,62 +3547,25 @@ namespace MediaBrowser.Controller.MediaEncoding { case "avc": case "h264": - if (_mediaEncoder.SupportsDecoder("h264_opencl") && encodingOptions.HardwareDecodingCodecs.Contains("h264", StringComparer.OrdinalIgnoreCase)) - { - return "-c:v h264_opencl"; - } - - break; + return GetHwDecoderName(encodingOptions, "h264_opencl", "h264", isColorDepth10); case "hevc": case "h265": - if (_mediaEncoder.SupportsDecoder("hevc_opencl") && encodingOptions.HardwareDecodingCodecs.Contains("h264", StringComparer.OrdinalIgnoreCase)) - { - return (isColorDepth10 && - !encodingOptions.EnableDecodingColorDepth10Hevc) ? null : "-c:v hevc_opencl"; - } - - break; + return GetHwDecoderName(encodingOptions, "hevc_opencl", "hevc", isColorDepth10); case "mpeg2video": - if (_mediaEncoder.SupportsDecoder("mpeg2_opencl") && encodingOptions.HardwareDecodingCodecs.Contains("mpeg2video", StringComparer.OrdinalIgnoreCase)) - { - return "-c:v mpeg2_opencl"; - } - - break; + return GetHwDecoderName(encodingOptions, "mpeg2_opencl", "mpeg2video", isColorDepth10); case "mpeg4": - if (_mediaEncoder.SupportsDecoder("mpeg4_opencl") && encodingOptions.HardwareDecodingCodecs.Contains("mpeg4", StringComparer.OrdinalIgnoreCase)) - { - return "-c:v mpeg4_opencl"; - } - - break; + return GetHwDecoderName(encodingOptions, "mpeg4_opencl", "mpeg4", isColorDepth10); case "vc1": - if (_mediaEncoder.SupportsDecoder("vc1_opencl") && encodingOptions.HardwareDecodingCodecs.Contains("vc1", StringComparer.OrdinalIgnoreCase)) - { - return "-c:v vc1_opencl"; - } - - break; + return GetHwDecoderName(encodingOptions, "vc1_opencl", "vc1", isColorDepth10); case "vp8": - if (_mediaEncoder.SupportsDecoder("vp8_opencl") && encodingOptions.HardwareDecodingCodecs.Contains("vc1", StringComparer.OrdinalIgnoreCase)) - { - return "-c:v vp8_opencl"; - } - - break; + return GetHwDecoderName(encodingOptions, "vp8_opencl", "vp8", isColorDepth10); case "vp9": - if (_mediaEncoder.SupportsDecoder("vp9_opencl") && encodingOptions.HardwareDecodingCodecs.Contains("vc1", StringComparer.OrdinalIgnoreCase)) - { - return (isColorDepth10 && - !encodingOptions.EnableDecodingColorDepth10Vp9) ? null : "-c:v vp9_opencl"; - } - - break; + return GetHwDecoderName(encodingOptions, "vp9_opencl", "vp9", isColorDepth10); } } } - var whichCodec = videoStream.Codec.ToLowerInvariant(); + var whichCodec = videoStream.Codec?.ToLowerInvariant(); switch (whichCodec) { case "avc": @@ -3087,14 +3584,52 @@ namespace MediaBrowser.Controller.MediaEncoding } /// <summary> - /// Gets a hwaccel type to use as a hardware decoder(dxva/vaapi) depending on the system + /// Gets a hw decoder name. /// </summary> - public string GetHwaccelType(EncodingJobInfo state, EncodingOptions options, string videoCodec) + /// <param name="options">Encoding options.</param> + /// <param name="decoder">Decoder to use.</param> + /// <param name="videoCodec">Video codec to use.</param> + /// <param name="isColorDepth10">Specifies if color depth 10.</param> + /// <returns>Hardware decoder name.</returns> + public string GetHwDecoderName(EncodingOptions options, string decoder, string videoCodec, bool isColorDepth10) { - var isWindows = RuntimeInformation.IsOSPlatform(OSPlatform.Windows); - var isLinux = RuntimeInformation.IsOSPlatform(OSPlatform.Linux); + var isCodecAvailable = _mediaEncoder.SupportsDecoder(decoder) && options.HardwareDecodingCodecs.Contains(videoCodec, StringComparer.OrdinalIgnoreCase); + if (isColorDepth10 && isCodecAvailable) + { + if ((options.HardwareDecodingCodecs.Contains("hevc", StringComparer.OrdinalIgnoreCase) && !options.EnableDecodingColorDepth10Hevc) + || (options.HardwareDecodingCodecs.Contains("vp9", StringComparer.OrdinalIgnoreCase) && !options.EnableDecodingColorDepth10Vp9)) + { + return null; + } + } + + return isCodecAvailable ? ("-c:v " + decoder) : null; + } + + /// <summary> + /// Gets a hwaccel type to use as a hardware decoder(dxva/vaapi) depending on the system. + /// </summary> + /// <param name="state">Encoding state.</param> + /// <param name="options">Encoding options.</param> + /// <param name="videoCodec">Video codec to use.</param> + /// <param name="isColorDepth10">Specifies if color depth 10.</param> + /// <returns>Hardware accelerator type.</returns> + public string GetHwaccelType(EncodingJobInfo state, EncodingOptions options, string videoCodec, bool isColorDepth10) + { + var isWindows = OperatingSystem.IsWindows(); + var isLinux = OperatingSystem.IsLinux(); var isWindows8orLater = Environment.OSVersion.Version.Major > 6 || (Environment.OSVersion.Version.Major == 6 && Environment.OSVersion.Version.Minor > 1); var isDxvaSupported = _mediaEncoder.SupportsHwaccel("dxva2") || _mediaEncoder.SupportsHwaccel("d3d11va"); + var isCodecAvailable = options.HardwareDecodingCodecs.Contains(videoCodec, StringComparer.OrdinalIgnoreCase); + + if (isColorDepth10 && isCodecAvailable) + { + if ((options.HardwareDecodingCodecs.Contains("hevc", StringComparer.OrdinalIgnoreCase) && !options.EnableDecodingColorDepth10Hevc) + || (options.HardwareDecodingCodecs.Contains("vp9", StringComparer.OrdinalIgnoreCase) && !options.EnableDecodingColorDepth10Vp9)) + { + return null; + } + } if (string.Equals(options.HardwareAccelerationType, "amf", StringComparison.OrdinalIgnoreCase)) { @@ -3113,7 +3648,9 @@ namespace MediaBrowser.Controller.MediaEncoding } } - if (string.Equals(options.HardwareAccelerationType, "vaapi", StringComparison.OrdinalIgnoreCase)) + if (string.Equals(options.HardwareAccelerationType, "vaapi", StringComparison.OrdinalIgnoreCase) + || (string.Equals(options.HardwareAccelerationType, "qsv", StringComparison.OrdinalIgnoreCase) + && IsVppTonemappingSupported(state, options))) { if (IsVaapiSupported(state) && options.HardwareDecodingCodecs.Contains(videoCodec, StringComparer.OrdinalIgnoreCase)) { @@ -3124,6 +3661,14 @@ namespace MediaBrowser.Controller.MediaEncoding } } + if (string.Equals(options.HardwareAccelerationType, "nvenc", StringComparison.OrdinalIgnoreCase)) + { + if (options.HardwareDecodingCodecs.Contains(videoCodec, StringComparer.OrdinalIgnoreCase)) + { + return "-hwaccel cuda"; + } + } + return null; } @@ -3193,7 +3738,7 @@ namespace MediaBrowser.Controller.MediaEncoding if (flags.Count > 0) { - return " -fflags " + string.Join("", flags); + return " -fflags " + string.Join(string.Empty, flags); } return string.Empty; @@ -3335,7 +3880,7 @@ namespace MediaBrowser.Controller.MediaEncoding args += " -ar " + state.OutputAudioSampleRate.Value.ToString(_usCulture); } - args += " " + GetAudioFilterParam(state, encodingOptions, false); + args += GetAudioFilterParam(state, encodingOptions); return args; } @@ -3376,7 +3921,7 @@ namespace MediaBrowser.Controller.MediaEncoding GetInputArgument(state, encodingOptions), threads, " -vn", - string.Join(" ", audioTranscodeParams), + string.Join(' ', audioTranscodeParams), outputPath, string.Empty, string.Empty, diff --git a/MediaBrowser.Controller/MediaEncoding/EncodingJobInfo.cs b/MediaBrowser.Controller/MediaEncoding/EncodingJobInfo.cs index 6cd0c70d2f..fa9f40d60d 100644 --- a/MediaBrowser.Controller/MediaEncoding/EncodingJobInfo.cs +++ b/MediaBrowser.Controller/MediaEncoding/EncodingJobInfo.cs @@ -1,4 +1,6 @@ -#pragma warning disable CS1591 +#nullable disable + +#pragma warning disable CS1591, SA1401 using System; using System.Collections.Generic; @@ -9,7 +11,6 @@ using MediaBrowser.Model.Dlna; using MediaBrowser.Model.Drawing; using MediaBrowser.Model.Dto; using MediaBrowser.Model.Entities; -using MediaBrowser.Model.IO; using MediaBrowser.Model.MediaInfo; using MediaBrowser.Model.Net; using MediaBrowser.Model.Session; @@ -19,6 +20,44 @@ namespace MediaBrowser.Controller.MediaEncoding // For now, a common base class until the API and MediaEncoding classes are unified public class EncodingJobInfo { + public int? OutputAudioBitrate; + public int? OutputAudioChannels; + + private TranscodeReason[] _transcodeReasons = null; + + public EncodingJobInfo(TranscodingJobType jobType) + { + TranscodingType = jobType; + RemoteHttpHeaders = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase); + SupportedAudioCodecs = Array.Empty<string>(); + SupportedVideoCodecs = Array.Empty<string>(); + SupportedSubtitleCodecs = Array.Empty<string>(); + } + + public TranscodeReason[] TranscodeReasons + { + get + { + if (_transcodeReasons == null) + { + if (BaseRequest.TranscodeReasons == null) + { + return Array.Empty<TranscodeReason>(); + } + + _transcodeReasons = BaseRequest.TranscodeReasons + .Split(',') + .Where(i => !string.IsNullOrEmpty(i)) + .Select(v => (TranscodeReason)Enum.Parse(typeof(TranscodeReason), v, true)) + .ToArray(); + } + + return _transcodeReasons; + } + } + + public IProgress<double> Progress { get; set; } + public MediaStream VideoStream { get; set; } public VideoType VideoType { get; set; } @@ -33,10 +72,6 @@ namespace MediaBrowser.Controller.MediaEncoding public bool IsInputVideo { get; set; } - public IIsoMount IsoMount { get; set; } - - public string[] PlayableStreamFileNames { get; set; } - public string OutputAudioCodec { get; set; } public int? OutputVideoBitrate { get; set; } @@ -61,39 +96,6 @@ namespace MediaBrowser.Controller.MediaEncoding public string MimeType { get; set; } - public string GetMimeType(string outputPath, bool enableStreamDefault = true) - { - if (!string.IsNullOrEmpty(MimeType)) - { - return MimeType; - } - - return MimeTypes.GetMimeType(outputPath, enableStreamDefault); - } - - private TranscodeReason[] _transcodeReasons = null; - public TranscodeReason[] TranscodeReasons - { - get - { - if (_transcodeReasons == null) - { - if (BaseRequest.TranscodeReasons == null) - { - return Array.Empty<TranscodeReason>(); - } - - _transcodeReasons = BaseRequest.TranscodeReasons - .Split(',') - .Where(i => !string.IsNullOrEmpty(i)) - .Select(v => (TranscodeReason)Enum.Parse(typeof(TranscodeReason), v, true)) - .ToArray(); - } - - return _transcodeReasons; - } - } - public bool IgnoreInputDts => MediaSource.IgnoreDts; public bool IgnoreInputIndex => MediaSource.IgnoreIndex; @@ -146,192 +148,17 @@ namespace MediaBrowser.Controller.MediaEncoding public BaseEncodingJobOptions BaseRequest { get; set; } - public long? StartTimeTicks => BaseRequest.StartTimeTicks; - - public bool CopyTimestamps => BaseRequest.CopyTimestamps; - - public int? OutputAudioBitrate; - public int? OutputAudioChannels; - - public bool DeInterlace(string videoCodec, bool forceDeinterlaceIfSourceIsInterlaced) - { - var videoStream = VideoStream; - var isInputInterlaced = videoStream != null && videoStream.IsInterlaced; - - if (!isInputInterlaced) - { - return false; - } - - // Support general param - if (BaseRequest.DeInterlace) - { - return true; - } - - if (!string.IsNullOrEmpty(videoCodec)) - { - if (string.Equals(BaseRequest.GetOption(videoCodec, "deinterlace"), "true", StringComparison.OrdinalIgnoreCase)) - { - return true; - } - } - - return forceDeinterlaceIfSourceIsInterlaced && isInputInterlaced; - } - - public string[] GetRequestedProfiles(string codec) - { - if (!string.IsNullOrEmpty(BaseRequest.Profile)) - { - return BaseRequest.Profile.Split(new[] { '|', ',' }, StringSplitOptions.RemoveEmptyEntries); - } - - if (!string.IsNullOrEmpty(codec)) - { - var profile = BaseRequest.GetOption(codec, "profile"); - - if (!string.IsNullOrEmpty(profile)) - { - return profile.Split(new[] { '|', ',' }, StringSplitOptions.RemoveEmptyEntries); - } - } - - return Array.Empty<string>(); - } - - public string GetRequestedLevel(string codec) - { - if (!string.IsNullOrEmpty(BaseRequest.Level)) - { - return BaseRequest.Level; - } - - if (!string.IsNullOrEmpty(codec)) - { - return BaseRequest.GetOption(codec, "level"); - } - - return null; - } - - public int? GetRequestedMaxRefFrames(string codec) - { - if (BaseRequest.MaxRefFrames.HasValue) - { - return BaseRequest.MaxRefFrames; - } - - if (!string.IsNullOrEmpty(codec)) - { - var value = BaseRequest.GetOption(codec, "maxrefframes"); - if (!string.IsNullOrEmpty(value) - && int.TryParse(value, NumberStyles.Any, CultureInfo.InvariantCulture, out var result)) - { - return result; - } - } - - return null; - } - - public int? GetRequestedVideoBitDepth(string codec) - { - if (BaseRequest.MaxVideoBitDepth.HasValue) - { - return BaseRequest.MaxVideoBitDepth; - } - - if (!string.IsNullOrEmpty(codec)) - { - var value = BaseRequest.GetOption(codec, "videobitdepth"); - if (!string.IsNullOrEmpty(value) - && int.TryParse(value, NumberStyles.Any, CultureInfo.InvariantCulture, out var result)) - { - return result; - } - } - - return null; - } - - public int? GetRequestedAudioBitDepth(string codec) - { - if (BaseRequest.MaxAudioBitDepth.HasValue) - { - return BaseRequest.MaxAudioBitDepth; - } - - if (!string.IsNullOrEmpty(codec)) - { - var value = BaseRequest.GetOption(codec, "audiobitdepth"); - if (!string.IsNullOrEmpty(value) - && int.TryParse(value, NumberStyles.Any, CultureInfo.InvariantCulture, out var result)) - { - return result; - } - } - - return null; - } - - public int? GetRequestedAudioChannels(string codec) - { - if (BaseRequest.MaxAudioChannels.HasValue) - { - return BaseRequest.MaxAudioChannels; - } - - if (BaseRequest.AudioChannels.HasValue) - { - return BaseRequest.AudioChannels; - } - - if (!string.IsNullOrEmpty(codec)) - { - var value = BaseRequest.GetOption(codec, "audiochannels"); - if (!string.IsNullOrEmpty(value) - && int.TryParse(value, NumberStyles.Any, CultureInfo.InvariantCulture, out var result)) - { - return result; - } - } - - return null; - } - public bool IsVideoRequest { get; set; } public TranscodingJobType TranscodingType { get; set; } - public EncodingJobInfo(TranscodingJobType jobType) - { - TranscodingType = jobType; - RemoteHttpHeaders = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase); - PlayableStreamFileNames = Array.Empty<string>(); - SupportedAudioCodecs = Array.Empty<string>(); - SupportedVideoCodecs = Array.Empty<string>(); - SupportedSubtitleCodecs = Array.Empty<string>(); - } + public long? StartTimeTicks => BaseRequest.StartTimeTicks; + + public bool CopyTimestamps => BaseRequest.CopyTimestamps; public bool IsSegmentedLiveStream => TranscodingType != TranscodingJobType.Progressive && !RunTimeTicks.HasValue; - public bool EnableBreakOnNonKeyFrames(string videoCodec) - { - if (TranscodingType != TranscodingJobType.Progressive) - { - if (IsSegmentedLiveStream) - { - return false; - } - - return BaseRequest.BreakOnNonKeyFrames && EncodingHelper.IsCopyCodec(videoCodec); - } - - return false; - } - public int? TotalOutputBitrate => (OutputAudioBitrate ?? 0) + (OutputVideoBitrate ?? 0); public int? OutputWidth @@ -404,7 +231,7 @@ namespace MediaBrowser.Controller.MediaEncoding { // Don't exceed what the encoder supports // Seeing issues of attempting to encode to 88200 - return Math.Min(44100, BaseRequest.AudioSampleRate.Value); + return BaseRequest.AudioSampleRate.Value; } return null; @@ -429,7 +256,7 @@ namespace MediaBrowser.Controller.MediaEncoding } /// <summary> - /// Predicts the audio sample rate that will be in the output stream. + /// Gets the target video level. /// </summary> public double? TargetVideoLevel { @@ -452,7 +279,7 @@ namespace MediaBrowser.Controller.MediaEncoding } /// <summary> - /// Predicts the audio sample rate that will be in the output stream. + /// Gets the target video bit depth. /// </summary> public int? TargetVideoBitDepth { @@ -487,7 +314,7 @@ namespace MediaBrowser.Controller.MediaEncoding } /// <summary> - /// Predicts the audio sample rate that will be in the output stream. + /// Gets the target framerate. /// </summary> public float? TargetFramerate { @@ -519,7 +346,7 @@ namespace MediaBrowser.Controller.MediaEncoding } /// <summary> - /// Predicts the audio sample rate that will be in the output stream. + /// Gets the target packet length. /// </summary> public int? TargetPacketLength { @@ -535,7 +362,7 @@ namespace MediaBrowser.Controller.MediaEncoding } /// <summary> - /// Predicts the audio sample rate that will be in the output stream. + /// Gets the target video profile. /// </summary> public string TargetVideoProfile { @@ -588,6 +415,11 @@ namespace MediaBrowser.Controller.MediaEncoding { get { + if (VideoStream == null) + { + return null; + } + if (EncodingHelper.IsCopyCodec(OutputVideoCodec)) { return VideoStream?.Codec; @@ -601,6 +433,11 @@ namespace MediaBrowser.Controller.MediaEncoding { get { + if (AudioStream == null) + { + return null; + } + if (EncodingHelper.IsCopyCodec(OutputAudioCodec)) { return AudioStream?.Codec; @@ -670,6 +507,21 @@ namespace MediaBrowser.Controller.MediaEncoding public int HlsListSize => 0; + public bool EnableBreakOnNonKeyFrames(string videoCodec) + { + if (TranscodingType != TranscodingJobType.Progressive) + { + if (IsSegmentedLiveStream) + { + return false; + } + + return BaseRequest.BreakOnNonKeyFrames && EncodingHelper.IsCopyCodec(videoCodec); + } + + return false; + } + private int? GetMediaStreamCount(MediaStreamType type, int limit) { var count = MediaSource.GetStreamCount(type); @@ -682,32 +534,171 @@ namespace MediaBrowser.Controller.MediaEncoding return count; } - public IProgress<double> Progress { get; set; } + public string GetMimeType(string outputPath, bool enableStreamDefault = true) + { + if (!string.IsNullOrEmpty(MimeType)) + { + return MimeType; + } - public virtual void ReportTranscodingProgress(TimeSpan? transcodingPosition, float? framerate, double? percentComplete, long? bytesTranscoded, int? bitRate) + return MimeTypes.GetMimeType(outputPath, enableStreamDefault); + } + + public bool DeInterlace(string videoCodec, bool forceDeinterlaceIfSourceIsInterlaced) { - Progress.Report(percentComplete.Value); + var videoStream = VideoStream; + var isInputInterlaced = videoStream != null && videoStream.IsInterlaced; + + if (!isInputInterlaced) + { + return false; + } + + // Support general param + if (BaseRequest.DeInterlace) + { + return true; + } + + if (!string.IsNullOrEmpty(videoCodec)) + { + if (string.Equals(BaseRequest.GetOption(videoCodec, "deinterlace"), "true", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + + return forceDeinterlaceIfSourceIsInterlaced && isInputInterlaced; } - } - /// <summary> - /// Enum TranscodingJobType. - /// </summary> - public enum TranscodingJobType - { - /// <summary> - /// The progressive. - /// </summary> - Progressive, + public string[] GetRequestedProfiles(string codec) + { + if (!string.IsNullOrEmpty(BaseRequest.Profile)) + { + return BaseRequest.Profile.Split(new[] { '|', ',' }, StringSplitOptions.RemoveEmptyEntries); + } - /// <summary> - /// The HLS. - /// </summary> - Hls, + if (!string.IsNullOrEmpty(codec)) + { + var profile = BaseRequest.GetOption(codec, "profile"); - /// <summary> - /// The dash. - /// </summary> - Dash + if (!string.IsNullOrEmpty(profile)) + { + return profile.Split(new[] { '|', ',' }, StringSplitOptions.RemoveEmptyEntries); + } + } + + return Array.Empty<string>(); + } + + public string GetRequestedLevel(string codec) + { + if (!string.IsNullOrEmpty(BaseRequest.Level)) + { + return BaseRequest.Level; + } + + if (!string.IsNullOrEmpty(codec)) + { + return BaseRequest.GetOption(codec, "level"); + } + + return null; + } + + public int? GetRequestedMaxRefFrames(string codec) + { + if (BaseRequest.MaxRefFrames.HasValue) + { + return BaseRequest.MaxRefFrames; + } + + if (!string.IsNullOrEmpty(codec)) + { + var value = BaseRequest.GetOption(codec, "maxrefframes"); + if (!string.IsNullOrEmpty(value) + && int.TryParse(value, NumberStyles.Any, CultureInfo.InvariantCulture, out var result)) + { + return result; + } + } + + return null; + } + + public int? GetRequestedVideoBitDepth(string codec) + { + if (BaseRequest.MaxVideoBitDepth.HasValue) + { + return BaseRequest.MaxVideoBitDepth; + } + + if (!string.IsNullOrEmpty(codec)) + { + var value = BaseRequest.GetOption(codec, "videobitdepth"); + if (!string.IsNullOrEmpty(value) + && int.TryParse(value, NumberStyles.Any, CultureInfo.InvariantCulture, out var result)) + { + return result; + } + } + + return null; + } + + public int? GetRequestedAudioBitDepth(string codec) + { + if (BaseRequest.MaxAudioBitDepth.HasValue) + { + return BaseRequest.MaxAudioBitDepth; + } + + if (!string.IsNullOrEmpty(codec)) + { + var value = BaseRequest.GetOption(codec, "audiobitdepth"); + if (!string.IsNullOrEmpty(value) + && int.TryParse(value, NumberStyles.Any, CultureInfo.InvariantCulture, out var result)) + { + return result; + } + } + + return null; + } + + public int? GetRequestedAudioChannels(string codec) + { + if (!string.IsNullOrEmpty(codec)) + { + var value = BaseRequest.GetOption(codec, "audiochannels"); + if (!string.IsNullOrEmpty(value) + && int.TryParse(value, NumberStyles.Any, CultureInfo.InvariantCulture, out var result)) + { + return result; + } + } + + if (BaseRequest.MaxAudioChannels.HasValue) + { + return BaseRequest.MaxAudioChannels; + } + + if (BaseRequest.AudioChannels.HasValue) + { + return BaseRequest.AudioChannels; + } + + if (BaseRequest.TranscodingMaxAudioChannels.HasValue) + { + return BaseRequest.TranscodingMaxAudioChannels; + } + + return null; + } + + public virtual void ReportTranscodingProgress(TimeSpan? transcodingPosition, float? framerate, double? percentComplete, long? bytesTranscoded, int? bitRate) + { + Progress.Report(percentComplete.Value); + } } } diff --git a/MediaBrowser.Controller/MediaEncoding/IAttachmentExtractor.cs b/MediaBrowser.Controller/MediaEncoding/IAttachmentExtractor.cs index fbc8275341..c38e7ec3b3 100644 --- a/MediaBrowser.Controller/MediaEncoding/IAttachmentExtractor.cs +++ b/MediaBrowser.Controller/MediaEncoding/IAttachmentExtractor.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System.IO; diff --git a/MediaBrowser.Controller/MediaEncoding/IEncodingManager.cs b/MediaBrowser.Controller/MediaEncoding/IEncodingManager.cs index 15a2580afd..8ce40a58d1 100644 --- a/MediaBrowser.Controller/MediaEncoding/IEncodingManager.cs +++ b/MediaBrowser.Controller/MediaEncoding/IEncodingManager.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System.Collections.Generic; @@ -14,6 +16,13 @@ namespace MediaBrowser.Controller.MediaEncoding /// <summary> /// Refreshes the chapter images. /// </summary> + /// <param name="video">Video to use.</param> + /// <param name="directoryService">Directory service to use.</param> + /// <param name="chapters">Set of chapters to refresh.</param> + /// <param name="extractImages">Option to extract images.</param> + /// <param name="saveChapters">Option to save chapters.</param> + /// <param name="cancellationToken">CancellationToken to use for operation.</param> + /// <returns><c>true</c> if successful, <c>false</c> if not.</returns> Task<bool> RefreshChapterImages(Video video, IDirectoryService directoryService, IReadOnlyList<ChapterInfo> chapters, bool extractImages, bool saveChapters, CancellationToken cancellationToken); } } diff --git a/MediaBrowser.Controller/MediaEncoding/IMediaEncoder.cs b/MediaBrowser.Controller/MediaEncoding/IMediaEncoder.cs index f6bc1f4de9..ff24560703 100644 --- a/MediaBrowser.Controller/MediaEncoding/IMediaEncoder.cs +++ b/MediaBrowser.Controller/MediaEncoding/IMediaEncoder.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System; @@ -5,8 +7,8 @@ using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; using MediaBrowser.Model.Dlna; +using MediaBrowser.Model.Dto; using MediaBrowser.Model.Entities; -using MediaBrowser.Model.IO; using MediaBrowser.Model.MediaInfo; using MediaBrowser.Model.System; @@ -18,7 +20,7 @@ namespace MediaBrowser.Controller.MediaEncoding public interface IMediaEncoder : ITranscoderSupport { /// <summary> - /// The location of the discovered FFmpeg tool. + /// Gets location of the discovered FFmpeg tool. /// </summary> FFmpegLocation EncoderLocation { get; } @@ -49,6 +51,14 @@ namespace MediaBrowser.Controller.MediaEncoding /// <returns><c>true</c> if XXXX, <c>false</c> otherwise.</returns> bool SupportsHwaccel(string hwaccel); + /// <summary> + /// Whether given filter is supported. + /// </summary> + /// <param name="filter">The filter.</param> + /// <param name="option">The option.</param> + /// <returns><c>true</c> if the filter is supported, <c>false</c> otherwise.</returns> + bool SupportsFilter(string filter, string option); + /// <summary> /// Extracts the audio image. /// </summary> @@ -61,18 +71,47 @@ namespace MediaBrowser.Controller.MediaEncoding /// <summary> /// Extracts the video image. /// </summary> - Task<string> ExtractVideoImage(string[] inputFiles, string container, MediaProtocol protocol, MediaStream videoStream, Video3DFormat? threedFormat, TimeSpan? offset, CancellationToken cancellationToken); + /// <param name="inputFile">Input file.</param> + /// <param name="container">Video container type.</param> + /// <param name="mediaSource">Media source information.</param> + /// <param name="videoStream">Media stream information.</param> + /// <param name="threedFormat">Video 3D format.</param> + /// <param name="offset">Time offset.</param> + /// <param name="cancellationToken">CancellationToken to use for operation.</param> + /// <returns>Location of video image.</returns> + Task<string> ExtractVideoImage(string inputFile, string container, MediaSourceInfo mediaSource, MediaStream videoStream, Video3DFormat? threedFormat, TimeSpan? offset, CancellationToken cancellationToken); - Task<string> ExtractVideoImage(string[] inputFiles, string container, MediaProtocol protocol, MediaStream imageStream, int? imageStreamIndex, CancellationToken cancellationToken); + /// <summary> + /// Extracts the video image. + /// </summary> + /// <param name="inputFile">Input file.</param> + /// <param name="container">Video container type.</param> + /// <param name="mediaSource">Media source information.</param> + /// <param name="imageStream">Media stream information.</param> + /// <param name="imageStreamIndex">Index of the stream to extract from.</param> + /// <param name="cancellationToken">CancellationToken to use for operation.</param> + /// <returns>Location of video image.</returns> + Task<string> ExtractVideoImage(string inputFile, string container, MediaSourceInfo mediaSource, MediaStream imageStream, int? imageStreamIndex, CancellationToken cancellationToken); /// <summary> /// Extracts the video images on interval. /// </summary> + /// <param name="inputFile">Input file.</param> + /// <param name="container">Video container type.</param> + /// <param name="videoStream">Media stream information.</param> + /// <param name="mediaSource">Media source information.</param> + /// <param name="threedFormat">Video 3D format.</param> + /// <param name="interval">Time interval.</param> + /// <param name="targetDirectory">Directory to write images.</param> + /// <param name="filenamePrefix">Filename prefix to use.</param> + /// <param name="maxWidth">Maximum width of image.</param> + /// <param name="cancellationToken">CancellationToken to use for operation.</param> + /// <returns>A task.</returns> Task ExtractVideoImagesOnInterval( - string[] inputFiles, + string inputFile, string container, MediaStream videoStream, - MediaProtocol protocol, + MediaSourceInfo mediaSource, Video3DFormat? threedFormat, TimeSpan interval, string targetDirectory, @@ -91,10 +130,10 @@ namespace MediaBrowser.Controller.MediaEncoding /// <summary> /// Gets the input argument. /// </summary> - /// <param name="inputFiles">The input files.</param> - /// <param name="protocol">The protocol.</param> + /// <param name="inputFile">The input file.</param> + /// <param name="mediaSource">The mediaSource.</param> /// <returns>System.String.</returns> - string GetInputArgument(IReadOnlyList<string> inputFiles, MediaProtocol protocol); + string GetInputArgument(string inputFile, MediaSourceInfo mediaSource); /// <summary> /// Gets the time parameter. @@ -112,10 +151,24 @@ namespace MediaBrowser.Controller.MediaEncoding /// <returns>System.String.</returns> string EscapeSubtitleFilterPath(string path); + /// <summary> + /// Sets the path to find FFmpeg. + /// </summary> void SetFFmpegPath(); + /// <summary> + /// Updates the encoder path. + /// </summary> + /// <param name="path">The path.</param> + /// <param name="pathType">The type of path.</param> void UpdateEncoderPath(string path, string pathType); - IEnumerable<string> GetPrimaryPlaylistVobFiles(string path, IIsoMount isoMount, uint? titleNumber); + /// <summary> + /// Gets the primary playlist of .vob files. + /// </summary> + /// <param name="path">The to the .vob files.</param> + /// <param name="titleNumber">The title number to start with.</param> + /// <returns>A playlist.</returns> + IEnumerable<string> GetPrimaryPlaylistVobFiles(string path, uint? titleNumber); } } diff --git a/MediaBrowser.Controller/MediaEncoding/ISubtitleEncoder.cs b/MediaBrowser.Controller/MediaEncoding/ISubtitleEncoder.cs index 6ebf7f159b..4483cf708a 100644 --- a/MediaBrowser.Controller/MediaEncoding/ISubtitleEncoder.cs +++ b/MediaBrowser.Controller/MediaEncoding/ISubtitleEncoder.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System.IO; @@ -13,6 +15,14 @@ namespace MediaBrowser.Controller.MediaEncoding /// <summary> /// Gets the subtitles. /// </summary> + /// <param name="item">Item to use.</param> + /// <param name="mediaSourceId">Media source.</param> + /// <param name="subtitleStreamIndex">Subtitle stream to use.</param> + /// <param name="outputFormat">Output format to use.</param> + /// <param name="startTimeTicks">Start time.</param> + /// <param name="endTimeTicks">End time.</param> + /// <param name="preserveOriginalTimestamps">Option to preserve original timestamps.</param> + /// <param name="cancellationToken">The cancellation token for the operation.</param> /// <returns>Task{Stream}.</returns> Task<Stream> GetSubtitles( BaseItem item, diff --git a/MediaBrowser.Controller/MediaEncoding/ImageEncodingOptions.cs b/MediaBrowser.Controller/MediaEncoding/ImageEncodingOptions.cs index e7b4c8c15c..044ba6d331 100644 --- a/MediaBrowser.Controller/MediaEncoding/ImageEncodingOptions.cs +++ b/MediaBrowser.Controller/MediaEncoding/ImageEncodingOptions.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 namespace MediaBrowser.Controller.MediaEncoding diff --git a/MediaBrowser.Controller/MediaEncoding/JobLogger.cs b/MediaBrowser.Controller/MediaEncoding/JobLogger.cs index ac520c5c44..b23c951127 100644 --- a/MediaBrowser.Controller/MediaEncoding/JobLogger.cs +++ b/MediaBrowser.Controller/MediaEncoding/JobLogger.cs @@ -1,10 +1,12 @@ +#nullable disable + #pragma warning disable CS1591 using System; using System.Globalization; using System.IO; -using System.Linq; using System.Text; +using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Logging; @@ -93,7 +95,7 @@ namespace MediaBrowser.Controller.MediaEncoding } else if (part.StartsWith("fps=", StringComparison.OrdinalIgnoreCase)) { - var rate = part.Split(new[] { '=' }, 2)[^1]; + var rate = part.Split('=', 2)[^1]; if (float.TryParse(rate, NumberStyles.Any, _usCulture, out var val)) { @@ -103,7 +105,7 @@ namespace MediaBrowser.Controller.MediaEncoding else if (state.RunTimeTicks.HasValue && part.StartsWith("time=", StringComparison.OrdinalIgnoreCase)) { - var time = part.Split(new[] { '=' }, 2).Last(); + var time = part.Split('=', 2)[^1]; if (TimeSpan.TryParse(time, _usCulture, out var val)) { @@ -116,7 +118,7 @@ namespace MediaBrowser.Controller.MediaEncoding } else if (part.StartsWith("size=", StringComparison.OrdinalIgnoreCase)) { - var size = part.Split(new[] { '=' }, 2).Last(); + var size = part.Split('=', 2)[^1]; int? scale = null; if (size.IndexOf("kb", StringComparison.OrdinalIgnoreCase) != -1) @@ -135,7 +137,7 @@ namespace MediaBrowser.Controller.MediaEncoding } else if (part.StartsWith("bitrate=", StringComparison.OrdinalIgnoreCase)) { - var rate = part.Split(new[] { '=' }, 2).Last(); + var rate = part.Split('=', 2)[^1]; int? scale = null; if (rate.IndexOf("kbits/s", StringComparison.OrdinalIgnoreCase) != -1) diff --git a/MediaBrowser.Controller/MediaEncoding/MediaEncoderHelpers.cs b/MediaBrowser.Controller/MediaEncoding/MediaEncoderHelpers.cs index ce53c23ad2..841e7b2872 100644 --- a/MediaBrowser.Controller/MediaEncoding/MediaEncoderHelpers.cs +++ b/MediaBrowser.Controller/MediaEncoding/MediaEncoderHelpers.cs @@ -1,11 +1,5 @@ #pragma warning disable CS1591 -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using MediaBrowser.Model.IO; - namespace MediaBrowser.Controller.MediaEncoding { /// <summary> @@ -13,38 +7,5 @@ namespace MediaBrowser.Controller.MediaEncoding /// </summary> public static class MediaEncoderHelpers { - /// <summary> - /// Gets the input argument. - /// </summary> - /// <param name="fileSystem">The file system.</param> - /// <param name="videoPath">The video path.</param> - /// <param name="isoMount">The iso mount.</param> - /// <param name="playableStreamFileNames">The playable stream file names.</param> - /// <returns>string[].</returns> - public static string[] GetInputArgument(IFileSystem fileSystem, string videoPath, IIsoMount isoMount, IReadOnlyCollection<string> playableStreamFileNames) - { - if (playableStreamFileNames.Count > 0) - { - if (isoMount == null) - { - return GetPlayableStreamFiles(fileSystem, videoPath, playableStreamFileNames); - } - - return GetPlayableStreamFiles(fileSystem, isoMount.MountedPath, playableStreamFileNames); - } - - return new[] { videoPath }; - } - - private static string[] GetPlayableStreamFiles(IFileSystem fileSystem, string rootPath, IEnumerable<string> filenames) - { - var allFiles = fileSystem - .GetFilePaths(rootPath, true) - .ToArray(); - - return filenames.Select(name => allFiles.FirstOrDefault(f => string.Equals(Path.GetFileName(f), name, StringComparison.OrdinalIgnoreCase))) - .Where(f => !string.IsNullOrEmpty(f)) - .ToArray(); - } } } diff --git a/MediaBrowser.Controller/MediaEncoding/MediaInfoRequest.cs b/MediaBrowser.Controller/MediaEncoding/MediaInfoRequest.cs index 59729de49c..1dd8bcf31b 100644 --- a/MediaBrowser.Controller/MediaEncoding/MediaInfoRequest.cs +++ b/MediaBrowser.Controller/MediaEncoding/MediaInfoRequest.cs @@ -1,9 +1,9 @@ +#nullable disable + #pragma warning disable CS1591 -using System; using MediaBrowser.Model.Dlna; using MediaBrowser.Model.Dto; -using MediaBrowser.Model.IO; namespace MediaBrowser.Controller.MediaEncoding { @@ -14,14 +14,5 @@ namespace MediaBrowser.Controller.MediaEncoding public bool ExtractChapters { get; set; } public DlnaProfileType MediaType { get; set; } - - public IIsoMount MountedIso { get; set; } - - public string[] PlayableStreamFileNames { get; set; } - - public MediaInfoRequest() - { - PlayableStreamFileNames = Array.Empty<string>(); - } } } diff --git a/MediaBrowser.Controller/MediaEncoding/TranscodingJobType.cs b/MediaBrowser.Controller/MediaEncoding/TranscodingJobType.cs new file mode 100644 index 0000000000..66b6283719 --- /dev/null +++ b/MediaBrowser.Controller/MediaEncoding/TranscodingJobType.cs @@ -0,0 +1,23 @@ +namespace MediaBrowser.Controller.MediaEncoding +{ + /// <summary> + /// Enum TranscodingJobType. + /// </summary> + public enum TranscodingJobType + { + /// <summary> + /// The progressive. + /// </summary> + Progressive, + + /// <summary> + /// The HLS. + /// </summary> + Hls, + + /// <summary> + /// The dash. + /// </summary> + Dash + } +} \ No newline at end of file diff --git a/MediaBrowser.Controller/Net/AuthorizationInfo.cs b/MediaBrowser.Controller/Net/AuthorizationInfo.cs index 735c46ef86..2452b25ab1 100644 --- a/MediaBrowser.Controller/Net/AuthorizationInfo.cs +++ b/MediaBrowser.Controller/Net/AuthorizationInfo.cs @@ -1,10 +1,13 @@ -#pragma warning disable CS1591 +#nullable disable using System; using Jellyfin.Data.Entities; namespace MediaBrowser.Controller.Net { + /// <summary> + /// The request authorization info. + /// </summary> public class AuthorizationInfo { /// <summary> @@ -43,6 +46,24 @@ namespace MediaBrowser.Controller.Net /// <value>The token.</value> public string Token { get; set; } + /// <summary> + /// Gets or sets a value indicating whether the authorization is from an api key. + /// </summary> + public bool IsApiKey { get; set; } + + /// <summary> + /// Gets or sets the user making the request. + /// </summary> public User User { get; set; } + + /// <summary> + /// Gets or sets a value indicating whether the token is authenticated. + /// </summary> + public bool IsAuthenticated { get; set; } + + /// <summary> + /// Gets or sets a value indicating whether the request has a token. + /// </summary> + public bool HasToken { get; set; } } } diff --git a/MediaBrowser.Controller/Net/BasePeriodicWebSocketListener.cs b/MediaBrowser.Controller/Net/BasePeriodicWebSocketListener.cs index 28227603b2..0813a8e7d5 100644 --- a/MediaBrowser.Controller/Net/BasePeriodicWebSocketListener.cs +++ b/MediaBrowser.Controller/Net/BasePeriodicWebSocketListener.cs @@ -1,4 +1,6 @@ -#pragma warning disable CS1591 +#nullable disable + +#pragma warning disable CS1591, SA1306, SA1401 using System; using System.Collections.Generic; @@ -28,6 +30,21 @@ namespace MediaBrowser.Controller.Net private readonly List<Tuple<IWebSocketConnection, CancellationTokenSource, TStateType>> _activeConnections = new List<Tuple<IWebSocketConnection, CancellationTokenSource, TStateType>>(); + /// <summary> + /// The logger. + /// </summary> + protected ILogger<BasePeriodicWebSocketListener<TReturnDataType, TStateType>> Logger; + + protected BasePeriodicWebSocketListener(ILogger<BasePeriodicWebSocketListener<TReturnDataType, TStateType>> logger) + { + if (logger == null) + { + throw new ArgumentNullException(nameof(logger)); + } + + Logger = logger; + } + /// <summary> /// Gets the type used for the messages sent to the client. /// </summary> @@ -52,21 +69,6 @@ namespace MediaBrowser.Controller.Net /// <returns>Task{`1}.</returns> protected abstract Task<TReturnDataType> GetDataToSend(); - /// <summary> - /// The logger. - /// </summary> - protected ILogger<BasePeriodicWebSocketListener<TReturnDataType, TStateType>> Logger; - - protected BasePeriodicWebSocketListener(ILogger<BasePeriodicWebSocketListener<TReturnDataType, TStateType>> logger) - { - if (logger == null) - { - throw new ArgumentNullException(nameof(logger)); - } - - Logger = logger; - } - /// <summary> /// Processes the message. /// </summary> @@ -92,6 +94,9 @@ namespace MediaBrowser.Controller.Net return Task.CompletedTask; } + /// <inheritdoc /> + public Task ProcessWebSocketConnectedAsync(IWebSocketConnection connection) => Task.CompletedTask; + /// <summary> /// Starts sending messages over a web socket. /// </summary> @@ -265,13 +270,4 @@ namespace MediaBrowser.Controller.Net GC.SuppressFinalize(this); } } - - public class WebSocketListenerState - { - public DateTime DateLastSendUtc { get; set; } - - public long InitialDelayMs { get; set; } - - public long IntervalMs { get; set; } - } } diff --git a/MediaBrowser.Controller/Net/IAuthService.cs b/MediaBrowser.Controller/Net/IAuthService.cs index 04b2e13e8c..d15c6d3183 100644 --- a/MediaBrowser.Controller/Net/IAuthService.cs +++ b/MediaBrowser.Controller/Net/IAuthService.cs @@ -1,5 +1,3 @@ -#nullable enable - using Microsoft.AspNetCore.Http; namespace MediaBrowser.Controller.Net diff --git a/MediaBrowser.Controller/Net/ISessionContext.cs b/MediaBrowser.Controller/Net/ISessionContext.cs index a60dc2ea19..6b896b41f4 100644 --- a/MediaBrowser.Controller/Net/ISessionContext.cs +++ b/MediaBrowser.Controller/Net/ISessionContext.cs @@ -10,10 +10,10 @@ namespace MediaBrowser.Controller.Net { SessionInfo GetSession(object requestContext); - User GetUser(object requestContext); + User? GetUser(object requestContext); SessionInfo GetSession(HttpContext requestContext); - User GetUser(HttpContext requestContext); + User? GetUser(HttpContext requestContext); } } diff --git a/MediaBrowser.Controller/Net/IWebSocketConnection.cs b/MediaBrowser.Controller/Net/IWebSocketConnection.cs index e87f3bca68..c8c5caf809 100644 --- a/MediaBrowser.Controller/Net/IWebSocketConnection.cs +++ b/MediaBrowser.Controller/Net/IWebSocketConnection.cs @@ -1,7 +1,5 @@ #pragma warning disable CS1591 -#nullable enable - using System; using System.Net; using System.Net.WebSockets; @@ -32,7 +30,7 @@ namespace MediaBrowser.Controller.Net DateTime LastKeepAliveDate { get; set; } /// <summary> - /// Gets or sets the query string. + /// Gets the query string. /// </summary> /// <value>The query string.</value> IQueryCollection QueryString { get; } @@ -58,11 +56,11 @@ namespace MediaBrowser.Controller.Net /// <summary> /// Sends a message asynchronously. /// </summary> - /// <typeparam name="T"></typeparam> + /// <typeparam name="T">The type of websocket message data.</typeparam> /// <param name="message">The message.</param> /// <param name="cancellationToken">The cancellation token.</param> /// <returns>Task.</returns> - /// <exception cref="ArgumentNullException">message</exception> + /// <exception cref="ArgumentNullException">The message is null.</exception> Task SendAsync<T>(WebSocketMessage<T> message, CancellationToken cancellationToken); Task ProcessAsync(CancellationToken cancellationToken = default); diff --git a/MediaBrowser.Controller/Net/IWebSocketListener.cs b/MediaBrowser.Controller/Net/IWebSocketListener.cs index 7250a57b0a..f1a75d5180 100644 --- a/MediaBrowser.Controller/Net/IWebSocketListener.cs +++ b/MediaBrowser.Controller/Net/IWebSocketListener.cs @@ -3,7 +3,7 @@ using System.Threading.Tasks; namespace MediaBrowser.Controller.Net { /// <summary> - ///This is an interface for listening to messages coming through a web socket connection. + /// Interface for listening to messages coming through a web socket connection. /// </summary> public interface IWebSocketListener { @@ -13,5 +13,12 @@ namespace MediaBrowser.Controller.Net /// <param name="message">The message.</param> /// <returns>Task.</returns> Task ProcessMessageAsync(WebSocketMessageInfo message); + + /// <summary> + /// Processes a new web socket connection. + /// </summary> + /// <param name="connection">An instance of the <see cref="IWebSocketConnection"/> interface.</param> + /// <returns>Task.</returns> + Task ProcessWebSocketConnectedAsync(IWebSocketConnection connection); } } diff --git a/MediaBrowser.Controller/Net/IWebSocketManager.cs b/MediaBrowser.Controller/Net/IWebSocketManager.cs index ce74173e70..bb0ae83bea 100644 --- a/MediaBrowser.Controller/Net/IWebSocketManager.cs +++ b/MediaBrowser.Controller/Net/IWebSocketManager.cs @@ -1,7 +1,4 @@ -using System; -using System.Collections.Generic; using System.Threading.Tasks; -using Jellyfin.Data.Events; using Microsoft.AspNetCore.Http; namespace MediaBrowser.Controller.Net @@ -11,11 +8,6 @@ namespace MediaBrowser.Controller.Net /// </summary> public interface IWebSocketManager { - /// <summary> - /// Occurs when [web socket connected]. - /// </summary> - event EventHandler<GenericEventArgs<IWebSocketConnection>> WebSocketConnected; - /// <summary> /// The HTTP request handler. /// </summary> diff --git a/MediaBrowser.Controller/Net/SecurityException.cs b/MediaBrowser.Controller/Net/SecurityException.cs index c6347133a8..f0d0b45a0a 100644 --- a/MediaBrowser.Controller/Net/SecurityException.cs +++ b/MediaBrowser.Controller/Net/SecurityException.cs @@ -1,5 +1,3 @@ -#nullable enable - using System; namespace MediaBrowser.Controller.Net diff --git a/MediaBrowser.Controller/Net/WebSocketListenerState.cs b/MediaBrowser.Controller/Net/WebSocketListenerState.cs new file mode 100644 index 0000000000..70604d60a0 --- /dev/null +++ b/MediaBrowser.Controller/Net/WebSocketListenerState.cs @@ -0,0 +1,17 @@ +#nullable disable + +#pragma warning disable CS1591 + +using System; + +namespace MediaBrowser.Controller.Net +{ + public class WebSocketListenerState + { + public DateTime DateLastSendUtc { get; set; } + + public long InitialDelayMs { get; set; } + + public long IntervalMs { get; set; } + } +} \ No newline at end of file diff --git a/MediaBrowser.Controller/Net/WebSocketMessageInfo.cs b/MediaBrowser.Controller/Net/WebSocketMessageInfo.cs index be0b3ddc3f..6f7ebf1565 100644 --- a/MediaBrowser.Controller/Net/WebSocketMessageInfo.cs +++ b/MediaBrowser.Controller/Net/WebSocketMessageInfo.cs @@ -1,3 +1,5 @@ +#nullable disable + using MediaBrowser.Model.Net; namespace MediaBrowser.Controller.Net diff --git a/MediaBrowser.Controller/Notifications/INotificationManager.cs b/MediaBrowser.Controller/Notifications/INotificationManager.cs index 08d9bc12a2..7caba1097a 100644 --- a/MediaBrowser.Controller/Notifications/INotificationManager.cs +++ b/MediaBrowser.Controller/Notifications/INotificationManager.cs @@ -19,7 +19,7 @@ namespace MediaBrowser.Controller.Notifications /// <returns>Task.</returns> Task SendNotification(NotificationRequest request, CancellationToken cancellationToken); - Task SendNotification(NotificationRequest request, BaseItem relatedItem, CancellationToken cancellationToken); + Task SendNotification(NotificationRequest request, BaseItem? relatedItem, CancellationToken cancellationToken); /// <summary> /// Adds the parts. diff --git a/MediaBrowser.Controller/Notifications/INotificationService.cs b/MediaBrowser.Controller/Notifications/INotificationService.cs index fa947220ad..535c08795b 100644 --- a/MediaBrowser.Controller/Notifications/INotificationService.cs +++ b/MediaBrowser.Controller/Notifications/INotificationService.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System.Threading; diff --git a/MediaBrowser.Controller/Notifications/UserNotification.cs b/MediaBrowser.Controller/Notifications/UserNotification.cs index d768abfe73..4be0e09ae7 100644 --- a/MediaBrowser.Controller/Notifications/UserNotification.cs +++ b/MediaBrowser.Controller/Notifications/UserNotification.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System; diff --git a/MediaBrowser.Controller/Persistence/IItemRepository.cs b/MediaBrowser.Controller/Persistence/IItemRepository.cs index 45c6805f0e..0a9073e7f5 100644 --- a/MediaBrowser.Controller/Persistence/IItemRepository.cs +++ b/MediaBrowser.Controller/Persistence/IItemRepository.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System; @@ -47,21 +49,23 @@ namespace MediaBrowser.Controller.Persistence /// <summary> /// Gets chapters for an item. /// </summary> - /// <param name="id"></param> - /// <returns></returns> + /// <param name="id">The item.</param> + /// <returns>The list of chapter info.</returns> List<ChapterInfo> GetChapters(BaseItem id); /// <summary> /// Gets a single chapter for an item. /// </summary> - /// <param name="id"></param> - /// <param name="index"></param> - /// <returns></returns> + /// <param name="id">The item.</param> + /// <param name="index">The chapter index.</param> + /// <returns>The chapter info at the specified index.</returns> ChapterInfo GetChapter(BaseItem id, int index); /// <summary> /// Saves the chapters. /// </summary> + /// <param name="id">The item id.</param> + /// <param name="chapters">The list of chapters to save.</param> void SaveChapters(Guid id, IReadOnlyList<ChapterInfo> chapters); /// <summary> @@ -153,8 +157,7 @@ namespace MediaBrowser.Controller.Persistence /// <summary> /// Updates the inherited values. /// </summary> - /// <param name="cancellationToken">The cancellation token.</param> - void UpdateInheritedValues(CancellationToken cancellationToken); + void UpdateInheritedValues(); int GetCount(InternalItemsQuery query); diff --git a/MediaBrowser.Controller/Persistence/IUserDataRepository.cs b/MediaBrowser.Controller/Persistence/IUserDataRepository.cs index 81ba513cef..c43acfb6de 100644 --- a/MediaBrowser.Controller/Persistence/IUserDataRepository.cs +++ b/MediaBrowser.Controller/Persistence/IUserDataRepository.cs @@ -1,3 +1,5 @@ +#nullable disable + using System.Collections.Generic; using System.Threading; using MediaBrowser.Controller.Entities; @@ -16,7 +18,6 @@ namespace MediaBrowser.Controller.Persistence /// <param name="key">The key.</param> /// <param name="userData">The user data.</param> /// <param name="cancellationToken">The cancellation token.</param> - /// <returns>Task.</returns> void SaveUserData(long userId, string key, UserItemData userData, CancellationToken cancellationToken); /// <summary> @@ -38,17 +39,16 @@ namespace MediaBrowser.Controller.Persistence /// <summary> /// Return all user data associated with the given user. /// </summary> - /// <param name="userId"></param> - /// <returns></returns> + /// <param name="userId">The user id.</param> + /// <returns>The list of user item data.</returns> List<UserItemData> GetAllUserData(long userId); /// <summary> /// Save all user data associated with the given user. /// </summary> - /// <param name="userId"></param> - /// <param name="userData"></param> - /// <param name="cancellationToken"></param> - /// <returns></returns> + /// <param name="userId">The user id.</param> + /// <param name="userData">The user item data.</param> + /// <param name="cancellationToken">The cancellation token.</param> void SaveAllUserData(long userId, UserItemData[] userData, CancellationToken cancellationToken); } } diff --git a/MediaBrowser.Controller/Playlists/IPlaylistManager.cs b/MediaBrowser.Controller/Playlists/IPlaylistManager.cs index fbf2c52131..f6c5920709 100644 --- a/MediaBrowser.Controller/Playlists/IPlaylistManager.cs +++ b/MediaBrowser.Controller/Playlists/IPlaylistManager.cs @@ -31,7 +31,7 @@ namespace MediaBrowser.Controller.Playlists /// <param name="itemIds">The item ids.</param> /// <param name="userId">The user identifier.</param> /// <returns>Task.</returns> - Task AddToPlaylistAsync(Guid playlistId, ICollection<Guid> itemIds, Guid userId); + Task AddToPlaylistAsync(Guid playlistId, IReadOnlyCollection<Guid> itemIds, Guid userId); /// <summary> /// Removes from playlist. diff --git a/MediaBrowser.Controller/Playlists/Playlist.cs b/MediaBrowser.Controller/Playlists/Playlist.cs index 216dd27098..5e671a725d 100644 --- a/MediaBrowser.Controller/Playlists/Playlist.cs +++ b/MediaBrowser.Controller/Playlists/Playlist.cs @@ -1,8 +1,11 @@ +#nullable disable + #pragma warning disable CS1591 using System; using System.Collections.Generic; using System.Globalization; +using System.IO; using System.Linq; using System.Text.Json.Serialization; using System.Threading; @@ -13,39 +16,33 @@ using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.Audio; using MediaBrowser.Controller.Providers; -using MediaBrowser.Model.Entities; using MediaBrowser.Model.Querying; namespace MediaBrowser.Controller.Playlists { public class Playlist : Folder, IHasShares { - public static string[] SupportedExtensions = - { - ".m3u", - ".m3u8", - ".pls", - ".wpl", - ".zpl" - }; - - public Guid OwnerUserId { get; set; } - - public Share[] Shares { get; set; } + public static readonly IReadOnlyList<string> SupportedExtensions = new[] + { + ".m3u", + ".m3u8", + ".pls", + ".wpl", + ".zpl" + }; public Playlist() { Shares = Array.Empty<Share>(); } + public Guid OwnerUserId { get; set; } + + public Share[] Shares { get; set; } + [JsonIgnore] public bool IsFile => IsPlaylistFile(Path); - public static bool IsPlaylistFile(string path) - { - return System.IO.Path.HasExtension(path); - } - [JsonIgnore] public override string ContainingFolderPath { @@ -77,6 +74,41 @@ namespace MediaBrowser.Controller.Playlists [JsonIgnore] public override bool SupportsCumulativeRunTimeTicks => true; + [JsonIgnore] + public override bool IsPreSorted => true; + + public string PlaylistMediaType { get; set; } + + [JsonIgnore] + public override string MediaType => PlaylistMediaType; + + [JsonIgnore] + private bool IsSharedItem + { + get + { + var path = Path; + + if (string.IsNullOrEmpty(path)) + { + return false; + } + + return FileSystem.ContainsSubPath(ConfigurationManager.ApplicationPaths.DataPath, path); + } + } + + public static bool IsPlaylistFile(string path) + { + // The path will sometimes be a directory and "Path.HasExtension" returns true if the name contains a '.' (dot). + return System.IO.Path.HasExtension(path) && !Directory.Exists(path); + } + + public void SetMediaType(string value) + { + PlaylistMediaType = value; + } + public override double GetDefaultPrimaryImageAspectRatio() { return 1; @@ -98,7 +130,7 @@ namespace MediaBrowser.Controller.Playlists return new List<BaseItem>(); } - protected override Task ValidateChildrenInternal(IProgress<double> progress, CancellationToken cancellationToken, bool recursive, bool refreshChildMetadata, MetadataRefreshOptions refreshOptions, IDirectoryService directoryService) + protected override Task ValidateChildrenInternal(IProgress<double> progress, bool recursive, bool refreshChildMetadata, MetadataRefreshOptions refreshOptions, IDirectoryService directoryService, CancellationToken cancellationToken) { return Task.CompletedTask; } @@ -125,10 +157,7 @@ namespace MediaBrowser.Controller.Playlists private List<BaseItem> GetPlayableItems(User user, InternalItemsQuery query) { - if (query == null) - { - query = new InternalItemsQuery(user); - } + query ??= new InternalItemsQuery(user); query.IsFolder = false; @@ -160,9 +189,9 @@ namespace MediaBrowser.Controller.Playlists return LibraryManager.GetItemList(new InternalItemsQuery(user) { Recursive = true, - IncludeItemTypes = new[] { typeof(Audio).Name }, + IncludeItemTypes = new[] { nameof(Audio) }, GenreIds = new[] { musicGenre.Id }, - OrderBy = new[] { ItemSortBy.AlbumArtist, ItemSortBy.Album, ItemSortBy.SortName }.Select(i => new ValueTuple<string, SortOrder>(i, SortOrder.Ascending)).ToArray(), + OrderBy = new[] { (ItemSortBy.AlbumArtist, SortOrder.Ascending), (ItemSortBy.Album, SortOrder.Ascending), (ItemSortBy.SortName, SortOrder.Ascending) }, DtoOptions = options }); } @@ -172,9 +201,9 @@ namespace MediaBrowser.Controller.Playlists return LibraryManager.GetItemList(new InternalItemsQuery(user) { Recursive = true, - IncludeItemTypes = new[] { typeof(Audio).Name }, + IncludeItemTypes = new[] { nameof(Audio) }, ArtistIds = new[] { musicArtist.Id }, - OrderBy = new[] { ItemSortBy.AlbumArtist, ItemSortBy.Album, ItemSortBy.SortName }.Select(i => new ValueTuple<string, SortOrder>(i, SortOrder.Ascending)).ToArray(), + OrderBy = new[] { (ItemSortBy.AlbumArtist, SortOrder.Ascending), (ItemSortBy.Album, SortOrder.Ascending), (ItemSortBy.SortName, SortOrder.Ascending) }, DtoOptions = options }); } @@ -197,35 +226,6 @@ namespace MediaBrowser.Controller.Playlists return new[] { item }; } - [JsonIgnore] - public override bool IsPreSorted => true; - - public string PlaylistMediaType { get; set; } - - [JsonIgnore] - public override string MediaType => PlaylistMediaType; - - public void SetMediaType(string value) - { - PlaylistMediaType = value; - } - - [JsonIgnore] - private bool IsSharedItem - { - get - { - var path = Path; - - if (string.IsNullOrEmpty(path)) - { - return false; - } - - return FileSystem.ContainsSubPath(ConfigurationManager.ApplicationPaths.DataPath, path); - } - } - public override bool IsVisible(User user) { if (!IsSharedItem) diff --git a/MediaBrowser.Controller/Plugins/ILocalizablePlugin.cs b/MediaBrowser.Controller/Plugins/ILocalizablePlugin.cs deleted file mode 100644 index bf15fe0407..0000000000 --- a/MediaBrowser.Controller/Plugins/ILocalizablePlugin.cs +++ /dev/null @@ -1,22 +0,0 @@ -#pragma warning disable CS1591 - -using System.IO; -using System.Reflection; - -namespace MediaBrowser.Controller.Plugins -{ - public interface ILocalizablePlugin - { - Stream GetDictionary(string culture); - } - - public static class LocalizablePluginHelper - { - public static Stream GetDictionary(Assembly assembly, string manifestPrefix, string culture) - { - // Find all dictionaries using GetManifestResourceNames, start start with the prefix - // Return the one for the culture if exists, otherwise return the default - return null; - } - } -} diff --git a/MediaBrowser.Controller/Plugins/IPluginConfigurationPage.cs b/MediaBrowser.Controller/Plugins/IPluginConfigurationPage.cs deleted file mode 100644 index 93eab42cc7..0000000000 --- a/MediaBrowser.Controller/Plugins/IPluginConfigurationPage.cs +++ /dev/null @@ -1,51 +0,0 @@ -using System.IO; -using MediaBrowser.Common.Plugins; - -namespace MediaBrowser.Controller.Plugins -{ - /// <summary> - /// Interface IConfigurationPage. - /// </summary> - public interface IPluginConfigurationPage - { - /// <summary> - /// Gets the name. - /// </summary> - /// <value>The name.</value> - string Name { get; } - - /// <summary> - /// Gets the type of the configuration page. - /// </summary> - /// <value>The type of the configuration page.</value> - ConfigurationPageType ConfigurationPageType { get; } - - /// <summary> - /// Gets the plugin. - /// </summary> - /// <value>The plugin.</value> - IPlugin Plugin { get; } - - /// <summary> - /// Gets the HTML stream. - /// </summary> - /// <returns>Stream.</returns> - Stream GetHtmlStream(); - } - - /// <summary> - /// Enum ConfigurationPageType. - /// </summary> - public enum ConfigurationPageType - { - /// <summary> - /// The plugin configuration. - /// </summary> - PluginConfiguration, - - /// <summary> - /// The none. - /// </summary> - None - } -} diff --git a/MediaBrowser.Controller/Plugins/IRunBeforeStartup.cs b/MediaBrowser.Controller/Plugins/IRunBeforeStartup.cs new file mode 100644 index 0000000000..2b831103a5 --- /dev/null +++ b/MediaBrowser.Controller/Plugins/IRunBeforeStartup.cs @@ -0,0 +1,9 @@ +namespace MediaBrowser.Controller.Plugins +{ + /// <summary> + /// Indicates that a <see cref="IServerEntryPoint"/> should be invoked as a pre-startup task. + /// </summary> + public interface IRunBeforeStartup + { + } +} diff --git a/MediaBrowser.Controller/Plugins/IServerEntryPoint.cs b/MediaBrowser.Controller/Plugins/IServerEntryPoint.cs index b44e2531e1..6024661e15 100644 --- a/MediaBrowser.Controller/Plugins/IServerEntryPoint.cs +++ b/MediaBrowser.Controller/Plugins/IServerEntryPoint.cs @@ -14,13 +14,7 @@ namespace MediaBrowser.Controller.Plugins /// <summary> /// Run the initialization for this module. This method is invoked at application start. /// </summary> + /// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns> Task RunAsync(); } - - /// <summary> - /// Indicates that a <see cref="IServerEntryPoint"/> should be invoked as a pre-startup task. - /// </summary> - public interface IRunBeforeStartup - { - } } diff --git a/MediaBrowser.Controller/Providers/AlbumInfo.cs b/MediaBrowser.Controller/Providers/AlbumInfo.cs index 276bcf1252..aefa520e72 100644 --- a/MediaBrowser.Controller/Providers/AlbumInfo.cs +++ b/MediaBrowser.Controller/Providers/AlbumInfo.cs @@ -1,4 +1,4 @@ -#pragma warning disable CS1591 +#pragma warning disable CA1002, CA2227, CS1591 using System; using System.Collections.Generic; @@ -7,6 +7,13 @@ namespace MediaBrowser.Controller.Providers { public class AlbumInfo : ItemLookupInfo { + public AlbumInfo() + { + ArtistProviderIds = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase); + SongInfos = new List<SongInfo>(); + AlbumArtists = Array.Empty<string>(); + } + /// <summary> /// Gets or sets the album artist. /// </summary> @@ -20,12 +27,5 @@ namespace MediaBrowser.Controller.Providers public Dictionary<string, string> ArtistProviderIds { get; set; } public List<SongInfo> SongInfos { get; set; } - - public AlbumInfo() - { - ArtistProviderIds = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase); - SongInfos = new List<SongInfo>(); - AlbumArtists = Array.Empty<string>(); - } } } diff --git a/MediaBrowser.Controller/Providers/ArtistInfo.cs b/MediaBrowser.Controller/Providers/ArtistInfo.cs index adf885baa6..4854d1a5fa 100644 --- a/MediaBrowser.Controller/Providers/ArtistInfo.cs +++ b/MediaBrowser.Controller/Providers/ArtistInfo.cs @@ -1,4 +1,4 @@ -#pragma warning disable CS1591 +#pragma warning disable CA1002, CA2227, CS1591 using System.Collections.Generic; @@ -6,11 +6,11 @@ namespace MediaBrowser.Controller.Providers { public class ArtistInfo : ItemLookupInfo { - public List<SongInfo> SongInfos { get; set; } - public ArtistInfo() { SongInfos = new List<SongInfo>(); } + + public List<SongInfo> SongInfos { get; set; } } } diff --git a/MediaBrowser.Controller/Providers/BookInfo.cs b/MediaBrowser.Controller/Providers/BookInfo.cs index cce0a25fcb..3055c5d871 100644 --- a/MediaBrowser.Controller/Providers/BookInfo.cs +++ b/MediaBrowser.Controller/Providers/BookInfo.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 namespace MediaBrowser.Controller.Providers diff --git a/MediaBrowser.Controller/Providers/DirectoryService.cs b/MediaBrowser.Controller/Providers/DirectoryService.cs index 16fd1d42b0..b312702704 100644 --- a/MediaBrowser.Controller/Providers/DirectoryService.cs +++ b/MediaBrowser.Controller/Providers/DirectoryService.cs @@ -12,11 +12,11 @@ namespace MediaBrowser.Controller.Providers { private readonly IFileSystem _fileSystem; - private readonly ConcurrentDictionary<string, FileSystemMetadata[]> _cache = new ConcurrentDictionary<string, FileSystemMetadata[]>(StringComparer.OrdinalIgnoreCase); + private readonly ConcurrentDictionary<string, FileSystemMetadata[]> _cache = new (StringComparer.Ordinal); - private readonly ConcurrentDictionary<string, FileSystemMetadata> _fileCache = new ConcurrentDictionary<string, FileSystemMetadata>(StringComparer.OrdinalIgnoreCase); + private readonly ConcurrentDictionary<string, FileSystemMetadata> _fileCache = new (StringComparer.Ordinal); - private readonly ConcurrentDictionary<string, List<string>> _filePathCache = new ConcurrentDictionary<string, List<string>>(StringComparer.OrdinalIgnoreCase); + private readonly ConcurrentDictionary<string, List<string>> _filePathCache = new (StringComparer.Ordinal); public DirectoryService(IFileSystem fileSystem) { @@ -25,15 +25,16 @@ namespace MediaBrowser.Controller.Providers public FileSystemMetadata[] GetFileSystemEntries(string path) { - return _cache.GetOrAdd(path, p => _fileSystem.GetFileSystemEntries(p).ToArray()); + return _cache.GetOrAdd(path, (p, fileSystem) => fileSystem.GetFileSystemEntries(p).ToArray(), _fileSystem); } public List<FileSystemMetadata> GetFiles(string path) { var list = new List<FileSystemMetadata>(); var items = GetFileSystemEntries(path); - foreach (var item in items) + for (var i = 0; i < items.Length; i++) { + var item = items[i]; if (!item.IsDirectory) { list.Add(item); @@ -43,18 +44,16 @@ namespace MediaBrowser.Controller.Providers return list; } - public FileSystemMetadata GetFile(string path) + public FileSystemMetadata? GetFile(string path) { - var result = _fileCache.GetOrAdd(path, p => + if (!_fileCache.TryGetValue(path, out var result)) { - var file = _fileSystem.GetFileInfo(p); - return file != null && file.Exists ? file : null; - }); - - if (result == null) - { - // lets not store null results in the cache - _fileCache.TryRemove(path, out _); + var file = _fileSystem.GetFileInfo(path); + if (file.Exists) + { + result = file; + _fileCache.TryAdd(path, result); + } } return result; @@ -63,14 +62,21 @@ namespace MediaBrowser.Controller.Providers public IReadOnlyList<string> GetFilePaths(string path) => GetFilePaths(path, false); - public IReadOnlyList<string> GetFilePaths(string path, bool clearCache) + public IReadOnlyList<string> GetFilePaths(string path, bool clearCache, bool sort = false) { if (clearCache) { _filePathCache.TryRemove(path, out _); } - return _filePathCache.GetOrAdd(path, p => _fileSystem.GetFilePaths(p).ToList()); + var filePaths = _filePathCache.GetOrAdd(path, (p, fileSystem) => fileSystem.GetFilePaths(p).ToList(), _fileSystem); + + if (sort) + { + filePaths.Sort(); + } + + return filePaths; } } } diff --git a/MediaBrowser.Controller/Providers/DynamicImageResponse.cs b/MediaBrowser.Controller/Providers/DynamicImageResponse.cs index 006174be8c..66fddb0e38 100644 --- a/MediaBrowser.Controller/Providers/DynamicImageResponse.cs +++ b/MediaBrowser.Controller/Providers/DynamicImageResponse.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System; diff --git a/MediaBrowser.Controller/Providers/EpisodeInfo.cs b/MediaBrowser.Controller/Providers/EpisodeInfo.cs index a4c8dab7ea..b59a037384 100644 --- a/MediaBrowser.Controller/Providers/EpisodeInfo.cs +++ b/MediaBrowser.Controller/Providers/EpisodeInfo.cs @@ -1,4 +1,6 @@ -#pragma warning disable CS1591 +#nullable disable + +#pragma warning disable CA2227, CS1591 using System; using System.Collections.Generic; @@ -7,6 +9,11 @@ namespace MediaBrowser.Controller.Providers { public class EpisodeInfo : ItemLookupInfo { + public EpisodeInfo() + { + SeriesProviderIds = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase); + } + public Dictionary<string, string> SeriesProviderIds { get; set; } public int? IndexNumberEnd { get; set; } @@ -14,10 +21,5 @@ namespace MediaBrowser.Controller.Providers public bool IsMissingEpisode { get; set; } public string SeriesDisplayOrder { get; set; } - - public EpisodeInfo() - { - SeriesProviderIds = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase); - } } } diff --git a/MediaBrowser.Controller/Providers/IDirectoryService.cs b/MediaBrowser.Controller/Providers/IDirectoryService.cs index f06481c7a9..48d6276918 100644 --- a/MediaBrowser.Controller/Providers/IDirectoryService.cs +++ b/MediaBrowser.Controller/Providers/IDirectoryService.cs @@ -1,4 +1,4 @@ -#pragma warning disable CS1591 +#pragma warning disable CA1002, CA1819, CS1591 using System.Collections.Generic; using MediaBrowser.Model.IO; @@ -11,10 +11,10 @@ namespace MediaBrowser.Controller.Providers List<FileSystemMetadata> GetFiles(string path); - FileSystemMetadata GetFile(string path); + FileSystemMetadata? GetFile(string path); IReadOnlyList<string> GetFilePaths(string path); - IReadOnlyList<string> GetFilePaths(string path, bool clearCache); + IReadOnlyList<string> GetFilePaths(string path, bool clearCache, bool sort = false); } } diff --git a/MediaBrowser.Controller/Providers/IExternalId.cs b/MediaBrowser.Controller/Providers/IExternalId.cs index 5e38446bc9..e2dbef2bc1 100644 --- a/MediaBrowser.Controller/Providers/IExternalId.cs +++ b/MediaBrowser.Controller/Providers/IExternalId.cs @@ -1,3 +1,5 @@ +#nullable disable + using MediaBrowser.Model.Entities; using MediaBrowser.Model.Providers; diff --git a/MediaBrowser.Controller/Providers/ILocalImageProvider.cs b/MediaBrowser.Controller/Providers/ILocalImageProvider.cs index c129eddb3a..f78bd6ddf4 100644 --- a/MediaBrowser.Controller/Providers/ILocalImageProvider.cs +++ b/MediaBrowser.Controller/Providers/ILocalImageProvider.cs @@ -10,6 +10,6 @@ namespace MediaBrowser.Controller.Providers /// </summary> public interface ILocalImageProvider : IImageProvider { - List<LocalImageInfo> GetImages(BaseItem item, IDirectoryService directoryService); + IEnumerable<LocalImageInfo> GetImages(BaseItem item, IDirectoryService directoryService); } } diff --git a/MediaBrowser.Controller/Providers/IMetadataService.cs b/MediaBrowser.Controller/Providers/IMetadataService.cs index 5f3d4274ef..05fbb18ee4 100644 --- a/MediaBrowser.Controller/Providers/IMetadataService.cs +++ b/MediaBrowser.Controller/Providers/IMetadataService.cs @@ -10,6 +10,12 @@ namespace MediaBrowser.Controller.Providers { public interface IMetadataService { + /// <summary> + /// Gets the order. + /// </summary> + /// <value>The order.</value> + int Order { get; } + /// <summary> /// Determines whether this instance can refresh the specified item. /// </summary> @@ -27,11 +33,5 @@ namespace MediaBrowser.Controller.Providers /// <param name="cancellationToken">The cancellation token.</param> /// <returns>Task.</returns> Task<ItemUpdateType> RefreshMetadata(BaseItem item, MetadataRefreshOptions refreshOptions, CancellationToken cancellationToken); - - /// <summary> - /// Gets the order. - /// </summary> - /// <value>The order.</value> - int Order { get; } } } diff --git a/MediaBrowser.Controller/Providers/IProviderManager.cs b/MediaBrowser.Controller/Providers/IProviderManager.cs index 996ec27c09..9f7a76be64 100644 --- a/MediaBrowser.Controller/Providers/IProviderManager.cs +++ b/MediaBrowser.Controller/Providers/IProviderManager.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System; @@ -6,9 +8,7 @@ using System.IO; using System.Net.Http; using System.Threading; using System.Threading.Tasks; -using Jellyfin.Data.Entities; using Jellyfin.Data.Events; -using MediaBrowser.Common.Net; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; using MediaBrowser.Model.Configuration; @@ -22,9 +22,18 @@ namespace MediaBrowser.Controller.Providers /// </summary> public interface IProviderManager { + event EventHandler<GenericEventArgs<BaseItem>> RefreshStarted; + + event EventHandler<GenericEventArgs<BaseItem>> RefreshCompleted; + + event EventHandler<GenericEventArgs<Tuple<BaseItem, double>>> RefreshProgress; + /// <summary> /// Queues the refresh. /// </summary> + /// <param name="itemId">Item ID.</param> + /// <param name="options">MetadataRefreshOptions for operation.</param> + /// <param name="priority">RefreshPriority for operation.</param> void QueueRefresh(Guid itemId, MetadataRefreshOptions options, RefreshPriority priority); /// <summary> @@ -45,6 +54,14 @@ namespace MediaBrowser.Controller.Providers /// <returns>Task.</returns> Task<ItemUpdateType> RefreshSingleItem(BaseItem item, MetadataRefreshOptions options, CancellationToken cancellationToken); + /// <summary> + /// Runs multiple metadata refreshes concurrently. + /// </summary> + /// <param name="action">The action to run.</param> + /// <param name="cancellationToken">The cancellation token.</param> + /// <returns>A <see cref="Task"/> representing the result of the asynchronous operation.</returns> + Task RunMetadataRefresh(Func<Task> action, CancellationToken cancellationToken); + /// <summary> /// Saves the image. /// </summary> @@ -71,6 +88,13 @@ namespace MediaBrowser.Controller.Providers /// <summary> /// Saves the image. /// </summary> + /// <param name="item">Image to save.</param> + /// <param name="source">Source of image.</param> + /// <param name="mimeType">Mime type image.</param> + /// <param name="type">Type of image.</param> + /// <param name="imageIndex">Index of image.</param> + /// <param name="saveLocallyWithMedia">Option to save locally.</param> + /// <param name="cancellationToken">CancellationToken to use with operation.</param> /// <returns>Task.</returns> Task SaveImage(BaseItem item, string source, string mimeType, ImageType type, int? imageIndex, bool? saveLocallyWithMedia, CancellationToken cancellationToken); @@ -79,8 +103,16 @@ namespace MediaBrowser.Controller.Providers /// <summary> /// Adds the metadata providers. /// </summary> - void AddParts(IEnumerable<IImageProvider> imageProviders, IEnumerable<IMetadataService> metadataServices, IEnumerable<IMetadataProvider> metadataProviders, - IEnumerable<IMetadataSaver> savers, + /// <param name="imageProviders">Image providers to use.</param> + /// <param name="metadataServices">Metadata services to use.</param> + /// <param name="metadataProviders">Metadata providers to use.</param> + /// <param name="metadataSavers">Metadata savers to use.</param> + /// <param name="externalIds">External IDs to use.</param> + void AddParts( + IEnumerable<IImageProvider> imageProviders, + IEnumerable<IMetadataService> metadataServices, + IEnumerable<IMetadataProvider> metadataProviders, + IEnumerable<IMetadataSaver> metadataSavers, IEnumerable<IExternalId> externalIds); /// <summary> @@ -124,12 +156,14 @@ namespace MediaBrowser.Controller.Providers /// </summary> /// <param name="item">The item.</param> /// <param name="updateType">Type of the update.</param> - /// <returns>Task.</returns> void SaveMetadata(BaseItem item, ItemUpdateType updateType); /// <summary> /// Saves the metadata. /// </summary> + /// <param name="item">The item.</param> + /// <param name="updateType">Type of the update.</param> + /// <param name="savers">The metadata savers.</param> void SaveMetadata(BaseItem item, ItemUpdateType updateType, IEnumerable<string> savers); /// <summary> @@ -171,18 +205,5 @@ namespace MediaBrowser.Controller.Providers void OnRefreshComplete(BaseItem item); double? GetRefreshProgress(Guid id); - - event EventHandler<GenericEventArgs<BaseItem>> RefreshStarted; - - event EventHandler<GenericEventArgs<BaseItem>> RefreshCompleted; - - event EventHandler<GenericEventArgs<Tuple<BaseItem, double>>> RefreshProgress; - } - - public enum RefreshPriority - { - High = 0, - Normal = 1, - Low = 2 } } diff --git a/MediaBrowser.Controller/Providers/IRemoteImageProvider.cs b/MediaBrowser.Controller/Providers/IRemoteImageProvider.cs index ee8f5b860a..de1631dcf4 100644 --- a/MediaBrowser.Controller/Providers/IRemoteImageProvider.cs +++ b/MediaBrowser.Controller/Providers/IRemoteImageProvider.cs @@ -2,7 +2,6 @@ using System.Collections.Generic; using System.Net.Http; using System.Threading; using System.Threading.Tasks; -using MediaBrowser.Common.Net; using MediaBrowser.Controller.Entities; using MediaBrowser.Model.Entities; using MediaBrowser.Model.Providers; diff --git a/MediaBrowser.Controller/Providers/IRemoteSearchProvider.cs b/MediaBrowser.Controller/Providers/IRemoteSearchProvider.cs index 9592baa7c1..e401ed211c 100644 --- a/MediaBrowser.Controller/Providers/IRemoteSearchProvider.cs +++ b/MediaBrowser.Controller/Providers/IRemoteSearchProvider.cs @@ -3,7 +3,6 @@ using System.Net.Http; using System.Threading; using System.Threading.Tasks; -using MediaBrowser.Common.Net; namespace MediaBrowser.Controller.Providers { diff --git a/MediaBrowser.Controller/Providers/ImageRefreshOptions.cs b/MediaBrowser.Controller/Providers/ImageRefreshOptions.cs index 9fc379f045..2ac4c728ba 100644 --- a/MediaBrowser.Controller/Providers/ImageRefreshOptions.cs +++ b/MediaBrowser.Controller/Providers/ImageRefreshOptions.cs @@ -1,4 +1,6 @@ -#pragma warning disable CS1591 +#nullable disable + +#pragma warning disable CA1819, CS1591 using System; using System.Linq; @@ -8,6 +10,15 @@ namespace MediaBrowser.Controller.Providers { public class ImageRefreshOptions { + public ImageRefreshOptions(IDirectoryService directoryService) + { + ImageRefreshMode = MetadataRefreshMode.Default; + DirectoryService = directoryService; + + ReplaceImages = Array.Empty<ImageType>(); + IsAutomated = true; + } + public MetadataRefreshMode ImageRefreshMode { get; set; } public IDirectoryService DirectoryService { get; private set; } @@ -18,15 +29,6 @@ namespace MediaBrowser.Controller.Providers public bool IsAutomated { get; set; } - public ImageRefreshOptions(IDirectoryService directoryService) - { - ImageRefreshMode = MetadataRefreshMode.Default; - DirectoryService = directoryService; - - ReplaceImages = Array.Empty<ImageType>(); - IsAutomated = true; - } - public bool IsReplacingImage(ImageType type) { return ImageRefreshMode == MetadataRefreshMode.FullRefresh && diff --git a/MediaBrowser.Controller/Providers/ItemInfo.cs b/MediaBrowser.Controller/Providers/ItemInfo.cs index b50def043f..b8dd416a2d 100644 --- a/MediaBrowser.Controller/Providers/ItemInfo.cs +++ b/MediaBrowser.Controller/Providers/ItemInfo.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System; @@ -14,8 +16,7 @@ namespace MediaBrowser.Controller.Providers ContainingFolderPath = item.ContainingFolderPath; IsInMixedFolder = item.IsInMixedFolder; - var video = item as Video; - if (video != null) + if (item is Video video) { VideoType = video.VideoType; IsPlaceHolder = video.IsPlaceHolder; diff --git a/MediaBrowser.Controller/Providers/ItemLookupInfo.cs b/MediaBrowser.Controller/Providers/ItemLookupInfo.cs index b777cc1d31..460f4e500f 100644 --- a/MediaBrowser.Controller/Providers/ItemLookupInfo.cs +++ b/MediaBrowser.Controller/Providers/ItemLookupInfo.cs @@ -1,4 +1,6 @@ -#pragma warning disable CS1591 +#nullable disable + +#pragma warning disable CA2227, CS1591 using System; using System.Collections.Generic; @@ -8,12 +10,24 @@ namespace MediaBrowser.Controller.Providers { public class ItemLookupInfo : IHasProviderIds { + public ItemLookupInfo() + { + IsAutomated = true; + ProviderIds = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase); + } + /// <summary> /// Gets or sets the name. /// </summary> /// <value>The name.</value> public string Name { get; set; } + /// <summary> + /// Gets or sets the original title. + /// </summary> + /// <value>The original title of the item.</value> + public string OriginalTitle { get; set; } + /// <summary> /// Gets or sets the path. /// </summary> @@ -51,11 +65,5 @@ namespace MediaBrowser.Controller.Providers public DateTime? PremiereDate { get; set; } public bool IsAutomated { get; set; } - - public ItemLookupInfo() - { - IsAutomated = true; - ProviderIds = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase); - } } } diff --git a/MediaBrowser.Controller/Providers/LocalImageInfo.cs b/MediaBrowser.Controller/Providers/LocalImageInfo.cs index 41801862fd..a8e70e6d08 100644 --- a/MediaBrowser.Controller/Providers/LocalImageInfo.cs +++ b/MediaBrowser.Controller/Providers/LocalImageInfo.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using MediaBrowser.Model.Entities; diff --git a/MediaBrowser.Controller/Providers/MetadataRefreshOptions.cs b/MediaBrowser.Controller/Providers/MetadataRefreshOptions.cs index b92b837012..a42c7f8b5e 100644 --- a/MediaBrowser.Controller/Providers/MetadataRefreshOptions.cs +++ b/MediaBrowser.Controller/Providers/MetadataRefreshOptions.cs @@ -1,4 +1,6 @@ -#pragma warning disable CS1591 +#nullable disable + +#pragma warning disable CA1819, CS1591 using System; using System.Linq; @@ -9,21 +11,6 @@ namespace MediaBrowser.Controller.Providers { public class MetadataRefreshOptions : ImageRefreshOptions { - /// <summary> - /// When paired with MetadataRefreshMode=FullRefresh, all existing data will be overwritten with new data from the providers. - /// </summary> - public bool ReplaceAllMetadata { get; set; } - - public MetadataRefreshMode MetadataRefreshMode { get; set; } - - public RemoteSearchResult SearchResult { get; set; } - - public string[] RefreshPaths { get; set; } - - public bool ForceSave { get; set; } - - public bool EnableRemoteContentProbe { get; set; } - public MetadataRefreshOptions(IDirectoryService directoryService) : base(directoryService) { @@ -45,15 +32,28 @@ namespace MediaBrowser.Controller.Providers if (copy.RefreshPaths != null && copy.RefreshPaths.Length > 0) { - if (RefreshPaths == null) - { - RefreshPaths = Array.Empty<string>(); - } + RefreshPaths ??= Array.Empty<string>(); RefreshPaths = copy.RefreshPaths.ToArray(); } } + /// <summary> + /// Gets or sets a value indicating whether all existing data should be overwritten with new data from providers + /// when paired with MetadataRefreshMode=FullRefresh. + /// </summary> + public bool ReplaceAllMetadata { get; set; } + + public MetadataRefreshMode MetadataRefreshMode { get; set; } + + public RemoteSearchResult SearchResult { get; set; } + + public string[] RefreshPaths { get; set; } + + public bool ForceSave { get; set; } + + public bool EnableRemoteContentProbe { get; set; } + public bool RefreshItem(BaseItem item) { if (RefreshPaths != null && RefreshPaths.Length > 0) diff --git a/MediaBrowser.Controller/Providers/MetadataResult.cs b/MediaBrowser.Controller/Providers/MetadataResult.cs index 1c695cafa0..2085ae4adf 100644 --- a/MediaBrowser.Controller/Providers/MetadataResult.cs +++ b/MediaBrowser.Controller/Providers/MetadataResult.cs @@ -1,24 +1,40 @@ -#pragma warning disable CS1591 +#nullable disable + +#pragma warning disable CA1002, CA2227, CS1591 using System; using System.Collections.Generic; using System.Globalization; using MediaBrowser.Controller.Entities; +using MediaBrowser.Model.Entities; namespace MediaBrowser.Controller.Providers { public class MetadataResult<T> { - public List<LocalImageInfo> Images { get; set; } - - public List<UserItemData> UserDataList { get; set; } + // Images aren't always used so the allocation is a waste a lot of the time + private List<LocalImageInfo> _images; + private List<(string url, ImageType type)> _remoteImages; public MetadataResult() { - Images = new List<LocalImageInfo>(); ResultLanguage = "en"; } + public List<LocalImageInfo> Images + { + get => _images ??= new List<LocalImageInfo>(); + set => _images = value; + } + + public List<(string url, ImageType type)> RemoteImages + { + get => _remoteImages ??= new List<(string url, ImageType type)>(); + set => _remoteImages = value; + } + + public List<UserItemData> UserDataList { get; set; } + public List<PersonInfo> People { get; set; } public bool HasMetadata { get; set; } @@ -33,10 +49,7 @@ namespace MediaBrowser.Controller.Providers public void AddPerson(PersonInfo p) { - if (People == null) - { - People = new List<PersonInfo>(); - } + People ??= new List<PersonInfo>(); PeopleHelper.AddPerson(People, p); } @@ -50,16 +63,15 @@ namespace MediaBrowser.Controller.Providers { People = new List<PersonInfo>(); } - - People.Clear(); + else + { + People.Clear(); + } } public UserItemData GetOrAddUserData(string userId) { - if (UserDataList == null) - { - UserDataList = new List<UserItemData>(); - } + UserDataList ??= new List<UserItemData>(); UserItemData userData = null; diff --git a/MediaBrowser.Controller/Providers/MusicVideoInfo.cs b/MediaBrowser.Controller/Providers/MusicVideoInfo.cs index 0b927f6eb0..322320abdf 100644 --- a/MediaBrowser.Controller/Providers/MusicVideoInfo.cs +++ b/MediaBrowser.Controller/Providers/MusicVideoInfo.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System.Collections.Generic; diff --git a/MediaBrowser.Controller/Providers/RefreshPriority.cs b/MediaBrowser.Controller/Providers/RefreshPriority.cs new file mode 100644 index 0000000000..3619f679d6 --- /dev/null +++ b/MediaBrowser.Controller/Providers/RefreshPriority.cs @@ -0,0 +1,23 @@ +namespace MediaBrowser.Controller.Providers +{ + /// <summary> + /// Provider refresh priority. + /// </summary> + public enum RefreshPriority + { + /// <summary> + /// High priority. + /// </summary> + High = 0, + + /// <summary> + /// Normal priority. + /// </summary> + Normal = 1, + + /// <summary> + /// Low priority. + /// </summary> + Low = 2 + } +} \ No newline at end of file diff --git a/MediaBrowser.Controller/Providers/RemoteSearchQuery.cs b/MediaBrowser.Controller/Providers/RemoteSearchQuery.cs index 9653bc1c4a..d4df5fa0db 100644 --- a/MediaBrowser.Controller/Providers/RemoteSearchQuery.cs +++ b/MediaBrowser.Controller/Providers/RemoteSearchQuery.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System; @@ -12,7 +14,7 @@ namespace MediaBrowser.Controller.Providers public Guid ItemId { get; set; } /// <summary> - /// Will only search within the given provider when set. + /// Gets or sets the provider name to search within if set. /// </summary> public string SearchProviderName { get; set; } diff --git a/MediaBrowser.Controller/Providers/SeasonInfo.cs b/MediaBrowser.Controller/Providers/SeasonInfo.cs index 2a4c1f03c7..1edceb0e4a 100644 --- a/MediaBrowser.Controller/Providers/SeasonInfo.cs +++ b/MediaBrowser.Controller/Providers/SeasonInfo.cs @@ -1,4 +1,4 @@ -#pragma warning disable CS1591 +#pragma warning disable CA2227, CS1591 using System; using System.Collections.Generic; @@ -7,11 +7,11 @@ namespace MediaBrowser.Controller.Providers { public class SeasonInfo : ItemLookupInfo { - public Dictionary<string, string> SeriesProviderIds { get; set; } - public SeasonInfo() { SeriesProviderIds = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase); } + + public Dictionary<string, string> SeriesProviderIds { get; set; } } } diff --git a/MediaBrowser.Controller/Providers/SongInfo.cs b/MediaBrowser.Controller/Providers/SongInfo.cs index 58f76dca91..4b64a8a982 100644 --- a/MediaBrowser.Controller/Providers/SongInfo.cs +++ b/MediaBrowser.Controller/Providers/SongInfo.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System; @@ -7,16 +9,16 @@ namespace MediaBrowser.Controller.Providers { public class SongInfo : ItemLookupInfo { - public IReadOnlyList<string> AlbumArtists { get; set; } - - public string Album { get; set; } - - public IReadOnlyList<string> Artists { get; set; } - public SongInfo() { Artists = Array.Empty<string>(); AlbumArtists = Array.Empty<string>(); } + + public IReadOnlyList<string> AlbumArtists { get; set; } + + public string Album { get; set; } + + public IReadOnlyList<string> Artists { get; set; } } } diff --git a/MediaBrowser.Controller/QuickConnect/IQuickConnect.cs b/MediaBrowser.Controller/QuickConnect/IQuickConnect.cs index 959a2d7712..ad34c86042 100644 --- a/MediaBrowser.Controller/QuickConnect/IQuickConnect.cs +++ b/MediaBrowser.Controller/QuickConnect/IQuickConnect.cs @@ -9,40 +9,9 @@ namespace MediaBrowser.Controller.QuickConnect public interface IQuickConnect { /// <summary> - /// Gets or sets the length of user facing codes. + /// Gets a value indicating whether quick connect is enabled or not. /// </summary> - int CodeLength { get; set; } - - /// <summary> - /// Gets or sets the name of internal access tokens. - /// </summary> - string TokenName { get; set; } - - /// <summary> - /// Gets the current state of quick connect. - /// </summary> - QuickConnectState State { get; } - - /// <summary> - /// Gets or sets the time (in minutes) before quick connect will automatically deactivate. - /// </summary> - int Timeout { get; set; } - - /// <summary> - /// Assert that quick connect is currently active and throws an exception if it is not. - /// </summary> - void AssertActive(); - - /// <summary> - /// Temporarily activates quick connect for a short amount of time. - /// </summary> - void Activate(); - - /// <summary> - /// Changes the state of quick connect. - /// </summary> - /// <param name="newState">New state to change to.</param> - void SetState(QuickConnectState newState); + bool IsEnabled { get; } /// <summary> /// Initiates a new quick connect request. @@ -64,24 +33,5 @@ namespace MediaBrowser.Controller.QuickConnect /// <param name="code">Identifying code for the request.</param> /// <returns>A boolean indicating if the authorization completed successfully.</returns> bool AuthorizeRequest(Guid userId, string code); - - /// <summary> - /// Expire quick connect requests that are over the time limit. If <paramref name="expireAll"/> is true, all requests are unconditionally expired. - /// </summary> - /// <param name="expireAll">If true, all requests will be expired.</param> - void ExpireRequests(bool expireAll = false); - - /// <summary> - /// Deletes all quick connect access tokens for the provided user. - /// </summary> - /// <param name="user">Guid of the user to delete tokens for.</param> - /// <returns>A count of the deleted tokens.</returns> - int DeleteAllDevices(Guid user); - - /// <summary> - /// Generates a short code to display to the user to uniquely identify this request. - /// </summary> - /// <returns>A short, unique alphanumeric string.</returns> - string GenerateCode(); } } diff --git a/MediaBrowser.Controller/Resolvers/IItemResolver.cs b/MediaBrowser.Controller/Resolvers/IItemResolver.cs index 75286eadc0..b95d00aa3c 100644 --- a/MediaBrowser.Controller/Resolvers/IItemResolver.cs +++ b/MediaBrowser.Controller/Resolvers/IItemResolver.cs @@ -13,18 +13,18 @@ namespace MediaBrowser.Controller.Resolvers /// </summary> public interface IItemResolver { + /// <summary> + /// Gets the priority. + /// </summary> + /// <value>The priority.</value> + ResolverPriority Priority { get; } + /// <summary> /// Resolves the path. /// </summary> /// <param name="args">The args.</param> /// <returns>BaseItem.</returns> BaseItem ResolvePath(ItemResolveArgs args); - - /// <summary> - /// Gets the priority. - /// </summary> - /// <value>The priority.</value> - ResolverPriority Priority { get; } } public interface IMultiItemResolver @@ -38,14 +38,14 @@ namespace MediaBrowser.Controller.Resolvers public class MultiItemResolverResult { - public List<BaseItem> Items { get; set; } - - public List<FileSystemMetadata> ExtraFiles { get; set; } - public MultiItemResolverResult() { Items = new List<BaseItem>(); ExtraFiles = new List<FileSystemMetadata>(); } + + public List<BaseItem> Items { get; set; } + + public List<FileSystemMetadata> ExtraFiles { get; set; } } } diff --git a/MediaBrowser.Controller/Resolvers/IResolverIgnoreRule.cs b/MediaBrowser.Controller/Resolvers/IResolverIgnoreRule.cs index bb80e60256..a07b3e8988 100644 --- a/MediaBrowser.Controller/Resolvers/IResolverIgnoreRule.cs +++ b/MediaBrowser.Controller/Resolvers/IResolverIgnoreRule.cs @@ -4,7 +4,7 @@ using MediaBrowser.Model.IO; namespace MediaBrowser.Controller.Resolvers { /// <summary> - /// Provides a base "rule" that anyone can use to have paths ignored by the resolver + /// Provides a base "rule" that anyone can use to have paths ignored by the resolver. /// </summary> public interface IResolverIgnoreRule { diff --git a/MediaBrowser.Controller/Resolvers/BaseItemResolver.cs b/MediaBrowser.Controller/Resolvers/ItemResolver.cs similarity index 91% rename from MediaBrowser.Controller/Resolvers/BaseItemResolver.cs rename to MediaBrowser.Controller/Resolvers/ItemResolver.cs index 67acdd9a3c..7fd54fcc69 100644 --- a/MediaBrowser.Controller/Resolvers/BaseItemResolver.cs +++ b/MediaBrowser.Controller/Resolvers/ItemResolver.cs @@ -1,3 +1,5 @@ +#nullable disable + using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; @@ -6,26 +8,26 @@ namespace MediaBrowser.Controller.Resolvers /// <summary> /// Class ItemResolver. /// </summary> - /// <typeparam name="T"></typeparam> + /// <typeparam name="T">The type of BaseItem.</typeparam> public abstract class ItemResolver<T> : IItemResolver where T : BaseItem, new() { + /// <summary> + /// Gets the priority. + /// </summary> + /// <value>The priority.</value> + public virtual ResolverPriority Priority => ResolverPriority.First; + /// <summary> /// Resolves the specified args. /// </summary> /// <param name="args">The args.</param> /// <returns>`0.</returns> - protected virtual T Resolve(ItemResolveArgs args) + public virtual T Resolve(ItemResolveArgs args) { return null; } - /// <summary> - /// Gets the priority. - /// </summary> - /// <value>The priority.</value> - public virtual ResolverPriority Priority => ResolverPriority.First; - /// <summary> /// Sets initial values on the newly resolved item. /// </summary> diff --git a/MediaBrowser.Controller/Resolvers/ResolverPriority.cs b/MediaBrowser.Controller/Resolvers/ResolverPriority.cs index ac73a5ea89..d4f975b6d1 100644 --- a/MediaBrowser.Controller/Resolvers/ResolverPriority.cs +++ b/MediaBrowser.Controller/Resolvers/ResolverPriority.cs @@ -25,9 +25,14 @@ namespace MediaBrowser.Controller.Resolvers /// </summary> Fourth = 4, + /// <summary> + /// The Fifth. + /// </summary> + Fifth = 5, + /// <summary> /// The last. /// </summary> - Last = 5 + Last = 6 } } diff --git a/MediaBrowser.Controller/Security/AuthenticationInfo.cs b/MediaBrowser.Controller/Security/AuthenticationInfo.cs index efac9273ec..b4b242f1b2 100644 --- a/MediaBrowser.Controller/Security/AuthenticationInfo.cs +++ b/MediaBrowser.Controller/Security/AuthenticationInfo.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System; diff --git a/MediaBrowser.Controller/Security/AuthenticationInfoQuery.cs b/MediaBrowser.Controller/Security/AuthenticationInfoQuery.cs index c5f3da0b1b..3af6a525c7 100644 --- a/MediaBrowser.Controller/Security/AuthenticationInfoQuery.cs +++ b/MediaBrowser.Controller/Security/AuthenticationInfoQuery.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System; diff --git a/MediaBrowser.Controller/Security/IAuthenticationRepository.cs b/MediaBrowser.Controller/Security/IAuthenticationRepository.cs index 883b74165c..bd1289c1a6 100644 --- a/MediaBrowser.Controller/Security/IAuthenticationRepository.cs +++ b/MediaBrowser.Controller/Security/IAuthenticationRepository.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using MediaBrowser.Model.Devices; @@ -11,14 +13,12 @@ namespace MediaBrowser.Controller.Security /// Creates the specified information. /// </summary> /// <param name="info">The information.</param> - /// <returns>Task.</returns> void Create(AuthenticationInfo info); /// <summary> /// Updates the specified information. /// </summary> /// <param name="info">The information.</param> - /// <returns>Task.</returns> void Update(AuthenticationInfo info); /// <summary> diff --git a/MediaBrowser.Controller/Session/AuthenticationRequest.cs b/MediaBrowser.Controller/Session/AuthenticationRequest.cs index cc321fd22e..647c75e66e 100644 --- a/MediaBrowser.Controller/Session/AuthenticationRequest.cs +++ b/MediaBrowser.Controller/Session/AuthenticationRequest.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System; @@ -12,6 +14,7 @@ namespace MediaBrowser.Controller.Session public string Password { get; set; } + [Obsolete("Send full password in Password field")] public string PasswordSha1 { get; set; } public string App { get; set; } diff --git a/MediaBrowser.Controller/Session/ISessionController.cs b/MediaBrowser.Controller/Session/ISessionController.cs index bc4ccd44ca..b38ee11462 100644 --- a/MediaBrowser.Controller/Session/ISessionController.cs +++ b/MediaBrowser.Controller/Session/ISessionController.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System; @@ -24,6 +26,12 @@ namespace MediaBrowser.Controller.Session /// <summary> /// Sends the message. /// </summary> + /// <typeparam name="T">The type of data.</typeparam> + /// <param name="name">Name of message type.</param> + /// <param name="messageId">Message ID.</param> + /// <param name="data">Data to send.</param> + /// <param name="cancellationToken">CancellationToken for operation.</param> + /// <returns>A task.</returns> Task SendMessage<T>(SessionMessageType name, Guid messageId, T data, CancellationToken cancellationToken); } } diff --git a/MediaBrowser.Controller/Session/ISessionManager.cs b/MediaBrowser.Controller/Session/ISessionManager.cs index 04c3004ee6..0ff32fb536 100644 --- a/MediaBrowser.Controller/Session/ISessionManager.cs +++ b/MediaBrowser.Controller/Session/ISessionManager.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System; @@ -46,6 +48,11 @@ namespace MediaBrowser.Controller.Session event EventHandler<SessionEventArgs> SessionActivity; + /// <summary> + /// Occurs when [session controller connected]. + /// </summary> + event EventHandler<SessionEventArgs> SessionControllerConnected; + /// <summary> /// Occurs when [capabilities changed]. /// </summary> @@ -76,8 +83,15 @@ namespace MediaBrowser.Controller.Session /// <param name="deviceName">Name of the device.</param> /// <param name="remoteEndPoint">The remote end point.</param> /// <param name="user">The user.</param> + /// <returns>Session information.</returns> SessionInfo LogSessionActivity(string appName, string appVersion, string deviceId, string deviceName, string remoteEndPoint, Jellyfin.Data.Entities.User user); + /// <summary> + /// Used to report that a session controller has connected. + /// </summary> + /// <param name="session">The session.</param> + void OnSessionControllerConnected(SessionInfo session); + void UpdateDeviceName(string sessionId, string reportedDeviceName); /// <summary> @@ -92,7 +106,7 @@ namespace MediaBrowser.Controller.Session /// </summary> /// <param name="info">The info.</param> /// <returns>Task.</returns> - /// <exception cref="ArgumentNullException"></exception> + /// <exception cref="ArgumentNullException">Throws if an argument is null.</exception> Task OnPlaybackProgress(PlaybackProgressInfo info); Task OnPlaybackProgress(PlaybackProgressInfo info, bool isAutomated); @@ -102,14 +116,13 @@ namespace MediaBrowser.Controller.Session /// </summary> /// <param name="info">The info.</param> /// <returns>Task.</returns> - /// <exception cref="ArgumentNullException"></exception> + /// <exception cref="ArgumentNullException">Throws if an argument is null.</exception> Task OnPlaybackStopped(PlaybackStopInfo info); /// <summary> /// Reports the session ended. /// </summary> /// <param name="sessionId">The session identifier.</param> - /// <returns>Task.</returns> void ReportSessionEnded(string sessionId); /// <summary> @@ -143,22 +156,23 @@ namespace MediaBrowser.Controller.Session Task SendPlayCommand(string controllingSessionId, string sessionId, PlayRequest command, CancellationToken cancellationToken); /// <summary> - /// Sends the SyncPlayCommand. + /// Sends a SyncPlayCommand to a session. /// </summary> - /// <param name="sessionId">The session id.</param> + /// <param name="session">The session.</param> /// <param name="command">The command.</param> /// <param name="cancellationToken">The cancellation token.</param> /// <returns>Task.</returns> - Task SendSyncPlayCommand(string sessionId, SendCommand command, CancellationToken cancellationToken); + Task SendSyncPlayCommand(SessionInfo session, SendCommand command, CancellationToken cancellationToken); /// <summary> - /// Sends the SyncPlayGroupUpdate. + /// Sends a SyncPlayGroupUpdate to a session. /// </summary> - /// <param name="sessionId">The session id.</param> + /// <param name="session">The session.</param> /// <param name="command">The group update.</param> /// <param name="cancellationToken">The cancellation token.</param> + /// <typeparam name="T">Type of group.</typeparam> /// <returns>Task.</returns> - Task SendSyncPlayGroupUpdate<T>(string sessionId, GroupUpdate<T> command, CancellationToken cancellationToken); + Task SendSyncPlayGroupUpdate<T>(SessionInfo session, GroupUpdate<T> command, CancellationToken cancellationToken); /// <summary> /// Sends the browse command. @@ -183,8 +197,8 @@ namespace MediaBrowser.Controller.Session /// <summary> /// Sends the message to admin sessions. /// </summary> - /// <typeparam name="T"></typeparam> - /// <param name="name">The name.</param> + /// <typeparam name="T">Type of data.</typeparam> + /// <param name="name">Message type name.</param> /// <param name="data">The data.</param> /// <param name="cancellationToken">The cancellation token.</param> /// <returns>Task.</returns> @@ -193,18 +207,31 @@ namespace MediaBrowser.Controller.Session /// <summary> /// Sends the message to user sessions. /// </summary> - /// <typeparam name="T"></typeparam> + /// <typeparam name="T">Type of data.</typeparam> + /// <param name="userIds">Users to send messages to.</param> + /// <param name="name">Message type name.</param> + /// <param name="data">The data.</param> + /// <param name="cancellationToken">The cancellation token.</param> /// <returns>Task.</returns> Task SendMessageToUserSessions<T>(List<Guid> userIds, SessionMessageType name, T data, CancellationToken cancellationToken); + /// <summary> + /// Sends the message to user sessions. + /// </summary> + /// <typeparam name="T">Type of data.</typeparam> + /// <param name="userIds">Users to send messages to.</param> + /// <param name="name">Message type name.</param> + /// <param name="dataFn">Data function.</param> + /// <param name="cancellationToken">The cancellation token.</param> + /// <returns>Task.</returns> Task SendMessageToUserSessions<T>(List<Guid> userIds, SessionMessageType name, Func<T> dataFn, CancellationToken cancellationToken); /// <summary> /// Sends the message to user device sessions. /// </summary> - /// <typeparam name="T"></typeparam> + /// <typeparam name="T">Type of data.</typeparam> /// <param name="deviceId">The device identifier.</param> - /// <param name="name">The name.</param> + /// <param name="name">Message type name.</param> /// <param name="data">The data.</param> /// <param name="cancellationToken">The cancellation token.</param> /// <returns>Task.</returns> @@ -333,21 +360,21 @@ namespace MediaBrowser.Controller.Session /// Logouts the specified access token. /// </summary> /// <param name="accessToken">The access token.</param> - /// <returns>Task.</returns> void Logout(string accessToken); + void Logout(AuthenticationInfo accessToken); /// <summary> /// Revokes the user tokens. /// </summary> - /// <returns>Task.</returns> + /// <param name="userId">User ID.</param> + /// <param name="currentAccessToken">Current access token.</param> void RevokeUserTokens(Guid userId, string currentAccessToken); /// <summary> /// Revokes the token. /// </summary> /// <param name="id">The identifier.</param> - /// <returns>Task.</returns> void RevokeToken(string id); void CloseIfNeeded(SessionInfo session); diff --git a/MediaBrowser.Controller/Session/SessionEventArgs.cs b/MediaBrowser.Controller/Session/SessionEventArgs.cs index 097e32eaec..269fe7dc4d 100644 --- a/MediaBrowser.Controller/Session/SessionEventArgs.cs +++ b/MediaBrowser.Controller/Session/SessionEventArgs.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System; diff --git a/MediaBrowser.Controller/Session/SessionInfo.cs b/MediaBrowser.Controller/Session/SessionInfo.cs index ce58a60b9a..6134c0cf33 100644 --- a/MediaBrowser.Controller/Session/SessionInfo.cs +++ b/MediaBrowser.Controller/Session/SessionInfo.cs @@ -1,6 +1,9 @@ +#nullable disable + #pragma warning disable CS1591 using System; +using System.Collections.Generic; using System.Linq; using System.Text.Json.Serialization; using System.Threading; @@ -51,10 +54,10 @@ namespace MediaBrowser.Controller.Session public string RemoteEndPoint { get; set; } /// <summary> - /// Gets or sets the playable media types. + /// Gets the playable media types. /// </summary> /// <value>The playable media types.</value> - public string[] PlayableMediaTypes + public IReadOnlyList<string> PlayableMediaTypes { get { @@ -227,10 +230,10 @@ namespace MediaBrowser.Controller.Session public string UserPrimaryImageTag { get; set; } /// <summary> - /// Gets or sets the supported commands. + /// Gets the supported commands. /// </summary> /// <value>The supported commands.</value> - public GeneralCommandType[] SupportedCommands + public IReadOnlyList<GeneralCommandType> SupportedCommands => Capabilities == null ? Array.Empty<GeneralCommandType>() : Capabilities.SupportedCommands; public Tuple<ISessionController, bool> EnsureController<T>(Func<SessionInfo, ISessionController> factory) diff --git a/MediaBrowser.Controller/Sorting/IBaseItemComparer.cs b/MediaBrowser.Controller/Sorting/IBaseItemComparer.cs index 727cbe639c..07fe1ea8a9 100644 --- a/MediaBrowser.Controller/Sorting/IBaseItemComparer.cs +++ b/MediaBrowser.Controller/Sorting/IBaseItemComparer.cs @@ -6,7 +6,7 @@ namespace MediaBrowser.Controller.Sorting /// <summary> /// Interface IBaseItemComparer. /// </summary> - public interface IBaseItemComparer : IComparer<BaseItem> + public interface IBaseItemComparer : IComparer<BaseItem?> { /// <summary> /// Gets the name. diff --git a/MediaBrowser.Controller/Sorting/IUserBaseItemComparer.cs b/MediaBrowser.Controller/Sorting/IUserBaseItemComparer.cs index 6d03d97ae3..bd47db39a6 100644 --- a/MediaBrowser.Controller/Sorting/IUserBaseItemComparer.cs +++ b/MediaBrowser.Controller/Sorting/IUserBaseItemComparer.cs @@ -1,3 +1,5 @@ +#nullable disable + using MediaBrowser.Controller.Library; namespace MediaBrowser.Controller.Sorting diff --git a/MediaBrowser.Controller/Sorting/SortExtensions.cs b/MediaBrowser.Controller/Sorting/SortExtensions.cs index 88467814cd..f9c0d39ddd 100644 --- a/MediaBrowser.Controller/Sorting/SortExtensions.cs +++ b/MediaBrowser.Controller/Sorting/SortExtensions.cs @@ -3,12 +3,13 @@ using System; using System.Collections.Generic; using System.Linq; +using Jellyfin.Extensions; namespace MediaBrowser.Controller.Sorting { public static class SortExtensions { - private static readonly AlphanumComparator _comparer = new AlphanumComparator(); + private static readonly AlphanumericComparator _comparer = new AlphanumericComparator(); public static IEnumerable<T> OrderByString<T>(this IEnumerable<T> list, Func<T, string> getName) { diff --git a/MediaBrowser.Controller/Subtitles/ISubtitleManager.cs b/MediaBrowser.Controller/Subtitles/ISubtitleManager.cs index f43d523a63..3330dd5408 100644 --- a/MediaBrowser.Controller/Subtitles/ISubtitleManager.cs +++ b/MediaBrowser.Controller/Subtitles/ISubtitleManager.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System; @@ -26,6 +28,11 @@ namespace MediaBrowser.Controller.Subtitles /// <summary> /// Searches the subtitles. /// </summary> + /// <param name="video">The video.</param> + /// <param name="language">Subtitle language.</param> + /// <param name="isPerfectMatch">Require perfect match.</param> + /// <param name="cancellationToken">CancellationToken to use for the operation.</param> + /// <returns>Subtitles, wrapped in task.</returns> Task<RemoteSubtitleInfo[]> SearchSubtitles( Video video, string language, @@ -45,13 +52,30 @@ namespace MediaBrowser.Controller.Subtitles /// <summary> /// Downloads the subtitles. /// </summary> + /// <param name="video">The video.</param> + /// <param name="subtitleId">Subtitle ID.</param> + /// <param name="cancellationToken">CancellationToken to use for the operation.</param> + /// <returns>A task.</returns> Task DownloadSubtitles(Video video, string subtitleId, CancellationToken cancellationToken); /// <summary> /// Downloads the subtitles. /// </summary> + /// <param name="video">The video.</param> + /// <param name="libraryOptions">Library options to use.</param> + /// <param name="subtitleId">Subtitle ID.</param> + /// <param name="cancellationToken">CancellationToken to use for the operation.</param> + /// <returns>A task.</returns> Task DownloadSubtitles(Video video, LibraryOptions libraryOptions, string subtitleId, CancellationToken cancellationToken); + /// <summary> + /// Upload new subtitle. + /// </summary> + /// <param name="video">The video the subtitle belongs to.</param> + /// <param name="response">The subtitle response.</param> + /// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns> + Task UploadSubtitle(Video video, SubtitleResponse response); + /// <summary> /// Gets the remote subtitles. /// </summary> @@ -63,11 +87,16 @@ namespace MediaBrowser.Controller.Subtitles /// <summary> /// Deletes the subtitles. /// </summary> + /// <param name="item">Media item.</param> + /// <param name="index">Subtitle index.</param> + /// <returns>A task.</returns> Task DeleteSubtitles(BaseItem item, int index); /// <summary> /// Gets the providers. /// </summary> + /// <param name="item">The media item.</param> + /// <returns>Subtitles providers.</returns> SubtitleProviderInfo[] GetSupportedProviders(BaseItem item); } } diff --git a/MediaBrowser.Controller/Subtitles/ISubtitleProvider.cs b/MediaBrowser.Controller/Subtitles/ISubtitleProvider.cs index a633262de9..326348d583 100644 --- a/MediaBrowser.Controller/Subtitles/ISubtitleProvider.cs +++ b/MediaBrowser.Controller/Subtitles/ISubtitleProvider.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System.Collections.Generic; diff --git a/MediaBrowser.Controller/Subtitles/SubtitleDownloadFailureEventArgs.cs b/MediaBrowser.Controller/Subtitles/SubtitleDownloadFailureEventArgs.cs index ce8141219a..c782f57961 100644 --- a/MediaBrowser.Controller/Subtitles/SubtitleDownloadFailureEventArgs.cs +++ b/MediaBrowser.Controller/Subtitles/SubtitleDownloadFailureEventArgs.cs @@ -1,3 +1,5 @@ +#nullable disable + using System; using MediaBrowser.Controller.Entities; diff --git a/MediaBrowser.Controller/Subtitles/SubtitleResponse.cs b/MediaBrowser.Controller/Subtitles/SubtitleResponse.cs index a86b057783..85b3e6fbd7 100644 --- a/MediaBrowser.Controller/Subtitles/SubtitleResponse.cs +++ b/MediaBrowser.Controller/Subtitles/SubtitleResponse.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System.IO; diff --git a/MediaBrowser.Controller/Subtitles/SubtitleSearchRequest.cs b/MediaBrowser.Controller/Subtitles/SubtitleSearchRequest.cs index 7d3c20e8f1..767d87d465 100644 --- a/MediaBrowser.Controller/Subtitles/SubtitleSearchRequest.cs +++ b/MediaBrowser.Controller/Subtitles/SubtitleSearchRequest.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System; @@ -9,6 +11,15 @@ namespace MediaBrowser.Controller.Subtitles { public class SubtitleSearchRequest : IHasProviderIds { + public SubtitleSearchRequest() + { + SearchAllProviders = true; + ProviderIds = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase); + + DisabledSubtitleFetchers = Array.Empty<string>(); + SubtitleFetcherOrder = Array.Empty<string>(); + } + public string Language { get; set; } public string TwoLetterISOLanguageName { get; set; } @@ -40,14 +51,5 @@ namespace MediaBrowser.Controller.Subtitles public string[] DisabledSubtitleFetchers { get; set; } public string[] SubtitleFetcherOrder { get; set; } - - public SubtitleSearchRequest() - { - SearchAllProviders = true; - ProviderIds = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase); - - DisabledSubtitleFetchers = Array.Empty<string>(); - SubtitleFetcherOrder = Array.Empty<string>(); - } } } diff --git a/MediaBrowser.Controller/Sync/IHasDynamicAccess.cs b/MediaBrowser.Controller/Sync/IHasDynamicAccess.cs deleted file mode 100644 index e7395b136d..0000000000 --- a/MediaBrowser.Controller/Sync/IHasDynamicAccess.cs +++ /dev/null @@ -1,20 +0,0 @@ -#pragma warning disable CS1591 - -using System.Threading; -using System.Threading.Tasks; -using MediaBrowser.Model.Sync; - -namespace MediaBrowser.Controller.Sync -{ - public interface IHasDynamicAccess - { - /// <summary> - /// Gets the synced file information. - /// </summary> - /// <param name="id">The identifier.</param> - /// <param name="target">The target.</param> - /// <param name="cancellationToken">The cancellation token.</param> - /// <returns>Task<SyncedFileInfo>.</returns> - Task<SyncedFileInfo> GetSyncedFileInfo(string id, SyncTarget target, CancellationToken cancellationToken); - } -} diff --git a/MediaBrowser.Controller/Sync/IRemoteSyncProvider.cs b/MediaBrowser.Controller/Sync/IRemoteSyncProvider.cs deleted file mode 100644 index b2c53365c4..0000000000 --- a/MediaBrowser.Controller/Sync/IRemoteSyncProvider.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace MediaBrowser.Controller.Sync -{ - /// <summary> - /// A marker interface. - /// </summary> - public interface IRemoteSyncProvider - { - } -} diff --git a/MediaBrowser.Controller/Sync/IServerSyncProvider.cs b/MediaBrowser.Controller/Sync/IServerSyncProvider.cs deleted file mode 100644 index c97fd70442..0000000000 --- a/MediaBrowser.Controller/Sync/IServerSyncProvider.cs +++ /dev/null @@ -1,30 +0,0 @@ -#pragma warning disable CS1591 - -using System; -using System.IO; -using System.Threading; -using System.Threading.Tasks; -using MediaBrowser.Model.IO; -using MediaBrowser.Model.Querying; -using MediaBrowser.Model.Sync; - -namespace MediaBrowser.Controller.Sync -{ - public interface IServerSyncProvider : ISyncProvider - { - /// <summary> - /// Transfers the file. - /// </summary> - Task<SyncedFileInfo> SendFile(SyncJob syncJob, string originalMediaPath, Stream inputStream, bool isMedia, string[] outputPathParts, SyncTarget target, IProgress<double> progress, CancellationToken cancellationToken); - - Task<QueryResult<FileSystemMetadata>> GetFiles(string[] directoryPathParts, SyncTarget target, CancellationToken cancellationToken); - } - - public interface ISupportsDirectCopy - { - /// <summary> - /// Sends the file. - /// </summary> - Task<SyncedFileInfo> SendFile(SyncJob syncJob, string originalMediaPath, string inputPath, bool isMedia, string[] outputPathParts, SyncTarget target, IProgress<double> progress, CancellationToken cancellationToken); - } -} diff --git a/MediaBrowser.Controller/Sync/ISyncProvider.cs b/MediaBrowser.Controller/Sync/ISyncProvider.cs deleted file mode 100644 index 950cc73e85..0000000000 --- a/MediaBrowser.Controller/Sync/ISyncProvider.cs +++ /dev/null @@ -1,29 +0,0 @@ -#pragma warning disable CS1591 - -using System.Collections.Generic; -using MediaBrowser.Model.Sync; - -namespace MediaBrowser.Controller.Sync -{ - public interface ISyncProvider - { - /// <summary> - /// Gets the name. - /// </summary> - /// <value>The name.</value> - string Name { get; } - - /// <summary> - /// Gets the synchronize targets. - /// </summary> - /// <param name="userId">The user identifier.</param> - /// <returns>IEnumerable<SyncTarget>.</returns> - List<SyncTarget> GetSyncTargets(string userId); - - /// <summary> - /// Gets all synchronize targets. - /// </summary> - /// <returns>IEnumerable<SyncTarget>.</returns> - List<SyncTarget> GetAllSyncTargets(); - } -} diff --git a/MediaBrowser.Controller/Sync/SyncedFileInfo.cs b/MediaBrowser.Controller/Sync/SyncedFileInfo.cs deleted file mode 100644 index a626738fb2..0000000000 --- a/MediaBrowser.Controller/Sync/SyncedFileInfo.cs +++ /dev/null @@ -1,41 +0,0 @@ -#pragma warning disable CS1591 - -using System.Collections.Generic; -using MediaBrowser.Model.MediaInfo; - -namespace MediaBrowser.Controller.Sync -{ - public class SyncedFileInfo - { - public SyncedFileInfo() - { - RequiredHttpHeaders = new Dictionary<string, string>(); - } - - /// <summary> - /// Gets or sets the path. - /// </summary> - /// <value>The path.</value> - public string Path { get; set; } - - public string[] PathParts { get; set; } - - /// <summary> - /// Gets or sets the protocol. - /// </summary> - /// <value>The protocol.</value> - public MediaProtocol Protocol { get; set; } - - /// <summary> - /// Gets or sets the required HTTP headers. - /// </summary> - /// <value>The required HTTP headers.</value> - public Dictionary<string, string> RequiredHttpHeaders { get; set; } - - /// <summary> - /// Gets or sets the identifier. - /// </summary> - /// <value>The identifier.</value> - public string Id { get; set; } - } -} diff --git a/MediaBrowser.Controller/SyncPlay/GroupInfo.cs b/MediaBrowser.Controller/SyncPlay/GroupInfo.cs deleted file mode 100644 index a1cada25cc..0000000000 --- a/MediaBrowser.Controller/SyncPlay/GroupInfo.cs +++ /dev/null @@ -1,160 +0,0 @@ -using System; -using System.Collections.Generic; -using MediaBrowser.Controller.Entities; -using MediaBrowser.Controller.Session; - -namespace MediaBrowser.Controller.SyncPlay -{ - /// <summary> - /// Class GroupInfo. - /// </summary> - /// <remarks> - /// Class is not thread-safe, external locking is required when accessing methods. - /// </remarks> - public class GroupInfo - { - /// <summary> - /// The default ping value used for sessions. - /// </summary> - public const long DefaultPing = 500; - - /// <summary> - /// Gets the group identifier. - /// </summary> - /// <value>The group identifier.</value> - public Guid GroupId { get; } = Guid.NewGuid(); - - /// <summary> - /// Gets or sets the playing item. - /// </summary> - /// <value>The playing item.</value> - public BaseItem PlayingItem { get; set; } - - /// <summary> - /// Gets or sets a value indicating whether playback is paused. - /// </summary> - /// <value>Playback is paused.</value> - public bool IsPaused { get; set; } - - /// <summary> - /// Gets or sets a value indicating whether there are position ticks. - /// </summary> - /// <value>The position ticks.</value> - public long PositionTicks { get; set; } - - /// <summary> - /// Gets or sets the last activity. - /// </summary> - /// <value>The last activity.</value> - public DateTime LastActivity { get; set; } - - /// <summary> - /// Gets the participants. - /// </summary> - /// <value>The participants, or members of the group.</value> - public Dictionary<string, GroupMember> Participants { get; } = - new Dictionary<string, GroupMember>(StringComparer.OrdinalIgnoreCase); - - /// <summary> - /// Checks if a session is in this group. - /// </summary> - /// <param name="sessionId">The session id to check.</param> - /// <returns><c>true</c> if the session is in this group; <c>false</c> otherwise.</returns> - public bool ContainsSession(string sessionId) - { - return Participants.ContainsKey(sessionId); - } - - /// <summary> - /// Adds the session to the group. - /// </summary> - /// <param name="session">The session.</param> - public void AddSession(SessionInfo session) - { - Participants.TryAdd( - session.Id, - new GroupMember - { - Session = session, - Ping = DefaultPing, - IsBuffering = false - }); - } - - /// <summary> - /// Removes the session from the group. - /// </summary> - /// <param name="session">The session.</param> - public void RemoveSession(SessionInfo session) - { - Participants.Remove(session.Id); - } - - /// <summary> - /// Updates the ping of a session. - /// </summary> - /// <param name="session">The session.</param> - /// <param name="ping">The ping.</param> - public void UpdatePing(SessionInfo session, long ping) - { - if (Participants.TryGetValue(session.Id, out GroupMember value)) - { - value.Ping = ping; - } - } - - /// <summary> - /// Gets the highest ping in the group. - /// </summary> - /// <returns>The highest ping in the group.</returns> - public long GetHighestPing() - { - long max = long.MinValue; - foreach (var session in Participants.Values) - { - max = Math.Max(max, session.Ping); - } - - return max; - } - - /// <summary> - /// Sets the session's buffering state. - /// </summary> - /// <param name="session">The session.</param> - /// <param name="isBuffering">The state.</param> - public void SetBuffering(SessionInfo session, bool isBuffering) - { - if (Participants.TryGetValue(session.Id, out GroupMember value)) - { - value.IsBuffering = isBuffering; - } - } - - /// <summary> - /// Gets the group buffering state. - /// </summary> - /// <returns><c>true</c> if there is a session buffering in the group; <c>false</c> otherwise.</returns> - public bool IsBuffering() - { - foreach (var session in Participants.Values) - { - if (session.IsBuffering) - { - return true; - } - } - - return false; - } - - /// <summary> - /// Checks if the group is empty. - /// </summary> - /// <returns><c>true</c> if the group is empty; <c>false</c> otherwise.</returns> - public bool IsEmpty() - { - return Participants.Count == 0; - } - } -} diff --git a/MediaBrowser.Controller/SyncPlay/GroupMember.cs b/MediaBrowser.Controller/SyncPlay/GroupMember.cs index cde6f8e8ce..7e7e759a51 100644 --- a/MediaBrowser.Controller/SyncPlay/GroupMember.cs +++ b/MediaBrowser.Controller/SyncPlay/GroupMember.cs @@ -1,3 +1,5 @@ +#nullable disable + using MediaBrowser.Controller.Session; namespace MediaBrowser.Controller.SyncPlay @@ -8,21 +10,36 @@ namespace MediaBrowser.Controller.SyncPlay public class GroupMember { /// <summary> - /// Gets or sets a value indicating whether this member is buffering. + /// Initializes a new instance of the <see cref="GroupMember"/> class. /// </summary> - /// <value><c>true</c> if member is buffering; <c>false</c> otherwise.</value> - public bool IsBuffering { get; set; } + /// <param name="session">The session.</param> + public GroupMember(SessionInfo session) + { + Session = session; + } /// <summary> - /// Gets or sets the session. + /// Gets the session. /// </summary> /// <value>The session.</value> - public SessionInfo Session { get; set; } + public SessionInfo Session { get; } /// <summary> - /// Gets or sets the ping. + /// Gets or sets the ping, in milliseconds. /// </summary> /// <value>The ping.</value> public long Ping { get; set; } + + /// <summary> + /// Gets or sets a value indicating whether this member is buffering. + /// </summary> + /// <value><c>true</c> if member is buffering; <c>false</c> otherwise.</value> + public bool IsBuffering { get; set; } + + /// <summary> + /// Gets or sets a value indicating whether this member is following group playback. + /// </summary> + /// <value><c>true</c> to ignore member on group wait; <c>false</c> if they're following group playback.</value> + public bool IgnoreGroupWait { get; set; } } } diff --git a/MediaBrowser.Controller/SyncPlay/GroupStates/AbstractGroupState.cs b/MediaBrowser.Controller/SyncPlay/GroupStates/AbstractGroupState.cs new file mode 100644 index 0000000000..91a13fb28e --- /dev/null +++ b/MediaBrowser.Controller/SyncPlay/GroupStates/AbstractGroupState.cs @@ -0,0 +1,224 @@ +#nullable disable + +using System.Threading; +using MediaBrowser.Controller.Session; +using MediaBrowser.Controller.SyncPlay.PlaybackRequests; +using MediaBrowser.Model.SyncPlay; +using Microsoft.Extensions.Logging; + +namespace MediaBrowser.Controller.SyncPlay.GroupStates +{ + /// <summary> + /// Class AbstractGroupState. + /// </summary> + /// <remarks> + /// Class is not thread-safe, external locking is required when accessing methods. + /// </remarks> + public abstract class AbstractGroupState : IGroupState + { + /// <summary> + /// The logger. + /// </summary> + private readonly ILogger<AbstractGroupState> _logger; + + /// <summary> + /// Initializes a new instance of the <see cref="AbstractGroupState"/> class. + /// </summary> + /// <param name="loggerFactory">Instance of the <see cref="ILoggerFactory"/> interface.</param> + protected AbstractGroupState(ILoggerFactory loggerFactory) + { + LoggerFactory = loggerFactory; + _logger = loggerFactory.CreateLogger<AbstractGroupState>(); + } + + /// <inheritdoc /> + public abstract GroupStateType Type { get; } + + /// <summary> + /// Gets the logger factory. + /// </summary> + protected ILoggerFactory LoggerFactory { get; } + + /// <inheritdoc /> + public abstract void SessionJoined(IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken); + + /// <inheritdoc /> + public abstract void SessionLeaving(IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken); + + /// <inheritdoc /> + public virtual void HandleRequest(IGroupPlaybackRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken) + { + UnhandledRequest(request); + } + + /// <inheritdoc /> + public virtual void HandleRequest(PlayGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken) + { + UnhandledRequest(request); + } + + /// <inheritdoc /> + public virtual void HandleRequest(SetPlaylistItemGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken) + { + var waitingState = new WaitingGroupState(LoggerFactory); + context.SetState(waitingState); + waitingState.HandleRequest(request, context, Type, session, cancellationToken); + } + + /// <inheritdoc /> + public virtual void HandleRequest(RemoveFromPlaylistGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken) + { + var playingItemRemoved = context.RemoveFromPlayQueue(request.PlaylistItemIds); + + var playQueueUpdate = context.GetPlayQueueUpdate(PlayQueueUpdateReason.RemoveItems); + var update = context.NewSyncPlayGroupUpdate(GroupUpdateType.PlayQueue, playQueueUpdate); + context.SendGroupUpdate(session, SyncPlayBroadcastType.AllGroup, update, cancellationToken); + + if (playingItemRemoved && !context.PlayQueue.IsItemPlaying()) + { + _logger.LogDebug("Play queue in group {GroupId} is now empty.", context.GroupId.ToString()); + + IGroupState idleState = new IdleGroupState(LoggerFactory); + context.SetState(idleState); + var stopRequest = new StopGroupRequest(); + idleState.HandleRequest(stopRequest, context, Type, session, cancellationToken); + } + } + + /// <inheritdoc /> + public virtual void HandleRequest(MovePlaylistItemGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken) + { + var result = context.MoveItemInPlayQueue(request.PlaylistItemId, request.NewIndex); + + if (!result) + { + _logger.LogError("Unable to move item in group {GroupId}.", context.GroupId.ToString()); + return; + } + + var playQueueUpdate = context.GetPlayQueueUpdate(PlayQueueUpdateReason.MoveItem); + var update = context.NewSyncPlayGroupUpdate(GroupUpdateType.PlayQueue, playQueueUpdate); + context.SendGroupUpdate(session, SyncPlayBroadcastType.AllGroup, update, cancellationToken); + } + + /// <inheritdoc /> + public virtual void HandleRequest(QueueGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken) + { + var result = context.AddToPlayQueue(request.ItemIds, request.Mode); + + if (!result) + { + _logger.LogError("Unable to add items to play queue in group {GroupId}.", context.GroupId.ToString()); + return; + } + + var reason = request.Mode switch + { + GroupQueueMode.QueueNext => PlayQueueUpdateReason.QueueNext, + _ => PlayQueueUpdateReason.Queue + }; + var playQueueUpdate = context.GetPlayQueueUpdate(reason); + var update = context.NewSyncPlayGroupUpdate(GroupUpdateType.PlayQueue, playQueueUpdate); + context.SendGroupUpdate(session, SyncPlayBroadcastType.AllGroup, update, cancellationToken); + } + + /// <inheritdoc /> + public virtual void HandleRequest(UnpauseGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken) + { + UnhandledRequest(request); + } + + /// <inheritdoc /> + public virtual void HandleRequest(PauseGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken) + { + UnhandledRequest(request); + } + + /// <inheritdoc /> + public virtual void HandleRequest(StopGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken) + { + UnhandledRequest(request); + } + + /// <inheritdoc /> + public virtual void HandleRequest(SeekGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken) + { + UnhandledRequest(request); + } + + /// <inheritdoc /> + public virtual void HandleRequest(BufferGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken) + { + UnhandledRequest(request); + } + + /// <inheritdoc /> + public virtual void HandleRequest(ReadyGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken) + { + UnhandledRequest(request); + } + + /// <inheritdoc /> + public virtual void HandleRequest(NextItemGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken) + { + UnhandledRequest(request); + } + + /// <inheritdoc /> + public virtual void HandleRequest(PreviousItemGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken) + { + UnhandledRequest(request); + } + + /// <inheritdoc /> + public virtual void HandleRequest(SetRepeatModeGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken) + { + context.SetRepeatMode(request.Mode); + var playQueueUpdate = context.GetPlayQueueUpdate(PlayQueueUpdateReason.RepeatMode); + var update = context.NewSyncPlayGroupUpdate(GroupUpdateType.PlayQueue, playQueueUpdate); + context.SendGroupUpdate(session, SyncPlayBroadcastType.AllGroup, update, cancellationToken); + } + + /// <inheritdoc /> + public virtual void HandleRequest(SetShuffleModeGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken) + { + context.SetShuffleMode(request.Mode); + var playQueueUpdate = context.GetPlayQueueUpdate(PlayQueueUpdateReason.ShuffleMode); + var update = context.NewSyncPlayGroupUpdate(GroupUpdateType.PlayQueue, playQueueUpdate); + context.SendGroupUpdate(session, SyncPlayBroadcastType.AllGroup, update, cancellationToken); + } + + /// <inheritdoc /> + public virtual void HandleRequest(PingGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken) + { + // Collected pings are used to account for network latency when unpausing playback. + context.UpdatePing(session, request.Ping); + } + + /// <inheritdoc /> + public virtual void HandleRequest(IgnoreWaitGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken) + { + context.SetIgnoreGroupWait(session, request.IgnoreWait); + } + + /// <summary> + /// Sends a group state update to all group. + /// </summary> + /// <param name="context">The context of the state.</param> + /// <param name="reason">The reason of the state change.</param> + /// <param name="session">The session.</param> + /// <param name="cancellationToken">The cancellation token.</param> + protected void SendGroupStateUpdate(IGroupStateContext context, IGroupPlaybackRequest reason, SessionInfo session, CancellationToken cancellationToken) + { + // Notify relevant state change event. + var stateUpdate = new GroupStateUpdate(Type, reason.Action); + var update = context.NewSyncPlayGroupUpdate(GroupUpdateType.StateUpdate, stateUpdate); + context.SendGroupUpdate(session, SyncPlayBroadcastType.AllGroup, update, cancellationToken); + } + + private void UnhandledRequest(IGroupPlaybackRequest request) + { + _logger.LogWarning("Unhandled request of type {RequestType} in {StateType} state.", request.Action, Type); + } + } +} diff --git a/MediaBrowser.Controller/SyncPlay/GroupStates/IdleGroupState.cs b/MediaBrowser.Controller/SyncPlay/GroupStates/IdleGroupState.cs new file mode 100644 index 0000000000..6b5a7cdf9a --- /dev/null +++ b/MediaBrowser.Controller/SyncPlay/GroupStates/IdleGroupState.cs @@ -0,0 +1,128 @@ +#nullable disable + +using System.Threading; +using MediaBrowser.Controller.Session; +using MediaBrowser.Controller.SyncPlay.PlaybackRequests; +using MediaBrowser.Model.SyncPlay; +using Microsoft.Extensions.Logging; + +namespace MediaBrowser.Controller.SyncPlay.GroupStates +{ + /// <summary> + /// Class IdleGroupState. + /// </summary> + /// <remarks> + /// Class is not thread-safe, external locking is required when accessing methods. + /// </remarks> + public class IdleGroupState : AbstractGroupState + { + /// <summary> + /// The logger. + /// </summary> + private readonly ILogger<IdleGroupState> _logger; + + /// <summary> + /// Initializes a new instance of the <see cref="IdleGroupState"/> class. + /// </summary> + /// <param name="loggerFactory">Instance of the <see cref="ILoggerFactory"/> interface.</param> + public IdleGroupState(ILoggerFactory loggerFactory) + : base(loggerFactory) + { + _logger = LoggerFactory.CreateLogger<IdleGroupState>(); + } + + /// <inheritdoc /> + public override GroupStateType Type { get; } = GroupStateType.Idle; + + /// <inheritdoc /> + public override void SessionJoined(IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken) + { + SendStopCommand(context, Type, session, cancellationToken); + } + + /// <inheritdoc /> + public override void SessionLeaving(IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken) + { + // Do nothing. + } + + /// <inheritdoc /> + public override void HandleRequest(PlayGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken) + { + // Change state. + var waitingState = new WaitingGroupState(LoggerFactory); + context.SetState(waitingState); + waitingState.HandleRequest(request, context, Type, session, cancellationToken); + } + + /// <inheritdoc /> + public override void HandleRequest(UnpauseGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken) + { + // Change state. + var waitingState = new WaitingGroupState(LoggerFactory); + context.SetState(waitingState); + waitingState.HandleRequest(request, context, Type, session, cancellationToken); + } + + /// <inheritdoc /> + public override void HandleRequest(PauseGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken) + { + SendStopCommand(context, prevState, session, cancellationToken); + } + + /// <inheritdoc /> + public override void HandleRequest(StopGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken) + { + SendStopCommand(context, prevState, session, cancellationToken); + } + + /// <inheritdoc /> + public override void HandleRequest(SeekGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken) + { + SendStopCommand(context, prevState, session, cancellationToken); + } + + /// <inheritdoc /> + public override void HandleRequest(BufferGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken) + { + SendStopCommand(context, prevState, session, cancellationToken); + } + + /// <inheritdoc /> + public override void HandleRequest(ReadyGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken) + { + SendStopCommand(context, prevState, session, cancellationToken); + } + + /// <inheritdoc /> + public override void HandleRequest(NextItemGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken) + { + // Change state. + var waitingState = new WaitingGroupState(LoggerFactory); + context.SetState(waitingState); + waitingState.HandleRequest(request, context, Type, session, cancellationToken); + } + + /// <inheritdoc /> + public override void HandleRequest(PreviousItemGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken) + { + // Change state. + var waitingState = new WaitingGroupState(LoggerFactory); + context.SetState(waitingState); + waitingState.HandleRequest(request, context, Type, session, cancellationToken); + } + + private void SendStopCommand(IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken) + { + var command = context.NewSyncPlayCommand(SendCommandType.Stop); + if (!prevState.Equals(Type)) + { + context.SendCommand(session, SyncPlayBroadcastType.AllGroup, command, cancellationToken); + } + else + { + context.SendCommand(session, SyncPlayBroadcastType.CurrentSession, command, cancellationToken); + } + } + } +} diff --git a/MediaBrowser.Controller/SyncPlay/GroupStates/PausedGroupState.cs b/MediaBrowser.Controller/SyncPlay/GroupStates/PausedGroupState.cs new file mode 100644 index 0000000000..b9786ddb08 --- /dev/null +++ b/MediaBrowser.Controller/SyncPlay/GroupStates/PausedGroupState.cs @@ -0,0 +1,167 @@ +#nullable disable + +using System; +using System.Threading; +using MediaBrowser.Controller.Session; +using MediaBrowser.Controller.SyncPlay.PlaybackRequests; +using MediaBrowser.Model.SyncPlay; +using Microsoft.Extensions.Logging; + +namespace MediaBrowser.Controller.SyncPlay.GroupStates +{ + /// <summary> + /// Class PausedGroupState. + /// </summary> + /// <remarks> + /// Class is not thread-safe, external locking is required when accessing methods. + /// </remarks> + public class PausedGroupState : AbstractGroupState + { + /// <summary> + /// The logger. + /// </summary> + private readonly ILogger<PausedGroupState> _logger; + + /// <summary> + /// Initializes a new instance of the <see cref="PausedGroupState"/> class. + /// </summary> + /// <param name="loggerFactory">Instance of the <see cref="ILoggerFactory"/> interface.</param> + public PausedGroupState(ILoggerFactory loggerFactory) + : base(loggerFactory) + { + _logger = LoggerFactory.CreateLogger<PausedGroupState>(); + } + + /// <inheritdoc /> + public override GroupStateType Type { get; } = GroupStateType.Paused; + + /// <inheritdoc /> + public override void SessionJoined(IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken) + { + // Wait for session to be ready. + var waitingState = new WaitingGroupState(LoggerFactory); + context.SetState(waitingState); + waitingState.SessionJoined(context, Type, session, cancellationToken); + } + + /// <inheritdoc /> + public override void SessionLeaving(IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken) + { + // Do nothing. + } + + /// <inheritdoc /> + public override void HandleRequest(PlayGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken) + { + // Change state. + var waitingState = new WaitingGroupState(LoggerFactory); + context.SetState(waitingState); + waitingState.HandleRequest(request, context, Type, session, cancellationToken); + } + + /// <inheritdoc /> + public override void HandleRequest(UnpauseGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken) + { + // Change state. + var playingState = new PlayingGroupState(LoggerFactory); + context.SetState(playingState); + playingState.HandleRequest(request, context, Type, session, cancellationToken); + } + + /// <inheritdoc /> + public override void HandleRequest(PauseGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken) + { + if (!prevState.Equals(Type)) + { + // Pause group and compute the media playback position. + var currentTime = DateTime.UtcNow; + var elapsedTime = currentTime - context.LastActivity; + context.LastActivity = currentTime; + // Elapsed time is negative if event happens + // during the delay added to account for latency. + // In this phase clients haven't started the playback yet. + // In other words, LastActivity is in the future, + // when playback unpause is supposed to happen. + // Seek only if playback actually started. + context.PositionTicks += Math.Max(elapsedTime.Ticks, 0); + + var command = context.NewSyncPlayCommand(SendCommandType.Pause); + context.SendCommand(session, SyncPlayBroadcastType.AllGroup, command, cancellationToken); + + // Notify relevant state change event. + SendGroupStateUpdate(context, request, session, cancellationToken); + } + else + { + // Client got lost, sending current state. + var command = context.NewSyncPlayCommand(SendCommandType.Pause); + context.SendCommand(session, SyncPlayBroadcastType.CurrentSession, command, cancellationToken); + } + } + + /// <inheritdoc /> + public override void HandleRequest(StopGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken) + { + // Change state. + var idleState = new IdleGroupState(LoggerFactory); + context.SetState(idleState); + idleState.HandleRequest(request, context, Type, session, cancellationToken); + } + + /// <inheritdoc /> + public override void HandleRequest(SeekGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken) + { + // Change state. + var waitingState = new WaitingGroupState(LoggerFactory); + context.SetState(waitingState); + waitingState.HandleRequest(request, context, Type, session, cancellationToken); + } + + /// <inheritdoc /> + public override void HandleRequest(BufferGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken) + { + // Change state. + var waitingState = new WaitingGroupState(LoggerFactory); + context.SetState(waitingState); + waitingState.HandleRequest(request, context, Type, session, cancellationToken); + } + + /// <inheritdoc /> + public override void HandleRequest(ReadyGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken) + { + if (prevState.Equals(Type)) + { + // Client got lost, sending current state. + var command = context.NewSyncPlayCommand(SendCommandType.Pause); + context.SendCommand(session, SyncPlayBroadcastType.CurrentSession, command, cancellationToken); + } + else if (prevState.Equals(GroupStateType.Waiting)) + { + // Sending current state to all clients. + var command = context.NewSyncPlayCommand(SendCommandType.Pause); + context.SendCommand(session, SyncPlayBroadcastType.AllGroup, command, cancellationToken); + + // Notify relevant state change event. + SendGroupStateUpdate(context, request, session, cancellationToken); + } + } + + /// <inheritdoc /> + public override void HandleRequest(NextItemGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken) + { + // Change state. + var waitingState = new WaitingGroupState(LoggerFactory); + context.SetState(waitingState); + waitingState.HandleRequest(request, context, Type, session, cancellationToken); + } + + /// <inheritdoc /> + public override void HandleRequest(PreviousItemGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken) + { + // Change state. + var waitingState = new WaitingGroupState(LoggerFactory); + context.SetState(waitingState); + waitingState.HandleRequest(request, context, Type, session, cancellationToken); + } + } +} diff --git a/MediaBrowser.Controller/SyncPlay/GroupStates/PlayingGroupState.cs b/MediaBrowser.Controller/SyncPlay/GroupStates/PlayingGroupState.cs new file mode 100644 index 0000000000..cb1cadf0bc --- /dev/null +++ b/MediaBrowser.Controller/SyncPlay/GroupStates/PlayingGroupState.cs @@ -0,0 +1,170 @@ +#nullable disable + +using System; +using System.Threading; +using MediaBrowser.Controller.Session; +using MediaBrowser.Controller.SyncPlay.PlaybackRequests; +using MediaBrowser.Model.SyncPlay; +using Microsoft.Extensions.Logging; + +namespace MediaBrowser.Controller.SyncPlay.GroupStates +{ + /// <summary> + /// Class PlayingGroupState. + /// </summary> + /// <remarks> + /// Class is not thread-safe, external locking is required when accessing methods. + /// </remarks> + public class PlayingGroupState : AbstractGroupState + { + /// <summary> + /// The logger. + /// </summary> + private readonly ILogger<PlayingGroupState> _logger; + + /// <summary> + /// Initializes a new instance of the <see cref="PlayingGroupState"/> class. + /// </summary> + /// <param name="loggerFactory">Instance of the <see cref="ILoggerFactory"/> interface.</param> + public PlayingGroupState(ILoggerFactory loggerFactory) + : base(loggerFactory) + { + _logger = LoggerFactory.CreateLogger<PlayingGroupState>(); + } + + /// <inheritdoc /> + public override GroupStateType Type { get; } = GroupStateType.Playing; + + /// <summary> + /// Gets or sets a value indicating whether requests for buffering should be ignored. + /// </summary> + public bool IgnoreBuffering { get; set; } + + /// <inheritdoc /> + public override void SessionJoined(IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken) + { + // Wait for session to be ready. + var waitingState = new WaitingGroupState(LoggerFactory); + context.SetState(waitingState); + waitingState.SessionJoined(context, Type, session, cancellationToken); + } + + /// <inheritdoc /> + public override void SessionLeaving(IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken) + { + // Do nothing. + } + + /// <inheritdoc /> + public override void HandleRequest(PlayGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken) + { + // Change state. + var waitingState = new WaitingGroupState(LoggerFactory); + context.SetState(waitingState); + waitingState.HandleRequest(request, context, Type, session, cancellationToken); + } + + /// <inheritdoc /> + public override void HandleRequest(UnpauseGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken) + { + if (!prevState.Equals(Type)) + { + // Pick a suitable time that accounts for latency. + var delayMillis = Math.Max(context.GetHighestPing() * 2, context.DefaultPing); + + // Unpause group and set starting point in future. + // Clients will start playback at LastActivity (datetime) from PositionTicks (playback position). + // The added delay does not guarantee, of course, that the command will be received in time. + // Playback synchronization will mainly happen client side. + context.LastActivity = DateTime.UtcNow.AddMilliseconds(delayMillis); + + var command = context.NewSyncPlayCommand(SendCommandType.Unpause); + context.SendCommand(session, SyncPlayBroadcastType.AllGroup, command, cancellationToken); + + // Notify relevant state change event. + SendGroupStateUpdate(context, request, session, cancellationToken); + } + else + { + // Client got lost, sending current state. + var command = context.NewSyncPlayCommand(SendCommandType.Unpause); + context.SendCommand(session, SyncPlayBroadcastType.CurrentSession, command, cancellationToken); + } + } + + /// <inheritdoc /> + public override void HandleRequest(PauseGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken) + { + // Change state. + var pausedState = new PausedGroupState(LoggerFactory); + context.SetState(pausedState); + pausedState.HandleRequest(request, context, Type, session, cancellationToken); + } + + /// <inheritdoc /> + public override void HandleRequest(StopGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken) + { + // Change state. + var idleState = new IdleGroupState(LoggerFactory); + context.SetState(idleState); + idleState.HandleRequest(request, context, Type, session, cancellationToken); + } + + /// <inheritdoc /> + public override void HandleRequest(SeekGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken) + { + // Change state. + var waitingState = new WaitingGroupState(LoggerFactory); + context.SetState(waitingState); + waitingState.HandleRequest(request, context, Type, session, cancellationToken); + } + + /// <inheritdoc /> + public override void HandleRequest(BufferGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken) + { + if (IgnoreBuffering) + { + return; + } + + // Change state. + var waitingState = new WaitingGroupState(LoggerFactory); + context.SetState(waitingState); + waitingState.HandleRequest(request, context, Type, session, cancellationToken); + } + + /// <inheritdoc /> + public override void HandleRequest(ReadyGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken) + { + if (prevState.Equals(Type)) + { + // Group was not waiting, make sure client has latest state. + var command = context.NewSyncPlayCommand(SendCommandType.Unpause); + context.SendCommand(session, SyncPlayBroadcastType.CurrentSession, command, cancellationToken); + } + else if (prevState.Equals(GroupStateType.Waiting)) + { + // Notify relevant state change event. + SendGroupStateUpdate(context, request, session, cancellationToken); + } + } + + /// <inheritdoc /> + public override void HandleRequest(NextItemGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken) + { + // Change state. + var waitingState = new WaitingGroupState(LoggerFactory); + context.SetState(waitingState); + waitingState.HandleRequest(request, context, Type, session, cancellationToken); + } + + /// <inheritdoc /> + public override void HandleRequest(PreviousItemGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken) + { + // Change state. + var waitingState = new WaitingGroupState(LoggerFactory); + context.SetState(waitingState); + waitingState.HandleRequest(request, context, Type, session, cancellationToken); + } + } +} diff --git a/MediaBrowser.Controller/SyncPlay/GroupStates/WaitingGroupState.cs b/MediaBrowser.Controller/SyncPlay/GroupStates/WaitingGroupState.cs new file mode 100644 index 0000000000..a0c38b3097 --- /dev/null +++ b/MediaBrowser.Controller/SyncPlay/GroupStates/WaitingGroupState.cs @@ -0,0 +1,682 @@ +#nullable disable + +using System; +using System.Threading; +using MediaBrowser.Controller.Session; +using MediaBrowser.Controller.SyncPlay.PlaybackRequests; +using MediaBrowser.Model.SyncPlay; +using Microsoft.Extensions.Logging; + +namespace MediaBrowser.Controller.SyncPlay.GroupStates +{ + /// <summary> + /// Class WaitingGroupState. + /// </summary> + /// <remarks> + /// Class is not thread-safe, external locking is required when accessing methods. + /// </remarks> + public class WaitingGroupState : AbstractGroupState + { + /// <summary> + /// The logger. + /// </summary> + private readonly ILogger<WaitingGroupState> _logger; + + /// <summary> + /// Initializes a new instance of the <see cref="WaitingGroupState"/> class. + /// </summary> + /// <param name="loggerFactory">Instance of the <see cref="ILoggerFactory"/> interface.</param> + public WaitingGroupState(ILoggerFactory loggerFactory) + : base(loggerFactory) + { + _logger = LoggerFactory.CreateLogger<WaitingGroupState>(); + } + + /// <inheritdoc /> + public override GroupStateType Type { get; } = GroupStateType.Waiting; + + /// <summary> + /// Gets or sets a value indicating whether playback should resume when group is ready. + /// </summary> + public bool ResumePlaying { get; set; } = false; + + /// <summary> + /// Gets or sets a value indicating whether the initial state has been set. + /// </summary> + private bool InitialStateSet { get; set; } = false; + + /// <summary> + /// Gets or sets the group state before the first ever event. + /// </summary> + private GroupStateType InitialState { get; set; } + + /// <inheritdoc /> + public override void SessionJoined(IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken) + { + // Save state if first event. + if (!InitialStateSet) + { + InitialState = prevState; + InitialStateSet = true; + } + + if (prevState.Equals(GroupStateType.Playing)) + { + ResumePlaying = true; + // Pause group and compute the media playback position. + var currentTime = DateTime.UtcNow; + var elapsedTime = currentTime - context.LastActivity; + context.LastActivity = currentTime; + // Elapsed time is negative if event happens + // during the delay added to account for latency. + // In this phase clients haven't started the playback yet. + // In other words, LastActivity is in the future, + // when playback unpause is supposed to happen. + // Seek only if playback actually started. + context.PositionTicks += Math.Max(elapsedTime.Ticks, 0); + } + + // Prepare new session. + var playQueueUpdate = context.GetPlayQueueUpdate(PlayQueueUpdateReason.NewPlaylist); + var update = context.NewSyncPlayGroupUpdate(GroupUpdateType.PlayQueue, playQueueUpdate); + context.SendGroupUpdate(session, SyncPlayBroadcastType.CurrentSession, update, cancellationToken); + + context.SetBuffering(session, true); + + // Send pause command to all non-buffering sessions. + var command = context.NewSyncPlayCommand(SendCommandType.Pause); + context.SendCommand(session, SyncPlayBroadcastType.AllReady, command, cancellationToken); + } + + /// <inheritdoc /> + public override void SessionLeaving(IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken) + { + // Save state if first event. + if (!InitialStateSet) + { + InitialState = prevState; + InitialStateSet = true; + } + + context.SetBuffering(session, false); + + if (!context.IsBuffering()) + { + if (ResumePlaying) + { + _logger.LogDebug("Session {SessionId} left group {GroupId}, notifying others to resume.", session.Id, context.GroupId.ToString()); + + // Client, that was buffering, left the group. + var playingState = new PlayingGroupState(LoggerFactory); + context.SetState(playingState); + var unpauseRequest = new UnpauseGroupRequest(); + playingState.HandleRequest(unpauseRequest, context, Type, session, cancellationToken); + } + else + { + _logger.LogDebug("Session {SessionId} left group {GroupId}, returning to previous state.", session.Id, context.GroupId.ToString()); + + // Group is ready, returning to previous state. + var pausedState = new PausedGroupState(LoggerFactory); + context.SetState(pausedState); + } + } + } + + /// <inheritdoc /> + public override void HandleRequest(PlayGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken) + { + // Save state if first event. + if (!InitialStateSet) + { + InitialState = prevState; + InitialStateSet = true; + } + + ResumePlaying = true; + + var setQueueStatus = context.SetPlayQueue(request.PlayingQueue, request.PlayingItemPosition, request.StartPositionTicks); + if (!setQueueStatus) + { + _logger.LogError("Unable to set playing queue in group {GroupId}.", context.GroupId.ToString()); + + // Ignore request and return to previous state. + IGroupState newState = prevState switch { + GroupStateType.Playing => new PlayingGroupState(LoggerFactory), + GroupStateType.Paused => new PausedGroupState(LoggerFactory), + _ => new IdleGroupState(LoggerFactory) + }; + + context.SetState(newState); + return; + } + + var playQueueUpdate = context.GetPlayQueueUpdate(PlayQueueUpdateReason.NewPlaylist); + var update = context.NewSyncPlayGroupUpdate(GroupUpdateType.PlayQueue, playQueueUpdate); + context.SendGroupUpdate(session, SyncPlayBroadcastType.AllGroup, update, cancellationToken); + + // Reset status of sessions and await for all Ready events. + context.SetAllBuffering(true); + + _logger.LogDebug("Session {SessionId} set a new play queue in group {GroupId}.", session.Id, context.GroupId.ToString()); + } + + /// <inheritdoc /> + public override void HandleRequest(SetPlaylistItemGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken) + { + // Save state if first event. + if (!InitialStateSet) + { + InitialState = prevState; + InitialStateSet = true; + } + + ResumePlaying = true; + + var result = context.SetPlayingItem(request.PlaylistItemId); + if (result) + { + var playQueueUpdate = context.GetPlayQueueUpdate(PlayQueueUpdateReason.SetCurrentItem); + var update = context.NewSyncPlayGroupUpdate(GroupUpdateType.PlayQueue, playQueueUpdate); + context.SendGroupUpdate(session, SyncPlayBroadcastType.AllGroup, update, cancellationToken); + + // Reset status of sessions and await for all Ready events. + context.SetAllBuffering(true); + } + else + { + // Return to old state. + IGroupState newState = prevState switch + { + GroupStateType.Playing => new PlayingGroupState(LoggerFactory), + GroupStateType.Paused => new PausedGroupState(LoggerFactory), + _ => new IdleGroupState(LoggerFactory) + }; + + context.SetState(newState); + + _logger.LogDebug("Unable to change current playing item in group {GroupId}.", context.GroupId.ToString()); + } + } + + /// <inheritdoc /> + public override void HandleRequest(UnpauseGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken) + { + // Save state if first event. + if (!InitialStateSet) + { + InitialState = prevState; + InitialStateSet = true; + } + + if (prevState.Equals(GroupStateType.Idle)) + { + ResumePlaying = true; + context.RestartCurrentItem(); + + var playQueueUpdate = context.GetPlayQueueUpdate(PlayQueueUpdateReason.NewPlaylist); + var update = context.NewSyncPlayGroupUpdate(GroupUpdateType.PlayQueue, playQueueUpdate); + context.SendGroupUpdate(session, SyncPlayBroadcastType.AllGroup, update, cancellationToken); + + // Reset status of sessions and await for all Ready events. + context.SetAllBuffering(true); + + _logger.LogDebug("Group {GroupId} is waiting for all ready events.", context.GroupId.ToString()); + } + else + { + if (ResumePlaying) + { + _logger.LogDebug("Forcing the playback to start in group {GroupId}. Group-wait is disabled until next state change.", context.GroupId.ToString()); + + // An Unpause request is forcing the playback to start, ignoring sessions that are not ready. + context.SetAllBuffering(false); + + // Change state. + var playingState = new PlayingGroupState(LoggerFactory) + { + IgnoreBuffering = true + }; + context.SetState(playingState); + playingState.HandleRequest(request, context, Type, session, cancellationToken); + } + else + { + // Group would have gone to paused state, now will go to playing state when ready. + ResumePlaying = true; + + // Notify relevant state change event. + SendGroupStateUpdate(context, request, session, cancellationToken); + } + } + } + + /// <inheritdoc /> + public override void HandleRequest(PauseGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken) + { + // Save state if first event. + if (!InitialStateSet) + { + InitialState = prevState; + InitialStateSet = true; + } + + // Wait for sessions to be ready, then switch to paused state. + ResumePlaying = false; + + // Notify relevant state change event. + SendGroupStateUpdate(context, request, session, cancellationToken); + } + + /// <inheritdoc /> + public override void HandleRequest(StopGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken) + { + // Save state if first event. + if (!InitialStateSet) + { + InitialState = prevState; + InitialStateSet = true; + } + + // Change state. + var idleState = new IdleGroupState(LoggerFactory); + context.SetState(idleState); + idleState.HandleRequest(request, context, Type, session, cancellationToken); + } + + /// <inheritdoc /> + public override void HandleRequest(SeekGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken) + { + // Save state if first event. + if (!InitialStateSet) + { + InitialState = prevState; + InitialStateSet = true; + } + + if (prevState.Equals(GroupStateType.Playing)) + { + ResumePlaying = true; + } + else if (prevState.Equals(GroupStateType.Paused)) + { + ResumePlaying = false; + } + + // Sanitize PositionTicks. + var ticks = context.SanitizePositionTicks(request.PositionTicks); + + // Seek. + context.PositionTicks = ticks; + context.LastActivity = DateTime.UtcNow; + + var command = context.NewSyncPlayCommand(SendCommandType.Seek); + context.SendCommand(session, SyncPlayBroadcastType.AllGroup, command, cancellationToken); + + // Reset status of sessions and await for all Ready events. + context.SetAllBuffering(true); + + // Notify relevant state change event. + SendGroupStateUpdate(context, request, session, cancellationToken); + } + + /// <inheritdoc /> + public override void HandleRequest(BufferGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken) + { + // Save state if first event. + if (!InitialStateSet) + { + InitialState = prevState; + InitialStateSet = true; + } + + // Make sure the client is playing the correct item. + if (!request.PlaylistItemId.Equals(context.PlayQueue.GetPlayingItemPlaylistId())) + { + _logger.LogDebug("Session {SessionId} reported wrong playlist item in group {GroupId}.", session.Id, context.GroupId.ToString()); + + var playQueueUpdate = context.GetPlayQueueUpdate(PlayQueueUpdateReason.SetCurrentItem); + var updateSession = context.NewSyncPlayGroupUpdate(GroupUpdateType.PlayQueue, playQueueUpdate); + context.SendGroupUpdate(session, SyncPlayBroadcastType.CurrentSession, updateSession, cancellationToken); + context.SetBuffering(session, true); + + return; + } + + if (prevState.Equals(GroupStateType.Playing)) + { + // Resume playback when all ready. + ResumePlaying = true; + + context.SetBuffering(session, true); + + // Pause group and compute the media playback position. + var currentTime = DateTime.UtcNow; + var elapsedTime = currentTime - context.LastActivity; + context.LastActivity = currentTime; + // Elapsed time is negative if event happens + // during the delay added to account for latency. + // In this phase clients haven't started the playback yet. + // In other words, LastActivity is in the future, + // when playback unpause is supposed to happen. + // Seek only if playback actually started. + context.PositionTicks += Math.Max(elapsedTime.Ticks, 0); + + // Send pause command to all non-buffering sessions. + var command = context.NewSyncPlayCommand(SendCommandType.Pause); + context.SendCommand(session, SyncPlayBroadcastType.AllReady, command, cancellationToken); + } + else if (prevState.Equals(GroupStateType.Paused)) + { + // Don't resume playback when all ready. + ResumePlaying = false; + + context.SetBuffering(session, true); + + // Send pause command to buffering session. + var command = context.NewSyncPlayCommand(SendCommandType.Pause); + context.SendCommand(session, SyncPlayBroadcastType.CurrentSession, command, cancellationToken); + } + else if (prevState.Equals(GroupStateType.Waiting)) + { + // Another session is now buffering. + context.SetBuffering(session, true); + + if (!ResumePlaying) + { + // Force update for this session that should be paused. + var command = context.NewSyncPlayCommand(SendCommandType.Pause); + context.SendCommand(session, SyncPlayBroadcastType.CurrentSession, command, cancellationToken); + } + } + + // Notify relevant state change event. + SendGroupStateUpdate(context, request, session, cancellationToken); + } + + /// <inheritdoc /> + public override void HandleRequest(ReadyGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken) + { + // Save state if first event. + if (!InitialStateSet) + { + InitialState = prevState; + InitialStateSet = true; + } + + // Make sure the client is playing the correct item. + if (!request.PlaylistItemId.Equals(context.PlayQueue.GetPlayingItemPlaylistId())) + { + _logger.LogDebug("Session {SessionId} reported wrong playlist item in group {GroupId}.", session.Id, context.GroupId.ToString()); + + var playQueueUpdate = context.GetPlayQueueUpdate(PlayQueueUpdateReason.SetCurrentItem); + var update = context.NewSyncPlayGroupUpdate(GroupUpdateType.PlayQueue, playQueueUpdate); + context.SendGroupUpdate(session, SyncPlayBroadcastType.CurrentSession, update, cancellationToken); + context.SetBuffering(session, true); + + return; + } + + // Compute elapsed time between the client reported time and now. + // Elapsed time is used to estimate the client position when playback is unpaused. + // Ideally, the request is received and handled without major delays. + // However, to avoid waiting indefinitely when a client is not reporting a correct time, + // the elapsed time is ignored after a certain threshold. + var currentTime = DateTime.UtcNow; + var elapsedTime = currentTime.Subtract(request.When); + var timeSyncThresholdTicks = TimeSpan.FromMilliseconds(context.TimeSyncOffset).Ticks; + if (Math.Abs(elapsedTime.Ticks) > timeSyncThresholdTicks) + { + _logger.LogWarning("Session {SessionId} is not time syncing properly. Ignoring elapsed time.", session.Id); + + elapsedTime = TimeSpan.Zero; + } + + // Ignore elapsed time if client is paused. + if (!request.IsPlaying) + { + elapsedTime = TimeSpan.Zero; + } + + var requestTicks = context.SanitizePositionTicks(request.PositionTicks); + var clientPosition = TimeSpan.FromTicks(requestTicks) + elapsedTime; + var delayTicks = context.PositionTicks - clientPosition.Ticks; + var maxPlaybackOffsetTicks = TimeSpan.FromMilliseconds(context.MaxPlaybackOffset).Ticks; + + _logger.LogDebug("Session {SessionId} is at {PositionTicks} (delay of {Delay} seconds) in group {GroupId}.", session.Id, clientPosition, TimeSpan.FromTicks(delayTicks).TotalSeconds, context.GroupId.ToString()); + + if (ResumePlaying) + { + // Handle case where session reported as ready but in reality + // it has no clue of the real position nor the playback state. + if (!request.IsPlaying && Math.Abs(delayTicks) > maxPlaybackOffsetTicks) + { + // Session not ready at all. + context.SetBuffering(session, true); + + // Correcting session's position. + var command = context.NewSyncPlayCommand(SendCommandType.Seek); + context.SendCommand(session, SyncPlayBroadcastType.CurrentSession, command, cancellationToken); + + // Notify relevant state change event. + SendGroupStateUpdate(context, request, session, cancellationToken); + + _logger.LogWarning("Session {SessionId} got lost in time, correcting.", session.Id); + return; + } + + // Session is ready. + context.SetBuffering(session, false); + + if (context.IsBuffering()) + { + // Others are still buffering, tell this client to pause when ready. + var command = context.NewSyncPlayCommand(SendCommandType.Pause); + command.When = currentTime.AddTicks(delayTicks); + context.SendCommand(session, SyncPlayBroadcastType.CurrentSession, command, cancellationToken); + + _logger.LogInformation("Session {SessionId} will pause when ready in {Delay} seconds. Group {GroupId} is waiting for all ready events.", session.Id, TimeSpan.FromTicks(delayTicks).TotalSeconds, context.GroupId.ToString()); + } + else + { + // If all ready, then start playback. + // Let other clients resume as soon as the buffering client catches up. + if (delayTicks > context.GetHighestPing() * 2 * TimeSpan.TicksPerMillisecond) + { + // Client that was buffering is recovering, notifying others to resume. + context.LastActivity = currentTime.AddTicks(delayTicks); + var command = context.NewSyncPlayCommand(SendCommandType.Unpause); + var filter = SyncPlayBroadcastType.AllExceptCurrentSession; + if (!request.IsPlaying) + { + filter = SyncPlayBroadcastType.AllGroup; + } + + context.SendCommand(session, filter, command, cancellationToken); + + _logger.LogInformation("Session {SessionId} is recovering, group {GroupId} will resume in {Delay} seconds.", session.Id, context.GroupId.ToString(), TimeSpan.FromTicks(delayTicks).TotalSeconds); + } + else + { + // Client, that was buffering, resumed playback but did not update others in time. + delayTicks = context.GetHighestPing() * 2 * TimeSpan.TicksPerMillisecond; + delayTicks = Math.Max(delayTicks, context.DefaultPing); + + context.LastActivity = currentTime.AddTicks(delayTicks); + + var command = context.NewSyncPlayCommand(SendCommandType.Unpause); + context.SendCommand(session, SyncPlayBroadcastType.AllGroup, command, cancellationToken); + + _logger.LogWarning("Session {SessionId} resumed playback, group {GroupId} has {Delay} seconds to recover.", session.Id, context.GroupId.ToString(), TimeSpan.FromTicks(delayTicks).TotalSeconds); + } + + // Change state. + var playingState = new PlayingGroupState(LoggerFactory); + context.SetState(playingState); + playingState.HandleRequest(request, context, Type, session, cancellationToken); + } + } + else + { + // Check that session is really ready, tolerate player imperfections under a certain threshold. + if (Math.Abs(context.PositionTicks - requestTicks) > maxPlaybackOffsetTicks) + { + // Session still not ready. + context.SetBuffering(session, true); + // Session is seeking to wrong position, correcting. + var command = context.NewSyncPlayCommand(SendCommandType.Seek); + context.SendCommand(session, SyncPlayBroadcastType.CurrentSession, command, cancellationToken); + + // Notify relevant state change event. + SendGroupStateUpdate(context, request, session, cancellationToken); + + _logger.LogWarning("Session {SessionId} is seeking to wrong position, correcting.", session.Id); + return; + } + else + { + // Session is ready. + context.SetBuffering(session, false); + } + + if (!context.IsBuffering()) + { + _logger.LogDebug("Session {SessionId} is ready, group {GroupId} is ready.", session.Id, context.GroupId.ToString()); + + // Group is ready, returning to previous state. + var pausedState = new PausedGroupState(LoggerFactory); + context.SetState(pausedState); + + if (InitialState.Equals(GroupStateType.Playing)) + { + // Group went from playing to waiting state and a pause request occured while waiting. + var pauseRequest = new PauseGroupRequest(); + pausedState.HandleRequest(pauseRequest, context, Type, session, cancellationToken); + } + else if (InitialState.Equals(GroupStateType.Paused)) + { + pausedState.HandleRequest(request, context, Type, session, cancellationToken); + } + } + } + } + + /// <inheritdoc /> + public override void HandleRequest(NextItemGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken) + { + // Save state if first event. + if (!InitialStateSet) + { + InitialState = prevState; + InitialStateSet = true; + } + + ResumePlaying = true; + + // Make sure the client knows the playing item, to avoid duplicate requests. + if (!request.PlaylistItemId.Equals(context.PlayQueue.GetPlayingItemPlaylistId())) + { + _logger.LogDebug("Session {SessionId} provided the wrong playlist item for group {GroupId}.", session.Id, context.GroupId.ToString()); + return; + } + + var newItem = context.NextItemInQueue(); + if (newItem) + { + // Send playing-queue update. + var playQueueUpdate = context.GetPlayQueueUpdate(PlayQueueUpdateReason.NextItem); + var update = context.NewSyncPlayGroupUpdate(GroupUpdateType.PlayQueue, playQueueUpdate); + context.SendGroupUpdate(session, SyncPlayBroadcastType.AllGroup, update, cancellationToken); + + // Reset status of sessions and await for all Ready events. + context.SetAllBuffering(true); + } + else + { + // Return to old state. + IGroupState newState = prevState switch + { + GroupStateType.Playing => new PlayingGroupState(LoggerFactory), + GroupStateType.Paused => new PausedGroupState(LoggerFactory), + _ => new IdleGroupState(LoggerFactory) + }; + + context.SetState(newState); + + _logger.LogDebug("No next item available in group {GroupId}.", context.GroupId.ToString()); + } + } + + /// <inheritdoc /> + public override void HandleRequest(PreviousItemGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken) + { + // Save state if first event. + if (!InitialStateSet) + { + InitialState = prevState; + InitialStateSet = true; + } + + ResumePlaying = true; + + // Make sure the client knows the playing item, to avoid duplicate requests. + if (!request.PlaylistItemId.Equals(context.PlayQueue.GetPlayingItemPlaylistId())) + { + _logger.LogDebug("Session {SessionId} provided the wrong playlist item for group {GroupId}.", session.Id, context.GroupId.ToString()); + return; + } + + var newItem = context.PreviousItemInQueue(); + if (newItem) + { + // Send playing-queue update. + var playQueueUpdate = context.GetPlayQueueUpdate(PlayQueueUpdateReason.PreviousItem); + var update = context.NewSyncPlayGroupUpdate(GroupUpdateType.PlayQueue, playQueueUpdate); + context.SendGroupUpdate(session, SyncPlayBroadcastType.AllGroup, update, cancellationToken); + + // Reset status of sessions and await for all Ready events. + context.SetAllBuffering(true); + } + else + { + // Return to old state. + IGroupState newState = prevState switch + { + GroupStateType.Playing => new PlayingGroupState(LoggerFactory), + GroupStateType.Paused => new PausedGroupState(LoggerFactory), + _ => new IdleGroupState(LoggerFactory) + }; + + context.SetState(newState); + + _logger.LogDebug("No previous item available in group {GroupId}.", context.GroupId.ToString()); + } + } + + /// <inheritdoc /> + public override void HandleRequest(IgnoreWaitGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken) + { + context.SetIgnoreGroupWait(session, request.IgnoreWait); + + if (!context.IsBuffering()) + { + _logger.LogDebug("Ignoring session {SessionId}, group {GroupId} is ready.", session.Id, context.GroupId.ToString()); + + if (ResumePlaying) + { + // Client, that was buffering, stopped following playback. + var playingState = new PlayingGroupState(LoggerFactory); + context.SetState(playingState); + var unpauseRequest = new UnpauseGroupRequest(); + playingState.HandleRequest(unpauseRequest, context, Type, session, cancellationToken); + } + else + { + // Group is ready, returning to previous state. + var pausedState = new PausedGroupState(LoggerFactory); + context.SetState(pausedState); + } + } + } + } +} diff --git a/MediaBrowser.Controller/SyncPlay/IGroupPlaybackRequest.cs b/MediaBrowser.Controller/SyncPlay/IGroupPlaybackRequest.cs new file mode 100644 index 0000000000..9045063eed --- /dev/null +++ b/MediaBrowser.Controller/SyncPlay/IGroupPlaybackRequest.cs @@ -0,0 +1,29 @@ +#nullable disable + +using System.Threading; +using MediaBrowser.Controller.Session; +using MediaBrowser.Model.SyncPlay; + +namespace MediaBrowser.Controller.SyncPlay +{ + /// <summary> + /// Interface IGroupPlaybackRequest. + /// </summary> + public interface IGroupPlaybackRequest : ISyncPlayRequest + { + /// <summary> + /// Gets the playback request type. + /// </summary> + /// <returns>The playback request type.</returns> + PlaybackRequestType Action { get; } + + /// <summary> + /// Applies the request to a group. + /// </summary> + /// <param name="context">The context of the state.</param> + /// <param name="state">The current state.</param> + /// <param name="session">The session.</param> + /// <param name="cancellationToken">The cancellation token.</param> + void Apply(IGroupStateContext context, IGroupState state, SessionInfo session, CancellationToken cancellationToken); + } +} diff --git a/MediaBrowser.Controller/SyncPlay/IGroupState.cs b/MediaBrowser.Controller/SyncPlay/IGroupState.cs new file mode 100644 index 0000000000..0666a62a85 --- /dev/null +++ b/MediaBrowser.Controller/SyncPlay/IGroupState.cs @@ -0,0 +1,219 @@ +#nullable disable + +using System.Threading; +using MediaBrowser.Controller.Session; +using MediaBrowser.Controller.SyncPlay.PlaybackRequests; +using MediaBrowser.Model.SyncPlay; + +namespace MediaBrowser.Controller.SyncPlay +{ + /// <summary> + /// Interface IGroupState. + /// </summary> + public interface IGroupState + { + /// <summary> + /// Gets the group state type. + /// </summary> + /// <value>The group state type.</value> + GroupStateType Type { get; } + + /// <summary> + /// Handles a session that joined the group. + /// </summary> + /// <param name="context">The context of the state.</param> + /// <param name="prevState">The previous state.</param> + /// <param name="session">The session.</param> + /// <param name="cancellationToken">The cancellation token.</param> + void SessionJoined(IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken); + + /// <summary> + /// Handles a session that is leaving the group. + /// </summary> + /// <param name="context">The context of the state.</param> + /// <param name="prevState">The previous state.</param> + /// <param name="session">The session.</param> + /// <param name="cancellationToken">The cancellation token.</param> + void SessionLeaving(IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken); + + /// <summary> + /// Generic handler. Context's state can change. + /// </summary> + /// <param name="request">The generic request.</param> + /// <param name="context">The context of the state.</param> + /// <param name="prevState">The previous state.</param> + /// <param name="session">The session.</param> + /// <param name="cancellationToken">The cancellation token.</param> + void HandleRequest(IGroupPlaybackRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken); + + /// <summary> + /// Handles a play request from a session. Context's state can change. + /// </summary> + /// <param name="request">The play request.</param> + /// <param name="context">The context of the state.</param> + /// <param name="prevState">The previous state.</param> + /// <param name="session">The session.</param> + /// <param name="cancellationToken">The cancellation token.</param> + void HandleRequest(PlayGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken); + + /// <summary> + /// Handles a set-playlist-item request from a session. Context's state can change. + /// </summary> + /// <param name="request">The set-playlist-item request.</param> + /// <param name="context">The context of the state.</param> + /// <param name="prevState">The previous state.</param> + /// <param name="session">The session.</param> + /// <param name="cancellationToken">The cancellation token.</param> + void HandleRequest(SetPlaylistItemGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken); + + /// <summary> + /// Handles a remove-items request from a session. Context's state can change. + /// </summary> + /// <param name="request">The remove-items request.</param> + /// <param name="context">The context of the state.</param> + /// <param name="prevState">The previous state.</param> + /// <param name="session">The session.</param> + /// <param name="cancellationToken">The cancellation token.</param> + void HandleRequest(RemoveFromPlaylistGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken); + + /// <summary> + /// Handles a move-playlist-item request from a session. Context's state should not change. + /// </summary> + /// <param name="request">The move-playlist-item request.</param> + /// <param name="context">The context of the state.</param> + /// <param name="prevState">The previous state.</param> + /// <param name="session">The session.</param> + /// <param name="cancellationToken">The cancellation token.</param> + void HandleRequest(MovePlaylistItemGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken); + + /// <summary> + /// Handles a queue request from a session. Context's state should not change. + /// </summary> + /// <param name="request">The queue request.</param> + /// <param name="context">The context of the state.</param> + /// <param name="prevState">The previous state.</param> + /// <param name="session">The session.</param> + /// <param name="cancellationToken">The cancellation token.</param> + void HandleRequest(QueueGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken); + + /// <summary> + /// Handles an unpause request from a session. Context's state can change. + /// </summary> + /// <param name="request">The unpause request.</param> + /// <param name="context">The context of the state.</param> + /// <param name="prevState">The previous state.</param> + /// <param name="session">The session.</param> + /// <param name="cancellationToken">The cancellation token.</param> + void HandleRequest(UnpauseGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken); + + /// <summary> + /// Handles a pause request from a session. Context's state can change. + /// </summary> + /// <param name="request">The pause request.</param> + /// <param name="context">The context of the state.</param> + /// <param name="prevState">The previous state.</param> + /// <param name="session">The session.</param> + /// <param name="cancellationToken">The cancellation token.</param> + void HandleRequest(PauseGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken); + + /// <summary> + /// Handles a stop request from a session. Context's state can change. + /// </summary> + /// <param name="request">The stop request.</param> + /// <param name="context">The context of the state.</param> + /// <param name="prevState">The previous state.</param> + /// <param name="session">The session.</param> + /// <param name="cancellationToken">The cancellation token.</param> + void HandleRequest(StopGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken); + + /// <summary> + /// Handles a seek request from a session. Context's state can change. + /// </summary> + /// <param name="request">The seek request.</param> + /// <param name="context">The context of the state.</param> + /// <param name="prevState">The previous state.</param> + /// <param name="session">The session.</param> + /// <param name="cancellationToken">The cancellation token.</param> + void HandleRequest(SeekGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken); + + /// <summary> + /// Handles a buffer request from a session. Context's state can change. + /// </summary> + /// <param name="request">The buffer request.</param> + /// <param name="context">The context of the state.</param> + /// <param name="prevState">The previous state.</param> + /// <param name="session">The session.</param> + /// <param name="cancellationToken">The cancellation token.</param> + void HandleRequest(BufferGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken); + + /// <summary> + /// Handles a ready request from a session. Context's state can change. + /// </summary> + /// <param name="request">The ready request.</param> + /// <param name="context">The context of the state.</param> + /// <param name="prevState">The previous state.</param> + /// <param name="session">The session.</param> + /// <param name="cancellationToken">The cancellation token.</param> + void HandleRequest(ReadyGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken); + + /// <summary> + /// Handles a next-item request from a session. Context's state can change. + /// </summary> + /// <param name="request">The next-item request.</param> + /// <param name="context">The context of the state.</param> + /// <param name="prevState">The previous state.</param> + /// <param name="session">The session.</param> + /// <param name="cancellationToken">The cancellation token.</param> + void HandleRequest(NextItemGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken); + + /// <summary> + /// Handles a previous-item request from a session. Context's state can change. + /// </summary> + /// <param name="request">The previous-item request.</param> + /// <param name="context">The context of the state.</param> + /// <param name="prevState">The previous state.</param> + /// <param name="session">The session.</param> + /// <param name="cancellationToken">The cancellation token.</param> + void HandleRequest(PreviousItemGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken); + + /// <summary> + /// Handles a set-repeat-mode request from a session. Context's state should not change. + /// </summary> + /// <param name="request">The repeat-mode request.</param> + /// <param name="context">The context of the state.</param> + /// <param name="prevState">The previous state.</param> + /// <param name="session">The session.</param> + /// <param name="cancellationToken">The cancellation token.</param> + void HandleRequest(SetRepeatModeGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken); + + /// <summary> + /// Handles a set-shuffle-mode request from a session. Context's state should not change. + /// </summary> + /// <param name="request">The shuffle-mode request.</param> + /// <param name="context">The context of the state.</param> + /// <param name="prevState">The previous state.</param> + /// <param name="session">The session.</param> + /// <param name="cancellationToken">The cancellation token.</param> + void HandleRequest(SetShuffleModeGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken); + + /// <summary> + /// Updates the ping of a session. Context's state should not change. + /// </summary> + /// <param name="request">The ping request.</param> + /// <param name="context">The context of the state.</param> + /// <param name="prevState">The previous state.</param> + /// <param name="session">The session.</param> + /// <param name="cancellationToken">The cancellation token.</param> + void HandleRequest(PingGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken); + + /// <summary> + /// Handles a ignore-wait request from a session. Context's state can change. + /// </summary> + /// <param name="request">The ignore-wait request.</param> + /// <param name="context">The context of the state.</param> + /// <param name="prevState">The previous state.</param> + /// <param name="session">The session.</param> + /// <param name="cancellationToken">The cancellation token.</param> + void HandleRequest(IgnoreWaitGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken); + } +} diff --git a/MediaBrowser.Controller/SyncPlay/IGroupStateContext.cs b/MediaBrowser.Controller/SyncPlay/IGroupStateContext.cs new file mode 100644 index 0000000000..de26c7d9ef --- /dev/null +++ b/MediaBrowser.Controller/SyncPlay/IGroupStateContext.cs @@ -0,0 +1,224 @@ +#nullable disable + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Controller.Session; +using MediaBrowser.Controller.SyncPlay.Queue; +using MediaBrowser.Model.SyncPlay; + +namespace MediaBrowser.Controller.SyncPlay +{ + /// <summary> + /// Interface IGroupStateContext. + /// </summary> + public interface IGroupStateContext + { + /// <summary> + /// Gets the default ping value used for sessions, in milliseconds. + /// </summary> + /// <value>The default ping value used for sessions, in milliseconds.</value> + long DefaultPing { get; } + + /// <summary> + /// Gets the maximum time offset error accepted for dates reported by clients, in milliseconds. + /// </summary> + /// <value>The maximum offset error accepted, in milliseconds.</value> + long TimeSyncOffset { get; } + + /// <summary> + /// Gets the maximum offset error accepted for position reported by clients, in milliseconds. + /// </summary> + /// <value>The maximum offset error accepted, in milliseconds.</value> + long MaxPlaybackOffset { get; } + + /// <summary> + /// Gets the group identifier. + /// </summary> + /// <value>The group identifier.</value> + Guid GroupId { get; } + + /// <summary> + /// Gets or sets the position ticks. + /// </summary> + /// <value>The position ticks.</value> + long PositionTicks { get; set; } + + /// <summary> + /// Gets or sets the last activity. + /// </summary> + /// <value>The last activity.</value> + DateTime LastActivity { get; set; } + + /// <summary> + /// Gets the play queue. + /// </summary> + /// <value>The play queue.</value> + PlayQueueManager PlayQueue { get; } + + /// <summary> + /// Sets a new state. + /// </summary> + /// <param name="state">The new state.</param> + void SetState(IGroupState state); + + /// <summary> + /// Sends a GroupUpdate message to the interested sessions. + /// </summary> + /// <typeparam name="T">The type of the data of the message.</typeparam> + /// <param name="from">The current session.</param> + /// <param name="type">The filtering type.</param> + /// <param name="message">The message to send.</param> + /// <param name="cancellationToken">The cancellation token.</param> + /// <returns>The task.</returns> + Task SendGroupUpdate<T>(SessionInfo from, SyncPlayBroadcastType type, GroupUpdate<T> message, CancellationToken cancellationToken); + + /// <summary> + /// Sends a playback command to the interested sessions. + /// </summary> + /// <param name="from">The current session.</param> + /// <param name="type">The filtering type.</param> + /// <param name="message">The message to send.</param> + /// <param name="cancellationToken">The cancellation token.</param> + /// <returns>The task.</returns> + Task SendCommand(SessionInfo from, SyncPlayBroadcastType type, SendCommand message, CancellationToken cancellationToken); + + /// <summary> + /// Builds a new playback command with some default values. + /// </summary> + /// <param name="type">The command type.</param> + /// <returns>The command.</returns> + SendCommand NewSyncPlayCommand(SendCommandType type); + + /// <summary> + /// Builds a new group update message. + /// </summary> + /// <typeparam name="T">The type of the data of the message.</typeparam> + /// <param name="type">The update type.</param> + /// <param name="data">The data to send.</param> + /// <returns>The group update.</returns> + GroupUpdate<T> NewSyncPlayGroupUpdate<T>(GroupUpdateType type, T data); + + /// <summary> + /// Sanitizes the PositionTicks, considers the current playing item when available. + /// </summary> + /// <param name="positionTicks">The PositionTicks.</param> + /// <returns>The sanitized position ticks.</returns> + long SanitizePositionTicks(long? positionTicks); + + /// <summary> + /// Updates the ping of a session, in milliseconds. + /// </summary> + /// <param name="session">The session.</param> + /// <param name="ping">The ping, in milliseconds.</param> + void UpdatePing(SessionInfo session, long ping); + + /// <summary> + /// Gets the highest ping in the group, in milliseconds. + /// </summary> + /// <returns>The highest ping in the group.</returns> + long GetHighestPing(); + + /// <summary> + /// Sets the session's buffering state. + /// </summary> + /// <param name="session">The session.</param> + /// <param name="isBuffering">The state.</param> + void SetBuffering(SessionInfo session, bool isBuffering); + + /// <summary> + /// Sets the buffering state of all the sessions. + /// </summary> + /// <param name="isBuffering">The state.</param> + void SetAllBuffering(bool isBuffering); + + /// <summary> + /// Gets the group buffering state. + /// </summary> + /// <returns><c>true</c> if there is a session buffering in the group; <c>false</c> otherwise.</returns> + bool IsBuffering(); + + /// <summary> + /// Sets the session's group wait state. + /// </summary> + /// <param name="session">The session.</param> + /// <param name="ignoreGroupWait">The state.</param> + void SetIgnoreGroupWait(SessionInfo session, bool ignoreGroupWait); + + /// <summary> + /// Sets a new play queue. + /// </summary> + /// <param name="playQueue">The new play queue.</param> + /// <param name="playingItemPosition">The playing item position in the play queue.</param> + /// <param name="startPositionTicks">The start position ticks.</param> + /// <returns><c>true</c> if the play queue has been changed; <c>false</c> if something went wrong.</returns> + bool SetPlayQueue(IReadOnlyList<Guid> playQueue, int playingItemPosition, long startPositionTicks); + + /// <summary> + /// Sets the playing item. + /// </summary> + /// <param name="playlistItemId">The new playing item identifier.</param> + /// <returns><c>true</c> if the play queue has been changed; <c>false</c> if something went wrong.</returns> + bool SetPlayingItem(Guid playlistItemId); + + /// <summary> + /// Removes items from the play queue. + /// </summary> + /// <param name="playlistItemIds">The items to remove.</param> + /// <returns><c>true</c> if playing item got removed; <c>false</c> otherwise.</returns> + bool RemoveFromPlayQueue(IReadOnlyList<Guid> playlistItemIds); + + /// <summary> + /// Moves an item in the play queue. + /// </summary> + /// <param name="playlistItemId">The playlist identifier of the item to move.</param> + /// <param name="newIndex">The new position.</param> + /// <returns><c>true</c> if item has been moved; <c>false</c> if something went wrong.</returns> + bool MoveItemInPlayQueue(Guid playlistItemId, int newIndex); + + /// <summary> + /// Updates the play queue. + /// </summary> + /// <param name="newItems">The new items to add to the play queue.</param> + /// <param name="mode">The mode with which the items will be added.</param> + /// <returns><c>true</c> if the play queue has been changed; <c>false</c> if something went wrong.</returns> + bool AddToPlayQueue(IReadOnlyList<Guid> newItems, GroupQueueMode mode); + + /// <summary> + /// Restarts current item in play queue. + /// </summary> + void RestartCurrentItem(); + + /// <summary> + /// Picks next item in play queue. + /// </summary> + /// <returns><c>true</c> if the item changed; <c>false</c> otherwise.</returns> + bool NextItemInQueue(); + + /// <summary> + /// Picks previous item in play queue. + /// </summary> + /// <returns><c>true</c> if the item changed; <c>false</c> otherwise.</returns> + bool PreviousItemInQueue(); + + /// <summary> + /// Sets the repeat mode. + /// </summary> + /// <param name="mode">The new mode.</param> + void SetRepeatMode(GroupRepeatMode mode); + + /// <summary> + /// Sets the shuffle mode. + /// </summary> + /// <param name="mode">The new mode.</param> + void SetShuffleMode(GroupShuffleMode mode); + + /// <summary> + /// Creates a play queue update. + /// </summary> + /// <param name="reason">The reason for the update.</param> + /// <returns>The play queue update.</returns> + PlayQueueUpdate GetPlayQueueUpdate(PlayQueueUpdateReason reason); + } +} diff --git a/MediaBrowser.Controller/SyncPlay/ISyncPlayController.cs b/MediaBrowser.Controller/SyncPlay/ISyncPlayController.cs deleted file mode 100644 index 60d17fe367..0000000000 --- a/MediaBrowser.Controller/SyncPlay/ISyncPlayController.cs +++ /dev/null @@ -1,67 +0,0 @@ -using System; -using System.Threading; -using MediaBrowser.Controller.Session; -using MediaBrowser.Model.SyncPlay; - -namespace MediaBrowser.Controller.SyncPlay -{ - /// <summary> - /// Interface ISyncPlayController. - /// </summary> - public interface ISyncPlayController - { - /// <summary> - /// Gets the group id. - /// </summary> - /// <value>The group id.</value> - Guid GetGroupId(); - - /// <summary> - /// Gets the playing item id. - /// </summary> - /// <value>The playing item id.</value> - Guid GetPlayingItemId(); - - /// <summary> - /// Checks if the group is empty. - /// </summary> - /// <value>If the group is empty.</value> - bool IsGroupEmpty(); - - /// <summary> - /// Initializes the group with the session's info. - /// </summary> - /// <param name="session">The session.</param> - /// <param name="cancellationToken">The cancellation token.</param> - void CreateGroup(SessionInfo session, CancellationToken cancellationToken); - - /// <summary> - /// Adds the session to the group. - /// </summary> - /// <param name="session">The session.</param> - /// <param name="request">The request.</param> - /// <param name="cancellationToken">The cancellation token.</param> - void SessionJoin(SessionInfo session, JoinGroupRequest request, CancellationToken cancellationToken); - - /// <summary> - /// Removes the session from the group. - /// </summary> - /// <param name="session">The session.</param> - /// <param name="cancellationToken">The cancellation token.</param> - void SessionLeave(SessionInfo session, CancellationToken cancellationToken); - - /// <summary> - /// Handles the requested action by the session. - /// </summary> - /// <param name="session">The session.</param> - /// <param name="request">The requested action.</param> - /// <param name="cancellationToken">The cancellation token.</param> - void HandleRequest(SessionInfo session, PlaybackRequest request, CancellationToken cancellationToken); - - /// <summary> - /// Gets the info about the group for the clients. - /// </summary> - /// <value>The group info for the clients.</value> - GroupInfoView GetInfo(); - } -} diff --git a/MediaBrowser.Controller/SyncPlay/ISyncPlayManager.cs b/MediaBrowser.Controller/SyncPlay/ISyncPlayManager.cs index 006fb687b8..a6999a12c9 100644 --- a/MediaBrowser.Controller/SyncPlay/ISyncPlayManager.cs +++ b/MediaBrowser.Controller/SyncPlay/ISyncPlayManager.cs @@ -1,7 +1,10 @@ +#nullable disable + using System; using System.Collections.Generic; using System.Threading; using MediaBrowser.Controller.Session; +using MediaBrowser.Controller.SyncPlay.Requests; using MediaBrowser.Model.SyncPlay; namespace MediaBrowser.Controller.SyncPlay @@ -15,32 +18,33 @@ namespace MediaBrowser.Controller.SyncPlay /// Creates a new group. /// </summary> /// <param name="session">The session that's creating the group.</param> + /// <param name="request">The request.</param> /// <param name="cancellationToken">The cancellation token.</param> - void NewGroup(SessionInfo session, CancellationToken cancellationToken); + void NewGroup(SessionInfo session, NewGroupRequest request, CancellationToken cancellationToken); /// <summary> /// Adds the session to a group. /// </summary> /// <param name="session">The session.</param> - /// <param name="groupId">The group id.</param> /// <param name="request">The request.</param> /// <param name="cancellationToken">The cancellation token.</param> - void JoinGroup(SessionInfo session, Guid groupId, JoinGroupRequest request, CancellationToken cancellationToken); + void JoinGroup(SessionInfo session, JoinGroupRequest request, CancellationToken cancellationToken); /// <summary> /// Removes the session from a group. /// </summary> /// <param name="session">The session.</param> + /// <param name="request">The request.</param> /// <param name="cancellationToken">The cancellation token.</param> - void LeaveGroup(SessionInfo session, CancellationToken cancellationToken); + void LeaveGroup(SessionInfo session, LeaveGroupRequest request, CancellationToken cancellationToken); /// <summary> /// Gets list of available groups for a session. /// </summary> /// <param name="session">The session.</param> - /// <param name="filterItemId">The item id to filter by.</param> - /// <value>The list of available groups.</value> - List<GroupInfoView> ListGroups(SessionInfo session, Guid filterItemId); + /// <param name="request">The request.</param> + /// <returns>The list of available groups.</returns> + List<GroupInfoDto> ListGroups(SessionInfo session, ListGroupsRequest request); /// <summary> /// Handle a request by a session in a group. @@ -48,22 +52,13 @@ namespace MediaBrowser.Controller.SyncPlay /// <param name="session">The session.</param> /// <param name="request">The request.</param> /// <param name="cancellationToken">The cancellation token.</param> - void HandleRequest(SessionInfo session, PlaybackRequest request, CancellationToken cancellationToken); + void HandleRequest(SessionInfo session, IGroupPlaybackRequest request, CancellationToken cancellationToken); /// <summary> - /// Maps a session to a group. + /// Checks whether a user has an active session using SyncPlay. /// </summary> - /// <param name="session">The session.</param> - /// <param name="group">The group.</param> - /// <exception cref="InvalidOperationException"></exception> - void AddSessionToGroup(SessionInfo session, ISyncPlayController group); - - /// <summary> - /// Unmaps a session from a group. - /// </summary> - /// <param name="session">The session.</param> - /// <param name="group">The group.</param> - /// <exception cref="InvalidOperationException"></exception> - void RemoveSessionFromGroup(SessionInfo session, ISyncPlayController group); + /// <param name="userId">The user identifier to check.</param> + /// <returns><c>true</c> if the user is using SyncPlay; <c>false</c> otherwise.</returns> + bool IsUserActive(Guid userId); } } diff --git a/MediaBrowser.Controller/SyncPlay/ISyncPlayRequest.cs b/MediaBrowser.Controller/SyncPlay/ISyncPlayRequest.cs new file mode 100644 index 0000000000..bf19817732 --- /dev/null +++ b/MediaBrowser.Controller/SyncPlay/ISyncPlayRequest.cs @@ -0,0 +1,16 @@ +using MediaBrowser.Model.SyncPlay; + +namespace MediaBrowser.Controller.SyncPlay +{ + /// <summary> + /// Interface ISyncPlayRequest. + /// </summary> + public interface ISyncPlayRequest + { + /// <summary> + /// Gets the request type. + /// </summary> + /// <returns>The request type.</returns> + RequestType Type { get; } + } +} diff --git a/MediaBrowser.Controller/SyncPlay/PlaybackRequests/AbstractPlaybackRequest.cs b/MediaBrowser.Controller/SyncPlay/PlaybackRequests/AbstractPlaybackRequest.cs new file mode 100644 index 0000000000..ef496c1038 --- /dev/null +++ b/MediaBrowser.Controller/SyncPlay/PlaybackRequests/AbstractPlaybackRequest.cs @@ -0,0 +1,31 @@ +#nullable disable + +using System.Threading; +using MediaBrowser.Controller.Session; +using MediaBrowser.Model.SyncPlay; + +namespace MediaBrowser.Controller.SyncPlay.PlaybackRequests +{ + /// <summary> + /// Class AbstractPlaybackRequest. + /// </summary> + public abstract class AbstractPlaybackRequest : IGroupPlaybackRequest + { + /// <summary> + /// Initializes a new instance of the <see cref="AbstractPlaybackRequest"/> class. + /// </summary> + protected AbstractPlaybackRequest() + { + // Do nothing. + } + + /// <inheritdoc /> + public RequestType Type { get; } = RequestType.Playback; + + /// <inheritdoc /> + public abstract PlaybackRequestType Action { get; } + + /// <inheritdoc /> + public abstract void Apply(IGroupStateContext context, IGroupState state, SessionInfo session, CancellationToken cancellationToken); + } +} diff --git a/MediaBrowser.Controller/SyncPlay/PlaybackRequests/BufferGroupRequest.cs b/MediaBrowser.Controller/SyncPlay/PlaybackRequests/BufferGroupRequest.cs new file mode 100644 index 0000000000..d188114c34 --- /dev/null +++ b/MediaBrowser.Controller/SyncPlay/PlaybackRequests/BufferGroupRequest.cs @@ -0,0 +1,63 @@ +#nullable disable + +using System; +using System.Threading; +using MediaBrowser.Controller.Session; +using MediaBrowser.Model.SyncPlay; + +namespace MediaBrowser.Controller.SyncPlay.PlaybackRequests +{ + /// <summary> + /// Class BufferGroupRequest. + /// </summary> + public class BufferGroupRequest : AbstractPlaybackRequest + { + /// <summary> + /// Initializes a new instance of the <see cref="BufferGroupRequest"/> class. + /// </summary> + /// <param name="when">When the request has been made, as reported by the client.</param> + /// <param name="positionTicks">The position ticks.</param> + /// <param name="isPlaying">Whether the client playback is unpaused.</param> + /// <param name="playlistItemId">The playlist item identifier of the playing item.</param> + public BufferGroupRequest(DateTime when, long positionTicks, bool isPlaying, Guid playlistItemId) + { + When = when; + PositionTicks = positionTicks; + IsPlaying = isPlaying; + PlaylistItemId = playlistItemId; + } + + /// <summary> + /// Gets when the request has been made by the client. + /// </summary> + /// <value>The date of the request.</value> + public DateTime When { get; } + + /// <summary> + /// Gets the position ticks. + /// </summary> + /// <value>The position ticks.</value> + public long PositionTicks { get; } + + /// <summary> + /// Gets a value indicating whether the client playback is unpaused. + /// </summary> + /// <value>The client playback status.</value> + public bool IsPlaying { get; } + + /// <summary> + /// Gets the playlist item identifier of the playing item. + /// </summary> + /// <value>The playlist item identifier.</value> + public Guid PlaylistItemId { get; } + + /// <inheritdoc /> + public override PlaybackRequestType Action { get; } = PlaybackRequestType.Buffer; + + /// <inheritdoc /> + public override void Apply(IGroupStateContext context, IGroupState state, SessionInfo session, CancellationToken cancellationToken) + { + state.HandleRequest(this, context, state.Type, session, cancellationToken); + } + } +} diff --git a/MediaBrowser.Controller/SyncPlay/PlaybackRequests/IgnoreWaitGroupRequest.cs b/MediaBrowser.Controller/SyncPlay/PlaybackRequests/IgnoreWaitGroupRequest.cs new file mode 100644 index 0000000000..464c81dfd2 --- /dev/null +++ b/MediaBrowser.Controller/SyncPlay/PlaybackRequests/IgnoreWaitGroupRequest.cs @@ -0,0 +1,38 @@ +#nullable disable + +using System.Threading; +using MediaBrowser.Controller.Session; +using MediaBrowser.Model.SyncPlay; + +namespace MediaBrowser.Controller.SyncPlay.PlaybackRequests +{ + /// <summary> + /// Class IgnoreWaitGroupRequest. + /// </summary> + public class IgnoreWaitGroupRequest : AbstractPlaybackRequest + { + /// <summary> + /// Initializes a new instance of the <see cref="IgnoreWaitGroupRequest"/> class. + /// </summary> + /// <param name="ignoreWait">Whether the client should be ignored.</param> + public IgnoreWaitGroupRequest(bool ignoreWait) + { + IgnoreWait = ignoreWait; + } + + /// <summary> + /// Gets a value indicating whether the client should be ignored. + /// </summary> + /// <value>The client group-wait status.</value> + public bool IgnoreWait { get; } + + /// <inheritdoc /> + public override PlaybackRequestType Action { get; } = PlaybackRequestType.IgnoreWait; + + /// <inheritdoc /> + public override void Apply(IGroupStateContext context, IGroupState state, SessionInfo session, CancellationToken cancellationToken) + { + state.HandleRequest(this, context, state.Type, session, cancellationToken); + } + } +} diff --git a/MediaBrowser.Controller/SyncPlay/PlaybackRequests/MovePlaylistItemGroupRequest.cs b/MediaBrowser.Controller/SyncPlay/PlaybackRequests/MovePlaylistItemGroupRequest.cs new file mode 100644 index 0000000000..be314e807b --- /dev/null +++ b/MediaBrowser.Controller/SyncPlay/PlaybackRequests/MovePlaylistItemGroupRequest.cs @@ -0,0 +1,47 @@ +#nullable disable + +using System; +using System.Threading; +using MediaBrowser.Controller.Session; +using MediaBrowser.Model.SyncPlay; + +namespace MediaBrowser.Controller.SyncPlay.PlaybackRequests +{ + /// <summary> + /// Class MovePlaylistItemGroupRequest. + /// </summary> + public class MovePlaylistItemGroupRequest : AbstractPlaybackRequest + { + /// <summary> + /// Initializes a new instance of the <see cref="MovePlaylistItemGroupRequest"/> class. + /// </summary> + /// <param name="playlistItemId">The playlist identifier of the item.</param> + /// <param name="newIndex">The new position.</param> + public MovePlaylistItemGroupRequest(Guid playlistItemId, int newIndex) + { + PlaylistItemId = playlistItemId; + NewIndex = newIndex; + } + + /// <summary> + /// Gets the playlist identifier of the item. + /// </summary> + /// <value>The playlist identifier of the item.</value> + public Guid PlaylistItemId { get; } + + /// <summary> + /// Gets the new position. + /// </summary> + /// <value>The new position.</value> + public int NewIndex { get; } + + /// <inheritdoc /> + public override PlaybackRequestType Action { get; } = PlaybackRequestType.MovePlaylistItem; + + /// <inheritdoc /> + public override void Apply(IGroupStateContext context, IGroupState state, SessionInfo session, CancellationToken cancellationToken) + { + state.HandleRequest(this, context, state.Type, session, cancellationToken); + } + } +} diff --git a/MediaBrowser.Controller/SyncPlay/PlaybackRequests/NextItemGroupRequest.cs b/MediaBrowser.Controller/SyncPlay/PlaybackRequests/NextItemGroupRequest.cs new file mode 100644 index 0000000000..679076239e --- /dev/null +++ b/MediaBrowser.Controller/SyncPlay/PlaybackRequests/NextItemGroupRequest.cs @@ -0,0 +1,39 @@ +#nullable disable + +using System; +using System.Threading; +using MediaBrowser.Controller.Session; +using MediaBrowser.Model.SyncPlay; + +namespace MediaBrowser.Controller.SyncPlay.PlaybackRequests +{ + /// <summary> + /// Class NextItemGroupRequest. + /// </summary> + public class NextItemGroupRequest : AbstractPlaybackRequest + { + /// <summary> + /// Initializes a new instance of the <see cref="NextItemGroupRequest"/> class. + /// </summary> + /// <param name="playlistItemId">The playing item identifier.</param> + public NextItemGroupRequest(Guid playlistItemId) + { + PlaylistItemId = playlistItemId; + } + + /// <summary> + /// Gets the playing item identifier. + /// </summary> + /// <value>The playing item identifier.</value> + public Guid PlaylistItemId { get; } + + /// <inheritdoc /> + public override PlaybackRequestType Action { get; } = PlaybackRequestType.NextItem; + + /// <inheritdoc /> + public override void Apply(IGroupStateContext context, IGroupState state, SessionInfo session, CancellationToken cancellationToken) + { + state.HandleRequest(this, context, state.Type, session, cancellationToken); + } + } +} diff --git a/MediaBrowser.Controller/SyncPlay/PlaybackRequests/PauseGroupRequest.cs b/MediaBrowser.Controller/SyncPlay/PlaybackRequests/PauseGroupRequest.cs new file mode 100644 index 0000000000..7ee18a3666 --- /dev/null +++ b/MediaBrowser.Controller/SyncPlay/PlaybackRequests/PauseGroupRequest.cs @@ -0,0 +1,23 @@ +#nullable disable + +using System.Threading; +using MediaBrowser.Controller.Session; +using MediaBrowser.Model.SyncPlay; + +namespace MediaBrowser.Controller.SyncPlay.PlaybackRequests +{ + /// <summary> + /// Class PauseGroupRequest. + /// </summary> + public class PauseGroupRequest : AbstractPlaybackRequest + { + /// <inheritdoc /> + public override PlaybackRequestType Action { get; } = PlaybackRequestType.Pause; + + /// <inheritdoc /> + public override void Apply(IGroupStateContext context, IGroupState state, SessionInfo session, CancellationToken cancellationToken) + { + state.HandleRequest(this, context, state.Type, session, cancellationToken); + } + } +} diff --git a/MediaBrowser.Controller/SyncPlay/PlaybackRequests/PingGroupRequest.cs b/MediaBrowser.Controller/SyncPlay/PlaybackRequests/PingGroupRequest.cs new file mode 100644 index 0000000000..beab655c59 --- /dev/null +++ b/MediaBrowser.Controller/SyncPlay/PlaybackRequests/PingGroupRequest.cs @@ -0,0 +1,38 @@ +#nullable disable + +using System.Threading; +using MediaBrowser.Controller.Session; +using MediaBrowser.Model.SyncPlay; + +namespace MediaBrowser.Controller.SyncPlay.PlaybackRequests +{ + /// <summary> + /// Class PingGroupRequest. + /// </summary> + public class PingGroupRequest : AbstractPlaybackRequest + { + /// <summary> + /// Initializes a new instance of the <see cref="PingGroupRequest"/> class. + /// </summary> + /// <param name="ping">The ping time.</param> + public PingGroupRequest(long ping) + { + Ping = ping; + } + + /// <summary> + /// Gets the ping time. + /// </summary> + /// <value>The ping time.</value> + public long Ping { get; } + + /// <inheritdoc /> + public override PlaybackRequestType Action { get; } = PlaybackRequestType.Ping; + + /// <inheritdoc /> + public override void Apply(IGroupStateContext context, IGroupState state, SessionInfo session, CancellationToken cancellationToken) + { + state.HandleRequest(this, context, state.Type, session, cancellationToken); + } + } +} diff --git a/MediaBrowser.Controller/SyncPlay/PlaybackRequests/PlayGroupRequest.cs b/MediaBrowser.Controller/SyncPlay/PlaybackRequests/PlayGroupRequest.cs new file mode 100644 index 0000000000..05ff262c1e --- /dev/null +++ b/MediaBrowser.Controller/SyncPlay/PlaybackRequests/PlayGroupRequest.cs @@ -0,0 +1,56 @@ +#nullable disable + +using System; +using System.Collections.Generic; +using System.Threading; +using MediaBrowser.Controller.Session; +using MediaBrowser.Model.SyncPlay; + +namespace MediaBrowser.Controller.SyncPlay.PlaybackRequests +{ + /// <summary> + /// Class PlayGroupRequest. + /// </summary> + public class PlayGroupRequest : AbstractPlaybackRequest + { + /// <summary> + /// Initializes a new instance of the <see cref="PlayGroupRequest"/> class. + /// </summary> + /// <param name="playingQueue">The playing queue.</param> + /// <param name="playingItemPosition">The playing item position.</param> + /// <param name="startPositionTicks">The start position ticks.</param> + public PlayGroupRequest(IReadOnlyList<Guid> playingQueue, int playingItemPosition, long startPositionTicks) + { + PlayingQueue = playingQueue ?? Array.Empty<Guid>(); + PlayingItemPosition = playingItemPosition; + StartPositionTicks = startPositionTicks; + } + + /// <summary> + /// Gets the playing queue. + /// </summary> + /// <value>The playing queue.</value> + public IReadOnlyList<Guid> PlayingQueue { get; } + + /// <summary> + /// Gets the position of the playing item in the queue. + /// </summary> + /// <value>The playing item position.</value> + public int PlayingItemPosition { get; } + + /// <summary> + /// Gets the start position ticks. + /// </summary> + /// <value>The start position ticks.</value> + public long StartPositionTicks { get; } + + /// <inheritdoc /> + public override PlaybackRequestType Action { get; } = PlaybackRequestType.Play; + + /// <inheritdoc /> + public override void Apply(IGroupStateContext context, IGroupState state, SessionInfo session, CancellationToken cancellationToken) + { + state.HandleRequest(this, context, state.Type, session, cancellationToken); + } + } +} diff --git a/MediaBrowser.Controller/SyncPlay/PlaybackRequests/PreviousItemGroupRequest.cs b/MediaBrowser.Controller/SyncPlay/PlaybackRequests/PreviousItemGroupRequest.cs new file mode 100644 index 0000000000..3e34b6ce46 --- /dev/null +++ b/MediaBrowser.Controller/SyncPlay/PlaybackRequests/PreviousItemGroupRequest.cs @@ -0,0 +1,39 @@ +#nullable disable + +using System; +using System.Threading; +using MediaBrowser.Controller.Session; +using MediaBrowser.Model.SyncPlay; + +namespace MediaBrowser.Controller.SyncPlay.PlaybackRequests +{ + /// <summary> + /// Class PreviousItemGroupRequest. + /// </summary> + public class PreviousItemGroupRequest : AbstractPlaybackRequest + { + /// <summary> + /// Initializes a new instance of the <see cref="PreviousItemGroupRequest"/> class. + /// </summary> + /// <param name="playlistItemId">The playing item identifier.</param> + public PreviousItemGroupRequest(Guid playlistItemId) + { + PlaylistItemId = playlistItemId; + } + + /// <summary> + /// Gets the playing item identifier. + /// </summary> + /// <value>The playing item identifier.</value> + public Guid PlaylistItemId { get; } + + /// <inheritdoc /> + public override PlaybackRequestType Action { get; } = PlaybackRequestType.PreviousItem; + + /// <inheritdoc /> + public override void Apply(IGroupStateContext context, IGroupState state, SessionInfo session, CancellationToken cancellationToken) + { + state.HandleRequest(this, context, state.Type, session, cancellationToken); + } + } +} diff --git a/MediaBrowser.Controller/SyncPlay/PlaybackRequests/QueueGroupRequest.cs b/MediaBrowser.Controller/SyncPlay/PlaybackRequests/QueueGroupRequest.cs new file mode 100644 index 0000000000..0f91476de1 --- /dev/null +++ b/MediaBrowser.Controller/SyncPlay/PlaybackRequests/QueueGroupRequest.cs @@ -0,0 +1,48 @@ +#nullable disable + +using System; +using System.Collections.Generic; +using System.Threading; +using MediaBrowser.Controller.Session; +using MediaBrowser.Model.SyncPlay; + +namespace MediaBrowser.Controller.SyncPlay.PlaybackRequests +{ + /// <summary> + /// Class QueueGroupRequest. + /// </summary> + public class QueueGroupRequest : AbstractPlaybackRequest + { + /// <summary> + /// Initializes a new instance of the <see cref="QueueGroupRequest"/> class. + /// </summary> + /// <param name="items">The items to add to the queue.</param> + /// <param name="mode">The enqueue mode.</param> + public QueueGroupRequest(IReadOnlyList<Guid> items, GroupQueueMode mode) + { + ItemIds = items ?? Array.Empty<Guid>(); + Mode = mode; + } + + /// <summary> + /// Gets the items to enqueue. + /// </summary> + /// <value>The items to enqueue.</value> + public IReadOnlyList<Guid> ItemIds { get; } + + /// <summary> + /// Gets the mode in which to add the new items. + /// </summary> + /// <value>The enqueue mode.</value> + public GroupQueueMode Mode { get; } + + /// <inheritdoc /> + public override PlaybackRequestType Action { get; } = PlaybackRequestType.Queue; + + /// <inheritdoc /> + public override void Apply(IGroupStateContext context, IGroupState state, SessionInfo session, CancellationToken cancellationToken) + { + state.HandleRequest(this, context, state.Type, session, cancellationToken); + } + } +} diff --git a/MediaBrowser.Controller/SyncPlay/PlaybackRequests/ReadyGroupRequest.cs b/MediaBrowser.Controller/SyncPlay/PlaybackRequests/ReadyGroupRequest.cs new file mode 100644 index 0000000000..b1f0bd3602 --- /dev/null +++ b/MediaBrowser.Controller/SyncPlay/PlaybackRequests/ReadyGroupRequest.cs @@ -0,0 +1,63 @@ +#nullable disable + +using System; +using System.Threading; +using MediaBrowser.Controller.Session; +using MediaBrowser.Model.SyncPlay; + +namespace MediaBrowser.Controller.SyncPlay.PlaybackRequests +{ + /// <summary> + /// Class ReadyGroupRequest. + /// </summary> + public class ReadyGroupRequest : AbstractPlaybackRequest + { + /// <summary> + /// Initializes a new instance of the <see cref="ReadyGroupRequest"/> class. + /// </summary> + /// <param name="when">When the request has been made, as reported by the client.</param> + /// <param name="positionTicks">The position ticks.</param> + /// <param name="isPlaying">Whether the client playback is unpaused.</param> + /// <param name="playlistItemId">The playlist item identifier of the playing item.</param> + public ReadyGroupRequest(DateTime when, long positionTicks, bool isPlaying, Guid playlistItemId) + { + When = when; + PositionTicks = positionTicks; + IsPlaying = isPlaying; + PlaylistItemId = playlistItemId; + } + + /// <summary> + /// Gets when the request has been made by the client. + /// </summary> + /// <value>The date of the request.</value> + public DateTime When { get; } + + /// <summary> + /// Gets the position ticks. + /// </summary> + /// <value>The position ticks.</value> + public long PositionTicks { get; } + + /// <summary> + /// Gets a value indicating whether the client playback is unpaused. + /// </summary> + /// <value>The client playback status.</value> + public bool IsPlaying { get; } + + /// <summary> + /// Gets the playlist item identifier of the playing item. + /// </summary> + /// <value>The playlist item identifier.</value> + public Guid PlaylistItemId { get; } + + /// <inheritdoc /> + public override PlaybackRequestType Action { get; } = PlaybackRequestType.Ready; + + /// <inheritdoc /> + public override void Apply(IGroupStateContext context, IGroupState state, SessionInfo session, CancellationToken cancellationToken) + { + state.HandleRequest(this, context, state.Type, session, cancellationToken); + } + } +} diff --git a/MediaBrowser.Controller/SyncPlay/PlaybackRequests/RemoveFromPlaylistGroupRequest.cs b/MediaBrowser.Controller/SyncPlay/PlaybackRequests/RemoveFromPlaylistGroupRequest.cs new file mode 100644 index 0000000000..6891452934 --- /dev/null +++ b/MediaBrowser.Controller/SyncPlay/PlaybackRequests/RemoveFromPlaylistGroupRequest.cs @@ -0,0 +1,40 @@ +#nullable disable + +using System; +using System.Collections.Generic; +using System.Threading; +using MediaBrowser.Controller.Session; +using MediaBrowser.Model.SyncPlay; + +namespace MediaBrowser.Controller.SyncPlay.PlaybackRequests +{ + /// <summary> + /// Class RemoveFromPlaylistGroupRequest. + /// </summary> + public class RemoveFromPlaylistGroupRequest : AbstractPlaybackRequest + { + /// <summary> + /// Initializes a new instance of the <see cref="RemoveFromPlaylistGroupRequest"/> class. + /// </summary> + /// <param name="items">The playlist ids of the items to remove.</param> + public RemoveFromPlaylistGroupRequest(IReadOnlyList<Guid> items) + { + PlaylistItemIds = items ?? Array.Empty<Guid>(); + } + + /// <summary> + /// Gets the playlist identifiers ot the items. + /// </summary> + /// <value>The playlist identifiers ot the items.</value> + public IReadOnlyList<Guid> PlaylistItemIds { get; } + + /// <inheritdoc /> + public override PlaybackRequestType Action { get; } = PlaybackRequestType.RemoveFromPlaylist; + + /// <inheritdoc /> + public override void Apply(IGroupStateContext context, IGroupState state, SessionInfo session, CancellationToken cancellationToken) + { + state.HandleRequest(this, context, state.Type, session, cancellationToken); + } + } +} diff --git a/MediaBrowser.Controller/SyncPlay/PlaybackRequests/SeekGroupRequest.cs b/MediaBrowser.Controller/SyncPlay/PlaybackRequests/SeekGroupRequest.cs new file mode 100644 index 0000000000..1961133749 --- /dev/null +++ b/MediaBrowser.Controller/SyncPlay/PlaybackRequests/SeekGroupRequest.cs @@ -0,0 +1,38 @@ +#nullable disable + +using System.Threading; +using MediaBrowser.Controller.Session; +using MediaBrowser.Model.SyncPlay; + +namespace MediaBrowser.Controller.SyncPlay.PlaybackRequests +{ + /// <summary> + /// Class SeekGroupRequest. + /// </summary> + public class SeekGroupRequest : AbstractPlaybackRequest + { + /// <summary> + /// Initializes a new instance of the <see cref="SeekGroupRequest"/> class. + /// </summary> + /// <param name="positionTicks">The position ticks.</param> + public SeekGroupRequest(long positionTicks) + { + PositionTicks = positionTicks; + } + + /// <summary> + /// Gets the position ticks. + /// </summary> + /// <value>The position ticks.</value> + public long PositionTicks { get; } + + /// <inheritdoc /> + public override PlaybackRequestType Action { get; } = PlaybackRequestType.Seek; + + /// <inheritdoc /> + public override void Apply(IGroupStateContext context, IGroupState state, SessionInfo session, CancellationToken cancellationToken) + { + state.HandleRequest(this, context, state.Type, session, cancellationToken); + } + } +} diff --git a/MediaBrowser.Controller/SyncPlay/PlaybackRequests/SetPlaylistItemGroupRequest.cs b/MediaBrowser.Controller/SyncPlay/PlaybackRequests/SetPlaylistItemGroupRequest.cs new file mode 100644 index 0000000000..44df127a6f --- /dev/null +++ b/MediaBrowser.Controller/SyncPlay/PlaybackRequests/SetPlaylistItemGroupRequest.cs @@ -0,0 +1,39 @@ +#nullable disable + +using System; +using System.Threading; +using MediaBrowser.Controller.Session; +using MediaBrowser.Model.SyncPlay; + +namespace MediaBrowser.Controller.SyncPlay.PlaybackRequests +{ + /// <summary> + /// Class SetPlaylistItemGroupRequest. + /// </summary> + public class SetPlaylistItemGroupRequest : AbstractPlaybackRequest + { + /// <summary> + /// Initializes a new instance of the <see cref="SetPlaylistItemGroupRequest"/> class. + /// </summary> + /// <param name="playlistItemId">The playlist identifier of the item.</param> + public SetPlaylistItemGroupRequest(Guid playlistItemId) + { + PlaylistItemId = playlistItemId; + } + + /// <summary> + /// Gets the playlist identifier of the playing item. + /// </summary> + /// <value>The playlist identifier of the playing item.</value> + public Guid PlaylistItemId { get; } + + /// <inheritdoc /> + public override PlaybackRequestType Action { get; } = PlaybackRequestType.SetPlaylistItem; + + /// <inheritdoc /> + public override void Apply(IGroupStateContext context, IGroupState state, SessionInfo session, CancellationToken cancellationToken) + { + state.HandleRequest(this, context, state.Type, session, cancellationToken); + } + } +} diff --git a/MediaBrowser.Controller/SyncPlay/PlaybackRequests/SetRepeatModeGroupRequest.cs b/MediaBrowser.Controller/SyncPlay/PlaybackRequests/SetRepeatModeGroupRequest.cs new file mode 100644 index 0000000000..d250eab56e --- /dev/null +++ b/MediaBrowser.Controller/SyncPlay/PlaybackRequests/SetRepeatModeGroupRequest.cs @@ -0,0 +1,38 @@ +#nullable disable + +using System.Threading; +using MediaBrowser.Controller.Session; +using MediaBrowser.Model.SyncPlay; + +namespace MediaBrowser.Controller.SyncPlay.PlaybackRequests +{ + /// <summary> + /// Class SetRepeatModeGroupRequest. + /// </summary> + public class SetRepeatModeGroupRequest : AbstractPlaybackRequest + { + /// <summary> + /// Initializes a new instance of the <see cref="SetRepeatModeGroupRequest"/> class. + /// </summary> + /// <param name="mode">The repeat mode.</param> + public SetRepeatModeGroupRequest(GroupRepeatMode mode) + { + Mode = mode; + } + + /// <summary> + /// Gets the repeat mode. + /// </summary> + /// <value>The repeat mode.</value> + public GroupRepeatMode Mode { get; } + + /// <inheritdoc /> + public override PlaybackRequestType Action { get; } = PlaybackRequestType.SetRepeatMode; + + /// <inheritdoc /> + public override void Apply(IGroupStateContext context, IGroupState state, SessionInfo session, CancellationToken cancellationToken) + { + state.HandleRequest(this, context, state.Type, session, cancellationToken); + } + } +} diff --git a/MediaBrowser.Controller/SyncPlay/PlaybackRequests/SetShuffleModeGroupRequest.cs b/MediaBrowser.Controller/SyncPlay/PlaybackRequests/SetShuffleModeGroupRequest.cs new file mode 100644 index 0000000000..5034e992eb --- /dev/null +++ b/MediaBrowser.Controller/SyncPlay/PlaybackRequests/SetShuffleModeGroupRequest.cs @@ -0,0 +1,38 @@ +#nullable disable + +using System.Threading; +using MediaBrowser.Controller.Session; +using MediaBrowser.Model.SyncPlay; + +namespace MediaBrowser.Controller.SyncPlay.PlaybackRequests +{ + /// <summary> + /// Class SetShuffleModeGroupRequest. + /// </summary> + public class SetShuffleModeGroupRequest : AbstractPlaybackRequest + { + /// <summary> + /// Initializes a new instance of the <see cref="SetShuffleModeGroupRequest"/> class. + /// </summary> + /// <param name="mode">The shuffle mode.</param> + public SetShuffleModeGroupRequest(GroupShuffleMode mode) + { + Mode = mode; + } + + /// <summary> + /// Gets the shuffle mode. + /// </summary> + /// <value>The shuffle mode.</value> + public GroupShuffleMode Mode { get; } + + /// <inheritdoc /> + public override PlaybackRequestType Action { get; } = PlaybackRequestType.SetShuffleMode; + + /// <inheritdoc /> + public override void Apply(IGroupStateContext context, IGroupState state, SessionInfo session, CancellationToken cancellationToken) + { + state.HandleRequest(this, context, state.Type, session, cancellationToken); + } + } +} diff --git a/MediaBrowser.Controller/SyncPlay/PlaybackRequests/StopGroupRequest.cs b/MediaBrowser.Controller/SyncPlay/PlaybackRequests/StopGroupRequest.cs new file mode 100644 index 0000000000..ad739213c5 --- /dev/null +++ b/MediaBrowser.Controller/SyncPlay/PlaybackRequests/StopGroupRequest.cs @@ -0,0 +1,21 @@ +using System.Threading; +using MediaBrowser.Controller.Session; +using MediaBrowser.Model.SyncPlay; + +namespace MediaBrowser.Controller.SyncPlay.PlaybackRequests +{ + /// <summary> + /// Class StopGroupRequest. + /// </summary> + public class StopGroupRequest : AbstractPlaybackRequest + { + /// <inheritdoc /> + public override PlaybackRequestType Action { get; } = PlaybackRequestType.Stop; + + /// <inheritdoc /> + public override void Apply(IGroupStateContext context, IGroupState state, SessionInfo session, CancellationToken cancellationToken) + { + state.HandleRequest(this, context, state.Type, session, cancellationToken); + } + } +} diff --git a/MediaBrowser.Controller/SyncPlay/PlaybackRequests/UnpauseGroupRequest.cs b/MediaBrowser.Controller/SyncPlay/PlaybackRequests/UnpauseGroupRequest.cs new file mode 100644 index 0000000000..aaf3d65a84 --- /dev/null +++ b/MediaBrowser.Controller/SyncPlay/PlaybackRequests/UnpauseGroupRequest.cs @@ -0,0 +1,21 @@ +using System.Threading; +using MediaBrowser.Controller.Session; +using MediaBrowser.Model.SyncPlay; + +namespace MediaBrowser.Controller.SyncPlay.PlaybackRequests +{ + /// <summary> + /// Class UnpauseGroupRequest. + /// </summary> + public class UnpauseGroupRequest : AbstractPlaybackRequest + { + /// <inheritdoc /> + public override PlaybackRequestType Action { get; } = PlaybackRequestType.Unpause; + + /// <inheritdoc /> + public override void Apply(IGroupStateContext context, IGroupState state, SessionInfo session, CancellationToken cancellationToken) + { + state.HandleRequest(this, context, state.Type, session, cancellationToken); + } + } +} diff --git a/MediaBrowser.Controller/SyncPlay/Queue/PlayQueueManager.cs b/MediaBrowser.Controller/SyncPlay/Queue/PlayQueueManager.cs new file mode 100644 index 0000000000..b8ae9f3ff6 --- /dev/null +++ b/MediaBrowser.Controller/SyncPlay/Queue/PlayQueueManager.cs @@ -0,0 +1,579 @@ +#nullable disable + +using System; +using System.Collections.Generic; +using System.Linq; +using MediaBrowser.Model.SyncPlay; + +namespace MediaBrowser.Controller.SyncPlay.Queue +{ + /// <summary> + /// Class PlayQueueManager. + /// </summary> + public class PlayQueueManager + { + /// <summary> + /// Placeholder index for when no item is playing. + /// </summary> + /// <value>The no-playing item index.</value> + private const int NoPlayingItemIndex = -1; + + /// <summary> + /// Random number generator used to shuffle lists. + /// </summary> + /// <value>The random number generator.</value> + private readonly Random _randomNumberGenerator = new Random(); + + /// <summary> + /// Initializes a new instance of the <see cref="PlayQueueManager" /> class. + /// </summary> + public PlayQueueManager() + { + Reset(); + } + + /// <summary> + /// Gets the playing item index. + /// </summary> + /// <value>The playing item index.</value> + public int PlayingItemIndex { get; private set; } + + /// <summary> + /// Gets the last time the queue has been changed. + /// </summary> + /// <value>The last time the queue has been changed.</value> + public DateTime LastChange { get; private set; } + + /// <summary> + /// Gets the shuffle mode. + /// </summary> + /// <value>The shuffle mode.</value> + public GroupShuffleMode ShuffleMode { get; private set; } = GroupShuffleMode.Sorted; + + /// <summary> + /// Gets the repeat mode. + /// </summary> + /// <value>The repeat mode.</value> + public GroupRepeatMode RepeatMode { get; private set; } = GroupRepeatMode.RepeatNone; + + /// <summary> + /// Gets or sets the sorted playlist. + /// </summary> + /// <value>The sorted playlist, or play queue of the group.</value> + private List<QueueItem> SortedPlaylist { get; set; } = new List<QueueItem>(); + + /// <summary> + /// Gets or sets the shuffled playlist. + /// </summary> + /// <value>The shuffled playlist, or play queue of the group.</value> + private List<QueueItem> ShuffledPlaylist { get; set; } = new List<QueueItem>(); + + /// <summary> + /// Checks if an item is playing. + /// </summary> + /// <returns><c>true</c> if an item is playing; <c>false</c> otherwise.</returns> + public bool IsItemPlaying() + { + return PlayingItemIndex != NoPlayingItemIndex; + } + + /// <summary> + /// Gets the current playlist considering the shuffle mode. + /// </summary> + /// <returns>The playlist.</returns> + public IReadOnlyList<QueueItem> GetPlaylist() + { + return GetPlaylistInternal(); + } + + /// <summary> + /// Sets a new playlist. Playing item is reset. + /// </summary> + /// <param name="items">The new items of the playlist.</param> + public void SetPlaylist(IReadOnlyList<Guid> items) + { + SortedPlaylist.Clear(); + ShuffledPlaylist.Clear(); + + SortedPlaylist = CreateQueueItemsFromArray(items); + if (ShuffleMode.Equals(GroupShuffleMode.Shuffle)) + { + ShuffledPlaylist = new List<QueueItem>(SortedPlaylist); + Shuffle(ShuffledPlaylist); + } + + PlayingItemIndex = NoPlayingItemIndex; + LastChange = DateTime.UtcNow; + } + + /// <summary> + /// Appends new items to the playlist. The specified order is mantained. + /// </summary> + /// <param name="items">The items to add to the playlist.</param> + public void Queue(IReadOnlyList<Guid> items) + { + var newItems = CreateQueueItemsFromArray(items); + + SortedPlaylist.AddRange(newItems); + if (ShuffleMode.Equals(GroupShuffleMode.Shuffle)) + { + ShuffledPlaylist.AddRange(newItems); + } + + LastChange = DateTime.UtcNow; + } + + /// <summary> + /// Shuffles the playlist. Shuffle mode is changed. The playlist gets re-shuffled if already shuffled. + /// </summary> + public void ShufflePlaylist() + { + if (PlayingItemIndex == NoPlayingItemIndex) + { + ShuffledPlaylist = new List<QueueItem>(SortedPlaylist); + Shuffle(ShuffledPlaylist); + } + else if (ShuffleMode.Equals(GroupShuffleMode.Sorted)) + { + // First time shuffle. + var playingItem = SortedPlaylist[PlayingItemIndex]; + ShuffledPlaylist = new List<QueueItem>(SortedPlaylist); + ShuffledPlaylist.RemoveAt(PlayingItemIndex); + Shuffle(ShuffledPlaylist); + ShuffledPlaylist.Insert(0, playingItem); + PlayingItemIndex = 0; + } + else + { + // Re-shuffle playlist. + var playingItem = ShuffledPlaylist[PlayingItemIndex]; + ShuffledPlaylist.RemoveAt(PlayingItemIndex); + Shuffle(ShuffledPlaylist); + ShuffledPlaylist.Insert(0, playingItem); + PlayingItemIndex = 0; + } + + ShuffleMode = GroupShuffleMode.Shuffle; + LastChange = DateTime.UtcNow; + } + + /// <summary> + /// Resets the playlist to sorted mode. Shuffle mode is changed. + /// </summary> + public void RestoreSortedPlaylist() + { + if (PlayingItemIndex != NoPlayingItemIndex) + { + var playingItem = ShuffledPlaylist[PlayingItemIndex]; + PlayingItemIndex = SortedPlaylist.IndexOf(playingItem); + } + + ShuffledPlaylist.Clear(); + + ShuffleMode = GroupShuffleMode.Sorted; + LastChange = DateTime.UtcNow; + } + + /// <summary> + /// Clears the playlist. Shuffle mode is preserved. + /// </summary> + /// <param name="clearPlayingItem">Whether to remove the playing item as well.</param> + public void ClearPlaylist(bool clearPlayingItem) + { + var playingItem = GetPlayingItem(); + SortedPlaylist.Clear(); + ShuffledPlaylist.Clear(); + LastChange = DateTime.UtcNow; + + if (!clearPlayingItem && playingItem != null) + { + SortedPlaylist.Add(playingItem); + if (ShuffleMode.Equals(GroupShuffleMode.Shuffle)) + { + ShuffledPlaylist.Add(playingItem); + } + + PlayingItemIndex = 0; + } + else + { + PlayingItemIndex = NoPlayingItemIndex; + } + } + + /// <summary> + /// Adds new items to the playlist right after the playing item. The specified order is mantained. + /// </summary> + /// <param name="items">The items to add to the playlist.</param> + public void QueueNext(IReadOnlyList<Guid> items) + { + var newItems = CreateQueueItemsFromArray(items); + + if (ShuffleMode.Equals(GroupShuffleMode.Shuffle)) + { + var playingItem = GetPlayingItem(); + var sortedPlayingItemIndex = SortedPlaylist.IndexOf(playingItem); + // Append items to sorted and shuffled playlist as they are. + SortedPlaylist.InsertRange(sortedPlayingItemIndex + 1, newItems); + ShuffledPlaylist.InsertRange(PlayingItemIndex + 1, newItems); + } + else + { + SortedPlaylist.InsertRange(PlayingItemIndex + 1, newItems); + } + + LastChange = DateTime.UtcNow; + } + + /// <summary> + /// Gets playlist identifier of the playing item, if any. + /// </summary> + /// <returns>The playlist identifier of the playing item.</returns> + public Guid GetPlayingItemPlaylistId() + { + var playingItem = GetPlayingItem(); + return playingItem?.PlaylistItemId ?? Guid.Empty; + } + + /// <summary> + /// Gets the playing item identifier, if any. + /// </summary> + /// <returns>The playing item identifier.</returns> + public Guid GetPlayingItemId() + { + var playingItem = GetPlayingItem(); + return playingItem?.ItemId ?? Guid.Empty; + } + + /// <summary> + /// Sets the playing item using its identifier. If not in the playlist, the playing item is reset. + /// </summary> + /// <param name="itemId">The new playing item identifier.</param> + public void SetPlayingItemById(Guid itemId) + { + var playlist = GetPlaylistInternal(); + PlayingItemIndex = playlist.FindIndex(item => item.ItemId.Equals(itemId)); + LastChange = DateTime.UtcNow; + } + + /// <summary> + /// Sets the playing item using its playlist identifier. If not in the playlist, the playing item is reset. + /// </summary> + /// <param name="playlistItemId">The new playing item identifier.</param> + /// <returns><c>true</c> if playing item has been set; <c>false</c> if item is not in the playlist.</returns> + public bool SetPlayingItemByPlaylistId(Guid playlistItemId) + { + var playlist = GetPlaylistInternal(); + PlayingItemIndex = playlist.FindIndex(item => item.PlaylistItemId.Equals(playlistItemId)); + LastChange = DateTime.UtcNow; + + return PlayingItemIndex != NoPlayingItemIndex; + } + + /// <summary> + /// Sets the playing item using its position. If not in range, the playing item is reset. + /// </summary> + /// <param name="playlistIndex">The new playing item index.</param> + public void SetPlayingItemByIndex(int playlistIndex) + { + var playlist = GetPlaylistInternal(); + if (playlistIndex < 0 || playlistIndex > playlist.Count) + { + PlayingItemIndex = NoPlayingItemIndex; + } + else + { + PlayingItemIndex = playlistIndex; + } + + LastChange = DateTime.UtcNow; + } + + /// <summary> + /// Removes items from the playlist. If not removed, the playing item is preserved. + /// </summary> + /// <param name="playlistItemIds">The items to remove.</param> + /// <returns><c>true</c> if playing item got removed; <c>false</c> otherwise.</returns> + public bool RemoveFromPlaylist(IReadOnlyList<Guid> playlistItemIds) + { + var playingItem = GetPlayingItem(); + + SortedPlaylist.RemoveAll(item => playlistItemIds.Contains(item.PlaylistItemId)); + ShuffledPlaylist.RemoveAll(item => playlistItemIds.Contains(item.PlaylistItemId)); + + LastChange = DateTime.UtcNow; + + if (playingItem != null) + { + if (playlistItemIds.Contains(playingItem.PlaylistItemId)) + { + // Playing item has been removed, picking previous item. + PlayingItemIndex--; + if (PlayingItemIndex < 0) + { + // Was first element, picking next if available. + // Default to no playing item otherwise. + PlayingItemIndex = SortedPlaylist.Count > 0 ? 0 : NoPlayingItemIndex; + } + + return true; + } + else + { + // Restoring playing item. + SetPlayingItemByPlaylistId(playingItem.PlaylistItemId); + return false; + } + } + else + { + return false; + } + } + + /// <summary> + /// Moves an item in the playlist to another position. + /// </summary> + /// <param name="playlistItemId">The item to move.</param> + /// <param name="newIndex">The new position.</param> + /// <returns><c>true</c> if the item has been moved; <c>false</c> otherwise.</returns> + public bool MovePlaylistItem(Guid playlistItemId, int newIndex) + { + var playlist = GetPlaylistInternal(); + var playingItem = GetPlayingItem(); + + var oldIndex = playlist.FindIndex(item => item.PlaylistItemId.Equals(playlistItemId)); + if (oldIndex < 0) + { + return false; + } + + var queueItem = playlist[oldIndex]; + playlist.RemoveAt(oldIndex); + newIndex = Math.Clamp(newIndex, 0, playlist.Count); + playlist.Insert(newIndex, queueItem); + + LastChange = DateTime.UtcNow; + PlayingItemIndex = playlist.IndexOf(playingItem); + return true; + } + + /// <summary> + /// Resets the playlist to its initial state. + /// </summary> + public void Reset() + { + SortedPlaylist.Clear(); + ShuffledPlaylist.Clear(); + PlayingItemIndex = NoPlayingItemIndex; + ShuffleMode = GroupShuffleMode.Sorted; + RepeatMode = GroupRepeatMode.RepeatNone; + LastChange = DateTime.UtcNow; + } + + /// <summary> + /// Sets the repeat mode. + /// </summary> + /// <param name="mode">The new mode.</param> + public void SetRepeatMode(GroupRepeatMode mode) + { + RepeatMode = mode; + LastChange = DateTime.UtcNow; + } + + /// <summary> + /// Sets the shuffle mode. + /// </summary> + /// <param name="mode">The new mode.</param> + public void SetShuffleMode(GroupShuffleMode mode) + { + if (mode.Equals(GroupShuffleMode.Shuffle)) + { + ShufflePlaylist(); + } + else + { + RestoreSortedPlaylist(); + } + } + + /// <summary> + /// Toggles the shuffle mode between sorted and shuffled. + /// </summary> + public void ToggleShuffleMode() + { + if (ShuffleMode.Equals(GroupShuffleMode.Sorted)) + { + ShufflePlaylist(); + } + else + { + RestoreSortedPlaylist(); + } + } + + /// <summary> + /// Gets the next item in the playlist considering repeat mode and shuffle mode. + /// </summary> + /// <returns>The next item in the playlist.</returns> + public QueueItem GetNextItemPlaylistId() + { + int newIndex; + var playlist = GetPlaylistInternal(); + + switch (RepeatMode) + { + case GroupRepeatMode.RepeatOne: + newIndex = PlayingItemIndex; + break; + case GroupRepeatMode.RepeatAll: + newIndex = PlayingItemIndex + 1; + if (newIndex >= playlist.Count) + { + newIndex = 0; + } + + break; + default: + newIndex = PlayingItemIndex + 1; + break; + } + + if (newIndex < 0 || newIndex >= playlist.Count) + { + return null; + } + + return playlist[newIndex]; + } + + /// <summary> + /// Sets the next item in the queue as playing item. + /// </summary> + /// <returns><c>true</c> if the playing item changed; <c>false</c> otherwise.</returns> + public bool Next() + { + if (RepeatMode.Equals(GroupRepeatMode.RepeatOne)) + { + LastChange = DateTime.UtcNow; + return true; + } + + PlayingItemIndex++; + if (PlayingItemIndex >= SortedPlaylist.Count) + { + if (RepeatMode.Equals(GroupRepeatMode.RepeatAll)) + { + PlayingItemIndex = 0; + } + else + { + PlayingItemIndex = SortedPlaylist.Count - 1; + return false; + } + } + + LastChange = DateTime.UtcNow; + return true; + } + + /// <summary> + /// Sets the previous item in the queue as playing item. + /// </summary> + /// <returns><c>true</c> if the playing item changed; <c>false</c> otherwise.</returns> + public bool Previous() + { + if (RepeatMode.Equals(GroupRepeatMode.RepeatOne)) + { + LastChange = DateTime.UtcNow; + return true; + } + + PlayingItemIndex--; + if (PlayingItemIndex < 0) + { + if (RepeatMode.Equals(GroupRepeatMode.RepeatAll)) + { + PlayingItemIndex = SortedPlaylist.Count - 1; + } + else + { + PlayingItemIndex = 0; + return false; + } + } + + LastChange = DateTime.UtcNow; + return true; + } + + /// <summary> + /// Shuffles a given list. + /// </summary> + /// <param name="list">The list to shuffle.</param> + private void Shuffle<T>(IList<T> list) + { + int n = list.Count; + while (n > 1) + { + n--; + int k = _randomNumberGenerator.Next(n + 1); + T value = list[k]; + list[k] = list[n]; + list[n] = value; + } + } + + /// <summary> + /// Creates a list from the array of items. Each item is given an unique playlist identifier. + /// </summary> + /// <returns>The list of queue items.</returns> + private List<QueueItem> CreateQueueItemsFromArray(IReadOnlyList<Guid> items) + { + var list = new List<QueueItem>(); + foreach (var item in items) + { + var queueItem = new QueueItem(item); + list.Add(queueItem); + } + + return list; + } + + /// <summary> + /// Gets the current playlist considering the shuffle mode. + /// </summary> + /// <returns>The playlist.</returns> + private List<QueueItem> GetPlaylistInternal() + { + if (ShuffleMode.Equals(GroupShuffleMode.Shuffle)) + { + return ShuffledPlaylist; + } + else + { + return SortedPlaylist; + } + } + + /// <summary> + /// Gets the current playing item, depending on the shuffle mode. + /// </summary> + /// <returns>The playing item.</returns> + private QueueItem GetPlayingItem() + { + if (PlayingItemIndex == NoPlayingItemIndex) + { + return null; + } + else if (ShuffleMode.Equals(GroupShuffleMode.Shuffle)) + { + return ShuffledPlaylist[PlayingItemIndex]; + } + else + { + return SortedPlaylist[PlayingItemIndex]; + } + } + } +} diff --git a/MediaBrowser.Controller/SyncPlay/Requests/JoinGroupRequest.cs b/MediaBrowser.Controller/SyncPlay/Requests/JoinGroupRequest.cs new file mode 100644 index 0000000000..38c9e8e20c --- /dev/null +++ b/MediaBrowser.Controller/SyncPlay/Requests/JoinGroupRequest.cs @@ -0,0 +1,29 @@ +using System; +using MediaBrowser.Model.SyncPlay; + +namespace MediaBrowser.Controller.SyncPlay.Requests +{ + /// <summary> + /// Class JoinGroupRequest. + /// </summary> + public class JoinGroupRequest : ISyncPlayRequest + { + /// <summary> + /// Initializes a new instance of the <see cref="JoinGroupRequest"/> class. + /// </summary> + /// <param name="groupId">The identifier of the group to join.</param> + public JoinGroupRequest(Guid groupId) + { + GroupId = groupId; + } + + /// <summary> + /// Gets the group identifier. + /// </summary> + /// <value>The identifier of the group to join.</value> + public Guid GroupId { get; } + + /// <inheritdoc /> + public RequestType Type { get; } = RequestType.JoinGroup; + } +} diff --git a/MediaBrowser.Controller/SyncPlay/Requests/LeaveGroupRequest.cs b/MediaBrowser.Controller/SyncPlay/Requests/LeaveGroupRequest.cs new file mode 100644 index 0000000000..545778264f --- /dev/null +++ b/MediaBrowser.Controller/SyncPlay/Requests/LeaveGroupRequest.cs @@ -0,0 +1,13 @@ +using MediaBrowser.Model.SyncPlay; + +namespace MediaBrowser.Controller.SyncPlay.Requests +{ + /// <summary> + /// Class LeaveGroupRequest. + /// </summary> + public class LeaveGroupRequest : ISyncPlayRequest + { + /// <inheritdoc /> + public RequestType Type { get; } = RequestType.LeaveGroup; + } +} diff --git a/MediaBrowser.Controller/SyncPlay/Requests/ListGroupsRequest.cs b/MediaBrowser.Controller/SyncPlay/Requests/ListGroupsRequest.cs new file mode 100644 index 0000000000..4a234fdab5 --- /dev/null +++ b/MediaBrowser.Controller/SyncPlay/Requests/ListGroupsRequest.cs @@ -0,0 +1,13 @@ +using MediaBrowser.Model.SyncPlay; + +namespace MediaBrowser.Controller.SyncPlay.Requests +{ + /// <summary> + /// Class ListGroupsRequest. + /// </summary> + public class ListGroupsRequest : ISyncPlayRequest + { + /// <inheritdoc /> + public RequestType Type { get; } = RequestType.ListGroups; + } +} diff --git a/MediaBrowser.Controller/SyncPlay/Requests/NewGroupRequest.cs b/MediaBrowser.Controller/SyncPlay/Requests/NewGroupRequest.cs new file mode 100644 index 0000000000..1321f0de8e --- /dev/null +++ b/MediaBrowser.Controller/SyncPlay/Requests/NewGroupRequest.cs @@ -0,0 +1,28 @@ +using MediaBrowser.Model.SyncPlay; + +namespace MediaBrowser.Controller.SyncPlay.Requests +{ + /// <summary> + /// Class NewGroupRequest. + /// </summary> + public class NewGroupRequest : ISyncPlayRequest + { + /// <summary> + /// Initializes a new instance of the <see cref="NewGroupRequest"/> class. + /// </summary> + /// <param name="groupName">The name of the new group.</param> + public NewGroupRequest(string groupName) + { + GroupName = groupName; + } + + /// <summary> + /// Gets the group name. + /// </summary> + /// <value>The name of the new group.</value> + public string GroupName { get; } + + /// <inheritdoc /> + public RequestType Type { get; } = RequestType.NewGroup; + } +} diff --git a/MediaBrowser.Controller/TV/ITVSeriesManager.cs b/MediaBrowser.Controller/TV/ITVSeriesManager.cs index 291dea04ef..e066c03fdc 100644 --- a/MediaBrowser.Controller/TV/ITVSeriesManager.cs +++ b/MediaBrowser.Controller/TV/ITVSeriesManager.cs @@ -6,16 +6,26 @@ using MediaBrowser.Model.Querying; namespace MediaBrowser.Controller.TV { + /// <summary> + /// The TV Series manager. + /// </summary> public interface ITVSeriesManager { /// <summary> /// Gets the next up. /// </summary> + /// <param name="query">The next up query.</param> + /// <param name="options">The dto options.</param> + /// <returns>The next up items.</returns> QueryResult<BaseItem> GetNextUp(NextUpQuery query, DtoOptions options); /// <summary> /// Gets the next up. /// </summary> + /// <param name="request">The next up request.</param> + /// <param name="parentsFolders">The list of parent folders.</param> + /// <param name="options">The dto options.</param> + /// <returns>The next up items.</returns> QueryResult<BaseItem> GetNextUp(NextUpQuery request, BaseItem[] parentsFolders, DtoOptions options); } } diff --git a/MediaBrowser.LocalMetadata/BaseXmlProvider.cs b/MediaBrowser.LocalMetadata/BaseXmlProvider.cs index b036a2cc84..9f8e921b4b 100644 --- a/MediaBrowser.LocalMetadata/BaseXmlProvider.cs +++ b/MediaBrowser.LocalMetadata/BaseXmlProvider.cs @@ -91,7 +91,7 @@ namespace MediaBrowser.LocalMetadata /// <param name="info">Item inf.</param> /// <param name="directoryService">Instance of the <see cref="IDirectoryService"/> interface.</param> /// <returns>The file system metadata.</returns> - protected abstract FileSystemMetadata GetXmlFile(ItemInfo info, IDirectoryService directoryService); + protected abstract FileSystemMetadata? GetXmlFile(ItemInfo info, IDirectoryService directoryService); /// <inheritdoc /> public bool HasChanged(BaseItem item, IDirectoryService directoryService) diff --git a/MediaBrowser.LocalMetadata/Images/CollectionFolderLocalImageProvider.cs b/MediaBrowser.LocalMetadata/Images/CollectionFolderLocalImageProvider.cs index 556bb6a0e4..b6189bcdd6 100644 --- a/MediaBrowser.LocalMetadata/Images/CollectionFolderLocalImageProvider.cs +++ b/MediaBrowser.LocalMetadata/Images/CollectionFolderLocalImageProvider.cs @@ -35,7 +35,7 @@ namespace MediaBrowser.LocalMetadata.Images } /// <inheritdoc /> - public List<LocalImageInfo> GetImages(BaseItem item, IDirectoryService directoryService) + public IEnumerable<LocalImageInfo> GetImages(BaseItem item, IDirectoryService directoryService) { var collectionFolder = (CollectionFolder)item; diff --git a/MediaBrowser.LocalMetadata/Images/EpisodeLocalImageProvider.cs b/MediaBrowser.LocalMetadata/Images/EpisodeLocalImageProvider.cs index 393ad2efb5..dc13bf4f6d 100644 --- a/MediaBrowser.LocalMetadata/Images/EpisodeLocalImageProvider.cs +++ b/MediaBrowser.LocalMetadata/Images/EpisodeLocalImageProvider.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using System.IO; using System.Linq; +using Jellyfin.Extensions; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.TV; using MediaBrowser.Controller.Providers; @@ -15,17 +16,6 @@ namespace MediaBrowser.LocalMetadata.Images /// </summary> public class EpisodeLocalImageProvider : ILocalImageProvider, IHasOrder { - private readonly IFileSystem _fileSystem; - - /// <summary> - /// Initializes a new instance of the <see cref="EpisodeLocalImageProvider"/> class. - /// </summary> - /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param> - public EpisodeLocalImageProvider(IFileSystem fileSystem) - { - _fileSystem = fileSystem; - } - /// <inheritdoc /> public string Name => "Local Images"; @@ -39,20 +29,24 @@ namespace MediaBrowser.LocalMetadata.Images } /// <inheritdoc /> - public List<LocalImageInfo> GetImages(BaseItem item, IDirectoryService directoryService) + public IEnumerable<LocalImageInfo> GetImages(BaseItem item, IDirectoryService directoryService) { var parentPath = Path.GetDirectoryName(item.Path); + if (parentPath == null) + { + return Enumerable.Empty<LocalImageInfo>(); + } var parentPathFiles = directoryService.GetFiles(parentPath); - var nameWithoutExtension = Path.GetFileNameWithoutExtension(item.Path); + var nameWithoutExtension = Path.GetFileNameWithoutExtension(item.Path.AsSpan()); return GetFilesFromParentFolder(nameWithoutExtension, parentPathFiles); } - private List<LocalImageInfo> GetFilesFromParentFolder(string filenameWithoutExtension, List<FileSystemMetadata> parentPathFiles) + private List<LocalImageInfo> GetFilesFromParentFolder(ReadOnlySpan<char> filenameWithoutExtension, List<FileSystemMetadata> parentPathFiles) { - var thumbName = filenameWithoutExtension + "-thumb"; + var thumbName = string.Concat(filenameWithoutExtension, "-thumb"); var list = new List<LocalImageInfo>(1); @@ -63,15 +57,15 @@ namespace MediaBrowser.LocalMetadata.Images continue; } - if (BaseItem.SupportedImageExtensions.Contains(i.Extension, StringComparer.OrdinalIgnoreCase)) + if (BaseItem.SupportedImageExtensions.Contains(i.Extension.AsSpan(), StringComparison.OrdinalIgnoreCase)) { - var currentNameWithoutExtension = _fileSystem.GetFileNameWithoutExtension(i); + var currentNameWithoutExtension = Path.GetFileNameWithoutExtension(i.FullName.AsSpan()); - if (string.Equals(filenameWithoutExtension, currentNameWithoutExtension, StringComparison.OrdinalIgnoreCase)) + if (filenameWithoutExtension.Equals(currentNameWithoutExtension, StringComparison.OrdinalIgnoreCase)) { list.Add(new LocalImageInfo { FileInfo = i, Type = ImageType.Primary }); } - else if (string.Equals(thumbName, currentNameWithoutExtension, StringComparison.OrdinalIgnoreCase)) + else if (currentNameWithoutExtension.Equals(thumbName, StringComparison.OrdinalIgnoreCase)) { list.Add(new LocalImageInfo { FileInfo = i, Type = ImageType.Primary }); } diff --git a/MediaBrowser.LocalMetadata/Images/InternalMetadataFolderImageProvider.cs b/MediaBrowser.LocalMetadata/Images/InternalMetadataFolderImageProvider.cs index 509b5d700d..10d691b3e9 100644 --- a/MediaBrowser.LocalMetadata/Images/InternalMetadataFolderImageProvider.cs +++ b/MediaBrowser.LocalMetadata/Images/InternalMetadataFolderImageProvider.cs @@ -1,5 +1,6 @@ using System.Collections.Generic; using System.IO; +using System.Linq; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.Audio; @@ -69,13 +70,13 @@ namespace MediaBrowser.LocalMetadata.Images } /// <inheritdoc /> - public List<LocalImageInfo> GetImages(BaseItem item, IDirectoryService directoryService) + public IEnumerable<LocalImageInfo> GetImages(BaseItem item, IDirectoryService directoryService) { var path = item.GetInternalMetadataPath(); if (!Directory.Exists(path)) { - return new List<LocalImageInfo>(); + return Enumerable.Empty<LocalImageInfo>(); } try @@ -85,7 +86,7 @@ namespace MediaBrowser.LocalMetadata.Images catch (IOException ex) { _logger.LogError(ex, "Error while getting images for {Library}", item.Name); - return new List<LocalImageInfo>(); + return Enumerable.Empty<LocalImageInfo>(); } } } diff --git a/MediaBrowser.LocalMetadata/Images/LocalImageProvider.cs b/MediaBrowser.LocalMetadata/Images/LocalImageProvider.cs index 914db53059..31475e22f4 100644 --- a/MediaBrowser.LocalMetadata/Images/LocalImageProvider.cs +++ b/MediaBrowser.LocalMetadata/Images/LocalImageProvider.cs @@ -108,7 +108,7 @@ namespace MediaBrowser.LocalMetadata.Images { if (!item.IsFileProtocol) { - return new List<FileSystemMetadata>(); + return Enumerable.Empty<FileSystemMetadata>(); } var path = item.ContainingFolderPath; @@ -116,7 +116,7 @@ namespace MediaBrowser.LocalMetadata.Images // Exit if the cache dir does not exist, alternative solution is to create it, but that's a lot of empty dirs... if (!Directory.Exists(path)) { - return Array.Empty<FileSystemMetadata>(); + return Enumerable.Empty<FileSystemMetadata>(); } if (includeDirectories) @@ -133,7 +133,7 @@ namespace MediaBrowser.LocalMetadata.Images } /// <inheritdoc /> - public List<LocalImageInfo> GetImages(BaseItem item, IDirectoryService directoryService) + public IEnumerable<LocalImageInfo> GetImages(BaseItem item, IDirectoryService directoryService) { var files = GetFiles(item, true, directoryService).ToList(); @@ -151,7 +151,7 @@ namespace MediaBrowser.LocalMetadata.Images /// <param name="path">The images path.</param> /// <param name="directoryService">Instance of the <see cref="IDirectoryService"/> interface.</param> /// <returns>The local image info.</returns> - public List<LocalImageInfo> GetImages(BaseItem item, string path, IDirectoryService directoryService) + public IEnumerable<LocalImageInfo> GetImages(BaseItem item, string path, IDirectoryService directoryService) { return GetImages(item, new[] { path }, directoryService); } @@ -163,7 +163,7 @@ namespace MediaBrowser.LocalMetadata.Images /// <param name="paths">The image paths.</param> /// <param name="directoryService">Instance of the <see cref="IDirectoryService"/> interface.</param> /// <returns>The local image info.</returns> - public List<LocalImageInfo> GetImages(BaseItem item, IEnumerable<string> paths, IDirectoryService directoryService) + public IEnumerable<LocalImageInfo> GetImages(BaseItem item, IEnumerable<string> paths, IDirectoryService directoryService) { IEnumerable<FileSystemMetadata> files = paths.SelectMany(i => _fileSystem.GetFiles(i, BaseItem.SupportedImageExtensions, true, false)); @@ -181,9 +181,7 @@ namespace MediaBrowser.LocalMetadata.Images { if (supportParentSeriesFiles) { - var season = item as Season; - - if (season != null) + if (item is Season season) { PopulateSeasonImagesFromSeriesFolder(season, images, directoryService); } @@ -468,7 +466,7 @@ namespace MediaBrowser.LocalMetadata.Images return added; } - private bool AddImage(IEnumerable<FileSystemMetadata> files, List<LocalImageInfo> images, string name, ImageType type) + private bool AddImage(List<FileSystemMetadata> files, List<LocalImageInfo> images, string name, ImageType type) { var image = GetImage(files, name); @@ -486,9 +484,20 @@ namespace MediaBrowser.LocalMetadata.Images return false; } - private FileSystemMetadata GetImage(IEnumerable<FileSystemMetadata> files, string name) + private static FileSystemMetadata? GetImage(IReadOnlyList<FileSystemMetadata> files, string name) { - return files.FirstOrDefault(i => !i.IsDirectory && string.Equals(name, _fileSystem.GetFileNameWithoutExtension(i), StringComparison.OrdinalIgnoreCase) && i.Length > 0); + for (var i = 0; i < files.Count; i++) + { + var file = files[i]; + if (!file.IsDirectory + && file.Length > 0 + && Path.GetFileNameWithoutExtension(file.FullName.AsSpan()).Equals(name, StringComparison.OrdinalIgnoreCase)) + { + return file; + } + } + + return null; } } } diff --git a/MediaBrowser.LocalMetadata/MediaBrowser.LocalMetadata.csproj b/MediaBrowser.LocalMetadata/MediaBrowser.LocalMetadata.csproj index 529e7065cd..1cf8fcd1b5 100644 --- a/MediaBrowser.LocalMetadata/MediaBrowser.LocalMetadata.csproj +++ b/MediaBrowser.LocalMetadata/MediaBrowser.LocalMetadata.csproj @@ -11,11 +11,9 @@ </ItemGroup> <PropertyGroup> - <TargetFramework>netstandard2.1</TargetFramework> + <TargetFramework>net5.0</TargetFramework> <GenerateAssemblyInfo>false</GenerateAssemblyInfo> <GenerateDocumentationFile>true</GenerateDocumentationFile> - <TreatWarningsAsErrors>true</TreatWarningsAsErrors> - <Nullable>enable</Nullable> </PropertyGroup> <ItemGroup> @@ -24,14 +22,9 @@ <!-- Code Analyzers--> <ItemGroup Condition=" '$(Configuration)' == 'Debug' "> - <PackageReference Include="Microsoft.CodeAnalysis.FxCopAnalyzers" Version="2.9.8" PrivateAssets="All" /> <PackageReference Include="SerilogAnalyzer" Version="0.15.0" PrivateAssets="All" /> <PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="All" /> <PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" PrivateAssets="All" /> </ItemGroup> - <PropertyGroup Condition=" '$(Configuration)' == 'Debug' "> - <CodeAnalysisRuleSet>../jellyfin.ruleset</CodeAnalysisRuleSet> - </PropertyGroup> - </Project> diff --git a/MediaBrowser.LocalMetadata/Parsers/BaseItemXmlParser.cs b/MediaBrowser.LocalMetadata/Parsers/BaseItemXmlParser.cs index 4ac2498400..ef130ee747 100644 --- a/MediaBrowser.LocalMetadata/Parsers/BaseItemXmlParser.cs +++ b/MediaBrowser.LocalMetadata/Parsers/BaseItemXmlParser.cs @@ -81,7 +81,7 @@ namespace MediaBrowser.LocalMetadata.Parsers var id = info.Key + "Id"; if (!_validProviderIds.ContainsKey(id)) { - _validProviderIds.Add(id, info.Key!); + _validProviderIds.Add(id, info.Key); } } @@ -344,8 +344,7 @@ namespace MediaBrowser.LocalMetadata.Parsers { var val = reader.ReadElementContentAsString(); - var hasAspectRatio = item as IHasAspectRatio; - if (!string.IsNullOrWhiteSpace(val) && hasAspectRatio != null) + if (!string.IsNullOrWhiteSpace(val) && item is IHasAspectRatio hasAspectRatio) { hasAspectRatio.AspectRatio = val; } @@ -469,8 +468,7 @@ namespace MediaBrowser.LocalMetadata.Parsers { var val = reader.ReadElementContentAsString(); - var hasDisplayOrder = item as IHasDisplayOrder; - if (hasDisplayOrder != null) + if (item is IHasDisplayOrder hasDisplayOrder) { if (!string.IsNullOrWhiteSpace(val)) { @@ -683,7 +681,7 @@ namespace MediaBrowser.LocalMetadata.Parsers default: { string readerName = reader.Name; - if (_validProviderIds!.TryGetValue(readerName, out string providerIdValue)) + if (_validProviderIds!.TryGetValue(readerName, out string? providerIdValue)) { var id = reader.ReadElementContentAsString(); if (!string.IsNullOrWhiteSpace(id)) @@ -752,46 +750,6 @@ namespace MediaBrowser.LocalMetadata.Parsers item.Shares = list.ToArray(); } - private Share GetShareFromNode(XmlReader reader) - { - var share = new Share(); - - reader.MoveToContent(); - reader.Read(); - - // Loop through each element - while (!reader.EOF && reader.ReadState == ReadState.Interactive) - { - if (reader.NodeType == XmlNodeType.Element) - { - switch (reader.Name) - { - case "UserId": - { - share.UserId = reader.ReadElementContentAsString(); - break; - } - - case "CanEdit": - { - share.CanEdit = string.Equals(reader.ReadElementContentAsString(), true.ToString(CultureInfo.InvariantCulture), StringComparison.OrdinalIgnoreCase); - break; - } - - default: - reader.Skip(); - break; - } - } - else - { - reader.Read(); - } - } - - return share; - } - private void FetchFromCountriesNode(XmlReader reader) { reader.MoveToContent(); @@ -1103,7 +1061,7 @@ namespace MediaBrowser.LocalMetadata.Parsers switch (reader.Name) { case "Name": - name = reader.ReadElementContentAsString() ?? string.Empty; + name = reader.ReadElementContentAsString(); break; case "Type": @@ -1272,12 +1230,10 @@ namespace MediaBrowser.LocalMetadata.Parsers /// <returns>IEnumerable{System.String}.</returns> private IEnumerable<string> SplitNames(string value) { - value ??= string.Empty; - // Only split by comma if there is no pipe in the string // We have to be careful to not split names like Matthew, Jr. - var separator = value.IndexOf('|', StringComparison.Ordinal) == -1 - && value.IndexOf(';', StringComparison.Ordinal) == -1 ? new[] { ',' } : new[] { '|', ';' }; + var separator = !value.Contains('|', StringComparison.Ordinal) + && !value.Contains(';', StringComparison.Ordinal) ? new[] { ',' } : new[] { '|', ';' }; value = value.Trim().Trim(separator); diff --git a/MediaBrowser.LocalMetadata/Parsers/BoxSetXmlParser.cs b/MediaBrowser.LocalMetadata/Parsers/BoxSetXmlParser.cs index ff846830bf..7df800971a 100644 --- a/MediaBrowser.LocalMetadata/Parsers/BoxSetXmlParser.cs +++ b/MediaBrowser.LocalMetadata/Parsers/BoxSetXmlParser.cs @@ -23,7 +23,7 @@ namespace MediaBrowser.LocalMetadata.Parsers } /// <inheritdoc /> - protected override void FetchDataFromXmlNode(XmlReader reader, MetadataResult<BoxSet> item) + protected override void FetchDataFromXmlNode(XmlReader reader, MetadataResult<BoxSet> itemResult) { switch (reader.Name) { @@ -33,7 +33,7 @@ namespace MediaBrowser.LocalMetadata.Parsers { using (var subReader = reader.ReadSubtree()) { - FetchFromCollectionItemsNode(subReader, item); + FetchFromCollectionItemsNode(subReader, itemResult); } } else @@ -44,7 +44,7 @@ namespace MediaBrowser.LocalMetadata.Parsers break; default: - base.FetchDataFromXmlNode(reader, item); + base.FetchDataFromXmlNode(reader, itemResult); break; } } diff --git a/MediaBrowser.LocalMetadata/Parsers/PlaylistXmlParser.cs b/MediaBrowser.LocalMetadata/Parsers/PlaylistXmlParser.cs index 78c0fa8ad3..b84307cb20 100644 --- a/MediaBrowser.LocalMetadata/Parsers/PlaylistXmlParser.cs +++ b/MediaBrowser.LocalMetadata/Parsers/PlaylistXmlParser.cs @@ -23,9 +23,9 @@ namespace MediaBrowser.LocalMetadata.Parsers } /// <inheritdoc /> - protected override void FetchDataFromXmlNode(XmlReader reader, MetadataResult<Playlist> result) + protected override void FetchDataFromXmlNode(XmlReader reader, MetadataResult<Playlist> itemResult) { - var item = result.Item; + var item = itemResult.Item; switch (reader.Name) { @@ -53,7 +53,7 @@ namespace MediaBrowser.LocalMetadata.Parsers break; default: - base.FetchDataFromXmlNode(reader, result); + base.FetchDataFromXmlNode(reader, itemResult); break; } } diff --git a/MediaBrowser.LocalMetadata/Providers/BoxSetXmlProvider.cs b/MediaBrowser.LocalMetadata/Providers/BoxSetXmlProvider.cs index cc705a9dfa..ea123bbf22 100644 --- a/MediaBrowser.LocalMetadata/Providers/BoxSetXmlProvider.cs +++ b/MediaBrowser.LocalMetadata/Providers/BoxSetXmlProvider.cs @@ -36,7 +36,7 @@ namespace MediaBrowser.LocalMetadata.Providers } /// <inheritdoc /> - protected override FileSystemMetadata GetXmlFile(ItemInfo info, IDirectoryService directoryService) + protected override FileSystemMetadata? GetXmlFile(ItemInfo info, IDirectoryService directoryService) { return directoryService.GetFile(Path.Combine(info.Path, "collection.xml")); } diff --git a/MediaBrowser.LocalMetadata/Providers/PlaylistXmlProvider.cs b/MediaBrowser.LocalMetadata/Providers/PlaylistXmlProvider.cs index 36f3048ad5..8990356521 100644 --- a/MediaBrowser.LocalMetadata/Providers/PlaylistXmlProvider.cs +++ b/MediaBrowser.LocalMetadata/Providers/PlaylistXmlProvider.cs @@ -39,7 +39,7 @@ namespace MediaBrowser.LocalMetadata.Providers } /// <inheritdoc /> - protected override FileSystemMetadata GetXmlFile(ItemInfo info, IDirectoryService directoryService) + protected override FileSystemMetadata? GetXmlFile(ItemInfo info, IDirectoryService directoryService) { return directoryService.GetFile(PlaylistXmlSaver.GetSavePath(info.Path)); } diff --git a/MediaBrowser.LocalMetadata/Savers/BaseXmlSaver.cs b/MediaBrowser.LocalMetadata/Savers/BaseXmlSaver.cs index 7a4823e1b8..dd824206f7 100644 --- a/MediaBrowser.LocalMetadata/Savers/BaseXmlSaver.cs +++ b/MediaBrowser.LocalMetadata/Savers/BaseXmlSaver.cs @@ -33,16 +33,12 @@ namespace MediaBrowser.LocalMetadata.Savers /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param> /// <param name="configurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param> /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> - /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> - /// <param name="userDataManager">Instance of the <see cref="IUserDataManager"/> interface.</param> /// <param name="logger">Instance of the <see cref="ILogger{BaseXmlSaver}"/> interface.</param> - public BaseXmlSaver(IFileSystem fileSystem, IServerConfigurationManager configurationManager, ILibraryManager libraryManager, IUserManager userManager, IUserDataManager userDataManager, ILogger<BaseXmlSaver> logger) + protected BaseXmlSaver(IFileSystem fileSystem, IServerConfigurationManager configurationManager, ILibraryManager libraryManager, ILogger<BaseXmlSaver> logger) { FileSystem = fileSystem; ConfigurationManager = configurationManager; LibraryManager = libraryManager; - UserManager = userManager; - UserDataManager = userDataManager; Logger = logger; } @@ -61,16 +57,6 @@ namespace MediaBrowser.LocalMetadata.Savers /// </summary> protected ILibraryManager LibraryManager { get; private set; } - /// <summary> - /// Gets the user manager. - /// </summary> - protected IUserManager UserManager { get; private set; } - - /// <summary> - /// Gets the user data manager. - /// </summary> - protected IUserDataManager UserDataManager { get; private set; } - /// <summary> /// Gets the logger. /// </summary> @@ -127,11 +113,13 @@ namespace MediaBrowser.LocalMetadata.Savers private void SaveToFile(Stream stream, string path) { - Directory.CreateDirectory(Path.GetDirectoryName(path)); + var directory = Path.GetDirectoryName(path) ?? throw new ArgumentException($"Provided path ({path}) is not valid.", nameof(path)); + Directory.CreateDirectory(directory); // On Windows, savint the file will fail if the file is hidden or readonly FileSystem.SetAttributes(path, false, false); - using (var filestream = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.Read)) + // use FileShare.None as this bypasses dotnet bug dotnet/runtime#42790 . + using (var filestream = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.None)) { stream.CopyTo(filestream); } @@ -212,7 +200,7 @@ namespace MediaBrowser.LocalMetadata.Savers if (item.LockedFields.Length > 0) { - writer.WriteElementString("LockedFields", string.Join("|", item.LockedFields)); + writer.WriteElementString("LockedFields", string.Join('|', item.LockedFields)); } if (item.CriticRating.HasValue) @@ -294,8 +282,7 @@ namespace MediaBrowser.LocalMetadata.Savers writer.WriteEndElement(); } - var hasDisplayOrder = item as IHasDisplayOrder; - if (hasDisplayOrder != null && !string.IsNullOrEmpty(hasDisplayOrder.DisplayOrder)) + if (item is IHasDisplayOrder hasDisplayOrder && !string.IsNullOrEmpty(hasDisplayOrder.DisplayOrder)) { writer.WriteElementString("DisplayOrder", hasDisplayOrder.DisplayOrder); } @@ -310,8 +297,7 @@ namespace MediaBrowser.LocalMetadata.Savers writer.WriteElementString("ProductionYear", item.ProductionYear.Value.ToString(_usCulture)); } - var hasAspectRatio = item as IHasAspectRatio; - if (hasAspectRatio != null) + if (item is IHasAspectRatio hasAspectRatio) { if (!string.IsNullOrEmpty(hasAspectRatio.AspectRatio)) { @@ -334,7 +320,7 @@ namespace MediaBrowser.LocalMetadata.Savers if (runTimeTicks.HasValue) { - var timespan = TimeSpan.FromTicks(runTimeTicks!.Value); + var timespan = TimeSpan.FromTicks(runTimeTicks.Value); writer.WriteElementString("RunningTime", Math.Floor(timespan.TotalMinutes).ToString(_usCulture)); } @@ -418,20 +404,17 @@ namespace MediaBrowser.LocalMetadata.Savers writer.WriteEndElement(); } - var boxset = item as BoxSet; - if (boxset != null) + if (item is BoxSet boxset) { AddLinkedChildren(boxset, writer, "CollectionItems", "CollectionItem"); } - var playlist = item as Playlist; - if (playlist != null && !Playlist.IsPlaylistFile(playlist.Path)) + if (item is Playlist playlist && !Playlist.IsPlaylistFile(playlist.Path)) { AddLinkedChildren(playlist, writer, "PlaylistItems", "PlaylistItem"); } - var hasShares = item as IHasShares; - if (hasShares != null) + if (item is IHasShares hasShares) { AddShares(hasShares, writer); } @@ -539,10 +522,5 @@ namespace MediaBrowser.LocalMetadata.Savers writer.WriteEndElement(); } - - private bool IsPersonType(PersonInfo person, string type) - { - return string.Equals(person.Type, type, StringComparison.OrdinalIgnoreCase) || string.Equals(person.Role, type, StringComparison.OrdinalIgnoreCase); - } } } diff --git a/MediaBrowser.LocalMetadata/Savers/BoxSetXmlSaver.cs b/MediaBrowser.LocalMetadata/Savers/BoxSetXmlSaver.cs index b08387b0c6..8a5da95bf3 100644 --- a/MediaBrowser.LocalMetadata/Savers/BoxSetXmlSaver.cs +++ b/MediaBrowser.LocalMetadata/Savers/BoxSetXmlSaver.cs @@ -20,11 +20,9 @@ namespace MediaBrowser.LocalMetadata.Savers /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param> /// <param name="configurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param> /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> - /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> - /// <param name="userDataManager">Instance of the <see cref="IUserDataManager"/> interface.</param> /// <param name="logger">Instance of the <see cref="ILogger{BoxSetXmlSaver}"/> interface.</param> - public BoxSetXmlSaver(IFileSystem fileSystem, IServerConfigurationManager configurationManager, ILibraryManager libraryManager, IUserManager userManager, IUserDataManager userDataManager, ILogger<BoxSetXmlSaver> logger) - : base(fileSystem, configurationManager, libraryManager, userManager, userDataManager, logger) + public BoxSetXmlSaver(IFileSystem fileSystem, IServerConfigurationManager configurationManager, ILibraryManager libraryManager, ILogger<BoxSetXmlSaver> logger) + : base(fileSystem, configurationManager, libraryManager, logger) { } diff --git a/MediaBrowser.LocalMetadata/Savers/PlaylistXmlSaver.cs b/MediaBrowser.LocalMetadata/Savers/PlaylistXmlSaver.cs index c2f1064238..76252bc090 100644 --- a/MediaBrowser.LocalMetadata/Savers/PlaylistXmlSaver.cs +++ b/MediaBrowser.LocalMetadata/Savers/PlaylistXmlSaver.cs @@ -25,11 +25,9 @@ namespace MediaBrowser.LocalMetadata.Savers /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param> /// <param name="configurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param> /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> - /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> - /// <param name="userDataManager">Instance of the <see cref="IUserDataManager"/> interface.</param> /// <param name="logger">Instance of the <see cref="ILogger{PlaylistXmlSaver}"/> interface.</param> - public PlaylistXmlSaver(IFileSystem fileSystem, IServerConfigurationManager configurationManager, ILibraryManager libraryManager, IUserManager userManager, IUserDataManager userDataManager, ILogger<PlaylistXmlSaver> logger) - : base(fileSystem, configurationManager, libraryManager, userManager, userDataManager, logger) + public PlaylistXmlSaver(IFileSystem fileSystem, IServerConfigurationManager configurationManager, ILibraryManager libraryManager, ILogger<PlaylistXmlSaver> logger) + : base(fileSystem, configurationManager, libraryManager, logger) { } diff --git a/MediaBrowser.MediaEncoding/Attachments/AttachmentExtractor.cs b/MediaBrowser.MediaEncoding/Attachments/AttachmentExtractor.cs index 21b5d0c5be..a0ec3bd90d 100644 --- a/MediaBrowser.MediaEncoding/Attachments/AttachmentExtractor.cs +++ b/MediaBrowser.MediaEncoding/Attachments/AttachmentExtractor.cs @@ -1,3 +1,4 @@ +#nullable disable #pragma warning disable CS1591 using System; @@ -87,19 +88,19 @@ namespace MediaBrowser.MediaEncoding.Attachments MediaAttachment mediaAttachment, CancellationToken cancellationToken) { - var attachmentPath = await GetReadableFile(mediaSource.Path, mediaSource.Path, mediaSource.Protocol, mediaAttachment, cancellationToken).ConfigureAwait(false); + var attachmentPath = await GetReadableFile(mediaSource.Path, mediaSource.Path, mediaSource, mediaAttachment, cancellationToken).ConfigureAwait(false); return File.OpenRead(attachmentPath); } private async Task<string> GetReadableFile( string mediaPath, string inputFile, - MediaProtocol protocol, + MediaSourceInfo mediaSource, MediaAttachment mediaAttachment, CancellationToken cancellationToken) { - var outputPath = GetAttachmentCachePath(mediaPath, protocol, mediaAttachment.Index); - await ExtractAttachment(inputFile, protocol, mediaAttachment.Index, outputPath, cancellationToken) + var outputPath = GetAttachmentCachePath(mediaPath, mediaSource, mediaAttachment.Index); + await ExtractAttachment(inputFile, mediaSource, mediaAttachment.Index, outputPath, cancellationToken) .ConfigureAwait(false); return outputPath; @@ -107,7 +108,7 @@ namespace MediaBrowser.MediaEncoding.Attachments private async Task ExtractAttachment( string inputFile, - MediaProtocol protocol, + MediaSourceInfo mediaSource, int attachmentStreamIndex, string outputPath, CancellationToken cancellationToken) @@ -121,7 +122,7 @@ namespace MediaBrowser.MediaEncoding.Attachments if (!File.Exists(outputPath)) { await ExtractAttachmentInternal( - _mediaEncoder.GetInputArgument(new[] { inputFile }, protocol), + _mediaEncoder.GetInputArgument(inputFile, mediaSource), attachmentStreamIndex, outputPath, cancellationToken).ConfigureAwait(false); @@ -178,7 +179,7 @@ namespace MediaBrowser.MediaEncoding.Attachments process.Start(); - var ranToCompletion = await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false); + var ranToCompletion = await ProcessExtensions.WaitForExitAsync(process, cancellationToken).ConfigureAwait(false); if (!ranToCompletion) { @@ -234,10 +235,10 @@ namespace MediaBrowser.MediaEncoding.Attachments } } - private string GetAttachmentCachePath(string mediaPath, MediaProtocol protocol, int attachmentStreamIndex) + private string GetAttachmentCachePath(string mediaPath, MediaSourceInfo mediaSource, int attachmentStreamIndex) { string filename; - if (protocol == MediaProtocol.File) + if (mediaSource.Protocol == MediaProtocol.File) { var date = _fileSystem.GetLastWriteTimeUtc(mediaPath); filename = (mediaPath + attachmentStreamIndex.ToString(CultureInfo.InvariantCulture) + "_" + date.Ticks.ToString(CultureInfo.InvariantCulture)).GetMD5().ToString("D", CultureInfo.InvariantCulture); diff --git a/MediaBrowser.MediaEncoding/BdInfo/BdInfoDirectoryInfo.cs b/MediaBrowser.MediaEncoding/BdInfo/BdInfoDirectoryInfo.cs index 4a54b677dd..e86e518be6 100644 --- a/MediaBrowser.MediaEncoding/BdInfo/BdInfoDirectoryInfo.cs +++ b/MediaBrowser.MediaEncoding/BdInfo/BdInfoDirectoryInfo.cs @@ -9,9 +9,9 @@ namespace MediaBrowser.MediaEncoding.BdInfo { public class BdInfoDirectoryInfo : IDirectoryInfo { - private readonly IFileSystem _fileSystem = null; + private readonly IFileSystem _fileSystem; - private readonly FileSystemMetadata _impl = null; + private readonly FileSystemMetadata _impl; public BdInfoDirectoryInfo(IFileSystem fileSystem, string path) { @@ -29,7 +29,7 @@ namespace MediaBrowser.MediaEncoding.BdInfo public string FullName => _impl.FullName; - public IDirectoryInfo Parent + public IDirectoryInfo? Parent { get { @@ -71,7 +71,7 @@ namespace MediaBrowser.MediaEncoding.BdInfo _impl.FullName, new[] { searchPattern }, false, - searchOption.HasFlag(System.IO.SearchOption.AllDirectories)).ToArray(), + (searchOption & System.IO.SearchOption.AllDirectories) == System.IO.SearchOption.AllDirectories).ToArray(), x => new BdInfoFileInfo(x)); } diff --git a/MediaBrowser.MediaEncoding/BdInfo/BdInfoExaminer.cs b/MediaBrowser.MediaEncoding/BdInfo/BdInfoExaminer.cs index 9108d96497..6ebaa4fff0 100644 --- a/MediaBrowser.MediaEncoding/BdInfo/BdInfoExaminer.cs +++ b/MediaBrowser.MediaEncoding/BdInfo/BdInfoExaminer.cs @@ -61,33 +61,25 @@ namespace MediaBrowser.MediaEncoding.BdInfo foreach (var stream in playlist.SortedStreams) { - var videoStream = stream as TSVideoStream; - - if (videoStream != null) + if (stream is TSVideoStream videoStream) { AddVideoStream(mediaStreams, videoStream); continue; } - var audioStream = stream as TSAudioStream; - - if (audioStream != null) + if (stream is TSAudioStream audioStream) { AddAudioStream(mediaStreams, audioStream); continue; } - var textStream = stream as TSTextStream; - - if (textStream != null) + if (stream is TSTextStream textStream) { AddSubtitleStream(mediaStreams, textStream); continue; } - var graphicsStream = stream as TSGraphicsStream; - - if (graphicsStream != null) + if (stream is TSGraphicsStream graphicsStream) { AddSubtitleStream(mediaStreams, graphicsStream); } diff --git a/MediaBrowser.MediaEncoding/BdInfo/BdInfoFileInfo.cs b/MediaBrowser.MediaEncoding/BdInfo/BdInfoFileInfo.cs index 0a8af8e9c8..41143c2593 100644 --- a/MediaBrowser.MediaEncoding/BdInfo/BdInfoFileInfo.cs +++ b/MediaBrowser.MediaEncoding/BdInfo/BdInfoFileInfo.cs @@ -7,7 +7,7 @@ namespace MediaBrowser.MediaEncoding.BdInfo { public class BdInfoFileInfo : BDInfo.IO.IFileInfo { - private FileSystemMetadata _impl = null; + private FileSystemMetadata _impl; public BdInfoFileInfo(FileSystemMetadata impl) { diff --git a/MediaBrowser.MediaEncoding/Encoder/EncoderValidator.cs b/MediaBrowser.MediaEncoding/Encoder/EncoderValidator.cs index 3287f9814e..f782e65bd1 100644 --- a/MediaBrowser.MediaEncoding/Encoder/EncoderValidator.cs +++ b/MediaBrowser.MediaEncoding/Encoder/EncoderValidator.cs @@ -25,6 +25,7 @@ namespace MediaBrowser.MediaEncoding.Encoder "ac3", "aac", "mp3", + "flac", "h264_qsv", "hevc_qsv", "mpeg2_qsv", @@ -71,6 +72,7 @@ namespace MediaBrowser.MediaEncoding.Encoder "libmp3lame", "libopus", "libvorbis", + "flac", "srt", "h264_amf", "hevc_amf", @@ -119,11 +121,11 @@ namespace MediaBrowser.MediaEncoding.Encoder // When changing this, also change the minimum library versions in _ffmpegMinimumLibraryVersions public static Version MinVersion { get; } = new Version(4, 0); - public static Version MaxVersion { get; } = null; + public static Version? MaxVersion { get; } = null; public bool ValidateVersion() { - string output = null; + string output; try { output = GetProcessOutput(_encoderPath, "-version"); @@ -131,6 +133,7 @@ namespace MediaBrowser.MediaEncoding.Encoder catch (Exception ex) { _logger.LogError(ex, "Error validating encoder"); + return false; } if (string.IsNullOrWhiteSpace(output)) @@ -205,7 +208,7 @@ namespace MediaBrowser.MediaEncoding.Encoder /// </summary> /// <param name="output">The output from "ffmpeg -version".</param> /// <returns>The FFmpeg version.</returns> - internal Version GetFFmpegVersion(string output) + internal Version? GetFFmpegVersion(string output) { // For pre-built binaries the FFmpeg version should be mentioned at the very start of the output var match = Regex.Match(output, @"^ffmpeg version n?((?:[0-9]+\.?)+)"); @@ -273,7 +276,7 @@ namespace MediaBrowser.MediaEncoding.Encoder private IEnumerable<string> GetHwaccelTypes() { - string output = null; + string? output = null; try { output = GetProcessOutput(_encoderPath, "-hwaccels"); @@ -294,10 +297,43 @@ namespace MediaBrowser.MediaEncoding.Encoder return found; } + public bool CheckFilter(string filter, string option) + { + if (string.IsNullOrEmpty(filter)) + { + return false; + } + + string output; + try + { + output = GetProcessOutput(_encoderPath, "-h filter=" + filter); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error detecting the given filter"); + return false; + } + + if (output.Contains("Filter " + filter, StringComparison.Ordinal)) + { + if (string.IsNullOrEmpty(option)) + { + return true; + } + + return output.Contains(option, StringComparison.Ordinal); + } + + _logger.LogWarning("Filter: {Name} with option {Option} is not available", filter, option); + + return false; + } + private IEnumerable<string> GetCodecs(Codec codec) { string codecstr = codec == Codec.Encoder ? "encoders" : "decoders"; - string output = null; + string output; try { output = GetProcessOutput(_encoderPath, "-" + codecstr); @@ -305,6 +341,7 @@ namespace MediaBrowser.MediaEncoding.Encoder catch (Exception ex) { _logger.LogError(ex, "Error detecting available {Codec}", codecstr); + return Enumerable.Empty<string>(); } if (string.IsNullOrWhiteSpace(output)) diff --git a/MediaBrowser.MediaEncoding/Encoder/EncodingUtils.cs b/MediaBrowser.MediaEncoding/Encoder/EncodingUtils.cs index 63310fdf6b..d0ea0429b5 100644 --- a/MediaBrowser.MediaEncoding/Encoder/EncodingUtils.cs +++ b/MediaBrowser.MediaEncoding/Encoder/EncodingUtils.cs @@ -1,53 +1,44 @@ #pragma warning disable CS1591 using System; -using System.Collections.Generic; using System.Globalization; -using System.Linq; using MediaBrowser.Model.MediaInfo; namespace MediaBrowser.MediaEncoding.Encoder { public static class EncodingUtils { - public static string GetInputArgument(IReadOnlyList<string> inputFiles, MediaProtocol protocol) + public static string GetInputArgument(string inputPrefix, string inputFile, MediaProtocol protocol) { if (protocol != MediaProtocol.File) { - var url = inputFiles[0]; - - return string.Format(CultureInfo.InvariantCulture, "\"{0}\"", url); + return string.Format(CultureInfo.InvariantCulture, "\"{0}\"", inputFile); } - return GetConcatInputArgument(inputFiles); + return GetConcatInputArgument(inputFile, inputPrefix); } /// <summary> /// Gets the concat input argument. /// </summary> - /// <param name="inputFiles">The input files.</param> + /// <param name="inputFile">The input file.</param> + /// <param name="inputPrefix">The input prefix.</param> /// <returns>System.String.</returns> - private static string GetConcatInputArgument(IReadOnlyList<string> inputFiles) + private static string GetConcatInputArgument(string inputFile, string inputPrefix) { // Get all streams // If there's more than one we'll need to use the concat command - if (inputFiles.Count > 1) - { - var files = string.Join("|", inputFiles.Select(NormalizePath)); - - return string.Format(CultureInfo.InvariantCulture, "concat:\"{0}\"", files); - } - // Determine the input path for video files - return GetFileInputArgument(inputFiles[0]); + return GetFileInputArgument(inputFile, inputPrefix); } /// <summary> /// Gets the file input argument. /// </summary> /// <param name="path">The path.</param> + /// <param name="inputPrefix">The path prefix.</param> /// <returns>System.String.</returns> - private static string GetFileInputArgument(string path) + private static string GetFileInputArgument(string path, string inputPrefix) { if (path.IndexOf("://", StringComparison.Ordinal) != -1) { @@ -57,7 +48,7 @@ namespace MediaBrowser.MediaEncoding.Encoder // Quotes are valid path characters in linux and they need to be escaped here with a leading \ path = NormalizePath(path); - return string.Format(CultureInfo.InvariantCulture, "file:\"{0}\"", path); + return string.Format(CultureInfo.InvariantCulture, "{1}:\"{0}\"", path, inputPrefix); } /// <summary> diff --git a/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs b/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs index 5a3a9185d7..412a953216 100644 --- a/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs +++ b/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs @@ -1,3 +1,4 @@ +#nullable disable #pragma warning disable CS1591 using System; @@ -10,14 +11,14 @@ using System.Text.Json; using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; +using Jellyfin.Extensions.Json; using MediaBrowser.Common.Configuration; using MediaBrowser.Common.Extensions; -using MediaBrowser.Common.Json; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.MediaEncoding; using MediaBrowser.MediaEncoding.Probing; -using MediaBrowser.Model.Configuration; using MediaBrowser.Model.Dlna; +using MediaBrowser.Model.Dto; using MediaBrowser.Model.Entities; using MediaBrowser.Model.Globalization; using MediaBrowser.Model.IO; @@ -34,9 +35,14 @@ namespace MediaBrowser.MediaEncoding.Encoder public class MediaEncoder : IMediaEncoder, IDisposable { /// <summary> - /// The default image extraction timeout in milliseconds. + /// The default SDR image extraction timeout in milliseconds. /// </summary> - internal const int DefaultImageExtractionTimeout = 5000; + internal const int DefaultSdrImageExtractionTimeout = 10000; + + /// <summary> + /// The default HDR image extraction timeout in milliseconds. + /// </summary> + internal const int DefaultHdrImageExtractionTimeout = 20000; /// <summary> /// The us culture. @@ -47,7 +53,6 @@ namespace MediaBrowser.MediaEncoding.Encoder private readonly IServerConfigurationManager _configurationManager; private readonly IFileSystem _fileSystem; private readonly ILocalizationManager _localization; - private readonly Lazy<EncodingHelper> _encodingHelperFactory; private readonly string _startupOptionFFmpegPath; private readonly SemaphoreSlim _thumbnailResourcePool = new SemaphoreSlim(2, 2); @@ -64,26 +69,23 @@ namespace MediaBrowser.MediaEncoding.Encoder private string _ffmpegPath = string.Empty; private string _ffprobePath; + private int threads; public MediaEncoder( ILogger<MediaEncoder> logger, IServerConfigurationManager configurationManager, IFileSystem fileSystem, ILocalizationManager localization, - Lazy<EncodingHelper> encodingHelperFactory, IConfiguration config) { _logger = logger; _configurationManager = configurationManager; _fileSystem = fileSystem; _localization = localization; - _encodingHelperFactory = encodingHelperFactory; _startupOptionFFmpegPath = config.GetValue<string>(Controller.Extensions.ConfigurationExtensions.FfmpegPathKey) ?? string.Empty; - _jsonSerializerOptions = JsonDefaults.GetOptions(); + _jsonSerializerOptions = JsonDefaults.Options; } - private EncodingHelper EncodingHelper => _encodingHelperFactory.Value; - /// <inheritdoc /> public string EncoderPath => _ffmpegPath; @@ -98,7 +100,7 @@ namespace MediaBrowser.MediaEncoding.Encoder public void SetFFmpegPath() { // 1) Custom path stored in config/encoding xml file under tag <EncoderAppPath> takes precedence - if (!ValidatePath(_configurationManager.GetConfiguration<EncodingOptions>("encoding").EncoderAppPath, FFmpegLocation.Custom)) + if (!ValidatePath(_configurationManager.GetEncodingOptions().EncoderAppPath, FFmpegLocation.Custom)) { // 2) Check if the --ffmpeg CLI switch has been given if (!ValidatePath(_startupOptionFFmpegPath, FFmpegLocation.SetByArgument)) @@ -113,7 +115,7 @@ namespace MediaBrowser.MediaEncoding.Encoder } // Write the FFmpeg path to the config/encoding.xml file as <EncoderAppPathDisplay> so it appears in UI - var config = _configurationManager.GetConfiguration<EncodingOptions>("encoding"); + var config = _configurationManager.GetEncodingOptions(); config.EncoderAppPathDisplay = _ffmpegPath ?? string.Empty; _configurationManager.SaveConfiguration("encoding", config); @@ -129,6 +131,7 @@ namespace MediaBrowser.MediaEncoding.Encoder SetAvailableDecoders(validator.GetDecoders()); SetAvailableEncoders(validator.GetEncoders()); SetAvailableHwaccels(validator.GetHwaccels()); + threads = EncodingHelper.GetNumberOfThreads(null, _configurationManager.GetEncodingOptions(), null); } _logger.LogInformation("FFmpeg: {EncoderLocation}: {FfmpegPath}", EncoderLocation, _ffmpegPath ?? string.Empty); @@ -171,7 +174,7 @@ namespace MediaBrowser.MediaEncoding.Encoder // Write the new ffmpeg path to the xml as <EncoderAppPath> // This ensures its not lost on next startup - var config = _configurationManager.GetConfiguration<EncodingOptions>("encoding"); + var config = _configurationManager.GetEncodingOptions(); config.EncoderAppPath = newPath; _configurationManager.SaveConfiguration("encoding", config); @@ -203,6 +206,7 @@ namespace MediaBrowser.MediaEncoding.Encoder _ffmpegPath = path; EncoderLocation = location; + return true; } else { @@ -289,6 +293,17 @@ namespace MediaBrowser.MediaEncoding.Encoder return _hwaccels.Contains(hwaccel, StringComparer.OrdinalIgnoreCase); } + public bool SupportsFilter(string filter, string option) + { + if (_ffmpegPath != null) + { + var validator = new EncoderValidator(_logger, _ffmpegPath); + return validator.CheckFilter(filter, option); + } + + return false; + } + public bool CanEncodeToAudioCodec(string codec) { if (string.Equals(codec, "opus", StringComparison.OrdinalIgnoreCase)) @@ -318,33 +333,24 @@ namespace MediaBrowser.MediaEncoding.Encoder public Task<MediaInfo> GetMediaInfo(MediaInfoRequest request, CancellationToken cancellationToken) { var extractChapters = request.MediaType == DlnaProfileType.Video && request.ExtractChapters; + var inputFile = request.MediaSource.Path; - var inputFiles = MediaEncoderHelpers.GetInputArgument(_fileSystem, request.MediaSource.Path, request.MountedIso, request.PlayableStreamFileNames); - - var probeSize = EncodingHelper.GetProbeSizeArgument(inputFiles.Length); - string analyzeDuration; + string analyzeDuration = string.Empty; if (request.MediaSource.AnalyzeDurationMs > 0) { analyzeDuration = "-analyzeduration " + (request.MediaSource.AnalyzeDurationMs * 1000).ToString(); } - else - { - analyzeDuration = EncodingHelper.GetAnalyzeDurationArgument(inputFiles.Length); - } - - probeSize = probeSize + " " + analyzeDuration; - probeSize = probeSize.Trim(); var forceEnableLogging = request.MediaSource.Protocol != MediaProtocol.File; return GetMediaInfoInternal( - GetInputArgument(inputFiles, request.MediaSource.Protocol), + GetInputArgument(inputFile, request.MediaSource), request.MediaSource.Path, request.MediaSource.Protocol, extractChapters, - probeSize, + analyzeDuration, request.MediaType == DlnaProfileType.Audio, request.MediaSource.VideoType, forceEnableLogging, @@ -354,12 +360,21 @@ namespace MediaBrowser.MediaEncoding.Encoder /// <summary> /// Gets the input argument. /// </summary> - /// <param name="inputFiles">The input files.</param> - /// <param name="protocol">The protocol.</param> + /// <param name="inputFile">The input file.</param> + /// <param name="mediaSource">The mediaSource.</param> /// <returns>System.String.</returns> /// <exception cref="ArgumentException">Unrecognized InputType.</exception> - public string GetInputArgument(IReadOnlyList<string> inputFiles, MediaProtocol protocol) - => EncodingUtils.GetInputArgument(inputFiles, protocol); + public string GetInputArgument(string inputFile, MediaSourceInfo mediaSource) + { + var prefix = "file"; + if (mediaSource.VideoType == VideoType.BluRay + || mediaSource.IsoType == IsoType.BluRay) + { + prefix = "bluray"; + } + + return EncodingUtils.GetInputArgument(prefix, inputFile, mediaSource.Protocol); + } /// <summary> /// Gets the media info internal. @@ -377,9 +392,9 @@ namespace MediaBrowser.MediaEncoding.Encoder CancellationToken cancellationToken) { var args = extractChapters - ? "{0} -i {1} -threads 0 -v warning -print_format json -show_streams -show_chapters -show_format" - : "{0} -i {1} -threads 0 -v warning -print_format json -show_streams -show_format"; - args = string.Format(CultureInfo.InvariantCulture, args, probeSizeArgument, inputPath).Trim(); + ? "{0} -i {1} -threads {2} -v warning -print_format json -show_streams -show_chapters -show_format" + : "{0} -i {1} -threads {2} -v warning -print_format json -show_streams -show_format"; + args = string.Format(CultureInfo.InvariantCulture, args, probeSizeArgument, inputPath, threads).Trim(); var process = new Process { @@ -431,7 +446,7 @@ namespace MediaBrowser.MediaEncoding.Encoder if (result == null || (result.Streams == null && result.Format == null)) { - throw new Exception("ffprobe failed - streams and format are both null."); + throw new FfmpegException("ffprobe failed - streams and format are both null."); } if (result.Streams != null) @@ -457,31 +472,36 @@ namespace MediaBrowser.MediaEncoding.Encoder public Task<string> ExtractAudioImage(string path, int? imageStreamIndex, CancellationToken cancellationToken) { - return ExtractImage(new[] { path }, null, null, imageStreamIndex, MediaProtocol.File, true, null, null, cancellationToken); + var mediaSource = new MediaSourceInfo + { + Protocol = MediaProtocol.File + }; + + return ExtractImage(path, null, null, imageStreamIndex, mediaSource, true, null, null, cancellationToken); } - public Task<string> ExtractVideoImage(string[] inputFiles, string container, MediaProtocol protocol, MediaStream videoStream, Video3DFormat? threedFormat, TimeSpan? offset, CancellationToken cancellationToken) + public Task<string> ExtractVideoImage(string inputFile, string container, MediaSourceInfo mediaSource, MediaStream videoStream, Video3DFormat? threedFormat, TimeSpan? offset, CancellationToken cancellationToken) { - return ExtractImage(inputFiles, container, videoStream, null, protocol, false, threedFormat, offset, cancellationToken); + return ExtractImage(inputFile, container, videoStream, null, mediaSource, false, threedFormat, offset, cancellationToken); } - public Task<string> ExtractVideoImage(string[] inputFiles, string container, MediaProtocol protocol, MediaStream imageStream, int? imageStreamIndex, CancellationToken cancellationToken) + public Task<string> ExtractVideoImage(string inputFile, string container, MediaSourceInfo mediaSource, MediaStream imageStream, int? imageStreamIndex, CancellationToken cancellationToken) { - return ExtractImage(inputFiles, container, imageStream, imageStreamIndex, protocol, false, null, null, cancellationToken); + return ExtractImage(inputFile, container, imageStream, imageStreamIndex, mediaSource, false, null, null, cancellationToken); } private async Task<string> ExtractImage( - string[] inputFiles, + string inputFile, string container, MediaStream videoStream, int? imageStreamIndex, - MediaProtocol protocol, + MediaSourceInfo mediaSource, bool isAudio, Video3DFormat? threedFormat, TimeSpan? offset, CancellationToken cancellationToken) { - var inputArgument = GetInputArgument(inputFiles, protocol); + var inputArgument = GetInputArgument(inputFile, mediaSource); if (isAudio) { @@ -493,9 +513,36 @@ namespace MediaBrowser.MediaEncoding.Encoder } else { + // The failure of HDR extraction usually occurs when using custom ffmpeg that does not contain the zscale filter. + try + { + return await ExtractImageInternal(inputArgument, container, videoStream, imageStreamIndex, threedFormat, offset, true, true, cancellationToken).ConfigureAwait(false); + } + catch (ArgumentException) + { + throw; + } + catch (Exception ex) + { + _logger.LogError(ex, "I-frame or HDR image extraction failed, will attempt with I-frame extraction disabled. Input: {Arguments}", inputArgument); + } + + try + { + return await ExtractImageInternal(inputArgument, container, videoStream, imageStreamIndex, threedFormat, offset, false, true, cancellationToken).ConfigureAwait(false); + } + catch (ArgumentException) + { + throw; + } + catch (Exception ex) + { + _logger.LogError(ex, "HDR image extraction failed, will fallback to SDR image extraction. Input: {Arguments}", inputArgument); + } + try { - return await ExtractImageInternal(inputArgument, container, videoStream, imageStreamIndex, threedFormat, offset, true, cancellationToken).ConfigureAwait(false); + return await ExtractImageInternal(inputArgument, container, videoStream, imageStreamIndex, threedFormat, offset, true, false, cancellationToken).ConfigureAwait(false); } catch (ArgumentException) { @@ -507,10 +554,10 @@ namespace MediaBrowser.MediaEncoding.Encoder } } - return await ExtractImageInternal(inputArgument, container, videoStream, imageStreamIndex, threedFormat, offset, false, cancellationToken).ConfigureAwait(false); + return await ExtractImageInternal(inputArgument, container, videoStream, imageStreamIndex, threedFormat, offset, false, false, cancellationToken).ConfigureAwait(false); } - private async Task<string> ExtractImageInternal(string inputPath, string container, MediaStream videoStream, int? imageStreamIndex, Video3DFormat? threedFormat, TimeSpan? offset, bool useIFrame, CancellationToken cancellationToken) + private async Task<string> ExtractImageInternal(string inputPath, string container, MediaStream videoStream, int? imageStreamIndex, Video3DFormat? threedFormat, TimeSpan? offset, bool useIFrame, bool allowTonemap, CancellationToken cancellationToken) { if (string.IsNullOrEmpty(inputPath)) { @@ -520,73 +567,61 @@ namespace MediaBrowser.MediaEncoding.Encoder var tempExtractPath = Path.Combine(_configurationManager.ApplicationPaths.TempDirectory, Guid.NewGuid() + ".jpg"); Directory.CreateDirectory(Path.GetDirectoryName(tempExtractPath)); - // apply some filters to thumbnail extracted below (below) crop any black lines that we made and get the correct ar then scale to width 600. + // apply some filters to thumbnail extracted below (below) crop any black lines that we made and get the correct ar. // This filter chain may have adverse effects on recorded tv thumbnails if ar changes during presentation ex. commercials @ diff ar - var vf = "scale=600:trunc(600/dar/2)*2"; + var vf = threedFormat switch + { + // hsbs crop width in half,scale to correct size, set the display aspect,crop out any black bars we may have made. Work out the correct height based on the display aspect it will maintain the aspect where -1 in this case (3d) may not. + Video3DFormat.HalfSideBySide => "-vf crop=iw/2:ih:0:0,scale=(iw*2):ih,setdar=dar=a,crop=min(iw\\,ih*dar):min(ih\\,iw/dar):(iw-min(iw\\,iw*sar))/2:(ih - min (ih\\,ih/sar))/2,setsar=sar=1", + // fsbs crop width in half,set the display aspect,crop out any black bars we may have made + Video3DFormat.FullSideBySide => "-vf crop=iw/2:ih:0:0,setdar=dar=a,crop=min(iw\\,ih*dar):min(ih\\,iw/dar):(iw-min(iw\\,iw*sar))/2:(ih - min (ih\\,ih/sar))/2,setsar=sar=1", + // htab crop heigh in half,scale to correct size, set the display aspect,crop out any black bars we may have made + Video3DFormat.HalfTopAndBottom => "-vf crop=iw:ih/2:0:0,scale=(iw*2):ih),setdar=dar=a,crop=min(iw\\,ih*dar):min(ih\\,iw/dar):(iw-min(iw\\,iw*sar))/2:(ih - min (ih\\,ih/sar))/2,setsar=sar=1", + // ftab crop heigt in half, set the display aspect,crop out any black bars we may have made + Video3DFormat.FullTopAndBottom => "-vf crop=iw:ih/2:0:0,setdar=dar=a,crop=min(iw\\,ih*dar):min(ih\\,iw/dar):(iw-min(iw\\,iw*sar))/2:(ih - min (ih\\,ih/sar))/2,setsar=sar=1", + _ => string.Empty + }; + + var mapArg = imageStreamIndex.HasValue ? (" -map 0:v:" + imageStreamIndex.Value.ToString(CultureInfo.InvariantCulture)) : string.Empty; - if (threedFormat.HasValue) + var enableHdrExtraction = allowTonemap && string.Equals(videoStream?.VideoRange, "HDR", StringComparison.OrdinalIgnoreCase); + if (enableHdrExtraction) { - switch (threedFormat.Value) + string tonemapFilters = "zscale=t=linear:npl=100,format=gbrpf32le,zscale=p=bt709,tonemap=tonemap=hable:desat=0:peak=100,zscale=t=bt709:m=bt709,format=yuv420p"; + if (vf.Length == 0) + { + vf = "-vf " + tonemapFilters; + } + else { - case Video3DFormat.HalfSideBySide: - vf = "crop=iw/2:ih:0:0,scale=(iw*2):ih,setdar=dar=a,crop=min(iw\\,ih*dar):min(ih\\,iw/dar):(iw-min(iw\\,iw*sar))/2:(ih - min (ih\\,ih/sar))/2,setsar=sar=1,scale=600:trunc(600/dar/2)*2"; - // hsbs crop width in half,scale to correct size, set the display aspect,crop out any black bars we may have made the scale width to 600. Work out the correct height based on the display aspect it will maintain the aspect where -1 in this case (3d) may not. - break; - case Video3DFormat.FullSideBySide: - vf = "crop=iw/2:ih:0:0,setdar=dar=a,crop=min(iw\\,ih*dar):min(ih\\,iw/dar):(iw-min(iw\\,iw*sar))/2:(ih - min (ih\\,ih/sar))/2,setsar=sar=1,scale=600:trunc(600/dar/2)*2"; - // fsbs crop width in half,set the display aspect,crop out any black bars we may have made the scale width to 600. - break; - case Video3DFormat.HalfTopAndBottom: - vf = "crop=iw:ih/2:0:0,scale=(iw*2):ih),setdar=dar=a,crop=min(iw\\,ih*dar):min(ih\\,iw/dar):(iw-min(iw\\,iw*sar))/2:(ih - min (ih\\,ih/sar))/2,setsar=sar=1,scale=600:trunc(600/dar/2)*2"; - // htab crop heigh in half,scale to correct size, set the display aspect,crop out any black bars we may have made the scale width to 600 - break; - case Video3DFormat.FullTopAndBottom: - vf = "crop=iw:ih/2:0:0,setdar=dar=a,crop=min(iw\\,ih*dar):min(ih\\,iw/dar):(iw-min(iw\\,iw*sar))/2:(ih - min (ih\\,ih/sar))/2,setsar=sar=1,scale=600:trunc(600/dar/2)*2"; - // ftab crop heigt in half, set the display aspect,crop out any black bars we may have made the scale width to 600 - break; - default: - break; + vf += "," + tonemapFilters; } } - var mapArg = imageStreamIndex.HasValue ? (" -map 0:v:" + imageStreamIndex.Value.ToString(CultureInfo.InvariantCulture)) : string.Empty; - - var enableThumbnail = !new List<string> { "wtv" }.Contains(container ?? string.Empty, StringComparer.OrdinalIgnoreCase); // Use ffmpeg to sample 100 (we can drop this if required using thumbnail=50 for 50 frames) frames and pick the best thumbnail. Have a fall back just in case. - var thumbnail = enableThumbnail ? ",thumbnail=24" : string.Empty; - - var args = useIFrame ? string.Format(CultureInfo.InvariantCulture, "-i {0}{3} -threads 0 -v quiet -vframes 1 -vf \"{2}{4}\" -f image2 \"{1}\"", inputPath, tempExtractPath, vf, mapArg, thumbnail) : - string.Format(CultureInfo.InvariantCulture, "-i {0}{3} -threads 0 -v quiet -vframes 1 -vf \"{2}\" -f image2 \"{1}\"", inputPath, tempExtractPath, vf, mapArg); - - var probeSizeArgument = EncodingHelper.GetProbeSizeArgument(1); - var analyzeDurationArgument = EncodingHelper.GetAnalyzeDurationArgument(1); - - if (!string.IsNullOrWhiteSpace(probeSizeArgument)) + // mpegts need larger batch size otherwise the corrupted thumbnail will be created. Larger batch size will lower the processing speed. + var enableThumbnail = useIFrame && !string.Equals("wtv", container, StringComparison.OrdinalIgnoreCase); + if (enableThumbnail) { - args = probeSizeArgument + " " + args; + var useLargerBatchSize = string.Equals("mpegts", container, StringComparison.OrdinalIgnoreCase); + var batchSize = useLargerBatchSize ? "50" : "24"; + if (string.IsNullOrEmpty(vf)) + { + vf = "-vf thumbnail=" + batchSize; + } + else + { + vf += ",thumbnail=" + batchSize; + } } - if (!string.IsNullOrWhiteSpace(analyzeDurationArgument)) - { - args = analyzeDurationArgument + " " + args; - } + var args = string.Format(CultureInfo.InvariantCulture, "-i {0}{3} -threads {4} -v quiet -vframes 1 {2} -f image2 \"{1}\"", inputPath, tempExtractPath, vf, mapArg, threads); if (offset.HasValue) { args = string.Format(CultureInfo.InvariantCulture, "-ss {0} ", GetTimeParameter(offset.Value)) + args; } - if (videoStream != null) - { - /* fix - var decoder = encodinghelper.GetHardwareAcceleratedVideoDecoder(VideoType.VideoFile, videoStream, GetEncodingOptions()); - if (!string.IsNullOrWhiteSpace(decoder)) - { - args = decoder + " " + args; - } - */ - } - if (!string.IsNullOrWhiteSpace(container)) { var inputFormat = EncodingHelper.GetInputFormat(container); @@ -624,7 +659,7 @@ namespace MediaBrowser.MediaEncoding.Encoder var timeoutMs = _configurationManager.Configuration.ImageExtractionTimeoutMs; if (timeoutMs <= 0) { - timeoutMs = DefaultImageExtractionTimeout; + timeoutMs = enableHdrExtraction ? DefaultHdrImageExtractionTimeout : DefaultSdrImageExtractionTimeout; } ranToCompletion = await process.WaitForExitAsync(TimeSpan.FromMilliseconds(timeoutMs)).ConfigureAwait(false); @@ -648,7 +683,7 @@ namespace MediaBrowser.MediaEncoding.Encoder _logger.LogError(msg); - throw new Exception(msg); + throw new FfmpegException(msg); } return tempExtractPath; @@ -668,10 +703,10 @@ namespace MediaBrowser.MediaEncoding.Encoder } public async Task ExtractVideoImagesOnInterval( - string[] inputFiles, + string inputFile, string container, MediaStream videoStream, - MediaProtocol protocol, + MediaSourceInfo mediaSource, Video3DFormat? threedFormat, TimeSpan interval, string targetDirectory, @@ -679,7 +714,7 @@ namespace MediaBrowser.MediaEncoding.Encoder int? maxWidth, CancellationToken cancellationToken) { - var inputArgument = GetInputArgument(inputFiles, protocol); + var inputArgument = GetInputArgument(inputFile, mediaSource); var vf = "fps=fps=1/" + interval.TotalSeconds.ToString(_usCulture); @@ -693,31 +728,7 @@ namespace MediaBrowser.MediaEncoding.Encoder Directory.CreateDirectory(targetDirectory); var outputPath = Path.Combine(targetDirectory, filenamePrefix + "%05d.jpg"); - var args = string.Format(CultureInfo.InvariantCulture, "-i {0} -threads 0 -v quiet -vf \"{2}\" -f image2 \"{1}\"", inputArgument, outputPath, vf); - - var probeSizeArgument = EncodingHelper.GetProbeSizeArgument(1); - var analyzeDurationArgument = EncodingHelper.GetAnalyzeDurationArgument(1); - - if (!string.IsNullOrWhiteSpace(probeSizeArgument)) - { - args = probeSizeArgument + " " + args; - } - - if (!string.IsNullOrWhiteSpace(analyzeDurationArgument)) - { - args = analyzeDurationArgument + " " + args; - } - - if (videoStream != null) - { - /* fix - var decoder = encodinghelper.GetHardwareAcceleratedVideoDecoder(VideoType.VideoFile, videoStream, GetEncodingOptions()); - if (!string.IsNullOrWhiteSpace(decoder)) - { - args = decoder + " " + args; - } - */ - } + var args = string.Format(CultureInfo.InvariantCulture, "-i {0} -threads {3} -v quiet {2} -f image2 \"{1}\"", inputArgument, outputPath, vf, threads); if (!string.IsNullOrWhiteSpace(container)) { @@ -797,7 +808,7 @@ namespace MediaBrowser.MediaEncoding.Encoder _logger.LogError(msg); - throw new Exception(msg); + throw new FfmpegException(msg); } } } @@ -889,16 +900,14 @@ namespace MediaBrowser.MediaEncoding.Encoder } /// <inheritdoc /> - public IEnumerable<string> GetPrimaryPlaylistVobFiles(string path, IIsoMount isoMount, uint? titleNumber) + public IEnumerable<string> GetPrimaryPlaylistVobFiles(string path, uint? titleNumber) { // min size 300 mb const long MinPlayableSize = 314572800; - var root = isoMount != null ? isoMount.MountedPath : path; - // Try to eliminate menus and intros by skipping all files at the front of the list that are less than the minimum size // Once we reach a file that is at least the minimum, return all subsequent ones - var allVobs = _fileSystem.GetFiles(root, true) + var allVobs = _fileSystem.GetFiles(path, true) .Where(file => string.Equals(file.Extension, ".vob", StringComparison.OrdinalIgnoreCase)) .OrderBy(i => i.FullName) .ToList(); diff --git a/MediaBrowser.MediaEncoding/FfmpegException.cs b/MediaBrowser.MediaEncoding/FfmpegException.cs new file mode 100644 index 0000000000..1697fd33a1 --- /dev/null +++ b/MediaBrowser.MediaEncoding/FfmpegException.cs @@ -0,0 +1,39 @@ +using System; + +namespace MediaBrowser.MediaEncoding +{ + /// <summary> + /// Represents errors that occur during interaction with FFmpeg. + /// </summary> + public class FfmpegException : Exception + { + /// <summary> + /// Initializes a new instance of the <see cref="FfmpegException"/> class. + /// </summary> + public FfmpegException() + { + } + + /// <summary> + /// Initializes a new instance of the <see cref="FfmpegException"/> class with a specified error message. + /// </summary> + /// <param name="message">The message that describes the error.</param> + public FfmpegException(string message) : base(message) + { + } + + /// <summary> + /// Initializes a new instance of the <see cref="FfmpegException"/> class with a specified error message and a + /// reference to the inner exception that is the cause of this exception. + /// </summary> + /// <param name="message">The error message that explains the reason for the exception.</param> + /// <param name="innerException"> + /// The exception that is the cause of the current exception, or a null reference (Nothing in Visual Basic) if + /// no inner exception is specified. + /// </param> + public FfmpegException(string message, Exception innerException) + : base(message, innerException) + { + } + } +} diff --git a/MediaBrowser.MediaEncoding/MediaBrowser.MediaEncoding.csproj b/MediaBrowser.MediaEncoding/MediaBrowser.MediaEncoding.csproj index 6ead93e09b..6da9886a4b 100644 --- a/MediaBrowser.MediaEncoding/MediaBrowser.MediaEncoding.csproj +++ b/MediaBrowser.MediaEncoding/MediaBrowser.MediaEncoding.csproj @@ -6,10 +6,9 @@ </PropertyGroup> <PropertyGroup> - <TargetFramework>netstandard2.1</TargetFramework> + <TargetFramework>net5.0</TargetFramework> <GenerateAssemblyInfo>false</GenerateAssemblyInfo> <GenerateDocumentationFile>true</GenerateDocumentationFile> - <TreatWarningsAsErrors>true</TreatWarningsAsErrors> </PropertyGroup> <ItemGroup> @@ -24,18 +23,14 @@ <ItemGroup> <PackageReference Include="BDInfo" Version="0.7.6.1" /> - <PackageReference Include="Microsoft.Extensions.Http" Version="3.1.6" /> - <PackageReference Include="System.Text.Encoding.CodePages" Version="4.7.1" /> - <PackageReference Include="UTF.Unknown" Version="2.3.0" /> + <PackageReference Include="libse" Version="3.6.0" /> + <PackageReference Include="Microsoft.Extensions.Http" Version="5.0.0" /> + <PackageReference Include="System.Text.Encoding.CodePages" Version="5.0.0" /> + <PackageReference Include="UTF.Unknown" Version="2.4.0" /> </ItemGroup> - <PropertyGroup Condition=" '$(Configuration)' == 'Debug' "> - <CodeAnalysisRuleSet>../jellyfin.ruleset</CodeAnalysisRuleSet> - </PropertyGroup> - <!-- Code Analyzers--> <ItemGroup Condition=" '$(Configuration)' == 'Debug' "> - <PackageReference Include="Microsoft.CodeAnalysis.FxCopAnalyzers" Version="2.9.8" PrivateAssets="All" /> <PackageReference Include="SerilogAnalyzer" Version="0.15.0" PrivateAssets="All" /> <PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="All" /> <PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" PrivateAssets="All" /> diff --git a/MediaBrowser.MediaEncoding/MediaBrowser.MediaEncoding.nuget.targets b/MediaBrowser.MediaEncoding/MediaBrowser.MediaEncoding.nuget.targets deleted file mode 100644 index f793e09bcb..0000000000 --- a/MediaBrowser.MediaEncoding/MediaBrowser.MediaEncoding.nuget.targets +++ /dev/null @@ -1,6 +0,0 @@ -<?xml version="1.0" encoding="utf-8" standalone="no"?> -<Project ToolsVersion="14.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> - <Target Name="EmitMSBuildWarning" BeforeTargets="Build"> - <Warning Text="Packages containing MSBuild targets and props files cannot be fully installed in projects targeting multiple frameworks. The MSBuild targets and props files have been ignored." /> - </Target> -</Project> \ No newline at end of file diff --git a/MediaBrowser.MediaEncoding/Probing/FFProbeHelpers.cs b/MediaBrowser.MediaEncoding/Probing/FFProbeHelpers.cs index b2d4db894d..9196fe1397 100644 --- a/MediaBrowser.MediaEncoding/Probing/FFProbeHelpers.cs +++ b/MediaBrowser.MediaEncoding/Probing/FFProbeHelpers.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Globalization; namespace MediaBrowser.MediaEncoding.Probing { @@ -19,7 +20,7 @@ namespace MediaBrowser.MediaEncoding.Probing throw new ArgumentNullException(nameof(result)); } - if (result.Format != null && result.Format.Tags != null) + if (result.Format?.Tags != null) { result.Format.Tags = ConvertDictionaryToCaseInsensitive(result.Format.Tags); } @@ -37,39 +38,17 @@ namespace MediaBrowser.MediaEncoding.Probing } } - /// <summary> - /// Gets a string from an FFProbeResult tags dictionary. - /// </summary> - /// <param name="tags">The tags.</param> - /// <param name="key">The key.</param> - /// <returns>System.String.</returns> - public static string GetDictionaryValue(IReadOnlyDictionary<string, string> tags, string key) - { - if (tags == null) - { - return null; - } - - tags.TryGetValue(key, out var val); - return val; - } - /// <summary> /// Gets an int from an FFProbeResult tags dictionary. /// </summary> /// <param name="tags">The tags.</param> /// <param name="key">The key.</param> /// <returns>System.Nullable{System.Int32}.</returns> - public static int? GetDictionaryNumericValue(Dictionary<string, string> tags, string key) + public static int? GetDictionaryNumericValue(IReadOnlyDictionary<string, string> tags, string key) { - var val = GetDictionaryValue(tags, key); - - if (!string.IsNullOrEmpty(val)) + if (tags.TryGetValue(key, out var val) && int.TryParse(val, out var i)) { - if (int.TryParse(val, out var i)) - { - return i; - } + return i; } return null; @@ -81,16 +60,13 @@ namespace MediaBrowser.MediaEncoding.Probing /// <param name="tags">The tags.</param> /// <param name="key">The key.</param> /// <returns>System.Nullable{DateTime}.</returns> - public static DateTime? GetDictionaryDateTime(Dictionary<string, string> tags, string key) + public static DateTime? GetDictionaryDateTime(IReadOnlyDictionary<string, string> tags, string key) { - var val = GetDictionaryValue(tags, key); - - if (!string.IsNullOrEmpty(val)) + if (tags.TryGetValue(key, out var val) + && (DateTime.TryParse(val, DateTimeFormatInfo.CurrentInfo, DateTimeStyles.AssumeUniversal, out var dateTime) + || DateTime.TryParseExact(val, "yyyy", DateTimeFormatInfo.CurrentInfo, DateTimeStyles.AssumeUniversal, out dateTime))) { - if (DateTime.TryParse(val, out var i)) - { - return i.ToUniversalTime(); - } + return dateTime.ToUniversalTime(); } return null; diff --git a/MediaBrowser.MediaEncoding/Probing/InternalMediaInfoResult.cs b/MediaBrowser.MediaEncoding/Probing/InternalMediaInfoResult.cs index 0e319c1a8b..d4d153b083 100644 --- a/MediaBrowser.MediaEncoding/Probing/InternalMediaInfoResult.cs +++ b/MediaBrowser.MediaEncoding/Probing/InternalMediaInfoResult.cs @@ -1,3 +1,5 @@ +#nullable disable + using System.Collections.Generic; using System.Text.Json.Serialization; diff --git a/MediaBrowser.MediaEncoding/Probing/MediaChapter.cs b/MediaBrowser.MediaEncoding/Probing/MediaChapter.cs index de062d06b0..a1cef7a9f2 100644 --- a/MediaBrowser.MediaEncoding/Probing/MediaChapter.cs +++ b/MediaBrowser.MediaEncoding/Probing/MediaChapter.cs @@ -1,3 +1,4 @@ +#nullable disable #pragma warning disable CS1591 using System.Collections.Generic; diff --git a/MediaBrowser.MediaEncoding/Probing/MediaFormatInfo.cs b/MediaBrowser.MediaEncoding/Probing/MediaFormatInfo.cs index 8af122ef9b..d50da37b89 100644 --- a/MediaBrowser.MediaEncoding/Probing/MediaFormatInfo.cs +++ b/MediaBrowser.MediaEncoding/Probing/MediaFormatInfo.cs @@ -1,3 +1,5 @@ +#nullable disable + using System.Collections.Generic; using System.Text.Json.Serialization; diff --git a/MediaBrowser.MediaEncoding/Probing/MediaStreamInfo.cs b/MediaBrowser.MediaEncoding/Probing/MediaStreamInfo.cs index 8a7c032c54..c9c8c34c2e 100644 --- a/MediaBrowser.MediaEncoding/Probing/MediaStreamInfo.cs +++ b/MediaBrowser.MediaEncoding/Probing/MediaStreamInfo.cs @@ -1,6 +1,7 @@ +#nullable disable + using System.Collections.Generic; using System.Text.Json.Serialization; -using MediaBrowser.Common.Json.Converters; namespace MediaBrowser.MediaEncoding.Probing { diff --git a/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs b/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs index 22537a4d95..875ee6f04c 100644 --- a/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs +++ b/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs @@ -1,3 +1,4 @@ +#nullable disable #pragma warning disable CS1591 using System; @@ -7,6 +8,7 @@ using System.IO; using System.Linq; using System.Text; using System.Xml; +using Jellyfin.Extensions; using MediaBrowser.Controller.Library; using MediaBrowser.Model.Dto; using MediaBrowser.Model.Entities; @@ -29,7 +31,7 @@ namespace MediaBrowser.MediaEncoding.Probing private readonly ILogger _logger; private readonly ILocalizationManager _localization; - private List<string> _splitWhiteList = null; + private string[] _splitWhiteList; public ProbeResultNormalizer(ILogger logger, ILocalizationManager localization) { @@ -37,6 +39,17 @@ namespace MediaBrowser.MediaEncoding.Probing _localization = localization; } + private IReadOnlyList<string> SplitWhitelist => _splitWhiteList ??= new string[] + { + "AC/DC", + "Au/Ra", + "이달의 소녀 1/3", + "LOONA 1/3", + "LOONA / yyxy", + "LOONA / ODD EYE CIRCLE", + "K/DA" + }; + public MediaInfo GetMediaInfo(InternalMediaInfoResult data, VideoType? videoType, bool isAudio, string path, MediaProtocol protocol) { var info = new MediaInfo @@ -57,7 +70,7 @@ namespace MediaBrowser.MediaEncoding.Probing .Where(i => i.Type != MediaStreamType.Subtitle || !string.IsNullOrWhiteSpace(i.Codec)) .ToList(); - info.MediaAttachments = internalStreams.Select(s => GetMediaAttachment(s)) + info.MediaAttachments = internalStreams.Select(GetMediaAttachment) .Where(i => i != null) .ToList(); @@ -77,62 +90,66 @@ namespace MediaBrowser.MediaEncoding.Probing var tags = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase); var tagStreamType = isAudio ? "audio" : "video"; - if (data.Streams != null) - { - var tagStream = data.Streams.FirstOrDefault(i => string.Equals(i.CodecType, tagStreamType, StringComparison.OrdinalIgnoreCase)); + var tagStream = data.Streams?.FirstOrDefault(i => string.Equals(i.CodecType, tagStreamType, StringComparison.OrdinalIgnoreCase)); - if (tagStream != null && tagStream.Tags != null) + if (tagStream?.Tags != null) + { + foreach (var (key, value) in tagStream.Tags) { - foreach (var pair in tagStream.Tags) - { - tags[pair.Key] = pair.Value; - } + tags[key] = value; } } - if (data.Format != null && data.Format.Tags != null) + if (data.Format?.Tags != null) { - foreach (var pair in data.Format.Tags) + foreach (var (key, value) in data.Format.Tags) { - tags[pair.Key] = pair.Value; + tags[key] = value; } } FetchGenres(info, tags); - var overview = FFProbeHelpers.GetDictionaryValue(tags, "synopsis"); - - if (string.IsNullOrWhiteSpace(overview)) - { - overview = FFProbeHelpers.GetDictionaryValue(tags, "description"); - } - - if (string.IsNullOrWhiteSpace(overview)) - { - overview = FFProbeHelpers.GetDictionaryValue(tags, "desc"); - } - - if (!string.IsNullOrWhiteSpace(overview)) - { - info.Overview = overview; - } - var title = FFProbeHelpers.GetDictionaryValue(tags, "title"); - if (!string.IsNullOrWhiteSpace(title)) - { - info.Name = title; - } + info.Name = tags.GetFirstNotNullNorWhiteSpaceValue("title", "title-eng"); + info.ForcedSortName = tags.GetFirstNotNullNorWhiteSpaceValue("sort_name", "title-sort", "titlesort"); + info.Overview = tags.GetFirstNotNullNorWhiteSpaceValue("synopsis", "description", "desc"); info.IndexNumber = FFProbeHelpers.GetDictionaryNumericValue(tags, "episode_sort"); info.ParentIndexNumber = FFProbeHelpers.GetDictionaryNumericValue(tags, "season_number"); - info.ShowName = FFProbeHelpers.GetDictionaryValue(tags, "show_name"); + info.ShowName = tags.GetValueOrDefault("show_name"); info.ProductionYear = FFProbeHelpers.GetDictionaryNumericValue(tags, "date"); - // Several different forms of retaildate - info.PremiereDate = FFProbeHelpers.GetDictionaryDateTime(tags, "retaildate") ?? + // Several different forms of retail/premiere date + info.PremiereDate = + FFProbeHelpers.GetDictionaryDateTime(tags, "originaldate") ?? + FFProbeHelpers.GetDictionaryDateTime(tags, "retaildate") ?? FFProbeHelpers.GetDictionaryDateTime(tags, "retail date") ?? FFProbeHelpers.GetDictionaryDateTime(tags, "retail_date") ?? + FFProbeHelpers.GetDictionaryDateTime(tags, "date_released") ?? FFProbeHelpers.GetDictionaryDateTime(tags, "date"); + // Set common metadata for music (audio) and music videos (video) + info.Album = tags.GetValueOrDefault("album"); + + if (tags.TryGetValue("artists", out var artists) && !string.IsNullOrWhiteSpace(artists)) + { + info.Artists = SplitDistinctArtists(artists, new[] { '/', ';' }, false).ToArray(); + } + else + { + var artist = tags.GetFirstNotNullNorWhiteSpaceValue("artist"); + info.Artists = artist == null + ? Array.Empty<string>() + : SplitDistinctArtists(artist, _nameDelimiters, true).ToArray(); + } + + // Guess ProductionYear from PremiereDate if missing + if (!info.ProductionYear.HasValue && info.PremiereDate.HasValue) + { + info.ProductionYear = info.PremiereDate.Value.Year; + } + + // Set mediaType-specific metadata if (isAudio) { SetAudioRuntimeTicks(data, info); @@ -146,10 +163,10 @@ namespace MediaBrowser.MediaEncoding.Probing { FetchStudios(info, tags, "copyright"); - var iTunEXTC = FFProbeHelpers.GetDictionaryValue(tags, "iTunEXTC"); - if (!string.IsNullOrWhiteSpace(iTunEXTC)) + var iTunExtc = tags.GetFirstNotNullNorWhiteSpaceValue("iTunEXTC"); + if (iTunExtc != null) { - var parts = iTunEXTC.Split(new[] { '|' }, StringSplitOptions.RemoveEmptyEntries); + var parts = iTunExtc.Split('|', StringSplitOptions.RemoveEmptyEntries); // Example // mpaa|G|100|For crude humor if (parts.Length > 1) @@ -163,10 +180,10 @@ namespace MediaBrowser.MediaEncoding.Probing } } - var itunesXml = FFProbeHelpers.GetDictionaryValue(tags, "iTunMOVI"); - if (!string.IsNullOrWhiteSpace(itunesXml)) + var iTunXml = tags.GetFirstNotNullNorWhiteSpaceValue("iTunMOVI"); + if (iTunXml != null) { - FetchFromItunesInfo(itunesXml, info); + FetchFromItunesInfo(iTunXml, info); } if (data.Format != null && !string.IsNullOrEmpty(data.Format.Duration)) @@ -183,8 +200,7 @@ namespace MediaBrowser.MediaEncoding.Probing ExtractTimestamp(info); - var stereoMode = GetDictionaryValue(tags, "stereo_mode"); - if (string.Equals(stereoMode, "left_right", StringComparison.OrdinalIgnoreCase)) + if (tags.TryGetValue("stereo_mode", out var stereoMode) && string.Equals(stereoMode, "left_right", StringComparison.OrdinalIgnoreCase)) { info.Video3DFormat = Video3DFormat.FullSideBySide; } @@ -234,17 +250,39 @@ namespace MediaBrowser.MediaEncoding.Probing var channelsValue = channels.Value; - if (string.Equals(codec, "aac", StringComparison.OrdinalIgnoreCase) || - string.Equals(codec, "mp3", StringComparison.OrdinalIgnoreCase)) + if (string.Equals(codec, "aac", StringComparison.OrdinalIgnoreCase) + || string.Equals(codec, "mp3", StringComparison.OrdinalIgnoreCase)) + { + switch (channelsValue) + { + case <= 2: + return 192000; + case >= 5: + return 320000; + } + } + + if (string.Equals(codec, "ac3", StringComparison.OrdinalIgnoreCase) + || string.Equals(codec, "eac3", StringComparison.OrdinalIgnoreCase)) { - if (channelsValue <= 2) + switch (channelsValue) { - return 192000; + case <= 2: + return 192000; + case >= 5: + return 640000; } + } - if (channelsValue >= 5) + if (string.Equals(codec, "flac", StringComparison.OrdinalIgnoreCase) + || string.Equals(codec, "alac", StringComparison.OrdinalIgnoreCase)) + { + switch (channelsValue) { - return 320000; + case <= 2: + return 960000; + case >= 5: + return 2880000; } } @@ -612,7 +650,7 @@ namespace MediaBrowser.MediaEncoding.Probing } // Filter out junk - if (!string.IsNullOrWhiteSpace(streamInfo.CodecTagString) && streamInfo.CodecTagString.IndexOf("[0]", StringComparison.OrdinalIgnoreCase) == -1) + if (!string.IsNullOrWhiteSpace(streamInfo.CodecTagString) && !streamInfo.CodecTagString.Contains("[0]", StringComparison.OrdinalIgnoreCase)) { stream.CodecTag = streamInfo.CodecTagString; } @@ -653,9 +691,9 @@ namespace MediaBrowser.MediaEncoding.Probing { stream.Type = MediaStreamType.Subtitle; stream.Codec = NormalizeSubtitleCodec(stream.Codec); - stream.localizedUndefined = _localization.GetLocalizedString("Undefined"); - stream.localizedDefault = _localization.GetLocalizedString("Default"); - stream.localizedForced = _localization.GetLocalizedString("Forced"); + stream.LocalizedUndefined = _localization.GetLocalizedString("Undefined"); + stream.LocalizedDefault = _localization.GetLocalizedString("Default"); + stream.LocalizedForced = _localization.GetLocalizedString("Forced"); } else if (string.Equals(streamInfo.CodecType, "video", StringComparison.OrdinalIgnoreCase)) { @@ -750,7 +788,11 @@ namespace MediaBrowser.MediaEncoding.Probing } } - if (bitrate == 0 && formatInfo != null && !string.IsNullOrEmpty(formatInfo.BitRate) && stream.Type == MediaStreamType.Video) + // The bitrate info of FLAC musics and some videos is included in formatInfo. + if (bitrate == 0 + && formatInfo != null + && !string.IsNullOrEmpty(formatInfo.BitRate) + && (stream.Type == MediaStreamType.Video || (isAudio && stream.Type == MediaStreamType.Audio))) { // If the stream info doesn't have a bitrate get the value from the media format info if (int.TryParse(formatInfo.BitRate, NumberStyles.Any, _usCulture, out var value)) @@ -764,6 +806,35 @@ namespace MediaBrowser.MediaEncoding.Probing stream.BitRate = bitrate; } + // Extract bitrate info from tag "BPS" if possible. + if (!stream.BitRate.HasValue + && (string.Equals(streamInfo.CodecType, "audio", StringComparison.OrdinalIgnoreCase) + || string.Equals(streamInfo.CodecType, "video", StringComparison.OrdinalIgnoreCase))) + { + var bps = GetBPSFromTags(streamInfo); + if (bps > 0) + { + stream.BitRate = bps; + } + } + + // Get average bitrate info from tag "NUMBER_OF_BYTES" and "DURATION" if possible. + if (!stream.BitRate.HasValue + && (string.Equals(streamInfo.CodecType, "audio", StringComparison.OrdinalIgnoreCase) + || string.Equals(streamInfo.CodecType, "video", StringComparison.OrdinalIgnoreCase))) + { + var durationInSeconds = GetRuntimeSecondsFromTags(streamInfo); + var bytes = GetNumberOfBytesFromTags(streamInfo); + if (durationInSeconds != null && bytes != null) + { + var bps = Convert.ToInt32(bytes * 8 / durationInSeconds, CultureInfo.InvariantCulture); + if (bps > 0) + { + stream.BitRate = bps; + } + } + } + var disposition = streamInfo.Disposition; if (disposition != null) { @@ -810,6 +881,7 @@ namespace MediaBrowser.MediaEncoding.Probing } tags.TryGetValue(key, out var val); + return val; } @@ -817,7 +889,7 @@ namespace MediaBrowser.MediaEncoding.Probing { if (string.IsNullOrEmpty(input)) { - return input; + return null; } return input.Split('(').FirstOrDefault(); @@ -905,174 +977,168 @@ namespace MediaBrowser.MediaEncoding.Probing /// <returns>System.Nullable{System.Single}.</returns> private float? GetFrameRate(string value) { - if (!string.IsNullOrEmpty(value)) + if (string.IsNullOrEmpty(value)) { - var parts = value.Split('/'); + return null; + } - float result; + var parts = value.Split('/'); - if (parts.Length == 2) - { - result = float.Parse(parts[0], _usCulture) / float.Parse(parts[1], _usCulture); - } - else - { - result = float.Parse(parts[0], _usCulture); - } + float result; - return float.IsNaN(result) ? (float?)null : result; + if (parts.Length == 2) + { + result = float.Parse(parts[0], _usCulture) / float.Parse(parts[1], _usCulture); + } + else + { + result = float.Parse(parts[0], _usCulture); } - return null; + return float.IsNaN(result) ? null : result; } private void SetAudioRuntimeTicks(InternalMediaInfoResult result, MediaInfo data) { - if (result.Streams != null) + // Get the first info stream + var stream = result.Streams?.FirstOrDefault(s => string.Equals(s.CodecType, "audio", StringComparison.OrdinalIgnoreCase)); + if (stream == null) { - // Get the first info stream - var stream = result.Streams.FirstOrDefault(s => string.Equals(s.CodecType, "audio", StringComparison.OrdinalIgnoreCase)); + return; + } - if (stream != null) - { - // Get duration from stream properties - var duration = stream.Duration; + // Get duration from stream properties + var duration = stream.Duration; - // If it's not there go into format properties - if (string.IsNullOrEmpty(duration)) - { - duration = result.Format.Duration; - } + // If it's not there go into format properties + if (string.IsNullOrEmpty(duration)) + { + duration = result.Format.Duration; + } - // If we got something, parse it - if (!string.IsNullOrEmpty(duration)) - { - data.RunTimeTicks = TimeSpan.FromSeconds(double.Parse(duration, _usCulture)).Ticks; - } - } + // If we got something, parse it + if (!string.IsNullOrEmpty(duration)) + { + data.RunTimeTicks = TimeSpan.FromSeconds(double.Parse(duration, _usCulture)).Ticks; } } - private void SetSize(InternalMediaInfoResult data, MediaInfo info) + private int? GetBPSFromTags(MediaStreamInfo streamInfo) { - if (data.Format != null) + if (streamInfo?.Tags == null) { - if (!string.IsNullOrEmpty(data.Format.Size)) - { - info.Size = long.Parse(data.Format.Size, _usCulture); - } - else - { - info.Size = null; - } + return null; } - } - private void SetAudioInfoFromTags(MediaInfo audio, Dictionary<string, string> tags) - { - var peoples = new List<BaseItemPerson>(); - var composer = FFProbeHelpers.GetDictionaryValue(tags, "composer"); - if (!string.IsNullOrWhiteSpace(composer)) + var bps = GetDictionaryValue(streamInfo.Tags, "BPS-eng") ?? GetDictionaryValue(streamInfo.Tags, "BPS"); + if (!string.IsNullOrEmpty(bps) + && int.TryParse(bps, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsedBps)) { - foreach (var person in Split(composer, false)) - { - peoples.Add(new BaseItemPerson { Name = person, Type = PersonType.Composer }); - } + return parsedBps; } - var conductor = FFProbeHelpers.GetDictionaryValue(tags, "conductor"); - if (!string.IsNullOrWhiteSpace(conductor)) + return null; + } + + private double? GetRuntimeSecondsFromTags(MediaStreamInfo streamInfo) + { + if (streamInfo?.Tags == null) { - foreach (var person in Split(conductor, false)) - { - peoples.Add(new BaseItemPerson { Name = person, Type = PersonType.Conductor }); - } + return null; } - var lyricist = FFProbeHelpers.GetDictionaryValue(tags, "lyricist"); - if (!string.IsNullOrWhiteSpace(lyricist)) + var duration = GetDictionaryValue(streamInfo.Tags, "DURATION-eng") ?? GetDictionaryValue(streamInfo.Tags, "DURATION"); + if (!string.IsNullOrEmpty(duration) && TimeSpan.TryParse(duration, out var parsedDuration)) { - foreach (var person in Split(lyricist, false)) - { - peoples.Add(new BaseItemPerson { Name = person, Type = PersonType.Lyricist }); - } + return parsedDuration.TotalSeconds; } - // Check for writer some music is tagged that way as alternative to composer/lyricist - var writer = FFProbeHelpers.GetDictionaryValue(tags, "writer"); + return null; + } - if (!string.IsNullOrWhiteSpace(writer)) + private long? GetNumberOfBytesFromTags(MediaStreamInfo streamInfo) + { + if (streamInfo?.Tags == null) { - foreach (var person in Split(writer, false)) - { - peoples.Add(new BaseItemPerson { Name = person, Type = PersonType.Writer }); - } + return null; } - audio.People = peoples.ToArray(); - audio.Album = FFProbeHelpers.GetDictionaryValue(tags, "album"); + var numberOfBytes = GetDictionaryValue(streamInfo.Tags, "NUMBER_OF_BYTES-eng") + ?? GetDictionaryValue(streamInfo.Tags, "NUMBER_OF_BYTES"); + if (!string.IsNullOrEmpty(numberOfBytes) + && long.TryParse(numberOfBytes, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsedBytes)) + { + return parsedBytes; + } - var artists = FFProbeHelpers.GetDictionaryValue(tags, "artists"); + return null; + } - if (!string.IsNullOrWhiteSpace(artists)) + private void SetSize(InternalMediaInfoResult data, MediaInfo info) + { + if (data.Format == null) { - audio.Artists = SplitArtists(artists, new[] { '/', ';' }, false) - .DistinctNames() - .ToArray(); + return; } - else + + info.Size = string.IsNullOrEmpty(data.Format.Size) ? null : long.Parse(data.Format.Size, _usCulture); + } + + private void SetAudioInfoFromTags(MediaInfo audio, IReadOnlyDictionary<string, string> tags) + { + var people = new List<BaseItemPerson>(); + if (tags.TryGetValue("composer", out var composer) && !string.IsNullOrWhiteSpace(composer)) { - var artist = FFProbeHelpers.GetDictionaryValue(tags, "artist"); - if (string.IsNullOrWhiteSpace(artist)) - { - audio.Artists = Array.Empty<string>(); - } - else + foreach (var person in Split(composer, false)) { - audio.Artists = SplitArtists(artist, _nameDelimiters, true) - .DistinctNames() - .ToArray(); + people.Add(new BaseItemPerson { Name = person, Type = PersonType.Composer }); } } - var albumArtist = FFProbeHelpers.GetDictionaryValue(tags, "albumartist"); - if (string.IsNullOrWhiteSpace(albumArtist)) + if (tags.TryGetValue("conductor", out var conductor) && !string.IsNullOrWhiteSpace(conductor)) { - albumArtist = FFProbeHelpers.GetDictionaryValue(tags, "album artist"); + foreach (var person in Split(conductor, false)) + { + people.Add(new BaseItemPerson { Name = person, Type = PersonType.Conductor }); + } } - if (string.IsNullOrWhiteSpace(albumArtist)) + if (tags.TryGetValue("lyricist", out var lyricist) && !string.IsNullOrWhiteSpace(lyricist)) { - albumArtist = FFProbeHelpers.GetDictionaryValue(tags, "album_artist"); + foreach (var person in Split(lyricist, false)) + { + people.Add(new BaseItemPerson { Name = person, Type = PersonType.Lyricist }); + } } - if (string.IsNullOrWhiteSpace(albumArtist)) - { - audio.AlbumArtists = Array.Empty<string>(); - } - else + // Check for writer some music is tagged that way as alternative to composer/lyricist + if (tags.TryGetValue("writer", out var writer) && !string.IsNullOrWhiteSpace(writer)) { - audio.AlbumArtists = SplitArtists(albumArtist, _nameDelimiters, true) - .DistinctNames() - .ToArray(); + foreach (var person in Split(writer, false)) + { + people.Add(new BaseItemPerson { Name = person, Type = PersonType.Writer }); + } } + audio.People = people.ToArray(); + + // Set album artist + var albumArtist = tags.GetFirstNotNullNorWhiteSpaceValue("albumartist", "album artist", "album_artist"); + audio.AlbumArtists = albumArtist != null + ? SplitDistinctArtists(albumArtist, _nameDelimiters, true).ToArray() + : Array.Empty<string>(); + + // Set album artist to artist if empty if (audio.AlbumArtists.Length == 0) { audio.AlbumArtists = audio.Artists; } // Track number - audio.IndexNumber = GetDictionaryDiscValue(tags, "track"); + audio.IndexNumber = GetDictionaryTrackOrDiscNumber(tags, "track"); // Disc number - audio.ParentIndexNumber = GetDictionaryDiscValue(tags, "disc"); - - // If we don't have a ProductionYear try and get it from PremiereDate - if (audio.PremiereDate.HasValue && !audio.ProductionYear.HasValue) - { - audio.ProductionYear = audio.PremiereDate.Value.ToLocalTime().Year; - } + audio.ParentIndexNumber = GetDictionaryTrackOrDiscNumber(tags, "disc"); // There's several values in tags may or may not be present FetchStudios(audio, tags, "organization"); @@ -1080,45 +1146,25 @@ namespace MediaBrowser.MediaEncoding.Probing FetchStudios(audio, tags, "publisher"); FetchStudios(audio, tags, "label"); - // These support mulitple values, but for now we only store the first. - var mb = GetMultipleMusicBrainzId(FFProbeHelpers.GetDictionaryValue(tags, "MusicBrainz Album Artist Id")); - if (mb == null) - { - mb = GetMultipleMusicBrainzId(FFProbeHelpers.GetDictionaryValue(tags, "MUSICBRAINZ_ALBUMARTISTID")); - } - + // These support multiple values, but for now we only store the first. + var mb = GetMultipleMusicBrainzId(tags.GetValueOrDefault("MusicBrainz Album Artist Id")) + ?? GetMultipleMusicBrainzId(tags.GetValueOrDefault("MUSICBRAINZ_ALBUMARTISTID")); audio.SetProviderId(MetadataProvider.MusicBrainzAlbumArtist, mb); - mb = GetMultipleMusicBrainzId(FFProbeHelpers.GetDictionaryValue(tags, "MusicBrainz Artist Id")); - if (mb == null) - { - mb = GetMultipleMusicBrainzId(FFProbeHelpers.GetDictionaryValue(tags, "MUSICBRAINZ_ARTISTID")); - } - + mb = GetMultipleMusicBrainzId(tags.GetValueOrDefault("MusicBrainz Artist Id")) + ?? GetMultipleMusicBrainzId(tags.GetValueOrDefault("MUSICBRAINZ_ARTISTID")); audio.SetProviderId(MetadataProvider.MusicBrainzArtist, mb); - mb = GetMultipleMusicBrainzId(FFProbeHelpers.GetDictionaryValue(tags, "MusicBrainz Album Id")); - if (mb == null) - { - mb = GetMultipleMusicBrainzId(FFProbeHelpers.GetDictionaryValue(tags, "MUSICBRAINZ_ALBUMID")); - } - + mb = GetMultipleMusicBrainzId(tags.GetValueOrDefault("MusicBrainz Album Id")) + ?? GetMultipleMusicBrainzId(tags.GetValueOrDefault("MUSICBRAINZ_ALBUMID")); audio.SetProviderId(MetadataProvider.MusicBrainzAlbum, mb); - mb = GetMultipleMusicBrainzId(FFProbeHelpers.GetDictionaryValue(tags, "MusicBrainz Release Group Id")); - if (mb == null) - { - mb = GetMultipleMusicBrainzId(FFProbeHelpers.GetDictionaryValue(tags, "MUSICBRAINZ_RELEASEGROUPID")); - } - + mb = GetMultipleMusicBrainzId(tags.GetValueOrDefault("MusicBrainz Release Group Id")) + ?? GetMultipleMusicBrainzId(tags.GetValueOrDefault("MUSICBRAINZ_RELEASEGROUPID")); audio.SetProviderId(MetadataProvider.MusicBrainzReleaseGroup, mb); - mb = GetMultipleMusicBrainzId(FFProbeHelpers.GetDictionaryValue(tags, "MusicBrainz Release Track Id")); - if (mb == null) - { - mb = GetMultipleMusicBrainzId(FFProbeHelpers.GetDictionaryValue(tags, "MUSICBRAINZ_RELEASETRACKID")); - } - + mb = GetMultipleMusicBrainzId(tags.GetValueOrDefault("MusicBrainz Release Track Id")) + ?? GetMultipleMusicBrainzId(tags.GetValueOrDefault("MUSICBRAINZ_RELEASETRACKID")); audio.SetProviderId(MetadataProvider.MusicBrainzTrack, mb); } @@ -1129,7 +1175,7 @@ namespace MediaBrowser.MediaEncoding.Probing return null; } - return value.Split(new[] { '/' }, StringSplitOptions.RemoveEmptyEntries) + return value.Split('/', StringSplitOptions.RemoveEmptyEntries) .Select(i => i.Trim()) .FirstOrDefault(i => !string.IsNullOrWhiteSpace(i)); } @@ -1142,18 +1188,18 @@ namespace MediaBrowser.MediaEncoding.Probing /// <returns>System.String[][].</returns> private IEnumerable<string> Split(string val, bool allowCommaDelimiter) { - // Only use the comma as a delimeter if there are no slashes or pipes. + // Only use the comma as a delimiter if there are no slashes or pipes. // We want to be careful not to split names that have commas in them - var delimeter = !allowCommaDelimiter || _nameDelimiters.Any(i => val.IndexOf(i, StringComparison.Ordinal) != -1) ? + var delimiter = !allowCommaDelimiter || _nameDelimiters.Any(i => val.IndexOf(i, StringComparison.Ordinal) != -1) ? _nameDelimiters : new[] { ',' }; - return val.Split(delimeter, StringSplitOptions.RemoveEmptyEntries) + return val.Split(delimiter, StringSplitOptions.RemoveEmptyEntries) .Where(i => !string.IsNullOrWhiteSpace(i)) .Select(i => i.Trim()); } - private IEnumerable<string> SplitArtists(string val, char[] delimiters, bool splitFeaturing) + private IEnumerable<string> SplitDistinctArtists(string val, char[] delimiters, bool splitFeaturing) { if (splitFeaturing) { @@ -1163,7 +1209,7 @@ namespace MediaBrowser.MediaEncoding.Probing var artistsFound = new List<string>(); - foreach (var whitelistArtist in GetSplitWhitelist()) + foreach (var whitelistArtist in SplitWhitelist) { var originalVal = val; val = val.Replace(whitelistArtist, "|", StringComparison.OrdinalIgnoreCase); @@ -1179,20 +1225,7 @@ namespace MediaBrowser.MediaEncoding.Probing .Select(i => i.Trim()); artistsFound.AddRange(artists); - return artistsFound; - } - - private IEnumerable<string> GetSplitWhitelist() - { - if (_splitWhiteList == null) - { - _splitWhiteList = new List<string> - { - "AC/DC" - }; - } - - return _splitWhiteList; + return artistsFound.DistinctNames(); } /// <summary> @@ -1201,36 +1234,38 @@ namespace MediaBrowser.MediaEncoding.Probing /// <param name="info">The info.</param> /// <param name="tags">The tags.</param> /// <param name="tagName">Name of the tag.</param> - private void FetchStudios(MediaInfo info, Dictionary<string, string> tags, string tagName) + private void FetchStudios(MediaInfo info, IReadOnlyDictionary<string, string> tags, string tagName) { - var val = FFProbeHelpers.GetDictionaryValue(tags, tagName); + var val = tags.GetValueOrDefault(tagName); - if (!string.IsNullOrEmpty(val)) + if (string.IsNullOrEmpty(val)) { - var studios = Split(val, true); - var studioList = new List<string>(); + return; + } - foreach (var studio in studios) - { - // Sometimes the artist name is listed here, account for that - if (info.Artists.Contains(studio, StringComparer.OrdinalIgnoreCase)) - { - continue; - } + var studios = Split(val, true); + var studioList = new List<string>(); - if (info.AlbumArtists.Contains(studio, StringComparer.OrdinalIgnoreCase)) - { - continue; - } + foreach (var studio in studios) + { + if (string.IsNullOrWhiteSpace(studio)) + { + continue; + } - studioList.Add(studio); + // Don't add artist/album artist name to studios, even if it's listed there + if (info.Artists.Contains(studio, StringComparer.OrdinalIgnoreCase) + || info.AlbumArtists.Contains(studio, StringComparer.OrdinalIgnoreCase)) + { + continue; } - info.Studios = studioList - .Where(i => !string.IsNullOrWhiteSpace(i)) - .Distinct(StringComparer.OrdinalIgnoreCase) - .ToArray(); + studioList.Add(studio); } + + info.Studios = studioList + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); } /// <summary> @@ -1238,58 +1273,55 @@ namespace MediaBrowser.MediaEncoding.Probing /// </summary> /// <param name="info">The information.</param> /// <param name="tags">The tags.</param> - private void FetchGenres(MediaInfo info, Dictionary<string, string> tags) + private void FetchGenres(MediaInfo info, IReadOnlyDictionary<string, string> tags) { - var val = FFProbeHelpers.GetDictionaryValue(tags, "genre"); + var genreVal = tags.GetValueOrDefault("genre"); + if (string.IsNullOrEmpty(genreVal)) + { + return; + } - if (!string.IsNullOrEmpty(val)) + var genres = new List<string>(info.Genres); + foreach (var genre in Split(genreVal, true)) { - var genres = new List<string>(info.Genres); - foreach (var genre in Split(val, true)) + if (string.IsNullOrWhiteSpace(genre)) { - genres.Add(genre); + continue; } - info.Genres = genres - .Where(i => !string.IsNullOrWhiteSpace(i)) - .Distinct(StringComparer.OrdinalIgnoreCase) - .ToArray(); + genres.Add(genre); } + + info.Genres = genres + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); } /// <summary> - /// Gets the disc number, which is sometimes can be in the form of '1', or '1/3'. + /// Gets the track or disc number, which can be in the form of '1', or '1/3'. /// </summary> /// <param name="tags">The tags.</param> /// <param name="tagName">Name of the tag.</param> - /// <returns>System.Nullable{System.Int32}.</returns> - private int? GetDictionaryDiscValue(Dictionary<string, string> tags, string tagName) + /// <returns>The track or disc number, or null, if missing or not parseable.</returns> + private static int? GetDictionaryTrackOrDiscNumber(IReadOnlyDictionary<string, string> tags, string tagName) { - var disc = FFProbeHelpers.GetDictionaryValue(tags, tagName); + var disc = tags.GetValueOrDefault(tagName); - if (!string.IsNullOrEmpty(disc)) + if (!string.IsNullOrEmpty(disc) && int.TryParse(disc.Split('/')[0], out var discNum)) { - disc = disc.Split('/')[0]; - - if (int.TryParse(disc, out var num)) - { - return num; - } + return discNum; } return null; } - private ChapterInfo GetChapterInfo(MediaChapter chapter) + private static ChapterInfo GetChapterInfo(MediaChapter chapter) { var info = new ChapterInfo(); - if (chapter.Tags != null) + if (chapter.Tags != null && chapter.Tags.TryGetValue("title", out string name)) { - if (chapter.Tags.TryGetValue("title", out string name)) - { - info.Name = name; - } + info.Name = name; } // Limit accuracy to milliseconds to match xml saving @@ -1306,14 +1338,14 @@ namespace MediaBrowser.MediaEncoding.Probing private void FetchWtvInfo(MediaInfo video, InternalMediaInfoResult data) { - if (data.Format == null || data.Format.Tags == null) + var tags = data.Format?.Tags; + + if (tags == null) { return; } - var genres = FFProbeHelpers.GetDictionaryValue(data.Format.Tags, "WM/Genre"); - - if (!string.IsNullOrWhiteSpace(genres)) + if (tags.TryGetValue("WM/Genre", out var genres) && !string.IsNullOrWhiteSpace(genres)) { var genreList = genres.Split(new[] { ';', '/', ',' }, StringSplitOptions.RemoveEmptyEntries) .Where(i => !string.IsNullOrWhiteSpace(i)) @@ -1327,16 +1359,12 @@ namespace MediaBrowser.MediaEncoding.Probing } } - var officialRating = FFProbeHelpers.GetDictionaryValue(data.Format.Tags, "WM/ParentalRating"); - - if (!string.IsNullOrWhiteSpace(officialRating)) + if (tags.TryGetValue("WM/ParentalRating", out var officialRating) && !string.IsNullOrWhiteSpace(officialRating)) { video.OfficialRating = officialRating; } - var people = FFProbeHelpers.GetDictionaryValue(data.Format.Tags, "WM/MediaCredits"); - - if (!string.IsNullOrEmpty(people)) + if (tags.TryGetValue("WM/MediaCredits", out var people) && !string.IsNullOrEmpty(people)) { video.People = people.Split(new[] { ';', '/' }, StringSplitOptions.RemoveEmptyEntries) .Where(i => !string.IsNullOrWhiteSpace(i)) @@ -1344,29 +1372,21 @@ namespace MediaBrowser.MediaEncoding.Probing .ToArray(); } - var year = FFProbeHelpers.GetDictionaryValue(data.Format.Tags, "WM/OriginalReleaseTime"); - if (!string.IsNullOrWhiteSpace(year)) + if (tags.TryGetValue("WM/OriginalReleaseTime", out var year) && int.TryParse(year, NumberStyles.Integer, _usCulture, out var parsedYear)) { - if (int.TryParse(year, NumberStyles.Integer, _usCulture, out var val)) - { - video.ProductionYear = val; - } + video.ProductionYear = parsedYear; } - var premiereDateString = FFProbeHelpers.GetDictionaryValue(data.Format.Tags, "WM/MediaOriginalBroadcastDateTime"); - if (!string.IsNullOrWhiteSpace(premiereDateString)) + // Credit to MCEBuddy: https://mcebuddy2x.codeplex.com/ + // DateTime is reported along with timezone info (typically Z i.e. UTC hence assume None) + if (tags.TryGetValue("WM/MediaOriginalBroadcastDateTime", out var premiereDateString) && DateTime.TryParse(year, null, DateTimeStyles.None, out var parsedDate)) { - // Credit to MCEBuddy: https://mcebuddy2x.codeplex.com/ - // DateTime is reported along with timezone info (typically Z i.e. UTC hence assume None) - if (DateTime.TryParse(year, null, DateTimeStyles.None, out var val)) - { - video.PremiereDate = val.ToUniversalTime(); - } + video.PremiereDate = parsedDate.ToUniversalTime(); } - var description = FFProbeHelpers.GetDictionaryValue(data.Format.Tags, "WM/SubTitleDescription"); + var description = tags.GetValueOrDefault("WM/SubTitleDescription"); - var subTitle = FFProbeHelpers.GetDictionaryValue(data.Format.Tags, "WM/SubTitle"); + var subTitle = tags.GetValueOrDefault("WM/SubTitle"); // For below code, credit to MCEBuddy: https://mcebuddy2x.codeplex.com/ @@ -1377,37 +1397,48 @@ namespace MediaBrowser.MediaEncoding.Probing // e.g. -> CBeebies Bedtime Hour. The Mystery: Animated adventures of two friends who live on an island in the middle of the big city. Some of Abney and Teal's favourite objects are missing. [S] if (string.IsNullOrWhiteSpace(subTitle) && !string.IsNullOrWhiteSpace(description) - && description.AsSpan().Slice(0, Math.Min(description.Length, MaxSubtitleDescriptionExtractionLength)).IndexOf(':') != -1) // Check within the Subtitle size limit, otherwise from description it can get too long creating an invalid filename + && description.AsSpan()[0..Math.Min(description.Length, MaxSubtitleDescriptionExtractionLength)].IndexOf(':') != -1) // Check within the Subtitle size limit, otherwise from description it can get too long creating an invalid filename { - string[] parts = description.Split(':'); - if (parts.Length > 0) + string[] descriptionParts = description.Split(':'); + if (descriptionParts.Length > 0) { - string subtitle = parts[0]; + string subtitle = descriptionParts[0]; try { - if (subtitle.Contains('/', StringComparison.Ordinal)) // It contains a episode number and season number + // Check if it contains a episode number and season number + if (subtitle.Contains('/', StringComparison.Ordinal)) { - string[] numbers = subtitle.Split(' '); - video.IndexNumber = int.Parse(numbers[0].Replace(".", string.Empty, StringComparison.Ordinal).Split('/')[0], CultureInfo.InvariantCulture); - int totalEpisodesInSeason = int.Parse(numbers[0].Replace(".", string.Empty, StringComparison.Ordinal).Split('/')[1], CultureInfo.InvariantCulture); + string[] subtitleParts = subtitle.Split(' '); + string[] numbers = subtitleParts[0].Replace(".", string.Empty, StringComparison.Ordinal).Split('/'); + video.IndexNumber = int.Parse(numbers[0], CultureInfo.InvariantCulture); + // int totalEpisodesInSeason = int.Parse(numbers[1], CultureInfo.InvariantCulture); - description = string.Join(" ", numbers, 1, numbers.Length - 1).Trim(); // Skip the first, concatenate the rest, clean up spaces and save it + // Skip the numbers, concatenate the rest, trim and set as new description + description = string.Join(' ', subtitleParts, 1, subtitleParts.Length - 1).Trim(); + } + else if (subtitle.Contains('.', StringComparison.Ordinal)) + { + var subtitleParts = subtitle.Split('.'); + description = string.Join('.', subtitleParts, 1, subtitleParts.Length - 1).Trim(); } else { - throw new Exception(); // Switch to default parsing + description = subtitle.Trim(); } } - catch // Default parsing + catch (Exception ex) { + _logger.LogError(ex, "Error while parsing subtitle field"); + + // Fallback to default parsing if (subtitle.Contains('.', StringComparison.Ordinal)) { - // skip the comment, keep the subtitle - description = string.Join(".", subtitle.Split('.'), 1, subtitle.Split('.').Length - 1).Trim(); // skip the first + var subtitleParts = subtitle.Split('.'); + description = string.Join('.', subtitleParts, 1, subtitleParts.Length - 1).Trim(); } else { - description = subtitle.Trim(); // Clean up whitespaces and save it + description = subtitle.Trim(); } } } @@ -1421,24 +1452,27 @@ namespace MediaBrowser.MediaEncoding.Probing private void ExtractTimestamp(MediaInfo video) { - if (video.VideoType == VideoType.VideoFile) + if (video.VideoType != VideoType.VideoFile) { - if (string.Equals(video.Container, "mpeg2ts", StringComparison.OrdinalIgnoreCase) || - string.Equals(video.Container, "m2ts", StringComparison.OrdinalIgnoreCase) || - string.Equals(video.Container, "ts", StringComparison.OrdinalIgnoreCase)) - { - try - { - video.Timestamp = GetMpegTimestamp(video.Path); + return; + } - _logger.LogDebug("Video has {Timestamp} timestamp", video.Timestamp); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error extracting timestamp info from {Path}", video.Path); - video.Timestamp = null; - } - } + if (!string.Equals(video.Container, "mpeg2ts", StringComparison.OrdinalIgnoreCase) + && !string.Equals(video.Container, "m2ts", StringComparison.OrdinalIgnoreCase) + && !string.Equals(video.Container, "ts", StringComparison.OrdinalIgnoreCase)) + { + return; + } + + try + { + video.Timestamp = GetMpegTimestamp(video.Path); + _logger.LogDebug("Video has {Timestamp} timestamp", video.Timestamp); + } + catch (Exception ex) + { + video.Timestamp = null; + _logger.LogError(ex, "Error extracting timestamp info from {Path}", video.Path); } } @@ -1457,17 +1491,17 @@ namespace MediaBrowser.MediaEncoding.Probing return TransportStreamTimestamp.None; } - if ((packetBuffer[4] == 71) && (packetBuffer[196] == 71)) + if ((packetBuffer[4] != 71) || (packetBuffer[196] != 71)) { - if ((packetBuffer[0] == 0) && (packetBuffer[1] == 0) && (packetBuffer[2] == 0) && (packetBuffer[3] == 0)) - { - return TransportStreamTimestamp.Zero; - } + return TransportStreamTimestamp.None; + } - return TransportStreamTimestamp.Valid; + if ((packetBuffer[0] == 0) && (packetBuffer[1] == 0) && (packetBuffer[2] == 0) && (packetBuffer[3] == 0)) + { + return TransportStreamTimestamp.Zero; } - return TransportStreamTimestamp.None; + return TransportStreamTimestamp.Valid; } } } diff --git a/MediaBrowser.MediaEncoding/Subtitles/AssParser.cs b/MediaBrowser.MediaEncoding/Subtitles/AssParser.cs index 86b87fddd8..08ee5c72e5 100644 --- a/MediaBrowser.MediaEncoding/Subtitles/AssParser.cs +++ b/MediaBrowser.MediaEncoding/Subtitles/AssParser.cs @@ -1,128 +1,19 @@ -#pragma warning disable CS1591 - -using System; -using System.Collections.Generic; -using System.Globalization; -using System.IO; -using System.Linq; -using System.Text.RegularExpressions; -using System.Threading; -using MediaBrowser.Model.MediaInfo; +using Microsoft.Extensions.Logging; +using Nikse.SubtitleEdit.Core.SubtitleFormats; namespace MediaBrowser.MediaEncoding.Subtitles { - public class AssParser : ISubtitleParser + /// <summary> + /// Advanced SubStation Alpha subtitle parser. + /// </summary> + public class AssParser : SubtitleEditParser<AdvancedSubStationAlpha> { - private readonly CultureInfo _usCulture = new CultureInfo("en-US"); - - /// <inheritdoc /> - public SubtitleTrackInfo Parse(Stream stream, CancellationToken cancellationToken) - { - var trackInfo = new SubtitleTrackInfo(); - var trackEvents = new List<SubtitleTrackEvent>(); - var eventIndex = 1; - using (var reader = new StreamReader(stream)) - { - string line; - while (reader.ReadLine() != "[Events]") - { - } - - var headers = ParseFieldHeaders(reader.ReadLine()); - - while ((line = reader.ReadLine()) != null) - { - cancellationToken.ThrowIfCancellationRequested(); - - if (string.IsNullOrWhiteSpace(line)) - { - continue; - } - - if (line[0] == '[') - { - break; - } - - var subEvent = new SubtitleTrackEvent { Id = eventIndex.ToString(_usCulture) }; - eventIndex++; - var sections = line.Substring(10).Split(','); - - subEvent.StartPositionTicks = GetTicks(sections[headers["Start"]]); - subEvent.EndPositionTicks = GetTicks(sections[headers["End"]]); - - subEvent.Text = string.Join(",", sections.Skip(headers["Text"])); - RemoteNativeFormatting(subEvent); - - subEvent.Text = subEvent.Text.Replace("\\n", ParserValues.NewLine, StringComparison.OrdinalIgnoreCase); - - subEvent.Text = Regex.Replace(subEvent.Text, @"\{(\\[\w]+\(?([\w\d]+,?)+\)?)+\}", string.Empty, RegexOptions.IgnoreCase); - - trackEvents.Add(subEvent); - } - } - - trackInfo.TrackEvents = trackEvents.ToArray(); - return trackInfo; - } - - private long GetTicks(ReadOnlySpan<char> time) + /// <summary> + /// Initializes a new instance of the <see cref="AssParser"/> class. + /// </summary> + /// <param name="logger">The logger.</param> + public AssParser(ILogger logger) : base(logger) { - return TimeSpan.TryParseExact(time, @"h\:mm\:ss\.ff", _usCulture, out var span) - ? span.Ticks : 0; - } - - private Dictionary<string, int> ParseFieldHeaders(string line) - { - var fields = line.Substring(8).Split(',').Select(x => x.Trim()).ToList(); - - return new Dictionary<string, int> - { - { "Start", fields.IndexOf("Start") }, - { "End", fields.IndexOf("End") }, - { "Text", fields.IndexOf("Text") } - }; - } - - private void RemoteNativeFormatting(SubtitleTrackEvent p) - { - int indexOfBegin = p.Text.IndexOf('{', StringComparison.Ordinal); - string pre = string.Empty; - while (indexOfBegin >= 0 && p.Text.IndexOf('}', StringComparison.Ordinal) > indexOfBegin) - { - string s = p.Text.Substring(indexOfBegin); - if (s.StartsWith("{\\an1}", StringComparison.Ordinal) || - s.StartsWith("{\\an2}", StringComparison.Ordinal) || - s.StartsWith("{\\an3}", StringComparison.Ordinal) || - s.StartsWith("{\\an4}", StringComparison.Ordinal) || - s.StartsWith("{\\an5}", StringComparison.Ordinal) || - s.StartsWith("{\\an6}", StringComparison.Ordinal) || - s.StartsWith("{\\an7}", StringComparison.Ordinal) || - s.StartsWith("{\\an8}", StringComparison.Ordinal) || - s.StartsWith("{\\an9}", StringComparison.Ordinal)) - { - pre = s.Substring(0, 6); - } - else if (s.StartsWith("{\\an1\\", StringComparison.Ordinal) || - s.StartsWith("{\\an2\\", StringComparison.Ordinal) || - s.StartsWith("{\\an3\\", StringComparison.Ordinal) || - s.StartsWith("{\\an4\\", StringComparison.Ordinal) || - s.StartsWith("{\\an5\\", StringComparison.Ordinal) || - s.StartsWith("{\\an6\\", StringComparison.Ordinal) || - s.StartsWith("{\\an7\\", StringComparison.Ordinal) || - s.StartsWith("{\\an8\\", StringComparison.Ordinal) || - s.StartsWith("{\\an9\\", StringComparison.Ordinal)) - { - pre = s.Substring(0, 5) + "}"; - } - - int indexOfEnd = p.Text.IndexOf('}', StringComparison.Ordinal); - p.Text = p.Text.Remove(indexOfBegin, (indexOfEnd - indexOfBegin) + 1); - - indexOfBegin = p.Text.IndexOf('{', StringComparison.Ordinal); - } - - p.Text = pre + p.Text; } } } diff --git a/MediaBrowser.MediaEncoding/Subtitles/ParserValues.cs b/MediaBrowser.MediaEncoding/Subtitles/ParserValues.cs deleted file mode 100644 index dca5c1e8ac..0000000000 --- a/MediaBrowser.MediaEncoding/Subtitles/ParserValues.cs +++ /dev/null @@ -1,10 +0,0 @@ -#nullable enable -#pragma warning disable CS1591 - -namespace MediaBrowser.MediaEncoding.Subtitles -{ - public static class ParserValues - { - public const string NewLine = "\r\n"; - } -} diff --git a/MediaBrowser.MediaEncoding/Subtitles/SrtParser.cs b/MediaBrowser.MediaEncoding/Subtitles/SrtParser.cs index cc35efb3f0..78d54ca51f 100644 --- a/MediaBrowser.MediaEncoding/Subtitles/SrtParser.cs +++ b/MediaBrowser.MediaEncoding/Subtitles/SrtParser.cs @@ -1,102 +1,19 @@ -#pragma warning disable CS1591 - -using System; -using System.Collections.Generic; -using System.Globalization; -using System.IO; -using System.Text.RegularExpressions; -using System.Threading; -using MediaBrowser.Model.MediaInfo; using Microsoft.Extensions.Logging; +using Nikse.SubtitleEdit.Core.SubtitleFormats; namespace MediaBrowser.MediaEncoding.Subtitles { - public class SrtParser : ISubtitleParser + /// <summary> + /// SubRip subtitle parser. + /// </summary> + public class SrtParser : SubtitleEditParser<SubRip> { - private readonly ILogger _logger; - - private readonly CultureInfo _usCulture = new CultureInfo("en-US"); - - public SrtParser(ILogger logger) - { - _logger = logger; - } - - /// <inheritdoc /> - public SubtitleTrackInfo Parse(Stream stream, CancellationToken cancellationToken) - { - var trackInfo = new SubtitleTrackInfo(); - var trackEvents = new List<SubtitleTrackEvent>(); - using (var reader = new StreamReader(stream)) - { - string line; - while ((line = reader.ReadLine()) != null) - { - cancellationToken.ThrowIfCancellationRequested(); - - if (string.IsNullOrWhiteSpace(line)) - { - continue; - } - - var subEvent = new SubtitleTrackEvent { Id = line }; - line = reader.ReadLine(); - - if (string.IsNullOrWhiteSpace(line)) - { - continue; - } - - var time = Regex.Split(line, @"[\t ]*-->[\t ]*"); - - if (time.Length < 2) - { - // This occurs when subtitle text has an empty line as part of the text. - // Need to adjust the break statement below to resolve this. - _logger.LogWarning("Unrecognized line in srt: {0}", line); - continue; - } - - subEvent.StartPositionTicks = GetTicks(time[0]); - var endTime = time[1].AsSpan(); - var idx = endTime.IndexOf(' '); - if (idx > 0) - { - endTime = endTime.Slice(0, idx); - } - - subEvent.EndPositionTicks = GetTicks(endTime); - var multiline = new List<string>(); - while ((line = reader.ReadLine()) != null) - { - if (string.IsNullOrEmpty(line)) - { - break; - } - - multiline.Add(line); - } - - subEvent.Text = string.Join(ParserValues.NewLine, multiline); - subEvent.Text = subEvent.Text.Replace(@"\N", ParserValues.NewLine, StringComparison.OrdinalIgnoreCase); - subEvent.Text = Regex.Replace(subEvent.Text, @"\{(?:\\\d?[\w.-]+(?:\([^\)]*\)|&H?[0-9A-Fa-f]+&|))+\}", string.Empty, RegexOptions.IgnoreCase); - subEvent.Text = Regex.Replace(subEvent.Text, "<", "<", RegexOptions.IgnoreCase); - subEvent.Text = Regex.Replace(subEvent.Text, ">", ">", RegexOptions.IgnoreCase); - subEvent.Text = Regex.Replace(subEvent.Text, "<(\\/?(font|b|u|i|s))((\\s+(\\w|\\w[\\w\\-]*\\w)(\\s*=\\s*(?:\\\".*?\\\"|'.*?'|[^'\\\">\\s]+))?)+\\s*|\\s*)(\\/?)>", "<$1$3$7>", RegexOptions.IgnoreCase); - trackEvents.Add(subEvent); - } - } - - trackInfo.TrackEvents = trackEvents.ToArray(); - return trackInfo; - } - - private long GetTicks(ReadOnlySpan<char> time) + /// <summary> + /// Initializes a new instance of the <see cref="SrtParser"/> class. + /// </summary> + /// <param name="logger">The logger.</param> + public SrtParser(ILogger logger) : base(logger) { - return TimeSpan.TryParseExact(time, @"hh\:mm\:ss\.fff", _usCulture, out var span) - ? span.Ticks - : (TimeSpan.TryParseExact(time, @"hh\:mm\:ss\,fff", _usCulture, out span) - ? span.Ticks : 0); } } } diff --git a/MediaBrowser.MediaEncoding/Subtitles/SsaParser.cs b/MediaBrowser.MediaEncoding/Subtitles/SsaParser.cs index a5d6417473..17c2ae40e0 100644 --- a/MediaBrowser.MediaEncoding/Subtitles/SsaParser.cs +++ b/MediaBrowser.MediaEncoding/Subtitles/SsaParser.cs @@ -1,469 +1,19 @@ -using System; -using System.Collections.Generic; -using System.Globalization; -using System.IO; -using System.Text; -using System.Threading; -using MediaBrowser.Model.MediaInfo; +using Microsoft.Extensions.Logging; +using Nikse.SubtitleEdit.Core.SubtitleFormats; namespace MediaBrowser.MediaEncoding.Subtitles { /// <summary> - /// <see href="https://github.com/SubtitleEdit/subtitleedit/blob/a299dc4407a31796364cc6ad83f0d3786194ba22/src/Logic/SubtitleFormats/SubStationAlpha.cs">Credit</see>. + /// SubStation Alpha subtitle parser. /// </summary> - public class SsaParser : ISubtitleParser + public class SsaParser : SubtitleEditParser<SubStationAlpha> { - /// <inheritdoc /> - public SubtitleTrackInfo Parse(Stream stream, CancellationToken cancellationToken) + /// <summary> + /// Initializes a new instance of the <see cref="SsaParser"/> class. + /// </summary> + /// <param name="logger">The logger.</param> + public SsaParser(ILogger logger) : base(logger) { - var trackInfo = new SubtitleTrackInfo(); - var trackEvents = new List<SubtitleTrackEvent>(); - - using (var reader = new StreamReader(stream)) - { - bool eventsStarted = false; - - string[] format = "Marked, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text".Split(','); - int indexLayer = 0; - int indexStart = 1; - int indexEnd = 2; - int indexStyle = 3; - int indexName = 4; - int indexEffect = 8; - int indexText = 9; - int lineNumber = 0; - - var header = new StringBuilder(); - - string line; - - while ((line = reader.ReadLine()) != null) - { - cancellationToken.ThrowIfCancellationRequested(); - - lineNumber++; - if (!eventsStarted) - { - header.AppendLine(line); - } - - if (string.Equals(line.Trim(), "[events]", StringComparison.OrdinalIgnoreCase)) - { - eventsStarted = true; - } - else if (!string.IsNullOrEmpty(line) && line.Trim().StartsWith(";", StringComparison.Ordinal)) - { - // skip comment lines - } - else if (eventsStarted && line.Trim().Length > 0) - { - string s = line.Trim().ToLowerInvariant(); - if (s.StartsWith("format:", StringComparison.Ordinal)) - { - if (line.Length > 10) - { - format = line.ToLowerInvariant().Substring(8).Split(','); - for (int i = 0; i < format.Length; i++) - { - if (string.Equals(format[i].Trim(), "layer", StringComparison.OrdinalIgnoreCase)) - { - indexLayer = i; - } - else if (string.Equals(format[i].Trim(), "start", StringComparison.OrdinalIgnoreCase)) - { - indexStart = i; - } - else if (string.Equals(format[i].Trim(), "end", StringComparison.OrdinalIgnoreCase)) - { - indexEnd = i; - } - else if (string.Equals(format[i].Trim(), "text", StringComparison.OrdinalIgnoreCase)) - { - indexText = i; - } - else if (string.Equals(format[i].Trim(), "effect", StringComparison.OrdinalIgnoreCase)) - { - indexEffect = i; - } - else if (string.Equals(format[i].Trim(), "style", StringComparison.OrdinalIgnoreCase)) - { - indexStyle = i; - } - } - } - } - else if (!string.IsNullOrEmpty(s)) - { - string text = string.Empty; - string start = string.Empty; - string end = string.Empty; - string style = string.Empty; - string layer = string.Empty; - string effect = string.Empty; - string name = string.Empty; - - string[] splittedLine; - - if (s.StartsWith("dialogue:", StringComparison.Ordinal)) - { - splittedLine = line.Substring(10).Split(','); - } - else - { - splittedLine = line.Split(','); - } - - for (int i = 0; i < splittedLine.Length; i++) - { - if (i == indexStart) - { - start = splittedLine[i].Trim(); - } - else if (i == indexEnd) - { - end = splittedLine[i].Trim(); - } - else if (i == indexLayer) - { - layer = splittedLine[i]; - } - else if (i == indexEffect) - { - effect = splittedLine[i]; - } - else if (i == indexText) - { - text = splittedLine[i]; - } - else if (i == indexStyle) - { - style = splittedLine[i]; - } - else if (i == indexName) - { - name = splittedLine[i]; - } - else if (i > indexText) - { - text += "," + splittedLine[i]; - } - } - - try - { - var p = new SubtitleTrackEvent(); - - p.StartPositionTicks = GetTimeCodeFromString(start); - p.EndPositionTicks = GetTimeCodeFromString(end); - p.Text = GetFormattedText(text); - - trackEvents.Add(p); - } - catch - { - } - } - } - } - - // if (header.Length > 0) - // subtitle.Header = header.ToString(); - - // subtitle.Renumber(1); - } - - trackInfo.TrackEvents = trackEvents.ToArray(); - return trackInfo; - } - - private static long GetTimeCodeFromString(string time) - { - // h:mm:ss.cc - string[] timeCode = time.Split(':', '.'); - return new TimeSpan( - 0, - int.Parse(timeCode[0], CultureInfo.InvariantCulture), - int.Parse(timeCode[1], CultureInfo.InvariantCulture), - int.Parse(timeCode[2], CultureInfo.InvariantCulture), - int.Parse(timeCode[3], CultureInfo.InvariantCulture) * 10).Ticks; - } - - private static string GetFormattedText(string text) - { - text = text.Replace("\\n", ParserValues.NewLine, StringComparison.OrdinalIgnoreCase); - - for (int i = 0; i < 10; i++) // just look ten times... - { - if (text.Contains(@"{\fn", StringComparison.Ordinal)) - { - int start = text.IndexOf(@"{\fn", StringComparison.Ordinal); - int end = text.IndexOf('}', start); - if (end > 0 && !text.Substring(start).StartsWith("{\\fn}", StringComparison.Ordinal)) - { - string fontName = text.Substring(start + 4, end - (start + 4)); - string extraTags = string.Empty; - CheckAndAddSubTags(ref fontName, ref extraTags, out bool italic); - text = text.Remove(start, end - start + 1); - if (italic) - { - text = text.Insert(start, "<font face=\"" + fontName + "\"" + extraTags + "><i>"); - } - else - { - text = text.Insert(start, "<font face=\"" + fontName + "\"" + extraTags + ">"); - } - - int indexOfEndTag = text.IndexOf("{\\fn}", start, StringComparison.Ordinal); - if (indexOfEndTag > 0) - { - text = text.Remove(indexOfEndTag, "{\\fn}".Length).Insert(indexOfEndTag, "</font>"); - } - else - { - text += "</font>"; - } - } - } - - if (text.Contains(@"{\fs", StringComparison.Ordinal)) - { - int start = text.IndexOf(@"{\fs", StringComparison.Ordinal); - int end = text.IndexOf('}', start); - if (end > 0 && !text.Substring(start).StartsWith("{\\fs}", StringComparison.Ordinal)) - { - string fontSize = text.Substring(start + 4, end - (start + 4)); - string extraTags = string.Empty; - CheckAndAddSubTags(ref fontSize, ref extraTags, out bool italic); - if (IsInteger(fontSize)) - { - text = text.Remove(start, end - start + 1); - if (italic) - { - text = text.Insert(start, "<font size=\"" + fontSize + "\"" + extraTags + "><i>"); - } - else - { - text = text.Insert(start, "<font size=\"" + fontSize + "\"" + extraTags + ">"); - } - - int indexOfEndTag = text.IndexOf("{\\fs}", start, StringComparison.Ordinal); - if (indexOfEndTag > 0) - { - text = text.Remove(indexOfEndTag, "{\\fs}".Length).Insert(indexOfEndTag, "</font>"); - } - else - { - text += "</font>"; - } - } - } - } - - if (text.Contains(@"{\c", StringComparison.Ordinal)) - { - int start = text.IndexOf(@"{\c", StringComparison.Ordinal); - int end = text.IndexOf('}', start); - if (end > 0 && !text.Substring(start).StartsWith("{\\c}", StringComparison.Ordinal)) - { - string color = text.Substring(start + 4, end - (start + 4)); - string extraTags = string.Empty; - CheckAndAddSubTags(ref color, ref extraTags, out bool italic); - - color = color.Replace("&", string.Empty, StringComparison.Ordinal).TrimStart('H'); - color = color.PadLeft(6, '0'); - - // switch to rrggbb from bbggrr - color = "#" + color.Remove(color.Length - 6) + color.Substring(color.Length - 2, 2) + color.Substring(color.Length - 4, 2) + color.Substring(color.Length - 6, 2); - color = color.ToLowerInvariant(); - - text = text.Remove(start, end - start + 1); - if (italic) - { - text = text.Insert(start, "<font color=\"" + color + "\"" + extraTags + "><i>"); - } - else - { - text = text.Insert(start, "<font color=\"" + color + "\"" + extraTags + ">"); - } - - int indexOfEndTag = text.IndexOf("{\\c}", start, StringComparison.Ordinal); - if (indexOfEndTag > 0) - { - text = text.Remove(indexOfEndTag, "{\\c}".Length).Insert(indexOfEndTag, "</font>"); - } - else - { - text += "</font>"; - } - } - } - - if (text.Contains(@"{\1c", StringComparison.Ordinal)) // "1" specifices primary color - { - int start = text.IndexOf(@"{\1c", StringComparison.Ordinal); - int end = text.IndexOf('}', start); - if (end > 0 && !text.Substring(start).StartsWith("{\\1c}", StringComparison.Ordinal)) - { - string color = text.Substring(start + 5, end - (start + 5)); - string extraTags = string.Empty; - CheckAndAddSubTags(ref color, ref extraTags, out bool italic); - - color = color.Replace("&", string.Empty, StringComparison.Ordinal).TrimStart('H'); - color = color.PadLeft(6, '0'); - - // switch to rrggbb from bbggrr - color = "#" + color.Remove(color.Length - 6) + color.Substring(color.Length - 2, 2) + color.Substring(color.Length - 4, 2) + color.Substring(color.Length - 6, 2); - color = color.ToLowerInvariant(); - - text = text.Remove(start, end - start + 1); - if (italic) - { - text = text.Insert(start, "<font color=\"" + color + "\"" + extraTags + "><i>"); - } - else - { - text = text.Insert(start, "<font color=\"" + color + "\"" + extraTags + ">"); - } - - text += "</font>"; - } - } - } - - text = text.Replace(@"{\i1}", "<i>", StringComparison.Ordinal); - text = text.Replace(@"{\i0}", "</i>", StringComparison.Ordinal); - text = text.Replace(@"{\i}", "</i>", StringComparison.Ordinal); - if (CountTagInText(text, "<i>") > CountTagInText(text, "</i>")) - { - text += "</i>"; - } - - text = text.Replace(@"{\u1}", "<u>", StringComparison.Ordinal); - text = text.Replace(@"{\u0}", "</u>", StringComparison.Ordinal); - text = text.Replace(@"{\u}", "</u>", StringComparison.Ordinal); - if (CountTagInText(text, "<u>") > CountTagInText(text, "</u>")) - { - text += "</u>"; - } - - text = text.Replace(@"{\b1}", "<b>", StringComparison.Ordinal); - text = text.Replace(@"{\b0}", "</b>", StringComparison.Ordinal); - text = text.Replace(@"{\b}", "</b>", StringComparison.Ordinal); - if (CountTagInText(text, "<b>") > CountTagInText(text, "</b>")) - { - text += "</b>"; - } - - return text; - } - - private static bool IsInteger(string s) - => int.TryParse(s, out _); - - private static int CountTagInText(string text, string tag) - { - int count = 0; - int index = text.IndexOf(tag, StringComparison.Ordinal); - while (index >= 0) - { - count++; - if (index == text.Length) - { - return count; - } - - index = text.IndexOf(tag, index + 1, StringComparison.Ordinal); - } - - return count; - } - - private static void CheckAndAddSubTags(ref string tagName, ref string extraTags, out bool italic) - { - italic = false; - int indexOfSPlit = tagName.IndexOf('\\', StringComparison.Ordinal); - if (indexOfSPlit > 0) - { - string rest = tagName.Substring(indexOfSPlit).TrimStart('\\'); - tagName = tagName.Remove(indexOfSPlit); - - for (int i = 0; i < 10; i++) - { - if (rest.StartsWith("fs", StringComparison.Ordinal) && rest.Length > 2) - { - indexOfSPlit = rest.IndexOf('\\', StringComparison.Ordinal); - string fontSize = rest; - if (indexOfSPlit > 0) - { - fontSize = rest.Substring(0, indexOfSPlit); - rest = rest.Substring(indexOfSPlit).TrimStart('\\'); - } - else - { - rest = string.Empty; - } - - extraTags += " size=\"" + fontSize.Substring(2) + "\""; - } - else if (rest.StartsWith("fn", StringComparison.Ordinal) && rest.Length > 2) - { - indexOfSPlit = rest.IndexOf('\\', StringComparison.Ordinal); - string fontName = rest; - if (indexOfSPlit > 0) - { - fontName = rest.Substring(0, indexOfSPlit); - rest = rest.Substring(indexOfSPlit).TrimStart('\\'); - } - else - { - rest = string.Empty; - } - - extraTags += " face=\"" + fontName.Substring(2) + "\""; - } - else if (rest.StartsWith("c", StringComparison.Ordinal) && rest.Length > 2) - { - indexOfSPlit = rest.IndexOf('\\', StringComparison.Ordinal); - string fontColor = rest; - if (indexOfSPlit > 0) - { - fontColor = rest.Substring(0, indexOfSPlit); - rest = rest.Substring(indexOfSPlit).TrimStart('\\'); - } - else - { - rest = string.Empty; - } - - string color = fontColor.Substring(2); - color = color.Replace("&", string.Empty, StringComparison.Ordinal).TrimStart('H'); - color = color.PadLeft(6, '0'); - // switch to rrggbb from bbggrr - color = "#" + color.Remove(color.Length - 6) + color.Substring(color.Length - 2, 2) + color.Substring(color.Length - 4, 2) + color.Substring(color.Length - 6, 2); - color = color.ToLowerInvariant(); - - extraTags += " color=\"" + color + "\""; - } - else if (rest.StartsWith("i1", StringComparison.Ordinal) && rest.Length > 1) - { - indexOfSPlit = rest.IndexOf('\\', StringComparison.Ordinal); - italic = true; - if (indexOfSPlit > 0) - { - rest = rest.Substring(indexOfSPlit).TrimStart('\\'); - } - else - { - rest = string.Empty; - } - } - else if (rest.Length > 0 && rest.Contains('\\', StringComparison.Ordinal)) - { - indexOfSPlit = rest.IndexOf('\\', StringComparison.Ordinal); - rest = rest.Substring(indexOfSPlit).TrimStart('\\'); - } - } - } } } } diff --git a/MediaBrowser.MediaEncoding/Subtitles/SubtitleEditParser.cs b/MediaBrowser.MediaEncoding/Subtitles/SubtitleEditParser.cs new file mode 100644 index 0000000000..24ceb1b571 --- /dev/null +++ b/MediaBrowser.MediaEncoding/Subtitles/SubtitleEditParser.cs @@ -0,0 +1,61 @@ +using System.Globalization; +using System.IO; +using System.Linq; +using System.Threading; +using Jellyfin.Extensions; +using MediaBrowser.Model.MediaInfo; +using Microsoft.Extensions.Logging; +using Nikse.SubtitleEdit.Core; +using ILogger = Microsoft.Extensions.Logging.ILogger; +using SubtitleFormat = Nikse.SubtitleEdit.Core.SubtitleFormats.SubtitleFormat; + +namespace MediaBrowser.MediaEncoding.Subtitles +{ + /// <summary> + /// SubStation Alpha subtitle parser. + /// </summary> + /// <typeparam name="T">The <see cref="SubtitleFormat" />.</typeparam> + public abstract class SubtitleEditParser<T> : ISubtitleParser + where T : SubtitleFormat, new() + { + private readonly ILogger _logger; + + /// <summary> + /// Initializes a new instance of the <see cref="SubtitleEditParser{T}"/> class. + /// </summary> + /// <param name="logger">The logger.</param> + protected SubtitleEditParser(ILogger logger) + { + _logger = logger; + } + + /// <inheritdoc /> + public SubtitleTrackInfo Parse(Stream stream, CancellationToken cancellationToken) + { + var subtitle = new Subtitle(); + var subRip = new T(); + var lines = stream.ReadAllLines().ToList(); + subRip.LoadSubtitle(subtitle, lines, "untitled"); + if (subRip.ErrorCount > 0) + { + _logger.LogError("{ErrorCount} errors encountered while parsing subtitle."); + } + + var trackInfo = new SubtitleTrackInfo(); + int len = subtitle.Paragraphs.Count; + var trackEvents = new SubtitleTrackEvent[len]; + for (int i = 0; i < len; i++) + { + var p = subtitle.Paragraphs[i]; + trackEvents[i] = new SubtitleTrackEvent(p.Number.ToString(CultureInfo.InvariantCulture), p.Text) + { + StartPositionTicks = p.StartTime.TimeSpan.Ticks, + EndPositionTicks = p.EndTime.TimeSpan.Ticks + }; + } + + trackInfo.TrackEvents = trackEvents; + return trackInfo; + } + } +} diff --git a/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs b/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs index 0a9958b9eb..608ebf4436 100644 --- a/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs +++ b/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Concurrent; using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; using System.Globalization; using System.IO; using System.Linq; @@ -25,9 +26,8 @@ using UtfUnknown; namespace MediaBrowser.MediaEncoding.Subtitles { - public class SubtitleEncoder : ISubtitleEncoder + public sealed class SubtitleEncoder : ISubtitleEncoder { - private readonly ILibraryManager _libraryManager; private readonly ILogger<SubtitleEncoder> _logger; private readonly IApplicationPaths _appPaths; private readonly IFileSystem _fileSystem; @@ -42,7 +42,6 @@ namespace MediaBrowser.MediaEncoding.Subtitles new ConcurrentDictionary<string, SemaphoreSlim>(); public SubtitleEncoder( - ILibraryManager libraryManager, ILogger<SubtitleEncoder> logger, IApplicationPaths appPaths, IFileSystem fileSystem, @@ -50,7 +49,6 @@ namespace MediaBrowser.MediaEncoding.Subtitles IHttpClientFactory httpClientFactory, IMediaSourceManager mediaSourceManager) { - _libraryManager = libraryManager; _logger = logger; _appPaths = appPaths; _fileSystem = fileSystem; @@ -74,8 +72,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles try { - var reader = GetReader(inputFormat, true); - + var reader = GetReader(inputFormat); var trackInfo = reader.Parse(stream, cancellationToken); FilterEvents(trackInfo, startTimeTicks, endTimeTicks, preserveOriginalTimestamps); @@ -142,10 +139,9 @@ namespace MediaBrowser.MediaEncoding.Subtitles .ConfigureAwait(false); var inputFormat = subtitle.format; - var writer = TryGetWriter(outputFormat); // Return the original if we don't have any way of converting it - if (writer == null) + if (!TryGetWriter(outputFormat, out var writer)) { return subtitle.stream; } @@ -168,44 +164,25 @@ namespace MediaBrowser.MediaEncoding.Subtitles MediaStream subtitleStream, CancellationToken cancellationToken) { - string[] inputFiles; - - if (mediaSource.VideoType.HasValue - && (mediaSource.VideoType.Value == VideoType.BluRay || mediaSource.VideoType.Value == VideoType.Dvd)) - { - var mediaSourceItem = (Video)_libraryManager.GetItemById(new Guid(mediaSource.Id)); - inputFiles = mediaSourceItem.GetPlayableStreamFileNames(); - } - else - { - inputFiles = new[] { mediaSource.Path }; - } + var fileInfo = await GetReadableFile(mediaSource, subtitleStream, cancellationToken).ConfigureAwait(false); - var protocol = mediaSource.Protocol; - if (subtitleStream.IsExternal) - { - protocol = _mediaSourceManager.GetPathProtocol(subtitleStream.Path); - } - - var fileInfo = await GetReadableFile(mediaSource.Path, inputFiles, protocol, subtitleStream, cancellationToken).ConfigureAwait(false); - - var stream = await GetSubtitleStream(fileInfo.Path, fileInfo.Protocol, fileInfo.IsExternal, cancellationToken).ConfigureAwait(false); + var stream = await GetSubtitleStream(fileInfo, cancellationToken).ConfigureAwait(false); return (stream, fileInfo.Format); } - private async Task<Stream> GetSubtitleStream(string path, MediaProtocol protocol, bool requiresCharset, CancellationToken cancellationToken) + private async Task<Stream> GetSubtitleStream(SubtitleInfo fileInfo, CancellationToken cancellationToken) { - if (requiresCharset) + if (fileInfo.IsExternal) { - using (var stream = await GetStream(path, protocol, cancellationToken).ConfigureAwait(false)) + using (var stream = await GetStream(fileInfo.Path, fileInfo.Protocol, cancellationToken).ConfigureAwait(false)) { var result = CharsetDetector.DetectFromStream(stream).Detected; stream.Position = 0; if (result != null) { - _logger.LogDebug("charset {CharSet} detected for {Path}", result.EncodingName, path); + _logger.LogDebug("charset {CharSet} detected for {Path}", result.EncodingName, fileInfo.Path); using var reader = new StreamReader(stream, result.Encoding); var text = await reader.ReadToEndAsync().ConfigureAwait(false); @@ -215,13 +192,11 @@ namespace MediaBrowser.MediaEncoding.Subtitles } } - return File.OpenRead(path); + return File.OpenRead(fileInfo.Path); } private async Task<SubtitleInfo> GetReadableFile( - string mediaPath, - string[] inputFiles, - MediaProtocol protocol, + MediaSourceInfo mediaSource, MediaStream subtitleStream, CancellationToken cancellationToken) { @@ -252,9 +227,9 @@ namespace MediaBrowser.MediaEncoding.Subtitles } // Extract - var outputPath = GetSubtitleCachePath(mediaPath, protocol, subtitleStream.Index, "." + outputFormat); + var outputPath = GetSubtitleCachePath(mediaSource, subtitleStream.Index, "." + outputFormat); - await ExtractTextSubtitle(inputFiles, protocol, subtitleStream.Index, outputCodec, outputPath, cancellationToken) + await ExtractTextSubtitle(mediaSource, subtitleStream.Index, outputCodec, outputPath, cancellationToken) .ConfigureAwait(false); return new SubtitleInfo(outputPath, MediaProtocol.File, outputFormat, false); @@ -263,50 +238,59 @@ namespace MediaBrowser.MediaEncoding.Subtitles var currentFormat = (Path.GetExtension(subtitleStream.Path) ?? subtitleStream.Codec) .TrimStart('.'); - if (GetReader(currentFormat, false) == null) + if (TryGetReader(currentFormat, out _)) { // Convert - var outputPath = GetSubtitleCachePath(mediaPath, protocol, subtitleStream.Index, ".srt"); + var outputPath = GetSubtitleCachePath(mediaSource, subtitleStream.Index, ".srt"); - await ConvertTextSubtitleToSrt(subtitleStream.Path, subtitleStream.Language, protocol, outputPath, cancellationToken).ConfigureAwait(false); + await ConvertTextSubtitleToSrt(subtitleStream.Path, subtitleStream.Language, mediaSource, outputPath, cancellationToken).ConfigureAwait(false); return new SubtitleInfo(outputPath, MediaProtocol.File, "srt", true); } - return new SubtitleInfo(subtitleStream.Path, protocol, currentFormat, true); - } - - private ISubtitleParser GetReader(string format, bool throwIfMissing) - { - if (string.IsNullOrEmpty(format)) + if (subtitleStream.IsExternal) { - throw new ArgumentNullException(nameof(format)); + return new SubtitleInfo(subtitleStream.Path, _mediaSourceManager.GetPathProtocol(subtitleStream.Path), currentFormat, true); } + return new SubtitleInfo(subtitleStream.Path, mediaSource.Protocol, currentFormat, true); + } + + private bool TryGetReader(string format, [NotNullWhen(true)] out ISubtitleParser? value) + { if (string.Equals(format, SubtitleFormat.SRT, StringComparison.OrdinalIgnoreCase)) { - return new SrtParser(_logger); + value = new SrtParser(_logger); + return true; } if (string.Equals(format, SubtitleFormat.SSA, StringComparison.OrdinalIgnoreCase)) { - return new SsaParser(); + value = new SsaParser(_logger); + return true; } if (string.Equals(format, SubtitleFormat.ASS, StringComparison.OrdinalIgnoreCase)) { - return new AssParser(); + value = new AssParser(_logger); + return true; } - if (throwIfMissing) + value = null; + return false; + } + + private ISubtitleParser GetReader(string format) + { + if (TryGetReader(format, out var reader)) { - throw new ArgumentException("Unsupported format: " + format); + return reader; } - return null; + throw new ArgumentException("Unsupported format: " + format); } - private ISubtitleWriter TryGetWriter(string format) + private bool TryGetWriter(string format, [NotNullWhen(true)] out ISubtitleWriter? value) { if (string.IsNullOrEmpty(format)) { @@ -315,32 +299,35 @@ namespace MediaBrowser.MediaEncoding.Subtitles if (string.Equals(format, "json", StringComparison.OrdinalIgnoreCase)) { - return new JsonWriter(); + value = new JsonWriter(); + return true; } if (string.Equals(format, SubtitleFormat.SRT, StringComparison.OrdinalIgnoreCase)) { - return new SrtWriter(); + value = new SrtWriter(); + return true; } if (string.Equals(format, SubtitleFormat.VTT, StringComparison.OrdinalIgnoreCase)) { - return new VttWriter(); + value = new VttWriter(); + return true; } if (string.Equals(format, SubtitleFormat.TTML, StringComparison.OrdinalIgnoreCase)) { - return new TtmlWriter(); + value = new TtmlWriter(); + return true; } - return null; + value = null; + return false; } private ISubtitleWriter GetWriter(string format) { - var writer = TryGetWriter(format); - - if (writer != null) + if (TryGetWriter(format, out var writer)) { return writer; } @@ -363,11 +350,11 @@ namespace MediaBrowser.MediaEncoding.Subtitles /// </summary> /// <param name="inputPath">The input path.</param> /// <param name="language">The language.</param> - /// <param name="inputProtocol">The input protocol.</param> + /// <param name="mediaSource">The input mediaSource.</param> /// <param name="outputPath">The output path.</param> /// <param name="cancellationToken">The cancellation token.</param> /// <returns>Task.</returns> - private async Task ConvertTextSubtitleToSrt(string inputPath, string language, MediaProtocol inputProtocol, string outputPath, CancellationToken cancellationToken) + private async Task ConvertTextSubtitleToSrt(string inputPath, string language, MediaSourceInfo mediaSource, string outputPath, CancellationToken cancellationToken) { var semaphore = GetLock(outputPath); @@ -377,7 +364,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles { if (!File.Exists(outputPath)) { - await ConvertTextSubtitleToSrtInternal(inputPath, language, inputProtocol, outputPath, cancellationToken).ConfigureAwait(false); + await ConvertTextSubtitleToSrtInternal(inputPath, language, mediaSource, outputPath, cancellationToken).ConfigureAwait(false); } } finally @@ -391,14 +378,14 @@ namespace MediaBrowser.MediaEncoding.Subtitles /// </summary> /// <param name="inputPath">The input path.</param> /// <param name="language">The language.</param> - /// <param name="inputProtocol">The input protocol.</param> + /// <param name="mediaSource">The input mediaSource.</param> /// <param name="outputPath">The output path.</param> /// <param name="cancellationToken">The cancellation token.</param> /// <returns>Task.</returns> /// <exception cref="ArgumentNullException"> /// The <c>inputPath</c> or <c>outputPath</c> is <c>null</c>. /// </exception> - private async Task ConvertTextSubtitleToSrtInternal(string inputPath, string language, MediaProtocol inputProtocol, string outputPath, CancellationToken cancellationToken) + private async Task ConvertTextSubtitleToSrtInternal(string inputPath, string language, MediaSourceInfo mediaSource, string outputPath, CancellationToken cancellationToken) { if (string.IsNullOrEmpty(inputPath)) { @@ -410,9 +397,9 @@ namespace MediaBrowser.MediaEncoding.Subtitles throw new ArgumentNullException(nameof(outputPath)); } - Directory.CreateDirectory(Path.GetDirectoryName(outputPath)); + Directory.CreateDirectory(Path.GetDirectoryName(outputPath) ?? throw new ArgumentException($"Provided path ({outputPath}) is not valid.", nameof(outputPath))); - var encodingParam = await GetSubtitleFileCharacterSet(inputPath, language, inputProtocol, cancellationToken).ConfigureAwait(false); + var encodingParam = await GetSubtitleFileCharacterSet(inputPath, language, mediaSource.Protocol, cancellationToken).ConfigureAwait(false); // FFmpeg automatically convert character encoding when it is UTF-16 // If we specify character encoding, it rejects with "do not specify a character encoding" and "Unable to recode subtitle event" @@ -503,7 +490,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles { _logger.LogError("ffmpeg subtitle conversion failed for {Path}", inputPath); - throw new Exception( + throw new FfmpegException( string.Format(CultureInfo.InvariantCulture, "ffmpeg subtitle conversion failed for {0}", inputPath)); } @@ -515,8 +502,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles /// <summary> /// Extracts the text subtitle. /// </summary> - /// <param name="inputFiles">The input files.</param> - /// <param name="protocol">The protocol.</param> + /// <param name="mediaSource">The mediaSource.</param> /// <param name="subtitleStreamIndex">Index of the subtitle stream.</param> /// <param name="outputCodec">The output codec.</param> /// <param name="outputPath">The output path.</param> @@ -524,8 +510,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles /// <returns>Task.</returns> /// <exception cref="ArgumentException">Must use inputPath list overload.</exception> private async Task ExtractTextSubtitle( - string[] inputFiles, - MediaProtocol protocol, + MediaSourceInfo mediaSource, int subtitleStreamIndex, string outputCodec, string outputPath, @@ -540,7 +525,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles if (!File.Exists(outputPath)) { await ExtractTextSubtitleInternal( - _mediaEncoder.GetInputArgument(inputFiles, protocol), + _mediaEncoder.GetInputArgument(mediaSource.Path, mediaSource), subtitleStreamIndex, outputCodec, outputPath, @@ -570,7 +555,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles throw new ArgumentNullException(nameof(outputPath)); } - Directory.CreateDirectory(Path.GetDirectoryName(outputPath)); + Directory.CreateDirectory(Path.GetDirectoryName(outputPath) ?? throw new ArgumentException($"Provided path ({outputPath}) is not valid.", nameof(outputPath))); var processArgs = string.Format( CultureInfo.InvariantCulture, @@ -658,7 +643,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles _logger.LogError(msg); - throw new Exception(msg); + throw new FfmpegException(msg); } else { @@ -698,7 +683,8 @@ namespace MediaBrowser.MediaEncoding.Subtitles if (!string.Equals(text, newText, StringComparison.Ordinal)) { - using (var fileStream = new FileStream(file, FileMode.Create, FileAccess.Write, FileShare.Read)) + // use FileShare.None as this bypasses dotnet bug dotnet/runtime#42790 . + using (var fileStream = new FileStream(file, FileMode.Create, FileAccess.Write, FileShare.None)) using (var writer = new StreamWriter(fileStream, encoding)) { await writer.WriteAsync(newText.AsMemory(), cancellationToken).ConfigureAwait(false); @@ -706,15 +692,15 @@ namespace MediaBrowser.MediaEncoding.Subtitles } } - private string GetSubtitleCachePath(string mediaPath, MediaProtocol protocol, int subtitleStreamIndex, string outputSubtitleExtension) + private string GetSubtitleCachePath(MediaSourceInfo mediaSource, int subtitleStreamIndex, string outputSubtitleExtension) { - if (protocol == MediaProtocol.File) + if (mediaSource.Protocol == MediaProtocol.File) { var ticksParam = string.Empty; - var date = _fileSystem.GetLastWriteTimeUtc(mediaPath); + var date = _fileSystem.GetLastWriteTimeUtc(mediaSource.Path); - ReadOnlySpan<char> filename = (mediaPath + "_" + subtitleStreamIndex.ToString(CultureInfo.InvariantCulture) + "_" + date.Ticks.ToString(CultureInfo.InvariantCulture) + ticksParam).GetMD5() + outputSubtitleExtension; + ReadOnlySpan<char> filename = (mediaSource.Path + "_" + subtitleStreamIndex.ToString(CultureInfo.InvariantCulture) + "_" + date.Ticks.ToString(CultureInfo.InvariantCulture) + ticksParam).GetMD5() + outputSubtitleExtension; var prefix = filename.Slice(0, 1); @@ -722,7 +708,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles } else { - ReadOnlySpan<char> filename = (mediaPath + "_" + subtitleStreamIndex.ToString(CultureInfo.InvariantCulture)).GetMD5() + outputSubtitleExtension; + ReadOnlySpan<char> filename = (mediaSource.Path + "_" + subtitleStreamIndex.ToString(CultureInfo.InvariantCulture)).GetMD5() + outputSubtitleExtension; var prefix = filename.Slice(0, 1); @@ -735,7 +721,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles { using (var stream = await GetStream(path, protocol, cancellationToken).ConfigureAwait(false)) { - var charset = CharsetDetector.DetectFromStream(stream).Detected?.EncodingName; + var charset = CharsetDetector.DetectFromStream(stream).Detected?.EncodingName ?? string.Empty; // UTF16 is automatically converted to UTF8 by FFmpeg, do not specify a character encoding if ((path.EndsWith(".ass", StringComparison.Ordinal) || path.EndsWith(".ssa", StringComparison.Ordinal) || path.EndsWith(".srt", StringComparison.Ordinal)) @@ -745,7 +731,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles charset = string.Empty; } - _logger.LogDebug("charset {0} detected for {Path}", charset ?? "null", path); + _logger.LogDebug("charset {0} detected for {Path}", charset, path); return charset; } @@ -758,9 +744,9 @@ namespace MediaBrowser.MediaEncoding.Subtitles case MediaProtocol.Http: { using var response = await _httpClientFactory.CreateClient(NamedClient.Default) - .GetAsync(path, cancellationToken) + .GetAsync(new Uri(path), cancellationToken) .ConfigureAwait(false); - return await response.Content.ReadAsStreamAsync().ConfigureAwait(false); + return await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); } case MediaProtocol.File: diff --git a/MediaBrowser.Model/Activity/ActivityLogEntry.cs b/MediaBrowser.Model/Activity/ActivityLogEntry.cs index 1d47ef9f69..f83dde56d4 100644 --- a/MediaBrowser.Model/Activity/ActivityLogEntry.cs +++ b/MediaBrowser.Model/Activity/ActivityLogEntry.cs @@ -1,13 +1,26 @@ -#nullable disable -#pragma warning disable CS1591 - using System; using Microsoft.Extensions.Logging; namespace MediaBrowser.Model.Activity { + /// <summary> + /// An activity log entry. + /// </summary> public class ActivityLogEntry { + /// <summary> + /// Initializes a new instance of the <see cref="ActivityLogEntry"/> class. + /// </summary> + /// <param name="name">The name.</param> + /// <param name="type">The type.</param> + /// <param name="userId">The user id.</param> + public ActivityLogEntry(string name, string type, Guid userId) + { + Name = name; + Type = type; + UserId = userId; + } + /// <summary> /// Gets or sets the identifier. /// </summary> @@ -24,13 +37,13 @@ namespace MediaBrowser.Model.Activity /// Gets or sets the overview. /// </summary> /// <value>The overview.</value> - public string Overview { get; set; } + public string? Overview { get; set; } /// <summary> /// Gets or sets the short overview. /// </summary> /// <value>The short overview.</value> - public string ShortOverview { get; set; } + public string? ShortOverview { get; set; } /// <summary> /// Gets or sets the type. @@ -42,7 +55,7 @@ namespace MediaBrowser.Model.Activity /// Gets or sets the item identifier. /// </summary> /// <value>The item identifier.</value> - public string ItemId { get; set; } + public string? ItemId { get; set; } /// <summary> /// Gets or sets the date. @@ -61,7 +74,7 @@ namespace MediaBrowser.Model.Activity /// </summary> /// <value>The user primary image tag.</value> [Obsolete("UserPrimaryImageTag is not used.")] - public string UserPrimaryImageTag { get; set; } + public string? UserPrimaryImageTag { get; set; } /// <summary> /// Gets or sets the log severity. diff --git a/MediaBrowser.Model/Activity/IActivityManager.cs b/MediaBrowser.Model/Activity/IActivityManager.cs index 3e4ea208ed..28073fb8d7 100644 --- a/MediaBrowser.Model/Activity/IActivityManager.cs +++ b/MediaBrowser.Model/Activity/IActivityManager.cs @@ -16,5 +16,12 @@ namespace MediaBrowser.Model.Activity Task CreateAsync(ActivityLog entry); Task<QueryResult<ActivityLogEntry>> GetPagedResultAsync(ActivityLogQuery query); + + /// <summary> + /// Remove all activity logs before the specified date. + /// </summary> + /// <param name="startDate">Activity log start date.</param> + /// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns> + Task CleanAsync(DateTime startDate); } } diff --git a/MediaBrowser.Model/ApiClient/ServerDiscoveryInfo.cs b/MediaBrowser.Model/ApiClient/ServerDiscoveryInfo.cs index fcc90a1f7a..f9f4745864 100644 --- a/MediaBrowser.Model/ApiClient/ServerDiscoveryInfo.cs +++ b/MediaBrowser.Model/ApiClient/ServerDiscoveryInfo.cs @@ -1,32 +1,43 @@ -#nullable disable -#pragma warning disable CS1591 - namespace MediaBrowser.Model.ApiClient { + /// <summary> + /// The server discovery info model. + /// </summary> public class ServerDiscoveryInfo { /// <summary> - /// Gets or sets the address. + /// Initializes a new instance of the <see cref="ServerDiscoveryInfo"/> class. + /// </summary> + /// <param name="address">The server address.</param> + /// <param name="id">The server id.</param> + /// <param name="name">The server name.</param> + /// <param name="endpointAddress">The endpoint address.</param> + public ServerDiscoveryInfo(string address, string id, string name, string? endpointAddress = null) + { + Address = address; + Id = id; + Name = name; + EndpointAddress = endpointAddress; + } + + /// <summary> + /// Gets the address. /// </summary> - /// <value>The address.</value> - public string Address { get; set; } + public string Address { get; } /// <summary> - /// Gets or sets the server identifier. + /// Gets the server identifier. /// </summary> - /// <value>The server identifier.</value> - public string Id { get; set; } + public string Id { get; } /// <summary> - /// Gets or sets the name. + /// Gets the name. /// </summary> - /// <value>The name.</value> - public string Name { get; set; } + public string Name { get; } /// <summary> - /// Gets or sets the endpoint address. + /// Gets the endpoint address. /// </summary> - /// <value>The endpoint address.</value> - public string EndpointAddress { get; set; } + public string? EndpointAddress { get; } } } diff --git a/MediaBrowser.Model/Branding/BrandingOptions.cs b/MediaBrowser.Model/Branding/BrandingOptions.cs index 5ddf1e7e6e..7f19a5b852 100644 --- a/MediaBrowser.Model/Branding/BrandingOptions.cs +++ b/MediaBrowser.Model/Branding/BrandingOptions.cs @@ -1,4 +1,3 @@ -#nullable disable #pragma warning disable CS1591 namespace MediaBrowser.Model.Branding @@ -9,12 +8,12 @@ namespace MediaBrowser.Model.Branding /// Gets or sets the login disclaimer. /// </summary> /// <value>The login disclaimer.</value> - public string LoginDisclaimer { get; set; } + public string? LoginDisclaimer { get; set; } /// <summary> /// Gets or sets the custom CSS. /// </summary> /// <value>The custom CSS.</value> - public string CustomCss { get; set; } + public string? CustomCss { get; set; } } } diff --git a/MediaBrowser.Model/Channels/ChannelFeatures.cs b/MediaBrowser.Model/Channels/ChannelFeatures.cs index a55754eddc..d925b78b6d 100644 --- a/MediaBrowser.Model/Channels/ChannelFeatures.cs +++ b/MediaBrowser.Model/Channels/ChannelFeatures.cs @@ -7,6 +7,13 @@ namespace MediaBrowser.Model.Channels { public class ChannelFeatures { + public ChannelFeatures() + { + MediaTypes = Array.Empty<ChannelMediaType>(); + ContentTypes = Array.Empty<ChannelMediaContentType>(); + DefaultSortFields = Array.Empty<ChannelItemSortField>(); + } + /// <summary> /// Gets or sets the name. /// </summary> @@ -38,7 +45,7 @@ namespace MediaBrowser.Model.Channels public ChannelMediaContentType[] ContentTypes { get; set; } /// <summary> - /// Represents the maximum number of records the channel allows retrieving at a time. + /// Gets or sets the maximum number of records the channel allows retrieving at a time. /// </summary> public int? MaxPageSize { get; set; } @@ -55,7 +62,7 @@ namespace MediaBrowser.Model.Channels public ChannelItemSortField[] DefaultSortFields { get; set; } /// <summary> - /// Indicates if a sort ascending/descending toggle is supported or not. + /// Gets or sets a value indicating whether a sort ascending/descending toggle is supported. /// </summary> public bool SupportsSortOrderToggle { get; set; } @@ -76,12 +83,5 @@ namespace MediaBrowser.Model.Channels /// </summary> /// <value><c>true</c> if [supports content downloading]; otherwise, <c>false</c>.</value> public bool SupportsContentDownloading { get; set; } - - public ChannelFeatures() - { - MediaTypes = Array.Empty<ChannelMediaType>(); - ContentTypes = Array.Empty<ChannelMediaContentType>(); - DefaultSortFields = Array.Empty<ChannelItemSortField>(); - } } } diff --git a/MediaBrowser.Model/Channels/ChannelInfo.cs b/MediaBrowser.Model/Channels/ChannelInfo.cs deleted file mode 100644 index f2432aaeb2..0000000000 --- a/MediaBrowser.Model/Channels/ChannelInfo.cs +++ /dev/null @@ -1,32 +0,0 @@ -#nullable disable -#pragma warning disable CS1591 - -namespace MediaBrowser.Model.Channels -{ - public class ChannelInfo - { - /// <summary> - /// Gets or sets the name. - /// </summary> - /// <value>The name.</value> - public string Name { get; set; } - - /// <summary> - /// Gets or sets the identifier. - /// </summary> - /// <value>The identifier.</value> - public string Id { get; set; } - - /// <summary> - /// Gets or sets the home page URL. - /// </summary> - /// <value>The home page URL.</value> - public string HomePageUrl { get; set; } - - /// <summary> - /// Gets or sets the features. - /// </summary> - /// <value>The features.</value> - public ChannelFeatures Features { get; set; } - } -} diff --git a/MediaBrowser.Model/Channels/ChannelQuery.cs b/MediaBrowser.Model/Channels/ChannelQuery.cs index fd90e7f062..59966127fb 100644 --- a/MediaBrowser.Model/Channels/ChannelQuery.cs +++ b/MediaBrowser.Model/Channels/ChannelQuery.cs @@ -10,7 +10,7 @@ namespace MediaBrowser.Model.Channels public class ChannelQuery { /// <summary> - /// Fields to return within the items, in addition to basic information. + /// Gets or sets the fields to return within the items, in addition to basic information. /// </summary> /// <value>The fields.</value> public ItemFields[] Fields { get; set; } @@ -28,13 +28,13 @@ namespace MediaBrowser.Model.Channels public Guid UserId { get; set; } /// <summary> - /// Skips over a given number of items within the results. Use for paging. + /// Gets or sets the start index. Use for paging. /// </summary> /// <value>The start index.</value> public int? StartIndex { get; set; } /// <summary> - /// The maximum number of items to return. + /// Gets or sets the maximum number of items to return. /// </summary> /// <value>The limit.</value> public int? Limit { get; set; } diff --git a/MediaBrowser.Model/Configuration/AccessSchedule.cs b/MediaBrowser.Model/Configuration/AccessSchedule.cs deleted file mode 100644 index 7bd355449f..0000000000 --- a/MediaBrowser.Model/Configuration/AccessSchedule.cs +++ /dev/null @@ -1,27 +0,0 @@ -using Jellyfin.Data.Enums; - -#pragma warning disable CS1591 - -namespace MediaBrowser.Model.Configuration -{ - public class AccessSchedule - { - /// <summary> - /// Gets or sets the day of week. - /// </summary> - /// <value>The day of week.</value> - public DynamicDayOfWeek DayOfWeek { get; set; } - - /// <summary> - /// Gets or sets the start hour. - /// </summary> - /// <value>The start hour.</value> - public double StartHour { get; set; } - - /// <summary> - /// Gets or sets the end hour. - /// </summary> - /// <value>The end hour.</value> - public double EndHour { get; set; } - } -} diff --git a/MediaBrowser.Model/Configuration/EncodingOptions.cs b/MediaBrowser.Model/Configuration/EncodingOptions.cs index 2cd637c5b0..365bbeef66 100644 --- a/MediaBrowser.Model/Configuration/EncodingOptions.cs +++ b/MediaBrowser.Model/Configuration/EncodingOptions.cs @@ -5,10 +5,49 @@ namespace MediaBrowser.Model.Configuration { public class EncodingOptions { + public EncodingOptions() + { + EnableFallbackFont = false; + DownMixAudioBoost = 2; + MaxMuxingQueueSize = 2048; + EnableThrottling = false; + ThrottleDelaySeconds = 180; + EncodingThreadCount = -1; + // This is a DRM device that is almost guaranteed to be there on every intel platform, + // plus it's the default one in ffmpeg if you don't specify anything + VaapiDevice = "/dev/dri/renderD128"; + // This is the OpenCL device that is used for tonemapping. + // The left side of the dot is the platform number, and the right side is the device number on the platform. + OpenclDevice = "0.0"; + EnableTonemapping = false; + EnableVppTonemapping = false; + TonemappingAlgorithm = "hable"; + TonemappingRange = "auto"; + TonemappingDesat = 0; + TonemappingThreshold = 0.8; + TonemappingPeak = 100; + TonemappingParam = 0; + H264Crf = 23; + H265Crf = 28; + DeinterlaceDoubleRate = false; + DeinterlaceMethod = "yadif"; + EnableDecodingColorDepth10Hevc = true; + EnableDecodingColorDepth10Vp9 = true; + EnableEnhancedNvdecDecoder = true; + EnableHardwareEncoding = true; + AllowHevcEncoding = false; + EnableSubtitleExtraction = true; + HardwareDecodingCodecs = new string[] { "h264", "vc1" }; + } + public int EncodingThreadCount { get; set; } public string TranscodingTempPath { get; set; } + public string FallbackFontPath { get; set; } + + public bool EnableFallbackFont { get; set; } + public double DownMixAudioBoost { get; set; } public int MaxMuxingQueueSize { get; set; } @@ -20,12 +59,12 @@ namespace MediaBrowser.Model.Configuration public string HardwareAccelerationType { get; set; } /// <summary> - /// FFmpeg path as set by the user via the UI. + /// Gets or sets the FFmpeg path as set by the user via the UI. /// </summary> public string EncoderAppPath { get; set; } /// <summary> - /// The current FFmpeg path being used by the system and displayed on the transcode page. + /// Gets or sets the current FFmpeg path being used by the system and displayed on the transcode page. /// </summary> public string EncoderAppPathDisplay { get; set; } @@ -35,6 +74,8 @@ namespace MediaBrowser.Model.Configuration public bool EnableTonemapping { get; set; } + public bool EnableVppTonemapping { get; set; } + public string TonemappingAlgorithm { get; set; } public string TonemappingRange { get; set; } @@ -61,41 +102,14 @@ namespace MediaBrowser.Model.Configuration public bool EnableDecodingColorDepth10Vp9 { get; set; } + public bool EnableEnhancedNvdecDecoder { get; set; } + public bool EnableHardwareEncoding { get; set; } + public bool AllowHevcEncoding { get; set; } + public bool EnableSubtitleExtraction { get; set; } public string[] HardwareDecodingCodecs { get; set; } - - public EncodingOptions() - { - DownMixAudioBoost = 2; - MaxMuxingQueueSize = 2048; - EnableThrottling = false; - ThrottleDelaySeconds = 180; - EncodingThreadCount = -1; - // This is a DRM device that is almost guaranteed to be there on every intel platform, - // plus it's the default one in ffmpeg if you don't specify anything - VaapiDevice = "/dev/dri/renderD128"; - // This is the OpenCL device that is used for tonemapping. - // The left side of the dot is the platform number, and the right side is the device number on the platform. - OpenclDevice = "0.0"; - EnableTonemapping = false; - TonemappingAlgorithm = "reinhard"; - TonemappingRange = "auto"; - TonemappingDesat = 0; - TonemappingThreshold = 0.8; - TonemappingPeak = 0; - TonemappingParam = 0; - H264Crf = 23; - H265Crf = 28; - DeinterlaceDoubleRate = false; - DeinterlaceMethod = "yadif"; - EnableDecodingColorDepth10Hevc = true; - EnableDecodingColorDepth10Vp9 = true; - EnableHardwareEncoding = true; - EnableSubtitleExtraction = true; - HardwareDecodingCodecs = new string[] { "h264", "vc1" }; - } } } diff --git a/MediaBrowser.Model/Configuration/ImageOption.cs b/MediaBrowser.Model/Configuration/ImageOption.cs index 2b1268c743..0af7b7e146 100644 --- a/MediaBrowser.Model/Configuration/ImageOption.cs +++ b/MediaBrowser.Model/Configuration/ImageOption.cs @@ -6,6 +6,11 @@ namespace MediaBrowser.Model.Configuration { public class ImageOption { + public ImageOption() + { + Limit = 1; + } + /// <summary> /// Gets or sets the type. /// </summary> @@ -23,10 +28,5 @@ namespace MediaBrowser.Model.Configuration /// </summary> /// <value>The minimum width.</value> public int MinWidth { get; set; } - - public ImageOption() - { - Limit = 1; - } } } diff --git a/MediaBrowser.Model/Configuration/LibraryOptions.cs b/MediaBrowser.Model/Configuration/LibraryOptions.cs index 54ef49ea62..24698360ec 100644 --- a/MediaBrowser.Model/Configuration/LibraryOptions.cs +++ b/MediaBrowser.Model/Configuration/LibraryOptions.cs @@ -2,13 +2,30 @@ #pragma warning disable CS1591 using System; -using System.Collections.Generic; -using MediaBrowser.Model.Entities; namespace MediaBrowser.Model.Configuration { public class LibraryOptions { + public LibraryOptions() + { + TypeOptions = Array.Empty<TypeOptions>(); + DisabledSubtitleFetchers = Array.Empty<string>(); + SubtitleFetcherOrder = Array.Empty<string>(); + DisabledLocalMetadataReaders = Array.Empty<string>(); + + SkipSubtitlesIfAudioTrackMatches = true; + RequirePerfectSubtitleMatch = true; + + EnablePhotos = true; + SaveSubtitlesWithMedia = true; + EnableRealtimeMonitor = true; + PathInfos = Array.Empty<MediaPathInfo>(); + EnableInternetProviders = true; + EnableAutomaticSeriesGrouping = true; + SeasonZeroDisplayName = "Specials"; + } + public bool EnablePhotos { get; set; } public bool EnableRealtimeMonitor { get; set; } @@ -17,8 +34,6 @@ namespace MediaBrowser.Model.Configuration public bool ExtractChapterImagesDuringLibraryScan { get; set; } - public bool DownloadImagesInAdvance { get; set; } - public MediaPathInfo[] PathInfos { get; set; } public bool SaveLocalMetadata { get; set; } @@ -81,387 +96,5 @@ namespace MediaBrowser.Model.Configuration return null; } - - public LibraryOptions() - { - TypeOptions = Array.Empty<TypeOptions>(); - DisabledSubtitleFetchers = Array.Empty<string>(); - SubtitleFetcherOrder = Array.Empty<string>(); - DisabledLocalMetadataReaders = Array.Empty<string>(); - - SkipSubtitlesIfAudioTrackMatches = true; - RequirePerfectSubtitleMatch = true; - - EnablePhotos = true; - SaveSubtitlesWithMedia = true; - EnableRealtimeMonitor = true; - PathInfos = Array.Empty<MediaPathInfo>(); - EnableInternetProviders = true; - EnableAutomaticSeriesGrouping = true; - SeasonZeroDisplayName = "Specials"; - } - } - - public class MediaPathInfo - { - public string Path { get; set; } - - public string NetworkPath { get; set; } - } - - public class TypeOptions - { - public string Type { get; set; } - - public string[] MetadataFetchers { get; set; } - - public string[] MetadataFetcherOrder { get; set; } - - public string[] ImageFetchers { get; set; } - - public string[] ImageFetcherOrder { get; set; } - - public ImageOption[] ImageOptions { get; set; } - - public ImageOption GetImageOptions(ImageType type) - { - foreach (var i in ImageOptions) - { - if (i.Type == type) - { - return i; - } - } - - if (DefaultImageOptions.TryGetValue(Type, out ImageOption[] options)) - { - foreach (var i in options) - { - if (i.Type == type) - { - return i; - } - } - } - - return DefaultInstance; - } - - public int GetLimit(ImageType type) - { - return GetImageOptions(type).Limit; - } - - public int GetMinWidth(ImageType type) - { - return GetImageOptions(type).MinWidth; - } - - public bool IsEnabled(ImageType type) - { - return GetLimit(type) > 0; - } - - public TypeOptions() - { - MetadataFetchers = Array.Empty<string>(); - MetadataFetcherOrder = Array.Empty<string>(); - ImageFetchers = Array.Empty<string>(); - ImageFetcherOrder = Array.Empty<string>(); - ImageOptions = Array.Empty<ImageOption>(); - } - - public static Dictionary<string, ImageOption[]> DefaultImageOptions = new Dictionary<string, ImageOption[]> - { - { - "Movie", new [] - { - new ImageOption - { - Limit = 1, - MinWidth = 1280, - Type = ImageType.Backdrop - }, - - // Don't download this by default as it's rarely used. - new ImageOption - { - Limit = 0, - Type = ImageType.Art - }, - - // Don't download this by default as it's rarely used. - new ImageOption - { - Limit = 0, - Type = ImageType.Disc - }, - - new ImageOption - { - Limit = 1, - Type = ImageType.Primary - }, - - new ImageOption - { - Limit = 0, - Type = ImageType.Banner - }, - - new ImageOption - { - Limit = 1, - Type = ImageType.Thumb - }, - - new ImageOption - { - Limit = 1, - Type = ImageType.Logo - } - } - }, - { - "MusicVideo", new [] - { - new ImageOption - { - Limit = 1, - MinWidth = 1280, - Type = ImageType.Backdrop - }, - - // Don't download this by default as it's rarely used. - new ImageOption - { - Limit = 0, - Type = ImageType.Art - }, - - // Don't download this by default as it's rarely used. - new ImageOption - { - Limit = 0, - Type = ImageType.Disc - }, - - new ImageOption - { - Limit = 1, - Type = ImageType.Primary - }, - - new ImageOption - { - Limit = 0, - Type = ImageType.Banner - }, - - new ImageOption - { - Limit = 1, - Type = ImageType.Thumb - }, - - new ImageOption - { - Limit = 1, - Type = ImageType.Logo - } - } - }, - { - "Series", new [] - { - new ImageOption - { - Limit = 1, - MinWidth = 1280, - Type = ImageType.Backdrop - }, - - // Don't download this by default as it's rarely used. - new ImageOption - { - Limit = 0, - Type = ImageType.Art - }, - - new ImageOption - { - Limit = 1, - Type = ImageType.Primary - }, - - new ImageOption - { - Limit = 1, - Type = ImageType.Banner - }, - - new ImageOption - { - Limit = 1, - Type = ImageType.Thumb - }, - - new ImageOption - { - Limit = 1, - Type = ImageType.Logo - } - } - }, - { - "MusicAlbum", new [] - { - new ImageOption - { - Limit = 0, - MinWidth = 1280, - Type = ImageType.Backdrop - }, - - // Don't download this by default as it's rarely used. - new ImageOption - { - Limit = 0, - Type = ImageType.Disc - } - } - }, - { - "MusicArtist", new [] - { - new ImageOption - { - Limit = 1, - MinWidth = 1280, - Type = ImageType.Backdrop - }, - - // Don't download this by default - // They do look great, but most artists won't have them, which means a banner view isn't really possible - new ImageOption - { - Limit = 0, - Type = ImageType.Banner - }, - - // Don't download this by default - // Generally not used - new ImageOption - { - Limit = 0, - Type = ImageType.Art - }, - - new ImageOption - { - Limit = 1, - Type = ImageType.Logo - } - } - }, - { - "BoxSet", new [] - { - new ImageOption - { - Limit = 1, - MinWidth = 1280, - Type = ImageType.Backdrop - }, - - new ImageOption - { - Limit = 1, - Type = ImageType.Primary - }, - - new ImageOption - { - Limit = 1, - Type = ImageType.Thumb - }, - - new ImageOption - { - Limit = 1, - Type = ImageType.Logo - }, - - // Don't download this by default as it's rarely used. - new ImageOption - { - Limit = 0, - Type = ImageType.Art - }, - - // Don't download this by default as it's rarely used. - new ImageOption - { - Limit = 0, - Type = ImageType.Disc - }, - - // Don't download this by default as it's rarely used. - new ImageOption - { - Limit = 0, - Type = ImageType.Banner - } - } - }, - { - "Season", new [] - { - new ImageOption - { - Limit = 0, - MinWidth = 1280, - Type = ImageType.Backdrop - }, - - new ImageOption - { - Limit = 1, - Type = ImageType.Primary - }, - - new ImageOption - { - Limit = 0, - Type = ImageType.Banner - }, - - new ImageOption - { - Limit = 0, - Type = ImageType.Thumb - } - } - }, - { - "Episode", new [] - { - new ImageOption - { - Limit = 0, - MinWidth = 1280, - Type = ImageType.Backdrop - }, - - new ImageOption - { - Limit = 1, - Type = ImageType.Primary - } - } - } - }; - - public static ImageOption DefaultInstance = new ImageOption(); } } diff --git a/MediaBrowser.Model/Configuration/MediaPathInfo.cs b/MediaBrowser.Model/Configuration/MediaPathInfo.cs new file mode 100644 index 0000000000..4f311c58f0 --- /dev/null +++ b/MediaBrowser.Model/Configuration/MediaPathInfo.cs @@ -0,0 +1,12 @@ +#nullable disable +#pragma warning disable CS1591 + +namespace MediaBrowser.Model.Configuration +{ + public class MediaPathInfo + { + public string Path { get; set; } + + public string NetworkPath { get; set; } + } +} diff --git a/MediaBrowser.Model/Configuration/MetadataConfiguration.cs b/MediaBrowser.Model/Configuration/MetadataConfiguration.cs index 706831bddc..be044243dd 100644 --- a/MediaBrowser.Model/Configuration/MetadataConfiguration.cs +++ b/MediaBrowser.Model/Configuration/MetadataConfiguration.cs @@ -4,11 +4,11 @@ namespace MediaBrowser.Model.Configuration { public class MetadataConfiguration { - public bool UseFileCreationTimeForDateAdded { get; set; } - public MetadataConfiguration() { UseFileCreationTimeForDateAdded = true; } + + public bool UseFileCreationTimeForDateAdded { get; set; } } } diff --git a/MediaBrowser.Model/Configuration/MetadataOptions.cs b/MediaBrowser.Model/Configuration/MetadataOptions.cs index e7dc3da3cb..76b72bd08e 100644 --- a/MediaBrowser.Model/Configuration/MetadataOptions.cs +++ b/MediaBrowser.Model/Configuration/MetadataOptions.cs @@ -10,6 +10,16 @@ namespace MediaBrowser.Model.Configuration /// </summary> public class MetadataOptions { + public MetadataOptions() + { + DisabledMetadataSavers = Array.Empty<string>(); + LocalMetadataReaderOrder = Array.Empty<string>(); + DisabledMetadataFetchers = Array.Empty<string>(); + MetadataFetcherOrder = Array.Empty<string>(); + DisabledImageFetchers = Array.Empty<string>(); + ImageFetcherOrder = Array.Empty<string>(); + } + public string ItemType { get; set; } public string[] DisabledMetadataSavers { get; set; } @@ -23,15 +33,5 @@ namespace MediaBrowser.Model.Configuration public string[] DisabledImageFetchers { get; set; } public string[] ImageFetcherOrder { get; set; } - - public MetadataOptions() - { - DisabledMetadataSavers = Array.Empty<string>(); - LocalMetadataReaderOrder = Array.Empty<string>(); - DisabledMetadataFetchers = Array.Empty<string>(); - MetadataFetcherOrder = Array.Empty<string>(); - DisabledImageFetchers = Array.Empty<string>(); - ImageFetcherOrder = Array.Empty<string>(); - } } } diff --git a/MediaBrowser.Model/Configuration/MetadataPluginSummary.cs b/MediaBrowser.Model/Configuration/MetadataPluginSummary.cs index 0c197ee021..aa07d66237 100644 --- a/MediaBrowser.Model/Configuration/MetadataPluginSummary.cs +++ b/MediaBrowser.Model/Configuration/MetadataPluginSummary.cs @@ -8,6 +8,12 @@ namespace MediaBrowser.Model.Configuration { public class MetadataPluginSummary { + public MetadataPluginSummary() + { + SupportedImageTypes = Array.Empty<ImageType>(); + Plugins = Array.Empty<MetadataPlugin>(); + } + /// <summary> /// Gets or sets the type of the item. /// </summary> @@ -25,11 +31,5 @@ namespace MediaBrowser.Model.Configuration /// </summary> /// <value>The supported image types.</value> public ImageType[] SupportedImageTypes { get; set; } - - public MetadataPluginSummary() - { - SupportedImageTypes = Array.Empty<ImageType>(); - Plugins = Array.Empty<MetadataPlugin>(); - } } } diff --git a/MediaBrowser.Model/Configuration/PathSubstitution.cs b/MediaBrowser.Model/Configuration/PathSubstitution.cs new file mode 100644 index 0000000000..2c9b5f005c --- /dev/null +++ b/MediaBrowser.Model/Configuration/PathSubstitution.cs @@ -0,0 +1,18 @@ +namespace MediaBrowser.Model.Configuration +{ + /// <summary> + /// Defines the <see cref="PathSubstitution" />. + /// </summary> + public class PathSubstitution + { + /// <summary> + /// Gets or sets the value to substitute. + /// </summary> + public string From { get; set; } = string.Empty; + + /// <summary> + /// Gets or sets the value to substitution with. + /// </summary> + public string To { get; set; } = string.Empty; + } +} diff --git a/MediaBrowser.Model/Configuration/ServerConfiguration.cs b/MediaBrowser.Model/Configuration/ServerConfiguration.cs index 3b51a0a360..9ec936eda1 100644 --- a/MediaBrowser.Model/Configuration/ServerConfiguration.cs +++ b/MediaBrowser.Model/Configuration/ServerConfiguration.cs @@ -1,5 +1,5 @@ -#nullable disable #pragma warning disable CS1591 +#pragma warning disable CA1819 using System; using System.Collections.Generic; @@ -13,44 +13,194 @@ namespace MediaBrowser.Model.Configuration /// </summary> public class ServerConfiguration : BaseApplicationConfiguration { + /// <summary> + /// The default value for <see cref="HttpServerPortNumber"/>. + /// </summary> public const int DefaultHttpPort = 8096; - public const int DefaultManagementPort = 12000; + + /// <summary> + /// The default value for <see cref="PublicHttpsPort"/> and <see cref="HttpsPortNumber"/>. + /// </summary> public const int DefaultHttpsPort = 8920; - private string _baseUrl; + + /// <summary> + /// The default port for the management API section. + /// </summary> + public const int DefaultManagementPort = 12000; + + private string _baseUrl = string.Empty; + + /// <summary> + /// Initializes a new instance of the <see cref="ServerConfiguration" /> class. + /// </summary> + public ServerConfiguration() + { + MetadataOptions = new[] + { + new MetadataOptions() + { + ItemType = "Book" + }, + new MetadataOptions() + { + ItemType = "Movie" + }, + new MetadataOptions + { + ItemType = "MusicVideo", + DisabledMetadataFetchers = new[] { "The Open Movie Database" }, + DisabledImageFetchers = new[] { "The Open Movie Database" } + }, + new MetadataOptions + { + ItemType = "Series", + }, + new MetadataOptions + { + ItemType = "MusicAlbum", + DisabledMetadataFetchers = new[] { "TheAudioDB" } + }, + new MetadataOptions + { + ItemType = "MusicArtist", + DisabledMetadataFetchers = new[] { "TheAudioDB" } + }, + new MetadataOptions + { + ItemType = "BoxSet" + }, + new MetadataOptions + { + ItemType = "Season", + }, + new MetadataOptions + { + ItemType = "Episode", + } + }; + } /// <summary> /// Gets or sets a value indicating whether to enable automatic port forwarding. /// </summary> - public bool EnableUPnP { get; set; } + public bool EnableUPnP { get; set; } = false; /// <summary> /// Gets or sets a value indicating whether to enable prometheus metrics exporting. /// </summary> - public bool EnableMetrics { get; set; } + public bool EnableMetrics { get; set; } = false; /// <summary> /// Gets or sets the public mapped port. /// </summary> /// <value>The public mapped port.</value> - public int PublicPort { get; set; } + public int PublicPort { get; set; } = DefaultHttpPort; + + /// <summary> + /// Gets or sets a value indicating whether the http port should be mapped as part of UPnP automatic port forwarding. + /// </summary> + public bool UPnPCreateHttpPortMap { get; set; } = false; + + /// <summary> + /// Gets or sets client udp port range. + /// </summary> + public string UDPPortRange { get; set; } = string.Empty; + + /// <summary> + /// Gets or sets a value indicating whether IPV6 capability is enabled. + /// </summary> + public bool EnableIPV6 { get; set; } = false; + + /// <summary> + /// Gets or sets a value indicating whether IPV4 capability is enabled. + /// </summary> + public bool EnableIPV4 { get; set; } = true; + + /// <summary> + /// Gets or sets a value indicating whether detailed ssdp logs are sent to the console/log. + /// "Emby.Dlna": "Debug" must be set in logging.default.json for this property to work. + /// </summary> + public bool EnableSSDPTracing { get; set; } = false; + + /// <summary> + /// Gets or sets a value indicating whether an IP address is to be used to filter the detailed ssdp logs that are being sent to the console/log. + /// If the setting "Emby.Dlna": "Debug" msut be set in logging.default.json for this property to work. + /// </summary> + public string SSDPTracingFilter { get; set; } = string.Empty; + + /// <summary> + /// Gets or sets the number of times SSDP UDP messages are sent. + /// </summary> + public int UDPSendCount { get; set; } = 2; + + /// <summary> + /// Gets or sets the delay between each groups of SSDP messages (in ms). + /// </summary> + public int UDPSendDelay { get; set; } = 100; + + /// <summary> + /// Gets or sets a value indicating whether address names that match <see cref="VirtualInterfaceNames"/> should be Ignore for the purposes of binding. + /// </summary> + public bool IgnoreVirtualInterfaces { get; set; } = true; + + /// <summary> + /// Gets or sets a value indicating the interfaces that should be ignored. The list can be comma separated. <seealso cref="IgnoreVirtualInterfaces"/>. + /// </summary> + public string VirtualInterfaceNames { get; set; } = "vEthernet*"; + + /// <summary> + /// Gets or sets the time (in seconds) between the pings of SSDP gateway monitor. + /// </summary> + public int GatewayMonitorPeriod { get; set; } = 60; + + /// <summary> + /// Gets a value indicating whether multi-socket binding is available. + /// </summary> + public bool EnableMultiSocketBinding { get; } = true; + + /// <summary> + /// Gets or sets a value indicating whether all IPv6 interfaces should be treated as on the internal network. + /// Depending on the address range implemented ULA ranges might not be used. + /// </summary> + public bool TrustAllIP6Interfaces { get; set; } = false; + + /// <summary> + /// Gets or sets the ports that HDHomerun uses. + /// </summary> + public string HDHomerunPortRange { get; set; } = string.Empty; + + /// <summary> + /// Gets or sets PublishedServerUri to advertise for specific subnets. + /// </summary> + public string[] PublishedServerUriBySubnet { get; set; } = Array.Empty<string>(); + + /// <summary> + /// Gets or sets a value indicating whether Autodiscovery tracing is enabled. + /// </summary> + public bool AutoDiscoveryTracing { get; set; } = false; + + /// <summary> + /// Gets or sets a value indicating whether Autodiscovery is enabled. + /// </summary> + public bool AutoDiscovery { get; set; } = true; /// <summary> /// Gets or sets the public HTTPS port. /// </summary> /// <value>The public HTTPS port.</value> - public int PublicHttpsPort { get; set; } + public int PublicHttpsPort { get; set; } = DefaultHttpsPort; /// <summary> /// Gets or sets the HTTP server port number. /// </summary> /// <value>The HTTP server port number.</value> - public int HttpServerPortNumber { get; set; } + public int HttpServerPortNumber { get; set; } = DefaultHttpPort; /// <summary> /// Gets or sets the HTTPS server port number. /// </summary> /// <value>The HTTPS server port number.</value> - public int HttpsPortNumber { get; set; } + public int HttpsPortNumber { get; set; } = DefaultHttpsPort; /// <summary> /// Gets or sets a value indicating whether to use HTTPS. @@ -59,19 +209,19 @@ namespace MediaBrowser.Model.Configuration /// In order for HTTPS to be used, in addition to setting this to true, valid values must also be /// provided for <see cref="CertificatePath"/> and <see cref="CertificatePassword"/>. /// </remarks> - public bool EnableHttps { get; set; } + public bool EnableHttps { get; set; } = false; - public bool EnableNormalizedItemByNameIds { get; set; } + public bool EnableNormalizedItemByNameIds { get; set; } = true; /// <summary> /// Gets or sets the filesystem path of an X.509 certificate to use for SSL. /// </summary> - public string CertificatePath { get; set; } + public string CertificatePath { get; set; } = string.Empty; /// <summary> /// Gets or sets the password required to access the X.509 certificate data in the file specified by <see cref="CertificatePath"/>. /// </summary> - public string CertificatePassword { get; set; } + public string CertificatePassword { get; set; } = string.Empty; /// <summary> /// Gets or sets a value indicating whether this instance is port authorized. @@ -80,90 +230,105 @@ namespace MediaBrowser.Model.Configuration public bool IsPortAuthorized { get; set; } /// <summary> - /// Gets or sets if quick connect is available for use on this server. + /// Gets or sets a value indicating whether quick connect is available for use on this server. /// </summary> - public bool QuickConnectAvailable { get; set; } + public bool QuickConnectAvailable { get; set; } = false; - public bool EnableRemoteAccess { get; set; } + /// <summary> + /// Gets or sets a value indicating whether access outside of the LAN is permitted. + /// </summary> + public bool EnableRemoteAccess { get; set; } = true; /// <summary> /// Gets or sets a value indicating whether [enable case sensitive item ids]. /// </summary> /// <value><c>true</c> if [enable case sensitive item ids]; otherwise, <c>false</c>.</value> - public bool EnableCaseSensitiveItemIds { get; set; } + public bool EnableCaseSensitiveItemIds { get; set; } = true; - public bool DisableLiveTvChannelUserDataName { get; set; } + public bool DisableLiveTvChannelUserDataName { get; set; } = true; /// <summary> /// Gets or sets the metadata path. /// </summary> /// <value>The metadata path.</value> - public string MetadataPath { get; set; } + public string MetadataPath { get; set; } = string.Empty; - public string MetadataNetworkPath { get; set; } + public string MetadataNetworkPath { get; set; } = string.Empty; /// <summary> /// Gets or sets the preferred metadata language. /// </summary> /// <value>The preferred metadata language.</value> - public string PreferredMetadataLanguage { get; set; } + public string PreferredMetadataLanguage { get; set; } = "en"; /// <summary> /// Gets or sets the metadata country code. /// </summary> /// <value>The metadata country code.</value> - public string MetadataCountryCode { get; set; } + public string MetadataCountryCode { get; set; } = "US"; /// <summary> - /// Characters to be replaced with a ' ' in strings to create a sort name. + /// Gets or sets characters to be replaced with a ' ' in strings to create a sort name. /// </summary> /// <value>The sort replace characters.</value> - public string[] SortReplaceCharacters { get; set; } + public string[] SortReplaceCharacters { get; set; } = new[] { ".", "+", "%" }; /// <summary> - /// Characters to be removed from strings to create a sort name. + /// Gets or sets characters to be removed from strings to create a sort name. /// </summary> /// <value>The sort remove characters.</value> - public string[] SortRemoveCharacters { get; set; } + public string[] SortRemoveCharacters { get; set; } = new[] { ",", "&", "-", "{", "}", "'" }; /// <summary> - /// Words to be removed from strings to create a sort name. + /// Gets or sets words to be removed from strings to create a sort name. /// </summary> /// <value>The sort remove words.</value> - public string[] SortRemoveWords { get; set; } + public string[] SortRemoveWords { get; set; } = new[] { "the", "a", "an" }; /// <summary> /// Gets or sets the minimum percentage of an item that must be played in order for playstate to be updated. /// </summary> /// <value>The min resume PCT.</value> - public int MinResumePct { get; set; } + public int MinResumePct { get; set; } = 5; /// <summary> /// Gets or sets the maximum percentage of an item that can be played while still saving playstate. If this percentage is crossed playstate will be reset to the beginning and the item will be marked watched. /// </summary> /// <value>The max resume PCT.</value> - public int MaxResumePct { get; set; } + public int MaxResumePct { get; set; } = 90; /// <summary> /// Gets or sets the minimum duration that an item must have in order to be eligible for playstate updates.. /// </summary> /// <value>The min resume duration seconds.</value> - public int MinResumeDurationSeconds { get; set; } + public int MinResumeDurationSeconds { get; set; } = 300; + + /// <summary> + /// Gets or sets the minimum minutes of a book that must be played in order for playstate to be updated. + /// </summary> + /// <value>The min resume in minutes.</value> + public int MinAudiobookResume { get; set; } = 5; /// <summary> - /// The delay in seconds that we will wait after a file system change to try and discover what has been added/removed + /// Gets or sets the remaining minutes of a book that can be played while still saving playstate. If this percentage is crossed playstate will be reset to the beginning and the item will be marked watched. + /// </summary> + /// <value>The remaining time in minutes.</value> + public int MaxAudiobookResume { get; set; } = 5; + + /// <summary> + /// Gets or sets the delay in seconds that we will wait after a file system change to try and discover what has been added/removed /// Some delay is necessary with some items because their creation is not atomic. It involves the creation of several /// different directories and files. /// </summary> /// <value>The file watcher delay.</value> - public int LibraryMonitorDelay { get; set; } + public int LibraryMonitorDelay { get; set; } = 60; /// <summary> /// Gets or sets a value indicating whether [enable dashboard response caching]. /// Allows potential contributors without visual studio to modify production dashboard code and test changes. /// </summary> /// <value><c>true</c> if [enable dashboard response caching]; otherwise, <c>false</c>.</value> - public bool EnableDashboardResponseCaching { get; set; } + public bool EnableDashboardResponseCaching { get; set; } = true; /// <summary> /// Gets or sets the image saving convention. @@ -173,9 +338,9 @@ namespace MediaBrowser.Model.Configuration public MetadataOptions[] MetadataOptions { get; set; } - public bool SkipDeserializationForBasicTypes { get; set; } + public bool SkipDeserializationForBasicTypes { get; set; } = true; - public string ServerName { get; set; } + public string ServerName { get; set; } = string.Empty; public string BaseUrl { @@ -207,188 +372,97 @@ namespace MediaBrowser.Model.Configuration } } - public string UICulture { get; set; } + public string UICulture { get; set; } = "en-US"; - public bool SaveMetadataHidden { get; set; } + public bool SaveMetadataHidden { get; set; } = false; - public NameValuePair[] ContentTypes { get; set; } + public NameValuePair[] ContentTypes { get; set; } = Array.Empty<NameValuePair>(); - public int RemoteClientBitrateLimit { get; set; } + public int RemoteClientBitrateLimit { get; set; } = 0; - public bool EnableFolderView { get; set; } + public bool EnableFolderView { get; set; } = false; - public bool EnableGroupingIntoCollections { get; set; } + public bool EnableGroupingIntoCollections { get; set; } = false; - public bool DisplaySpecialsWithinSeasons { get; set; } + public bool DisplaySpecialsWithinSeasons { get; set; } = true; - public string[] LocalNetworkSubnets { get; set; } - - public string[] LocalNetworkAddresses { get; set; } + /// <summary> + /// Gets or sets the subnets that are deemed to make up the LAN. + /// </summary> + public string[] LocalNetworkSubnets { get; set; } = Array.Empty<string>(); - public string[] CodecsUsed { get; set; } + /// <summary> + /// Gets or sets the interface addresses which Jellyfin will bind to. If empty, all interfaces will be used. + /// </summary> + public string[] LocalNetworkAddresses { get; set; } = Array.Empty<string>(); - public List<RepositoryInfo> PluginRepositories { get; set; } + public string[] CodecsUsed { get; set; } = Array.Empty<string>(); - public bool IgnoreVirtualInterfaces { get; set; } + public List<RepositoryInfo> PluginRepositories { get; set; } = new List<RepositoryInfo>(); - public bool EnableExternalContentInSuggestions { get; set; } + public bool EnableExternalContentInSuggestions { get; set; } = true; /// <summary> /// Gets or sets a value indicating whether the server should force connections over HTTPS. /// </summary> - public bool RequireHttps { get; set; } - - public bool EnableNewOmdbSupport { get; set; } + public bool RequireHttps { get; set; } = false; - public string[] RemoteIPFilter { get; set; } + public bool EnableNewOmdbSupport { get; set; } = true; - public bool IsRemoteIPFilterBlacklist { get; set; } + /// <summary> + /// Gets or sets the filter for remote IP connectivity. Used in conjuntion with <seealso cref="IsRemoteIPFilterBlacklist"/>. + /// </summary> + public string[] RemoteIPFilter { get; set; } = Array.Empty<string>(); - public int ImageExtractionTimeoutMs { get; set; } + /// <summary> + /// Gets or sets a value indicating whether <seealso cref="RemoteIPFilter"/> contains a blacklist or a whitelist. Default is a whitelist. + /// </summary> + public bool IsRemoteIPFilterBlacklist { get; set; } = false; - public PathSubstitution[] PathSubstitutions { get; set; } + public int ImageExtractionTimeoutMs { get; set; } = 0; - public bool EnableSimpleArtistDetection { get; set; } + public PathSubstitution[] PathSubstitutions { get; set; } = Array.Empty<PathSubstitution>(); - public string[] UninstalledPlugins { get; set; } + public string[] UninstalledPlugins { get; set; } = Array.Empty<string>(); /// <summary> /// Gets or sets a value indicating whether slow server responses should be logged as a warning. /// </summary> - public bool EnableSlowResponseWarning { get; set; } + public bool EnableSlowResponseWarning { get; set; } = true; /// <summary> /// Gets or sets the threshold for the slow response time warning in ms. /// </summary> - public long SlowResponseThresholdMs { get; set; } + public long SlowResponseThresholdMs { get; set; } = 500; /// <summary> /// Gets or sets the cors hosts. /// </summary> - public string[] CorsHosts { get; set; } + public string[] CorsHosts { get; set; } = new[] { "*" }; /// <summary> /// Gets or sets the known proxies. /// </summary> - public string[] KnownProxies { get; set; } + public string[] KnownProxies { get; set; } = Array.Empty<string>(); /// <summary> - /// Initializes a new instance of the <see cref="ServerConfiguration" /> class. + /// Gets or sets the number of days we should retain activity logs. /// </summary> - public ServerConfiguration() - { - UninstalledPlugins = Array.Empty<string>(); - RemoteIPFilter = Array.Empty<string>(); - LocalNetworkSubnets = Array.Empty<string>(); - LocalNetworkAddresses = Array.Empty<string>(); - CodecsUsed = Array.Empty<string>(); - PathSubstitutions = Array.Empty<PathSubstitution>(); - IgnoreVirtualInterfaces = false; - EnableSimpleArtistDetection = false; - SkipDeserializationForBasicTypes = true; - - PluginRepositories = new List<RepositoryInfo>(); - - DisplaySpecialsWithinSeasons = true; - EnableExternalContentInSuggestions = true; - - ImageSavingConvention = ImageSavingConvention.Compatible; - PublicPort = DefaultHttpPort; - PublicHttpsPort = DefaultHttpsPort; - HttpServerPortNumber = DefaultHttpPort; - HttpsPortNumber = DefaultHttpsPort; - EnableMetrics = false; - EnableHttps = false; - EnableDashboardResponseCaching = true; - EnableCaseSensitiveItemIds = true; - EnableNormalizedItemByNameIds = true; - DisableLiveTvChannelUserDataName = true; - EnableNewOmdbSupport = true; - - EnableRemoteAccess = true; - QuickConnectAvailable = false; - - EnableUPnP = false; - MinResumePct = 5; - MaxResumePct = 90; - - // 5 minutes - MinResumeDurationSeconds = 300; - - LibraryMonitorDelay = 60; - - ContentTypes = Array.Empty<NameValuePair>(); - - PreferredMetadataLanguage = "en"; - MetadataCountryCode = "US"; - - SortReplaceCharacters = new[] { ".", "+", "%" }; - SortRemoveCharacters = new[] { ",", "&", "-", "{", "}", "'" }; - SortRemoveWords = new[] { "the", "a", "an" }; - - BaseUrl = string.Empty; - UICulture = "en-US"; + public int? ActivityLogRetentionDays { get; set; } = 30; - MetadataOptions = new[] - { - new MetadataOptions() - { - ItemType = "Book" - }, - new MetadataOptions() - { - ItemType = "Movie" - }, - new MetadataOptions - { - ItemType = "MusicVideo", - DisabledMetadataFetchers = new[] { "The Open Movie Database" }, - DisabledImageFetchers = new[] { "The Open Movie Database" } - }, - new MetadataOptions - { - ItemType = "Series", - DisabledMetadataFetchers = new[] { "TheMovieDb" }, - DisabledImageFetchers = new[] { "TheMovieDb" } - }, - new MetadataOptions - { - ItemType = "MusicAlbum", - DisabledMetadataFetchers = new[] { "TheAudioDB" } - }, - new MetadataOptions - { - ItemType = "MusicArtist", - DisabledMetadataFetchers = new[] { "TheAudioDB" } - }, - new MetadataOptions - { - ItemType = "BoxSet" - }, - new MetadataOptions - { - ItemType = "Season", - DisabledMetadataFetchers = new[] { "TheMovieDb" }, - }, - new MetadataOptions - { - ItemType = "Episode", - DisabledMetadataFetchers = new[] { "The Open Movie Database", "TheMovieDb" }, - DisabledImageFetchers = new[] { "The Open Movie Database", "TheMovieDb" } - } - }; - - EnableSlowResponseWarning = true; - SlowResponseThresholdMs = 500; - CorsHosts = new[] { "*" }; - KnownProxies = Array.Empty<string>(); - } - } + /// <summary> + /// Gets or sets the how the library scan fans out. + /// </summary> + public int LibraryScanFanoutConcurrency { get; set; } - public class PathSubstitution - { - public string From { get; set; } + /// <summary> + /// Gets or sets the how many metadata refreshes can run concurrently. + /// </summary> + public int LibraryMetadataRefreshConcurrency { get; set; } - public string To { get; set; } + /// <summary> + /// Gets or sets a value indicating whether older plugins should automatically be deleted from the plugin folder. + /// </summary> + public bool RemoveOldPlugins { get; set; } } } diff --git a/MediaBrowser.Model/Configuration/TypeOptions.cs b/MediaBrowser.Model/Configuration/TypeOptions.cs new file mode 100644 index 0000000000..d0179e5aab --- /dev/null +++ b/MediaBrowser.Model/Configuration/TypeOptions.cs @@ -0,0 +1,365 @@ +#nullable disable +#pragma warning disable CS1591 + +using System; +using System.Collections.Generic; +using MediaBrowser.Model.Entities; + +namespace MediaBrowser.Model.Configuration +{ + public class TypeOptions + { + public static readonly ImageOption DefaultInstance = new ImageOption(); + + public static readonly Dictionary<string, ImageOption[]> DefaultImageOptions = new Dictionary<string, ImageOption[]> + { + { + "Movie", new[] + { + new ImageOption + { + Limit = 1, + MinWidth = 1280, + Type = ImageType.Backdrop + }, + + // Don't download this by default as it's rarely used. + new ImageOption + { + Limit = 0, + Type = ImageType.Art + }, + + // Don't download this by default as it's rarely used. + new ImageOption + { + Limit = 0, + Type = ImageType.Disc + }, + + new ImageOption + { + Limit = 1, + Type = ImageType.Primary + }, + + new ImageOption + { + Limit = 0, + Type = ImageType.Banner + }, + + new ImageOption + { + Limit = 1, + Type = ImageType.Thumb + }, + + new ImageOption + { + Limit = 1, + Type = ImageType.Logo + } + } + }, + { + "MusicVideo", new[] + { + new ImageOption + { + Limit = 1, + MinWidth = 1280, + Type = ImageType.Backdrop + }, + + // Don't download this by default as it's rarely used. + new ImageOption + { + Limit = 0, + Type = ImageType.Art + }, + + // Don't download this by default as it's rarely used. + new ImageOption + { + Limit = 0, + Type = ImageType.Disc + }, + + new ImageOption + { + Limit = 1, + Type = ImageType.Primary + }, + + new ImageOption + { + Limit = 0, + Type = ImageType.Banner + }, + + new ImageOption + { + Limit = 1, + Type = ImageType.Thumb + }, + + new ImageOption + { + Limit = 1, + Type = ImageType.Logo + } + } + }, + { + "Series", new[] + { + new ImageOption + { + Limit = 1, + MinWidth = 1280, + Type = ImageType.Backdrop + }, + + // Don't download this by default as it's rarely used. + new ImageOption + { + Limit = 0, + Type = ImageType.Art + }, + + new ImageOption + { + Limit = 1, + Type = ImageType.Primary + }, + + new ImageOption + { + Limit = 1, + Type = ImageType.Banner + }, + + new ImageOption + { + Limit = 1, + Type = ImageType.Thumb + }, + + new ImageOption + { + Limit = 1, + Type = ImageType.Logo + } + } + }, + { + "MusicAlbum", new[] + { + new ImageOption + { + Limit = 0, + MinWidth = 1280, + Type = ImageType.Backdrop + }, + + // Don't download this by default as it's rarely used. + new ImageOption + { + Limit = 0, + Type = ImageType.Disc + } + } + }, + { + "MusicArtist", new[] + { + new ImageOption + { + Limit = 1, + MinWidth = 1280, + Type = ImageType.Backdrop + }, + + // Don't download this by default + // They do look great, but most artists won't have them, which means a banner view isn't really possible + new ImageOption + { + Limit = 0, + Type = ImageType.Banner + }, + + // Don't download this by default + // Generally not used + new ImageOption + { + Limit = 0, + Type = ImageType.Art + }, + + new ImageOption + { + Limit = 1, + Type = ImageType.Logo + } + } + }, + { + "BoxSet", new[] + { + new ImageOption + { + Limit = 1, + MinWidth = 1280, + Type = ImageType.Backdrop + }, + + new ImageOption + { + Limit = 1, + Type = ImageType.Primary + }, + + new ImageOption + { + Limit = 1, + Type = ImageType.Thumb + }, + + new ImageOption + { + Limit = 1, + Type = ImageType.Logo + }, + + // Don't download this by default as it's rarely used. + new ImageOption + { + Limit = 0, + Type = ImageType.Art + }, + + // Don't download this by default as it's rarely used. + new ImageOption + { + Limit = 0, + Type = ImageType.Disc + }, + + // Don't download this by default as it's rarely used. + new ImageOption + { + Limit = 0, + Type = ImageType.Banner + } + } + }, + { + "Season", new[] + { + new ImageOption + { + Limit = 0, + MinWidth = 1280, + Type = ImageType.Backdrop + }, + + new ImageOption + { + Limit = 1, + Type = ImageType.Primary + }, + + new ImageOption + { + Limit = 0, + Type = ImageType.Banner + }, + + new ImageOption + { + Limit = 0, + Type = ImageType.Thumb + } + } + }, + { + "Episode", new[] + { + new ImageOption + { + Limit = 0, + MinWidth = 1280, + Type = ImageType.Backdrop + }, + + new ImageOption + { + Limit = 1, + Type = ImageType.Primary + } + } + } + }; + + public TypeOptions() + { + MetadataFetchers = Array.Empty<string>(); + MetadataFetcherOrder = Array.Empty<string>(); + ImageFetchers = Array.Empty<string>(); + ImageFetcherOrder = Array.Empty<string>(); + ImageOptions = Array.Empty<ImageOption>(); + } + + public string Type { get; set; } + + public string[] MetadataFetchers { get; set; } + + public string[] MetadataFetcherOrder { get; set; } + + public string[] ImageFetchers { get; set; } + + public string[] ImageFetcherOrder { get; set; } + + public ImageOption[] ImageOptions { get; set; } + + public ImageOption GetImageOptions(ImageType type) + { + foreach (var i in ImageOptions) + { + if (i.Type == type) + { + return i; + } + } + + if (DefaultImageOptions.TryGetValue(Type, out ImageOption[] options)) + { + foreach (var i in options) + { + if (i.Type == type) + { + return i; + } + } + } + + return DefaultInstance; + } + + public int GetLimit(ImageType type) + { + return GetImageOptions(type).Limit; + } + + public int GetMinWidth(ImageType type) + { + return GetImageOptions(type).MinWidth; + } + + public bool IsEnabled(ImageType type) + { + return GetLimit(type) > 0; + } + } +} diff --git a/MediaBrowser.Model/Configuration/UserConfiguration.cs b/MediaBrowser.Model/Configuration/UserConfiguration.cs index cc0e0c4681..935e6cbe10 100644 --- a/MediaBrowser.Model/Configuration/UserConfiguration.cs +++ b/MediaBrowser.Model/Configuration/UserConfiguration.cs @@ -11,6 +11,24 @@ namespace MediaBrowser.Model.Configuration /// </summary> public class UserConfiguration { + /// <summary> + /// Initializes a new instance of the <see cref="UserConfiguration" /> class. + /// </summary> + public UserConfiguration() + { + EnableNextEpisodeAutoPlay = true; + RememberAudioSelections = true; + RememberSubtitleSelections = true; + + HidePlayedInLatest = true; + PlayDefaultAudioTrack = true; + + LatestItemsExcludes = Array.Empty<string>(); + OrderedViews = Array.Empty<string>(); + MyMediaExcludes = Array.Empty<string>(); + GroupedFolders = Array.Empty<string>(); + } + /// <summary> /// Gets or sets the audio language preference. /// </summary> @@ -52,23 +70,5 @@ namespace MediaBrowser.Model.Configuration public bool RememberSubtitleSelections { get; set; } public bool EnableNextEpisodeAutoPlay { get; set; } - - /// <summary> - /// Initializes a new instance of the <see cref="UserConfiguration" /> class. - /// </summary> - public UserConfiguration() - { - EnableNextEpisodeAutoPlay = true; - RememberAudioSelections = true; - RememberSubtitleSelections = true; - - HidePlayedInLatest = true; - PlayDefaultAudioTrack = true; - - LatestItemsExcludes = Array.Empty<string>(); - OrderedViews = Array.Empty<string>(); - MyMediaExcludes = Array.Empty<string>(); - GroupedFolders = Array.Empty<string>(); - } } } diff --git a/MediaBrowser.Model/Configuration/XbmcMetadataOptions.cs b/MediaBrowser.Model/Configuration/XbmcMetadataOptions.cs index 4d5f996f84..8ad070dcbf 100644 --- a/MediaBrowser.Model/Configuration/XbmcMetadataOptions.cs +++ b/MediaBrowser.Model/Configuration/XbmcMetadataOptions.cs @@ -5,6 +5,14 @@ namespace MediaBrowser.Model.Configuration { public class XbmcMetadataOptions { + public XbmcMetadataOptions() + { + ReleaseDateFormat = "yyyy-MM-dd"; + + SaveImagePathsInNfo = true; + EnablePathSubstitution = true; + } + public string UserId { get; set; } public string ReleaseDateFormat { get; set; } @@ -14,13 +22,5 @@ namespace MediaBrowser.Model.Configuration public bool EnablePathSubstitution { get; set; } public bool EnableExtraThumbsDuplication { get; set; } - - public XbmcMetadataOptions() - { - ReleaseDateFormat = "yyyy-MM-dd"; - - SaveImagePathsInNfo = true; - EnablePathSubstitution = true; - } } } diff --git a/MediaBrowser.Model/Dlna/AudioOptions.cs b/MediaBrowser.Model/Dlna/AudioOptions.cs index 67e4ffe03e..4d4d8d78cb 100644 --- a/MediaBrowser.Model/Dlna/AudioOptions.cs +++ b/MediaBrowser.Model/Dlna/AudioOptions.cs @@ -34,22 +34,22 @@ namespace MediaBrowser.Model.Dlna public DeviceProfile Profile { get; set; } /// <summary> - /// Optional. Only needed if a specific AudioStreamIndex or SubtitleStreamIndex are requested. + /// Gets or sets a media source id. Optional. Only needed if a specific AudioStreamIndex or SubtitleStreamIndex are requested. /// </summary> public string MediaSourceId { get; set; } public string DeviceId { get; set; } /// <summary> - /// Allows an override of supported number of audio channels - /// Example: DeviceProfile supports five channel, but user only has stereo speakers + /// Gets or sets an override of supported number of audio channels + /// Example: DeviceProfile supports five channel, but user only has stereo speakers. /// </summary> public int? MaxAudioChannels { get; set; } /// <summary> - /// The application's configured quality setting. + /// Gets or sets the application's configured quality setting. /// </summary> - public long? MaxBitrate { get; set; } + public int? MaxBitrate { get; set; } /// <summary> /// Gets or sets the context. @@ -66,8 +66,9 @@ namespace MediaBrowser.Model.Dlna /// <summary> /// Gets the maximum bitrate. /// </summary> + /// <param name="isAudio">Whether or not this is audio.</param> /// <returns>System.Nullable<System.Int32>.</returns> - public long? GetMaxBitrate(bool isAudio) + public int? GetMaxBitrate(bool isAudio) { if (MaxBitrate.HasValue) { diff --git a/MediaBrowser.Model/Dlna/CodecProfile.cs b/MediaBrowser.Model/Dlna/CodecProfile.cs index d4fd3e6730..8343cf028b 100644 --- a/MediaBrowser.Model/Dlna/CodecProfile.cs +++ b/MediaBrowser.Model/Dlna/CodecProfile.cs @@ -9,6 +9,12 @@ namespace MediaBrowser.Model.Dlna { public class CodecProfile { + public CodecProfile() + { + Conditions = Array.Empty<ProfileCondition>(); + ApplyConditions = Array.Empty<ProfileCondition>(); + } + [XmlAttribute("type")] public CodecType Type { get; set; } @@ -22,12 +28,6 @@ namespace MediaBrowser.Model.Dlna [XmlAttribute("container")] public string Container { get; set; } - public CodecProfile() - { - Conditions = Array.Empty<ProfileCondition>(); - ApplyConditions = Array.Empty<ProfileCondition>(); - } - public string[] GetCodecs() { return ContainerProfile.SplitValue(Codec); diff --git a/MediaBrowser.Model/Dlna/ConditionProcessor.cs b/MediaBrowser.Model/Dlna/ConditionProcessor.cs index faf1ee41be..55c4dd0742 100644 --- a/MediaBrowser.Model/Dlna/ConditionProcessor.cs +++ b/MediaBrowser.Model/Dlna/ConditionProcessor.cs @@ -1,8 +1,8 @@ #pragma warning disable CS1591 using System; -using System.Linq; using System.Globalization; +using System.Linq; using MediaBrowser.Model.MediaInfo; namespace MediaBrowser.Model.Dlna diff --git a/MediaBrowser.Model/Dlna/ContainerProfile.cs b/MediaBrowser.Model/Dlna/ContainerProfile.cs index f77d9b2675..7409660881 100644 --- a/MediaBrowser.Model/Dlna/ContainerProfile.cs +++ b/MediaBrowser.Model/Dlna/ContainerProfile.cs @@ -1,4 +1,3 @@ -#nullable disable #pragma warning disable CS1591 using System; @@ -12,42 +11,32 @@ namespace MediaBrowser.Model.Dlna [XmlAttribute("type")] public DlnaProfileType Type { get; set; } - public ProfileCondition[] Conditions { get; set; } + public ProfileCondition[]? Conditions { get; set; } = Array.Empty<ProfileCondition>(); [XmlAttribute("container")] - public string Container { get; set; } + public string Container { get; set; } = string.Empty; - public ContainerProfile() - { - Conditions = Array.Empty<ProfileCondition>(); - } - - public string[] GetContainers() - { - return SplitValue(Container); - } - - public static string[] SplitValue(string value) + public static string[] SplitValue(string? value) { if (string.IsNullOrEmpty(value)) { return Array.Empty<string>(); } - return value.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries); + return value.Split(',', StringSplitOptions.RemoveEmptyEntries); } - public bool ContainsContainer(string container) + public bool ContainsContainer(string? container) { - var containers = GetContainers(); + var containers = SplitValue(Container); return ContainsContainer(containers, container); } - public static bool ContainsContainer(string profileContainers, string inputContainer) + public static bool ContainsContainer(string? profileContainers, string? inputContainer) { var isNegativeList = false; - if (profileContainers != null && profileContainers.StartsWith("-", StringComparison.Ordinal)) + if (profileContainers != null && profileContainers.StartsWith('-')) { isNegativeList = true; profileContainers = profileContainers.Substring(1); @@ -56,46 +45,30 @@ namespace MediaBrowser.Model.Dlna return ContainsContainer(SplitValue(profileContainers), isNegativeList, inputContainer); } - public static bool ContainsContainer(string[] profileContainers, string inputContainer) + public static bool ContainsContainer(string[]? profileContainers, string? inputContainer) { return ContainsContainer(profileContainers, false, inputContainer); } - public static bool ContainsContainer(string[] profileContainers, bool isNegativeList, string inputContainer) + public static bool ContainsContainer(string[]? profileContainers, bool isNegativeList, string? inputContainer) { - if (profileContainers.Length == 0) + if (profileContainers == null || profileContainers.Length == 0) { + // Empty profiles always support all containers/codecs return true; } - if (isNegativeList) - { - var allInputContainers = SplitValue(inputContainer); + var allInputContainers = SplitValue(inputContainer); - foreach (var container in allInputContainers) - { - if (profileContainers.Contains(container, StringComparer.OrdinalIgnoreCase)) - { - return false; - } - } - - return true; - } - else + foreach (var container in allInputContainers) { - var allInputContainers = SplitValue(inputContainer); - - foreach (var container in allInputContainers) + if (profileContainers.Contains(container, StringComparer.OrdinalIgnoreCase)) { - if (profileContainers.Contains(container, StringComparer.OrdinalIgnoreCase)) - { - return true; - } + return !isNegativeList; } - - return false; } + + return isNegativeList; } } } diff --git a/MediaBrowser.Model/Dlna/ContentFeatureBuilder.cs b/MediaBrowser.Model/Dlna/ContentFeatureBuilder.cs index 8b73ecbd4d..600a441570 100644 --- a/MediaBrowser.Model/Dlna/ContentFeatureBuilder.cs +++ b/MediaBrowser.Model/Dlna/ContentFeatureBuilder.cs @@ -10,14 +10,8 @@ namespace MediaBrowser.Model.Dlna { public class ContentFeatureBuilder { - private readonly DeviceProfile _profile; - - public ContentFeatureBuilder(DeviceProfile profile) - { - _profile = profile; - } - - public string BuildImageHeader( + public static string BuildImageHeader( + DeviceProfile profile, string container, int? width, int? height, @@ -38,27 +32,31 @@ namespace MediaBrowser.Model.Dlna ";DLNA.ORG_FLAGS={0}", DlnaMaps.FlagsToString(flagValue)); - ResponseProfile mediaProfile = _profile.GetImageMediaProfile( - container, - width, - height); - if (string.IsNullOrEmpty(orgPn)) { + ResponseProfile mediaProfile = profile.GetImageMediaProfile( + container, + width, + height); + orgPn = mediaProfile?.OrgPn; + + if (string.IsNullOrEmpty(orgPn)) + { + orgPn = GetImageOrgPnValue(container, width, height); + } } if (string.IsNullOrEmpty(orgPn)) { - orgPn = GetImageOrgPnValue(container, width, height); + return orgOp.TrimStart(';') + orgCi + dlnaflags; } - string contentFeatures = string.IsNullOrEmpty(orgPn) ? string.Empty : "DLNA.ORG_PN=" + orgPn; - - return (contentFeatures + orgOp + orgCi + dlnaflags).Trim(';'); + return "DLNA.ORG_PN=" + orgPn + orgOp + orgCi + dlnaflags; } - public string BuildAudioHeader( + public static string BuildAudioHeader( + DeviceProfile profile, string container, string audioCodec, int? audioBitrate, @@ -81,20 +79,20 @@ namespace MediaBrowser.Model.Dlna DlnaFlags.DlnaV15; // if (isDirectStream) - //{ - // flagValue = flagValue | DlnaFlags.ByteBasedSeek; - //} - // else if (runtimeTicks.HasValue) - //{ - // flagValue = flagValue | DlnaFlags.TimeBasedSeek; - //} + // { + // flagValue = flagValue | DlnaFlags.ByteBasedSeek; + // } + // else if (runtimeTicks.HasValue) + // { + // flagValue = flagValue | DlnaFlags.TimeBasedSeek; + // } string dlnaflags = string.Format( CultureInfo.InvariantCulture, ";DLNA.ORG_FLAGS={0}", DlnaMaps.FlagsToString(flagValue)); - ResponseProfile mediaProfile = _profile.GetAudioMediaProfile( + ResponseProfile mediaProfile = profile.GetAudioMediaProfile( container, audioCodec, audioChannels, @@ -109,12 +107,16 @@ namespace MediaBrowser.Model.Dlna orgPn = GetAudioOrgPnValue(container, audioBitrate, audioSampleRate, audioChannels); } - string contentFeatures = string.IsNullOrEmpty(orgPn) ? string.Empty : "DLNA.ORG_PN=" + orgPn; + if (string.IsNullOrEmpty(orgPn)) + { + return orgOp.TrimStart(';') + orgCi + dlnaflags; + } - return (contentFeatures + orgOp + orgCi + dlnaflags).Trim(';'); + return "DLNA.ORG_PN=" + orgPn + orgOp + orgCi + dlnaflags; } - public List<string> BuildVideoHeader( + public static List<string> BuildVideoHeader( + DeviceProfile profile, string container, string videoCodec, string audioCodec, @@ -150,18 +152,20 @@ namespace MediaBrowser.Model.Dlna DlnaFlags.DlnaV15; // if (isDirectStream) - //{ - // flagValue = flagValue | DlnaFlags.ByteBasedSeek; - //} - // else if (runtimeTicks.HasValue) - //{ - // flagValue = flagValue | DlnaFlags.TimeBasedSeek; - //} - - string dlnaflags = string.Format(CultureInfo.InvariantCulture, ";DLNA.ORG_FLAGS={0}", - DlnaMaps.FlagsToString(flagValue)); - - ResponseProfile mediaProfile = _profile.GetVideoMediaProfile( + // { + // flagValue = flagValue | DlnaFlags.ByteBasedSeek; + // } + // else if (runtimeTicks.HasValue) + // { + // flagValue = flagValue | DlnaFlags.TimeBasedSeek; + // } + + string dlnaflags = string.Format( + CultureInfo.InvariantCulture, + ";DLNA.ORG_FLAGS={0}", + DlnaMaps.FlagsToString(flagValue)); + + ResponseProfile mediaProfile = profile.GetVideoMediaProfile( container, audioCodec, videoCodec, @@ -186,13 +190,13 @@ namespace MediaBrowser.Model.Dlna if (mediaProfile != null && !string.IsNullOrEmpty(mediaProfile.OrgPn)) { - orgPnValues.AddRange(mediaProfile.OrgPn.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries)); + orgPnValues.AddRange(mediaProfile.OrgPn.Split(',', StringSplitOptions.RemoveEmptyEntries)); } else { - foreach (string s in GetVideoOrgPnValue(container, videoCodec, audioCodec, width, height, timestamp)) + foreach (var s in GetVideoOrgPnValue(container, videoCodec, audioCodec, width, height, timestamp)) { - orgPnValues.Add(s); + orgPnValues.Add(s.ToString()); break; } } @@ -201,20 +205,20 @@ namespace MediaBrowser.Model.Dlna foreach (string orgPn in orgPnValues) { - string contentFeatures = string.IsNullOrEmpty(orgPn) ? string.Empty : "DLNA.ORG_PN=" + orgPn; - - var value = (contentFeatures + orgOp + orgCi + dlnaflags).Trim(';'); - - contentFeatureList.Add(value); + if (string.IsNullOrEmpty(orgPn)) + { + contentFeatureList.Add(orgOp.TrimStart(';') + orgCi + dlnaflags); + continue; + } + else + { + contentFeatureList.Add("DLNA.ORG_PN=" + orgPn + orgCi + dlnaflags); + } } if (orgPnValues.Count == 0) { - string contentFeatures = string.Empty; - - var value = (contentFeatures + orgOp + orgCi + dlnaflags).Trim(';'); - - contentFeatureList.Add(value); + contentFeatureList.Add(orgOp.TrimStart(';') + orgCi + dlnaflags); } return contentFeatureList; @@ -222,19 +226,14 @@ namespace MediaBrowser.Model.Dlna private static string GetImageOrgPnValue(string container, int? width, int? height) { - MediaFormatProfile? format = new MediaFormatProfileResolver() - .ResolveImageFormat( - container, - width, - height); + MediaFormatProfile? format = MediaFormatProfileResolver.ResolveImageFormat(container, width, height); return format.HasValue ? format.Value.ToString() : null; } private static string GetAudioOrgPnValue(string container, int? audioBitrate, int? audioSampleRate, int? audioChannels) { - MediaFormatProfile? format = new MediaFormatProfileResolver() - .ResolveAudioFormat( + MediaFormatProfile? format = MediaFormatProfileResolver.ResolveAudioFormat( container, audioBitrate, audioSampleRate, @@ -243,9 +242,9 @@ namespace MediaBrowser.Model.Dlna return format.HasValue ? format.Value.ToString() : null; } - private static string[] GetVideoOrgPnValue(string container, string videoCodec, string audioCodec, int? width, int? height, TransportStreamTimestamp timestamp) + private static MediaFormatProfile[] GetVideoOrgPnValue(string container, string videoCodec, string audioCodec, int? width, int? height, TransportStreamTimestamp timestamp) { - return new MediaFormatProfileResolver().ResolveVideoFormat(container, videoCodec, audioCodec, width, height, timestamp); + return MediaFormatProfileResolver.ResolveVideoFormat(container, videoCodec, audioCodec, width, height, timestamp); } } } diff --git a/MediaBrowser.Model/Dlna/DeviceIdentification.cs b/MediaBrowser.Model/Dlna/DeviceIdentification.cs index 43407383a8..c511801f4f 100644 --- a/MediaBrowser.Model/Dlna/DeviceIdentification.cs +++ b/MediaBrowser.Model/Dlna/DeviceIdentification.cs @@ -11,59 +11,54 @@ namespace MediaBrowser.Model.Dlna /// Gets or sets the name of the friendly. /// </summary> /// <value>The name of the friendly.</value> - public string FriendlyName { get; set; } + public string FriendlyName { get; set; } = string.Empty; /// <summary> /// Gets or sets the model number. /// </summary> /// <value>The model number.</value> - public string ModelNumber { get; set; } + public string ModelNumber { get; set; } = string.Empty; /// <summary> /// Gets or sets the serial number. /// </summary> /// <value>The serial number.</value> - public string SerialNumber { get; set; } + public string SerialNumber { get; set; } = string.Empty; /// <summary> /// Gets or sets the name of the model. /// </summary> /// <value>The name of the model.</value> - public string ModelName { get; set; } + public string ModelName { get; set; } = string.Empty; /// <summary> /// Gets or sets the model description. /// </summary> /// <value>The model description.</value> - public string ModelDescription { get; set; } + public string ModelDescription { get; set; } = string.Empty; /// <summary> /// Gets or sets the model URL. /// </summary> /// <value>The model URL.</value> - public string ModelUrl { get; set; } + public string ModelUrl { get; set; } = string.Empty; /// <summary> /// Gets or sets the manufacturer. /// </summary> /// <value>The manufacturer.</value> - public string Manufacturer { get; set; } + public string Manufacturer { get; set; } = string.Empty; /// <summary> /// Gets or sets the manufacturer URL. /// </summary> /// <value>The manufacturer URL.</value> - public string ManufacturerUrl { get; set; } + public string ManufacturerUrl { get; set; } = string.Empty; /// <summary> /// Gets or sets the headers. /// </summary> /// <value>The headers.</value> - public HttpHeaderInfo[] Headers { get; set; } - - public DeviceIdentification() - { - Headers = Array.Empty<HttpHeaderInfo>(); - } + public HttpHeaderInfo[] Headers { get; set; } = Array.Empty<HttpHeaderInfo>(); } } diff --git a/MediaBrowser.Model/Dlna/DeviceProfile.cs b/MediaBrowser.Model/Dlna/DeviceProfile.cs index 44412f3e41..feb3d880ec 100644 --- a/MediaBrowser.Model/Dlna/DeviceProfile.cs +++ b/MediaBrowser.Model/Dlna/DeviceProfile.cs @@ -1,143 +1,249 @@ -#nullable disable -#pragma warning disable CS1591 - +#pragma warning disable CA1819 // Properties should not return arrays using System; +using System.ComponentModel; using System.Linq; using System.Xml.Serialization; using MediaBrowser.Model.MediaInfo; namespace MediaBrowser.Model.Dlna { + /// <summary> + /// A <see cref="DeviceProfile" /> represents a set of metadata which determines which content a certain device is able to play. + /// <br/> + /// Specifically, it defines the supported <see cref="ContainerProfiles">containers</see> and + /// <see cref="CodecProfiles">codecs</see> (video and/or audio, including codec profiles and levels) + /// the device is able to direct play (without transcoding or remuxing), + /// as well as which <see cref="TranscodingProfiles">containers/codecs to transcode to</see> in case it isn't. + /// </summary> [XmlRoot("Profile")] public class DeviceProfile { /// <summary> - /// Gets or sets the name. + /// Gets or sets the name of this device profile. /// </summary> - /// <value>The name.</value> - public string Name { get; set; } + public string? Name { get; set; } + /// <summary> + /// Gets or sets the Id. + /// </summary> [XmlIgnore] - public string Id { get; set; } + public string? Id { get; set; } /// <summary> - /// Gets or sets the identification. + /// Gets or sets the Identification. /// </summary> - /// <value>The identification.</value> - public DeviceIdentification Identification { get; set; } + public DeviceIdentification? Identification { get; set; } - public string FriendlyName { get; set; } + /// <summary> + /// Gets or sets the friendly name of the device profile, which can be shown to users. + /// </summary> + public string? FriendlyName { get; set; } - public string Manufacturer { get; set; } + /// <summary> + /// Gets or sets the manufacturer of the device which this profile represents. + /// </summary> + public string? Manufacturer { get; set; } - public string ManufacturerUrl { get; set; } + /// <summary> + /// Gets or sets an url for the manufacturer of the device which this profile represents. + /// </summary> + public string? ManufacturerUrl { get; set; } - public string ModelName { get; set; } + /// <summary> + /// Gets or sets the model name of the device which this profile represents. + /// </summary> + public string? ModelName { get; set; } - public string ModelDescription { get; set; } + /// <summary> + /// Gets or sets the model description of the device which this profile represents. + /// </summary> + public string? ModelDescription { get; set; } - public string ModelNumber { get; set; } + /// <summary> + /// Gets or sets the model number of the device which this profile represents. + /// </summary> + public string? ModelNumber { get; set; } - public string ModelUrl { get; set; } + /// <summary> + /// Gets or sets the ModelUrl. + /// </summary> + public string? ModelUrl { get; set; } - public string SerialNumber { get; set; } + /// <summary> + /// Gets or sets the serial number of the device which this profile represents. + /// </summary> + public string? SerialNumber { get; set; } + /// <summary> + /// Gets or sets a value indicating whether EnableAlbumArtInDidl. + /// </summary> + [DefaultValue(false)] public bool EnableAlbumArtInDidl { get; set; } + /// <summary> + /// Gets or sets a value indicating whether EnableSingleAlbumArtLimit. + /// </summary> + [DefaultValue(false)] public bool EnableSingleAlbumArtLimit { get; set; } + /// <summary> + /// Gets or sets a value indicating whether EnableSingleSubtitleLimit. + /// </summary> + [DefaultValue(false)] public bool EnableSingleSubtitleLimit { get; set; } - public string SupportedMediaTypes { get; set; } + /// <summary> + /// Gets or sets the SupportedMediaTypes. + /// </summary> + public string SupportedMediaTypes { get; set; } = "Audio,Photo,Video"; - public string UserId { get; set; } + /// <summary> + /// Gets or sets the UserId. + /// </summary> + public string? UserId { get; set; } - public string AlbumArtPn { get; set; } + /// <summary> + /// Gets or sets the AlbumArtPn. + /// </summary> + public string? AlbumArtPn { get; set; } - public int MaxAlbumArtWidth { get; set; } + /// <summary> + /// Gets or sets the MaxAlbumArtWidth. + /// </summary> + public int? MaxAlbumArtWidth { get; set; } - public int MaxAlbumArtHeight { get; set; } + /// <summary> + /// Gets or sets the MaxAlbumArtHeight. + /// </summary> + public int? MaxAlbumArtHeight { get; set; } + /// <summary> + /// Gets or sets the maximum allowed width of embedded icons. + /// </summary> public int? MaxIconWidth { get; set; } + /// <summary> + /// Gets or sets the maximum allowed height of embedded icons. + /// </summary> public int? MaxIconHeight { get; set; } - public long? MaxStreamingBitrate { get; set; } + /// <summary> + /// Gets or sets the maximum allowed bitrate for all streamed content. + /// </summary> + public int? MaxStreamingBitrate { get; set; } = 8000000; - public long? MaxStaticBitrate { get; set; } + /// <summary> + /// Gets or sets the maximum allowed bitrate for statically streamed content (= direct played files). + /// </summary> + public int? MaxStaticBitrate { get; set; } = 8000000; - public int? MusicStreamingTranscodingBitrate { get; set; } + /// <summary> + /// Gets or sets the maximum allowed bitrate for transcoded music streams. + /// </summary> + public int? MusicStreamingTranscodingBitrate { get; set; } = 128000; - public int? MaxStaticMusicBitrate { get; set; } + /// <summary> + /// Gets or sets the maximum allowed bitrate for statically streamed (= direct played) music files. + /// </summary> + public int? MaxStaticMusicBitrate { get; set; } = 8000000; /// <summary> - /// Controls the content of the aggregationFlags element in the urn:schemas-sonycom:av namespace. + /// Gets or sets the content of the aggregationFlags element in the urn:schemas-sonycom:av namespace. /// </summary> - public string SonyAggregationFlags { get; set; } + public string? SonyAggregationFlags { get; set; } - public string ProtocolInfo { get; set; } + /// <summary> + /// Gets or sets the ProtocolInfo. + /// </summary> + public string? ProtocolInfo { get; set; } + /// <summary> + /// Gets or sets the TimelineOffsetSeconds. + /// </summary> + [DefaultValue(0)] public int TimelineOffsetSeconds { get; set; } + /// <summary> + /// Gets or sets a value indicating whether RequiresPlainVideoItems. + /// </summary> + [DefaultValue(false)] public bool RequiresPlainVideoItems { get; set; } + /// <summary> + /// Gets or sets a value indicating whether RequiresPlainFolders. + /// </summary> + [DefaultValue(false)] public bool RequiresPlainFolders { get; set; } + /// <summary> + /// Gets or sets a value indicating whether EnableMSMediaReceiverRegistrar. + /// </summary> + [DefaultValue(false)] public bool EnableMSMediaReceiverRegistrar { get; set; } + /// <summary> + /// Gets or sets a value indicating whether IgnoreTranscodeByteRangeRequests. + /// </summary> + [DefaultValue(false)] public bool IgnoreTranscodeByteRangeRequests { get; set; } - public XmlAttribute[] XmlRootAttributes { get; set; } + /// <summary> + /// Gets or sets the XmlRootAttributes. + /// </summary> + public XmlAttribute[] XmlRootAttributes { get; set; } = Array.Empty<XmlAttribute>(); /// <summary> /// Gets or sets the direct play profiles. /// </summary> - /// <value>The direct play profiles.</value> - public DirectPlayProfile[] DirectPlayProfiles { get; set; } + public DirectPlayProfile[] DirectPlayProfiles { get; set; } = Array.Empty<DirectPlayProfile>(); /// <summary> /// Gets or sets the transcoding profiles. /// </summary> - /// <value>The transcoding profiles.</value> - public TranscodingProfile[] TranscodingProfiles { get; set; } + public TranscodingProfile[] TranscodingProfiles { get; set; } = Array.Empty<TranscodingProfile>(); - public ContainerProfile[] ContainerProfiles { get; set; } - - public CodecProfile[] CodecProfiles { get; set; } + /// <summary> + /// Gets or sets the container profiles. + /// </summary> + public ContainerProfile[] ContainerProfiles { get; set; } = Array.Empty<ContainerProfile>(); - public ResponseProfile[] ResponseProfiles { get; set; } + /// <summary> + /// Gets or sets the codec profiles. + /// </summary> + public CodecProfile[] CodecProfiles { get; set; } = Array.Empty<CodecProfile>(); - public SubtitleProfile[] SubtitleProfiles { get; set; } + /// <summary> + /// Gets or sets the ResponseProfiles. + /// </summary> + public ResponseProfile[] ResponseProfiles { get; set; } = Array.Empty<ResponseProfile>(); - public DeviceProfile() - { - DirectPlayProfiles = Array.Empty<DirectPlayProfile>(); - TranscodingProfiles = Array.Empty<TranscodingProfile>(); - ResponseProfiles = Array.Empty<ResponseProfile>(); - CodecProfiles = Array.Empty<CodecProfile>(); - ContainerProfiles = Array.Empty<ContainerProfile>(); - SubtitleProfiles = Array.Empty<SubtitleProfile>(); - - XmlRootAttributes = Array.Empty<XmlAttribute>(); - - SupportedMediaTypes = "Audio,Photo,Video"; - MaxStreamingBitrate = 8000000; - MaxStaticBitrate = 8000000; - MusicStreamingTranscodingBitrate = 128000; - } + /// <summary> + /// Gets or sets the subtitle profiles. + /// </summary> + public SubtitleProfile[] SubtitleProfiles { get; set; } = Array.Empty<SubtitleProfile>(); + /// <summary> + /// The GetSupportedMediaTypes. + /// </summary> + /// <returns>The .</returns> public string[] GetSupportedMediaTypes() { return ContainerProfile.SplitValue(SupportedMediaTypes); } - public TranscodingProfile GetAudioTranscodingProfile(string container, string audioCodec) + /// <summary> + /// Gets the audio transcoding profile. + /// </summary> + /// <param name="container">The container.</param> + /// <param name="audioCodec">The audio Codec.</param> + /// <returns>A <see cref="TranscodingProfile"/>.</returns> + public TranscodingProfile? GetAudioTranscodingProfile(string? container, string? audioCodec) { container = (container ?? string.Empty).TrimStart('.'); foreach (var i in TranscodingProfiles) { - if (i.Type != MediaBrowser.Model.Dlna.DlnaProfileType.Audio) + if (i.Type != DlnaProfileType.Audio) { continue; } @@ -158,13 +264,20 @@ namespace MediaBrowser.Model.Dlna return null; } - public TranscodingProfile GetVideoTranscodingProfile(string container, string audioCodec, string videoCodec) + /// <summary> + /// Gets the video transcoding profile. + /// </summary> + /// <param name="container">The container.</param> + /// <param name="audioCodec">The audio Codec.</param> + /// <param name="videoCodec">The video Codec.</param> + /// <returns>The <see cref="TranscodingProfile"/>.</returns> + public TranscodingProfile? GetVideoTranscodingProfile(string? container, string? audioCodec, string? videoCodec) { container = (container ?? string.Empty).TrimStart('.'); foreach (var i in TranscodingProfiles) { - if (i.Type != MediaBrowser.Model.Dlna.DlnaProfileType.Video) + if (i.Type != DlnaProfileType.Video) { continue; } @@ -179,7 +292,7 @@ namespace MediaBrowser.Model.Dlna continue; } - if (!string.Equals(videoCodec, i.VideoCodec ?? string.Empty, StringComparison.OrdinalIgnoreCase)) + if (!string.Equals(videoCodec, i.VideoCodec, StringComparison.OrdinalIgnoreCase)) { continue; } @@ -190,7 +303,17 @@ namespace MediaBrowser.Model.Dlna return null; } - public ResponseProfile GetAudioMediaProfile(string container, string audioCodec, int? audioChannels, int? audioBitrate, int? audioSampleRate, int? audioBitDepth) + /// <summary> + /// Gets the audio media profile. + /// </summary> + /// <param name="container">The container.</param> + /// <param name="audioCodec">The audio codec.</param> + /// <param name="audioChannels">The audio channels.</param> + /// <param name="audioBitrate">The audio bitrate.</param> + /// <param name="audioSampleRate">The audio sample rate.</param> + /// <param name="audioBitDepth">The audio bit depth.</param> + /// <returns>The <see cref="ResponseProfile"/>.</returns> + public ResponseProfile? GetAudioMediaProfile(string container, string? audioCodec, int? audioChannels, int? audioBitrate, int? audioSampleRate, int? audioBitDepth) { foreach (var i in ResponseProfiles) { @@ -231,6 +354,11 @@ namespace MediaBrowser.Model.Dlna return null; } + /// <summary> + /// Gets the model profile condition. + /// </summary> + /// <param name="c">The c<see cref="ProfileCondition"/>.</param> + /// <returns>The <see cref="ProfileCondition"/>.</returns> private ProfileCondition GetModelProfileCondition(ProfileCondition c) { return new ProfileCondition @@ -242,7 +370,14 @@ namespace MediaBrowser.Model.Dlna }; } - public ResponseProfile GetImageMediaProfile(string container, int? width, int? height) + /// <summary> + /// Gets the image media profile. + /// </summary> + /// <param name="container">The container.</param> + /// <param name="width">The width.</param> + /// <param name="height">The height.</param> + /// <returns>The <see cref="ResponseProfile"/>.</returns> + public ResponseProfile? GetImageMediaProfile(string container, int? width, int? height) { foreach (var i in ResponseProfiles) { @@ -277,10 +412,33 @@ namespace MediaBrowser.Model.Dlna return null; } - public ResponseProfile GetVideoMediaProfile( + /// <summary> + /// Gets the video media profile. + /// </summary> + /// <param name="container">The container.</param> + /// <param name="audioCodec">The audio codec.</param> + /// <param name="videoCodec">The video codec.</param> + /// <param name="width">The width.</param> + /// <param name="height">The height.</param> + /// <param name="bitDepth">The bit depth.</param> + /// <param name="videoBitrate">The video bitrate.</param> + /// <param name="videoProfile">The video profile.</param> + /// <param name="videoLevel">The video level.</param> + /// <param name="videoFramerate">The video framerate.</param> + /// <param name="packetLength">The packet length.</param> + /// <param name="timestamp">The timestamp<see cref="TransportStreamTimestamp"/>.</param> + /// <param name="isAnamorphic">True if anamorphic.</param> + /// <param name="isInterlaced">True if interlaced.</param> + /// <param name="refFrames">The ref frames.</param> + /// <param name="numVideoStreams">The number of video streams.</param> + /// <param name="numAudioStreams">The number of audio streams.</param> + /// <param name="videoCodecTag">The video Codec tag.</param> + /// <param name="isAvc">True if Avc.</param> + /// <returns>The <see cref="ResponseProfile"/>.</returns> + public ResponseProfile? GetVideoMediaProfile( string container, - string audioCodec, - string videoCodec, + string? audioCodec, + string? videoCodec, int? width, int? height, int? bitDepth, diff --git a/MediaBrowser.Model/Dlna/DirectPlayProfile.cs b/MediaBrowser.Model/Dlna/DirectPlayProfile.cs index 88cb839918..fa3ad098f0 100644 --- a/MediaBrowser.Model/Dlna/DirectPlayProfile.cs +++ b/MediaBrowser.Model/Dlna/DirectPlayProfile.cs @@ -1,6 +1,6 @@ -#nullable disable #pragma warning disable CS1591 +using System.ComponentModel.DataAnnotations; using System.Xml.Serialization; namespace MediaBrowser.Model.Dlna @@ -8,13 +8,13 @@ namespace MediaBrowser.Model.Dlna public class DirectPlayProfile { [XmlAttribute("container")] - public string Container { get; set; } + public string? Container { get; set; } [XmlAttribute("audioCodec")] - public string AudioCodec { get; set; } + public string? AudioCodec { get; set; } [XmlAttribute("videoCodec")] - public string VideoCodec { get; set; } + public string? VideoCodec { get; set; } [XmlAttribute("type")] public DlnaProfileType Type { get; set; } diff --git a/MediaBrowser.Model/Dlna/IDeviceDiscovery.cs b/MediaBrowser.Model/Dlna/IDeviceDiscovery.cs index 05209e53d0..086088deae 100644 --- a/MediaBrowser.Model/Dlna/IDeviceDiscovery.cs +++ b/MediaBrowser.Model/Dlna/IDeviceDiscovery.cs @@ -8,6 +8,7 @@ namespace MediaBrowser.Model.Dlna public interface IDeviceDiscovery { event EventHandler<GenericEventArgs<UpnpDeviceInfo>> DeviceDiscovered; + event EventHandler<GenericEventArgs<UpnpDeviceInfo>> DeviceLeft; } } diff --git a/MediaBrowser.Model/Dlna/MediaFormatProfileResolver.cs b/MediaBrowser.Model/Dlna/MediaFormatProfileResolver.cs index 3c955989a1..7ce248509c 100644 --- a/MediaBrowser.Model/Dlna/MediaFormatProfileResolver.cs +++ b/MediaBrowser.Model/Dlna/MediaFormatProfileResolver.cs @@ -9,16 +9,9 @@ using MediaBrowser.Model.MediaInfo; namespace MediaBrowser.Model.Dlna { - public class MediaFormatProfileResolver + public static class MediaFormatProfileResolver { - public string[] ResolveVideoFormat(string container, string videoCodec, string audioCodec, int? width, int? height, TransportStreamTimestamp timestampType) - { - return ResolveVideoFormatInternal(container, videoCodec, audioCodec, width, height, timestampType) - .Select(i => i.ToString()) - .ToArray(); - } - - private MediaFormatProfile[] ResolveVideoFormatInternal(string container, string videoCodec, string audioCodec, int? width, int? height, TransportStreamTimestamp timestampType) + public static MediaFormatProfile[] ResolveVideoFormat(string container, string videoCodec, string audioCodec, int? width, int? height, TransportStreamTimestamp timestampType) { if (string.Equals(container, "asf", StringComparison.OrdinalIgnoreCase)) { @@ -57,7 +50,6 @@ namespace MediaBrowser.Model.Dlna string.Equals(container, "mpegts", StringComparison.OrdinalIgnoreCase) || string.Equals(container, "m2ts", StringComparison.OrdinalIgnoreCase)) { - return ResolveVideoMPEG2TSFormat(videoCodec, audioCodec, width, height, timestampType); } @@ -85,7 +77,7 @@ namespace MediaBrowser.Model.Dlna return Array.Empty<MediaFormatProfile>(); } - private MediaFormatProfile[] ResolveVideoMPEG2TSFormat(string videoCodec, string audioCodec, int? width, int? height, TransportStreamTimestamp timestampType) + private static MediaFormatProfile[] ResolveVideoMPEG2TSFormat(string videoCodec, string audioCodec, int? width, int? height, TransportStreamTimestamp timestampType) { string suffix = string.Empty; @@ -210,12 +202,12 @@ namespace MediaBrowser.Model.Dlna return Array.Empty<MediaFormatProfile>(); } - private MediaFormatProfile ValueOf(string value) + private static MediaFormatProfile ValueOf(string value) { return (MediaFormatProfile)Enum.Parse(typeof(MediaFormatProfile), value, true); } - private MediaFormatProfile? ResolveVideoMP4Format(string videoCodec, string audioCodec, int? width, int? height) + private static MediaFormatProfile? ResolveVideoMP4Format(string videoCodec, string audioCodec, int? width, int? height) { if (string.Equals(videoCodec, "h264", StringComparison.OrdinalIgnoreCase)) { @@ -288,7 +280,7 @@ namespace MediaBrowser.Model.Dlna return null; } - private MediaFormatProfile? ResolveVideo3GPFormat(string videoCodec, string audioCodec) + private static MediaFormatProfile? ResolveVideo3GPFormat(string videoCodec, string audioCodec) { if (string.Equals(videoCodec, "h264", StringComparison.OrdinalIgnoreCase)) { @@ -318,12 +310,11 @@ namespace MediaBrowser.Model.Dlna return null; } - private MediaFormatProfile? ResolveVideoASFFormat(string videoCodec, string audioCodec, int? width, int? height) + private static MediaFormatProfile? ResolveVideoASFFormat(string videoCodec, string audioCodec, int? width, int? height) { if (string.Equals(videoCodec, "wmv", StringComparison.OrdinalIgnoreCase) && (string.IsNullOrEmpty(audioCodec) || string.Equals(audioCodec, "wma", StringComparison.OrdinalIgnoreCase) || string.Equals(videoCodec, "wmapro", StringComparison.OrdinalIgnoreCase))) { - if (width.HasValue && height.HasValue) { if ((width.Value <= 720) && (height.Value <= 576)) @@ -373,7 +364,7 @@ namespace MediaBrowser.Model.Dlna return null; } - public MediaFormatProfile? ResolveAudioFormat(string container, int? bitrate, int? frequency, int? channels) + public static MediaFormatProfile? ResolveAudioFormat(string container, int? bitrate, int? frequency, int? channels) { if (string.Equals(container, "asf", StringComparison.OrdinalIgnoreCase)) { @@ -415,7 +406,7 @@ namespace MediaBrowser.Model.Dlna return null; } - private MediaFormatProfile ResolveAudioASFFormat(int? bitrate) + private static MediaFormatProfile ResolveAudioASFFormat(int? bitrate) { if (bitrate.HasValue && bitrate.Value <= 193) { @@ -425,7 +416,7 @@ namespace MediaBrowser.Model.Dlna return MediaFormatProfile.WMA_FULL; } - private MediaFormatProfile? ResolveAudioLPCMFormat(int? frequency, int? channels) + private static MediaFormatProfile? ResolveAudioLPCMFormat(int? frequency, int? channels) { if (frequency.HasValue && channels.HasValue) { @@ -455,7 +446,7 @@ namespace MediaBrowser.Model.Dlna return MediaFormatProfile.LPCM16_48_STEREO; } - private MediaFormatProfile ResolveAudioMP4Format(int? bitrate) + private static MediaFormatProfile ResolveAudioMP4Format(int? bitrate) { if (bitrate.HasValue && bitrate.Value <= 320) { @@ -465,7 +456,7 @@ namespace MediaBrowser.Model.Dlna return MediaFormatProfile.AAC_ISO; } - private MediaFormatProfile ResolveAudioADTSFormat(int? bitrate) + private static MediaFormatProfile ResolveAudioADTSFormat(int? bitrate) { if (bitrate.HasValue && bitrate.Value <= 320) { @@ -475,11 +466,13 @@ namespace MediaBrowser.Model.Dlna return MediaFormatProfile.AAC_ADTS; } - public MediaFormatProfile? ResolveImageFormat(string container, int? width, int? height) + public static MediaFormatProfile? ResolveImageFormat(string container, int? width, int? height) { if (string.Equals(container, "jpeg", StringComparison.OrdinalIgnoreCase) || string.Equals(container, "jpg", StringComparison.OrdinalIgnoreCase)) + { return ResolveImageJPGFormat(width, height); + } if (string.Equals(container, "png", StringComparison.OrdinalIgnoreCase)) { @@ -499,7 +492,7 @@ namespace MediaBrowser.Model.Dlna return null; } - private MediaFormatProfile ResolveImageJPGFormat(int? width, int? height) + private static MediaFormatProfile ResolveImageJPGFormat(int? width, int? height) { if (width.HasValue && height.HasValue) { @@ -524,7 +517,7 @@ namespace MediaBrowser.Model.Dlna return MediaFormatProfile.JPEG_SM; } - private MediaFormatProfile ResolveImagePNGFormat(int? width, int? height) + private static MediaFormatProfile ResolveImagePNGFormat(int? width, int? height) { if (width.HasValue && height.HasValue) { diff --git a/MediaBrowser.Model/Dlna/ResolutionConfiguration.cs b/MediaBrowser.Model/Dlna/ResolutionConfiguration.cs index 30c44fbe0e..f8f76c69d8 100644 --- a/MediaBrowser.Model/Dlna/ResolutionConfiguration.cs +++ b/MediaBrowser.Model/Dlna/ResolutionConfiguration.cs @@ -4,14 +4,14 @@ namespace MediaBrowser.Model.Dlna { public class ResolutionConfiguration { - public int MaxWidth { get; set; } - - public int MaxBitrate { get; set; } - public ResolutionConfiguration(int maxWidth, int maxBitrate) { MaxWidth = maxWidth; MaxBitrate = maxBitrate; } + + public int MaxWidth { get; set; } + + public int MaxBitrate { get; set; } } } diff --git a/MediaBrowser.Model/Dlna/ResolutionNormalizer.cs b/MediaBrowser.Model/Dlna/ResolutionNormalizer.cs index a4305c8104..65fccbdd41 100644 --- a/MediaBrowser.Model/Dlna/ResolutionNormalizer.cs +++ b/MediaBrowser.Model/Dlna/ResolutionNormalizer.cs @@ -15,7 +15,7 @@ namespace MediaBrowser.Model.Dlna new ResolutionConfiguration(720, 950000), new ResolutionConfiguration(1280, 2500000), new ResolutionConfiguration(1920, 4000000), - new ResolutionConfiguration(2560, 8000000), + new ResolutionConfiguration(2560, 20000000), new ResolutionConfiguration(3840, 35000000) }; @@ -29,7 +29,7 @@ namespace MediaBrowser.Model.Dlna int? maxWidth, int? maxHeight) { - // If the bitrate isn't changing, then don't downlscale the resolution + // If the bitrate isn't changing, then don't downscale the resolution if (inputBitrate.HasValue && outputBitrate >= inputBitrate.Value) { if (maxWidth.HasValue || maxHeight.HasValue) @@ -80,11 +80,11 @@ namespace MediaBrowser.Model.Dlna private static double GetVideoBitrateScaleFactor(string codec) { - if (string.Equals(codec, "h265", StringComparison.OrdinalIgnoreCase) || - string.Equals(codec, "hevc", StringComparison.OrdinalIgnoreCase) || - string.Equals(codec, "vp9", StringComparison.OrdinalIgnoreCase)) + if (string.Equals(codec, "h265", StringComparison.OrdinalIgnoreCase) + || string.Equals(codec, "hevc", StringComparison.OrdinalIgnoreCase) + || string.Equals(codec, "vp9", StringComparison.OrdinalIgnoreCase)) { - return .5; + return .6; } return 1; diff --git a/MediaBrowser.Model/Dlna/ResponseProfile.cs b/MediaBrowser.Model/Dlna/ResponseProfile.cs index 48f53f06c2..bf9661f7f3 100644 --- a/MediaBrowser.Model/Dlna/ResponseProfile.cs +++ b/MediaBrowser.Model/Dlna/ResponseProfile.cs @@ -8,6 +8,11 @@ namespace MediaBrowser.Model.Dlna { public class ResponseProfile { + public ResponseProfile() + { + Conditions = Array.Empty<ProfileCondition>(); + } + [XmlAttribute("container")] public string Container { get; set; } @@ -28,11 +33,6 @@ namespace MediaBrowser.Model.Dlna public ProfileCondition[] Conditions { get; set; } - public ResponseProfile() - { - Conditions = Array.Empty<ProfileCondition>(); - } - public string[] GetContainers() { return ContainerProfile.SplitValue(Container); diff --git a/MediaBrowser.Model/Dlna/SearchCriteria.cs b/MediaBrowser.Model/Dlna/SearchCriteria.cs index 94f5bd3dbe..b1fc48c087 100644 --- a/MediaBrowser.Model/Dlna/SearchCriteria.cs +++ b/MediaBrowser.Model/Dlna/SearchCriteria.cs @@ -7,31 +7,6 @@ namespace MediaBrowser.Model.Dlna { public class SearchCriteria { - public SearchType SearchType { get; set; } - - /// <summary> - /// Splits the specified string. - /// </summary> - /// <param name="str">The string.</param> - /// <param name="term">The term.</param> - /// <param name="limit">The limit.</param> - /// <returns>System.String[].</returns> - private static string[] RegexSplit(string str, string term, int limit) - { - return new Regex(term).Split(str, limit); - } - - /// <summary> - /// Splits the specified string. - /// </summary> - /// <param name="str">The string.</param> - /// <param name="term">The term.</param> - /// <returns>System.String[].</returns> - private static string[] RegexSplit(string str, string term) - { - return Regex.Split(str, term, RegexOptions.IgnoreCase); - } - public SearchCriteria(string search) { if (search.Length == 0) @@ -48,8 +23,8 @@ namespace MediaBrowser.Model.Dlna if (subFactors.Length == 3) { - if (string.Equals("upnp:class", subFactors[0], StringComparison.OrdinalIgnoreCase) && - (string.Equals("=", subFactors[1], StringComparison.Ordinal) || string.Equals("derivedfrom", subFactors[1], StringComparison.OrdinalIgnoreCase))) + if (string.Equals("upnp:class", subFactors[0], StringComparison.OrdinalIgnoreCase) + && (string.Equals("=", subFactors[1], StringComparison.Ordinal) || string.Equals("derivedfrom", subFactors[1], StringComparison.OrdinalIgnoreCase))) { if (string.Equals("\"object.item.imageItem\"", subFactors[2], StringComparison.Ordinal) || string.Equals("\"object.item.imageItem.photo\"", subFactors[2], StringComparison.OrdinalIgnoreCase)) { @@ -71,5 +46,30 @@ namespace MediaBrowser.Model.Dlna } } } + + public SearchType SearchType { get; set; } + + /// <summary> + /// Splits the specified string. + /// </summary> + /// <param name="str">The string.</param> + /// <param name="term">The term.</param> + /// <param name="limit">The limit.</param> + /// <returns>System.String[].</returns> + private static string[] RegexSplit(string str, string term, int limit) + { + return new Regex(term).Split(str, limit); + } + + /// <summary> + /// Splits the specified string. + /// </summary> + /// <param name="str">The string.</param> + /// <param name="term">The term.</param> + /// <returns>System.String[].</returns> + private static string[] RegexSplit(string str, string term) + { + return Regex.Split(str, term, RegexOptions.IgnoreCase); + } } } diff --git a/MediaBrowser.Model/Dlna/SortCriteria.cs b/MediaBrowser.Model/Dlna/SortCriteria.cs index 53e4540cbb..7769d0bd3e 100644 --- a/MediaBrowser.Model/Dlna/SortCriteria.cs +++ b/MediaBrowser.Model/Dlna/SortCriteria.cs @@ -6,10 +6,10 @@ namespace MediaBrowser.Model.Dlna { public class SortCriteria { - public SortOrder SortOrder => SortOrder.Ascending; - public SortCriteria(string value) { } + + public SortOrder SortOrder => SortOrder.Ascending; } } diff --git a/MediaBrowser.Model/Dlna/StreamBuilder.cs b/MediaBrowser.Model/Dlna/StreamBuilder.cs index 4959a9b922..f4c69fe8f5 100644 --- a/MediaBrowser.Model/Dlna/StreamBuilder.cs +++ b/MediaBrowser.Model/Dlna/StreamBuilder.cs @@ -227,7 +227,7 @@ namespace MediaBrowser.Model.Dlna } } - public static string NormalizeMediaSourceFormatIntoSingleContainer(string inputContainer, string _, DeviceProfile profile, DlnaProfileType type) + public static string NormalizeMediaSourceFormatIntoSingleContainer(string inputContainer, DeviceProfile profile, DlnaProfileType type) { if (string.IsNullOrEmpty(inputContainer)) { @@ -274,14 +274,14 @@ namespace MediaBrowser.Model.Dlna if (options.ForceDirectPlay) { playlistItem.PlayMethod = PlayMethod.DirectPlay; - playlistItem.Container = NormalizeMediaSourceFormatIntoSingleContainer(item.Container, item.Path, options.Profile, DlnaProfileType.Audio); + playlistItem.Container = NormalizeMediaSourceFormatIntoSingleContainer(item.Container, options.Profile, DlnaProfileType.Audio); return playlistItem; } if (options.ForceDirectStream) { playlistItem.PlayMethod = PlayMethod.DirectStream; - playlistItem.Container = NormalizeMediaSourceFormatIntoSingleContainer(item.Container, item.Path, options.Profile, DlnaProfileType.Audio); + playlistItem.Container = NormalizeMediaSourceFormatIntoSingleContainer(item.Container, options.Profile, DlnaProfileType.Audio); return playlistItem; } @@ -297,7 +297,7 @@ namespace MediaBrowser.Model.Dlna int? inputAudioSampleRate = audioStream?.SampleRate; int? inputAudioBitDepth = audioStream?.BitDepth; - if (directPlayMethods.Count() > 0) + if (directPlayMethods.Any()) { string audioCodec = audioStream?.Codec; @@ -349,7 +349,7 @@ namespace MediaBrowser.Model.Dlna playlistItem.PlayMethod = PlayMethod.DirectStream; } - playlistItem.Container = NormalizeMediaSourceFormatIntoSingleContainer(item.Container, item.Path, options.Profile, DlnaProfileType.Audio); + playlistItem.Container = NormalizeMediaSourceFormatIntoSingleContainer(item.Container, options.Profile, DlnaProfileType.Audio); return playlistItem; } @@ -514,6 +514,8 @@ namespace MediaBrowser.Model.Dlna private static List<TranscodeReason> GetTranscodeReasonsFromDirectPlayProfile(MediaSourceInfo item, MediaStream videoStream, MediaStream audioStream, IEnumerable<DirectPlayProfile> directPlayProfiles) { + var mediaType = videoStream == null ? DlnaProfileType.Audio : DlnaProfileType.Video; + var containerSupported = false; var audioSupported = false; var videoSupported = false; @@ -521,7 +523,7 @@ namespace MediaBrowser.Model.Dlna foreach (var profile in directPlayProfiles) { // Check container type - if (profile.SupportsContainer(item.Container)) + if (profile.Type == mediaType && profile.SupportsContainer(item.Container)) { containerSupported = true; @@ -674,7 +676,7 @@ namespace MediaBrowser.Model.Dlna var videoStream = item.VideoStream; - // TODO: This doesn't accout for situation of device being able to handle media bitrate, but wifi connection not fast enough + // TODO: This doesn't account for situations where the device is able to handle the media's bitrate, but the connection isn't fast enough var directPlayEligibilityResult = IsEligibleForDirectPlay(item, GetBitrateForDirectPlayCheck(item, options, true) ?? 0, subtitleStream, options, PlayMethod.DirectPlay); var directStreamEligibilityResult = IsEligibleForDirectPlay(item, options.GetMaxBitrate(false) ?? 0, subtitleStream, options, PlayMethod.DirectStream); bool isEligibleForDirectPlay = options.EnableDirectPlay && (options.ForceDirectPlay || directPlayEligibilityResult.Item1); @@ -698,7 +700,7 @@ namespace MediaBrowser.Model.Dlna if (directPlay != null) { playlistItem.PlayMethod = directPlay.Value; - playlistItem.Container = NormalizeMediaSourceFormatIntoSingleContainer(item.Container, item.Path, options.Profile, DlnaProfileType.Video); + playlistItem.Container = NormalizeMediaSourceFormatIntoSingleContainer(item.Container, options.Profile, DlnaProfileType.Video); if (subtitleStream != null) { @@ -872,11 +874,34 @@ namespace MediaBrowser.Model.Dlna return playlistItem; } - private static int GetDefaultAudioBitrateIfUnknown(MediaStream audioStream) + private static int GetDefaultAudioBitrate(string audioCodec, int? audioChannels) { - if ((audioStream.Channels ?? 0) >= 6) + if (!string.IsNullOrEmpty(audioCodec)) { - return 384000; + // Default to a higher bitrate for stream copy + if (string.Equals(audioCodec, "aac", StringComparison.OrdinalIgnoreCase) + || string.Equals(audioCodec, "mp3", StringComparison.OrdinalIgnoreCase) + || string.Equals(audioCodec, "ac3", StringComparison.OrdinalIgnoreCase) + || string.Equals(audioCodec, "eac3", StringComparison.OrdinalIgnoreCase)) + { + if ((audioChannels ?? 0) < 2) + { + return 128000; + } + + return (audioChannels ?? 0) >= 6 ? 640000 : 384000; + } + + if (string.Equals(audioCodec, "flac", StringComparison.OrdinalIgnoreCase) + || string.Equals(audioCodec, "alac", StringComparison.OrdinalIgnoreCase)) + { + if ((audioChannels ?? 0) < 2) + { + return 768000; + } + + return (audioChannels ?? 0) >= 6 ? 3584000 : 1536000; + } } return 192000; @@ -897,14 +922,27 @@ namespace MediaBrowser.Model.Dlna } else { - if (targetAudioChannels.HasValue && audioStream.Channels.HasValue && targetAudioChannels.Value < audioStream.Channels.Value) + if (targetAudioChannels.HasValue + && audioStream.Channels.HasValue + && audioStream.Channels.Value > targetAudioChannels.Value) { - // Reduce the bitrate if we're downmixing - defaultBitrate = targetAudioChannels.Value < 2 ? 128000 : 192000; + // Reduce the bitrate if we're downmixing. + defaultBitrate = GetDefaultAudioBitrate(targetAudioCodec, targetAudioChannels); + } + else if (targetAudioChannels.HasValue + && audioStream.Channels.HasValue + && audioStream.Channels.Value <= targetAudioChannels.Value + && !string.IsNullOrEmpty(audioStream.Codec) + && targetAudioCodecs != null + && targetAudioCodecs.Length > 0 + && !Array.Exists(targetAudioCodecs, elem => string.Equals(audioStream.Codec, elem, StringComparison.OrdinalIgnoreCase))) + { + // Shift the bitrate if we're transcoding to a different audio codec. + defaultBitrate = GetDefaultAudioBitrate(targetAudioCodec, audioStream.Channels.Value); } else { - defaultBitrate = audioStream.BitRate ?? GetDefaultAudioBitrateIfUnknown(audioStream); + defaultBitrate = audioStream.BitRate ?? GetDefaultAudioBitrate(targetAudioCodec, targetAudioChannels); } // Seeing webm encoding failures when source has 1 audio channel and 22k bitrate. @@ -938,8 +976,28 @@ namespace MediaBrowser.Model.Dlna { return 448000; } + else if (totalBitrate <= 4000000) + { + return 640000; + } + else if (totalBitrate <= 5000000) + { + return 768000; + } + else if (totalBitrate <= 10000000) + { + return 1536000; + } + else if (totalBitrate <= 15000000) + { + return 2304000; + } + else if (totalBitrate <= 20000000) + { + return 3584000; + } - return 640000; + return 7168000; } private (PlayMethod?, List<TranscodeReason>) GetVideoDirectPlayProfile( @@ -961,14 +1019,15 @@ namespace MediaBrowser.Model.Dlna } DeviceProfile profile = options.Profile; + string container = mediaSource.Container; // See if it can be direct played DirectPlayProfile directPlay = null; - foreach (var i in profile.DirectPlayProfiles) + foreach (var p in profile.DirectPlayProfiles) { - if (i.Type == DlnaProfileType.Video && IsVideoDirectPlaySupported(i, mediaSource, videoStream, audioStream)) + if (p.Type == DlnaProfileType.Video && IsVideoDirectPlaySupported(p, container, videoStream, audioStream)) { - directPlay = i; + directPlay = p; break; } } @@ -976,23 +1035,23 @@ namespace MediaBrowser.Model.Dlna if (directPlay == null) { _logger.LogInformation( - "Profile: {0}, No video direct play profiles found for {1} with codec {2}", - profile.Name ?? "Unknown Profile", - mediaSource.Path ?? "Unknown path", - videoStream.Codec ?? "Unknown codec"); + "Container: {Container}, Video: {Video}, Audio: {Audio} cannot be direct played by profile: {Profile} for path: {Path}", + container, + videoStream?.Codec ?? "no video", + audioStream?.Codec ?? "no audio", + profile.Name ?? "unknown profile", + mediaSource.Path ?? "unknown path"); return (null, GetTranscodeReasonsFromDirectPlayProfile(mediaSource, videoStream, audioStream, profile.DirectPlayProfiles)); } - string container = mediaSource.Container; - var conditions = new List<ProfileCondition>(); - foreach (var i in profile.ContainerProfiles) + foreach (var p in profile.ContainerProfiles) { - if (i.Type == DlnaProfileType.Video - && i.ContainsContainer(container)) + if (p.Type == DlnaProfileType.Video + && p.ContainsContainer(container)) { - foreach (var c in i.Conditions) + foreach (var c in p.Conditions) { conditions.Add(c); } @@ -1348,7 +1407,9 @@ namespace MediaBrowser.Model.Dlna { _logger.LogInformation( "Bitrate exceeds {PlayBackMethod} limit: media bitrate: {MediaBitrate}, max bitrate: {MaxBitrate}", - playMethod, itemBitrate, requestedMaxBitrate); + playMethod, + itemBitrate, + requestedMaxBitrate); return false; } @@ -1438,6 +1499,32 @@ namespace MediaBrowser.Model.Dlna break; } + case ProfileConditionValue.AudioSampleRate: + { + if (!enableNonQualifiedConditions) + { + continue; + } + + if (int.TryParse(value, NumberStyles.Any, CultureInfo.InvariantCulture, out var num)) + { + if (condition.Condition == ProfileConditionType.Equals) + { + item.AudioSampleRate = num; + } + else if (condition.Condition == ProfileConditionType.LessThanEqual) + { + item.AudioSampleRate = Math.Min(num, item.AudioSampleRate ?? num); + } + else if (condition.Condition == ProfileConditionType.GreaterThanEqual) + { + item.AudioSampleRate = Math.Max(num, item.AudioSampleRate ?? num); + } + } + + break; + } + case ProfileConditionValue.AudioChannels: { if (string.IsNullOrEmpty(qualifier)) @@ -1647,11 +1734,11 @@ namespace MediaBrowser.Model.Dlna // strip spaces to avoid having to encode var values = value - .Split(new[] { '|' }, StringSplitOptions.RemoveEmptyEntries); + .Split('|', StringSplitOptions.RemoveEmptyEntries); if (condition.Condition == ProfileConditionType.Equals || condition.Condition == ProfileConditionType.EqualsAny) { - item.SetOption(qualifier, "profile", string.Join(",", values)); + item.SetOption(qualifier, "profile", string.Join(',', values)); } } @@ -1812,10 +1899,10 @@ namespace MediaBrowser.Model.Dlna return true; } - private bool IsVideoDirectPlaySupported(DirectPlayProfile profile, MediaSourceInfo item, MediaStream videoStream, MediaStream audioStream) + private bool IsVideoDirectPlaySupported(DirectPlayProfile profile, string container, MediaStream videoStream, MediaStream audioStream) { // Check container type - if (!profile.SupportsContainer(item.Container)) + if (!profile.SupportsContainer(container)) { return false; } diff --git a/MediaBrowser.Model/Dlna/StreamInfo.cs b/MediaBrowser.Model/Dlna/StreamInfo.cs index 9399d21f16..252872847a 100644 --- a/MediaBrowser.Model/Dlna/StreamInfo.cs +++ b/MediaBrowser.Model/Dlna/StreamInfo.cs @@ -27,45 +27,6 @@ namespace MediaBrowser.Model.Dlna StreamOptions = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase); } - public void SetOption(string qualifier, string name, string value) - { - if (string.IsNullOrEmpty(qualifier)) - { - SetOption(name, value); - } - else - { - SetOption(qualifier + "-" + name, value); - } - } - - public void SetOption(string name, string value) - { - StreamOptions[name] = value; - } - - public string GetOption(string qualifier, string name) - { - var value = GetOption(qualifier + "-" + name); - - if (string.IsNullOrEmpty(value)) - { - value = GetOption(name); - } - - return value; - } - - public string GetOption(string name) - { - if (StreamOptions.TryGetValue(name, out var value)) - { - return value; - } - - return null; - } - public Guid ItemId { get; set; } public PlayMethod PlayMethod { get; set; } @@ -110,6 +71,8 @@ namespace MediaBrowser.Model.Dlna public int? AudioBitrate { get; set; } + public int? AudioSampleRate { get; set; } + public int? VideoBitrate { get; set; } public int? MaxWidth { get; set; } @@ -150,882 +113,904 @@ namespace MediaBrowser.Model.Dlna PlayMethod == PlayMethod.DirectStream || PlayMethod == PlayMethod.DirectPlay; - public string ToUrl(string baseUrl, string accessToken) - { - if (PlayMethod == PlayMethod.DirectPlay) - { - return MediaSource.Path; - } + /// <summary> + /// Gets the audio stream that will be used. + /// </summary> + public MediaStream TargetAudioStream => MediaSource?.GetDefaultAudioStream(AudioStreamIndex); - if (string.IsNullOrEmpty(baseUrl)) + /// <summary> + /// Gets the video stream that will be used. + /// </summary> + public MediaStream TargetVideoStream => MediaSource?.VideoStream; + + /// <summary> + /// Gets the audio sample rate that will be in the output stream. + /// </summary> + public int? TargetAudioSampleRate + { + get { - throw new ArgumentNullException(nameof(baseUrl)); + var stream = TargetAudioStream; + return AudioSampleRate.HasValue && !IsDirectStream + ? AudioSampleRate + : stream == null ? null : stream.SampleRate; } + } - var list = new List<string>(); - foreach (NameValuePair pair in BuildParams(this, accessToken)) + /// <summary> + /// Gets the audio sample rate that will be in the output stream. + /// </summary> + public int? TargetAudioBitDepth + { + get { - if (string.IsNullOrEmpty(pair.Value)) + if (IsDirectStream) { - continue; + return TargetAudioStream == null ? (int?)null : TargetAudioStream.BitDepth; } - // Try to keep the url clean by omitting defaults - if (string.Equals(pair.Name, "StartTimeTicks", StringComparison.OrdinalIgnoreCase) && - string.Equals(pair.Value, "0", StringComparison.OrdinalIgnoreCase)) + var targetAudioCodecs = TargetAudioCodec; + var audioCodec = targetAudioCodecs.Length == 0 ? null : targetAudioCodecs[0]; + if (!string.IsNullOrEmpty(audioCodec)) { - continue; + return GetTargetAudioBitDepth(audioCodec); } - if (string.Equals(pair.Name, "SubtitleStreamIndex", StringComparison.OrdinalIgnoreCase) && - string.Equals(pair.Value, "-1", StringComparison.OrdinalIgnoreCase)) + return TargetAudioStream == null ? (int?)null : TargetAudioStream.BitDepth; + } + } + + /// <summary> + /// Gets the audio sample rate that will be in the output stream. + /// </summary> + public int? TargetVideoBitDepth + { + get + { + if (IsDirectStream) { - continue; + return TargetVideoStream == null ? (int?)null : TargetVideoStream.BitDepth; } - if (string.Equals(pair.Name, "Static", StringComparison.OrdinalIgnoreCase) && - string.Equals(pair.Value, "false", StringComparison.OrdinalIgnoreCase)) + var targetVideoCodecs = TargetVideoCodec; + var videoCodec = targetVideoCodecs.Length == 0 ? null : targetVideoCodecs[0]; + if (!string.IsNullOrEmpty(videoCodec)) { - continue; + return GetTargetVideoBitDepth(videoCodec); } - var encodedValue = pair.Value.Replace(" ", "%20"); - - list.Add(string.Format(CultureInfo.InvariantCulture, "{0}={1}", pair.Name, encodedValue)); + return TargetVideoStream == null ? (int?)null : TargetVideoStream.BitDepth; } - - string queryString = string.Join("&", list.ToArray()); - - return GetUrl(baseUrl, queryString); } - private string GetUrl(string baseUrl, string queryString) + /// <summary> + /// Gets the target reference frames. + /// </summary> + /// <value>The target reference frames.</value> + public int? TargetRefFrames { - if (string.IsNullOrEmpty(baseUrl)) - { - throw new ArgumentNullException(nameof(baseUrl)); - } - - string extension = string.IsNullOrEmpty(Container) ? string.Empty : "." + Container; - - baseUrl = baseUrl.TrimEnd('/'); - - if (MediaType == DlnaProfileType.Audio) + get { - if (string.Equals(SubProtocol, "hls", StringComparison.OrdinalIgnoreCase)) + if (IsDirectStream) { - return string.Format(CultureInfo.InvariantCulture, "{0}/audio/{1}/master.m3u8?{2}", baseUrl, ItemId, queryString); + return TargetVideoStream == null ? (int?)null : TargetVideoStream.RefFrames; } - return string.Format(CultureInfo.InvariantCulture, "{0}/audio/{1}/stream{2}?{3}", baseUrl, ItemId, extension, queryString); - } + var targetVideoCodecs = TargetVideoCodec; + var videoCodec = targetVideoCodecs.Length == 0 ? null : targetVideoCodecs[0]; + if (!string.IsNullOrEmpty(videoCodec)) + { + return GetTargetRefFrames(videoCodec); + } - if (string.Equals(SubProtocol, "hls", StringComparison.OrdinalIgnoreCase)) - { - return string.Format(CultureInfo.InvariantCulture, "{0}/videos/{1}/master.m3u8?{2}", baseUrl, ItemId, queryString); + return TargetVideoStream == null ? (int?)null : TargetVideoStream.RefFrames; } - - return string.Format(CultureInfo.InvariantCulture, "{0}/videos/{1}/stream{2}?{3}", baseUrl, ItemId, extension, queryString); } - private static List<NameValuePair> BuildParams(StreamInfo item, string accessToken) + /// <summary> + /// Gets the audio sample rate that will be in the output stream. + /// </summary> + public float? TargetFramerate { - var list = new List<NameValuePair>(); - - string audioCodecs = item.AudioCodecs.Length == 0 ? - string.Empty : - string.Join(",", item.AudioCodecs); - - string videoCodecs = item.VideoCodecs.Length == 0 ? - string.Empty : - string.Join(",", item.VideoCodecs); - - list.Add(new NameValuePair("DeviceProfileId", item.DeviceProfileId ?? string.Empty)); - list.Add(new NameValuePair("DeviceId", item.DeviceId ?? string.Empty)); - list.Add(new NameValuePair("MediaSourceId", item.MediaSourceId ?? string.Empty)); - list.Add(new NameValuePair("Static", item.IsDirectStream.ToString(CultureInfo.InvariantCulture).ToLowerInvariant())); - list.Add(new NameValuePair("VideoCodec", videoCodecs)); - list.Add(new NameValuePair("AudioCodec", audioCodecs)); - list.Add(new NameValuePair("AudioStreamIndex", item.AudioStreamIndex.HasValue ? item.AudioStreamIndex.Value.ToString(CultureInfo.InvariantCulture) : string.Empty)); - list.Add(new NameValuePair("SubtitleStreamIndex", item.SubtitleStreamIndex.HasValue && item.SubtitleDeliveryMethod != SubtitleDeliveryMethod.External ? item.SubtitleStreamIndex.Value.ToString(CultureInfo.InvariantCulture) : string.Empty)); - list.Add(new NameValuePair("VideoBitrate", item.VideoBitrate.HasValue ? item.VideoBitrate.Value.ToString(CultureInfo.InvariantCulture) : string.Empty)); - list.Add(new NameValuePair("AudioBitrate", item.AudioBitrate.HasValue ? item.AudioBitrate.Value.ToString(CultureInfo.InvariantCulture) : string.Empty)); - - list.Add(new NameValuePair("MaxFramerate", item.MaxFramerate.HasValue ? item.MaxFramerate.Value.ToString(CultureInfo.InvariantCulture) : string.Empty)); - list.Add(new NameValuePair("MaxWidth", item.MaxWidth.HasValue ? item.MaxWidth.Value.ToString(CultureInfo.InvariantCulture) : string.Empty)); - list.Add(new NameValuePair("MaxHeight", item.MaxHeight.HasValue ? item.MaxHeight.Value.ToString(CultureInfo.InvariantCulture) : string.Empty)); - - long startPositionTicks = item.StartPositionTicks; - - var isHls = string.Equals(item.SubProtocol, "hls", StringComparison.OrdinalIgnoreCase); - - if (isHls) - { - list.Add(new NameValuePair("StartTimeTicks", string.Empty)); - } - else + get { - list.Add(new NameValuePair("StartTimeTicks", startPositionTicks.ToString(CultureInfo.InvariantCulture))); + var stream = TargetVideoStream; + return MaxFramerate.HasValue && !IsDirectStream + ? MaxFramerate + : stream == null ? null : stream.AverageFrameRate ?? stream.RealFrameRate; } + } - list.Add(new NameValuePair("PlaySessionId", item.PlaySessionId ?? string.Empty)); - list.Add(new NameValuePair("api_key", accessToken ?? string.Empty)); - - string liveStreamId = item.MediaSource?.LiveStreamId; - list.Add(new NameValuePair("LiveStreamId", liveStreamId ?? string.Empty)); - - list.Add(new NameValuePair("SubtitleMethod", item.SubtitleStreamIndex.HasValue && item.SubtitleDeliveryMethod != SubtitleDeliveryMethod.External ? item.SubtitleDeliveryMethod.ToString() : string.Empty)); - - if (!item.IsDirectStream) + /// <summary> + /// Gets the audio sample rate that will be in the output stream. + /// </summary> + public double? TargetVideoLevel + { + get { - if (item.RequireNonAnamorphic) + if (IsDirectStream) { - list.Add(new NameValuePair("RequireNonAnamorphic", item.RequireNonAnamorphic.ToString(CultureInfo.InvariantCulture).ToLowerInvariant())); + return TargetVideoStream == null ? (double?)null : TargetVideoStream.Level; } - list.Add(new NameValuePair("TranscodingMaxAudioChannels", item.TranscodingMaxAudioChannels.HasValue ? item.TranscodingMaxAudioChannels.Value.ToString(CultureInfo.InvariantCulture) : string.Empty)); - - if (item.EnableSubtitlesInManifest) + var targetVideoCodecs = TargetVideoCodec; + var videoCodec = targetVideoCodecs.Length == 0 ? null : targetVideoCodecs[0]; + if (!string.IsNullOrEmpty(videoCodec)) { - list.Add(new NameValuePair("EnableSubtitlesInManifest", item.EnableSubtitlesInManifest.ToString(CultureInfo.InvariantCulture).ToLowerInvariant())); + return GetTargetVideoLevel(videoCodec); } - if (item.EnableMpegtsM2TsMode) - { - list.Add(new NameValuePair("EnableMpegtsM2TsMode", item.EnableMpegtsM2TsMode.ToString(CultureInfo.InvariantCulture).ToLowerInvariant())); - } + return TargetVideoStream == null ? (double?)null : TargetVideoStream.Level; + } + } - if (item.EstimateContentLength) - { - list.Add(new NameValuePair("EstimateContentLength", item.EstimateContentLength.ToString(CultureInfo.InvariantCulture).ToLowerInvariant())); - } + /// <summary> + /// Gets the audio sample rate that will be in the output stream. + /// </summary> + public int? TargetPacketLength + { + get + { + var stream = TargetVideoStream; + return !IsDirectStream + ? null + : stream == null ? null : stream.PacketLength; + } + } - if (item.TranscodeSeekInfo != TranscodeSeekInfo.Auto) + /// <summary> + /// Gets the audio sample rate that will be in the output stream. + /// </summary> + public string TargetVideoProfile + { + get + { + if (IsDirectStream) { - list.Add(new NameValuePair("TranscodeSeekInfo", item.TranscodeSeekInfo.ToString().ToLowerInvariant())); + return TargetVideoStream == null ? null : TargetVideoStream.Profile; } - if (item.CopyTimestamps) + var targetVideoCodecs = TargetVideoCodec; + var videoCodec = targetVideoCodecs.Length == 0 ? null : targetVideoCodecs[0]; + if (!string.IsNullOrEmpty(videoCodec)) { - list.Add(new NameValuePair("CopyTimestamps", item.CopyTimestamps.ToString(CultureInfo.InvariantCulture).ToLowerInvariant())); + return GetOption(videoCodec, "profile"); } - list.Add(new NameValuePair("RequireAvc", item.RequireAvc.ToString(CultureInfo.InvariantCulture).ToLowerInvariant())); - } - - list.Add(new NameValuePair("Tag", item.MediaSource.ETag ?? string.Empty)); - - string subtitleCodecs = item.SubtitleCodecs.Length == 0 ? - string.Empty : - string.Join(",", item.SubtitleCodecs); - - list.Add(new NameValuePair("SubtitleCodec", item.SubtitleStreamIndex.HasValue && item.SubtitleDeliveryMethod == SubtitleDeliveryMethod.Embed ? subtitleCodecs : string.Empty)); - - if (isHls) - { - list.Add(new NameValuePair("SegmentContainer", item.Container ?? string.Empty)); - - if (item.SegmentLength.HasValue) - { - list.Add(new NameValuePair("SegmentLength", item.SegmentLength.Value.ToString(CultureInfo.InvariantCulture))); - } - - if (item.MinSegments.HasValue) - { - list.Add(new NameValuePair("MinSegments", item.MinSegments.Value.ToString(CultureInfo.InvariantCulture))); - } - - list.Add(new NameValuePair("BreakOnNonKeyFrames", item.BreakOnNonKeyFrames.ToString(CultureInfo.InvariantCulture))); - } - - foreach (var pair in item.StreamOptions) - { - if (string.IsNullOrEmpty(pair.Value)) - { - continue; - } - - // strip spaces to avoid having to encode h264 profile names - list.Add(new NameValuePair(pair.Key, pair.Value.Replace(" ", ""))); + return TargetVideoStream == null ? null : TargetVideoStream.Profile; } + } - if (!item.IsDirectStream) + /// <summary> + /// Gets the target video codec tag. + /// </summary> + /// <value>The target video codec tag.</value> + public string TargetVideoCodecTag + { + get { - list.Add(new NameValuePair("TranscodeReasons", string.Join(",", item.TranscodeReasons.Distinct().Select(i => i.ToString())))); + var stream = TargetVideoStream; + return !IsDirectStream + ? null + : stream == null ? null : stream.CodecTag; } - - return list; } - public List<SubtitleStreamInfo> GetExternalSubtitles(ITranscoderSupport transcoderSupport, bool includeSelectedTrackOnly, string baseUrl, string accessToken) + /// <summary> + /// Gets the audio bitrate that will be in the output stream. + /// </summary> + public int? TargetAudioBitrate { - return GetExternalSubtitles(transcoderSupport, includeSelectedTrackOnly, false, baseUrl, accessToken); + get + { + var stream = TargetAudioStream; + return AudioBitrate.HasValue && !IsDirectStream + ? AudioBitrate + : stream == null ? null : stream.BitRate; + } } - public List<SubtitleStreamInfo> GetExternalSubtitles(ITranscoderSupport transcoderSupport, bool includeSelectedTrackOnly, bool enableAllProfiles, string baseUrl, string accessToken) + /// <summary> + /// Gets the audio channels that will be in the output stream. + /// </summary> + public int? TargetAudioChannels { - var list = GetSubtitleProfiles(transcoderSupport, includeSelectedTrackOnly, enableAllProfiles, baseUrl, accessToken); - var newList = new List<SubtitleStreamInfo>(); - - // First add the selected track - foreach (SubtitleStreamInfo stream in list) + get { - if (stream.DeliveryMethod == SubtitleDeliveryMethod.External) + if (IsDirectStream) { - newList.Add(stream); + return TargetAudioStream == null ? (int?)null : TargetAudioStream.Channels; } - } - return newList; - } + var targetAudioCodecs = TargetAudioCodec; + var codec = targetAudioCodecs.Length == 0 ? null : targetAudioCodecs[0]; + if (!string.IsNullOrEmpty(codec)) + { + return GetTargetRefFrames(codec); + } - public List<SubtitleStreamInfo> GetSubtitleProfiles(ITranscoderSupport transcoderSupport, bool includeSelectedTrackOnly, string baseUrl, string accessToken) - { - return GetSubtitleProfiles(transcoderSupport, includeSelectedTrackOnly, false, baseUrl, accessToken); + return TargetAudioStream == null ? (int?)null : TargetAudioStream.Channels; + } } - public List<SubtitleStreamInfo> GetSubtitleProfiles(ITranscoderSupport transcoderSupport, bool includeSelectedTrackOnly, bool enableAllProfiles, string baseUrl, string accessToken) + /// <summary> + /// Gets the audio codec that will be in the output stream. + /// </summary> + public string[] TargetAudioCodec { - var list = new List<SubtitleStreamInfo>(); + get + { + var stream = TargetAudioStream; - // HLS will preserve timestamps so we can just grab the full subtitle stream - long startPositionTicks = string.Equals(SubProtocol, "hls", StringComparison.OrdinalIgnoreCase) - ? 0 - : (PlayMethod == PlayMethod.Transcode && !CopyTimestamps ? StartPositionTicks : 0); + string inputCodec = stream?.Codec; - // First add the selected track - if (SubtitleStreamIndex.HasValue) - { - foreach (var stream in MediaSource.MediaStreams) + if (IsDirectStream) { - if (stream.Type == MediaStreamType.Subtitle && stream.Index == SubtitleStreamIndex.Value) - { - AddSubtitleProfiles(list, stream, transcoderSupport, enableAllProfiles, baseUrl, accessToken, startPositionTicks); - } + return string.IsNullOrEmpty(inputCodec) ? Array.Empty<string>() : new[] { inputCodec }; } - } - if (!includeSelectedTrackOnly) - { - foreach (var stream in MediaSource.MediaStreams) + foreach (string codec in AudioCodecs) { - if (stream.Type == MediaStreamType.Subtitle && (!SubtitleStreamIndex.HasValue || stream.Index != SubtitleStreamIndex.Value)) + if (string.Equals(codec, inputCodec, StringComparison.OrdinalIgnoreCase)) { - AddSubtitleProfiles(list, stream, transcoderSupport, enableAllProfiles, baseUrl, accessToken, startPositionTicks); + return string.IsNullOrEmpty(codec) ? Array.Empty<string>() : new[] { codec }; } } - } - - return list; - } - - private void AddSubtitleProfiles(List<SubtitleStreamInfo> list, MediaStream stream, ITranscoderSupport transcoderSupport, bool enableAllProfiles, string baseUrl, string accessToken, long startPositionTicks) - { - if (enableAllProfiles) - { - foreach (var profile in DeviceProfile.SubtitleProfiles) - { - var info = GetSubtitleStreamInfo(stream, baseUrl, accessToken, startPositionTicks, new[] { profile }, transcoderSupport); - - list.Add(info); - } - } - else - { - var info = GetSubtitleStreamInfo(stream, baseUrl, accessToken, startPositionTicks, DeviceProfile.SubtitleProfiles, transcoderSupport); - list.Add(info); + return AudioCodecs; } } - private SubtitleStreamInfo GetSubtitleStreamInfo(MediaStream stream, string baseUrl, string accessToken, long startPositionTicks, SubtitleProfile[] subtitleProfiles, ITranscoderSupport transcoderSupport) + public string[] TargetVideoCodec { - var subtitleProfile = StreamBuilder.GetSubtitleProfile(MediaSource, stream, subtitleProfiles, PlayMethod, transcoderSupport, Container, SubProtocol); - var info = new SubtitleStreamInfo + get { - IsForced = stream.IsForced, - Language = stream.Language, - Name = stream.Language ?? "Unknown", - Format = subtitleProfile.Format, - Index = stream.Index, - DeliveryMethod = subtitleProfile.Method, - DisplayTitle = stream.DisplayTitle - }; + var stream = TargetVideoStream; - if (info.DeliveryMethod == SubtitleDeliveryMethod.External) - { - if (MediaSource.Protocol == MediaProtocol.File || !string.Equals(stream.Codec, subtitleProfile.Format, StringComparison.OrdinalIgnoreCase) || !stream.IsExternal) + string inputCodec = stream?.Codec; + + if (IsDirectStream) { - info.Url = string.Format(CultureInfo.InvariantCulture, "{0}/Videos/{1}/{2}/Subtitles/{3}/{4}/Stream.{5}", - baseUrl, - ItemId, - MediaSourceId, - stream.Index.ToString(CultureInfo.InvariantCulture), - startPositionTicks.ToString(CultureInfo.InvariantCulture), - subtitleProfile.Format); + return string.IsNullOrEmpty(inputCodec) ? Array.Empty<string>() : new[] { inputCodec }; + } - if (!string.IsNullOrEmpty(accessToken)) + foreach (string codec in VideoCodecs) + { + if (string.Equals(codec, inputCodec, StringComparison.OrdinalIgnoreCase)) { - info.Url += "?api_key=" + accessToken; + return string.IsNullOrEmpty(codec) ? Array.Empty<string>() : new[] { codec }; } - - info.IsExternalUrl = false; - } - else - { - info.Url = stream.Path; - info.IsExternalUrl = true; } - } - return info; + return VideoCodecs; + } } /// <summary> - /// Returns the audio stream that will be used. + /// Gets the audio channels that will be in the output stream. /// </summary> - public MediaStream TargetAudioStream + public long? TargetSize { get { - if (MediaSource != null) + if (IsDirectStream) + { + return MediaSource.Size; + } + + if (RunTimeTicks.HasValue) { - return MediaSource.GetDefaultAudioStream(AudioStreamIndex); + int? totalBitrate = TargetTotalBitrate; + + double totalSeconds = RunTimeTicks.Value; + // Convert to ms + totalSeconds /= 10000; + // Convert to seconds + totalSeconds /= 1000; + + return totalBitrate.HasValue ? + Convert.ToInt64(totalBitrate.Value * totalSeconds) : + (long?)null; } return null; } } - /// <summary> - /// Returns the video stream that will be used. - /// </summary> - public MediaStream TargetVideoStream + public int? TargetVideoBitrate { get { - if (MediaSource != null) - { - return MediaSource.VideoStream; - } + var stream = TargetVideoStream; - return null; + return VideoBitrate.HasValue && !IsDirectStream + ? VideoBitrate + : stream == null ? null : stream.BitRate; } } - /// <summary> - /// Predicts the audio sample rate that will be in the output stream. - /// </summary> - public int? TargetAudioSampleRate + public TransportStreamTimestamp TargetTimestamp { get { - var stream = TargetAudioStream; - return stream == null ? null : stream.SampleRate; + var defaultValue = string.Equals(Container, "m2ts", StringComparison.OrdinalIgnoreCase) + ? TransportStreamTimestamp.Valid + : TransportStreamTimestamp.None; + + return !IsDirectStream + ? defaultValue + : MediaSource == null ? defaultValue : MediaSource.Timestamp ?? TransportStreamTimestamp.None; } } - /// <summary> - /// Predicts the audio sample rate that will be in the output stream. - /// </summary> - public int? TargetAudioBitDepth + public int? TargetTotalBitrate => (TargetAudioBitrate ?? 0) + (TargetVideoBitrate ?? 0); + + public bool? IsTargetAnamorphic { get { if (IsDirectStream) { - return TargetAudioStream == null ? (int?)null : TargetAudioStream.BitDepth; - } - - var targetAudioCodecs = TargetAudioCodec; - var audioCodec = targetAudioCodecs.Length == 0 ? null : targetAudioCodecs[0]; - if (!string.IsNullOrEmpty(audioCodec)) - { - return GetTargetAudioBitDepth(audioCodec); + return TargetVideoStream == null ? null : TargetVideoStream.IsAnamorphic; } - return TargetAudioStream == null ? (int?)null : TargetAudioStream.BitDepth; + return false; } } - /// <summary> - /// Predicts the audio sample rate that will be in the output stream. - /// </summary> - public int? TargetVideoBitDepth + public bool? IsTargetInterlaced { get { if (IsDirectStream) { - return TargetVideoStream == null ? (int?)null : TargetVideoStream.BitDepth; + return TargetVideoStream == null ? (bool?)null : TargetVideoStream.IsInterlaced; } var targetVideoCodecs = TargetVideoCodec; var videoCodec = targetVideoCodecs.Length == 0 ? null : targetVideoCodecs[0]; if (!string.IsNullOrEmpty(videoCodec)) { - return GetTargetVideoBitDepth(videoCodec); + if (string.Equals(GetOption(videoCodec, "deinterlace"), "true", StringComparison.OrdinalIgnoreCase)) + { + return false; + } } - return TargetVideoStream == null ? (int?)null : TargetVideoStream.BitDepth; + return TargetVideoStream == null ? (bool?)null : TargetVideoStream.IsInterlaced; } } - /// <summary> - /// Gets the target reference frames. - /// </summary> - /// <value>The target reference frames.</value> - public int? TargetRefFrames + public bool? IsTargetAVC { get { if (IsDirectStream) { - return TargetVideoStream == null ? (int?)null : TargetVideoStream.RefFrames; + return TargetVideoStream == null ? null : TargetVideoStream.IsAVC; } - var targetVideoCodecs = TargetVideoCodec; - var videoCodec = targetVideoCodecs.Length == 0 ? null : targetVideoCodecs[0]; - if (!string.IsNullOrEmpty(videoCodec)) + return true; + } + } + + public int? TargetWidth + { + get + { + var videoStream = TargetVideoStream; + + if (videoStream != null && videoStream.Width.HasValue && videoStream.Height.HasValue) { - return GetTargetRefFrames(videoCodec); + ImageDimensions size = new ImageDimensions(videoStream.Width.Value, videoStream.Height.Value); + + size = DrawingUtils.Resize(size, 0, 0, MaxWidth ?? 0, MaxHeight ?? 0); + + return size.Width; } - return TargetVideoStream == null ? (int?)null : TargetVideoStream.RefFrames; + return MaxWidth; } } - /// <summary> - /// Predicts the audio sample rate that will be in the output stream. - /// </summary> - public float? TargetFramerate + public int? TargetHeight { get { - var stream = TargetVideoStream; - return MaxFramerate.HasValue && !IsDirectStream - ? MaxFramerate - : stream == null ? null : stream.AverageFrameRate ?? stream.RealFrameRate; + var videoStream = TargetVideoStream; + + if (videoStream != null && videoStream.Width.HasValue && videoStream.Height.HasValue) + { + ImageDimensions size = new ImageDimensions(videoStream.Width.Value, videoStream.Height.Value); + + size = DrawingUtils.Resize(size, 0, 0, MaxWidth ?? 0, MaxHeight ?? 0); + + return size.Height; + } + + return MaxHeight; } } - /// <summary> - /// Predicts the audio sample rate that will be in the output stream. - /// </summary> - public double? TargetVideoLevel + public int? TargetVideoStreamCount { get { if (IsDirectStream) { - return TargetVideoStream == null ? (double?)null : TargetVideoStream.Level; - } - - var targetVideoCodecs = TargetVideoCodec; - var videoCodec = targetVideoCodecs.Length == 0 ? null : targetVideoCodecs[0]; - if (!string.IsNullOrEmpty(videoCodec)) - { - return GetTargetVideoLevel(videoCodec); + return GetMediaStreamCount(MediaStreamType.Video, int.MaxValue); } - return TargetVideoStream == null ? (double?)null : TargetVideoStream.Level; + return GetMediaStreamCount(MediaStreamType.Video, 1); } } - public int? GetTargetVideoBitDepth(string codec) + public int? TargetAudioStreamCount { - var value = GetOption(codec, "videobitdepth"); - if (string.IsNullOrEmpty(value)) + get { - return null; - } + if (IsDirectStream) + { + return GetMediaStreamCount(MediaStreamType.Audio, int.MaxValue); + } - if (int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var result)) - { - return result; + return GetMediaStreamCount(MediaStreamType.Audio, 1); } - - return null; } - public int? GetTargetAudioBitDepth(string codec) + public void SetOption(string qualifier, string name, string value) { - var value = GetOption(codec, "audiobitdepth"); - if (string.IsNullOrEmpty(value)) + if (string.IsNullOrEmpty(qualifier)) { - return null; + SetOption(name, value); } - - if (int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var result)) + else { - return result; + SetOption(qualifier + "-" + name, value); } + } - return null; + public void SetOption(string name, string value) + { + StreamOptions[name] = value; } - public double? GetTargetVideoLevel(string codec) + public string GetOption(string qualifier, string name) { - var value = GetOption(codec, "level"); + var value = GetOption(qualifier + "-" + name); + if (string.IsNullOrEmpty(value)) { - return null; + value = GetOption(name); } - if (double.TryParse(value, NumberStyles.Any, CultureInfo.InvariantCulture, out var result)) + return value; + } + + public string GetOption(string name) + { + if (StreamOptions.TryGetValue(name, out var value)) { - return result; + return value; } return null; } - public int? GetTargetRefFrames(string codec) + public string ToUrl(string baseUrl, string accessToken) { - var value = GetOption(codec, "maxrefframes"); - if (string.IsNullOrEmpty(value)) + if (PlayMethod == PlayMethod.DirectPlay) { - return null; + return MediaSource.Path; } - if (int.TryParse(value, NumberStyles.Any, CultureInfo.InvariantCulture, out var result)) + if (string.IsNullOrEmpty(baseUrl)) { - return result; + throw new ArgumentNullException(nameof(baseUrl)); } - return null; - } - - /// <summary> - /// Predicts the audio sample rate that will be in the output stream. - /// </summary> - public int? TargetPacketLength - { - get + var list = new List<string>(); + foreach (NameValuePair pair in BuildParams(this, accessToken)) { - var stream = TargetVideoStream; - return !IsDirectStream - ? null - : stream == null ? null : stream.PacketLength; - } - } + if (string.IsNullOrEmpty(pair.Value)) + { + continue; + } - /// <summary> - /// Predicts the audio sample rate that will be in the output stream. - /// </summary> - public string TargetVideoProfile - { - get - { - if (IsDirectStream) + // Try to keep the url clean by omitting defaults + if (string.Equals(pair.Name, "StartTimeTicks", StringComparison.OrdinalIgnoreCase) && + string.Equals(pair.Value, "0", StringComparison.OrdinalIgnoreCase)) { - return TargetVideoStream == null ? null : TargetVideoStream.Profile; + continue; } - var targetVideoCodecs = TargetVideoCodec; - var videoCodec = targetVideoCodecs.Length == 0 ? null : targetVideoCodecs[0]; - if (!string.IsNullOrEmpty(videoCodec)) + if (string.Equals(pair.Name, "SubtitleStreamIndex", StringComparison.OrdinalIgnoreCase) && + string.Equals(pair.Value, "-1", StringComparison.OrdinalIgnoreCase)) { - return GetOption(videoCodec, "profile"); + continue; } - return TargetVideoStream == null ? null : TargetVideoStream.Profile; - } - } + if (string.Equals(pair.Name, "Static", StringComparison.OrdinalIgnoreCase) && + string.Equals(pair.Value, "false", StringComparison.OrdinalIgnoreCase)) + { + continue; + } - /// <summary> - /// Gets the target video codec tag. - /// </summary> - /// <value>The target video codec tag.</value> - public string TargetVideoCodecTag - { - get - { - var stream = TargetVideoStream; - return !IsDirectStream - ? null - : stream == null ? null : stream.CodecTag; + var encodedValue = pair.Value.Replace(" ", "%20"); + + list.Add(string.Format(CultureInfo.InvariantCulture, "{0}={1}", pair.Name, encodedValue)); } + + string queryString = string.Join("&", list.ToArray()); + + return GetUrl(baseUrl, queryString); } - /// <summary> - /// Predicts the audio bitrate that will be in the output stream. - /// </summary> - public int? TargetAudioBitrate + private string GetUrl(string baseUrl, string queryString) { - get + if (string.IsNullOrEmpty(baseUrl)) { - var stream = TargetAudioStream; - return AudioBitrate.HasValue && !IsDirectStream - ? AudioBitrate - : stream == null ? null : stream.BitRate; + throw new ArgumentNullException(nameof(baseUrl)); } - } - /// <summary> - /// Predicts the audio channels that will be in the output stream. - /// </summary> - public int? TargetAudioChannels - { - get - { - if (IsDirectStream) - { - return TargetAudioStream == null ? (int?)null : TargetAudioStream.Channels; - } + string extension = string.IsNullOrEmpty(Container) ? string.Empty : "." + Container; - var targetAudioCodecs = TargetAudioCodec; - var codec = targetAudioCodecs.Length == 0 ? null : targetAudioCodecs[0]; - if (!string.IsNullOrEmpty(codec)) + baseUrl = baseUrl.TrimEnd('/'); + + if (MediaType == DlnaProfileType.Audio) + { + if (string.Equals(SubProtocol, "hls", StringComparison.OrdinalIgnoreCase)) { - return GetTargetRefFrames(codec); + return string.Format(CultureInfo.InvariantCulture, "{0}/audio/{1}/master.m3u8?{2}", baseUrl, ItemId, queryString); } - return TargetAudioStream == null ? (int?)null : TargetAudioStream.Channels; - } - } - - public int? GetTargetAudioChannels(string codec) - { - var defaultValue = GlobalMaxAudioChannels; - - var value = GetOption(codec, "audiochannels"); - if (string.IsNullOrEmpty(value)) - { - return defaultValue; + return string.Format(CultureInfo.InvariantCulture, "{0}/audio/{1}/stream{2}?{3}", baseUrl, ItemId, extension, queryString); } - if (int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var result)) + if (string.Equals(SubProtocol, "hls", StringComparison.OrdinalIgnoreCase)) { - return Math.Min(result, defaultValue ?? result); + return string.Format(CultureInfo.InvariantCulture, "{0}/videos/{1}/master.m3u8?{2}", baseUrl, ItemId, queryString); } - return defaultValue; + return string.Format(CultureInfo.InvariantCulture, "{0}/videos/{1}/stream{2}?{3}", baseUrl, ItemId, extension, queryString); } - /// <summary> - /// Predicts the audio codec that will be in the output stream. - /// </summary> - public string[] TargetAudioCodec + private static List<NameValuePair> BuildParams(StreamInfo item, string accessToken) { - get - { - var stream = TargetAudioStream; - - string inputCodec = stream?.Codec; + var list = new List<NameValuePair>(); - if (IsDirectStream) + string audioCodecs = item.AudioCodecs.Length == 0 ? + string.Empty : + string.Join(",", item.AudioCodecs); + + string videoCodecs = item.VideoCodecs.Length == 0 ? + string.Empty : + string.Join(",", item.VideoCodecs); + + list.Add(new NameValuePair("DeviceProfileId", item.DeviceProfileId ?? string.Empty)); + list.Add(new NameValuePair("DeviceId", item.DeviceId ?? string.Empty)); + list.Add(new NameValuePair("MediaSourceId", item.MediaSourceId ?? string.Empty)); + list.Add(new NameValuePair("Static", item.IsDirectStream.ToString(CultureInfo.InvariantCulture).ToLowerInvariant())); + list.Add(new NameValuePair("VideoCodec", videoCodecs)); + list.Add(new NameValuePair("AudioCodec", audioCodecs)); + list.Add(new NameValuePair("AudioStreamIndex", item.AudioStreamIndex.HasValue ? item.AudioStreamIndex.Value.ToString(CultureInfo.InvariantCulture) : string.Empty)); + list.Add(new NameValuePair("SubtitleStreamIndex", item.SubtitleStreamIndex.HasValue && item.SubtitleDeliveryMethod != SubtitleDeliveryMethod.External ? item.SubtitleStreamIndex.Value.ToString(CultureInfo.InvariantCulture) : string.Empty)); + list.Add(new NameValuePair("VideoBitrate", item.VideoBitrate.HasValue ? item.VideoBitrate.Value.ToString(CultureInfo.InvariantCulture) : string.Empty)); + list.Add(new NameValuePair("AudioBitrate", item.AudioBitrate.HasValue ? item.AudioBitrate.Value.ToString(CultureInfo.InvariantCulture) : string.Empty)); + list.Add(new NameValuePair("AudioSampleRate", item.AudioSampleRate.HasValue ? item.AudioSampleRate.Value.ToString(CultureInfo.InvariantCulture) : string.Empty)); + + list.Add(new NameValuePair("MaxFramerate", item.MaxFramerate.HasValue ? item.MaxFramerate.Value.ToString(CultureInfo.InvariantCulture) : string.Empty)); + list.Add(new NameValuePair("MaxWidth", item.MaxWidth.HasValue ? item.MaxWidth.Value.ToString(CultureInfo.InvariantCulture) : string.Empty)); + list.Add(new NameValuePair("MaxHeight", item.MaxHeight.HasValue ? item.MaxHeight.Value.ToString(CultureInfo.InvariantCulture) : string.Empty)); + + long startPositionTicks = item.StartPositionTicks; + + var isHls = string.Equals(item.SubProtocol, "hls", StringComparison.OrdinalIgnoreCase); + + if (isHls) + { + list.Add(new NameValuePair("StartTimeTicks", string.Empty)); + } + else + { + list.Add(new NameValuePair("StartTimeTicks", startPositionTicks.ToString(CultureInfo.InvariantCulture))); + } + + list.Add(new NameValuePair("PlaySessionId", item.PlaySessionId ?? string.Empty)); + list.Add(new NameValuePair("api_key", accessToken ?? string.Empty)); + + string liveStreamId = item.MediaSource?.LiveStreamId; + list.Add(new NameValuePair("LiveStreamId", liveStreamId ?? string.Empty)); + + list.Add(new NameValuePair("SubtitleMethod", item.SubtitleStreamIndex.HasValue && item.SubtitleDeliveryMethod != SubtitleDeliveryMethod.External ? item.SubtitleDeliveryMethod.ToString() : string.Empty)); + + if (!item.IsDirectStream) + { + if (item.RequireNonAnamorphic) { - return string.IsNullOrEmpty(inputCodec) ? Array.Empty<string>() : new[] { inputCodec }; + list.Add(new NameValuePair("RequireNonAnamorphic", item.RequireNonAnamorphic.ToString(CultureInfo.InvariantCulture).ToLowerInvariant())); } - foreach (string codec in AudioCodecs) + list.Add(new NameValuePair("TranscodingMaxAudioChannels", item.TranscodingMaxAudioChannels.HasValue ? item.TranscodingMaxAudioChannels.Value.ToString(CultureInfo.InvariantCulture) : string.Empty)); + + if (item.EnableSubtitlesInManifest) { - if (string.Equals(codec, inputCodec, StringComparison.OrdinalIgnoreCase)) - { - return string.IsNullOrEmpty(codec) ? Array.Empty<string>() : new[] { codec }; - } + list.Add(new NameValuePair("EnableSubtitlesInManifest", item.EnableSubtitlesInManifest.ToString(CultureInfo.InvariantCulture).ToLowerInvariant())); } - return AudioCodecs; - } - } - - public string[] TargetVideoCodec - { - get - { - var stream = TargetVideoStream; + if (item.EnableMpegtsM2TsMode) + { + list.Add(new NameValuePair("EnableMpegtsM2TsMode", item.EnableMpegtsM2TsMode.ToString(CultureInfo.InvariantCulture).ToLowerInvariant())); + } - string inputCodec = stream?.Codec; + if (item.EstimateContentLength) + { + list.Add(new NameValuePair("EstimateContentLength", item.EstimateContentLength.ToString(CultureInfo.InvariantCulture).ToLowerInvariant())); + } - if (IsDirectStream) + if (item.TranscodeSeekInfo != TranscodeSeekInfo.Auto) { - return string.IsNullOrEmpty(inputCodec) ? Array.Empty<string>() : new[] { inputCodec }; + list.Add(new NameValuePair("TranscodeSeekInfo", item.TranscodeSeekInfo.ToString().ToLowerInvariant())); } - foreach (string codec in VideoCodecs) + if (item.CopyTimestamps) { - if (string.Equals(codec, inputCodec, StringComparison.OrdinalIgnoreCase)) - { - return string.IsNullOrEmpty(codec) ? Array.Empty<string>() : new[] { codec }; - } + list.Add(new NameValuePair("CopyTimestamps", item.CopyTimestamps.ToString(CultureInfo.InvariantCulture).ToLowerInvariant())); } - return VideoCodecs; + list.Add(new NameValuePair("RequireAvc", item.RequireAvc.ToString(CultureInfo.InvariantCulture).ToLowerInvariant())); } - } - /// <summary> - /// Predicts the audio channels that will be in the output stream. - /// </summary> - public long? TargetSize - { - get + list.Add(new NameValuePair("Tag", item.MediaSource.ETag ?? string.Empty)); + + string subtitleCodecs = item.SubtitleCodecs.Length == 0 ? + string.Empty : + string.Join(",", item.SubtitleCodecs); + + list.Add(new NameValuePair("SubtitleCodec", item.SubtitleStreamIndex.HasValue && item.SubtitleDeliveryMethod == SubtitleDeliveryMethod.Embed ? subtitleCodecs : string.Empty)); + + if (isHls) { - if (IsDirectStream) + list.Add(new NameValuePair("SegmentContainer", item.Container ?? string.Empty)); + + if (item.SegmentLength.HasValue) { - return MediaSource.Size; + list.Add(new NameValuePair("SegmentLength", item.SegmentLength.Value.ToString(CultureInfo.InvariantCulture))); } - if (RunTimeTicks.HasValue) + if (item.MinSegments.HasValue) { - int? totalBitrate = TargetTotalBitrate; - - double totalSeconds = RunTimeTicks.Value; - // Convert to ms - totalSeconds /= 10000; - // Convert to seconds - totalSeconds /= 1000; - - return totalBitrate.HasValue ? - Convert.ToInt64(totalBitrate.Value * totalSeconds) : - (long?)null; + list.Add(new NameValuePair("MinSegments", item.MinSegments.Value.ToString(CultureInfo.InvariantCulture))); } - return null; + list.Add(new NameValuePair("BreakOnNonKeyFrames", item.BreakOnNonKeyFrames.ToString(CultureInfo.InvariantCulture))); } - } - public int? TargetVideoBitrate - { - get + foreach (var pair in item.StreamOptions) { - var stream = TargetVideoStream; + if (string.IsNullOrEmpty(pair.Value)) + { + continue; + } - return VideoBitrate.HasValue && !IsDirectStream - ? VideoBitrate - : stream == null ? null : stream.BitRate; + // strip spaces to avoid having to encode h264 profile names + list.Add(new NameValuePair(pair.Key, pair.Value.Replace(" ", string.Empty))); } - } - public TransportStreamTimestamp TargetTimestamp - { - get + if (!item.IsDirectStream) { - var defaultValue = string.Equals(Container, "m2ts", StringComparison.OrdinalIgnoreCase) - ? TransportStreamTimestamp.Valid - : TransportStreamTimestamp.None; - - return !IsDirectStream - ? defaultValue - : MediaSource == null ? defaultValue : MediaSource.Timestamp ?? TransportStreamTimestamp.None; + list.Add(new NameValuePair("TranscodeReasons", string.Join(',', item.TranscodeReasons.Distinct()))); } + + return list; } - public int? TargetTotalBitrate => (TargetAudioBitrate ?? 0) + (TargetVideoBitrate ?? 0); + public List<SubtitleStreamInfo> GetExternalSubtitles(ITranscoderSupport transcoderSupport, bool includeSelectedTrackOnly, string baseUrl, string accessToken) + { + return GetExternalSubtitles(transcoderSupport, includeSelectedTrackOnly, false, baseUrl, accessToken); + } - public bool? IsTargetAnamorphic + public List<SubtitleStreamInfo> GetExternalSubtitles(ITranscoderSupport transcoderSupport, bool includeSelectedTrackOnly, bool enableAllProfiles, string baseUrl, string accessToken) { - get + var list = GetSubtitleProfiles(transcoderSupport, includeSelectedTrackOnly, enableAllProfiles, baseUrl, accessToken); + var newList = new List<SubtitleStreamInfo>(); + + // First add the selected track + foreach (SubtitleStreamInfo stream in list) { - if (IsDirectStream) + if (stream.DeliveryMethod == SubtitleDeliveryMethod.External) { - return TargetVideoStream == null ? null : TargetVideoStream.IsAnamorphic; + newList.Add(stream); } - - return false; } + + return newList; } - public bool? IsTargetInterlaced + public List<SubtitleStreamInfo> GetSubtitleProfiles(ITranscoderSupport transcoderSupport, bool includeSelectedTrackOnly, string baseUrl, string accessToken) { - get + return GetSubtitleProfiles(transcoderSupport, includeSelectedTrackOnly, false, baseUrl, accessToken); + } + + public List<SubtitleStreamInfo> GetSubtitleProfiles(ITranscoderSupport transcoderSupport, bool includeSelectedTrackOnly, bool enableAllProfiles, string baseUrl, string accessToken) + { + var list = new List<SubtitleStreamInfo>(); + + // HLS will preserve timestamps so we can just grab the full subtitle stream + long startPositionTicks = string.Equals(SubProtocol, "hls", StringComparison.OrdinalIgnoreCase) + ? 0 + : (PlayMethod == PlayMethod.Transcode && !CopyTimestamps ? StartPositionTicks : 0); + + // First add the selected track + if (SubtitleStreamIndex.HasValue) { - if (IsDirectStream) + foreach (var stream in MediaSource.MediaStreams) { - return TargetVideoStream == null ? (bool?)null : TargetVideoStream.IsInterlaced; + if (stream.Type == MediaStreamType.Subtitle && stream.Index == SubtitleStreamIndex.Value) + { + AddSubtitleProfiles(list, stream, transcoderSupport, enableAllProfiles, baseUrl, accessToken, startPositionTicks); + } } + } - var targetVideoCodecs = TargetVideoCodec; - var videoCodec = targetVideoCodecs.Length == 0 ? null : targetVideoCodecs[0]; - if (!string.IsNullOrEmpty(videoCodec)) + if (!includeSelectedTrackOnly) + { + foreach (var stream in MediaSource.MediaStreams) { - if (string.Equals(GetOption(videoCodec, "deinterlace"), "true", StringComparison.OrdinalIgnoreCase)) + if (stream.Type == MediaStreamType.Subtitle && (!SubtitleStreamIndex.HasValue || stream.Index != SubtitleStreamIndex.Value)) { - return false; + AddSubtitleProfiles(list, stream, transcoderSupport, enableAllProfiles, baseUrl, accessToken, startPositionTicks); } } - - return TargetVideoStream == null ? (bool?)null : TargetVideoStream.IsInterlaced; } + + return list; } - public bool? IsTargetAVC + private void AddSubtitleProfiles(List<SubtitleStreamInfo> list, MediaStream stream, ITranscoderSupport transcoderSupport, bool enableAllProfiles, string baseUrl, string accessToken, long startPositionTicks) { - get + if (enableAllProfiles) { - if (IsDirectStream) + foreach (var profile in DeviceProfile.SubtitleProfiles) { - return TargetVideoStream == null ? null : TargetVideoStream.IsAVC; + var info = GetSubtitleStreamInfo(stream, baseUrl, accessToken, startPositionTicks, new[] { profile }, transcoderSupport); + + list.Add(info); } + } + else + { + var info = GetSubtitleStreamInfo(stream, baseUrl, accessToken, startPositionTicks, DeviceProfile.SubtitleProfiles, transcoderSupport); - return true; + list.Add(info); } } - public int? TargetWidth + private SubtitleStreamInfo GetSubtitleStreamInfo(MediaStream stream, string baseUrl, string accessToken, long startPositionTicks, SubtitleProfile[] subtitleProfiles, ITranscoderSupport transcoderSupport) { - get + var subtitleProfile = StreamBuilder.GetSubtitleProfile(MediaSource, stream, subtitleProfiles, PlayMethod, transcoderSupport, Container, SubProtocol); + var info = new SubtitleStreamInfo { - var videoStream = TargetVideoStream; + IsForced = stream.IsForced, + Language = stream.Language, + Name = stream.Language ?? "Unknown", + Format = subtitleProfile.Format, + Index = stream.Index, + DeliveryMethod = subtitleProfile.Method, + DisplayTitle = stream.DisplayTitle + }; - if (videoStream != null && videoStream.Width.HasValue && videoStream.Height.HasValue) + if (info.DeliveryMethod == SubtitleDeliveryMethod.External) + { + if (MediaSource.Protocol == MediaProtocol.File || !string.Equals(stream.Codec, subtitleProfile.Format, StringComparison.OrdinalIgnoreCase) || !stream.IsExternal) { - ImageDimensions size = new ImageDimensions(videoStream.Width.Value, videoStream.Height.Value); + info.Url = string.Format( + CultureInfo.InvariantCulture, + "{0}/Videos/{1}/{2}/Subtitles/{3}/{4}/Stream.{5}", + baseUrl, + ItemId, + MediaSourceId, + stream.Index.ToString(CultureInfo.InvariantCulture), + startPositionTicks.ToString(CultureInfo.InvariantCulture), + subtitleProfile.Format); - size = DrawingUtils.Resize(size, 0, 0, MaxWidth ?? 0, MaxHeight ?? 0); + if (!string.IsNullOrEmpty(accessToken)) + { + info.Url += "?api_key=" + accessToken; + } - return size.Width; + info.IsExternalUrl = false; + } + else + { + info.Url = stream.Path; + info.IsExternalUrl = true; } + } - return MaxWidth; + return info; + } + + public int? GetTargetVideoBitDepth(string codec) + { + var value = GetOption(codec, "videobitdepth"); + if (string.IsNullOrEmpty(value)) + { + return null; } + + if (int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var result)) + { + return result; + } + + return null; } - public int? TargetHeight + public int? GetTargetAudioBitDepth(string codec) { - get + var value = GetOption(codec, "audiobitdepth"); + if (string.IsNullOrEmpty(value)) { - var videoStream = TargetVideoStream; + return null; + } - if (videoStream != null && videoStream.Width.HasValue && videoStream.Height.HasValue) - { - ImageDimensions size = new ImageDimensions(videoStream.Width.Value, videoStream.Height.Value); + if (int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var result)) + { + return result; + } - size = DrawingUtils.Resize(size, 0, 0, MaxWidth ?? 0, MaxHeight ?? 0); + return null; + } - return size.Height; - } + public double? GetTargetVideoLevel(string codec) + { + var value = GetOption(codec, "level"); + if (string.IsNullOrEmpty(value)) + { + return null; + } - return MaxHeight; + if (double.TryParse(value, NumberStyles.Any, CultureInfo.InvariantCulture, out var result)) + { + return result; } + + return null; } - public int? TargetVideoStreamCount + public int? GetTargetRefFrames(string codec) { - get + var value = GetOption(codec, "maxrefframes"); + if (string.IsNullOrEmpty(value)) { - if (IsDirectStream) - { - return GetMediaStreamCount(MediaStreamType.Video, int.MaxValue); - } + return null; + } - return GetMediaStreamCount(MediaStreamType.Video, 1); + if (int.TryParse(value, NumberStyles.Any, CultureInfo.InvariantCulture, out var result)) + { + return result; } + + return null; } - public int? TargetAudioStreamCount + public int? GetTargetAudioChannels(string codec) { - get + var defaultValue = GlobalMaxAudioChannels ?? TranscodingMaxAudioChannels; + + var value = GetOption(codec, "audiochannels"); + if (string.IsNullOrEmpty(value)) { - if (IsDirectStream) - { - return GetMediaStreamCount(MediaStreamType.Audio, int.MaxValue); - } + return defaultValue; + } - return GetMediaStreamCount(MediaStreamType.Audio, 1); + if (int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var result)) + { + return Math.Min(result, defaultValue ?? result); } + + return defaultValue; } private int? GetMediaStreamCount(MediaStreamType type, int limit) diff --git a/MediaBrowser.Model/Dlna/SubtitleDeliveryMethod.cs b/MediaBrowser.Model/Dlna/SubtitleDeliveryMethod.cs index e7fe8d6af2..9b39f9e11a 100644 --- a/MediaBrowser.Model/Dlna/SubtitleDeliveryMethod.cs +++ b/MediaBrowser.Model/Dlna/SubtitleDeliveryMethod.cs @@ -2,25 +2,28 @@ namespace MediaBrowser.Model.Dlna { + /// <summary> + /// Delivery method to use during playback of a specific subtitle format. + /// </summary> public enum SubtitleDeliveryMethod { /// <summary> - /// The encode. + /// Burn the subtitles in the video track. /// </summary> Encode = 0, /// <summary> - /// The embed. + /// Embed the subtitles in the file or stream. /// </summary> Embed = 1, /// <summary> - /// The external. + /// Serve the subtitles as an external file. /// </summary> External = 2, /// <summary> - /// The HLS. + /// Serve the subtitles as a separate HLS stream. /// </summary> Hls = 3 } diff --git a/MediaBrowser.Model/Dlna/TranscodingProfile.cs b/MediaBrowser.Model/Dlna/TranscodingProfile.cs index f05e31047c..214578a85e 100644 --- a/MediaBrowser.Model/Dlna/TranscodingProfile.cs +++ b/MediaBrowser.Model/Dlna/TranscodingProfile.cs @@ -1,6 +1,7 @@ -#nullable disable #pragma warning disable CS1591 +using System.ComponentModel; +using System.ComponentModel.DataAnnotations; using System.Xml.Serialization; namespace MediaBrowser.Model.Dlna @@ -8,47 +9,56 @@ namespace MediaBrowser.Model.Dlna public class TranscodingProfile { [XmlAttribute("container")] - public string Container { get; set; } + public string Container { get; set; } = string.Empty; [XmlAttribute("type")] public DlnaProfileType Type { get; set; } [XmlAttribute("videoCodec")] - public string VideoCodec { get; set; } + public string VideoCodec { get; set; } = string.Empty; [XmlAttribute("audioCodec")] - public string AudioCodec { get; set; } + public string AudioCodec { get; set; } = string.Empty; [XmlAttribute("protocol")] - public string Protocol { get; set; } + public string Protocol { get; set; } = string.Empty; + [DefaultValue(false)] [XmlAttribute("estimateContentLength")] public bool EstimateContentLength { get; set; } + [DefaultValue(false)] [XmlAttribute("enableMpegtsM2TsMode")] public bool EnableMpegtsM2TsMode { get; set; } + [DefaultValue(TranscodeSeekInfo.Auto)] [XmlAttribute("transcodeSeekInfo")] public TranscodeSeekInfo TranscodeSeekInfo { get; set; } + [DefaultValue(false)] [XmlAttribute("copyTimestamps")] public bool CopyTimestamps { get; set; } + [DefaultValue(EncodingContext.Streaming)] [XmlAttribute("context")] public EncodingContext Context { get; set; } + [DefaultValue(false)] [XmlAttribute("enableSubtitlesInManifest")] public bool EnableSubtitlesInManifest { get; set; } [XmlAttribute("maxAudioChannels")] - public string MaxAudioChannels { get; set; } + public string? MaxAudioChannels { get; set; } + [DefaultValue(0)] [XmlAttribute("minSegments")] public int MinSegments { get; set; } + [DefaultValue(0)] [XmlAttribute("segmentLength")] public int SegmentLength { get; set; } + [DefaultValue(false)] [XmlAttribute("breakOnNonKeyFrames")] public bool BreakOnNonKeyFrames { get; set; } diff --git a/MediaBrowser.Model/Dlna/UpnpDeviceInfo.cs b/MediaBrowser.Model/Dlna/UpnpDeviceInfo.cs index d71013f019..987a3a908f 100644 --- a/MediaBrowser.Model/Dlna/UpnpDeviceInfo.cs +++ b/MediaBrowser.Model/Dlna/UpnpDeviceInfo.cs @@ -16,5 +16,7 @@ namespace MediaBrowser.Model.Dlna public IPAddress LocalIpAddress { get; set; } public int LocalPort { get; set; } + + public IPAddress RemoteIpAddress { get; set; } } } diff --git a/MediaBrowser.Model/Dlna/XmlAttribute.cs b/MediaBrowser.Model/Dlna/XmlAttribute.cs index 3a8939a797..03bb2e4b11 100644 --- a/MediaBrowser.Model/Dlna/XmlAttribute.cs +++ b/MediaBrowser.Model/Dlna/XmlAttribute.cs @@ -5,11 +5,20 @@ using System.Xml.Serialization; namespace MediaBrowser.Model.Dlna { + /// <summary> + /// Defines the <see cref="XmlAttribute" />. + /// </summary> public class XmlAttribute { + /// <summary> + /// Gets or sets the name of the attribute. + /// </summary> [XmlAttribute("name")] public string Name { get; set; } + /// <summary> + /// Gets or sets the value of the attribute. + /// </summary> [XmlAttribute("value")] public string Value { get; set; } } diff --git a/MediaBrowser.Model/Drawing/DrawingUtils.cs b/MediaBrowser.Model/Drawing/DrawingUtils.cs index 1512c52337..5567927682 100644 --- a/MediaBrowser.Model/Drawing/DrawingUtils.cs +++ b/MediaBrowser.Model/Drawing/DrawingUtils.cs @@ -57,6 +57,52 @@ namespace MediaBrowser.Model.Drawing return new ImageDimensions(newWidth, newHeight); } + /// <summary> + /// Scale down to fill box. + /// Returns original size if both width and height are null or zero. + /// </summary> + /// <param name="size">The original size object.</param> + /// <param name="fillWidth">A new fixed width, if desired.</param> + /// <param name="fillHeight">A new fixed height, if desired.</param> + /// <returns>A new size object or size.</returns> + public static ImageDimensions ResizeFill( + ImageDimensions size, + int? fillWidth, + int? fillHeight) + { + // Return original size if input is invalid. + if ((fillWidth == null || fillWidth == 0) + && (fillHeight == null || fillHeight == 0)) + { + return size; + } + + if (fillWidth == null || fillWidth == 0) + { + fillWidth = 1; + } + + if (fillHeight == null || fillHeight == 0) + { + fillHeight = 1; + } + + double widthRatio = size.Width / (double)fillWidth; + double heightRatio = size.Height / (double)fillHeight; + double scaleRatio = Math.Min(widthRatio, heightRatio); + + // Clamp to current size. + if (scaleRatio < 1) + { + return size; + } + + int newWidth = Convert.ToInt32(Math.Ceiling(size.Width / scaleRatio)); + int newHeight = Convert.ToInt32(Math.Ceiling(size.Height / scaleRatio)); + + return new ImageDimensions(newWidth, newHeight); + } + /// <summary> /// Gets the new width. /// </summary> diff --git a/MediaBrowser.Model/Dto/BaseItemDto.cs b/MediaBrowser.Model/Dto/BaseItemDto.cs index fac7541773..a784025e3d 100644 --- a/MediaBrowser.Model/Dto/BaseItemDto.cs +++ b/MediaBrowser.Model/Dto/BaseItemDto.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; +using Jellyfin.Data.Enums; using MediaBrowser.Model.Drawing; using MediaBrowser.Model.Entities; using MediaBrowser.Model.Library; @@ -152,7 +153,7 @@ namespace MediaBrowser.Model.Dto /// Gets or sets the channel identifier. /// </summary> /// <value>The channel identifier.</value> - public Guid ChannelId { get; set; } + public Guid? ChannelId { get; set; } public string ChannelName { get; set; } @@ -270,13 +271,13 @@ namespace MediaBrowser.Model.Dto /// Gets or sets the parent id. /// </summary> /// <value>The parent id.</value> - public Guid ParentId { get; set; } + public Guid? ParentId { get; set; } /// <summary> /// Gets or sets the type. /// </summary> /// <value>The type.</value> - public string Type { get; set; } + public BaseItemKind Type { get; set; } /// <summary> /// Gets or sets the people. @@ -293,13 +294,13 @@ namespace MediaBrowser.Model.Dto public NameGuidPair[] GenreItems { get; set; } /// <summary> - /// If the item does not have a logo, this will hold the Id of the Parent that has one. + /// Gets or sets wether the item has a logo, this will hold the Id of the Parent that has one. /// </summary> /// <value>The parent logo item id.</value> public string ParentLogoItemId { get; set; } /// <summary> - /// If the item does not have any backdrops, this will hold the Id of the Parent that has one. + /// Gets or sets wether the item has any backdrops, this will hold the Id of the Parent that has one. /// </summary> /// <value>The parent backdrop item id.</value> public string ParentBackdropItemId { get; set; } @@ -317,7 +318,7 @@ namespace MediaBrowser.Model.Dto public int? LocalTrailerCount { get; set; } /// <summary> - /// User data for this item based on the user it's being requested for. + /// Gets or sets the user data for this item based on the user it's being requested for. /// </summary> /// <value>The user data.</value> public UserItemDataDto UserData { get; set; } @@ -344,13 +345,13 @@ namespace MediaBrowser.Model.Dto /// Gets or sets the series id. /// </summary> /// <value>The series id.</value> - public Guid SeriesId { get; set; } + public Guid? SeriesId { get; set; } /// <summary> /// Gets or sets the season identifier. /// </summary> /// <value>The season identifier.</value> - public Guid SeasonId { get; set; } + public Guid? SeasonId { get; set; } /// <summary> /// Gets or sets the special feature count. @@ -428,7 +429,7 @@ namespace MediaBrowser.Model.Dto /// Gets or sets the album id. /// </summary> /// <value>The album id.</value> - public Guid AlbumId { get; set; } + public Guid? AlbumId { get; set; } /// <summary> /// Gets or sets the album image tag. @@ -505,7 +506,7 @@ namespace MediaBrowser.Model.Dto public string ParentLogoImageTag { get; set; } /// <summary> - /// If the item does not have a art, this will hold the Id of the Parent that has one. + /// Gets or sets wether the item has fan art, this will hold the Id of the Parent that has one. /// </summary> /// <value>The parent art item id.</value> public string ParentArtItemId { get; set; } @@ -694,7 +695,7 @@ namespace MediaBrowser.Model.Dto public string ChannelPrimaryImageTag { get; set; } /// <summary> - /// The start date of the recording, in UTC. + /// Gets or sets the start date of the recording, in UTC. /// </summary> public DateTime? StartDate { get; set; } diff --git a/MediaBrowser.Model/Dto/MediaSourceInfo.cs b/MediaBrowser.Model/Dto/MediaSourceInfo.cs index be682be23c..ec3b37efa3 100644 --- a/MediaBrowser.Model/Dto/MediaSourceInfo.cs +++ b/MediaBrowser.Model/Dto/MediaSourceInfo.cs @@ -12,6 +12,18 @@ namespace MediaBrowser.Model.Dto { public class MediaSourceInfo { + public MediaSourceInfo() + { + Formats = Array.Empty<string>(); + MediaStreams = new List<MediaStream>(); + MediaAttachments = Array.Empty<MediaAttachment>(); + RequiredHttpHeaders = new Dictionary<string, string>(); + SupportsTranscoding = true; + SupportsDirectStream = true; + SupportsDirectPlay = true; + SupportsProbing = true; + } + public MediaProtocol Protocol { get; set; } public string Id { get; set; } @@ -31,6 +43,7 @@ namespace MediaBrowser.Model.Dto public string Name { get; set; } /// <summary> + /// Gets or sets a value indicating whether the media is remote. /// Differentiate internet url vs local network. /// </summary> public bool IsRemote { get; set; } @@ -95,16 +108,28 @@ namespace MediaBrowser.Model.Dto public int? AnalyzeDurationMs { get; set; } - public MediaSourceInfo() + [JsonIgnore] + public TranscodeReason[] TranscodeReasons { get; set; } + + public int? DefaultAudioStreamIndex { get; set; } + + public int? DefaultSubtitleStreamIndex { get; set; } + + [JsonIgnore] + public MediaStream VideoStream { - Formats = Array.Empty<string>(); - MediaStreams = new List<MediaStream>(); - MediaAttachments = Array.Empty<MediaAttachment>(); - RequiredHttpHeaders = new Dictionary<string, string>(); - SupportsTranscoding = true; - SupportsDirectStream = true; - SupportsDirectPlay = true; - SupportsProbing = true; + get + { + foreach (var i in MediaStreams) + { + if (i.Type == MediaStreamType.Video) + { + return i; + } + } + + return null; + } } public void InferTotalBitrate(bool force = false) @@ -134,13 +159,6 @@ namespace MediaBrowser.Model.Dto } } - [JsonIgnore] - public TranscodeReason[] TranscodeReasons { get; set; } - - public int? DefaultAudioStreamIndex { get; set; } - - public int? DefaultSubtitleStreamIndex { get; set; } - public MediaStream GetDefaultAudioStream(int? defaultIndex) { if (defaultIndex.HasValue) @@ -175,23 +193,6 @@ namespace MediaBrowser.Model.Dto return null; } - [JsonIgnore] - public MediaStream VideoStream - { - get - { - foreach (var i in MediaStreams) - { - if (i.Type == MediaStreamType.Video) - { - return i; - } - } - - return null; - } - } - public MediaStream GetMediaStream(MediaStreamType type, int index) { foreach (var i in MediaStreams) diff --git a/MediaBrowser.Model/Dto/MetadataEditorInfo.cs b/MediaBrowser.Model/Dto/MetadataEditorInfo.cs index e4f38d6af3..e0e889f7d0 100644 --- a/MediaBrowser.Model/Dto/MetadataEditorInfo.cs +++ b/MediaBrowser.Model/Dto/MetadataEditorInfo.cs @@ -10,6 +10,15 @@ namespace MediaBrowser.Model.Dto { public class MetadataEditorInfo { + public MetadataEditorInfo() + { + ParentalRatingOptions = Array.Empty<ParentalRating>(); + Countries = Array.Empty<CountryInfo>(); + Cultures = Array.Empty<CultureDto>(); + ExternalIdInfos = Array.Empty<ExternalIdInfo>(); + ContentTypeOptions = Array.Empty<NameValuePair>(); + } + public ParentalRating[] ParentalRatingOptions { get; set; } public CountryInfo[] Countries { get; set; } @@ -21,14 +30,5 @@ namespace MediaBrowser.Model.Dto public string ContentType { get; set; } public NameValuePair[] ContentTypeOptions { get; set; } - - public MetadataEditorInfo() - { - ParentalRatingOptions = Array.Empty<ParentalRating>(); - Countries = Array.Empty<CountryInfo>(); - Cultures = Array.Empty<CultureDto>(); - ExternalIdInfos = Array.Empty<ExternalIdInfo>(); - ContentTypeOptions = Array.Empty<NameValuePair>(); - } } } diff --git a/MediaBrowser.Model/Dto/NameGuidPair.cs b/MediaBrowser.Model/Dto/NameGuidPair.cs new file mode 100644 index 0000000000..71166df979 --- /dev/null +++ b/MediaBrowser.Model/Dto/NameGuidPair.cs @@ -0,0 +1,14 @@ +#nullable disable +#pragma warning disable CS1591 + +using System; + +namespace MediaBrowser.Model.Dto +{ + public class NameGuidPair + { + public string Name { get; set; } + + public Guid Id { get; set; } + } +} diff --git a/MediaBrowser.Model/Dto/NameIdPair.cs b/MediaBrowser.Model/Dto/NameIdPair.cs index 45c2fb35db..31516947f0 100644 --- a/MediaBrowser.Model/Dto/NameIdPair.cs +++ b/MediaBrowser.Model/Dto/NameIdPair.cs @@ -1,8 +1,6 @@ #nullable disable #pragma warning disable CS1591 -using System; - namespace MediaBrowser.Model.Dto { public class NameIdPair @@ -19,11 +17,4 @@ namespace MediaBrowser.Model.Dto /// <value>The identifier.</value> public string Id { get; set; } } - - public class NameGuidPair - { - public string Name { get; set; } - - public Guid Id { get; set; } - } } diff --git a/MediaBrowser.Model/Dto/UserDto.cs b/MediaBrowser.Model/Dto/UserDto.cs index 40222c9dca..256d7b10f1 100644 --- a/MediaBrowser.Model/Dto/UserDto.cs +++ b/MediaBrowser.Model/Dto/UserDto.cs @@ -10,6 +10,15 @@ namespace MediaBrowser.Model.Dto /// </summary> public class UserDto : IItemDto, IHasServerId { + /// <summary> + /// Initializes a new instance of the <see cref="UserDto"/> class. + /// </summary> + public UserDto() + { + Configuration = new UserConfiguration(); + Policy = new UserPolicy(); + } + /// <summary> /// Gets or sets the name. /// </summary> @@ -94,15 +103,6 @@ namespace MediaBrowser.Model.Dto /// <value>The primary image aspect ratio.</value> public double? PrimaryImageAspectRatio { get; set; } - /// <summary> - /// Initializes a new instance of the <see cref="UserDto"/> class. - /// </summary> - public UserDto() - { - Configuration = new UserConfiguration(); - Policy = new UserPolicy(); - } - /// <inheritdoc /> public override string ToString() { diff --git a/MediaBrowser.Model/Entities/CollectionType.cs b/MediaBrowser.Model/Entities/CollectionType.cs index 3540387129..60b69d4b01 100644 --- a/MediaBrowser.Model/Entities/CollectionType.cs +++ b/MediaBrowser.Model/Entities/CollectionType.cs @@ -24,36 +24,4 @@ namespace MediaBrowser.Model.Entities public const string Playlists = "playlists"; public const string Folders = "folders"; } - - public static class SpecialFolder - { - public const string TvShowSeries = "TvShowSeries"; - public const string TvGenres = "TvGenres"; - public const string TvGenre = "TvGenre"; - public const string TvLatest = "TvLatest"; - public const string TvNextUp = "TvNextUp"; - public const string TvResume = "TvResume"; - public const string TvFavoriteSeries = "TvFavoriteSeries"; - public const string TvFavoriteEpisodes = "TvFavoriteEpisodes"; - - public const string MovieLatest = "MovieLatest"; - public const string MovieResume = "MovieResume"; - public const string MovieMovies = "MovieMovies"; - public const string MovieCollections = "MovieCollections"; - public const string MovieFavorites = "MovieFavorites"; - public const string MovieGenres = "MovieGenres"; - public const string MovieGenre = "MovieGenre"; - - public const string MusicArtists = "MusicArtists"; - public const string MusicAlbumArtists = "MusicAlbumArtists"; - public const string MusicAlbums = "MusicAlbums"; - public const string MusicGenres = "MusicGenres"; - public const string MusicLatest = "MusicLatest"; - public const string MusicPlaylists = "MusicPlaylists"; - public const string MusicSongs = "MusicSongs"; - public const string MusicFavorites = "MusicFavorites"; - public const string MusicFavoriteArtists = "MusicFavoriteArtists"; - public const string MusicFavoriteAlbums = "MusicFavoriteAlbums"; - public const string MusicFavoriteSongs = "MusicFavoriteSongs"; - } } diff --git a/MediaBrowser.Model/Entities/CollectionTypeOptions.cs b/MediaBrowser.Model/Entities/CollectionTypeOptions.cs new file mode 100644 index 0000000000..e1894d84ad --- /dev/null +++ b/MediaBrowser.Model/Entities/CollectionTypeOptions.cs @@ -0,0 +1,16 @@ +#pragma warning disable CS1591 + +namespace MediaBrowser.Model.Entities +{ + public enum CollectionTypeOptions + { + Movies = 0, + TvShows = 1, + Music = 2, + MusicVideos = 3, + HomeVideos = 4, + BoxSets = 5, + Books = 6, + Mixed = 7 + } +} diff --git a/MediaBrowser.Model/Entities/MediaStream.cs b/MediaBrowser.Model/Entities/MediaStream.cs index fa3c9aaa2f..9653a8ece7 100644 --- a/MediaBrowser.Model/Entities/MediaStream.cs +++ b/MediaBrowser.Model/Entities/MediaStream.cs @@ -84,7 +84,7 @@ namespace MediaBrowser.Model.Entities public string Title { get; set; } /// <summary> - /// Gets or sets the video range. + /// Gets the video range. /// </summary> /// <value>The video range.</value> public string VideoRange @@ -104,15 +104,28 @@ namespace MediaBrowser.Model.Entities return "HDR"; } + // For some Dolby Vision files, no color transfer is provided, so check the codec + + var codecTag = CodecTag; + + if (string.Equals(codecTag, "dva1", StringComparison.OrdinalIgnoreCase) + || string.Equals(codecTag, "dvav", StringComparison.OrdinalIgnoreCase) + || string.Equals(codecTag, "dvh1", StringComparison.OrdinalIgnoreCase) + || string.Equals(codecTag, "dvhe", StringComparison.OrdinalIgnoreCase) + || string.Equals(codecTag, "dav1", StringComparison.OrdinalIgnoreCase)) + { + return "HDR"; + } + return "SDR"; } } - public string localizedUndefined { get; set; } + public string LocalizedUndefined { get; set; } - public string localizedDefault { get; set; } + public string LocalizedDefault { get; set; } - public string localizedForced { get; set; } + public string LocalizedForced { get; set; } public string DisplayTitle { @@ -154,7 +167,7 @@ namespace MediaBrowser.Model.Entities if (IsDefault) { - attributes.Add(string.IsNullOrEmpty(localizedDefault) ? "Default" : localizedDefault); + attributes.Add(string.IsNullOrEmpty(LocalizedDefault) ? "Default" : LocalizedDefault); } if (!string.IsNullOrEmpty(Title)) @@ -163,7 +176,7 @@ namespace MediaBrowser.Model.Entities foreach (var tag in attributes) { // Keep Tags that are not already in Title. - if (Title.IndexOf(tag, StringComparison.OrdinalIgnoreCase) == -1) + if (!Title.Contains(tag, StringComparison.OrdinalIgnoreCase)) { result.Append(" - ").Append(tag); } @@ -191,13 +204,18 @@ namespace MediaBrowser.Model.Entities attributes.Add(Codec.ToUpperInvariant()); } + if (!string.IsNullOrEmpty(VideoRange)) + { + attributes.Add(VideoRange.ToUpperInvariant()); + } + if (!string.IsNullOrEmpty(Title)) { var result = new StringBuilder(Title); foreach (var tag in attributes) { // Keep Tags that are not already in Title. - if (Title.IndexOf(tag, StringComparison.OrdinalIgnoreCase) == -1) + if (!Title.Contains(tag, StringComparison.OrdinalIgnoreCase)) { result.Append(" - ").Append(tag); } @@ -206,7 +224,7 @@ namespace MediaBrowser.Model.Entities return result.ToString(); } - return string.Join(" ", attributes); + return string.Join(' ', attributes); } case MediaStreamType.Subtitle: @@ -224,17 +242,17 @@ namespace MediaBrowser.Model.Entities } else { - attributes.Add(string.IsNullOrEmpty(localizedUndefined) ? "Und" : localizedUndefined); + attributes.Add(string.IsNullOrEmpty(LocalizedUndefined) ? "Und" : LocalizedUndefined); } if (IsDefault) { - attributes.Add(string.IsNullOrEmpty(localizedDefault) ? "Default" : localizedDefault); + attributes.Add(string.IsNullOrEmpty(LocalizedDefault) ? "Default" : LocalizedDefault); } if (IsForced) { - attributes.Add(string.IsNullOrEmpty(localizedForced) ? "Forced" : localizedForced); + attributes.Add(string.IsNullOrEmpty(LocalizedForced) ? "Forced" : LocalizedForced); } if (!string.IsNullOrEmpty(Title)) @@ -261,67 +279,6 @@ namespace MediaBrowser.Model.Entities } } - private string GetResolutionText() - { - var i = this; - - if (i.Width.HasValue && i.Height.HasValue) - { - var width = i.Width.Value; - var height = i.Height.Value; - - if (width >= 3800 || height >= 2000) - { - return "4K"; - } - - if (width >= 2500) - { - if (i.IsInterlaced) - { - return "1440i"; - } - - return "1440p"; - } - - if (width >= 1900 || height >= 1000) - { - if (i.IsInterlaced) - { - return "1080i"; - } - - return "1080p"; - } - - if (width >= 1260 || height >= 700) - { - if (i.IsInterlaced) - { - return "720i"; - } - - return "720p"; - } - - if (width >= 700 || height >= 440) - { - - if (i.IsInterlaced) - { - return "480i"; - } - - return "480p"; - } - - return "SD"; - } - - return null; - } - public string NalLengthSize { get; set; } /// <summary> @@ -482,15 +439,71 @@ namespace MediaBrowser.Model.Entities } } + /// <summary> + /// Gets or sets a value indicating whether [supports external stream]. + /// </summary> + /// <value><c>true</c> if [supports external stream]; otherwise, <c>false</c>.</value> + public bool SupportsExternalStream { get; set; } + + /// <summary> + /// Gets or sets the filename. + /// </summary> + /// <value>The filename.</value> + public string Path { get; set; } + + /// <summary> + /// Gets or sets the pixel format. + /// </summary> + /// <value>The pixel format.</value> + public string PixelFormat { get; set; } + + /// <summary> + /// Gets or sets the level. + /// </summary> + /// <value>The level.</value> + public double? Level { get; set; } + + /// <summary> + /// Gets or sets whether this instance is anamorphic. + /// </summary> + /// <value><c>true</c> if this instance is anamorphic; otherwise, <c>false</c>.</value> + public bool? IsAnamorphic { get; set; } + + internal string GetResolutionText() + { + if (!Width.HasValue || !Height.HasValue) + { + return null; + } + + return Width switch + { + <= 720 when Height <= 480 => IsInterlaced ? "480i" : "480p", + // 720x576 (PAL) (768 when rescaled for square pixels) + <= 768 when Height <= 576 => IsInterlaced ? "576i" : "576p", + // 960x540 (sometimes 544 which is multiple of 16) + <= 960 when Height <= 544 => IsInterlaced ? "540i" : "540p", + // 1280x720 + <= 1280 when Height <= 962 => IsInterlaced ? "720i" : "720p", + // 1920x1080 + <= 1920 when Height <= 1440 => IsInterlaced ? "1080i" : "1080p", + // 4K + <= 4096 when Height <= 3072 => "4K", + // 8K + <= 8192 when Height <= 6144 => "8K", + _ => null + }; + } + public static bool IsTextFormat(string format) { string codec = format ?? string.Empty; // sub = external .sub file - return codec.IndexOf("pgs", StringComparison.OrdinalIgnoreCase) == -1 && - codec.IndexOf("dvd", StringComparison.OrdinalIgnoreCase) == -1 && - codec.IndexOf("dvbsub", StringComparison.OrdinalIgnoreCase) == -1 && + return !codec.Contains("pgs", StringComparison.OrdinalIgnoreCase) && + !codec.Contains("dvd", StringComparison.OrdinalIgnoreCase) && + !codec.Contains("dvbsub", StringComparison.OrdinalIgnoreCase) && !string.Equals(codec, "sub", StringComparison.OrdinalIgnoreCase) && !string.Equals(codec, "dvb_subtitle", StringComparison.OrdinalIgnoreCase); } @@ -528,35 +541,5 @@ namespace MediaBrowser.Model.Entities return true; } - - /// <summary> - /// Gets or sets a value indicating whether [supports external stream]. - /// </summary> - /// <value><c>true</c> if [supports external stream]; otherwise, <c>false</c>.</value> - public bool SupportsExternalStream { get; set; } - - /// <summary> - /// Gets or sets the filename. - /// </summary> - /// <value>The filename.</value> - public string Path { get; set; } - - /// <summary> - /// Gets or sets the pixel format. - /// </summary> - /// <value>The pixel format.</value> - public string PixelFormat { get; set; } - - /// <summary> - /// Gets or sets the level. - /// </summary> - /// <value>The level.</value> - public double? Level { get; set; } - - /// <summary> - /// Gets a value indicating whether this instance is anamorphic. - /// </summary> - /// <value><c>true</c> if this instance is anamorphic; otherwise, <c>false</c>.</value> - public bool? IsAnamorphic { get; set; } } } diff --git a/MediaBrowser.Model/Entities/PackageReviewInfo.cs b/MediaBrowser.Model/Entities/PackageReviewInfo.cs deleted file mode 100644 index 5b22b34ace..0000000000 --- a/MediaBrowser.Model/Entities/PackageReviewInfo.cs +++ /dev/null @@ -1,40 +0,0 @@ -#nullable disable -#pragma warning disable CS1591 - -using System; - -namespace MediaBrowser.Model.Entities -{ - public class PackageReviewInfo - { - /// <summary> - /// Gets or sets the package id (database key) for this review. - /// </summary> - public int id { get; set; } - - /// <summary> - /// Gets or sets the rating value. - /// </summary> - public int rating { get; set; } - - /// <summary> - /// Gets or sets whether or not this review recommends this item. - /// </summary> - public bool recommend { get; set; } - - /// <summary> - /// Gets or sets a short description of the review. - /// </summary> - public string title { get; set; } - - /// <summary> - /// Gets or sets the full review. - /// </summary> - public string review { get; set; } - - /// <summary> - /// Gets or sets the time of review. - /// </summary> - public DateTime timestamp { get; set; } - } -} diff --git a/MediaBrowser.Model/Entities/ProviderIdsExtensions.cs b/MediaBrowser.Model/Entities/ProviderIdsExtensions.cs index 9c11fe0ad2..ce4b0ec92e 100644 --- a/MediaBrowser.Model/Entities/ProviderIdsExtensions.cs +++ b/MediaBrowser.Model/Entities/ProviderIdsExtensions.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; namespace MediaBrowser.Model.Entities { @@ -9,25 +10,30 @@ namespace MediaBrowser.Model.Entities public static class ProviderIdsExtensions { /// <summary> - /// Determines whether [has provider identifier] [the specified instance]. + /// Checks if this instance has an id for the given provider. /// </summary> /// <param name="instance">The instance.</param> - /// <param name="provider">The provider.</param> - /// <returns><c>true</c> if [has provider identifier] [the specified instance]; otherwise, <c>false</c>.</returns> - public static bool HasProviderId(this IHasProviderIds instance, MetadataProvider provider) + /// <param name="name">The of the provider name.</param> + /// <returns><c>true</c> if a provider id with the given name was found; otherwise <c>false</c>.</returns> + public static bool HasProviderId(this IHasProviderIds instance, string name) { - return !string.IsNullOrEmpty(instance.GetProviderId(provider.ToString())); + if (instance == null) + { + throw new ArgumentNullException(nameof(instance)); + } + + return instance.TryGetProviderId(name, out _); } /// <summary> - /// Gets a provider id. + /// Checks if this instance has an id for the given provider. /// </summary> /// <param name="instance">The instance.</param> /// <param name="provider">The provider.</param> - /// <returns>System.String.</returns> - public static string? GetProviderId(this IHasProviderIds instance, MetadataProvider provider) + /// <returns><c>true</c> if a provider id with the given name was found; otherwise <c>false</c>.</returns> + public static bool HasProviderId(this IHasProviderIds instance, MetadataProvider provider) { - return instance.GetProviderId(provider.ToString()); + return instance.HasProviderId(provider.ToString()); } /// <summary> @@ -35,8 +41,9 @@ namespace MediaBrowser.Model.Entities /// </summary> /// <param name="instance">The instance.</param> /// <param name="name">The name.</param> - /// <returns>System.String.</returns> - public static string? GetProviderId(this IHasProviderIds instance, string name) + /// <param name="id">The provider id.</param> + /// <returns><c>true</c> if a provider id with the given name was found; otherwise <c>false</c>.</returns> + public static bool TryGetProviderId(this IHasProviderIds instance, string name, [NotNullWhen(true)] out string? id) { if (instance == null) { @@ -45,13 +52,56 @@ namespace MediaBrowser.Model.Entities if (instance.ProviderIds == null) { - return null; + id = null; + return false; + } + + var foundProviderId = instance.ProviderIds.TryGetValue(name, out id); + // This occurs when searching with Identify (and possibly in other places) + if (string.IsNullOrEmpty(id)) + { + id = null; + foundProviderId = false; } - instance.ProviderIds.TryGetValue(name, out string id); + return foundProviderId; + } + + /// <summary> + /// Gets a provider id. + /// </summary> + /// <param name="instance">The instance.</param> + /// <param name="provider">The provider.</param> + /// <param name="id">The provider id.</param> + /// <returns><c>true</c> if a provider id with the given name was found; otherwise <c>false</c>.</returns> + public static bool TryGetProviderId(this IHasProviderIds instance, MetadataProvider provider, [NotNullWhen(true)] out string? id) + { + return instance.TryGetProviderId(provider.ToString(), out id); + } + + /// <summary> + /// Gets a provider id. + /// </summary> + /// <param name="instance">The instance.</param> + /// <param name="name">The name.</param> + /// <returns>System.String.</returns> + public static string? GetProviderId(this IHasProviderIds instance, string name) + { + instance.TryGetProviderId(name, out string? id); return id; } + /// <summary> + /// Gets a provider id. + /// </summary> + /// <param name="instance">The instance.</param> + /// <param name="provider">The provider.</param> + /// <returns>System.String.</returns> + public static string? GetProviderId(this IHasProviderIds instance, MetadataProvider provider) + { + return instance.GetProviderId(provider.ToString()); + } + /// <summary> /// Sets a provider id. /// </summary> @@ -68,21 +118,12 @@ namespace MediaBrowser.Model.Entities // If it's null remove the key from the dictionary if (string.IsNullOrEmpty(value)) { - if (instance.ProviderIds != null) - { - if (instance.ProviderIds.ContainsKey(name)) - { - instance.ProviderIds.Remove(name); - } - } + instance.ProviderIds?.Remove(name); } else { // Ensure it exists - if (instance.ProviderIds == null) - { - instance.ProviderIds = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase); - } + instance.ProviderIds ??= new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase); instance.ProviderIds[name] = value; } diff --git a/MediaBrowser.Model/Entities/SpecialFolder.cs b/MediaBrowser.Model/Entities/SpecialFolder.cs new file mode 100644 index 0000000000..2250c5dffb --- /dev/null +++ b/MediaBrowser.Model/Entities/SpecialFolder.cs @@ -0,0 +1,36 @@ +#pragma warning disable CS1591 + +namespace MediaBrowser.Model.Entities +{ + public static class SpecialFolder + { + public const string TvShowSeries = "TvShowSeries"; + public const string TvGenres = "TvGenres"; + public const string TvGenre = "TvGenre"; + public const string TvLatest = "TvLatest"; + public const string TvNextUp = "TvNextUp"; + public const string TvResume = "TvResume"; + public const string TvFavoriteSeries = "TvFavoriteSeries"; + public const string TvFavoriteEpisodes = "TvFavoriteEpisodes"; + + public const string MovieLatest = "MovieLatest"; + public const string MovieResume = "MovieResume"; + public const string MovieMovies = "MovieMovies"; + public const string MovieCollections = "MovieCollections"; + public const string MovieFavorites = "MovieFavorites"; + public const string MovieGenres = "MovieGenres"; + public const string MovieGenre = "MovieGenre"; + + public const string MusicArtists = "MusicArtists"; + public const string MusicAlbumArtists = "MusicAlbumArtists"; + public const string MusicAlbums = "MusicAlbums"; + public const string MusicGenres = "MusicGenres"; + public const string MusicLatest = "MusicLatest"; + public const string MusicPlaylists = "MusicPlaylists"; + public const string MusicSongs = "MusicSongs"; + public const string MusicFavorites = "MusicFavorites"; + public const string MusicFavoriteArtists = "MusicFavoriteArtists"; + public const string MusicFavoriteAlbums = "MusicFavoriteAlbums"; + public const string MusicFavoriteSongs = "MusicFavoriteSongs"; + } +} diff --git a/MediaBrowser.Model/Entities/VirtualFolderInfo.cs b/MediaBrowser.Model/Entities/VirtualFolderInfo.cs index f2bc6f25e0..2b2bda12c3 100644 --- a/MediaBrowser.Model/Entities/VirtualFolderInfo.cs +++ b/MediaBrowser.Model/Entities/VirtualFolderInfo.cs @@ -2,6 +2,8 @@ #pragma warning disable CS1591 using System; +using System.Text.Json.Serialization; +using Jellyfin.Extensions.Json.Converters; using MediaBrowser.Model.Configuration; namespace MediaBrowser.Model.Entities @@ -11,6 +13,14 @@ namespace MediaBrowser.Model.Entities /// </summary> public class VirtualFolderInfo { + /// <summary> + /// Initializes a new instance of the <see cref="VirtualFolderInfo"/> class. + /// </summary> + public VirtualFolderInfo() + { + Locations = Array.Empty<string>(); + } + /// <summary> /// Gets or sets the name. /// </summary> @@ -27,18 +37,11 @@ namespace MediaBrowser.Model.Entities /// Gets or sets the type of the collection. /// </summary> /// <value>The type of the collection.</value> - public string CollectionType { get; set; } + [JsonConverter(typeof(JsonLowerCaseConverter<CollectionTypeOptions?>))] + public CollectionTypeOptions? CollectionType { get; set; } public LibraryOptions LibraryOptions { get; set; } - /// <summary> - /// Initializes a new instance of the <see cref="VirtualFolderInfo"/> class. - /// </summary> - public VirtualFolderInfo() - { - Locations = Array.Empty<string>(); - } - /// <summary> /// Gets or sets the item identifier. /// </summary> diff --git a/MediaBrowser.Model/Extensions/StringHelper.cs b/MediaBrowser.Model/Extensions/StringHelper.cs index 8ffa3c4ba6..77cbef00f5 100644 --- a/MediaBrowser.Model/Extensions/StringHelper.cs +++ b/MediaBrowser.Model/Extensions/StringHelper.cs @@ -17,16 +17,12 @@ namespace MediaBrowser.Model.Extensions return str; } - if (char.IsUpper(str[0])) + // We check IsLower instead of IsUpper because both return false for non-letters + if (!char.IsLower(str[0])) { return str; } -#if NETSTANDARD2_0 - char[] a = str.ToCharArray(); - a[0] = char.ToUpperInvariant(a[0]); - return new string(a); -#else return string.Create( str.Length, str, @@ -38,7 +34,6 @@ namespace MediaBrowser.Model.Extensions chars[i] = buf[i]; } }); -#endif } } } diff --git a/MediaBrowser.Model/Globalization/CultureDto.cs b/MediaBrowser.Model/Globalization/CultureDto.cs index 6af4a872ce..5246f87d92 100644 --- a/MediaBrowser.Model/Globalization/CultureDto.cs +++ b/MediaBrowser.Model/Globalization/CultureDto.cs @@ -10,6 +10,11 @@ namespace MediaBrowser.Model.Globalization /// </summary> public class CultureDto { + public CultureDto() + { + ThreeLetterISOLanguageNames = Array.Empty<string>(); + } + /// <summary> /// Gets or sets the name. /// </summary> @@ -29,7 +34,7 @@ namespace MediaBrowser.Model.Globalization public string TwoLetterISOLanguageName { get; set; } /// <summary> - /// Gets or sets the name of the three letter ISO language. + /// Gets the name of the three letter ISO language. /// </summary> /// <value>The name of the three letter ISO language.</value> public string ThreeLetterISOLanguageName @@ -47,10 +52,5 @@ namespace MediaBrowser.Model.Globalization } public string[] ThreeLetterISOLanguageNames { get; set; } - - public CultureDto() - { - ThreeLetterISOLanguageNames = Array.Empty<string>(); - } } } diff --git a/MediaBrowser.Model/Globalization/ILocalizationManager.cs b/MediaBrowser.Model/Globalization/ILocalizationManager.cs index baefeb39cf..b213e7aa00 100644 --- a/MediaBrowser.Model/Globalization/ILocalizationManager.cs +++ b/MediaBrowser.Model/Globalization/ILocalizationManager.cs @@ -1,4 +1,3 @@ -#nullable disable using System.Collections.Generic; using System.Globalization; using MediaBrowser.Model.Entities; @@ -56,19 +55,11 @@ namespace MediaBrowser.Model.Globalization /// <returns><see cref="IEnumerable{LocalizatonOption}" />.</returns> IEnumerable<LocalizationOption> GetLocalizationOptions(); - /// <summary> - /// Checks if the string contains a character with the specified unicode category. - /// </summary> - /// <param name="value">The string.</param> - /// <param name="category">The unicode category.</param> - /// <returns>Wether or not the string contains a character with the specified unicode category.</returns> - bool HasUnicodeCategory(string value, UnicodeCategory category); - /// <summary> /// Returns the correct <see cref="CultureInfo" /> for the given language. /// </summary> /// <param name="language">The language.</param> /// <returns>The correct <see cref="CultureInfo" /> for the given language.</returns> - CultureDto FindLanguageInfo(string language); + CultureDto? FindLanguageInfo(string language); } } diff --git a/MediaBrowser.Model/IO/FileSystemMetadata.cs b/MediaBrowser.Model/IO/FileSystemMetadata.cs index 118c78e801..fb74886bfa 100644 --- a/MediaBrowser.Model/IO/FileSystemMetadata.cs +++ b/MediaBrowser.Model/IO/FileSystemMetadata.cs @@ -37,12 +37,6 @@ namespace MediaBrowser.Model.IO /// <value>The length.</value> public long Length { get; set; } - /// <summary> - /// Gets or sets the name of the directory. - /// </summary> - /// <value>The name of the directory.</value> - public string DirectoryName { get; set; } - /// <summary> /// Gets or sets the last write time UTC. /// </summary> diff --git a/MediaBrowser.Model/IO/IFileSystem.cs b/MediaBrowser.Model/IO/IFileSystem.cs index dc65497876..be4f1e16b5 100644 --- a/MediaBrowser.Model/IO/IFileSystem.cs +++ b/MediaBrowser.Model/IO/IFileSystem.cs @@ -1,4 +1,3 @@ -#nullable disable #pragma warning disable CS1591 using System; @@ -25,7 +24,7 @@ namespace MediaBrowser.Model.IO /// </summary> /// <param name="filename">The filename.</param> /// <returns>System.String.</returns> - string ResolveShortcut(string filename); + string? ResolveShortcut(string filename); /// <summary> /// Creates the shortcut. @@ -117,13 +116,6 @@ namespace MediaBrowser.Model.IO /// <returns><c>true</c> if [contains sub path] [the specified parent path]; otherwise, <c>false</c>.</returns> bool ContainsSubPath(string parentPath, string path); - /// <summary> - /// Determines whether [is root path] [the specified path]. - /// </summary> - /// <param name="path">The path.</param> - /// <returns><c>true</c> if [is root path] [the specified path]; otherwise, <c>false</c>.</returns> - bool IsRootPath(string path); - /// <summary> /// Normalizes the path. /// </summary> @@ -155,16 +147,19 @@ namespace MediaBrowser.Model.IO /// Gets the directories. /// </summary> /// <param name="path">The path.</param> - /// <param name="recursive">if set to <c>true</c> [recursive].</param> - /// <returns>IEnumerable<DirectoryInfo>.</returns> + /// <param name="recursive">If set to <c>true</c> also searches in subdirectiories.</param> + /// <returns>All found directories.</returns> IEnumerable<FileSystemMetadata> GetDirectories(string path, bool recursive = false); /// <summary> /// Gets the files. /// </summary> + /// <param name="path">The path in which to search.</param> + /// <param name="recursive">If set to <c>true</c> also searches in subdirectiories.</param> + /// <returns>All found files.</returns> IEnumerable<FileSystemMetadata> GetFiles(string path, bool recursive = false); - IEnumerable<FileSystemMetadata> GetFiles(string path, IReadOnlyList<string> extensions, bool enableCaseSensitiveExtensions, bool recursive); + IEnumerable<FileSystemMetadata> GetFiles(string path, IReadOnlyList<string>? extensions, bool enableCaseSensitiveExtensions, bool recursive); /// <summary> /// Gets the file system entries. @@ -190,7 +185,7 @@ namespace MediaBrowser.Model.IO /// <returns>IEnumerable<System.String>.</returns> IEnumerable<string> GetFilePaths(string path, bool recursive = false); - IEnumerable<string> GetFilePaths(string path, string[] extensions, bool enableCaseSensitiveExtensions, bool recursive); + IEnumerable<string> GetFilePaths(string path, string[]? extensions, bool enableCaseSensitiveExtensions, bool recursive); /// <summary> /// Gets the file system entry paths. diff --git a/MediaBrowser.Model/IO/IIsoManager.cs b/MediaBrowser.Model/IO/IIsoManager.cs deleted file mode 100644 index 299bb0a213..0000000000 --- a/MediaBrowser.Model/IO/IIsoManager.cs +++ /dev/null @@ -1,35 +0,0 @@ -#pragma warning disable CS1591 - -using System; -using System.Collections.Generic; -using System.IO; -using System.Threading; -using System.Threading.Tasks; - -namespace MediaBrowser.Model.IO -{ - public interface IIsoManager - { - /// <summary> - /// Mounts the specified iso path. - /// </summary> - /// <param name="isoPath">The iso path.</param> - /// <param name="cancellationToken">The cancellation token.</param> - /// <returns>IsoMount.</returns> - /// <exception cref="IOException">Unable to create mount.</exception> - Task<IIsoMount> Mount(string isoPath, CancellationToken cancellationToken); - - /// <summary> - /// Determines whether this instance can mount the specified path. - /// </summary> - /// <param name="path">The path.</param> - /// <returns><c>true</c> if this instance can mount the specified path; otherwise, <c>false</c>.</returns> - bool CanMount(string path); - - /// <summary> - /// Adds the parts. - /// </summary> - /// <param name="mounters">The mounters.</param> - void AddParts(IEnumerable<IIsoMounter> mounters); - } -} diff --git a/MediaBrowser.Model/IO/IIsoMount.cs b/MediaBrowser.Model/IO/IIsoMount.cs deleted file mode 100644 index ea65d976a0..0000000000 --- a/MediaBrowser.Model/IO/IIsoMount.cs +++ /dev/null @@ -1,22 +0,0 @@ -using System; - -namespace MediaBrowser.Model.IO -{ - /// <summary> - /// Interface IIsoMount. - /// </summary> - public interface IIsoMount : IDisposable - { - /// <summary> - /// Gets the iso path. - /// </summary> - /// <value>The iso path.</value> - string IsoPath { get; } - - /// <summary> - /// Gets the mounted path. - /// </summary> - /// <value>The mounted path.</value> - string MountedPath { get; } - } -} diff --git a/MediaBrowser.Model/IO/IIsoMounter.cs b/MediaBrowser.Model/IO/IIsoMounter.cs deleted file mode 100644 index 0d257395a9..0000000000 --- a/MediaBrowser.Model/IO/IIsoMounter.cs +++ /dev/null @@ -1,35 +0,0 @@ -#pragma warning disable CS1591 - -using System; -using System.IO; -using System.Threading; -using System.Threading.Tasks; - -namespace MediaBrowser.Model.IO -{ - public interface IIsoMounter - { - /// <summary> - /// Gets the name. - /// </summary> - /// <value>The name.</value> - string Name { get; } - - /// <summary> - /// Mounts the specified iso path. - /// </summary> - /// <param name="isoPath">The iso path.</param> - /// <param name="cancellationToken">The cancellation token.</param> - /// <returns>IsoMount.</returns> - /// <exception cref="ArgumentNullException">isoPath</exception> - /// <exception cref="IOException">Unable to create mount.</exception> - Task<IIsoMount> Mount(string isoPath, CancellationToken cancellationToken); - - /// <summary> - /// Determines whether this instance can mount the specified path. - /// </summary> - /// <param name="path">The path.</param> - /// <returns><c>true</c> if this instance can mount the specified path; otherwise, <c>false</c>.</returns> - bool CanMount(string path); - } -} diff --git a/MediaBrowser.Model/IO/IShortcutHandler.cs b/MediaBrowser.Model/IO/IShortcutHandler.cs index 14d5c4b62f..2c364a962f 100644 --- a/MediaBrowser.Model/IO/IShortcutHandler.cs +++ b/MediaBrowser.Model/IO/IShortcutHandler.cs @@ -15,7 +15,7 @@ namespace MediaBrowser.Model.IO /// </summary> /// <param name="shortcutPath">The shortcut path.</param> /// <returns>System.String.</returns> - string Resolve(string shortcutPath); + string? Resolve(string shortcutPath); /// <summary> /// Creates the specified shortcut path. diff --git a/MediaBrowser.Model/IO/IStreamHelper.cs b/MediaBrowser.Model/IO/IStreamHelper.cs index 0e09db16e8..f900da5567 100644 --- a/MediaBrowser.Model/IO/IStreamHelper.cs +++ b/MediaBrowser.Model/IO/IStreamHelper.cs @@ -9,7 +9,7 @@ namespace MediaBrowser.Model.IO { public interface IStreamHelper { - Task CopyToAsync(Stream source, Stream destination, int bufferSize, Action onStarted, CancellationToken cancellationToken); + Task CopyToAsync(Stream source, Stream destination, int bufferSize, Action? onStarted, CancellationToken cancellationToken); Task CopyToAsync(Stream source, Stream destination, int bufferSize, int emptyReadLimit, CancellationToken cancellationToken); diff --git a/MediaBrowser.Model/LiveTv/BaseTimerInfoDto.cs b/MediaBrowser.Model/LiveTv/BaseTimerInfoDto.cs index 07e76d9600..c6de4c1ab8 100644 --- a/MediaBrowser.Model/LiveTv/BaseTimerInfoDto.cs +++ b/MediaBrowser.Model/LiveTv/BaseTimerInfoDto.cs @@ -9,7 +9,7 @@ namespace MediaBrowser.Model.LiveTv public class BaseTimerInfoDto : IHasServerId { /// <summary> - /// Id of the recording. + /// Gets or sets the Id of the recording. /// </summary> public string Id { get; set; } @@ -28,7 +28,7 @@ namespace MediaBrowser.Model.LiveTv public string ExternalId { get; set; } /// <summary> - /// ChannelId of the recording. + /// Gets or sets the channel id of the recording. /// </summary> public Guid ChannelId { get; set; } @@ -39,7 +39,7 @@ namespace MediaBrowser.Model.LiveTv public string ExternalChannelId { get; set; } /// <summary> - /// ChannelName of the recording. + /// Gets or sets the channel name of the recording. /// </summary> public string ChannelName { get; set; } @@ -58,22 +58,22 @@ namespace MediaBrowser.Model.LiveTv public string ExternalProgramId { get; set; } /// <summary> - /// Name of the recording. + /// Gets or sets the name of the recording. /// </summary> public string Name { get; set; } /// <summary> - /// Description of the recording. + /// Gets or sets the description of the recording. /// </summary> public string Overview { get; set; } /// <summary> - /// The start date of the recording, in UTC. + /// Gets or sets the start date of the recording, in UTC. /// </summary> public DateTime StartDate { get; set; } /// <summary> - /// The end date of the recording, in UTC. + /// Gets or sets the end date of the recording, in UTC. /// </summary> public DateTime EndDate { get; set; } @@ -108,7 +108,7 @@ namespace MediaBrowser.Model.LiveTv public bool IsPrePaddingRequired { get; set; } /// <summary> - /// If the item does not have any backdrops, this will hold the Id of the Parent that has one. + /// Gets or sets the Id of the Parent that has a backdrop if the item does not have one. /// </summary> /// <value>The parent backdrop item id.</value> public string ParentBackdropItemId { get; set; } diff --git a/MediaBrowser.Model/LiveTv/ListingsProviderInfo.cs b/MediaBrowser.Model/LiveTv/ListingsProviderInfo.cs new file mode 100644 index 0000000000..082daeb51b --- /dev/null +++ b/MediaBrowser.Model/LiveTv/ListingsProviderInfo.cs @@ -0,0 +1,58 @@ +#nullable disable +#pragma warning disable CS1591 + +using System; +using MediaBrowser.Model.Dto; + +namespace MediaBrowser.Model.LiveTv +{ + public class ListingsProviderInfo + { + public ListingsProviderInfo() + { + NewsCategories = new[] { "news", "journalism", "documentary", "current affairs" }; + SportsCategories = new[] { "sports", "basketball", "baseball", "football" }; + KidsCategories = new[] { "kids", "family", "children", "childrens", "disney" }; + MovieCategories = new[] { "movie" }; + EnabledTuners = Array.Empty<string>(); + EnableAllTuners = true; + ChannelMappings = Array.Empty<NameValuePair>(); + } + + public string Id { get; set; } + + public string Type { get; set; } + + public string Username { get; set; } + + public string Password { get; set; } + + public string ListingsId { get; set; } + + public string ZipCode { get; set; } + + public string Country { get; set; } + + public string Path { get; set; } + + public string[] EnabledTuners { get; set; } + + public bool EnableAllTuners { get; set; } + + public string[] NewsCategories { get; set; } + + public string[] SportsCategories { get; set; } + + public string[] KidsCategories { get; set; } + + public string[] MovieCategories { get; set; } + + public NameValuePair[] ChannelMappings { get; set; } + + public string MoviePrefix { get; set; } + + public string PreferredLanguage { get; set; } + + public string UserAgent { get; set; } + } +} diff --git a/MediaBrowser.Model/LiveTv/LiveTvChannelQuery.cs b/MediaBrowser.Model/LiveTv/LiveTvChannelQuery.cs index bcba344cc5..673d97a9ed 100644 --- a/MediaBrowser.Model/LiveTv/LiveTvChannelQuery.cs +++ b/MediaBrowser.Model/LiveTv/LiveTvChannelQuery.cs @@ -11,6 +11,12 @@ namespace MediaBrowser.Model.LiveTv /// </summary> public class LiveTvChannelQuery { + public LiveTvChannelQuery() + { + EnableUserData = true; + SortBy = Array.Empty<string>(); + } + /// <summary> /// Gets or sets the type of the channel. /// </summary> @@ -48,13 +54,13 @@ namespace MediaBrowser.Model.LiveTv public Guid UserId { get; set; } /// <summary> - /// Skips over a given number of items within the results. Use for paging. + /// Gets or sets the start index. Used for paging. /// </summary> /// <value>The start index.</value> public int? StartIndex { get; set; } /// <summary> - /// The maximum number of items to return. + /// Gets or sets the maximum number of items to return. /// </summary> /// <value>The limit.</value> public int? Limit { get; set; } @@ -68,15 +74,15 @@ namespace MediaBrowser.Model.LiveTv public bool EnableUserData { get; set; } /// <summary> - /// Used to specific whether to return news or not. + /// Gets or sets a value whether to return news or not. /// </summary> - /// <remarks>If set to null, all programs will be returned</remarks> + /// <remarks>If set to <c>null</c>, all programs will be returned.</remarks> public bool? IsNews { get; set; } /// <summary> - /// Used to specific whether to return movies or not. + /// Gets or sets a value whether to return movies or not. /// </summary> - /// <remarks>If set to null, all programs will be returned</remarks> + /// <remarks>If set to <c>null</c>, all programs will be returned.</remarks> public bool? IsMovie { get; set; } /// <summary> @@ -96,15 +102,9 @@ namespace MediaBrowser.Model.LiveTv public string[] SortBy { get; set; } /// <summary> - /// The sort order to return results with. + /// Gets or sets the sort order to return results with. /// </summary> /// <value>The sort order.</value> public SortOrder? SortOrder { get; set; } - - public LiveTvChannelQuery() - { - EnableUserData = true; - SortBy = Array.Empty<string>(); - } } } diff --git a/MediaBrowser.Model/LiveTv/LiveTvOptions.cs b/MediaBrowser.Model/LiveTv/LiveTvOptions.cs index 789de3198a..4cece941cf 100644 --- a/MediaBrowser.Model/LiveTv/LiveTvOptions.cs +++ b/MediaBrowser.Model/LiveTv/LiveTvOptions.cs @@ -2,12 +2,19 @@ #pragma warning disable CS1591 using System; -using MediaBrowser.Model.Dto; namespace MediaBrowser.Model.LiveTv { public class LiveTvOptions { + public LiveTvOptions() + { + TunerHosts = Array.Empty<TunerHostInfo>(); + ListingProviders = Array.Empty<ListingsProviderInfo>(); + MediaLocationsCreated = Array.Empty<string>(); + RecordingPostProcessorArguments = "\"{path}\""; + } + public int? GuideDays { get; set; } public string RecordingPath { get; set; } @@ -33,93 +40,5 @@ namespace MediaBrowser.Model.LiveTv public string RecordingPostProcessor { get; set; } public string RecordingPostProcessorArguments { get; set; } - - public LiveTvOptions() - { - TunerHosts = Array.Empty<TunerHostInfo>(); - ListingProviders = Array.Empty<ListingsProviderInfo>(); - MediaLocationsCreated = Array.Empty<string>(); - RecordingPostProcessorArguments = "\"{path}\""; - } - } - - public class TunerHostInfo - { - public string Id { get; set; } - - public string Url { get; set; } - - public string Type { get; set; } - - public string DeviceId { get; set; } - - public string FriendlyName { get; set; } - - public bool ImportFavoritesOnly { get; set; } - - public bool AllowHWTranscoding { get; set; } - - public bool EnableStreamLooping { get; set; } - - public string Source { get; set; } - - public int TunerCount { get; set; } - - public string UserAgent { get; set; } - - public TunerHostInfo() - { - AllowHWTranscoding = true; - } - } - - public class ListingsProviderInfo - { - public string Id { get; set; } - - public string Type { get; set; } - - public string Username { get; set; } - - public string Password { get; set; } - - public string ListingsId { get; set; } - - public string ZipCode { get; set; } - - public string Country { get; set; } - - public string Path { get; set; } - - public string[] EnabledTuners { get; set; } - - public bool EnableAllTuners { get; set; } - - public string[] NewsCategories { get; set; } - - public string[] SportsCategories { get; set; } - - public string[] KidsCategories { get; set; } - - public string[] MovieCategories { get; set; } - - public NameValuePair[] ChannelMappings { get; set; } - - public string MoviePrefix { get; set; } - - public string PreferredLanguage { get; set; } - - public string UserAgent { get; set; } - - public ListingsProviderInfo() - { - NewsCategories = new[] { "news", "journalism", "documentary", "current affairs" }; - SportsCategories = new[] { "sports", "basketball", "baseball", "football" }; - KidsCategories = new[] { "kids", "family", "children", "childrens", "disney" }; - MovieCategories = new[] { "movie" }; - EnabledTuners = Array.Empty<string>(); - EnableAllTuners = true; - ChannelMappings = Array.Empty<NameValuePair>(); - } } } diff --git a/MediaBrowser.Model/LiveTv/LiveTvServiceInfo.cs b/MediaBrowser.Model/LiveTv/LiveTvServiceInfo.cs index 856f638c5c..ef5c5d2f31 100644 --- a/MediaBrowser.Model/LiveTv/LiveTvServiceInfo.cs +++ b/MediaBrowser.Model/LiveTv/LiveTvServiceInfo.cs @@ -10,6 +10,11 @@ namespace MediaBrowser.Model.LiveTv /// </summary> public class LiveTvServiceInfo { + public LiveTvServiceInfo() + { + Tuners = Array.Empty<string>(); + } + /// <summary> /// Gets or sets the name. /// </summary> @@ -53,10 +58,5 @@ namespace MediaBrowser.Model.LiveTv public bool IsVisible { get; set; } public string[] Tuners { get; set; } - - public LiveTvServiceInfo() - { - Tuners = Array.Empty<string>(); - } } } diff --git a/MediaBrowser.Model/LiveTv/RecordingQuery.cs b/MediaBrowser.Model/LiveTv/RecordingQuery.cs index 69e7db4708..99bb1603c3 100644 --- a/MediaBrowser.Model/LiveTv/RecordingQuery.cs +++ b/MediaBrowser.Model/LiveTv/RecordingQuery.cs @@ -12,6 +12,11 @@ namespace MediaBrowser.Model.LiveTv /// </summary> public class RecordingQuery { + public RecordingQuery() + { + EnableTotalRecordCount = true; + } + /// <summary> /// Gets or sets the channel identifier. /// </summary> @@ -31,13 +36,13 @@ namespace MediaBrowser.Model.LiveTv public string Id { get; set; } /// <summary> - /// Skips over a given number of items within the results. Use for paging. + /// Gets or sets the start index. Use for paging. /// </summary> /// <value>The start index.</value> public int? StartIndex { get; set; } /// <summary> - /// The maximum number of items to return. + /// Gets or sets the maximum number of items to return. /// </summary> /// <value>The limit.</value> public int? Limit { get; set; } @@ -61,7 +66,7 @@ namespace MediaBrowser.Model.LiveTv public string SeriesTimerId { get; set; } /// <summary> - /// Fields to return within the items, in addition to basic information. + /// Gets or sets the fields to return within the items, in addition to basic information. /// </summary> /// <value>The fields.</value> public ItemFields[] Fields { get; set; } @@ -85,10 +90,5 @@ namespace MediaBrowser.Model.LiveTv public ImageType[] EnableImageTypes { get; set; } public bool EnableTotalRecordCount { get; set; } - - public RecordingQuery() - { - EnableTotalRecordCount = true; - } } } diff --git a/MediaBrowser.Model/LiveTv/SeriesTimerInfoDto.cs b/MediaBrowser.Model/LiveTv/SeriesTimerInfoDto.cs index 90422d19c3..b26f5f45fe 100644 --- a/MediaBrowser.Model/LiveTv/SeriesTimerInfoDto.cs +++ b/MediaBrowser.Model/LiveTv/SeriesTimerInfoDto.cs @@ -7,6 +7,14 @@ using MediaBrowser.Model.Entities; namespace MediaBrowser.Model.LiveTv { + public enum KeepUntil + { + UntilDeleted, + UntilSpaceNeeded, + UntilWatched, + UntilDate + } + /// <summary> /// Class SeriesTimerInfoDto. /// </summary> @@ -83,12 +91,4 @@ namespace MediaBrowser.Model.LiveTv /// <value>The parent primary image tag.</value> public string ParentPrimaryImageTag { get; set; } } - - public enum KeepUntil - { - UntilDeleted, - UntilSpaceNeeded, - UntilWatched, - UntilDate - } } diff --git a/MediaBrowser.Model/LiveTv/TunerHostInfo.cs b/MediaBrowser.Model/LiveTv/TunerHostInfo.cs new file mode 100644 index 0000000000..05576a0f8d --- /dev/null +++ b/MediaBrowser.Model/LiveTv/TunerHostInfo.cs @@ -0,0 +1,35 @@ +#nullable disable +#pragma warning disable CS1591 + +namespace MediaBrowser.Model.LiveTv +{ + public class TunerHostInfo + { + public TunerHostInfo() + { + AllowHWTranscoding = true; + } + + public string Id { get; set; } + + public string Url { get; set; } + + public string Type { get; set; } + + public string DeviceId { get; set; } + + public string FriendlyName { get; set; } + + public bool ImportFavoritesOnly { get; set; } + + public bool AllowHWTranscoding { get; set; } + + public bool EnableStreamLooping { get; set; } + + public string Source { get; set; } + + public int TunerCount { get; set; } + + public string UserAgent { get; set; } + } +} diff --git a/MediaBrowser.Model/MediaBrowser.Model.csproj b/MediaBrowser.Model/MediaBrowser.Model.csproj index 253ee7e795..a371afc2cf 100644 --- a/MediaBrowser.Model/MediaBrowser.Model.csproj +++ b/MediaBrowser.Model/MediaBrowser.Model.csproj @@ -1,4 +1,4 @@ -<Project Sdk="Microsoft.NET.Sdk"> +<Project Sdk="Microsoft.NET.Sdk"> <!-- ProjectGuid is only included as a requirement for SonarQube analysis --> <PropertyGroup> @@ -8,22 +8,24 @@ <PropertyGroup> <Authors>Jellyfin Contributors</Authors> <PackageId>Jellyfin.Model</PackageId> - <VersionPrefix>10.7.0</VersionPrefix> + <VersionPrefix>10.8.0</VersionPrefix> <RepositoryUrl>https://github.com/jellyfin/jellyfin</RepositoryUrl> <PackageLicenseExpression>GPL-3.0-only</PackageLicenseExpression> </PropertyGroup> <PropertyGroup> - <TargetFrameworks>netstandard2.0;netstandard2.1</TargetFrameworks> + <TargetFramework>net5.0</TargetFramework> <GenerateAssemblyInfo>false</GenerateAssemblyInfo> <GenerateDocumentationFile>true</GenerateDocumentationFile> - <TreatWarningsAsErrors Condition=" '$(Configuration)' == 'Release' ">true</TreatWarningsAsErrors> - <Nullable>enable</Nullable> - <LangVersion>latest</LangVersion> <PublishRepositoryUrl>true</PublishRepositoryUrl> <EmbedUntrackedSources>true</EmbedUntrackedSources> <IncludeSymbols>true</IncludeSymbols> <SymbolPackageFormat>snupkg</SymbolPackageFormat> + <TreatWarningsAsErrors>false</TreatWarningsAsErrors> + </PropertyGroup> + + <PropertyGroup Condition=" '$(Configuration)' == 'Release'"> + <TreatWarningsAsErrors>true</TreatWarningsAsErrors> </PropertyGroup> <PropertyGroup Condition=" '$(Stability)'=='Unstable'"> @@ -32,11 +34,10 @@ </PropertyGroup> <ItemGroup> - <PackageReference Include="Microsoft.SourceLink.GitHub" Version="1.0.0" PrivateAssets="All"/> - <PackageReference Include="Microsoft.AspNetCore.Http.Abstractions" Version="2.2.0" /> - <PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="3.1.9" /> + <PackageReference Include="Microsoft.SourceLink.GitHub" Version="1.0.0" PrivateAssets="All" /> + <PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="5.0.0" /> <PackageReference Include="System.Globalization" Version="4.3.0" /> - <PackageReference Include="System.Text.Json" Version="5.0.0-preview.8.20407.11" /> + <PackageReference Include="System.Text.Json" Version="5.0.2" /> </ItemGroup> <ItemGroup> @@ -45,17 +46,13 @@ <!-- Code Analyzers--> <ItemGroup Condition=" '$(Configuration)' == 'Debug' "> - <PackageReference Include="Microsoft.CodeAnalysis.FxCopAnalyzers" Version="2.9.8" PrivateAssets="All" /> <PackageReference Include="SerilogAnalyzer" Version="0.15.0" PrivateAssets="All" /> <PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="All" /> <PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" PrivateAssets="All" /> </ItemGroup> <ItemGroup> - <ProjectReference Include="..\Jellyfin.Data\Jellyfin.Data.csproj" /> + <ProjectReference Include="../Jellyfin.Data/Jellyfin.Data.csproj" /> + <ProjectReference Include="../src/Jellyfin.Extensions/Jellyfin.Extensions.csproj" /> </ItemGroup> - <PropertyGroup Condition=" '$(Configuration)' == 'Debug' "> - <CodeAnalysisRuleSet>../jellyfin.ruleset</CodeAnalysisRuleSet> - </PropertyGroup> - </Project> diff --git a/MediaBrowser.Model/MediaInfo/LiveStreamRequest.cs b/MediaBrowser.Model/MediaInfo/LiveStreamRequest.cs index 83bda5d569..36a2407067 100644 --- a/MediaBrowser.Model/MediaInfo/LiveStreamRequest.cs +++ b/MediaBrowser.Model/MediaInfo/LiveStreamRequest.cs @@ -2,6 +2,7 @@ #pragma warning disable CS1591 using System; +using System.Collections.Generic; using MediaBrowser.Model.Dlna; namespace MediaBrowser.Model.MediaInfo @@ -37,7 +38,7 @@ namespace MediaBrowser.Model.MediaInfo public string PlaySessionId { get; set; } - public long? MaxStreamingBitrate { get; set; } + public int? MaxStreamingBitrate { get; set; } public long? StartTimeTicks { get; set; } @@ -55,6 +56,6 @@ namespace MediaBrowser.Model.MediaInfo public bool EnableDirectStream { get; set; } - public MediaProtocol[] DirectPlayProtocols { get; set; } + public IReadOnlyList<MediaProtocol> DirectPlayProtocols { get; set; } } } diff --git a/MediaBrowser.Model/MediaInfo/MediaInfo.cs b/MediaBrowser.Model/MediaInfo/MediaInfo.cs index 472055c22c..453aeb028a 100644 --- a/MediaBrowser.Model/MediaInfo/MediaInfo.cs +++ b/MediaBrowser.Model/MediaInfo/MediaInfo.cs @@ -10,6 +10,17 @@ namespace MediaBrowser.Model.MediaInfo { public class MediaInfo : MediaSourceInfo, IHasProviderIds { + public MediaInfo() + { + Chapters = Array.Empty<ChapterInfo>(); + Artists = Array.Empty<string>(); + AlbumArtists = Array.Empty<string>(); + Studios = Array.Empty<string>(); + Genres = Array.Empty<string>(); + People = Array.Empty<BaseItemPerson>(); + ProviderIds = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase); + } + public ChapterInfo[] Chapters { get; set; } /// <summary> @@ -40,6 +51,8 @@ namespace MediaBrowser.Model.MediaInfo public string ShowName { get; set; } + public string ForcedSortName { get; set; } + public int? IndexNumber { get; set; } public int? ParentIndexNumber { get; set; } @@ -69,16 +82,5 @@ namespace MediaBrowser.Model.MediaInfo /// </summary> /// <value>The overview.</value> public string Overview { get; set; } - - public MediaInfo() - { - Chapters = Array.Empty<ChapterInfo>(); - Artists = Array.Empty<string>(); - AlbumArtists = Array.Empty<string>(); - Studios = Array.Empty<string>(); - Genres = Array.Empty<string>(); - People = Array.Empty<BaseItemPerson>(); - ProviderIds = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase); - } } } diff --git a/MediaBrowser.Model/MediaInfo/PlaybackInfoRequest.cs b/MediaBrowser.Model/MediaInfo/PlaybackInfoRequest.cs index 3216856777..ecd9b8834e 100644 --- a/MediaBrowser.Model/MediaInfo/PlaybackInfoRequest.cs +++ b/MediaBrowser.Model/MediaInfo/PlaybackInfoRequest.cs @@ -8,6 +8,17 @@ namespace MediaBrowser.Model.MediaInfo { public class PlaybackInfoRequest { + public PlaybackInfoRequest() + { + EnableDirectPlay = true; + EnableDirectStream = true; + EnableTranscoding = true; + AllowVideoStreamCopy = true; + AllowAudioStreamCopy = true; + IsPlayback = true; + DirectPlayProtocols = new MediaProtocol[] { MediaProtocol.Http }; + } + public Guid Id { get; set; } public Guid UserId { get; set; } @@ -43,16 +54,5 @@ namespace MediaBrowser.Model.MediaInfo public bool AutoOpenLiveStream { get; set; } public MediaProtocol[] DirectPlayProtocols { get; set; } - - public PlaybackInfoRequest() - { - EnableDirectPlay = true; - EnableDirectStream = true; - EnableTranscoding = true; - AllowVideoStreamCopy = true; - AllowAudioStreamCopy = true; - IsPlayback = true; - DirectPlayProtocols = new MediaProtocol[] { MediaProtocol.Http }; - } } } diff --git a/MediaBrowser.Model/MediaInfo/PlaybackInfoResponse.cs b/MediaBrowser.Model/MediaInfo/PlaybackInfoResponse.cs index 2733501822..32971b108f 100644 --- a/MediaBrowser.Model/MediaInfo/PlaybackInfoResponse.cs +++ b/MediaBrowser.Model/MediaInfo/PlaybackInfoResponse.cs @@ -10,6 +10,14 @@ namespace MediaBrowser.Model.MediaInfo /// </summary> public class PlaybackInfoResponse { + /// <summary> + /// Initializes a new instance of the <see cref="PlaybackInfoResponse" /> class. + /// </summary> + public PlaybackInfoResponse() + { + MediaSources = Array.Empty<MediaSourceInfo>(); + } + /// <summary> /// Gets or sets the media sources. /// </summary> @@ -27,13 +35,5 @@ namespace MediaBrowser.Model.MediaInfo /// </summary> /// <value>The error code.</value> public PlaybackErrorCode? ErrorCode { get; set; } - - /// <summary> - /// Initializes a new instance of the <see cref="PlaybackInfoResponse" /> class. - /// </summary> - public PlaybackInfoResponse() - { - MediaSources = Array.Empty<MediaSourceInfo>(); - } } } diff --git a/MediaBrowser.Model/MediaInfo/SubtitleTrackEvent.cs b/MediaBrowser.Model/MediaInfo/SubtitleTrackEvent.cs index 72bb3d9c63..88b00c166d 100644 --- a/MediaBrowser.Model/MediaInfo/SubtitleTrackEvent.cs +++ b/MediaBrowser.Model/MediaInfo/SubtitleTrackEvent.cs @@ -1,10 +1,15 @@ -#nullable disable #pragma warning disable CS1591 namespace MediaBrowser.Model.MediaInfo { public class SubtitleTrackEvent { + public SubtitleTrackEvent(string id, string text) + { + Id = id; + Text = text; + } + public string Id { get; set; } public string Text { get; set; } diff --git a/MediaBrowser.Model/MediaInfo/SubtitleTrackInfo.cs b/MediaBrowser.Model/MediaInfo/SubtitleTrackInfo.cs index 37f5c55da6..d5c3a6aec5 100644 --- a/MediaBrowser.Model/MediaInfo/SubtitleTrackInfo.cs +++ b/MediaBrowser.Model/MediaInfo/SubtitleTrackInfo.cs @@ -7,11 +7,11 @@ namespace MediaBrowser.Model.MediaInfo { public class SubtitleTrackInfo { - public IReadOnlyList<SubtitleTrackEvent> TrackEvents { get; set; } - public SubtitleTrackInfo() { TrackEvents = Array.Empty<SubtitleTrackEvent>(); } + + public IReadOnlyList<SubtitleTrackEvent> TrackEvents { get; set; } } } diff --git a/MediaBrowser.Model/Net/HttpException.cs b/MediaBrowser.Model/Net/HttpException.cs deleted file mode 100644 index 48ff5d51cc..0000000000 --- a/MediaBrowser.Model/Net/HttpException.cs +++ /dev/null @@ -1,42 +0,0 @@ -using System; -using System.Net; - -namespace MediaBrowser.Model.Net -{ - /// <summary> - /// Class HttpException. - /// </summary> - public class HttpException : Exception - { - /// <summary> - /// Gets or sets the status code. - /// </summary> - /// <value>The status code.</value> - public HttpStatusCode? StatusCode { get; set; } - - /// <summary> - /// Gets or sets a value indicating whether this instance is timed out. - /// </summary> - /// <value><c>true</c> if this instance is timed out; otherwise, <c>false</c>.</value> - public bool IsTimedOut { get; set; } - - /// <summary> - /// Initializes a new instance of the <see cref="HttpException" /> class. - /// </summary> - /// <param name="message">The message.</param> - /// <param name="innerException">The inner exception.</param> - public HttpException(string message, Exception innerException) - : base(message, innerException) - { - } - - /// <summary> - /// Initializes a new instance of the <see cref="HttpException" /> class. - /// </summary> - /// <param name="message">The message.</param> - public HttpException(string message) - : base(message) - { - } - } -} diff --git a/MediaBrowser.Model/Net/ISocket.cs b/MediaBrowser.Model/Net/ISocket.cs index 5b6ed92df1..3de41d565a 100644 --- a/MediaBrowser.Model/Net/ISocket.cs +++ b/MediaBrowser.Model/Net/ISocket.cs @@ -23,6 +23,12 @@ namespace MediaBrowser.Model.Net /// <summary> /// Sends a UDP message to a particular end point (uni or multicast). /// </summary> + /// <param name="buffer">An array of type <see cref="byte" /> that contains the data to send.</param> + /// <param name="offset">The zero-based position in buffer at which to begin sending data.</param> + /// <param name="bytes">The number of bytes to send.</param> + /// <param name="endPoint">An <see cref="IPEndPoint" /> that represents the remote device.</param> + /// <param name="cancellationToken">The cancellation token to cancel operation.</param> + /// <returns>The task object representing the asynchronous operation.</returns> Task SendToAsync(byte[] buffer, int offset, int bytes, IPEndPoint endPoint, CancellationToken cancellationToken); } } diff --git a/MediaBrowser.Model/Net/ISocketFactory.cs b/MediaBrowser.Model/Net/ISocketFactory.cs index 363abefc19..1527ef595c 100644 --- a/MediaBrowser.Model/Net/ISocketFactory.cs +++ b/MediaBrowser.Model/Net/ISocketFactory.cs @@ -14,6 +14,9 @@ namespace MediaBrowser.Model.Net /// <summary> /// Creates a new unicast socket using the specified local port number. /// </summary> + /// <param name="localIp">The local IP address to bind to.</param> + /// <param name="localPort">The local port to bind to.</param> + /// <returns>A new unicast socket using the specified local port number.</returns> ISocket CreateSsdpUdpSocket(IPAddress localIp, int localPort); /// <summary> diff --git a/MediaBrowser.Model/Net/MimeTypes.cs b/MediaBrowser.Model/Net/MimeTypes.cs index afe7351d32..96f5ab51ae 100644 --- a/MediaBrowser.Model/Net/MimeTypes.cs +++ b/MediaBrowser.Model/Net/MimeTypes.cs @@ -91,9 +91,9 @@ namespace MediaBrowser.Model.Net { ".webp", "image/webp" }, // Type font - { ".ttf" , "font/ttf" }, - { ".woff" , "font/woff" }, - { ".woff2" , "font/woff2" }, + { ".ttf", "font/ttf" }, + { ".woff", "font/woff" }, + { ".woff2", "font/woff2" }, // Type text { ".ass", "text/x-ssa" }, @@ -168,16 +168,19 @@ namespace MediaBrowser.Model.Net /// <summary> /// Gets the type of the MIME. /// </summary> - public static string? GetMimeType(string path, bool enableStreamDefault) + /// <param name="filename">The filename to find the MIME type of.</param> + /// <param name="enableStreamDefault">Whether of not to return a default value if no fitting MIME type is found.</param> + /// <returns>The worrect MIME type for the given filename, or `null` if it wasn't found and <paramref name="enableStreamDefault"/> is false.</returns> + public static string? GetMimeType(string filename, bool enableStreamDefault) { - if (path.Length == 0) + if (filename.Length == 0) { - throw new ArgumentException("String can't be empty.", nameof(path)); + throw new ArgumentException("String can't be empty.", nameof(filename)); } - var ext = Path.GetExtension(path); + var ext = Path.GetExtension(filename); - if (_mimeTypeLookup.TryGetValue(ext, out string result)) + if (_mimeTypeLookup.TryGetValue(ext, out string? result)) { return result; } @@ -220,7 +223,7 @@ namespace MediaBrowser.Model.Net // handle text/html; charset=UTF-8 mimeType = mimeType.Split(';')[0]; - if (_extensionLookup.TryGetValue(mimeType, out string result)) + if (_extensionLookup.TryGetValue(mimeType, out string? result)) { return result; } diff --git a/MediaBrowser.Model/Net/NetworkShare.cs b/MediaBrowser.Model/Net/NetworkShare.cs deleted file mode 100644 index 6344cbe21e..0000000000 --- a/MediaBrowser.Model/Net/NetworkShare.cs +++ /dev/null @@ -1,33 +0,0 @@ -#nullable disable -#pragma warning disable CS1591 - -namespace MediaBrowser.Model.Net -{ - public class NetworkShare - { - /// <summary> - /// The name of the computer that this share belongs to. - /// </summary> - public string Server { get; set; } - - /// <summary> - /// Share name. - /// </summary> - public string Name { get; set; } - - /// <summary> - /// Local path. - /// </summary> - public string Path { get; set; } - - /// <summary> - /// Share type. - /// </summary> - public NetworkShareType ShareType { get; set; } - - /// <summary> - /// Comment. - /// </summary> - public string Remark { get; set; } - } -} diff --git a/MediaBrowser.Model/Net/SocketReceiveResult.cs b/MediaBrowser.Model/Net/SocketReceiveResult.cs index 54139fe9c5..1524786ea7 100644 --- a/MediaBrowser.Model/Net/SocketReceiveResult.cs +++ b/MediaBrowser.Model/Net/SocketReceiveResult.cs @@ -20,12 +20,12 @@ namespace MediaBrowser.Model.Net public int ReceivedBytes { get; set; } /// <summary> - /// The <see cref="IPEndPoint"/> the data was received from. + /// Gets or sets the <see cref="IPEndPoint"/> the data was received from. /// </summary> public IPEndPoint RemoteEndPoint { get; set; } /// <summary> - /// The local <see cref="IPAddress"/>. + /// Gets or sets the local <see cref="IPAddress"/>. /// </summary> public IPAddress LocalIPAddress { get; set; } } diff --git a/MediaBrowser.Model/Net/WebSocketMessage.cs b/MediaBrowser.Model/Net/WebSocketMessage.cs index bffbbe612d..b00158cb33 100644 --- a/MediaBrowser.Model/Net/WebSocketMessage.cs +++ b/MediaBrowser.Model/Net/WebSocketMessage.cs @@ -9,7 +9,7 @@ namespace MediaBrowser.Model.Net /// <summary> /// Class WebSocketMessage. /// </summary> - /// <typeparam name="T"></typeparam> + /// <typeparam name="T">The type of the data.</typeparam> public class WebSocketMessage<T> { /// <summary> diff --git a/MediaBrowser.Model/Notifications/NotificationOptions.cs b/MediaBrowser.Model/Notifications/NotificationOptions.cs index 239a3777e1..09beb2ef72 100644 --- a/MediaBrowser.Model/Notifications/NotificationOptions.cs +++ b/MediaBrowser.Model/Notifications/NotificationOptions.cs @@ -2,18 +2,14 @@ #pragma warning disable CS1591 using System; -using Jellyfin.Data.Enums; -using MediaBrowser.Model.Extensions; using System.Linq; using Jellyfin.Data.Entities; -using MediaBrowser.Model.Users; +using Jellyfin.Data.Enums; namespace MediaBrowser.Model.Notifications { public class NotificationOptions { - public NotificationOption[] Options { get; set; } - public NotificationOptions() { Options = new[] @@ -71,6 +67,8 @@ namespace MediaBrowser.Model.Notifications }; } + public NotificationOption[] Options { get; set; } + public NotificationOption GetOptions(string type) { foreach (NotificationOption i in Options) @@ -95,16 +93,17 @@ namespace MediaBrowser.Model.Notifications { NotificationOption opt = GetOptions(notificationType); - return opt == null || - !opt.DisabledServices.Contains(service, StringComparer.OrdinalIgnoreCase); + return opt == null + || !opt.DisabledServices.Contains(service, StringComparer.OrdinalIgnoreCase); } public bool IsEnabledToMonitorUser(string type, Guid userId) { NotificationOption opt = GetOptions(type); - return opt != null && opt.Enabled && - !opt.DisabledMonitorUsers.Contains(userId.ToString(""), StringComparer.OrdinalIgnoreCase); + return opt != null + && opt.Enabled + && !opt.DisabledMonitorUsers.Contains(userId.ToString("N"), StringComparer.OrdinalIgnoreCase); } public bool IsEnabledToSendToUser(string type, string userId, User user) diff --git a/MediaBrowser.Model/Notifications/NotificationRequest.cs b/MediaBrowser.Model/Notifications/NotificationRequest.cs index febc2bc099..622c50cd88 100644 --- a/MediaBrowser.Model/Notifications/NotificationRequest.cs +++ b/MediaBrowser.Model/Notifications/NotificationRequest.cs @@ -7,6 +7,12 @@ namespace MediaBrowser.Model.Notifications { public class NotificationRequest { + public NotificationRequest() + { + UserIds = Array.Empty<Guid>(); + Date = DateTime.UtcNow; + } + public string Name { get; set; } public string Description { get; set; } @@ -20,16 +26,10 @@ namespace MediaBrowser.Model.Notifications public DateTime Date { get; set; } /// <summary> - /// The corresponding type name used in configuration. Not for display. + /// Gets or sets the corresponding type name used in configuration. Not for display. /// </summary> public string NotificationType { get; set; } public SendToUserType? SendToUserMode { get; set; } - - public NotificationRequest() - { - UserIds = Array.Empty<Guid>(); - Date = DateTime.UtcNow; - } } } diff --git a/MediaBrowser.Model/Notifications/NotificationType.cs b/MediaBrowser.Model/Notifications/NotificationType.cs index d58fbbc216..a8b257b8d0 100644 --- a/MediaBrowser.Model/Notifications/NotificationType.cs +++ b/MediaBrowser.Model/Notifications/NotificationType.cs @@ -18,7 +18,6 @@ namespace MediaBrowser.Model.Notifications NewLibraryContent, ServerRestartRequired, TaskFailed, - CameraImageUploaded, UserLockedOut } } diff --git a/MediaBrowser.Model/Playlists/PlaylistCreationRequest.cs b/MediaBrowser.Model/Playlists/PlaylistCreationRequest.cs index ef435b21ec..e8ee494034 100644 --- a/MediaBrowser.Model/Playlists/PlaylistCreationRequest.cs +++ b/MediaBrowser.Model/Playlists/PlaylistCreationRequest.cs @@ -2,6 +2,7 @@ #pragma warning disable CS1591 using System; +using System.Collections.Generic; namespace MediaBrowser.Model.Playlists { @@ -9,15 +10,10 @@ namespace MediaBrowser.Model.Playlists { public string Name { get; set; } - public Guid[] ItemIdList { get; set; } + public IReadOnlyList<Guid> ItemIdList { get; set; } = Array.Empty<Guid>(); public string MediaType { get; set; } public Guid UserId { get; set; } - - public PlaylistCreationRequest() - { - ItemIdList = Array.Empty<Guid>(); - } } } diff --git a/MediaBrowser.Model/Plugins/PluginInfo.cs b/MediaBrowser.Model/Plugins/PluginInfo.cs index dd215192f9..8eb90bdb05 100644 --- a/MediaBrowser.Model/Plugins/PluginInfo.cs +++ b/MediaBrowser.Model/Plugins/PluginInfo.cs @@ -1,4 +1,5 @@ -#nullable disable +using System; + namespace MediaBrowser.Model.Plugins { /// <summary> @@ -6,35 +7,47 @@ namespace MediaBrowser.Model.Plugins /// </summary> public class PluginInfo { + /// <summary> + /// Initializes a new instance of the <see cref="PluginInfo"/> class. + /// </summary> + /// <param name="name">The plugin name.</param> + /// <param name="version">The plugin <see cref="Version"/>.</param> + /// <param name="description">The plugin description.</param> + /// <param name="id">The <see cref="Guid"/>.</param> + /// <param name="canUninstall">True if this plugin can be uninstalled.</param> + public PluginInfo(string name, Version version, string description, Guid id, bool canUninstall) + { + Name = name; + Version = version; + Description = description; + Id = id; + CanUninstall = canUninstall; + } + /// <summary> /// Gets or sets the name. /// </summary> - /// <value>The name.</value> public string Name { get; set; } /// <summary> /// Gets or sets the version. /// </summary> - /// <value>The version.</value> - public string Version { get; set; } + public Version Version { get; set; } /// <summary> /// Gets or sets the name of the configuration file. /// </summary> - /// <value>The name of the configuration file.</value> - public string ConfigurationFileName { get; set; } + public string? ConfigurationFileName { get; set; } /// <summary> /// Gets or sets the description. /// </summary> - /// <value>The description.</value> public string Description { get; set; } /// <summary> /// Gets or sets the unique id. /// </summary> - /// <value>The unique id.</value> - public string Id { get; set; } + public Guid Id { get; set; } /// <summary> /// Gets or sets a value indicating whether the plugin can be uninstalled. @@ -42,9 +55,13 @@ namespace MediaBrowser.Model.Plugins public bool CanUninstall { get; set; } /// <summary> - /// Gets or sets the image URL. + /// Gets or sets a value indicating whether this plugin has a valid image. + /// </summary> + public bool HasImage { get; set; } + + /// <summary> + /// Gets or sets a value indicating the status of the plugin. /// </summary> - /// <value>The image URL.</value> - public string ImageUrl { get; set; } + public PluginStatus Status { get; set; } } } diff --git a/MediaBrowser.Model/Plugins/PluginPageInfo.cs b/MediaBrowser.Model/Plugins/PluginPageInfo.cs index ca72e19ee1..f4d83c28b1 100644 --- a/MediaBrowser.Model/Plugins/PluginPageInfo.cs +++ b/MediaBrowser.Model/Plugins/PluginPageInfo.cs @@ -1,20 +1,38 @@ -#nullable disable -#pragma warning disable CS1591 - namespace MediaBrowser.Model.Plugins { + /// <summary> + /// Defines the <see cref="PluginPageInfo" />. + /// </summary> public class PluginPageInfo { - public string Name { get; set; } + /// <summary> + /// Gets or sets the name. + /// </summary> + public string Name { get; set; } = string.Empty; - public string DisplayName { get; set; } + /// <summary> + /// Gets or sets the display name. + /// </summary> + public string? DisplayName { get; set; } - public string EmbeddedResourcePath { get; set; } + /// <summary> + /// Gets or sets the resource path. + /// </summary> + public string EmbeddedResourcePath { get; set; } = string.Empty; + /// <summary> + /// Gets or sets a value indicating whether this plugin should appear in the main menu. + /// </summary> public bool EnableInMainMenu { get; set; } - public string MenuSection { get; set; } + /// <summary> + /// Gets or sets the menu section. + /// </summary> + public string? MenuSection { get; set; } - public string MenuIcon { get; set; } + /// <summary> + /// Gets or sets the menu icon. + /// </summary> + public string? MenuIcon { get; set; } } } diff --git a/MediaBrowser.Model/Plugins/PluginStatus.cs b/MediaBrowser.Model/Plugins/PluginStatus.cs new file mode 100644 index 0000000000..4b9b9bbeee --- /dev/null +++ b/MediaBrowser.Model/Plugins/PluginStatus.cs @@ -0,0 +1,47 @@ +namespace MediaBrowser.Model.Plugins +{ + /// <summary> + /// Plugin load status. + /// </summary> + public enum PluginStatus + { + /// <summary> + /// This plugin requires a restart in order for it to load. This is a memory only status. + /// The actual status of the plugin after reload is present in the manifest. + /// eg. A disabled plugin will still be active until the next restart, and so will have a memory status of Restart, + /// but a disk manifest status of Disabled. + /// </summary> + Restart = 1, + + /// <summary> + /// This plugin is currently running. + /// </summary> + Active = 0, + + /// <summary> + /// This plugin has been marked as disabled. + /// </summary> + Disabled = -1, + + /// <summary> + /// This plugin does not meet the TargetAbi requirements. + /// </summary> + NotSupported = -2, + + /// <summary> + /// This plugin caused an error when instantiated. (Either DI loop, or exception) + /// </summary> + Malfunctioned = -3, + + /// <summary> + /// This plugin has been superceded by another version. + /// </summary> + Superceded = -4, + + /// <summary> + /// An attempt to remove this plugin from disk will happen at every restart. + /// It will not be loaded, if unable to do so. + /// </summary> + Deleted = -5 + } +} diff --git a/MediaBrowser.Model/Properties/AssemblyInfo.cs b/MediaBrowser.Model/Properties/AssemblyInfo.cs index f99e9ece96..e50baf604e 100644 --- a/MediaBrowser.Model/Properties/AssemblyInfo.cs +++ b/MediaBrowser.Model/Properties/AssemblyInfo.cs @@ -1,5 +1,6 @@ using System.Reflection; using System.Resources; +using System.Runtime.CompilerServices; using System.Runtime.InteropServices; // General Information about an assembly is controlled through the following @@ -14,6 +15,7 @@ using System.Runtime.InteropServices; [assembly: AssemblyTrademark("")] [assembly: AssemblyCulture("")] [assembly: NeutralResourcesLanguage("en")] +[assembly: InternalsVisibleTo("Jellyfin.Model.Tests")] // Setting ComVisible to false makes the types in this assembly not visible // to COM components. If you need to access a type in this assembly from diff --git a/MediaBrowser.Model/Providers/ExternalIdInfo.cs b/MediaBrowser.Model/Providers/ExternalIdInfo.cs index 01784554f9..0ea3e96cae 100644 --- a/MediaBrowser.Model/Providers/ExternalIdInfo.cs +++ b/MediaBrowser.Model/Providers/ExternalIdInfo.cs @@ -5,17 +5,32 @@ namespace MediaBrowser.Model.Providers /// </summary> public class ExternalIdInfo { + /// <summary> + /// Initializes a new instance of the <see cref="ExternalIdInfo"/> class. + /// </summary> + /// <param name="name">Name of the external id provider (IE: IMDB, MusicBrainz, etc).</param> + /// <param name="key">Key for this id. This key should be unique across all providers.</param> + /// <param name="type">Specific media type for this id.</param> + /// <param name="urlFormatString">URL format string.</param> + public ExternalIdInfo(string name, string key, ExternalIdMediaType? type, string urlFormatString) + { + Name = name; + Key = key; + Type = type; + UrlFormatString = urlFormatString; + } + /// <summary> /// Gets or sets the display name of the external id provider (IE: IMDB, MusicBrainz, etc). /// </summary> // TODO: This should be renamed to ProviderName - public string? Name { get; set; } + public string Name { get; set; } /// <summary> /// Gets or sets the unique key for this id. This key should be unique across all providers. /// </summary> // TODO: This property is not actually unique across the concrete types at the moment. It should be updated to be unique. - public string? Key { get; set; } + public string Key { get; set; } /// <summary> /// Gets or sets the specific media type for this id. This is used to distinguish between the different @@ -31,6 +46,6 @@ namespace MediaBrowser.Model.Providers /// <summary> /// Gets or sets the URL format string. /// </summary> - public string? UrlFormatString { get; set; } + public string UrlFormatString { get; set; } } } diff --git a/MediaBrowser.Model/Providers/RemoteImageInfo.cs b/MediaBrowser.Model/Providers/RemoteImageInfo.cs index fb25999e0a..48207d2d41 100644 --- a/MediaBrowser.Model/Providers/RemoteImageInfo.cs +++ b/MediaBrowser.Model/Providers/RemoteImageInfo.cs @@ -22,7 +22,7 @@ namespace MediaBrowser.Model.Providers public string Url { get; set; } /// <summary> - /// Gets a url used for previewing a smaller version. + /// Gets or sets a url used for previewing a smaller version. /// </summary> public string ThumbnailUrl { get; set; } diff --git a/MediaBrowser.Model/Providers/SubtitleOptions.cs b/MediaBrowser.Model/Providers/SubtitleOptions.cs index 5702c460b0..6ea1e14862 100644 --- a/MediaBrowser.Model/Providers/SubtitleOptions.cs +++ b/MediaBrowser.Model/Providers/SubtitleOptions.cs @@ -7,6 +7,14 @@ namespace MediaBrowser.Model.Providers { public class SubtitleOptions { + public SubtitleOptions() + { + DownloadLanguages = Array.Empty<string>(); + + SkipIfAudioTrackMatches = true; + RequirePerfectMatch = true; + } + public bool SkipIfEmbeddedSubtitlesPresent { get; set; } public bool SkipIfAudioTrackMatches { get; set; } @@ -24,13 +32,5 @@ namespace MediaBrowser.Model.Providers public bool IsOpenSubtitleVipAccount { get; set; } public bool RequirePerfectMatch { get; set; } - - public SubtitleOptions() - { - DownloadLanguages = Array.Empty<string>(); - - SkipIfAudioTrackMatches = true; - RequirePerfectMatch = true; - } } } diff --git a/MediaBrowser.Model/Querying/EpisodeQuery.cs b/MediaBrowser.Model/Querying/EpisodeQuery.cs deleted file mode 100644 index 13b1a0dcbf..0000000000 --- a/MediaBrowser.Model/Querying/EpisodeQuery.cs +++ /dev/null @@ -1,75 +0,0 @@ -#nullable disable -#pragma warning disable CS1591 - -using System; - -namespace MediaBrowser.Model.Querying -{ - public class EpisodeQuery - { - /// <summary> - /// Gets or sets the user identifier. - /// </summary> - /// <value>The user identifier.</value> - public string UserId { get; set; } - - /// <summary> - /// Gets or sets the season identifier. - /// </summary> - /// <value>The season identifier.</value> - public string SeasonId { get; set; } - - /// <summary> - /// Gets or sets the series identifier. - /// </summary> - /// <value>The series identifier.</value> - public string SeriesId { get; set; } - - /// <summary> - /// Gets or sets a value indicating whether this instance is missing. - /// </summary> - /// <value><c>null</c> if [is missing] contains no value, <c>true</c> if [is missing]; otherwise, <c>false</c>.</value> - public bool? IsMissing { get; set; } - - /// <summary> - /// Gets or sets a value indicating whether this instance is virtual unaired. - /// </summary> - /// <value><c>null</c> if [is virtual unaired] contains no value, <c>true</c> if [is virtual unaired]; otherwise, <c>false</c>.</value> - public bool? IsVirtualUnaired { get; set; } - - /// <summary> - /// Gets or sets the season number. - /// </summary> - /// <value>The season number.</value> - public int? SeasonNumber { get; set; } - - /// <summary> - /// Gets or sets the fields. - /// </summary> - /// <value>The fields.</value> - public ItemFields[] Fields { get; set; } - - /// <summary> - /// Gets or sets the start index. - /// </summary> - /// <value>The start index.</value> - public int? StartIndex { get; set; } - - /// <summary> - /// Gets or sets the limit. - /// </summary> - /// <value>The limit.</value> - public int? Limit { get; set; } - - /// <summary> - /// Gets or sets the start item identifier. - /// </summary> - /// <value>The start item identifier.</value> - public string StartItemId { get; set; } - - public EpisodeQuery() - { - Fields = Array.Empty<ItemFields>(); - } - } -} diff --git a/MediaBrowser.Model/Querying/LatestItemsQuery.cs b/MediaBrowser.Model/Querying/LatestItemsQuery.cs index 7954ef4b43..f555ffb36d 100644 --- a/MediaBrowser.Model/Querying/LatestItemsQuery.cs +++ b/MediaBrowser.Model/Querying/LatestItemsQuery.cs @@ -14,31 +14,32 @@ namespace MediaBrowser.Model.Querying } /// <summary> - /// The user to localize search results for. + /// Gets or sets the user to localize search results for. /// </summary> /// <value>The user id.</value> public Guid UserId { get; set; } /// <summary> + /// Gets or sets the parent id. /// Specify this to localize the search to a specific item or folder. Omit to use the root. /// </summary> /// <value>The parent id.</value> public Guid ParentId { get; set; } /// <summary> - /// Skips over a given number of items within the results. Use for paging. + /// Gets or sets the start index. Used for paging. /// </summary> /// <value>The start index.</value> public int? StartIndex { get; set; } /// <summary> - /// The maximum number of items to return. + /// Gets or sets the maximum number of items to return. /// </summary> /// <value>The limit.</value> public int? Limit { get; set; } /// <summary> - /// Fields to return within the items, in addition to basic information. + /// Gets or sets the fields to return within the items, in addition to basic information. /// </summary> /// <value>The fields.</value> public ItemFields[] Fields { get; set; } diff --git a/MediaBrowser.Model/Querying/MovieRecommendationQuery.cs b/MediaBrowser.Model/Querying/MovieRecommendationQuery.cs deleted file mode 100644 index 1c8875890a..0000000000 --- a/MediaBrowser.Model/Querying/MovieRecommendationQuery.cs +++ /dev/null @@ -1,47 +0,0 @@ -#nullable disable -#pragma warning disable CS1591 - -using System; - -namespace MediaBrowser.Model.Querying -{ - public class MovieRecommendationQuery - { - /// <summary> - /// Gets or sets the user identifier. - /// </summary> - /// <value>The user identifier.</value> - public string UserId { get; set; } - - /// <summary> - /// Gets or sets the parent identifier. - /// </summary> - /// <value>The parent identifier.</value> - public string ParentId { get; set; } - - /// <summary> - /// Gets or sets the item limit. - /// </summary> - /// <value>The item limit.</value> - public int ItemLimit { get; set; } - - /// <summary> - /// Gets or sets the category limit. - /// </summary> - /// <value>The category limit.</value> - public int CategoryLimit { get; set; } - - /// <summary> - /// Gets or sets the fields. - /// </summary> - /// <value>The fields.</value> - public ItemFields[] Fields { get; set; } - - public MovieRecommendationQuery() - { - ItemLimit = 10; - CategoryLimit = 6; - Fields = Array.Empty<ItemFields>(); - } - } -} diff --git a/MediaBrowser.Model/Querying/NextUpQuery.cs b/MediaBrowser.Model/Querying/NextUpQuery.cs index ee13ffc168..fa8aa829d6 100644 --- a/MediaBrowser.Model/Querying/NextUpQuery.cs +++ b/MediaBrowser.Model/Querying/NextUpQuery.cs @@ -8,6 +8,14 @@ namespace MediaBrowser.Model.Querying { public class NextUpQuery { + public NextUpQuery() + { + EnableImageTypes = Array.Empty<ImageType>(); + EnableTotalRecordCount = true; + DisableFirstEpisode = false; + NextUpDateCutoff = DateTime.MinValue; + } + /// <summary> /// Gets or sets the user id. /// </summary> @@ -18,7 +26,7 @@ namespace MediaBrowser.Model.Querying /// Gets or sets the parent identifier. /// </summary> /// <value>The parent identifier.</value> - public string ParentId { get; set; } + public Guid? ParentId { get; set; } /// <summary> /// Gets or sets the series id. @@ -27,19 +35,19 @@ namespace MediaBrowser.Model.Querying public string SeriesId { get; set; } /// <summary> - /// Skips over a given number of items within the results. Use for paging. + /// Gets or sets the start index. Use for paging. /// </summary> /// <value>The start index.</value> public int? StartIndex { get; set; } /// <summary> - /// The maximum number of items to return. + /// Gets or sets the maximum number of items to return. /// </summary> /// <value>The limit.</value> public int? Limit { get; set; } /// <summary> - /// Fields to return within the items, in addition to basic information. + /// gets or sets the fields to return within the items, in addition to basic information. /// </summary> /// <value>The fields.</value> public ItemFields[] Fields { get; set; } @@ -64,10 +72,14 @@ namespace MediaBrowser.Model.Querying public bool EnableTotalRecordCount { get; set; } - public NextUpQuery() - { - EnableImageTypes = Array.Empty<ImageType>(); - EnableTotalRecordCount = true; - } + /// <summary> + /// Gets or sets a value indicating whether do disable sending first episode as next up. + /// </summary> + public bool DisableFirstEpisode { get; set; } + + /// <summary> + /// Gets or sets a value indicating the oldest date for a show to appear in Next Up. + /// </summary> + public DateTime NextUpDateCutoff { get; set; } } } diff --git a/MediaBrowser.Model/Querying/QueryFilters.cs b/MediaBrowser.Model/Querying/QueryFilters.cs index 6e4d251818..73b27a7b06 100644 --- a/MediaBrowser.Model/Querying/QueryFilters.cs +++ b/MediaBrowser.Model/Querying/QueryFilters.cs @@ -6,35 +6,16 @@ using MediaBrowser.Model.Dto; namespace MediaBrowser.Model.Querying { - public class QueryFiltersLegacy + public class QueryFilters { - public string[] Genres { get; set; } - - public string[] Tags { get; set; } - - public string[] OfficialRatings { get; set; } - - public int[] Years { get; set; } - - public QueryFiltersLegacy() + public QueryFilters() { - Genres = Array.Empty<string>(); Tags = Array.Empty<string>(); - OfficialRatings = Array.Empty<string>(); - Years = Array.Empty<int>(); + Genres = Array.Empty<NameGuidPair>(); } - } - public class QueryFilters - { public NameGuidPair[] Genres { get; set; } public string[] Tags { get; set; } - - public QueryFilters() - { - Tags = Array.Empty<string>(); - Genres = Array.Empty<NameGuidPair>(); - } } } diff --git a/MediaBrowser.Model/Querying/QueryFiltersLegacy.cs b/MediaBrowser.Model/Querying/QueryFiltersLegacy.cs new file mode 100644 index 0000000000..fcb450ed30 --- /dev/null +++ b/MediaBrowser.Model/Querying/QueryFiltersLegacy.cs @@ -0,0 +1,26 @@ +#nullable disable +#pragma warning disable CS1591 + +using System; + +namespace MediaBrowser.Model.Querying +{ + public class QueryFiltersLegacy + { + public QueryFiltersLegacy() + { + Genres = Array.Empty<string>(); + Tags = Array.Empty<string>(); + OfficialRatings = Array.Empty<string>(); + Years = Array.Empty<int>(); + } + + public string[] Genres { get; set; } + + public string[] Tags { get; set; } + + public string[] OfficialRatings { get; set; } + + public int[] Years { get; set; } + } +} diff --git a/MediaBrowser.Model/Querying/QueryResult.cs b/MediaBrowser.Model/Querying/QueryResult.cs index 490f48b84b..8ce794800b 100644 --- a/MediaBrowser.Model/Querying/QueryResult.cs +++ b/MediaBrowser.Model/Querying/QueryResult.cs @@ -8,6 +8,17 @@ namespace MediaBrowser.Model.Querying { public class QueryResult<T> { + public QueryResult() + { + Items = Array.Empty<T>(); + } + + public QueryResult(IReadOnlyList<T> items) + { + Items = items; + TotalRecordCount = items.Count; + } + /// <summary> /// Gets or sets the items. /// </summary> @@ -15,26 +26,15 @@ namespace MediaBrowser.Model.Querying public IReadOnlyList<T> Items { get; set; } /// <summary> - /// The total number of records available. + /// Gets or sets the total number of records available. /// </summary> /// <value>The total record count.</value> public int TotalRecordCount { get; set; } /// <summary> - /// The index of the first record in Items. + /// Gets or sets the index of the first record in Items. /// </summary> /// <value>First record index.</value> public int StartIndex { get; set; } - - public QueryResult() - { - Items = Array.Empty<T>(); - } - - public QueryResult(IReadOnlyList<T> items) - { - Items = items; - TotalRecordCount = items.Count; - } } } diff --git a/MediaBrowser.Model/Querying/UpcomingEpisodesQuery.cs b/MediaBrowser.Model/Querying/UpcomingEpisodesQuery.cs deleted file mode 100644 index eb62394605..0000000000 --- a/MediaBrowser.Model/Querying/UpcomingEpisodesQuery.cs +++ /dev/null @@ -1,64 +0,0 @@ -#nullable disable -#pragma warning disable CS1591 - -using System; -using MediaBrowser.Model.Entities; - -namespace MediaBrowser.Model.Querying -{ - public class UpcomingEpisodesQuery - { - /// <summary> - /// Gets or sets the user id. - /// </summary> - /// <value>The user id.</value> - public string UserId { get; set; } - - /// <summary> - /// Gets or sets the parent identifier. - /// </summary> - /// <value>The parent identifier.</value> - public string ParentId { get; set; } - - /// <summary> - /// Skips over a given number of items within the results. Use for paging. - /// </summary> - /// <value>The start index.</value> - public int? StartIndex { get; set; } - - /// <summary> - /// The maximum number of items to return. - /// </summary> - /// <value>The limit.</value> - public int? Limit { get; set; } - - /// <summary> - /// Fields to return within the items, in addition to basic information. - /// </summary> - /// <value>The fields.</value> - public ItemFields[] Fields { get; set; } - - /// <summary> - /// Gets or sets a value indicating whether [enable images]. - /// </summary> - /// <value><c>null</c> if [enable images] contains no value, <c>true</c> if [enable images]; otherwise, <c>false</c>.</value> - public bool? EnableImages { get; set; } - - /// <summary> - /// Gets or sets the image type limit. - /// </summary> - /// <value>The image type limit.</value> - public int? ImageTypeLimit { get; set; } - - /// <summary> - /// Gets or sets the enable image types. - /// </summary> - /// <value>The enable image types.</value> - public ImageType[] EnableImageTypes { get; set; } - - public UpcomingEpisodesQuery() - { - EnableImageTypes = Array.Empty<ImageType>(); - } - } -} diff --git a/MediaBrowser.Model/QuickConnect/QuickConnectResult.cs b/MediaBrowser.Model/QuickConnect/QuickConnectResult.cs index 0fa40b6a72..d180d29860 100644 --- a/MediaBrowser.Model/QuickConnect/QuickConnectResult.cs +++ b/MediaBrowser.Model/QuickConnect/QuickConnectResult.cs @@ -3,38 +3,46 @@ using System; namespace MediaBrowser.Model.QuickConnect { /// <summary> - /// Stores the result of an incoming quick connect request. + /// Stores the state of an quick connect request. /// </summary> public class QuickConnectResult { /// <summary> - /// Gets a value indicating whether this request is authorized. + /// Initializes a new instance of the <see cref="QuickConnectResult"/> class. /// </summary> - public bool Authenticated => !string.IsNullOrEmpty(Authentication); + /// <param name="secret">The secret used to query the request state.</param> + /// <param name="code">The code used to allow the request.</param> + /// <param name="dateAdded">The time when the request was created.</param> + public QuickConnectResult(string secret, string code, DateTime dateAdded) + { + Secret = secret; + Code = code; + DateAdded = dateAdded; + } /// <summary> - /// Gets or sets the secret value used to uniquely identify this request. Can be used to retrieve authentication information. + /// Gets a value indicating whether this request is authorized. /// </summary> - public string? Secret { get; set; } + public bool Authenticated => Authentication != null; /// <summary> - /// Gets or sets the user facing code used so the user can quickly differentiate this request from others. + /// Gets the secret value used to uniquely identify this request. Can be used to retrieve authentication information. /// </summary> - public string? Code { get; set; } + public string Secret { get; } /// <summary> - /// Gets or sets the private access token. + /// Gets the user facing code used so the user can quickly differentiate this request from others. /// </summary> - public string? Authentication { get; set; } + public string Code { get; } /// <summary> - /// Gets or sets an error message. + /// Gets or sets the private access token. /// </summary> - public string? Error { get; set; } + public Guid? Authentication { get; set; } /// <summary> /// Gets or sets the DateTime that this request was created. /// </summary> - public DateTime? DateAdded { get; set; } + public DateTime DateAdded { get; set; } } } diff --git a/MediaBrowser.Model/QuickConnect/QuickConnectState.cs b/MediaBrowser.Model/QuickConnect/QuickConnectState.cs deleted file mode 100644 index f1074f25f2..0000000000 --- a/MediaBrowser.Model/QuickConnect/QuickConnectState.cs +++ /dev/null @@ -1,23 +0,0 @@ -namespace MediaBrowser.Model.QuickConnect -{ - /// <summary> - /// Quick connect state. - /// </summary> - public enum QuickConnectState - { - /// <summary> - /// This feature has not been opted into and is unavailable until the server administrator chooses to opt-in. - /// </summary> - Unavailable = 0, - - /// <summary> - /// The feature is enabled for use on the server but is not currently accepting connection requests. - /// </summary> - Available = 1, - - /// <summary> - /// The feature is actively accepting connection requests. - /// </summary> - Active = 2 - } -} diff --git a/MediaBrowser.Model/Search/SearchQuery.cs b/MediaBrowser.Model/Search/SearchQuery.cs index 01ad319a43..aedfa4d363 100644 --- a/MediaBrowser.Model/Search/SearchQuery.cs +++ b/MediaBrowser.Model/Search/SearchQuery.cs @@ -7,8 +7,21 @@ namespace MediaBrowser.Model.Search { public class SearchQuery { + public SearchQuery() + { + IncludeArtists = true; + IncludeGenres = true; + IncludeMedia = true; + IncludePeople = true; + IncludeStudios = true; + + MediaTypes = Array.Empty<string>(); + IncludeItemTypes = Array.Empty<string>(); + ExcludeItemTypes = Array.Empty<string>(); + } + /// <summary> - /// The user to localize search results for. + /// Gets or sets the user to localize search results for. /// </summary> /// <value>The user id.</value> public Guid UserId { get; set; } @@ -20,13 +33,13 @@ namespace MediaBrowser.Model.Search public string SearchTerm { get; set; } /// <summary> - /// Skips over a given number of items within the results. Use for paging. + /// Gets or sets the start index. Used for paging. /// </summary> /// <value>The start index.</value> public int? StartIndex { get; set; } /// <summary> - /// The maximum number of items to return. + /// Gets or sets the maximum number of items to return. /// </summary> /// <value>The limit.</value> public int? Limit { get; set; } @@ -47,7 +60,7 @@ namespace MediaBrowser.Model.Search public string[] ExcludeItemTypes { get; set; } - public string ParentId { get; set; } + public Guid? ParentId { get; set; } public bool? IsMovie { get; set; } @@ -58,18 +71,5 @@ namespace MediaBrowser.Model.Search public bool? IsKids { get; set; } public bool? IsSports { get; set; } - - public SearchQuery() - { - IncludeArtists = true; - IncludeGenres = true; - IncludeMedia = true; - IncludePeople = true; - IncludeStudios = true; - - MediaTypes = Array.Empty<string>(); - IncludeItemTypes = Array.Empty<string>(); - ExcludeItemTypes = Array.Empty<string>(); - } } } diff --git a/MediaBrowser.Model/Serialization/IJsonSerializer.cs b/MediaBrowser.Model/Serialization/IJsonSerializer.cs deleted file mode 100644 index 09b6ff9b5a..0000000000 --- a/MediaBrowser.Model/Serialization/IJsonSerializer.cs +++ /dev/null @@ -1,102 +0,0 @@ -#nullable disable -#pragma warning disable CS1591 - -using System; -using System.IO; -using System.Threading.Tasks; - -namespace MediaBrowser.Model.Serialization -{ - public interface IJsonSerializer - { - /// <summary> - /// Serializes to stream. - /// </summary> - /// <param name="obj">The obj.</param> - /// <param name="stream">The stream.</param> - /// <exception cref="ArgumentNullException">obj</exception> - void SerializeToStream(object obj, Stream stream); - - /// <summary> - /// Serializes to stream. - /// </summary> - /// <param name="obj">The obj.</param> - /// <param name="stream">The stream.</param> - /// <exception cref="ArgumentNullException">obj</exception> - void SerializeToStream<T>(T obj, Stream stream); - - /// <summary> - /// Serializes to file. - /// </summary> - /// <param name="obj">The obj.</param> - /// <param name="file">The file.</param> - /// <exception cref="ArgumentNullException">obj</exception> - void SerializeToFile(object obj, string file); - - /// <summary> - /// Deserializes from file. - /// </summary> - /// <param name="type">The type.</param> - /// <param name="file">The file.</param> - /// <returns>System.Object.</returns> - /// <exception cref="ArgumentNullException">type</exception> - object DeserializeFromFile(Type type, string file); - - /// <summary> - /// Deserializes from file. - /// </summary> - /// <typeparam name="T"></typeparam> - /// <param name="file">The file.</param> - /// <returns>``0.</returns> - /// <exception cref="ArgumentNullException">file</exception> - T DeserializeFromFile<T>(string file) - where T : class; - - /// <summary> - /// Deserializes from stream. - /// </summary> - /// <typeparam name="T"></typeparam> - /// <param name="stream">The stream.</param> - /// <returns>``0.</returns> - /// <exception cref="ArgumentNullException">stream</exception> - T DeserializeFromStream<T>(Stream stream); - - /// <summary> - /// Deserializes from string. - /// </summary> - /// <typeparam name="T"></typeparam> - /// <param name="text">The text.</param> - /// <returns>``0.</returns> - /// <exception cref="ArgumentNullException">text</exception> - T DeserializeFromString<T>(string text); - - /// <summary> - /// Deserializes from stream. - /// </summary> - /// <param name="stream">The stream.</param> - /// <param name="type">The type.</param> - /// <returns>System.Object.</returns> - /// <exception cref="ArgumentNullException">stream</exception> - object DeserializeFromStream(Stream stream, Type type); - - /// <summary> - /// Deserializes from string. - /// </summary> - /// <param name="json">The json.</param> - /// <param name="type">The type.</param> - /// <returns>System.Object.</returns> - /// <exception cref="ArgumentNullException">json</exception> - object DeserializeFromString(string json, Type type); - - /// <summary> - /// Serializes to string. - /// </summary> - /// <param name="obj">The obj.</param> - /// <returns>System.String.</returns> - /// <exception cref="ArgumentNullException">obj</exception> - string SerializeToString(object obj); - - Task<object> DeserializeFromStreamAsync(Stream stream, Type type); - Task<T> DeserializeFromStreamAsync<T>(Stream stream); - } -} diff --git a/MediaBrowser.Model/Session/BrowseRequest.cs b/MediaBrowser.Model/Session/BrowseRequest.cs index 1c997d5846..65afe5cf34 100644 --- a/MediaBrowser.Model/Session/BrowseRequest.cs +++ b/MediaBrowser.Model/Session/BrowseRequest.cs @@ -7,6 +7,7 @@ namespace MediaBrowser.Model.Session public class BrowseRequest { /// <summary> + /// Gets or sets the item type. /// Artist, Genre, Studio, Person, or any kind of BaseItem. /// </summary> /// <value>The type of the item.</value> diff --git a/MediaBrowser.Model/Session/ClientCapabilities.cs b/MediaBrowser.Model/Session/ClientCapabilities.cs index a85e6ff2a4..d692906c64 100644 --- a/MediaBrowser.Model/Session/ClientCapabilities.cs +++ b/MediaBrowser.Model/Session/ClientCapabilities.cs @@ -2,15 +2,23 @@ #pragma warning disable CS1591 using System; +using System.Collections.Generic; using MediaBrowser.Model.Dlna; namespace MediaBrowser.Model.Session { public class ClientCapabilities { - public string[] PlayableMediaTypes { get; set; } + public ClientCapabilities() + { + PlayableMediaTypes = Array.Empty<string>(); + SupportedCommands = Array.Empty<GeneralCommandType>(); + SupportsPersistentIdentifier = true; + } + + public IReadOnlyList<string> PlayableMediaTypes { get; set; } - public GeneralCommandType[] SupportedCommands { get; set; } + public IReadOnlyList<GeneralCommandType> SupportedCommands { get; set; } public bool SupportsMediaControl { get; set; } @@ -27,12 +35,5 @@ namespace MediaBrowser.Model.Session public string AppStoreUrl { get; set; } public string IconUrl { get; set; } - - public ClientCapabilities() - { - PlayableMediaTypes = Array.Empty<string>(); - SupportedCommands = Array.Empty<GeneralCommandType>(); - SupportsPersistentIdentifier = true; - } } } diff --git a/MediaBrowser.Model/Session/GeneralCommand.cs b/MediaBrowser.Model/Session/GeneralCommand.cs index 77bb6bcf77..29528c1106 100644 --- a/MediaBrowser.Model/Session/GeneralCommand.cs +++ b/MediaBrowser.Model/Session/GeneralCommand.cs @@ -1,4 +1,3 @@ -#nullable disable #pragma warning disable CS1591 using System; @@ -8,15 +7,15 @@ namespace MediaBrowser.Model.Session { public class GeneralCommand { - public GeneralCommandType Name { get; set; } - - public Guid ControllingUserId { get; set; } - - public Dictionary<string, string> Arguments { get; set; } - public GeneralCommand() { Arguments = new Dictionary<string, string>(); } + + public GeneralCommandType Name { get; set; } + + public Guid ControllingUserId { get; set; } + + public Dictionary<string, string> Arguments { get; } } } diff --git a/MediaBrowser.Model/Session/HardwareEncodingType.cs b/MediaBrowser.Model/Session/HardwareEncodingType.cs new file mode 100644 index 0000000000..0e172f35f3 --- /dev/null +++ b/MediaBrowser.Model/Session/HardwareEncodingType.cs @@ -0,0 +1,48 @@ +namespace MediaBrowser.Model.Session +{ + /// <summary> + /// Enum HardwareEncodingType. + /// </summary> + public enum HardwareEncodingType + { + /// <summary> + /// AMD AMF + /// </summary> + AMF = 0, + + /// <summary> + /// Intel Quick Sync Video + /// </summary> + QSV = 1, + + /// <summary> + /// NVIDIA NVENC + /// </summary> + NVENC = 2, + + /// <summary> + /// OpenMax OMX + /// </summary> + OMX = 3, + + /// <summary> + /// Exynos V4L2 MFC + /// </summary> + V4L2M2M = 4, + + /// <summary> + /// MediaCodec Android + /// </summary> + MediaCodec = 5, + + /// <summary> + /// Video Acceleration API (VAAPI) + /// </summary> + VAAPI = 6, + + /// <summary> + /// Video ToolBox + /// </summary> + VideoToolBox = 7 + } +} diff --git a/MediaBrowser.Model/Session/MessageCommand.cs b/MediaBrowser.Model/Session/MessageCommand.cs index 09abfbb3f2..cc9db8e6c5 100644 --- a/MediaBrowser.Model/Session/MessageCommand.cs +++ b/MediaBrowser.Model/Session/MessageCommand.cs @@ -1,12 +1,15 @@ #nullable disable #pragma warning disable CS1591 +using System.ComponentModel.DataAnnotations; + namespace MediaBrowser.Model.Session { public class MessageCommand { public string Header { get; set; } + [Required(AllowEmptyStrings = false)] public string Text { get; set; } public long? TimeoutMs { get; set; } diff --git a/MediaBrowser.Model/Session/PlaybackProgressInfo.cs b/MediaBrowser.Model/Session/PlaybackProgressInfo.cs index 73dbe6a2da..a6e7efcb0c 100644 --- a/MediaBrowser.Model/Session/PlaybackProgressInfo.cs +++ b/MediaBrowser.Model/Session/PlaybackProgressInfo.cs @@ -111,18 +111,4 @@ namespace MediaBrowser.Model.Session public string PlaylistItemId { get; set; } } - - public enum RepeatMode - { - RepeatNone = 0, - RepeatAll = 1, - RepeatOne = 2 - } - - public class QueueItem - { - public Guid Id { get; set; } - - public string PlaylistItemId { get; set; } - } } diff --git a/MediaBrowser.Model/Session/PlaystateCommand.cs b/MediaBrowser.Model/Session/PlaystateCommand.cs index 3aa091f791..df47f3b73d 100644 --- a/MediaBrowser.Model/Session/PlaystateCommand.cs +++ b/MediaBrowser.Model/Session/PlaystateCommand.cs @@ -1,5 +1,3 @@ -#pragma warning disable CS1591 - namespace MediaBrowser.Model.Session { /// <summary> @@ -46,6 +44,10 @@ namespace MediaBrowser.Model.Session /// The fast forward. /// </summary> FastForward, + + /// <summary> + /// The play pause. + /// </summary> PlayPause } } diff --git a/MediaBrowser.Model/Session/QueueItem.cs b/MediaBrowser.Model/Session/QueueItem.cs new file mode 100644 index 0000000000..32b19101b2 --- /dev/null +++ b/MediaBrowser.Model/Session/QueueItem.cs @@ -0,0 +1,14 @@ +#nullable disable +#pragma warning disable CS1591 + +using System; + +namespace MediaBrowser.Model.Session +{ + public class QueueItem + { + public Guid Id { get; set; } + + public string PlaylistItemId { get; set; } + } +} diff --git a/MediaBrowser.Model/Session/RepeatMode.cs b/MediaBrowser.Model/Session/RepeatMode.cs new file mode 100644 index 0000000000..c6e173d6b8 --- /dev/null +++ b/MediaBrowser.Model/Session/RepeatMode.cs @@ -0,0 +1,11 @@ +#pragma warning disable CS1591 + +namespace MediaBrowser.Model.Session +{ + public enum RepeatMode + { + RepeatNone = 0, + RepeatAll = 1, + RepeatOne = 2 + } +} diff --git a/MediaBrowser.Model/Session/SessionMessageType.cs b/MediaBrowser.Model/Session/SessionMessageType.cs index 23c41026dc..84f4716b4d 100644 --- a/MediaBrowser.Model/Session/SessionMessageType.cs +++ b/MediaBrowser.Model/Session/SessionMessageType.cs @@ -15,7 +15,7 @@ namespace MediaBrowser.Model.Session Play, SyncPlayCommand, SyncPlayGroupUpdate, - PlayState, + Playstate, RestartRequired, ServerShuttingDown, ServerRestarting, diff --git a/MediaBrowser.Model/Session/TranscodeReason.cs b/MediaBrowser.Model/Session/TranscodeReason.cs new file mode 100644 index 0000000000..e93b5d2882 --- /dev/null +++ b/MediaBrowser.Model/Session/TranscodeReason.cs @@ -0,0 +1,31 @@ +#pragma warning disable CS1591 + +namespace MediaBrowser.Model.Session +{ + public enum TranscodeReason + { + ContainerNotSupported = 0, + VideoCodecNotSupported = 1, + AudioCodecNotSupported = 2, + ContainerBitrateExceedsLimit = 3, + AudioBitrateNotSupported = 4, + AudioChannelsNotSupported = 5, + VideoResolutionNotSupported = 6, + UnknownVideoStreamInfo = 7, + UnknownAudioStreamInfo = 8, + AudioProfileNotSupported = 9, + AudioSampleRateNotSupported = 10, + AnamorphicVideoNotSupported = 11, + InterlacedVideoNotSupported = 12, + SecondaryAudioNotSupported = 13, + RefFramesNotSupported = 14, + VideoBitDepthNotSupported = 15, + VideoBitrateNotSupported = 16, + VideoFramerateNotSupported = 17, + VideoLevelNotSupported = 18, + VideoProfileNotSupported = 19, + AudioBitDepthNotSupported = 20, + SubtitleCodecNotSupported = 21, + DirectPlayError = 22 + } +} diff --git a/MediaBrowser.Model/Session/TranscodingInfo.cs b/MediaBrowser.Model/Session/TranscodingInfo.cs index e832c2f6fa..68ab691f88 100644 --- a/MediaBrowser.Model/Session/TranscodingInfo.cs +++ b/MediaBrowser.Model/Session/TranscodingInfo.cs @@ -7,6 +7,11 @@ namespace MediaBrowser.Model.Session { public class TranscodingInfo { + public TranscodingInfo() + { + TranscodeReasons = Array.Empty<TranscodeReason>(); + } + public string AudioCodec { get; set; } public string VideoCodec { get; set; } @@ -29,38 +34,8 @@ namespace MediaBrowser.Model.Session public int? AudioChannels { get; set; } - public TranscodeReason[] TranscodeReasons { get; set; } - - public TranscodingInfo() - { - TranscodeReasons = Array.Empty<TranscodeReason>(); - } - } + public HardwareEncodingType? HardwareAccelerationType { get; set; } - public enum TranscodeReason - { - ContainerNotSupported = 0, - VideoCodecNotSupported = 1, - AudioCodecNotSupported = 2, - ContainerBitrateExceedsLimit = 3, - AudioBitrateNotSupported = 4, - AudioChannelsNotSupported = 5, - VideoResolutionNotSupported = 6, - UnknownVideoStreamInfo = 7, - UnknownAudioStreamInfo = 8, - AudioProfileNotSupported = 9, - AudioSampleRateNotSupported = 10, - AnamorphicVideoNotSupported = 11, - InterlacedVideoNotSupported = 12, - SecondaryAudioNotSupported = 13, - RefFramesNotSupported = 14, - VideoBitDepthNotSupported = 15, - VideoBitrateNotSupported = 16, - VideoFramerateNotSupported = 17, - VideoLevelNotSupported = 18, - VideoProfileNotSupported = 19, - AudioBitDepthNotSupported = 20, - SubtitleCodecNotSupported = 21, - DirectPlayError = 22 + public TranscodeReason[] TranscodeReasons { get; set; } } } diff --git a/MediaBrowser.Model/Subtitles/FontFile.cs b/MediaBrowser.Model/Subtitles/FontFile.cs new file mode 100644 index 0000000000..115c492957 --- /dev/null +++ b/MediaBrowser.Model/Subtitles/FontFile.cs @@ -0,0 +1,34 @@ +using System; + +namespace MediaBrowser.Model.Subtitles +{ + /// <summary> + /// Class FontFile. + /// </summary> + public class FontFile + { + /// <summary> + /// Gets or sets the name. + /// </summary> + /// <value>The name.</value> + public string? Name { get; set; } + + /// <summary> + /// Gets or sets the size. + /// </summary> + /// <value>The size.</value> + public long Size { get; set; } + + /// <summary> + /// Gets or sets the date created. + /// </summary> + /// <value>The date created.</value> + public DateTime DateCreated { get; set; } + + /// <summary> + /// Gets or sets the date modified. + /// </summary> + /// <value>The date modified.</value> + public DateTime DateModified { get; set; } + } +} diff --git a/MediaBrowser.Model/Sync/SyncCategory.cs b/MediaBrowser.Model/Sync/SyncCategory.cs deleted file mode 100644 index 1248c2f739..0000000000 --- a/MediaBrowser.Model/Sync/SyncCategory.cs +++ /dev/null @@ -1,22 +0,0 @@ -#pragma warning disable CS1591 - -namespace MediaBrowser.Model.Sync -{ - public enum SyncCategory - { - /// <summary> - /// The latest. - /// </summary> - Latest = 0, - - /// <summary> - /// The next up. - /// </summary> - NextUp = 1, - - /// <summary> - /// The resume. - /// </summary> - Resume = 2 - } -} diff --git a/MediaBrowser.Model/Sync/SyncJob.cs b/MediaBrowser.Model/Sync/SyncJob.cs deleted file mode 100644 index b9290b6e83..0000000000 --- a/MediaBrowser.Model/Sync/SyncJob.cs +++ /dev/null @@ -1,135 +0,0 @@ -#nullable disable -#pragma warning disable CS1591 - -using System; - -namespace MediaBrowser.Model.Sync -{ - public class SyncJob - { - /// <summary> - /// Gets or sets the identifier. - /// </summary> - /// <value>The identifier.</value> - public string Id { get; set; } - - /// <summary> - /// Gets or sets the device identifier. - /// </summary> - /// <value>The device identifier.</value> - public string TargetId { get; set; } - - /// <summary> - /// Gets or sets the name of the target. - /// </summary> - /// <value>The name of the target.</value> - public string TargetName { get; set; } - - /// <summary> - /// Gets or sets the quality. - /// </summary> - /// <value>The quality.</value> - public string Quality { get; set; } - - /// <summary> - /// Gets or sets the bitrate. - /// </summary> - /// <value>The bitrate.</value> - public int? Bitrate { get; set; } - - /// <summary> - /// Gets or sets the profile. - /// </summary> - /// <value>The profile.</value> - public string Profile { get; set; } - - /// <summary> - /// Gets or sets the category. - /// </summary> - /// <value>The category.</value> - public SyncCategory? Category { get; set; } - - /// <summary> - /// Gets or sets the parent identifier. - /// </summary> - /// <value>The parent identifier.</value> - public string ParentId { get; set; } - - /// <summary> - /// Gets or sets the current progress. - /// </summary> - /// <value>The current progress.</value> - public double? Progress { get; set; } - - /// <summary> - /// Gets or sets the name. - /// </summary> - /// <value>The name.</value> - public string Name { get; set; } - - /// <summary> - /// Gets or sets the status. - /// </summary> - /// <value>The status.</value> - public SyncJobStatus Status { get; set; } - - /// <summary> - /// Gets or sets the user identifier. - /// </summary> - /// <value>The user identifier.</value> - public string UserId { get; set; } - - /// <summary> - /// Gets or sets a value indicating whether [unwatched only]. - /// </summary> - /// <value><c>true</c> if [unwatched only]; otherwise, <c>false</c>.</value> - public bool UnwatchedOnly { get; set; } - - /// <summary> - /// Gets or sets a value indicating whether [synchronize new content]. - /// </summary> - /// <value><c>true</c> if [synchronize new content]; otherwise, <c>false</c>.</value> - public bool SyncNewContent { get; set; } - - /// <summary> - /// Gets or sets the item limit. - /// </summary> - /// <value>The item limit.</value> - public int? ItemLimit { get; set; } - - /// <summary> - /// Gets or sets the requested item ids. - /// </summary> - /// <value>The requested item ids.</value> - public Guid[] RequestedItemIds { get; set; } - - /// <summary> - /// Gets or sets the date created. - /// </summary> - /// <value>The date created.</value> - public DateTime DateCreated { get; set; } - - /// <summary> - /// Gets or sets the date last modified. - /// </summary> - /// <value>The date last modified.</value> - public DateTime DateLastModified { get; set; } - - /// <summary> - /// Gets or sets the item count. - /// </summary> - /// <value>The item count.</value> - public int ItemCount { get; set; } - - public string ParentName { get; set; } - - public string PrimaryImageItemId { get; set; } - - public string PrimaryImageTag { get; set; } - - public SyncJob() - { - RequestedItemIds = Array.Empty<Guid>(); - } - } -} diff --git a/MediaBrowser.Model/Sync/SyncJobStatus.cs b/MediaBrowser.Model/Sync/SyncJobStatus.cs deleted file mode 100644 index 226a47d4c0..0000000000 --- a/MediaBrowser.Model/Sync/SyncJobStatus.cs +++ /dev/null @@ -1,15 +0,0 @@ -#pragma warning disable CS1591 - -namespace MediaBrowser.Model.Sync -{ - public enum SyncJobStatus - { - Queued = 0, - Converting = 1, - ReadyToTransfer = 2, - Transferring = 3, - Completed = 4, - CompletedWithError = 5, - Failed = 6 - } -} diff --git a/MediaBrowser.Model/Sync/SyncTarget.cs b/MediaBrowser.Model/Sync/SyncTarget.cs deleted file mode 100644 index 9e6bbbc009..0000000000 --- a/MediaBrowser.Model/Sync/SyncTarget.cs +++ /dev/null @@ -1,20 +0,0 @@ -#nullable disable -#pragma warning disable CS1591 - -namespace MediaBrowser.Model.Sync -{ - public class SyncTarget - { - /// <summary> - /// Gets or sets the name. - /// </summary> - /// <value>The name.</value> - public string Name { get; set; } - - /// <summary> - /// Gets or sets the identifier. - /// </summary> - /// <value>The identifier.</value> - public string Id { get; set; } - } -} diff --git a/MediaBrowser.Model/SyncPlay/GroupInfoDto.cs b/MediaBrowser.Model/SyncPlay/GroupInfoDto.cs new file mode 100644 index 0000000000..8c0960b830 --- /dev/null +++ b/MediaBrowser.Model/SyncPlay/GroupInfoDto.cs @@ -0,0 +1,58 @@ +using System; +using System.Collections.Generic; + +namespace MediaBrowser.Model.SyncPlay +{ + /// <summary> + /// Class GroupInfoDto. + /// </summary> + public class GroupInfoDto + { + /// <summary> + /// Initializes a new instance of the <see cref="GroupInfoDto"/> class. + /// </summary> + /// <param name="groupId">The group identifier.</param> + /// <param name="groupName">The group name.</param> + /// <param name="state">The group state.</param> + /// <param name="participants">The participants.</param> + /// <param name="lastUpdatedAt">The date when this DTO has been created.</param> + public GroupInfoDto(Guid groupId, string groupName, GroupStateType state, IReadOnlyList<string> participants, DateTime lastUpdatedAt) + { + GroupId = groupId; + GroupName = groupName; + State = state; + Participants = participants; + LastUpdatedAt = lastUpdatedAt; + } + + /// <summary> + /// Gets the group identifier. + /// </summary> + /// <value>The group identifier.</value> + public Guid GroupId { get; } + + /// <summary> + /// Gets the group name. + /// </summary> + /// <value>The group name.</value> + public string GroupName { get; } + + /// <summary> + /// Gets the group state. + /// </summary> + /// <value>The group state.</value> + public GroupStateType State { get; } + + /// <summary> + /// Gets the participants. + /// </summary> + /// <value>The participants.</value> + public IReadOnlyList<string> Participants { get; } + + /// <summary> + /// Gets the date when this DTO has been created. + /// </summary> + /// <value>The date when this DTO has been created.</value> + public DateTime LastUpdatedAt { get; } + } +} diff --git a/MediaBrowser.Model/SyncPlay/GroupInfoView.cs b/MediaBrowser.Model/SyncPlay/GroupInfoView.cs deleted file mode 100644 index f4c6859988..0000000000 --- a/MediaBrowser.Model/SyncPlay/GroupInfoView.cs +++ /dev/null @@ -1,42 +0,0 @@ -#nullable disable - -using System.Collections.Generic; - -namespace MediaBrowser.Model.SyncPlay -{ - /// <summary> - /// Class GroupInfoView. - /// </summary> - public class GroupInfoView - { - /// <summary> - /// Gets or sets the group identifier. - /// </summary> - /// <value>The group identifier.</value> - public string GroupId { get; set; } - - /// <summary> - /// Gets or sets the playing item id. - /// </summary> - /// <value>The playing item id.</value> - public string PlayingItemId { get; set; } - - /// <summary> - /// Gets or sets the playing item name. - /// </summary> - /// <value>The playing item name.</value> - public string PlayingItemName { get; set; } - - /// <summary> - /// Gets or sets the position ticks. - /// </summary> - /// <value>The position ticks.</value> - public long PositionTicks { get; set; } - - /// <summary> - /// Gets or sets the participants. - /// </summary> - /// <value>The participants.</value> - public IReadOnlyList<string> Participants { get; set; } - } -} diff --git a/MediaBrowser.Model/SyncPlay/GroupQueueMode.cs b/MediaBrowser.Model/SyncPlay/GroupQueueMode.cs new file mode 100644 index 0000000000..5c9c2627b9 --- /dev/null +++ b/MediaBrowser.Model/SyncPlay/GroupQueueMode.cs @@ -0,0 +1,18 @@ +namespace MediaBrowser.Model.SyncPlay +{ + /// <summary> + /// Enum GroupQueueMode. + /// </summary> + public enum GroupQueueMode + { + /// <summary> + /// Insert items at the end of the queue. + /// </summary> + Queue = 0, + + /// <summary> + /// Insert items after the currently playing item. + /// </summary> + QueueNext = 1 + } +} diff --git a/MediaBrowser.Model/SyncPlay/GroupRepeatMode.cs b/MediaBrowser.Model/SyncPlay/GroupRepeatMode.cs new file mode 100644 index 0000000000..4895e57b7f --- /dev/null +++ b/MediaBrowser.Model/SyncPlay/GroupRepeatMode.cs @@ -0,0 +1,23 @@ +namespace MediaBrowser.Model.SyncPlay +{ + /// <summary> + /// Enum GroupRepeatMode. + /// </summary> + public enum GroupRepeatMode + { + /// <summary> + /// Repeat one item only. + /// </summary> + RepeatOne = 0, + + /// <summary> + /// Cycle the playlist. + /// </summary> + RepeatAll = 1, + + /// <summary> + /// Do not repeat. + /// </summary> + RepeatNone = 2 + } +} diff --git a/MediaBrowser.Model/SyncPlay/GroupShuffleMode.cs b/MediaBrowser.Model/SyncPlay/GroupShuffleMode.cs new file mode 100644 index 0000000000..de860883c0 --- /dev/null +++ b/MediaBrowser.Model/SyncPlay/GroupShuffleMode.cs @@ -0,0 +1,18 @@ +namespace MediaBrowser.Model.SyncPlay +{ + /// <summary> + /// Enum GroupShuffleMode. + /// </summary> + public enum GroupShuffleMode + { + /// <summary> + /// Sorted playlist. + /// </summary> + Sorted = 0, + + /// <summary> + /// Shuffled playlist. + /// </summary> + Shuffle = 1 + } +} diff --git a/MediaBrowser.Model/SyncPlay/GroupStateType.cs b/MediaBrowser.Model/SyncPlay/GroupStateType.cs new file mode 100644 index 0000000000..7aa454f928 --- /dev/null +++ b/MediaBrowser.Model/SyncPlay/GroupStateType.cs @@ -0,0 +1,28 @@ +namespace MediaBrowser.Model.SyncPlay +{ + /// <summary> + /// Enum GroupState. + /// </summary> + public enum GroupStateType + { + /// <summary> + /// The group is in idle state. No media is playing. + /// </summary> + Idle = 0, + + /// <summary> + /// The group is in wating state. Playback is paused. Will start playing when users are ready. + /// </summary> + Waiting = 1, + + /// <summary> + /// The group is in paused state. Playback is paused. Will resume on play command. + /// </summary> + Paused = 2, + + /// <summary> + /// The group is in playing state. Playback is advancing. + /// </summary> + Playing = 3 + } +} diff --git a/MediaBrowser.Model/SyncPlay/GroupStateUpdate.cs b/MediaBrowser.Model/SyncPlay/GroupStateUpdate.cs new file mode 100644 index 0000000000..7f7deb86bb --- /dev/null +++ b/MediaBrowser.Model/SyncPlay/GroupStateUpdate.cs @@ -0,0 +1,31 @@ +namespace MediaBrowser.Model.SyncPlay +{ + /// <summary> + /// Class GroupStateUpdate. + /// </summary> + public class GroupStateUpdate + { + /// <summary> + /// Initializes a new instance of the <see cref="GroupStateUpdate"/> class. + /// </summary> + /// <param name="state">The state of the group.</param> + /// <param name="reason">The reason of the state change.</param> + public GroupStateUpdate(GroupStateType state, PlaybackRequestType reason) + { + State = state; + Reason = reason; + } + + /// <summary> + /// Gets the state of the group. + /// </summary> + /// <value>The state of the group.</value> + public GroupStateType State { get; } + + /// <summary> + /// Gets the reason of the state change. + /// </summary> + /// <value>The reason of the state change.</value> + public PlaybackRequestType Reason { get; } + } +} diff --git a/MediaBrowser.Model/SyncPlay/GroupUpdate.cs b/MediaBrowser.Model/SyncPlay/GroupUpdate.cs index 8c7208211c..6f159d653c 100644 --- a/MediaBrowser.Model/SyncPlay/GroupUpdate.cs +++ b/MediaBrowser.Model/SyncPlay/GroupUpdate.cs @@ -1,28 +1,42 @@ -#nullable disable +using System; namespace MediaBrowser.Model.SyncPlay { /// <summary> /// Class GroupUpdate. /// </summary> + /// <typeparam name="T">The type of the data of the message.</typeparam> public class GroupUpdate<T> { /// <summary> - /// Gets or sets the group identifier. + /// Initializes a new instance of the <see cref="GroupUpdate{T}"/> class. + /// </summary> + /// <param name="groupId">The group identifier.</param> + /// <param name="type">The update type.</param> + /// <param name="data">The update data.</param> + public GroupUpdate(Guid groupId, GroupUpdateType type, T data) + { + GroupId = groupId; + Type = type; + Data = data; + } + + /// <summary> + /// Gets the group identifier. /// </summary> /// <value>The group identifier.</value> - public string GroupId { get; set; } + public Guid GroupId { get; } /// <summary> - /// Gets or sets the update type. + /// Gets the update type. /// </summary> /// <value>The update type.</value> - public GroupUpdateType Type { get; set; } + public GroupUpdateType Type { get; } /// <summary> - /// Gets or sets the data. + /// Gets the update data. /// </summary> - /// <value>The data.</value> - public T Data { get; set; } + /// <value>The update data.</value> + public T Data { get; } } } diff --git a/MediaBrowser.Model/SyncPlay/GroupUpdateType.cs b/MediaBrowser.Model/SyncPlay/GroupUpdateType.cs index c749f7b13a..907d1defe0 100644 --- a/MediaBrowser.Model/SyncPlay/GroupUpdateType.cs +++ b/MediaBrowser.Model/SyncPlay/GroupUpdateType.cs @@ -26,14 +26,14 @@ namespace MediaBrowser.Model.SyncPlay GroupLeft, /// <summary> - /// The group-wait update. Tells members of the group that a user is buffering. + /// The group-state update. Tells members of the group that the state changed. /// </summary> - GroupWait, + StateUpdate, /// <summary> - /// The prepare-session update. Tells a user to load some content. + /// The play-queue update. Tells a user the playing queue of the group. /// </summary> - PrepareSession, + PlayQueue, /// <summary> /// The not-in-group error. Tells a user that they don't belong to a group. diff --git a/MediaBrowser.Model/SyncPlay/JoinGroupRequest.cs b/MediaBrowser.Model/SyncPlay/JoinGroupRequest.cs deleted file mode 100644 index 0c77a61322..0000000000 --- a/MediaBrowser.Model/SyncPlay/JoinGroupRequest.cs +++ /dev/null @@ -1,16 +0,0 @@ -using System; - -namespace MediaBrowser.Model.SyncPlay -{ - /// <summary> - /// Class JoinGroupRequest. - /// </summary> - public class JoinGroupRequest - { - /// <summary> - /// Gets or sets the Group id. - /// </summary> - /// <value>The Group id to join.</value> - public Guid GroupId { get; set; } - } -} diff --git a/MediaBrowser.Model/SyncPlay/PlayQueueUpdate.cs b/MediaBrowser.Model/SyncPlay/PlayQueueUpdate.cs new file mode 100644 index 0000000000..a851229f74 --- /dev/null +++ b/MediaBrowser.Model/SyncPlay/PlayQueueUpdate.cs @@ -0,0 +1,74 @@ +using System; +using System.Collections.Generic; + +namespace MediaBrowser.Model.SyncPlay +{ + /// <summary> + /// Class PlayQueueUpdate. + /// </summary> + public class PlayQueueUpdate + { + /// <summary> + /// Initializes a new instance of the <see cref="PlayQueueUpdate"/> class. + /// </summary> + /// <param name="reason">The reason for the update.</param> + /// <param name="lastUpdate">The UTC time of the last change to the playing queue.</param> + /// <param name="playlist">The playlist.</param> + /// <param name="playingItemIndex">The playing item index in the playlist.</param> + /// <param name="startPositionTicks">The start position ticks.</param> + /// <param name="shuffleMode">The shuffle mode.</param> + /// <param name="repeatMode">The repeat mode.</param> + public PlayQueueUpdate(PlayQueueUpdateReason reason, DateTime lastUpdate, IReadOnlyList<QueueItem> playlist, int playingItemIndex, long startPositionTicks, GroupShuffleMode shuffleMode, GroupRepeatMode repeatMode) + { + Reason = reason; + LastUpdate = lastUpdate; + Playlist = playlist; + PlayingItemIndex = playingItemIndex; + StartPositionTicks = startPositionTicks; + ShuffleMode = shuffleMode; + RepeatMode = repeatMode; + } + + /// <summary> + /// Gets the request type that originated this update. + /// </summary> + /// <value>The reason for the update.</value> + public PlayQueueUpdateReason Reason { get; } + + /// <summary> + /// Gets the UTC time of the last change to the playing queue. + /// </summary> + /// <value>The UTC time of the last change to the playing queue.</value> + public DateTime LastUpdate { get; } + + /// <summary> + /// Gets the playlist. + /// </summary> + /// <value>The playlist.</value> + public IReadOnlyList<QueueItem> Playlist { get; } + + /// <summary> + /// Gets the playing item index in the playlist. + /// </summary> + /// <value>The playing item index in the playlist.</value> + public int PlayingItemIndex { get; } + + /// <summary> + /// Gets the start position ticks. + /// </summary> + /// <value>The start position ticks.</value> + public long StartPositionTicks { get; } + + /// <summary> + /// Gets the shuffle mode. + /// </summary> + /// <value>The shuffle mode.</value> + public GroupShuffleMode ShuffleMode { get; } + + /// <summary> + /// Gets the repeat mode. + /// </summary> + /// <value>The repeat mode.</value> + public GroupRepeatMode RepeatMode { get; } + } +} diff --git a/MediaBrowser.Model/SyncPlay/PlayQueueUpdateReason.cs b/MediaBrowser.Model/SyncPlay/PlayQueueUpdateReason.cs new file mode 100644 index 0000000000..b609f4b1bd --- /dev/null +++ b/MediaBrowser.Model/SyncPlay/PlayQueueUpdateReason.cs @@ -0,0 +1,58 @@ +namespace MediaBrowser.Model.SyncPlay +{ + /// <summary> + /// Enum PlayQueueUpdateReason. + /// </summary> + public enum PlayQueueUpdateReason + { + /// <summary> + /// A user is requesting to play a new playlist. + /// </summary> + NewPlaylist = 0, + + /// <summary> + /// A user is changing the playing item. + /// </summary> + SetCurrentItem = 1, + + /// <summary> + /// A user is removing items from the playlist. + /// </summary> + RemoveItems = 2, + + /// <summary> + /// A user is moving an item in the playlist. + /// </summary> + MoveItem = 3, + + /// <summary> + /// A user is adding items the queue. + /// </summary> + Queue = 4, + + /// <summary> + /// A user is adding items to the queue, after the currently playing item. + /// </summary> + QueueNext = 5, + + /// <summary> + /// A user is requesting the next item in queue. + /// </summary> + NextItem = 6, + + /// <summary> + /// A user is requesting the previous item in queue. + /// </summary> + PreviousItem = 7, + + /// <summary> + /// A user is changing repeat mode. + /// </summary> + RepeatMode = 8, + + /// <summary> + /// A user is changing shuffle mode. + /// </summary> + ShuffleMode = 9 + } +} diff --git a/MediaBrowser.Model/SyncPlay/PlaybackRequest.cs b/MediaBrowser.Model/SyncPlay/PlaybackRequest.cs deleted file mode 100644 index 9de23194e3..0000000000 --- a/MediaBrowser.Model/SyncPlay/PlaybackRequest.cs +++ /dev/null @@ -1,34 +0,0 @@ -using System; - -namespace MediaBrowser.Model.SyncPlay -{ - /// <summary> - /// Class PlaybackRequest. - /// </summary> - public class PlaybackRequest - { - /// <summary> - /// Gets or sets the request type. - /// </summary> - /// <value>The request type.</value> - public PlaybackRequestType Type { get; set; } - - /// <summary> - /// Gets or sets when the request has been made by the client. - /// </summary> - /// <value>The date of the request.</value> - public DateTime? When { get; set; } - - /// <summary> - /// Gets or sets the position ticks. - /// </summary> - /// <value>The position ticks.</value> - public long? PositionTicks { get; set; } - - /// <summary> - /// Gets or sets the ping time. - /// </summary> - /// <value>The ping time.</value> - public long? Ping { get; set; } - } -} diff --git a/MediaBrowser.Model/SyncPlay/PlaybackRequestType.cs b/MediaBrowser.Model/SyncPlay/PlaybackRequestType.cs index e89efeed8a..4429623dd9 100644 --- a/MediaBrowser.Model/SyncPlay/PlaybackRequestType.cs +++ b/MediaBrowser.Model/SyncPlay/PlaybackRequestType.cs @@ -6,33 +6,88 @@ namespace MediaBrowser.Model.SyncPlay public enum PlaybackRequestType { /// <summary> - /// A user is requesting a play command for the group. + /// A user is setting a new playlist. /// </summary> Play = 0, + /// <summary> + /// A user is changing the playlist item. + /// </summary> + SetPlaylistItem = 1, + + /// <summary> + /// A user is removing items from the playlist. + /// </summary> + RemoveFromPlaylist = 2, + + /// <summary> + /// A user is moving an item in the playlist. + /// </summary> + MovePlaylistItem = 3, + + /// <summary> + /// A user is adding items to the playlist. + /// </summary> + Queue = 4, + + /// <summary> + /// A user is requesting an unpause command for the group. + /// </summary> + Unpause = 5, + /// <summary> /// A user is requesting a pause command for the group. /// </summary> - Pause = 1, + Pause = 6, /// <summary> - /// A user is requesting a seek command for the group. + /// A user is requesting a stop command for the group. /// </summary> - Seek = 2, + Stop = 7, /// <summary> + /// A user is requesting a seek command for the group. + /// </summary> + Seek = 8, + + /// <summary> /// A user is signaling that playback is buffering. /// </summary> - Buffer = 3, + Buffer = 9, /// <summary> /// A user is signaling that playback resumed. /// </summary> - Ready = 4, + Ready = 10, + + /// <summary> + /// A user is requesting next item in playlist. + /// </summary> + NextItem = 11, + + /// <summary> + /// A user is requesting previous item in playlist. + /// </summary> + PreviousItem = 12, + + /// <summary> + /// A user is setting the repeat mode. + /// </summary> + SetRepeatMode = 13, + + /// <summary> + /// A user is setting the shuffle mode. + /// </summary> + SetShuffleMode = 14, + + /// <summary> + /// A user is reporting their ping. + /// </summary> + Ping = 15, /// <summary> - /// A user is reporting its ping. + /// A user is requesting to be ignored on group wait. /// </summary> - Ping = 5 + IgnoreWait = 16 } } diff --git a/MediaBrowser.Model/SyncPlay/QueueItem.cs b/MediaBrowser.Model/SyncPlay/QueueItem.cs new file mode 100644 index 0000000000..a6dcc109ed --- /dev/null +++ b/MediaBrowser.Model/SyncPlay/QueueItem.cs @@ -0,0 +1,31 @@ +using System; + +namespace MediaBrowser.Model.SyncPlay +{ + /// <summary> + /// Class QueueItem. + /// </summary> + public class QueueItem + { + /// <summary> + /// Initializes a new instance of the <see cref="QueueItem"/> class. + /// </summary> + /// <param name="itemId">The item identifier.</param> + public QueueItem(Guid itemId) + { + ItemId = itemId; + } + + /// <summary> + /// Gets the item identifier. + /// </summary> + /// <value>The item identifier.</value> + public Guid ItemId { get; } + + /// <summary> + /// Gets the playlist identifier of the item. + /// </summary> + /// <value>The playlist identifier of the item.</value> + public Guid PlaylistItemId { get; } = Guid.NewGuid(); + } +} diff --git a/MediaBrowser.Model/SyncPlay/RequestType.cs b/MediaBrowser.Model/SyncPlay/RequestType.cs new file mode 100644 index 0000000000..a6e397dcd6 --- /dev/null +++ b/MediaBrowser.Model/SyncPlay/RequestType.cs @@ -0,0 +1,33 @@ +namespace MediaBrowser.Model.SyncPlay +{ + /// <summary> + /// Enum RequestType. + /// </summary> + public enum RequestType + { + /// <summary> + /// A user is requesting to create a new group. + /// </summary> + NewGroup = 0, + + /// <summary> + /// A user is requesting to join a group. + /// </summary> + JoinGroup = 1, + + /// <summary> + /// A user is requesting to leave a group. + /// </summary> + LeaveGroup = 2, + + /// <summary> + /// A user is requesting the list of available groups. + /// </summary> + ListGroups = 3, + + /// <summary> + /// A user is sending a playback command to a group. + /// </summary> + Playback = 4 + } +} diff --git a/MediaBrowser.Model/SyncPlay/SendCommand.cs b/MediaBrowser.Model/SyncPlay/SendCommand.cs index 0f0be0152d..73cb50876d 100644 --- a/MediaBrowser.Model/SyncPlay/SendCommand.cs +++ b/MediaBrowser.Model/SyncPlay/SendCommand.cs @@ -1,4 +1,4 @@ -#nullable disable +using System; namespace MediaBrowser.Model.SyncPlay { @@ -8,33 +8,58 @@ namespace MediaBrowser.Model.SyncPlay public class SendCommand { /// <summary> - /// Gets or sets the group identifier. + /// Initializes a new instance of the <see cref="SendCommand"/> class. + /// </summary> + /// <param name="groupId">The group identifier.</param> + /// <param name="playlistItemId">The playlist identifier of the playing item.</param> + /// <param name="when">The UTC time when to execute the command.</param> + /// <param name="command">The command.</param> + /// <param name="positionTicks">The position ticks, for commands that require it.</param> + /// <param name="emittedAt">The UTC time when this command has been emitted.</param> + public SendCommand(Guid groupId, Guid playlistItemId, DateTime when, SendCommandType command, long? positionTicks, DateTime emittedAt) + { + GroupId = groupId; + PlaylistItemId = playlistItemId; + When = when; + Command = command; + PositionTicks = positionTicks; + EmittedAt = emittedAt; + } + + /// <summary> + /// Gets the group identifier. /// </summary> /// <value>The group identifier.</value> - public string GroupId { get; set; } + public Guid GroupId { get; } + + /// <summary> + /// Gets the playlist identifier of the playing item. + /// </summary> + /// <value>The playlist identifier of the playing item.</value> + public Guid PlaylistItemId { get; } /// <summary> /// Gets or sets the UTC time when to execute the command. /// </summary> /// <value>The UTC time when to execute the command.</value> - public string When { get; set; } + public DateTime When { get; set; } /// <summary> - /// Gets or sets the position ticks. + /// Gets the position ticks. /// </summary> /// <value>The position ticks.</value> - public long? PositionTicks { get; set; } + public long? PositionTicks { get; } /// <summary> - /// Gets or sets the command. + /// Gets the command. /// </summary> /// <value>The command.</value> - public SendCommandType Command { get; set; } + public SendCommandType Command { get; } /// <summary> - /// Gets or sets the UTC time when this command has been emitted. + /// Gets the UTC time when this command has been emitted. /// </summary> /// <value>The UTC time when this command has been emitted.</value> - public string EmittedAt { get; set; } + public DateTime EmittedAt { get; } } } diff --git a/MediaBrowser.Model/SyncPlay/SendCommandType.cs b/MediaBrowser.Model/SyncPlay/SendCommandType.cs index 86dec9e900..e6b17c60ae 100644 --- a/MediaBrowser.Model/SyncPlay/SendCommandType.cs +++ b/MediaBrowser.Model/SyncPlay/SendCommandType.cs @@ -6,18 +6,23 @@ namespace MediaBrowser.Model.SyncPlay public enum SendCommandType { /// <summary> - /// The play command. Instructs users to start playback. + /// The unpause command. Instructs users to unpause playback. /// </summary> - Play = 0, + Unpause = 0, /// <summary> /// The pause command. Instructs users to pause playback. /// </summary> Pause = 1, + /// <summary> + /// The stop command. Instructs users to stop playback. + /// </summary> + Stop = 2, + /// <summary> /// The seek command. Instructs users to seek to a specified time. /// </summary> - Seek = 2 + Seek = 3 } } diff --git a/MediaBrowser.Model/SyncPlay/SyncPlayBroadcastType.cs b/MediaBrowser.Model/SyncPlay/SyncPlayBroadcastType.cs new file mode 100644 index 0000000000..29dbb11b38 --- /dev/null +++ b/MediaBrowser.Model/SyncPlay/SyncPlayBroadcastType.cs @@ -0,0 +1,28 @@ +namespace MediaBrowser.Model.SyncPlay +{ + /// <summary> + /// Used to filter the sessions of a group. + /// </summary> + public enum SyncPlayBroadcastType + { + /// <summary> + /// All sessions will receive the message. + /// </summary> + AllGroup = 0, + + /// <summary> + /// Only the specified session will receive the message. + /// </summary> + CurrentSession = 1, + + /// <summary> + /// All sessions, except the current one, will receive the message. + /// </summary> + AllExceptCurrentSession = 2, + + /// <summary> + /// Only sessions that are not buffering will receive the message. + /// </summary> + AllReady = 3 + } +} diff --git a/MediaBrowser.Model/SyncPlay/UtcTimeResponse.cs b/MediaBrowser.Model/SyncPlay/UtcTimeResponse.cs index 8ec5eaab3b..219e7b1e0c 100644 --- a/MediaBrowser.Model/SyncPlay/UtcTimeResponse.cs +++ b/MediaBrowser.Model/SyncPlay/UtcTimeResponse.cs @@ -1,4 +1,4 @@ -#nullable disable +using System; namespace MediaBrowser.Model.SyncPlay { @@ -8,15 +8,26 @@ namespace MediaBrowser.Model.SyncPlay public class UtcTimeResponse { /// <summary> - /// Gets or sets the UTC time when request has been received. + /// Initializes a new instance of the <see cref="UtcTimeResponse"/> class. + /// </summary> + /// <param name="requestReceptionTime">The UTC time when request has been received.</param> + /// <param name="responseTransmissionTime">The UTC time when response has been sent.</param> + public UtcTimeResponse(DateTime requestReceptionTime, DateTime responseTransmissionTime) + { + RequestReceptionTime = requestReceptionTime; + ResponseTransmissionTime = responseTransmissionTime; + } + + /// <summary> + /// Gets the UTC time when request has been received. /// </summary> /// <value>The UTC time when request has been received.</value> - public string RequestReceptionTime { get; set; } + public DateTime RequestReceptionTime { get; } /// <summary> - /// Gets or sets the UTC time when response has been sent. + /// Gets the UTC time when response has been sent. /// </summary> /// <value>The UTC time when response has been sent.</value> - public string ResponseTransmissionTime { get; set; } + public DateTime ResponseTransmissionTime { get; } } } diff --git a/MediaBrowser.Model/System/SystemInfo.cs b/MediaBrowser.Model/System/SystemInfo.cs index 4b83fb7e61..e45b2f33a6 100644 --- a/MediaBrowser.Model/System/SystemInfo.cs +++ b/MediaBrowser.Model/System/SystemInfo.cs @@ -30,6 +30,14 @@ namespace MediaBrowser.Model.System /// </summary> public class SystemInfo : PublicSystemInfo { + /// <summary> + /// Initializes a new instance of the <see cref="SystemInfo" /> class. + /// </summary> + public SystemInfo() + { + CompletedInstallations = Array.Empty<InstallationInfo>(); + } + /// <summary> /// Gets or sets the display name of the operating system. /// </summary> @@ -37,7 +45,7 @@ namespace MediaBrowser.Model.System public string OperatingSystemDisplayName { get; set; } /// <summary> - /// Get or sets the package name. + /// Gets or sets the package name. /// </summary> /// <value>The value of the '-package' command line argument.</value> public string PackageName { get; set; } @@ -122,18 +130,11 @@ namespace MediaBrowser.Model.System /// Gets or sets a value indicating whether this instance has update available. /// </summary> /// <value><c>true</c> if this instance has update available; otherwise, <c>false</c>.</value> + [Obsolete("This should be handled by the package manager")] public bool HasUpdateAvailable { get; set; } public FFmpegLocation EncoderLocation { get; set; } public Architecture SystemArchitecture { get; set; } - - /// <summary> - /// Initializes a new instance of the <see cref="SystemInfo" /> class. - /// </summary> - public SystemInfo() - { - CompletedInstallations = Array.Empty<InstallationInfo>(); - } } } diff --git a/MediaBrowser.Model/System/WakeOnLanInfo.cs b/MediaBrowser.Model/System/WakeOnLanInfo.cs index b2cbe737d1..aba19a6baf 100644 --- a/MediaBrowser.Model/System/WakeOnLanInfo.cs +++ b/MediaBrowser.Model/System/WakeOnLanInfo.cs @@ -36,7 +36,7 @@ namespace MediaBrowser.Model.System /// Gets the MAC address of the device. /// </summary> /// <value>The MAC address.</value> - public string? MacAddress { get; set; } + public string? MacAddress { get; } /// <summary> /// Gets or sets the wake-on-LAN port. diff --git a/MediaBrowser.Model/Tasks/IScheduledTaskWorker.cs b/MediaBrowser.Model/Tasks/IScheduledTaskWorker.cs index 2f05e08c51..ca769e26b3 100644 --- a/MediaBrowser.Model/Tasks/IScheduledTaskWorker.cs +++ b/MediaBrowser.Model/Tasks/IScheduledTaskWorker.cs @@ -15,7 +15,7 @@ namespace MediaBrowser.Model.Tasks event EventHandler<GenericEventArgs<double>> TaskProgress; /// <summary> - /// Gets or sets the scheduled task. + /// Gets the scheduled task. /// </summary> /// <value>The scheduled task.</value> IScheduledTask ScheduledTask { get; } @@ -57,10 +57,9 @@ namespace MediaBrowser.Model.Tasks double? CurrentProgress { get; } /// <summary> - /// Gets the triggers that define when the task will run. + /// Gets or sets the triggers that define when the task will run. /// </summary> /// <value>The triggers.</value> - /// <exception cref="ArgumentNullException">value</exception> TaskTriggerInfo[] Triggers { get; set; } /// <summary> diff --git a/MediaBrowser.Model/Tasks/ITaskManager.cs b/MediaBrowser.Model/Tasks/ITaskManager.cs index 02b29074e3..a86bf2a1c8 100644 --- a/MediaBrowser.Model/Tasks/ITaskManager.cs +++ b/MediaBrowser.Model/Tasks/ITaskManager.cs @@ -9,6 +9,10 @@ namespace MediaBrowser.Model.Tasks { public interface ITaskManager : IDisposable { + event EventHandler<GenericEventArgs<IScheduledTaskWorker>> TaskExecuting; + + event EventHandler<TaskCompletionEventArgs> TaskCompleted; + /// <summary> /// Gets the list of Scheduled Tasks. /// </summary> @@ -18,7 +22,7 @@ namespace MediaBrowser.Model.Tasks /// <summary> /// Cancels if running and queue. /// </summary> - /// <typeparam name="T"></typeparam> + /// <typeparam name="T">An implementatin of <see cref="IScheduledTask" />.</typeparam> /// <param name="options">Task options.</param> void CancelIfRunningAndQueue<T>(TaskOptions options) where T : IScheduledTask; @@ -26,21 +30,21 @@ namespace MediaBrowser.Model.Tasks /// <summary> /// Cancels if running and queue. /// </summary> - /// <typeparam name="T"></typeparam> + /// <typeparam name="T">An implementatin of <see cref="IScheduledTask" />.</typeparam> void CancelIfRunningAndQueue<T>() where T : IScheduledTask; /// <summary> /// Cancels if running. /// </summary> - /// <typeparam name="T"></typeparam> + /// <typeparam name="T">An implementatin of <see cref="IScheduledTask" />.</typeparam> void CancelIfRunning<T>() where T : IScheduledTask; /// <summary> /// Queues the scheduled task. /// </summary> - /// <typeparam name="T"></typeparam> + /// <typeparam name="T">An implementatin of <see cref="IScheduledTask" />.</typeparam> /// <param name="options">Task options.</param> void QueueScheduledTask<T>(TaskOptions options) where T : IScheduledTask; @@ -48,7 +52,7 @@ namespace MediaBrowser.Model.Tasks /// <summary> /// Queues the scheduled task. /// </summary> - /// <typeparam name="T"></typeparam> + /// <typeparam name="T">An implementatin of <see cref="IScheduledTask" />.</typeparam> void QueueScheduledTask<T>() where T : IScheduledTask; @@ -58,6 +62,8 @@ namespace MediaBrowser.Model.Tasks /// <summary> /// Queues the scheduled task. /// </summary> + /// <param name="task">The <see cref="IScheduledTask" /> to queue.</param> + /// <param name="options">The <see cref="TaskOptions" /> to use.</param> void QueueScheduledTask(IScheduledTask task, TaskOptions options); /// <summary> @@ -67,12 +73,10 @@ namespace MediaBrowser.Model.Tasks void AddTasks(IEnumerable<IScheduledTask> tasks); void Cancel(IScheduledTaskWorker task); + Task Execute(IScheduledTaskWorker task, TaskOptions options); void Execute<T>() where T : IScheduledTask; - - event EventHandler<GenericEventArgs<IScheduledTaskWorker>> TaskExecuting; - event EventHandler<TaskCompletionEventArgs> TaskCompleted; } } diff --git a/MediaBrowser.Model/Tasks/ITaskTrigger.cs b/MediaBrowser.Model/Tasks/ITaskTrigger.cs index 5c30d6c220..999db9605d 100644 --- a/MediaBrowser.Model/Tasks/ITaskTrigger.cs +++ b/MediaBrowser.Model/Tasks/ITaskTrigger.cs @@ -11,16 +11,20 @@ namespace MediaBrowser.Model.Tasks /// <summary> /// Fires when the trigger condition is satisfied and the task should run. /// </summary> - event EventHandler<EventArgs> Triggered; + event EventHandler<EventArgs>? Triggered; /// <summary> - /// Gets or sets the options of this task. + /// Gets the options of this task. /// </summary> - TaskOptions TaskOptions { get; set; } + TaskOptions TaskOptions { get; } /// <summary> /// Stars waiting for the trigger action. /// </summary> + /// <param name="lastResult">Result of the last run triggerd task.</param> + /// <param name="logger">The <see cref="ILogger"/>.</param> + /// <param name="taskName">The name of the task.</param> + /// <param name="isApplicationStartup">Wheter or not this is is fired during startup.</param> void Start(TaskResult lastResult, ILogger logger, string taskName, bool isApplicationStartup); /// <summary> diff --git a/MediaBrowser.Model/Tasks/TaskInfo.cs b/MediaBrowser.Model/Tasks/TaskInfo.cs index 77100dfe76..16de0b1210 100644 --- a/MediaBrowser.Model/Tasks/TaskInfo.cs +++ b/MediaBrowser.Model/Tasks/TaskInfo.cs @@ -8,6 +8,14 @@ namespace MediaBrowser.Model.Tasks /// </summary> public class TaskInfo { + /// <summary> + /// Initializes a new instance of the <see cref="TaskInfo"/> class. + /// </summary> + public TaskInfo() + { + Triggers = Array.Empty<TaskTriggerInfo>(); + } + /// <summary> /// Gets or sets the name. /// </summary> @@ -67,13 +75,5 @@ namespace MediaBrowser.Model.Tasks /// </summary> /// <value>The key.</value> public string Key { get; set; } - - /// <summary> - /// Initializes a new instance of the <see cref="TaskInfo"/> class. - /// </summary> - public TaskInfo() - { - Triggers = Array.Empty<TaskTriggerInfo>(); - } } } diff --git a/MediaBrowser.Model/Tasks/TaskTriggerInfo.cs b/MediaBrowser.Model/Tasks/TaskTriggerInfo.cs index 5aeaffc2b3..f8a8c727ef 100644 --- a/MediaBrowser.Model/Tasks/TaskTriggerInfo.cs +++ b/MediaBrowser.Model/Tasks/TaskTriggerInfo.cs @@ -10,6 +10,12 @@ namespace MediaBrowser.Model.Tasks /// </summary> public class TaskTriggerInfo { + public const string TriggerDaily = "DailyTrigger"; + public const string TriggerWeekly = "WeeklyTrigger"; + public const string TriggerInterval = "IntervalTrigger"; + public const string TriggerSystemEvent = "SystemEventTrigger"; + public const string TriggerStartup = "StartupTrigger"; + /// <summary> /// Gets or sets the type. /// </summary> @@ -39,11 +45,5 @@ namespace MediaBrowser.Model.Tasks /// </summary> /// <value>The maximum runtime ticks.</value> public long? MaxRuntimeTicks { get; set; } - - public const string TriggerDaily = "DailyTrigger"; - public const string TriggerWeekly = "WeeklyTrigger"; - public const string TriggerInterval = "IntervalTrigger"; - public const string TriggerSystemEvent = "SystemEventTrigger"; - public const string TriggerStartup = "StartupTrigger"; } } diff --git a/MediaBrowser.Model/Updates/InstallationInfo.cs b/MediaBrowser.Model/Updates/InstallationInfo.cs index a6d80dba62..cc600de9de 100644 --- a/MediaBrowser.Model/Updates/InstallationInfo.cs +++ b/MediaBrowser.Model/Updates/InstallationInfo.cs @@ -1,5 +1,7 @@ #nullable disable + using System; +using System.Text.Json.Serialization; namespace MediaBrowser.Model.Updates { @@ -9,10 +11,11 @@ namespace MediaBrowser.Model.Updates public class InstallationInfo { /// <summary> - /// Gets or sets the guid. + /// Gets or sets the Id. /// </summary> - /// <value>The guid.</value> - public Guid Guid { get; set; } + /// <value>The Id.</value> + [JsonPropertyName("Guid")] + public Guid Id { get; set; } /// <summary> /// Gets or sets the name. @@ -43,5 +46,11 @@ namespace MediaBrowser.Model.Updates /// </summary> /// <value>The checksum.</value> public string Checksum { get; set; } + + /// <summary> + /// Gets or sets package information for the installation. + /// </summary> + /// <value>The package information.</value> + public PackageInfo PackageInfo { get; set; } } } diff --git a/MediaBrowser.Model/Updates/PackageInfo.cs b/MediaBrowser.Model/Updates/PackageInfo.cs index 98b151d551..aeaaa8b355 100644 --- a/MediaBrowser.Model/Updates/PackageInfo.cs +++ b/MediaBrowser.Model/Updates/PackageInfo.cs @@ -1,6 +1,6 @@ -#nullable disable using System; using System.Collections.Generic; +using System.Text.Json.Serialization; namespace MediaBrowser.Model.Updates { @@ -9,65 +9,75 @@ namespace MediaBrowser.Model.Updates /// </summary> public class PackageInfo { + /// <summary> + /// Initializes a new instance of the <see cref="PackageInfo"/> class. + /// </summary> + public PackageInfo() + { + Versions = Array.Empty<VersionInfo>(); + Category = string.Empty; + Name = string.Empty; + Overview = string.Empty; + Owner = string.Empty; + Description = string.Empty; + } + /// <summary> /// Gets or sets the name. /// </summary> /// <value>The name.</value> - public string name { get; set; } + [JsonPropertyName("name")] + public string Name { get; set; } /// <summary> /// Gets or sets a long description of the plugin containing features or helpful explanations. /// </summary> /// <value>The description.</value> - public string description { get; set; } + [JsonPropertyName("description")] + public string Description { get; set; } /// <summary> /// Gets or sets a short overview of what the plugin does. /// </summary> /// <value>The overview.</value> - public string overview { get; set; } + [JsonPropertyName("overview")] + public string Overview { get; set; } /// <summary> /// Gets or sets the owner. /// </summary> /// <value>The owner.</value> - public string owner { get; set; } + [JsonPropertyName("owner")] + public string Owner { get; set; } /// <summary> /// Gets or sets the category. /// </summary> /// <value>The category.</value> - public string category { get; set; } + [JsonPropertyName("category")] + public string Category { get; set; } /// <summary> - /// The guid of the assembly associated with this plugin. + /// Gets or sets the guid of the assembly associated with this plugin. /// This is used to identify the proper item for automatic updates. /// </summary> /// <value>The name.</value> - public string guid { get; set; } + [JsonPropertyName("guid")] + public Guid Id { get; set; } /// <summary> /// Gets or sets the versions. /// </summary> /// <value>The versions.</value> - public IReadOnlyList<VersionInfo> versions { get; set; } + [JsonPropertyName("versions")] +#pragma warning disable CA2227 // Collection properties should be read only + public IList<VersionInfo> Versions { get; set; } +#pragma warning restore CA2227 // Collection properties should be read only /// <summary> - /// Gets or sets the repository name. + /// Gets or sets the image url for the package. /// </summary> - public string repositoryName { get; set; } - - /// <summary> - /// Gets or sets the repository url. - /// </summary> - public string repositoryUrl { get; set; } - - /// <summary> - /// Initializes a new instance of the <see cref="PackageInfo"/> class. - /// </summary> - public PackageInfo() - { - versions = Array.Empty<VersionInfo>(); - } + [JsonPropertyName("imageUrl")] + public string? ImageUrl { get; set; } } } diff --git a/MediaBrowser.Model/Updates/RepositoryInfo.cs b/MediaBrowser.Model/Updates/RepositoryInfo.cs index bd42e77f0f..705d3b33cc 100644 --- a/MediaBrowser.Model/Updates/RepositoryInfo.cs +++ b/MediaBrowser.Model/Updates/RepositoryInfo.cs @@ -16,5 +16,11 @@ namespace MediaBrowser.Model.Updates /// </summary> /// <value>The URL.</value> public string? Url { get; set; } + + /// <summary> + /// Gets or sets a value indicating whether the repository is enabled. + /// </summary> + /// <value><c>true</c> if enabled.</value> + public bool Enabled { get; set; } = true; } } diff --git a/MediaBrowser.Model/Updates/VersionInfo.cs b/MediaBrowser.Model/Updates/VersionInfo.cs index a4aa0e75f3..03a540dde2 100644 --- a/MediaBrowser.Model/Updates/VersionInfo.cs +++ b/MediaBrowser.Model/Updates/VersionInfo.cs @@ -1,48 +1,77 @@ -#nullable disable - -using System; +using System.Text.Json.Serialization; +using SysVersion = System.Version; namespace MediaBrowser.Model.Updates { /// <summary> - /// Class PackageVersionInfo. + /// Defines the <see cref="VersionInfo"/> class. /// </summary> public class VersionInfo { + private SysVersion? _version; + /// <summary> /// Gets or sets the version. /// </summary> /// <value>The version.</value> - public string version { get; set; } + [JsonPropertyName("version")] + public string Version + { + get => _version == null ? string.Empty : _version.ToString(); + + set => _version = SysVersion.Parse(value); + } + + /// <summary> + /// Gets the version as a <see cref="SysVersion"/>. + /// </summary> + public SysVersion VersionNumber => _version ?? new SysVersion(0, 0, 0); /// <summary> /// Gets or sets the changelog for this version. /// </summary> /// <value>The changelog.</value> - public string changelog { get; set; } + [JsonPropertyName("changelog")] + public string? Changelog { get; set; } /// <summary> /// Gets or sets the ABI that this version was built against. /// </summary> /// <value>The target ABI version.</value> - public string targetAbi { get; set; } + [JsonPropertyName("targetAbi")] + public string? TargetAbi { get; set; } /// <summary> /// Gets or sets the source URL. /// </summary> /// <value>The source URL.</value> - public string sourceUrl { get; set; } + [JsonPropertyName("sourceUrl")] + public string? SourceUrl { get; set; } /// <summary> /// Gets or sets a checksum for the binary. /// </summary> /// <value>The checksum.</value> - public string checksum { get; set; } + [JsonPropertyName("checksum")] + public string? Checksum { get; set; } /// <summary> /// Gets or sets a timestamp of when the binary was built. /// </summary> /// <value>The timestamp.</value> - public string timestamp { get; set; } + [JsonPropertyName("timestamp")] + public string? Timestamp { get; set; } + + /// <summary> + /// Gets or sets the repository name. + /// </summary> + [JsonPropertyName("repositoryName")] + public string RepositoryName { get; set; } = string.Empty; + + /// <summary> + /// Gets or sets the repository url. + /// </summary> + [JsonPropertyName("repositoryUrl")] + public string RepositoryUrl { get; set; } = string.Empty; } } diff --git a/MediaBrowser.Model/Users/UserAction.cs b/MediaBrowser.Model/Users/UserAction.cs deleted file mode 100644 index 7646db4a82..0000000000 --- a/MediaBrowser.Model/Users/UserAction.cs +++ /dev/null @@ -1,24 +0,0 @@ -#nullable disable -#pragma warning disable CS1591 - -using System; - -namespace MediaBrowser.Model.Users -{ - public class UserAction - { - public string Id { get; set; } - - public string ServerId { get; set; } - - public Guid UserId { get; set; } - - public Guid ItemId { get; set; } - - public UserActionType Type { get; set; } - - public DateTime Date { get; set; } - - public long? PositionTicks { get; set; } - } -} diff --git a/MediaBrowser.Model/Users/UserActionType.cs b/MediaBrowser.Model/Users/UserActionType.cs deleted file mode 100644 index dbb1513f22..0000000000 --- a/MediaBrowser.Model/Users/UserActionType.cs +++ /dev/null @@ -1,9 +0,0 @@ -#pragma warning disable CS1591 - -namespace MediaBrowser.Model.Users -{ - public enum UserActionType - { - PlayedItem = 0 - } -} diff --git a/MediaBrowser.Model/Users/UserPolicy.cs b/MediaBrowser.Model/Users/UserPolicy.cs index 363b2633fd..111070d813 100644 --- a/MediaBrowser.Model/Users/UserPolicy.cs +++ b/MediaBrowser.Model/Users/UserPolicy.cs @@ -10,6 +10,56 @@ namespace MediaBrowser.Model.Users { public class UserPolicy { + public UserPolicy() + { + IsHidden = true; + + EnableContentDeletion = false; + EnableContentDeletionFromFolders = Array.Empty<string>(); + + EnableSyncTranscoding = true; + EnableMediaConversion = true; + + EnableMediaPlayback = true; + EnableAudioPlaybackTranscoding = true; + EnableVideoPlaybackTranscoding = true; + EnablePlaybackRemuxing = true; + ForceRemoteSourceTranscoding = false; + EnableLiveTvManagement = true; + EnableLiveTvAccess = true; + + // Without this on by default, admins won't be able to do this + // Improve in the future + EnableLiveTvManagement = true; + + EnableSharedDeviceControl = true; + + BlockedTags = Array.Empty<string>(); + BlockUnratedItems = Array.Empty<UnratedItem>(); + + EnableUserPreferenceAccess = true; + + AccessSchedules = Array.Empty<AccessSchedule>(); + + LoginAttemptsBeforeLockout = -1; + + MaxActiveSessions = 0; + + EnableAllChannels = true; + EnabledChannels = Array.Empty<Guid>(); + + EnableAllFolders = true; + EnabledFolders = Array.Empty<Guid>(); + + EnabledDevices = Array.Empty<string>(); + EnableAllDevices = true; + + EnableContentDownloading = true; + EnablePublicSharing = true; + EnableRemoteAccess = true; + SyncPlayAccess = SyncPlayUserAccessType.CreateAndJoinGroups; + } + /// <summary> /// Gets or sets a value indicating whether this instance is administrator. /// </summary> @@ -111,56 +161,6 @@ namespace MediaBrowser.Model.Users /// Gets or sets a value indicating what SyncPlay features the user can access. /// </summary> /// <value>Access level to SyncPlay features.</value> - public SyncPlayAccess SyncPlayAccess { get; set; } - - public UserPolicy() - { - IsHidden = true; - - EnableContentDeletion = false; - EnableContentDeletionFromFolders = Array.Empty<string>(); - - EnableSyncTranscoding = true; - EnableMediaConversion = true; - - EnableMediaPlayback = true; - EnableAudioPlaybackTranscoding = true; - EnableVideoPlaybackTranscoding = true; - EnablePlaybackRemuxing = true; - ForceRemoteSourceTranscoding = false; - EnableLiveTvManagement = true; - EnableLiveTvAccess = true; - - // Without this on by default, admins won't be able to do this - // Improve in the future - EnableLiveTvManagement = true; - - EnableSharedDeviceControl = true; - - BlockedTags = Array.Empty<string>(); - BlockUnratedItems = Array.Empty<UnratedItem>(); - - EnableUserPreferenceAccess = true; - - AccessSchedules = Array.Empty<AccessSchedule>(); - - LoginAttemptsBeforeLockout = -1; - - MaxActiveSessions = 0; - - EnableAllChannels = true; - EnabledChannels = Array.Empty<Guid>(); - - EnableAllFolders = true; - EnabledFolders = Array.Empty<Guid>(); - - EnabledDevices = Array.Empty<string>(); - EnableAllDevices = true; - - EnableContentDownloading = true; - EnablePublicSharing = true; - EnableRemoteAccess = true; - SyncPlayAccess = SyncPlayAccess.CreateAndJoinGroups; - } + public SyncPlayUserAccessType SyncPlayAccess { get; set; } } } diff --git a/MediaBrowser.Providers/BoxSets/BoxSetMetadataService.cs b/MediaBrowser.Providers/BoxSets/BoxSetMetadataService.cs index e5326da71c..88ce8d087d 100644 --- a/MediaBrowser.Providers/BoxSets/BoxSetMetadataService.cs +++ b/MediaBrowser.Providers/BoxSets/BoxSetMetadataService.cs @@ -59,9 +59,9 @@ namespace MediaBrowser.Providers.BoxSets } /// <inheritdoc /> - protected override ItemUpdateType BeforeSaveInternal(BoxSet item, bool isFullRefresh, ItemUpdateType currentUpdateType) + protected override ItemUpdateType BeforeSaveInternal(BoxSet item, bool isFullRefresh, ItemUpdateType updateType) { - var updateType = base.BeforeSaveInternal(item, isFullRefresh, currentUpdateType); + var updatedType = base.BeforeSaveInternal(item, isFullRefresh, updateType); var libraryFolderIds = item.GetLibraryFolderIds(); @@ -69,10 +69,10 @@ namespace MediaBrowser.Providers.BoxSets if (itemLibraryFolderIds == null || !libraryFolderIds.SequenceEqual(itemLibraryFolderIds)) { item.LibraryFolderIds = libraryFolderIds; - updateType |= ItemUpdateType.MetadataImport; + updatedType |= ItemUpdateType.MetadataImport; } - return updateType; + return updatedType; } } } diff --git a/MediaBrowser.Providers/Manager/ImageSaver.cs b/MediaBrowser.Providers/Manager/ImageSaver.cs index 19a42d506c..fb1d4f4906 100644 --- a/MediaBrowser.Providers/Manager/ImageSaver.cs +++ b/MediaBrowser.Providers/Manager/ImageSaver.cs @@ -102,10 +102,8 @@ namespace MediaBrowser.Providers.Manager { saveLocally = false; - var season = item as Season; - // If season is virtual under a physical series, save locally if using compatible convention - if (season != null && _config.Configuration.ImageSavingConvention == ImageSavingConvention.Compatible) + if (item is Season season && _config.Configuration.ImageSavingConvention == ImageSavingConvention.Compatible) { var series = season.Series; @@ -138,7 +136,7 @@ namespace MediaBrowser.Providers.Manager var memoryStream = new MemoryStream(); await using (source.ConfigureAwait(false)) { - await source.CopyToAsync(memoryStream).ConfigureAwait(false); + await source.CopyToAsync(memoryStream, cancellationToken).ConfigureAwait(false); } source = memoryStream; @@ -174,7 +172,9 @@ namespace MediaBrowser.Providers.Manager SetImagePath(item, type, imageIndex, savedPaths[0]); // Delete the current path - if (currentImageIsLocalFile && !savedPaths.Contains(currentImagePath, StringComparer.OrdinalIgnoreCase)) + if (currentImageIsLocalFile + && !savedPaths.Contains(currentImagePath, StringComparer.OrdinalIgnoreCase) + && (saveLocally || currentImagePath.Contains(_config.ApplicationPaths.InternalMetadataPath, StringComparison.OrdinalIgnoreCase))) { var currentPath = currentImagePath; @@ -263,7 +263,8 @@ namespace MediaBrowser.Providers.Manager _fileSystem.SetAttributes(path, false, false); - await using (var fs = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.Read, IODefaults.FileStreamBufferSize, FileOptions.Asynchronous)) + // use FileShare.None as this bypasses dotnet bug dotnet/runtime#42790 . + await using (var fs = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.None, IODefaults.FileStreamBufferSize, FileOptions.Asynchronous)) { await source.CopyToAsync(fs, cancellationToken).ConfigureAwait(false); } diff --git a/MediaBrowser.Providers/Manager/ItemImageProvider.cs b/MediaBrowser.Providers/Manager/ItemImageProvider.cs index d0bdbd7c95..607fd127b2 100644 --- a/MediaBrowser.Providers/Manager/ItemImageProvider.cs +++ b/MediaBrowser.Providers/Manager/ItemImageProvider.cs @@ -1,10 +1,12 @@ -#pragma warning disable CS1591 +#pragma warning disable CA1002, CS1591 using System; using System.Collections.Generic; +using System.Collections.ObjectModel; using System.IO; using System.Linq; using System.Net; +using System.Net.Http; using System.Threading; using System.Threading.Tasks; using MediaBrowser.Controller.Entities; @@ -31,7 +33,7 @@ namespace MediaBrowser.Providers.Manager /// <summary> /// Image types that are only one per item. /// </summary> - private readonly ImageType[] _singularImages = + private static readonly ImageType[] _singularImages = { ImageType.Primary, ImageType.Art, @@ -101,7 +103,7 @@ namespace MediaBrowser.Providers.Manager { if (provider is IRemoteImageProvider remoteProvider) { - await RefreshFromProvider(item, libraryOptions, remoteProvider, refreshOptions, typeOptions, backdropLimit, screenshotLimit, downloadedImages, result, cancellationToken).ConfigureAwait(false); + await RefreshFromProvider(item, remoteProvider, refreshOptions, typeOptions, backdropLimit, screenshotLimit, downloadedImages, result, cancellationToken).ConfigureAwait(false); continue; } @@ -207,9 +209,14 @@ namespace MediaBrowser.Providers.Manager /// <returns><c>true</c> if the specified item contains images; otherwise, <c>false</c>.</returns> private bool ContainsImages(BaseItem item, List<ImageType> images, TypeOptions savedOptions, int backdropLimit, int screenshotLimit) { - if (_singularImages.Any(i => images.Contains(i) && !HasImage(item, i) && savedOptions.GetLimit(i) > 0)) + // Using .Any causes the creation of a DisplayClass aka. variable capture + for (var i = 0; i < _singularImages.Length; i++) { - return false; + var type = _singularImages[i]; + if (images.Contains(type) && !HasImage(item, type) && savedOptions.GetLimit(type) > 0) + { + return false; + } } if (images.Contains(ImageType.Backdrop) && item.GetImages(ImageType.Backdrop).Count() < backdropLimit) @@ -229,7 +236,6 @@ namespace MediaBrowser.Providers.Manager /// Refreshes from provider. /// </summary> /// <param name="item">The item.</param> - /// <param name="libraryOptions">The library options.</param> /// <param name="provider">The provider.</param> /// <param name="refreshOptions">The refresh options.</param> /// <param name="savedOptions">The saved options.</param> @@ -241,7 +247,6 @@ namespace MediaBrowser.Providers.Manager /// <returns>Task.</returns> private async Task RefreshFromProvider( BaseItem item, - LibraryOptions libraryOptions, IRemoteImageProvider provider, ImageRefreshOptions refreshOptions, TypeOptions savedOptions, @@ -289,7 +294,7 @@ namespace MediaBrowser.Providers.Manager if (!HasImage(item, imageType) || (refreshOptions.IsReplacingImage(imageType) && !downloadedImages.Contains(imageType))) { minWidth = savedOptions.GetMinWidth(imageType); - var downloaded = await DownloadImage(item, libraryOptions, provider, result, list, minWidth, imageType, cancellationToken).ConfigureAwait(false); + var downloaded = await DownloadImage(item, provider, result, list, minWidth, imageType, cancellationToken).ConfigureAwait(false); if (downloaded) { @@ -299,12 +304,12 @@ namespace MediaBrowser.Providers.Manager } minWidth = savedOptions.GetMinWidth(ImageType.Backdrop); - await DownloadBackdrops(item, libraryOptions, ImageType.Backdrop, backdropLimit, provider, result, list, minWidth, cancellationToken).ConfigureAwait(false); + await DownloadBackdrops(item, ImageType.Backdrop, backdropLimit, provider, result, list, minWidth, cancellationToken).ConfigureAwait(false); if (item is IHasScreenshots hasScreenshots) { minWidth = savedOptions.GetMinWidth(ImageType.Screenshot); - await DownloadBackdrops(item, libraryOptions, ImageType.Screenshot, screenshotLimit, provider, result, list, minWidth, cancellationToken).ConfigureAwait(false); + await DownloadBackdrops(item, ImageType.Screenshot, screenshotLimit, provider, result, list, minWidth, cancellationToken).ConfigureAwait(false); } } catch (OperationCanceledException) @@ -328,7 +333,7 @@ namespace MediaBrowser.Providers.Manager var deleted = false; var deletedImages = new List<ItemImageInfo>(); - foreach (var image in item.GetImages(type).ToList()) + foreach (var image in item.GetImages(type)) { if (!image.IsLocalFile) { @@ -354,13 +359,14 @@ namespace MediaBrowser.Providers.Manager } } - public bool MergeImages(BaseItem item, List<LocalImageInfo> images) + public bool MergeImages(BaseItem item, IReadOnlyList<LocalImageInfo> images) { var changed = false; - foreach (var type in _singularImages) + for (var i = 0; i < _singularImages.Length; i++) { - var image = images.FirstOrDefault(i => i.Type == type); + var type = _singularImages[i]; + var image = GetFirstLocalImageInfoByType(images, type); if (image != null) { @@ -422,15 +428,29 @@ namespace MediaBrowser.Providers.Manager return changed; } - private bool UpdateMultiImages(BaseItem item, List<LocalImageInfo> images, ImageType type) + private static LocalImageInfo GetFirstLocalImageInfoByType(IReadOnlyList<LocalImageInfo> images, ImageType type) { - var changed = false; + var len = images.Count; + for (var i = 0; i < len; i++) + { + var image = images[i]; + if (image.Type == type) + { + return image; + } + } - var newImages = images.Where(i => i.Type == type).ToList(); + return null; + } - var newImageFileInfos = newImages - .Select(i => i.FileInfo) - .ToList(); + private bool UpdateMultiImages(BaseItem item, IReadOnlyList<LocalImageInfo> images, ImageType type) + { + var changed = false; + + var newImageFileInfos = images + .Where(i => i.Type == type) + .Select(i => i.FileInfo) + .ToList(); if (item.AddImages(type, newImageFileInfos)) { @@ -442,7 +462,6 @@ namespace MediaBrowser.Providers.Manager private async Task<bool> DownloadImage( BaseItem item, - LibraryOptions libraryOptions, IRemoteImageProvider provider, RefreshResult result, IEnumerable<RemoteImageInfo> images, @@ -454,7 +473,7 @@ namespace MediaBrowser.Providers.Manager .Where(i => i.Type == type && !(i.Width.HasValue && i.Width.Value < minWidth)) .ToList(); - if (EnableImageStub(item, libraryOptions) && eligibleImages.Count > 0) + if (EnableImageStub(item) && eligibleImages.Count > 0) { SaveImageStub(item, type, eligibleImages.Select(i => i.Url)); result.UpdateType |= ItemUpdateType.ImageUpdate; @@ -468,7 +487,8 @@ namespace MediaBrowser.Providers.Manager try { using var response = await provider.GetImageResponse(url, cancellationToken).ConfigureAwait(false); - await using var stream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); await _providerManager.SaveImage( item, @@ -481,7 +501,7 @@ namespace MediaBrowser.Providers.Manager result.UpdateType |= ItemUpdateType.ImageUpdate; return true; } - catch (HttpException ex) + catch (HttpRequestException ex) { // Sometimes providers send back bad url's. Just move to the next image if (ex.StatusCode.HasValue @@ -497,7 +517,7 @@ namespace MediaBrowser.Providers.Manager return false; } - private bool EnableImageStub(BaseItem item, LibraryOptions libraryOptions) + private bool EnableImageStub(BaseItem item) { if (item is LiveTvProgram) { @@ -518,12 +538,8 @@ namespace MediaBrowser.Providers.Manager } } - if (libraryOptions.DownloadImagesInAdvance) - { - return false; - } - - return true; + // We always want to use prefetched images + return false; } private void SaveImageStub(BaseItem item, ImageType imageType, IEnumerable<string> urls) @@ -546,7 +562,7 @@ namespace MediaBrowser.Providers.Manager newIndex); } - private async Task DownloadBackdrops(BaseItem item, LibraryOptions libraryOptions, ImageType imageType, int limit, IRemoteImageProvider provider, RefreshResult result, IEnumerable<RemoteImageInfo> images, int minWidth, CancellationToken cancellationToken) + private async Task DownloadBackdrops(BaseItem item, ImageType imageType, int limit, IRemoteImageProvider provider, RefreshResult result, IEnumerable<RemoteImageInfo> images, int minWidth, CancellationToken cancellationToken) { foreach (var image in images.Where(i => i.Type == imageType)) { @@ -562,7 +578,7 @@ namespace MediaBrowser.Providers.Manager var url = image.Url; - if (EnableImageStub(item, libraryOptions)) + if (EnableImageStub(item)) { SaveImageStub(item, imageType, new[] { url }); result.UpdateType |= ItemUpdateType.ImageUpdate; @@ -590,7 +606,7 @@ namespace MediaBrowser.Providers.Manager } } - await using var stream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false); + await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); await _providerManager.SaveImage( item, stream, @@ -600,7 +616,7 @@ namespace MediaBrowser.Providers.Manager cancellationToken).ConfigureAwait(false); result.UpdateType = result.UpdateType | ItemUpdateType.ImageUpdate; } - catch (HttpException ex) + catch (HttpRequestException ex) { // Sometimes providers send back bad urls. Just move onto the next image if (ex.StatusCode.HasValue diff --git a/MediaBrowser.Providers/Manager/MetadataService.cs b/MediaBrowser.Providers/Manager/MetadataService.cs index f110eafa5a..3a42eb4c19 100644 --- a/MediaBrowser.Providers/Manager/MetadataService.cs +++ b/MediaBrowser.Providers/Manager/MetadataService.cs @@ -28,8 +28,11 @@ namespace MediaBrowser.Providers.Manager ProviderManager = providerManager; FileSystem = fileSystem; LibraryManager = libraryManager; + ImageProvider = new ItemImageProvider(Logger, ProviderManager, FileSystem); } + protected ItemImageProvider ImageProvider { get; } + protected IServerConfigurationManager ServerConfigurationManager { get; } protected ILogger<MetadataService<TItemType, TIdType>> Logger { get; } @@ -88,7 +91,6 @@ namespace MediaBrowser.Providers.Manager } } - var itemImageProvider = new ItemImageProvider(Logger, ProviderManager, FileSystem); var localImagesFailed = false; var allImageProviders = ((ProviderManager)ProviderManager).GetImageProviders(item, refreshOptions).ToList(); @@ -97,7 +99,7 @@ namespace MediaBrowser.Providers.Manager try { // Always validate images and check for new locally stored ones. - if (itemImageProvider.ValidateImages(item, allImageProviders.OfType<ILocalImageProvider>(), refreshOptions.DirectoryService)) + if (ImageProvider.ValidateImages(item, allImageProviders.OfType<ILocalImageProvider>(), refreshOptions.DirectoryService)) { updateType |= ItemUpdateType.ImageUpdate; } @@ -143,7 +145,7 @@ namespace MediaBrowser.Providers.Manager // await FindIdentities(id, cancellationToken).ConfigureAwait(false); id.IsAutomated = refreshOptions.IsAutomated; - var result = await RefreshWithProviders(metadataResult, id, refreshOptions, providers, itemImageProvider, cancellationToken).ConfigureAwait(false); + var result = await RefreshWithProviders(metadataResult, id, refreshOptions, providers, ImageProvider, cancellationToken).ConfigureAwait(false); updateType |= result.UpdateType; if (result.Failures > 0) @@ -160,7 +162,7 @@ namespace MediaBrowser.Providers.Manager if (providers.Count > 0) { - var result = await itemImageProvider.RefreshImages(itemOfType, libraryOptions, providers, refreshOptions, cancellationToken).ConfigureAwait(false); + var result = await ImageProvider.RefreshImages(itemOfType, libraryOptions, providers, refreshOptions, cancellationToken).ConfigureAwait(false); updateType |= result.UpdateType; if (result.Failures > 0) @@ -201,7 +203,7 @@ namespace MediaBrowser.Providers.Manager } // Save to database - await SaveItemAsync(metadataResult, libraryOptions, updateType, cancellationToken).ConfigureAwait(false); + await SaveItemAsync(metadataResult, updateType, cancellationToken).ConfigureAwait(false); } await AfterMetadataRefresh(itemOfType, refreshOptions, cancellationToken).ConfigureAwait(false); @@ -211,85 +213,37 @@ namespace MediaBrowser.Providers.Manager private void ApplySearchResult(ItemLookupInfo lookupInfo, RemoteSearchResult result) { - lookupInfo.ProviderIds = result.ProviderIds; - lookupInfo.Name = result.Name; - lookupInfo.Year = result.ProductionYear; + // Episode and Season do not support Identify, so the search results are the Series' + switch (lookupInfo) + { + case EpisodeInfo episodeInfo: + episodeInfo.SeriesProviderIds = result.ProviderIds; + episodeInfo.ProviderIds.Clear(); + break; + case SeasonInfo seasonInfo: + seasonInfo.SeriesProviderIds = result.ProviderIds; + seasonInfo.ProviderIds.Clear(); + break; + default: + lookupInfo.ProviderIds = result.ProviderIds; + lookupInfo.Name = result.Name; + lookupInfo.Year = result.ProductionYear; + break; + } } - protected async Task SaveItemAsync(MetadataResult<TItemType> result, LibraryOptions libraryOptions, ItemUpdateType reason, CancellationToken cancellationToken) + protected async Task SaveItemAsync(MetadataResult<TItemType> result, ItemUpdateType reason, CancellationToken cancellationToken) { if (result.Item.SupportsPeople && result.People != null) { var baseItem = result.Item; - LibraryManager.UpdatePeople(baseItem, result.People); - await SavePeopleMetadataAsync(result.People, libraryOptions, cancellationToken).ConfigureAwait(false); + await LibraryManager.UpdatePeopleAsync(baseItem, result.People, cancellationToken).ConfigureAwait(false); } await result.Item.UpdateToRepositoryAsync(reason, cancellationToken).ConfigureAwait(false); } - private async Task SavePeopleMetadataAsync(List<PersonInfo> people, LibraryOptions libraryOptions, CancellationToken cancellationToken) - { - foreach (var person in people) - { - cancellationToken.ThrowIfCancellationRequested(); - - if (person.ProviderIds.Count > 0 || !string.IsNullOrWhiteSpace(person.ImageUrl)) - { - var updateType = ItemUpdateType.MetadataDownload; - - var saveEntity = false; - var personEntity = LibraryManager.GetPerson(person.Name); - foreach (var id in person.ProviderIds) - { - if (!string.Equals(personEntity.GetProviderId(id.Key), id.Value, StringComparison.OrdinalIgnoreCase)) - { - personEntity.SetProviderId(id.Key, id.Value); - saveEntity = true; - } - } - - if (!string.IsNullOrWhiteSpace(person.ImageUrl) && !personEntity.HasImage(ImageType.Primary)) - { - await AddPersonImageAsync(personEntity, libraryOptions, person.ImageUrl, cancellationToken).ConfigureAwait(false); - - saveEntity = true; - updateType |= ItemUpdateType.ImageUpdate; - } - - if (saveEntity) - { - await personEntity.UpdateToRepositoryAsync(updateType, cancellationToken).ConfigureAwait(false); - } - } - } - } - - private async Task AddPersonImageAsync(Person personEntity, LibraryOptions libraryOptions, string imageUrl, CancellationToken cancellationToken) - { - if (libraryOptions.DownloadImagesInAdvance) - { - try - { - await ProviderManager.SaveImage(personEntity, imageUrl, ImageType.Primary, null, cancellationToken).ConfigureAwait(false); - return; - } - catch (Exception ex) - { - Logger.LogError(ex, "Error in AddPersonImage"); - } - } - - personEntity.SetImage( - new ItemImageInfo - { - Path = imageUrl, - Type = ImageType.Primary - }, - 0); - } - protected virtual Task AfterMetadataRefresh(TItemType item, MetadataRefreshOptions refreshOptions, CancellationToken cancellationToken) { item.AfterMetadataRefresh(); @@ -343,8 +297,7 @@ namespace MediaBrowser.Providers.Manager return true; } - var folder = item as Folder; - if (folder != null) + if (item is Folder folder) { return folder.SupportsDateLastMediaAdded || folder.SupportsCumulativeRunTimeTicks; } @@ -398,8 +351,7 @@ namespace MediaBrowser.Providers.Manager private ItemUpdateType UpdateCumulativeRunTimeTicks(TItemType item, IList<BaseItem> children) { - var folder = item as Folder; - if (folder != null && folder.SupportsCumulativeRunTimeTicks) + if (item is Folder folder && folder.SupportsCumulativeRunTimeTicks) { long ticks = 0; @@ -487,7 +439,7 @@ namespace MediaBrowser.Providers.Manager if ((originalPremiereDate ?? DateTime.MinValue) != (item.PremiereDate ?? DateTime.MinValue) || (originalProductionYear ?? -1) != (item.ProductionYear ?? -1)) { - updateType = updateType | ItemUpdateType.MetadataEdit; + updateType |= ItemUpdateType.MetadataEdit; } return updateType; @@ -507,7 +459,7 @@ namespace MediaBrowser.Providers.Manager if (currentList.Length != item.Genres.Length || !currentList.OrderBy(i => i).SequenceEqual(item.Genres.OrderBy(i => i), StringComparer.OrdinalIgnoreCase)) { - updateType = updateType | ItemUpdateType.MetadataEdit; + updateType |= ItemUpdateType.MetadataEdit; } } @@ -528,7 +480,7 @@ namespace MediaBrowser.Providers.Manager if (currentList.Length != item.Studios.Length || !currentList.OrderBy(i => i).SequenceEqual(item.Studios.OrderBy(i => i), StringComparer.OrdinalIgnoreCase)) { - updateType = updateType | ItemUpdateType.MetadataEdit; + updateType |= ItemUpdateType.MetadataEdit; } } @@ -543,7 +495,7 @@ namespace MediaBrowser.Providers.Manager { if (item.UpdateRatingToItems(children)) { - updateType = updateType | ItemUpdateType.MetadataEdit; + updateType |= ItemUpdateType.MetadataEdit; } } @@ -553,6 +505,11 @@ namespace MediaBrowser.Providers.Manager /// <summary> /// Gets the providers. /// </summary> + /// <param name="item">A media item.</param> + /// <param name="libraryOptions">The LibraryOptions to use.</param> + /// <param name="options">The MetadataRefreshOptions to use.</param> + /// <param name="isFirstRefresh">Specifies first refresh mode.</param> + /// <param name="requiresRefresh">Specifies refresh mode.</param> /// <returns>IEnumerable{`0}.</returns> protected IEnumerable<IMetadataProvider> GetProviders(BaseItem item, LibraryOptions libraryOptions, MetadataRefreshOptions options, bool isFirstRefresh, bool requiresRefresh) { @@ -627,7 +584,7 @@ namespace MediaBrowser.Providers.Manager protected virtual IEnumerable<IImageProvider> GetNonLocalImageProviders(BaseItem item, IEnumerable<IImageProvider> allImageProviders, ImageRefreshOptions options) { // Get providers to refresh - var providers = allImageProviders.Where(i => !(i is ILocalImageProvider)).ToList(); + var providers = allImageProviders.Where(i => !(i is ILocalImageProvider)); var dateLastImageRefresh = item.DateLastRefreshed; @@ -639,15 +596,13 @@ namespace MediaBrowser.Providers.Manager providers = providers .Where(i => { - var hasFileChangeMonitor = i as IHasItemChangeMonitor; - if (hasFileChangeMonitor != null) + if (i is IHasItemChangeMonitor hasFileChangeMonitor) { return HasChanged(item, hasFileChangeMonitor, options.DirectoryService); } return false; - }) - .ToList(); + }); } return providers; @@ -667,7 +622,7 @@ namespace MediaBrowser.Providers.Manager MetadataResult<TItemType> metadata, TIdType id, MetadataRefreshOptions options, - List<IMetadataProvider> providers, + ICollection<IMetadataProvider> providers, ItemImageProvider imageService, CancellationToken cancellationToken) { @@ -700,7 +655,7 @@ namespace MediaBrowser.Providers.Manager var remoteResult = await ExecuteRemoteProviders(temp, logName, id, providers.OfType<IRemoteMetadataProvider<TItemType, TIdType>>(), cancellationToken) .ConfigureAwait(false); - refreshResult.UpdateType = refreshResult.UpdateType | remoteResult.UpdateType; + refreshResult.UpdateType |= remoteResult.UpdateType; refreshResult.ErrorMessage = remoteResult.ErrorMessage; refreshResult.Failures += remoteResult.Failures; } @@ -720,9 +675,15 @@ namespace MediaBrowser.Providers.Manager if (localItem.HasMetadata) { + foreach (var remoteImage in localItem.RemoteImages) + { + await ProviderManager.SaveImage(item, remoteImage.url, remoteImage.type, null, cancellationToken).ConfigureAwait(false); + refreshResult.UpdateType |= ItemUpdateType.ImageUpdate; + } + if (imageService.MergeImages(item, localItem.Images)) { - refreshResult.UpdateType = refreshResult.UpdateType | ItemUpdateType.ImageUpdate; + refreshResult.UpdateType |= ItemUpdateType.ImageUpdate; } if (localItem.UserDataList != null) @@ -731,7 +692,7 @@ namespace MediaBrowser.Providers.Manager } MergeData(localItem, temp, Array.Empty<MetadataField>(), !options.ReplaceAllMetadata, true); - refreshResult.UpdateType = refreshResult.UpdateType | ItemUpdateType.MetadataImport; + refreshResult.UpdateType |= ItemUpdateType.MetadataImport; // Only one local provider allowed per item if (item.IsLocked || localItem.Item.IsLocked || IsFullLocalMetadata(localItem.Item)) @@ -763,7 +724,7 @@ namespace MediaBrowser.Providers.Manager var remoteResult = await ExecuteRemoteProviders(temp, logName, id, providers.OfType<IRemoteMetadataProvider<TItemType, TIdType>>(), cancellationToken) .ConfigureAwait(false); - refreshResult.UpdateType = refreshResult.UpdateType | remoteResult.UpdateType; + refreshResult.UpdateType |= remoteResult.UpdateType; refreshResult.ErrorMessage = remoteResult.ErrorMessage; refreshResult.Failures += remoteResult.Failures; } @@ -859,7 +820,7 @@ namespace MediaBrowser.Providers.Manager MergeData(result, temp, Array.Empty<MetadataField>(), false, false); MergeNewData(temp.Item, id); - refreshResult.UpdateType = refreshResult.UpdateType | ItemUpdateType.MetadataDownload; + refreshResult.UpdateType |= ItemUpdateType.MetadataDownload; } else { diff --git a/MediaBrowser.Providers/Manager/ProviderManager.cs b/MediaBrowser.Providers/Manager/ProviderManager.cs index a0c7d4ad09..2dfaa372c5 100644 --- a/MediaBrowser.Providers/Manager/ProviderManager.cs +++ b/MediaBrowser.Providers/Manager/ProviderManager.cs @@ -13,6 +13,7 @@ using Jellyfin.Data.Events; using MediaBrowser.Common.Net; using MediaBrowser.Common.Progress; using MediaBrowser.Controller; +using MediaBrowser.Controller.BaseItemManager; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; @@ -24,7 +25,6 @@ using MediaBrowser.Controller.Subtitles; using MediaBrowser.Model.Configuration; using MediaBrowser.Model.Entities; using MediaBrowser.Model.IO; -using MediaBrowser.Model.Net; using MediaBrowser.Model.Providers; using Microsoft.Extensions.Logging; using Priority_Queue; @@ -51,6 +51,7 @@ namespace MediaBrowser.Providers.Manager private readonly ILibraryManager _libraryManager; private readonly ISubtitleManager _subtitleManager; private readonly IServerConfigurationManager _configurationManager; + private readonly IBaseItemManager _baseItemManager; private readonly ConcurrentDictionary<Guid, double> _activeRefreshes = new ConcurrentDictionary<Guid, double>(); private readonly CancellationTokenSource _disposeCancellationTokenSource = new CancellationTokenSource(); private readonly SimplePriorityQueue<Tuple<Guid, MetadataRefreshOptions>> _refreshQueue = @@ -58,8 +59,8 @@ namespace MediaBrowser.Providers.Manager private IMetadataService[] _metadataServices = Array.Empty<IMetadataService>(); private IMetadataProvider[] _metadataProviders = Array.Empty<IMetadataProvider>(); - private IEnumerable<IMetadataSaver> _savers; - private IExternalId[] _externalIds; + private IMetadataSaver[] _savers = Array.Empty<IMetadataSaver>(); + private IExternalId[] _externalIds = Array.Empty<IExternalId>(); private bool _isProcessingRefreshQueue; private bool _disposed; @@ -74,6 +75,7 @@ namespace MediaBrowser.Providers.Manager /// <param name="fileSystem">The filesystem.</param> /// <param name="appPaths">The server application paths.</param> /// <param name="libraryManager">The library manager.</param> + /// <param name="baseItemManager">The BaseItem manager.</param> public ProviderManager( IHttpClientFactory httpClientFactory, ISubtitleManager subtitleManager, @@ -82,7 +84,8 @@ namespace MediaBrowser.Providers.Manager ILogger<ProviderManager> logger, IFileSystem fileSystem, IServerApplicationPaths appPaths, - ILibraryManager libraryManager) + ILibraryManager libraryManager, + IBaseItemManager baseItemManager) { _logger = logger; _httpClientFactory = httpClientFactory; @@ -92,6 +95,7 @@ namespace MediaBrowser.Providers.Manager _appPaths = appPaths; _libraryManager = libraryManager; _subtitleManager = subtitleManager; + _baseItemManager = baseItemManager; } /// <inheritdoc/> @@ -120,7 +124,7 @@ namespace MediaBrowser.Providers.Manager _externalIds = externalIds.OrderBy(i => i.ProviderName).ToArray(); _savers = metadataSavers - .Where(i => !(i is IConfigurableProvider configurable) || configurable.IsEnabled) + .Where(i => i is not IConfigurableProvider configurable || configurable.IsEnabled) .ToArray(); } @@ -160,13 +164,10 @@ namespace MediaBrowser.Providers.Manager if (response.StatusCode != HttpStatusCode.OK) { - throw new HttpException("Invalid image received.") - { - StatusCode = response.StatusCode - }; + throw new HttpRequestException("Invalid image received.", null, response.StatusCode); } - var contentType = response.Content.Headers.ContentType.MediaType; + var contentType = response.Content.Headers.ContentType?.MediaType; // Workaround for tvheadend channel icons // TODO: Isolate this hack into the tvh plugin @@ -181,13 +182,10 @@ namespace MediaBrowser.Providers.Manager // thetvdb will sometimes serve a rubbish 404 html page with a 200 OK code, because reasons... if (contentType.Equals(MediaTypeNames.Text.Html, StringComparison.OrdinalIgnoreCase)) { - throw new HttpException("Invalid image received.") - { - StatusCode = HttpStatusCode.NotFound - }; + throw new HttpRequestException("Invalid image received.", null, HttpStatusCode.NotFound); } - await using var stream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false); + await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); await SaveImage( item, stream, @@ -243,6 +241,7 @@ namespace MediaBrowser.Providers.Manager languages.Add(preferredLanguage); } + // TODO include [query.IncludeAllLanguages] as an argument to the providers var tasks = providers.Select(i => GetImages(item, i, languages, cancellationToken, query.ImageType)); var results = await Task.WhenAll(tasks).ConfigureAwait(false); @@ -398,7 +397,7 @@ namespace MediaBrowser.Providers.Manager if (provider is IRemoteMetadataProvider) { - if (!forceEnableInternetMetadata && !item.IsMetadataFetcherEnabled(libraryOptions, provider.Name)) + if (!forceEnableInternetMetadata && !_baseItemManager.IsMetadataFetcherEnabled(item, libraryOptions, provider.Name)) { return false; } @@ -442,7 +441,7 @@ namespace MediaBrowser.Providers.Manager if (provider is IRemoteImageProvider || provider is IDynamicImageProvider) { - if (!item.IsImageFetcherEnabled(libraryOptions, provider.Name)) + if (!_baseItemManager.IsImageFetcherEnabled(item, libraryOptions, provider.Name)) { return false; } @@ -870,14 +869,14 @@ namespace MediaBrowser.Providers.Manager } } } - catch (Exception) +#pragma warning disable CA1031 // do not catch general exception types + catch (Exception ex) +#pragma warning restore CA1031 // do not catch general exception types { - // Logged at lower levels + _logger.LogError(ex, "Provider {ProviderName} failed to retrieve search results", provider.Name); } } - // _logger.LogDebug("Returning search results {0}", _json.SerializeToString(resultList)); - return resultList; } @@ -961,13 +960,11 @@ namespace MediaBrowser.Providers.Manager public IEnumerable<ExternalIdInfo> GetExternalIdInfos(IHasProviderIds item) { return GetExternalIds(item) - .Select(i => new ExternalIdInfo - { - Name = i.ProviderName, - Key = i.Key, - Type = i.Type, - UrlFormatString = i.UrlFormatString - }); + .Select(i => new ExternalIdInfo( + name: i.ProviderName, + key: i.Key, + type: i.Type, + urlFormatString: i.UrlFormatString)); } /// <inheritdoc/> @@ -1024,26 +1021,26 @@ namespace MediaBrowser.Providers.Manager // TODO: Need to hunt down the conditions for this happening _activeRefreshes.AddOrUpdate( id, - (_) => throw new Exception( + (_) => throw new InvalidOperationException( string.Format( CultureInfo.InvariantCulture, "Cannot update refresh progress of item '{0}' ({1}) because a refresh for this item is not running", item.GetType().Name, item.Id.ToString("N", CultureInfo.InvariantCulture))), - (_, __) => progress); + (_, _) => progress); RefreshProgress?.Invoke(this, new GenericEventArgs<Tuple<BaseItem, double>>(new Tuple<BaseItem, double>(item, progress))); } /// <inheritdoc/> - public void QueueRefresh(Guid id, MetadataRefreshOptions options, RefreshPriority priority) + public void QueueRefresh(Guid itemId, MetadataRefreshOptions options, RefreshPriority priority) { if (_disposed) { return; } - _refreshQueue.Enqueue(new Tuple<Guid, MetadataRefreshOptions>(id, options), (int)priority); + _refreshQueue.Enqueue(new Tuple<Guid, MetadataRefreshOptions>(itemId, options), (int)priority); lock (_refreshQueueLock) { @@ -1076,17 +1073,16 @@ namespace MediaBrowser.Providers.Manager try { var item = libraryManager.GetItemById(refreshItem.Item1); - if (item != null) + if (item == null) { - // Try to throttle this a little bit. - await Task.Delay(100, cancellationToken).ConfigureAwait(false); + continue; + } - var task = item is MusicArtist artist - ? RefreshArtist(artist, refreshItem.Item2, cancellationToken) - : RefreshItem(item, refreshItem.Item2, cancellationToken); + var task = item is MusicArtist artist + ? RefreshArtist(artist, refreshItem.Item2, cancellationToken) + : RefreshItem(item, refreshItem.Item2, cancellationToken); - await task.ConfigureAwait(false); - } + await task.ConfigureAwait(false); } catch (OperationCanceledException) { @@ -1115,7 +1111,7 @@ namespace MediaBrowser.Providers.Manager await RefreshCollectionFolderChildren(options, collectionFolder, cancellationToken).ConfigureAwait(false); break; case Folder folder: - await folder.ValidateChildren(new SimpleProgress<double>(), cancellationToken, options).ConfigureAwait(false); + await folder.ValidateChildren(new SimpleProgress<double>(), options, cancellationToken: cancellationToken).ConfigureAwait(false); break; } } @@ -1126,7 +1122,7 @@ namespace MediaBrowser.Providers.Manager { await child.RefreshMetadata(options, cancellationToken).ConfigureAwait(false); - await child.ValidateChildren(new SimpleProgress<double>(), cancellationToken, options).ConfigureAwait(false); + await child.ValidateChildren(new SimpleProgress<double>(), options, cancellationToken: cancellationToken).ConfigureAwait(false); } } @@ -1148,7 +1144,7 @@ namespace MediaBrowser.Providers.Manager .Select(i => i.MusicArtist) .Where(i => i != null); - var musicArtistRefreshTasks = musicArtists.Select(i => i.ValidateChildren(new SimpleProgress<double>(), cancellationToken, options, true)); + var musicArtistRefreshTasks = musicArtists.Select(i => i.ValidateChildren(new SimpleProgress<double>(), options, true, cancellationToken)); await Task.WhenAll(musicArtistRefreshTasks).ConfigureAwait(false); @@ -1168,6 +1164,29 @@ namespace MediaBrowser.Providers.Manager return RefreshItem(item, options, cancellationToken); } + /// <summary> + /// Runs multiple metadata refreshes concurrently. + /// </summary> + /// <param name="action">The action to run.</param> + /// <param name="cancellationToken">The cancellation token.</param> + /// <returns>A <see cref="Task"/> representing the result of the asynchronous operation.</returns> + public async Task RunMetadataRefresh(Func<Task> action, CancellationToken cancellationToken) + { + // create a variable for this since it is possible MetadataRefreshThrottler could change due to a config update during a scan + var metadataRefreshThrottler = _baseItemManager.MetadataRefreshThrottler; + + await metadataRefreshThrottler.WaitAsync(cancellationToken).ConfigureAwait(false); + + try + { + await action().ConfigureAwait(false); + } + finally + { + metadataRefreshThrottler.Release(); + } + } + /// <inheritdoc/> public void Dispose() { diff --git a/MediaBrowser.Providers/Manager/ProviderUtils.cs b/MediaBrowser.Providers/Manager/ProviderUtils.cs index 70a5a6ac13..aceba22158 100644 --- a/MediaBrowser.Providers/Manager/ProviderUtils.cs +++ b/MediaBrowser.Providers/Manager/ProviderUtils.cs @@ -3,9 +3,9 @@ using System; using System.Collections.Generic; using System.Linq; +using Diacritics.Extensions; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.Audio; -using MediaBrowser.Controller.Extensions; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Entities; @@ -38,7 +38,7 @@ namespace MediaBrowser.Providers.Manager { if (replaceData || string.IsNullOrEmpty(target.Name)) { - // Safeguard against incoming data having an emtpy name + // Safeguard against incoming data having an empty name if (!string.IsNullOrWhiteSpace(source.Name)) { target.Name = source.Name; @@ -48,14 +48,14 @@ namespace MediaBrowser.Providers.Manager if (replaceData || string.IsNullOrEmpty(target.OriginalTitle)) { - // Safeguard against incoming data having an emtpy name + // Safeguard against incoming data having an empty name if (!string.IsNullOrWhiteSpace(source.OriginalTitle)) { target.OriginalTitle = source.OriginalTitle; } } - if (replaceData || !target.CommunityRating.HasValue || (source.CommunityRating.HasValue && string.Equals(sourceResult.Provider, "The Open Movie Database", StringComparison.OrdinalIgnoreCase))) + if (replaceData || !target.CommunityRating.HasValue) { target.CommunityRating = source.CommunityRating; } diff --git a/MediaBrowser.Providers/MediaBrowser.Providers.csproj b/MediaBrowser.Providers/MediaBrowser.Providers.csproj index 24400eae53..3d866cdc2b 100644 --- a/MediaBrowser.Providers/MediaBrowser.Providers.csproj +++ b/MediaBrowser.Providers/MediaBrowser.Providers.csproj @@ -16,34 +16,32 @@ </ItemGroup> <ItemGroup> - <PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="3.1.9" /> - <PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="3.1.9" /> - <PackageReference Include="Microsoft.Extensions.Http" Version="3.1.9" /> - <PackageReference Include="OptimizedPriorityQueue" Version="4.2.0" /> - <PackageReference Include="PlaylistsNET" Version="1.1.2" /> - <PackageReference Include="TMDbLib" Version="1.7.3-alpha" /> - <PackageReference Include="TvDbSharper" Version="3.2.2" /> + <PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="5.0.0" /> + <PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="5.0.0" /> + <PackageReference Include="Microsoft.Extensions.Http" Version="5.0.0" /> + <PackageReference Include="Newtonsoft.Json" Version="13.0.1" /> + <PackageReference Include="OptimizedPriorityQueue" Version="5.0.0" /> + <PackageReference Include="PlaylistsNET" Version="1.1.3" /> + <PackageReference Include="TMDbLib" Version="1.8.1" /> </ItemGroup> <PropertyGroup> - <TargetFramework>netstandard2.1</TargetFramework> + <TargetFramework>net5.0</TargetFramework> <GenerateAssemblyInfo>false</GenerateAssemblyInfo> <GenerateDocumentationFile>true</GenerateDocumentationFile> - <TreatWarningsAsErrors Condition=" '$(Configuration)' == 'Release'">true</TreatWarningsAsErrors> + <TreatWarningsAsErrors>true</TreatWarningsAsErrors> + <AnalysisMode Condition=" '$(Configuration)' == 'Debug'">AllEnabledByDefault</AnalysisMode> + <CodeAnalysisRuleSet>../jellyfin.ruleset</CodeAnalysisRuleSet> + <Nullable>disable</Nullable> </PropertyGroup> <!-- Code Analyzers--> <ItemGroup Condition=" '$(Configuration)' == 'Debug' "> - <PackageReference Include="Microsoft.CodeAnalysis.FxCopAnalyzers" Version="2.9.8" PrivateAssets="All" /> <PackageReference Include="SerilogAnalyzer" Version="0.15.0" PrivateAssets="All" /> <PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="All" /> <PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" PrivateAssets="All" /> </ItemGroup> - <PropertyGroup Condition=" '$(Configuration)' == 'Debug' "> - <CodeAnalysisRuleSet>../jellyfin.ruleset</CodeAnalysisRuleSet> - </PropertyGroup> - <ItemGroup> <None Remove="Plugins\AudioDb\Configuration\config.html" /> <EmbeddedResource Include="Plugins\AudioDb\Configuration\config.html" /> diff --git a/MediaBrowser.Providers/MediaInfo/AudioImageProvider.cs b/MediaBrowser.Providers/MediaInfo/AudioImageProvider.cs index 64ad1bddfe..12125cbb95 100644 --- a/MediaBrowser.Providers/MediaInfo/AudioImageProvider.cs +++ b/MediaBrowser.Providers/MediaInfo/AudioImageProvider.cs @@ -1,4 +1,4 @@ -#pragma warning disable CS1591 +#pragma warning disable CA1002, CS1591 using System; using System.Collections.Generic; @@ -137,9 +137,7 @@ namespace MediaBrowser.Providers.MediaInfo return false; } - var audio = item as Audio; - - return audio != null; + return item is Audio; } } } diff --git a/MediaBrowser.Providers/MediaInfo/FFProbeAudioInfo.cs b/MediaBrowser.Providers/MediaInfo/FFProbeAudioInfo.cs index 9454636669..cf271e7db1 100644 --- a/MediaBrowser.Providers/MediaInfo/FFProbeAudioInfo.cs +++ b/MediaBrowser.Providers/MediaInfo/FFProbeAudioInfo.cs @@ -111,6 +111,11 @@ namespace MediaBrowser.Providers.MediaInfo audio.Name = data.Name; } + if (!string.IsNullOrEmpty(data.ForcedSortName)) + { + audio.ForcedSortName = data.ForcedSortName; + } + if (audio.SupportsPeople && !audio.LockedFields.Contains(MetadataField.Cast)) { var people = new List<PersonInfo>(); diff --git a/MediaBrowser.Providers/MediaInfo/FFProbeProvider.cs b/MediaBrowser.Providers/MediaInfo/FFProbeProvider.cs index c61187fdf6..4fff57273c 100644 --- a/MediaBrowser.Providers/MediaInfo/FFProbeProvider.cs +++ b/MediaBrowser.Providers/MediaInfo/FFProbeProvider.cs @@ -150,11 +150,6 @@ namespace MediaBrowser.Providers.MediaInfo public Task<ItemUpdateType> FetchVideoInfo<T>(T item, MetadataRefreshOptions options, CancellationToken cancellationToken) where T : Video { - if (item.VideoType == VideoType.Iso) - { - return _cachedTask; - } - if (item.IsPlaceHolder) { return _cachedTask; @@ -208,7 +203,7 @@ namespace MediaBrowser.Providers.MediaInfo { item.ShortcutPath = File.ReadAllLines(item.Path) .Select(NormalizeStrmLine) - .FirstOrDefault(i => !string.IsNullOrWhiteSpace(i) && !i.StartsWith("#", StringComparison.OrdinalIgnoreCase)); + .FirstOrDefault(i => !string.IsNullOrWhiteSpace(i) && !i.StartsWith('#')); } public Task<ItemUpdateType> FetchAudioInfo<T>(T item, MetadataRefreshOptions options, CancellationToken cancellationToken) diff --git a/MediaBrowser.Providers/MediaInfo/FFProbeVideoInfo.cs b/MediaBrowser.Providers/MediaInfo/FFProbeVideoInfo.cs index 776dee7802..1f17d8cd4c 100644 --- a/MediaBrowser.Providers/MediaInfo/FFProbeVideoInfo.cs +++ b/MediaBrowser.Providers/MediaInfo/FFProbeVideoInfo.cs @@ -1,4 +1,4 @@ -#pragma warning disable CS1591 +#pragma warning disable CA1068, CS1591 using System; using System.Collections.Generic; @@ -111,12 +111,9 @@ namespace MediaBrowser.Providers.MediaInfo } } - if (streamFileNames == null) - { - streamFileNames = Array.Empty<string>(); - } + streamFileNames ??= Array.Empty<string>(); - mediaInfoResult = await GetMediaInfo(item, streamFileNames, cancellationToken).ConfigureAwait(false); + mediaInfoResult = await GetMediaInfo(item, cancellationToken).ConfigureAwait(false); cancellationToken.ThrowIfCancellationRequested(); } @@ -128,7 +125,6 @@ namespace MediaBrowser.Providers.MediaInfo private Task<Model.MediaInfo.MediaInfo> GetMediaInfo( Video item, - string[] streamFileNames, CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); @@ -145,14 +141,14 @@ namespace MediaBrowser.Providers.MediaInfo return _mediaEncoder.GetMediaInfo( new MediaInfoRequest { - PlayableStreamFileNames = streamFileNames, ExtractChapters = true, MediaType = DlnaProfileType.Video, MediaSource = new MediaSourceInfo { Path = path, Protocol = protocol, - VideoType = item.VideoType + VideoType = item.VideoType, + IsoType = item.IsoType } }, cancellationToken); @@ -396,6 +392,12 @@ namespace MediaBrowser.Providers.MediaInfo } } + if (video is MusicVideo musicVideo) + { + musicVideo.Album = data.Album; + musicVideo.Artists = data.Artists; + } + if (data.ProductionYear.HasValue) { if (!video.ProductionYear.HasValue || isFullRefresh) @@ -438,6 +440,11 @@ namespace MediaBrowser.Providers.MediaInfo video.Name = data.Name; } } + + if (!string.IsNullOrWhiteSpace(data.ForcedSortName)) + { + video.ForcedSortName = data.ForcedSortName; + } } // If we don't have a ProductionYear try and get it from PremiereDate @@ -621,7 +628,7 @@ namespace MediaBrowser.Providers.MediaInfo item.RunTimeTicks = GetRuntime(primaryTitle); } - return _mediaEncoder.GetPrimaryPlaylistVobFiles(item.Path, null, titleNumber) + return _mediaEncoder.GetPrimaryPlaylistVobFiles(item.Path, titleNumber) .Select(Path.GetFileName) .ToArray(); } diff --git a/MediaBrowser.Providers/MediaInfo/SubtitleDownloader.cs b/MediaBrowser.Providers/MediaInfo/SubtitleDownloader.cs index 912aedb0db..aa0743bd02 100644 --- a/MediaBrowser.Providers/MediaInfo/SubtitleDownloader.cs +++ b/MediaBrowser.Providers/MediaInfo/SubtitleDownloader.cs @@ -1,7 +1,8 @@ -#pragma warning disable CS1591 +#pragma warning disable CA1002, CS1591 using System; using System.Collections.Generic; +using System.Collections.ObjectModel; using System.Linq; using System.Threading; using System.Threading.Tasks; @@ -172,9 +173,7 @@ namespace MediaBrowser.Providers.MediaInfo SubtitleFetcherOrder = subtitleFetcherOrder }; - var episode = video as Episode; - - if (episode != null) + if (video is Episode episode) { request.IndexNumberEnd = episode.IndexNumberEnd; request.SeriesName = episode.SeriesName; diff --git a/MediaBrowser.Providers/MediaInfo/SubtitleResolver.cs b/MediaBrowser.Providers/MediaInfo/SubtitleResolver.cs index e9f999c6d0..b3d0659290 100644 --- a/MediaBrowser.Providers/MediaInfo/SubtitleResolver.cs +++ b/MediaBrowser.Providers/MediaInfo/SubtitleResolver.cs @@ -1,9 +1,9 @@ -#pragma warning disable CS1591 +#pragma warning disable CA1002, CS1591 using System; using System.Collections.Generic; +using System.Collections.ObjectModel; using System.IO; -using System.Linq; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Entities; @@ -15,17 +15,6 @@ namespace MediaBrowser.Providers.MediaInfo { private readonly ILocalizationManager _localization; - private static readonly HashSet<string> SubtitleExtensions = new HashSet<string>(StringComparer.OrdinalIgnoreCase) - { - ".srt", - ".ssa", - ".ass", - ".sub", - ".smi", - ".sami", - ".vtt" - }; - public SubtitleResolver(ILocalizationManager localization) { _localization = localization; @@ -66,138 +55,142 @@ namespace MediaBrowser.Providers.MediaInfo return streams; } - public List<string> GetExternalSubtitleFiles( + public IEnumerable<string> GetExternalSubtitleFiles( Video video, IDirectoryService directoryService, bool clearCache) { - var list = new List<string>(); - if (!video.IsFileProtocol) { - return list; + yield break; } var streams = GetExternalSubtitleStreams(video, 0, directoryService, clearCache); foreach (var stream in streams) { - list.Add(stream.Path); + yield return stream.Path; } - - return list; - } - - private void AddExternalSubtitleStreams( - List<MediaStream> streams, - string folder, - string videoPath, - int startIndex, - IDirectoryService directoryService, - bool clearCache) - { - var files = directoryService.GetFilePaths(folder, clearCache).OrderBy(i => i).ToArray(); - - AddExternalSubtitleStreams(streams, videoPath, startIndex, files); } public void AddExternalSubtitleStreams( List<MediaStream> streams, string videoPath, int startIndex, - string[] files) + IReadOnlyList<string> files) { - var videoFileNameWithoutExtension = Path.GetFileNameWithoutExtension(videoPath); - videoFileNameWithoutExtension = NormalizeFilenameForSubtitleComparison(videoFileNameWithoutExtension); + var videoFileNameWithoutExtension = NormalizeFilenameForSubtitleComparison(videoPath); - foreach (var fullName in files) + for (var i = 0; i < files.Count; i++) { - var extension = Path.GetExtension(fullName); - - if (!SubtitleExtensions.Contains(extension)) - { - continue; - } - - var fileNameWithoutExtension = Path.GetFileNameWithoutExtension(fullName); - fileNameWithoutExtension = NormalizeFilenameForSubtitleComparison(fileNameWithoutExtension); - - if (!string.Equals(videoFileNameWithoutExtension, fileNameWithoutExtension, StringComparison.OrdinalIgnoreCase) && - !fileNameWithoutExtension.StartsWith(videoFileNameWithoutExtension + ".", StringComparison.OrdinalIgnoreCase)) + var fullName = files[i]; + var extension = Path.GetExtension(fullName.AsSpan()); + if (!IsSubtitleExtension(extension)) { continue; } - var codec = Path.GetExtension(fullName).ToLowerInvariant().TrimStart('.'); + var fileNameWithoutExtension = NormalizeFilenameForSubtitleComparison(fullName); - if (string.Equals(codec, "txt", StringComparison.OrdinalIgnoreCase)) - { - codec = "srt"; - } + MediaStream mediaStream; - // If the subtitle file matches the video file name - if (string.Equals(videoFileNameWithoutExtension, fileNameWithoutExtension, StringComparison.OrdinalIgnoreCase)) + // The subtitle filename must either be equal to the video filename or start with the video filename followed by a dot + if (videoFileNameWithoutExtension.Equals(fileNameWithoutExtension, StringComparison.OrdinalIgnoreCase)) { - streams.Add(new MediaStream + mediaStream = new MediaStream { Index = startIndex++, Type = MediaStreamType.Subtitle, IsExternal = true, - Path = fullName, - Codec = codec - }); + Path = fullName + }; } - else if (fileNameWithoutExtension.StartsWith(videoFileNameWithoutExtension + ".", StringComparison.OrdinalIgnoreCase)) + else if (fileNameWithoutExtension.Length > videoFileNameWithoutExtension.Length + && fileNameWithoutExtension[videoFileNameWithoutExtension.Length] == '.' + && fileNameWithoutExtension.StartsWith(videoFileNameWithoutExtension, StringComparison.OrdinalIgnoreCase)) { - var isForced = fullName.IndexOf(".forced.", StringComparison.OrdinalIgnoreCase) != -1 || - fullName.IndexOf(".foreign.", StringComparison.OrdinalIgnoreCase) != -1; + var isForced = fullName.Contains(".forced.", StringComparison.OrdinalIgnoreCase) + || fullName.Contains(".foreign.", StringComparison.OrdinalIgnoreCase); - var isDefault = fullName.IndexOf(".default.", StringComparison.OrdinalIgnoreCase) != -1; + var isDefault = fullName.Contains(".default.", StringComparison.OrdinalIgnoreCase); // Support xbmc naming conventions - 300.spanish.srt - var language = fileNameWithoutExtension - .Replace(".forced", string.Empty, StringComparison.OrdinalIgnoreCase) - .Replace(".foreign", string.Empty, StringComparison.OrdinalIgnoreCase) - .Replace(".default", string.Empty, StringComparison.OrdinalIgnoreCase) - .Split('.') - .LastOrDefault(); + var languageSpan = fileNameWithoutExtension; + while (languageSpan.Length > 0) + { + var lastDot = languageSpan.LastIndexOf('.'); + var currentSlice = languageSpan[lastDot..]; + if (currentSlice.Equals(".default", StringComparison.OrdinalIgnoreCase) + || currentSlice.Equals(".forced", StringComparison.OrdinalIgnoreCase) + || currentSlice.Equals(".foreign", StringComparison.OrdinalIgnoreCase)) + { + languageSpan = languageSpan[..lastDot]; + continue; + } + + languageSpan = languageSpan[(lastDot + 1)..]; + break; + } // Try to translate to three character code // Be flexible and check against both the full and three character versions + var language = languageSpan.ToString(); var culture = _localization.FindLanguageInfo(language); - if (culture != null) - { - language = culture.ThreeLetterISOLanguageName; - } + language = culture == null ? language : culture.ThreeLetterISOLanguageName; - streams.Add(new MediaStream + mediaStream = new MediaStream { Index = startIndex++, Type = MediaStreamType.Subtitle, IsExternal = true, Path = fullName, - Codec = codec, Language = language, IsForced = isForced, IsDefault = isDefault - }); + }; + } + else + { + continue; } + + mediaStream.Codec = extension.TrimStart('.').ToString().ToLowerInvariant(); + + streams.Add(mediaStream); } } - private string NormalizeFilenameForSubtitleComparison(string filename) + private static bool IsSubtitleExtension(ReadOnlySpan<char> extension) + { + return extension.Equals(".srt", StringComparison.OrdinalIgnoreCase) + || extension.Equals(".ssa", StringComparison.OrdinalIgnoreCase) + || extension.Equals(".ass", StringComparison.OrdinalIgnoreCase) + || extension.Equals(".sub", StringComparison.OrdinalIgnoreCase) + || extension.Equals(".vtt", StringComparison.OrdinalIgnoreCase) + || extension.Equals(".smi", StringComparison.OrdinalIgnoreCase) + || extension.Equals(".sami", StringComparison.OrdinalIgnoreCase); + } + + private static ReadOnlySpan<char> NormalizeFilenameForSubtitleComparison(string filename) { // Try to account for sloppy file naming filename = filename.Replace("_", string.Empty, StringComparison.Ordinal); filename = filename.Replace(" ", string.Empty, StringComparison.Ordinal); + return Path.GetFileNameWithoutExtension(filename.AsSpan()); + } - // can't normalize this due to languages such as pt-br - // filename = filename.Replace("-", string.Empty); - - // filename = filename.Replace(".", string.Empty); + private void AddExternalSubtitleStreams( + List<MediaStream> streams, + string folder, + string videoPath, + int startIndex, + IDirectoryService directoryService, + bool clearCache) + { + var files = directoryService.GetFilePaths(folder, clearCache, true); - return filename; + AddExternalSubtitleStreams(streams, videoPath, startIndex, files); } } } diff --git a/MediaBrowser.Providers/MediaInfo/VideoImageProvider.cs b/MediaBrowser.Providers/MediaInfo/VideoImageProvider.cs index fc38d3832a..30af6710ab 100644 --- a/MediaBrowser.Providers/MediaInfo/VideoImageProvider.cs +++ b/MediaBrowser.Providers/MediaInfo/VideoImageProvider.cs @@ -9,6 +9,7 @@ using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.MediaEncoding; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Drawing; +using MediaBrowser.Model.Dto; using MediaBrowser.Model.Entities; using MediaBrowser.Model.IO; using MediaBrowser.Model.MediaInfo; @@ -50,7 +51,7 @@ namespace MediaBrowser.Providers.MediaInfo } // No support for this - if (video.VideoType == VideoType.Iso || video.VideoType == VideoType.Dvd || video.VideoType == VideoType.BluRay) + if (video.VideoType == VideoType.Dvd) { return Task.FromResult(new DynamicImageResponse { HasImage = false }); } @@ -69,11 +70,7 @@ namespace MediaBrowser.Providers.MediaInfo { var protocol = item.PathProtocol ?? MediaProtocol.File; - var inputPath = MediaEncoderHelpers.GetInputArgument( - _fileSystem, - item.Path, - null, - item.GetPlayableStreamFileNames()); + var inputPath = item.Path; var mediaStreams = item.GetMediaStreams(); @@ -107,7 +104,14 @@ namespace MediaBrowser.Providers.MediaInfo } } - extractedImagePath = await _mediaEncoder.ExtractVideoImage(inputPath, item.Container, protocol, imageStream, videoIndex, cancellationToken).ConfigureAwait(false); + MediaSourceInfo mediaSource = new MediaSourceInfo + { + VideoType = item.VideoType, + IsoType = item.IsoType, + Protocol = item.PathProtocol.Value, + }; + + extractedImagePath = await _mediaEncoder.ExtractVideoImage(inputPath, item.Container, mediaSource, imageStream, videoIndex, cancellationToken).ConfigureAwait(false); } else { @@ -119,8 +123,14 @@ namespace MediaBrowser.Providers.MediaInfo : TimeSpan.FromSeconds(10); var videoStream = mediaStreams.FirstOrDefault(i => i.Type == MediaStreamType.Video); + var mediaSource = new MediaSourceInfo + { + VideoType = item.VideoType, + IsoType = item.IsoType, + Protocol = item.PathProtocol.Value, + }; - extractedImagePath = await _mediaEncoder.ExtractVideoImage(inputPath, item.Container, protocol, videoStream, item.Video3DFormat, imageOffset, cancellationToken).ConfigureAwait(false); + extractedImagePath = await _mediaEncoder.ExtractVideoImage(inputPath, item.Container, mediaSource, videoStream, item.Video3DFormat, imageOffset, cancellationToken).ConfigureAwait(false); } return new DynamicImageResponse @@ -144,9 +154,7 @@ namespace MediaBrowser.Providers.MediaInfo return false; } - var video = item as Video; - - if (video != null && !video.IsPlaceHolder && video.IsCompleteMedia) + if (item is Video video && !video.IsPlaceHolder && video.IsCompleteMedia) { return true; } diff --git a/MediaBrowser.Providers/Plugins/AudioDb/AlbumImageProvider.cs b/MediaBrowser.Providers/Plugins/AudioDb/AudioDbAlbumImageProvider.cs similarity index 86% rename from MediaBrowser.Providers/Plugins/AudioDb/AlbumImageProvider.cs rename to MediaBrowser.Providers/Plugins/AudioDb/AudioDbAlbumImageProvider.cs index e3a1decb88..36d8eeb401 100644 --- a/MediaBrowser.Providers/Plugins/AudioDb/AlbumImageProvider.cs +++ b/MediaBrowser.Providers/Plugins/AudioDb/AudioDbAlbumImageProvider.cs @@ -1,9 +1,12 @@ #pragma warning disable CS1591 using System.Collections.Generic; +using System.IO; using System.Net.Http; +using System.Text.Json; using System.Threading; using System.Threading.Tasks; +using Jellyfin.Extensions.Json; using MediaBrowser.Common.Net; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Entities; @@ -11,7 +14,6 @@ using MediaBrowser.Controller.Entities.Audio; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Entities; using MediaBrowser.Model.Providers; -using MediaBrowser.Model.Serialization; namespace MediaBrowser.Providers.Plugins.AudioDb { @@ -19,13 +21,12 @@ namespace MediaBrowser.Providers.Plugins.AudioDb { private readonly IServerConfigurationManager _config; private readonly IHttpClientFactory _httpClientFactory; - private readonly IJsonSerializer _json; + private readonly JsonSerializerOptions _jsonOptions = JsonDefaults.Options; - public AudioDbAlbumImageProvider(IServerConfigurationManager config, IHttpClientFactory httpClientFactory, IJsonSerializer json) + public AudioDbAlbumImageProvider(IServerConfigurationManager config, IHttpClientFactory httpClientFactory) { _config = config; _httpClientFactory = httpClientFactory; - _json = json; } /// <inheritdoc /> @@ -56,7 +57,8 @@ namespace MediaBrowser.Providers.Plugins.AudioDb var path = AudioDbAlbumProvider.GetAlbumInfoPath(_config.ApplicationPaths, id); - var obj = _json.DeserializeFromFile<AudioDbAlbumProvider.RootObject>(path); + await using FileStream jsonStream = File.OpenRead(path); + var obj = await JsonSerializer.DeserializeAsync<AudioDbAlbumProvider.RootObject>(jsonStream, _jsonOptions, cancellationToken).ConfigureAwait(false); if (obj != null && obj.album != null && obj.album.Count > 0) { @@ -103,6 +105,6 @@ namespace MediaBrowser.Providers.Plugins.AudioDb /// <inheritdoc /> public bool Supports(BaseItem item) - => Plugin.Instance.Configuration.Enable && item is MusicAlbum; + => item is MusicAlbum; } } diff --git a/MediaBrowser.Providers/Plugins/AudioDb/AlbumProvider.cs b/MediaBrowser.Providers/Plugins/AudioDb/AudioDbAlbumProvider.cs similarity index 92% rename from MediaBrowser.Providers/Plugins/AudioDb/AlbumProvider.cs rename to MediaBrowser.Providers/Plugins/AudioDb/AudioDbAlbumProvider.cs index e6d89e6880..9f2f7fc11e 100644 --- a/MediaBrowser.Providers/Plugins/AudioDb/AlbumProvider.cs +++ b/MediaBrowser.Providers/Plugins/AudioDb/AudioDbAlbumProvider.cs @@ -1,4 +1,4 @@ -#pragma warning disable CS1591 +#pragma warning disable CA1002, CS1591, SA1300 using System; using System.Collections.Generic; @@ -6,8 +6,10 @@ using System.Globalization; using System.IO; using System.Linq; using System.Net.Http; +using System.Text.Json; using System.Threading; using System.Threading.Tasks; +using Jellyfin.Extensions.Json; using MediaBrowser.Common.Configuration; using MediaBrowser.Common.Extensions; using MediaBrowser.Common.Net; @@ -17,7 +19,6 @@ using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Entities; using MediaBrowser.Model.IO; using MediaBrowser.Model.Providers; -using MediaBrowser.Model.Serialization; using MediaBrowser.Providers.Music; namespace MediaBrowser.Providers.Plugins.AudioDb @@ -27,16 +28,17 @@ namespace MediaBrowser.Providers.Plugins.AudioDb private readonly IServerConfigurationManager _config; private readonly IFileSystem _fileSystem; private readonly IHttpClientFactory _httpClientFactory; - private readonly IJsonSerializer _json; + private readonly JsonSerializerOptions _jsonOptions = JsonDefaults.Options; +#pragma warning disable SA1401, CA2211 public static AudioDbAlbumProvider Current; +#pragma warning restore SA1401, CA2211 - public AudioDbAlbumProvider(IServerConfigurationManager config, IFileSystem fileSystem, IHttpClientFactory httpClientFactory, IJsonSerializer json) + public AudioDbAlbumProvider(IServerConfigurationManager config, IFileSystem fileSystem, IHttpClientFactory httpClientFactory) { _config = config; _fileSystem = fileSystem; _httpClientFactory = httpClientFactory; - _json = json; Current = this; } @@ -56,13 +58,6 @@ namespace MediaBrowser.Providers.Plugins.AudioDb public async Task<MetadataResult<MusicAlbum>> GetMetadata(AlbumInfo info, CancellationToken cancellationToken) { var result = new MetadataResult<MusicAlbum>(); - - // TODO maybe remove when artist metadata can be disabled - if (!Plugin.Instance.Configuration.Enable) - { - return result; - } - var id = info.GetReleaseGroupId(); if (!string.IsNullOrWhiteSpace(id)) @@ -71,7 +66,8 @@ namespace MediaBrowser.Providers.Plugins.AudioDb var path = GetAlbumInfoPath(_config.ApplicationPaths, id); - var obj = _json.DeserializeFromFile<RootObject>(path); + await using FileStream jsonStream = File.OpenRead(path); + var obj = await JsonSerializer.DeserializeAsync<RootObject>(jsonStream, _jsonOptions, cancellationToken).ConfigureAwait(false); if (obj != null && obj.album != null && obj.album.Count > 0) { @@ -175,8 +171,9 @@ namespace MediaBrowser.Providers.Plugins.AudioDb Directory.CreateDirectory(Path.GetDirectoryName(path)); using var response = await _httpClientFactory.CreateClient(NamedClient.Default).GetAsync(url, cancellationToken).ConfigureAwait(false); - await using var stream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false); - await using var xmlFileStream = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.Read, IODefaults.FileStreamBufferSize, true); + await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); + // use FileShare.None as this bypasses dotnet bug dotnet/runtime#42790 . + await using var xmlFileStream = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.None, IODefaults.FileStreamBufferSize, true); await stream.CopyToAsync(xmlFileStream, cancellationToken).ConfigureAwait(false); } @@ -201,6 +198,13 @@ namespace MediaBrowser.Providers.Plugins.AudioDb return Path.Combine(dataPath, "album.json"); } + /// <inheritdoc /> + public Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken cancellationToken) + { + throw new NotImplementedException(); + } + +#pragma warning disable CA1034, CA2227 public class Album { public string idAlbum { get; set; } @@ -284,11 +288,5 @@ namespace MediaBrowser.Providers.Plugins.AudioDb { public List<Album> album { get; set; } } - - /// <inheritdoc /> - public Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken cancellationToken) - { - throw new NotImplementedException(); - } } } diff --git a/MediaBrowser.Providers/Plugins/AudioDb/ArtistImageProvider.cs b/MediaBrowser.Providers/Plugins/AudioDb/AudioDbArtistImageProvider.cs similarity index 90% rename from MediaBrowser.Providers/Plugins/AudioDb/ArtistImageProvider.cs rename to MediaBrowser.Providers/Plugins/AudioDb/AudioDbArtistImageProvider.cs index 54851c4d07..aa61a56f66 100644 --- a/MediaBrowser.Providers/Plugins/AudioDb/ArtistImageProvider.cs +++ b/MediaBrowser.Providers/Plugins/AudioDb/AudioDbArtistImageProvider.cs @@ -1,9 +1,12 @@ #pragma warning disable CS1591 using System.Collections.Generic; +using System.IO; using System.Net.Http; +using System.Text.Json; using System.Threading; using System.Threading.Tasks; +using Jellyfin.Extensions.Json; using MediaBrowser.Common.Net; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Entities; @@ -11,7 +14,6 @@ using MediaBrowser.Controller.Entities.Audio; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Entities; using MediaBrowser.Model.Providers; -using MediaBrowser.Model.Serialization; namespace MediaBrowser.Providers.Plugins.AudioDb { @@ -19,12 +21,11 @@ namespace MediaBrowser.Providers.Plugins.AudioDb { private readonly IServerConfigurationManager _config; private readonly IHttpClientFactory _httpClientFactory; - private readonly IJsonSerializer _json; + private readonly JsonSerializerOptions _jsonOptions = JsonDefaults.Options; - public AudioDbArtistImageProvider(IServerConfigurationManager config, IJsonSerializer json, IHttpClientFactory httpClientFactory) + public AudioDbArtistImageProvider(IServerConfigurationManager config, IHttpClientFactory httpClientFactory) { _config = config; - _json = json; _httpClientFactory = httpClientFactory; } @@ -58,7 +59,8 @@ namespace MediaBrowser.Providers.Plugins.AudioDb var path = AudioDbArtistProvider.GetArtistInfoPath(_config.ApplicationPaths, id); - var obj = _json.DeserializeFromFile<AudioDbArtistProvider.RootObject>(path); + await using FileStream jsonStream = File.OpenRead(path); + var obj = await JsonSerializer.DeserializeAsync<AudioDbArtistProvider.RootObject>(jsonStream, _jsonOptions, cancellationToken).ConfigureAwait(false); if (obj != null && obj.artists != null && obj.artists.Count > 0) { @@ -144,6 +146,6 @@ namespace MediaBrowser.Providers.Plugins.AudioDb /// <inheritdoc /> public bool Supports(BaseItem item) - => Plugin.Instance.Configuration.Enable && item is MusicArtist; + => item is MusicArtist; } } diff --git a/MediaBrowser.Providers/Plugins/AudioDb/ArtistProvider.cs b/MediaBrowser.Providers/Plugins/AudioDb/AudioDbArtistProvider.cs similarity index 93% rename from MediaBrowser.Providers/Plugins/AudioDb/ArtistProvider.cs rename to MediaBrowser.Providers/Plugins/AudioDb/AudioDbArtistProvider.cs index 72dad8a25a..2857c6c13a 100644 --- a/MediaBrowser.Providers/Plugins/AudioDb/ArtistProvider.cs +++ b/MediaBrowser.Providers/Plugins/AudioDb/AudioDbArtistProvider.cs @@ -1,12 +1,14 @@ -#pragma warning disable CS1591 +#pragma warning disable CA1034, CS1591, CA1002, SA1028, SA1300 using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Net.Http; +using System.Text.Json; using System.Threading; using System.Threading.Tasks; +using Jellyfin.Extensions.Json; using MediaBrowser.Common.Configuration; using MediaBrowser.Common.Extensions; using MediaBrowser.Common.Net; @@ -16,7 +18,6 @@ using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Entities; using MediaBrowser.Model.IO; using MediaBrowser.Model.Providers; -using MediaBrowser.Model.Serialization; using MediaBrowser.Providers.Music; namespace MediaBrowser.Providers.Plugins.AudioDb @@ -29,14 +30,13 @@ namespace MediaBrowser.Providers.Plugins.AudioDb private readonly IServerConfigurationManager _config; private readonly IFileSystem _fileSystem; private readonly IHttpClientFactory _httpClientFactory; - private readonly IJsonSerializer _json; + private readonly JsonSerializerOptions _jsonOptions = JsonDefaults.Options; - public AudioDbArtistProvider(IServerConfigurationManager config, IFileSystem fileSystem, IHttpClientFactory httpClientFactory, IJsonSerializer json) + public AudioDbArtistProvider(IServerConfigurationManager config, IFileSystem fileSystem, IHttpClientFactory httpClientFactory) { _config = config; _fileSystem = fileSystem; _httpClientFactory = httpClientFactory; - _json = json; Current = this; } @@ -57,13 +57,6 @@ namespace MediaBrowser.Providers.Plugins.AudioDb public async Task<MetadataResult<MusicArtist>> GetMetadata(ArtistInfo info, CancellationToken cancellationToken) { var result = new MetadataResult<MusicArtist>(); - - // TODO maybe remove when artist metadata can be disabled - if (!Plugin.Instance.Configuration.Enable) - { - return result; - } - var id = info.GetMusicBrainzArtistId(); if (!string.IsNullOrWhiteSpace(id)) @@ -72,7 +65,8 @@ namespace MediaBrowser.Providers.Plugins.AudioDb var path = GetArtistInfoPath(_config.ApplicationPaths, id); - var obj = _json.DeserializeFromFile<RootObject>(path); + await using FileStream jsonStream = File.OpenRead(path); + var obj = await JsonSerializer.DeserializeAsync<RootObject>(jsonStream, _jsonOptions, cancellationToken).ConfigureAwait(false); if (obj != null && obj.artists != null && obj.artists.Count > 0) { @@ -156,11 +150,12 @@ namespace MediaBrowser.Providers.Plugins.AudioDb var path = GetArtistInfoPath(_config.ApplicationPaths, musicBrainzId); using var response = await _httpClientFactory.CreateClient(NamedClient.Default).GetAsync(url, cancellationToken).ConfigureAwait(false); - await using var stream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false); + await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); Directory.CreateDirectory(Path.GetDirectoryName(path)); - await using var xmlFileStream = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.Read, IODefaults.FileStreamBufferSize, true); + // use FileShare.None as this bypasses dotnet bug dotnet/runtime#42790 . + await using var xmlFileStream = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.None, IODefaults.FileStreamBufferSize, true); await stream.CopyToAsync(xmlFileStream, cancellationToken).ConfigureAwait(false); } @@ -188,6 +183,12 @@ namespace MediaBrowser.Providers.Plugins.AudioDb return Path.Combine(dataPath, "artist.json"); } + /// <inheritdoc /> + public Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken cancellationToken) + { + throw new NotImplementedException(); + } + public class Artist { public string idArtist { get; set; } @@ -273,15 +274,10 @@ namespace MediaBrowser.Providers.Plugins.AudioDb public string strLocked { get; set; } } +#pragma warning disable CA2227 public class RootObject { public List<Artist> artists { get; set; } } - - /// <inheritdoc /> - public Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken cancellationToken) - { - throw new NotImplementedException(); - } } } diff --git a/MediaBrowser.Providers/Plugins/AudioDb/Configuration/PluginConfiguration.cs b/MediaBrowser.Providers/Plugins/AudioDb/Configuration/PluginConfiguration.cs index 9657a290fc..664474dcdd 100644 --- a/MediaBrowser.Providers/Plugins/AudioDb/Configuration/PluginConfiguration.cs +++ b/MediaBrowser.Providers/Plugins/AudioDb/Configuration/PluginConfiguration.cs @@ -6,8 +6,6 @@ namespace MediaBrowser.Providers.Plugins.AudioDb { public class PluginConfiguration : BasePluginConfiguration { - public bool Enable { get; set; } - public bool ReplaceAlbumName { get; set; } } } diff --git a/MediaBrowser.Providers/Plugins/AudioDb/Configuration/config.html b/MediaBrowser.Providers/Plugins/AudioDb/Configuration/config.html index 82f26a8f26..eab252005f 100644 --- a/MediaBrowser.Providers/Plugins/AudioDb/Configuration/config.html +++ b/MediaBrowser.Providers/Plugins/AudioDb/Configuration/config.html @@ -8,10 +8,6 @@ <div data-role="content"> <div class="content-primary"> <form class="configForm"> - <label class="checkboxContainer"> - <input is="emby-checkbox" type="checkbox" id="enable" /> - <span>Enable this provider for metadata searches on artists and albums.</span> - </label> <label class="checkboxContainer"> <input is="emby-checkbox" type="checkbox" id="replaceAlbumName" /> <span>When an album is found during a metadata search, replace the name with the value on the server.</span> @@ -32,9 +28,8 @@ .addEventListener('pageshow', function () { Dashboard.showLoadingMsg(); ApiClient.getPluginConfiguration(PluginConfig.pluginId).then(function (config) { - document.querySelector('#enable').checked = config.Enable; document.querySelector('#replaceAlbumName').checked = config.ReplaceAlbumName; - + Dashboard.hideLoadingMsg(); }); }); @@ -42,14 +37,13 @@ document.querySelector('.configForm') .addEventListener('submit', function (e) { Dashboard.showLoadingMsg(); - + ApiClient.getPluginConfiguration(PluginConfig.pluginId).then(function (config) { - config.Enable = document.querySelector('#enable').checked; config.ReplaceAlbumName = document.querySelector('#replaceAlbumName').checked; - + ApiClient.updatePluginConfiguration(PluginConfig.pluginId, config).then(Dashboard.processPluginConfigurationUpdateResult); }); - + e.preventDefault(); return false; }); diff --git a/MediaBrowser.Providers/Plugins/AudioDb/Plugin.cs b/MediaBrowser.Providers/Plugins/AudioDb/Plugin.cs index b5bd72ff0d..ba0d7b5697 100644 --- a/MediaBrowser.Providers/Plugins/AudioDb/Plugin.cs +++ b/MediaBrowser.Providers/Plugins/AudioDb/Plugin.cs @@ -1,4 +1,4 @@ -#pragma warning disable CS1591 +#pragma warning disable CS1591 using System; using System.Collections.Generic; diff --git a/MediaBrowser.Providers/Plugins/MusicBrainz/Configuration/PluginConfiguration.cs b/MediaBrowser.Providers/Plugins/MusicBrainz/Configuration/PluginConfiguration.cs index 980da9a010..0cec9e359c 100644 --- a/MediaBrowser.Providers/Plugins/MusicBrainz/Configuration/PluginConfiguration.cs +++ b/MediaBrowser.Providers/Plugins/MusicBrainz/Configuration/PluginConfiguration.cs @@ -43,8 +43,6 @@ namespace MediaBrowser.Providers.Plugins.MusicBrainz } } - public bool Enable { get; set; } - public bool ReplaceArtistName { get; set; } } } diff --git a/MediaBrowser.Providers/Plugins/MusicBrainz/Configuration/config.html b/MediaBrowser.Providers/Plugins/MusicBrainz/Configuration/config.html index 1945e6cb49..6f1296bb77 100644 --- a/MediaBrowser.Providers/Plugins/MusicBrainz/Configuration/config.html +++ b/MediaBrowser.Providers/Plugins/MusicBrainz/Configuration/config.html @@ -16,10 +16,6 @@ <input is="emby-input" type="number" id="rateLimit" pattern="[0-9]*" required min="0" max="10000" label="Rate Limit" /> <div class="fieldDescription">Span of time between requests in milliseconds. The official server is limited to one request every two seconds.</div> </div> - <label class="checkboxContainer"> - <input is="emby-checkbox" type="checkbox" id="enable" /> - <span>Enable this provider for metadata searches on artists and albums.</span> - </label> <label class="checkboxContainer"> <input is="emby-checkbox" type="checkbox" id="replaceArtistName" /> <span>When an artist is found during a metadata search, replace the artist name with the value on the server.</span> @@ -46,7 +42,7 @@ bubbles: true, cancelable: false })); - + var rateLimit = document.querySelector('#rateLimit'); rateLimit.value = config.RateLimit; rateLimit.dispatchEvent(new Event('change', { @@ -54,26 +50,24 @@ cancelable: false })); - document.querySelector('#enable').checked = config.Enable; document.querySelector('#replaceArtistName').checked = config.ReplaceArtistName; Dashboard.hideLoadingMsg(); }); }); - + document.querySelector('.musicBrainzConfigForm') .addEventListener('submit', function (e) { Dashboard.showLoadingMsg(); - + ApiClient.getPluginConfiguration(MusicBrainzPluginConfig.uniquePluginId).then(function (config) { config.Server = document.querySelector('#server').value; config.RateLimit = document.querySelector('#rateLimit').value; - config.Enable = document.querySelector('#enable').checked; config.ReplaceArtistName = document.querySelector('#replaceArtistName').checked; - + ApiClient.updatePluginConfiguration(MusicBrainzPluginConfig.uniquePluginId, config).then(Dashboard.processPluginConfigurationUpdateResult); }); - + e.preventDefault(); return false; }); diff --git a/MediaBrowser.Providers/Plugins/MusicBrainz/ExternalIds.cs b/MediaBrowser.Providers/Plugins/MusicBrainz/ExternalIds.cs deleted file mode 100644 index 5600c389c0..0000000000 --- a/MediaBrowser.Providers/Plugins/MusicBrainz/ExternalIds.cs +++ /dev/null @@ -1,119 +0,0 @@ -#pragma warning disable CS1591 - -using MediaBrowser.Controller.Entities.Audio; -using MediaBrowser.Controller.Providers; -using MediaBrowser.Model.Entities; -using MediaBrowser.Model.Providers; -using MediaBrowser.Providers.Plugins.MusicBrainz; - -namespace MediaBrowser.Providers.Music -{ - public class MusicBrainzReleaseGroupExternalId : IExternalId - { - /// <inheritdoc /> - public string ProviderName => "MusicBrainz"; - - /// <inheritdoc /> - public string Key => MetadataProvider.MusicBrainzReleaseGroup.ToString(); - - /// <inheritdoc /> - public ExternalIdMediaType? Type => ExternalIdMediaType.ReleaseGroup; - - /// <inheritdoc /> - public string UrlFormatString => Plugin.Instance.Configuration.Server + "/release-group/{0}"; - - /// <inheritdoc /> - public bool Supports(IHasProviderIds item) => item is Audio || item is MusicAlbum; - } - - public class MusicBrainzAlbumArtistExternalId : IExternalId - { - /// <inheritdoc /> - public string ProviderName => "MusicBrainz"; - - /// <inheritdoc /> - public string Key => MetadataProvider.MusicBrainzAlbumArtist.ToString(); - - /// <inheritdoc /> - public ExternalIdMediaType? Type => ExternalIdMediaType.AlbumArtist; - - /// <inheritdoc /> - public string UrlFormatString => Plugin.Instance.Configuration.Server + "/artist/{0}"; - - /// <inheritdoc /> - public bool Supports(IHasProviderIds item) => item is Audio; - } - - public class MusicBrainzAlbumExternalId : IExternalId - { - /// <inheritdoc /> - public string ProviderName => "MusicBrainz"; - - /// <inheritdoc /> - public string Key => MetadataProvider.MusicBrainzAlbum.ToString(); - - /// <inheritdoc /> - public ExternalIdMediaType? Type => ExternalIdMediaType.Album; - - /// <inheritdoc /> - public string UrlFormatString => Plugin.Instance.Configuration.Server + "/release/{0}"; - - /// <inheritdoc /> - public bool Supports(IHasProviderIds item) => item is Audio || item is MusicAlbum; - } - - public class MusicBrainzArtistExternalId : IExternalId - { - /// <inheritdoc /> - public string ProviderName => "MusicBrainz"; - - /// <inheritdoc /> - public string Key => MetadataProvider.MusicBrainzArtist.ToString(); - - /// <inheritdoc /> - public ExternalIdMediaType? Type => ExternalIdMediaType.Artist; - - /// <inheritdoc /> - public string UrlFormatString => Plugin.Instance.Configuration.Server + "/artist/{0}"; - - /// <inheritdoc /> - public bool Supports(IHasProviderIds item) => item is MusicArtist; - } - - public class MusicBrainzOtherArtistExternalId : IExternalId - { - /// <inheritdoc /> - public string ProviderName => "MusicBrainz"; - - /// <inheritdoc /> - - public string Key => MetadataProvider.MusicBrainzArtist.ToString(); - - /// <inheritdoc /> - public ExternalIdMediaType? Type => ExternalIdMediaType.OtherArtist; - - /// <inheritdoc /> - public string UrlFormatString => Plugin.Instance.Configuration.Server + "/artist/{0}"; - - /// <inheritdoc /> - public bool Supports(IHasProviderIds item) => item is Audio || item is MusicAlbum; - } - - public class MusicBrainzTrackId : IExternalId - { - /// <inheritdoc /> - public string ProviderName => "MusicBrainz"; - - /// <inheritdoc /> - public string Key => MetadataProvider.MusicBrainzTrack.ToString(); - - /// <inheritdoc /> - public ExternalIdMediaType? Type => ExternalIdMediaType.Track; - - /// <inheritdoc /> - public string UrlFormatString => Plugin.Instance.Configuration.Server + "/track/{0}"; - - /// <inheritdoc /> - public bool Supports(IHasProviderIds item) => item is Audio; - } -} diff --git a/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzAlbumArtistExternalId.cs b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzAlbumArtistExternalId.cs new file mode 100644 index 0000000000..1b37e2a60d --- /dev/null +++ b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzAlbumArtistExternalId.cs @@ -0,0 +1,28 @@ +#pragma warning disable CS1591 + +using MediaBrowser.Controller.Entities.Audio; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.Providers; +using MediaBrowser.Providers.Plugins.MusicBrainz; + +namespace MediaBrowser.Providers.Music +{ + public class MusicBrainzAlbumArtistExternalId : IExternalId + { + /// <inheritdoc /> + public string ProviderName => "MusicBrainz"; + + /// <inheritdoc /> + public string Key => MetadataProvider.MusicBrainzAlbumArtist.ToString(); + + /// <inheritdoc /> + public ExternalIdMediaType? Type => ExternalIdMediaType.AlbumArtist; + + /// <inheritdoc /> + public string UrlFormatString => Plugin.Instance.Configuration.Server + "/artist/{0}"; + + /// <inheritdoc /> + public bool Supports(IHasProviderIds item) => item is Audio; + } +} diff --git a/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzAlbumExternalId.cs b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzAlbumExternalId.cs new file mode 100644 index 0000000000..ef095111a8 --- /dev/null +++ b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzAlbumExternalId.cs @@ -0,0 +1,28 @@ +#pragma warning disable CS1591 + +using MediaBrowser.Controller.Entities.Audio; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.Providers; +using MediaBrowser.Providers.Plugins.MusicBrainz; + +namespace MediaBrowser.Providers.Music +{ + public class MusicBrainzAlbumExternalId : IExternalId + { + /// <inheritdoc /> + public string ProviderName => "MusicBrainz"; + + /// <inheritdoc /> + public string Key => MetadataProvider.MusicBrainzAlbum.ToString(); + + /// <inheritdoc /> + public ExternalIdMediaType? Type => ExternalIdMediaType.Album; + + /// <inheritdoc /> + public string UrlFormatString => Plugin.Instance.Configuration.Server + "/release/{0}"; + + /// <inheritdoc /> + public bool Supports(IHasProviderIds item) => item is Audio || item is MusicAlbum; + } +} diff --git a/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzAlbumProvider.cs b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzAlbumProvider.cs index 31f0123dcf..c97affdbf2 100644 --- a/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzAlbumProvider.cs +++ b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzAlbumProvider.cs @@ -1,4 +1,4 @@ -#pragma warning disable CS1591 +#pragma warning disable CS1591, SA1401 using System; using System.Collections.Generic; @@ -23,7 +23,7 @@ using Microsoft.Extensions.Logging; namespace MediaBrowser.Providers.Music { - public class MusicBrainzAlbumProvider : IRemoteMetadataProvider<MusicAlbum, AlbumInfo>, IHasOrder + public class MusicBrainzAlbumProvider : IRemoteMetadataProvider<MusicAlbum, AlbumInfo>, IHasOrder, IDisposable { /// <summary> /// For each single MB lookup/search, this is the maximum number of @@ -36,12 +36,11 @@ namespace MediaBrowser.Providers.Music /// The Jellyfin user-agent is unrestricted but source IP must not exceed /// one request per second, therefore we rate limit to avoid throttling. /// Be prudent, use a value slightly above the minimun required. - /// https://musicbrainz.org/doc/XML_Web_Service/Rate_Limiting + /// https://musicbrainz.org/doc/XML_Web_Service/Rate_Limiting. /// </summary> private readonly long _musicBrainzQueryIntervalMs; private readonly IHttpClientFactory _httpClientFactory; - private readonly IApplicationHost _appHost; private readonly ILogger<MusicBrainzAlbumProvider> _logger; private readonly string _musicBrainzBaseUrl; @@ -51,11 +50,9 @@ namespace MediaBrowser.Providers.Music public MusicBrainzAlbumProvider( IHttpClientFactory httpClientFactory, - IApplicationHost appHost, ILogger<MusicBrainzAlbumProvider> logger) { _httpClientFactory = httpClientFactory; - _appHost = appHost; _logger = logger; _musicBrainzBaseUrl = Plugin.Instance.Configuration.Server; @@ -78,12 +75,6 @@ namespace MediaBrowser.Providers.Music /// <inheritdoc /> public async Task<IEnumerable<RemoteSearchResult>> GetSearchResults(AlbumInfo searchInfo, CancellationToken cancellationToken) { - // TODO maybe remove when artist metadata can be disabled - if (!Plugin.Instance.Configuration.Enable) - { - return Enumerable.Empty<RemoteSearchResult>(); - } - var releaseId = searchInfo.GetReleaseId(); var releaseGroupId = searchInfo.GetReleaseGroupId(); @@ -125,7 +116,7 @@ namespace MediaBrowser.Providers.Music if (!string.IsNullOrWhiteSpace(url)) { using var response = await GetMusicBrainzResponse(url, cancellationToken).ConfigureAwait(false); - await using var stream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false); + await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); return GetResultsFromResponse(stream); } @@ -134,72 +125,62 @@ namespace MediaBrowser.Providers.Music private IEnumerable<RemoteSearchResult> GetResultsFromResponse(Stream stream) { - using (var oReader = new StreamReader(stream, Encoding.UTF8)) + using var oReader = new StreamReader(stream, Encoding.UTF8); + var settings = new XmlReaderSettings() + { + ValidationType = ValidationType.None, + CheckCharacters = false, + IgnoreProcessingInstructions = true, + IgnoreComments = true + }; + + using var reader = XmlReader.Create(oReader, settings); + var results = ReleaseResult.Parse(reader); + + return results.Select(i => { - var settings = new XmlReaderSettings() + var result = new RemoteSearchResult { - ValidationType = ValidationType.None, - CheckCharacters = false, - IgnoreProcessingInstructions = true, - IgnoreComments = true + Name = i.Title, + ProductionYear = i.Year }; - using (var reader = XmlReader.Create(oReader, settings)) + if (i.Artists.Count > 0) { - var results = ReleaseResult.Parse(reader); - - return results.Select(i => + result.AlbumArtist = new RemoteSearchResult { - var result = new RemoteSearchResult - { - Name = i.Title, - ProductionYear = i.Year - }; - - if (i.Artists.Count > 0) - { - result.AlbumArtist = new RemoteSearchResult - { - SearchProviderName = Name, - Name = i.Artists[0].Item1 - }; - - result.AlbumArtist.SetProviderId(MetadataProvider.MusicBrainzArtist, i.Artists[0].Item2); - } + SearchProviderName = Name, + Name = i.Artists[0].Item1 + }; - if (!string.IsNullOrWhiteSpace(i.ReleaseId)) - { - result.SetProviderId(MetadataProvider.MusicBrainzAlbum, i.ReleaseId); - } + result.AlbumArtist.SetProviderId(MetadataProvider.MusicBrainzArtist, i.Artists[0].Item2); + } - if (!string.IsNullOrWhiteSpace(i.ReleaseGroupId)) - { - result.SetProviderId(MetadataProvider.MusicBrainzReleaseGroup, i.ReleaseGroupId); - } + if (!string.IsNullOrWhiteSpace(i.ReleaseId)) + { + result.SetProviderId(MetadataProvider.MusicBrainzAlbum, i.ReleaseId); + } - return result; - }); + if (!string.IsNullOrWhiteSpace(i.ReleaseGroupId)) + { + result.SetProviderId(MetadataProvider.MusicBrainzReleaseGroup, i.ReleaseGroupId); } - } + + return result; + }); } /// <inheritdoc /> - public async Task<MetadataResult<MusicAlbum>> GetMetadata(AlbumInfo id, CancellationToken cancellationToken) + public async Task<MetadataResult<MusicAlbum>> GetMetadata(AlbumInfo info, CancellationToken cancellationToken) { - var releaseId = id.GetReleaseId(); - var releaseGroupId = id.GetReleaseGroupId(); + var releaseId = info.GetReleaseId(); + var releaseGroupId = info.GetReleaseGroupId(); var result = new MetadataResult<MusicAlbum> { Item = new MusicAlbum() }; - // TODO maybe remove when artist metadata can be disabled - if (!Plugin.Instance.Configuration.Enable) - { - return result; - } - // If we have a release group Id but not a release Id... if (string.IsNullOrWhiteSpace(releaseId) && !string.IsNullOrWhiteSpace(releaseGroupId)) { @@ -209,9 +190,9 @@ namespace MediaBrowser.Providers.Music if (string.IsNullOrWhiteSpace(releaseId)) { - var artistMusicBrainzId = id.GetMusicBrainzArtistId(); + var artistMusicBrainzId = info.GetMusicBrainzArtistId(); - var releaseResult = await GetReleaseResult(artistMusicBrainzId, id.GetAlbumArtist(), id.Name, cancellationToken).ConfigureAwait(false); + var releaseResult = await GetReleaseResult(artistMusicBrainzId, info.GetAlbumArtist(), info.Name, cancellationToken).ConfigureAwait(false); if (releaseResult != null) { @@ -284,7 +265,7 @@ namespace MediaBrowser.Providers.Music artistId); using var response = await GetMusicBrainzResponse(url, cancellationToken).ConfigureAwait(false); - await using var stream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false); + await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); using var oReader = new StreamReader(stream, Encoding.UTF8); var settings = new XmlReaderSettings { @@ -307,7 +288,7 @@ namespace MediaBrowser.Providers.Music WebUtility.UrlEncode(artistName)); using var response = await GetMusicBrainzResponse(url, cancellationToken).ConfigureAwait(false); - await using var stream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false); + await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); using var oReader = new StreamReader(stream, Encoding.UTF8); var settings = new XmlReaderSettings() { @@ -321,187 +302,6 @@ namespace MediaBrowser.Providers.Music return ReleaseResult.Parse(reader).FirstOrDefault(); } - private class ReleaseResult - { - public string ReleaseId; - public string ReleaseGroupId; - public string Title; - public string Overview; - public int? Year; - - public List<ValueTuple<string, string>> Artists = new List<ValueTuple<string, string>>(); - - public static IEnumerable<ReleaseResult> Parse(XmlReader reader) - { - reader.MoveToContent(); - reader.Read(); - - // Loop through each element - while (!reader.EOF && reader.ReadState == ReadState.Interactive) - { - if (reader.NodeType == XmlNodeType.Element) - { - switch (reader.Name) - { - case "release-list": - { - if (reader.IsEmptyElement) - { - reader.Read(); - continue; - } - - using (var subReader = reader.ReadSubtree()) - { - return ParseReleaseList(subReader).ToList(); - } - } - - default: - { - reader.Skip(); - break; - } - } - } - else - { - reader.Read(); - } - } - - return Enumerable.Empty<ReleaseResult>(); - } - - private static IEnumerable<ReleaseResult> ParseReleaseList(XmlReader reader) - { - reader.MoveToContent(); - reader.Read(); - - // Loop through each element - while (!reader.EOF && reader.ReadState == ReadState.Interactive) - { - if (reader.NodeType == XmlNodeType.Element) - { - switch (reader.Name) - { - case "release": - { - if (reader.IsEmptyElement) - { - reader.Read(); - continue; - } - - var releaseId = reader.GetAttribute("id"); - - using (var subReader = reader.ReadSubtree()) - { - var release = ParseRelease(subReader, releaseId); - if (release != null) - { - yield return release; - } - } - - break; - } - - default: - { - reader.Skip(); - break; - } - } - } - else - { - reader.Read(); - } - } - } - - private static ReleaseResult ParseRelease(XmlReader reader, string releaseId) - { - var result = new ReleaseResult - { - ReleaseId = releaseId - }; - - reader.MoveToContent(); - reader.Read(); - - // http://stackoverflow.com/questions/2299632/why-does-xmlreader-skip-every-other-element-if-there-is-no-whitespace-separator - - // Loop through each element - while (!reader.EOF && reader.ReadState == ReadState.Interactive) - { - if (reader.NodeType == XmlNodeType.Element) - { - switch (reader.Name) - { - case "title": - { - result.Title = reader.ReadElementContentAsString(); - break; - } - - case "date": - { - var val = reader.ReadElementContentAsString(); - if (DateTime.TryParse(val, out var date)) - { - result.Year = date.Year; - } - - break; - } - - case "annotation": - { - result.Overview = reader.ReadElementContentAsString(); - break; - } - - case "release-group": - { - result.ReleaseGroupId = reader.GetAttribute("id"); - reader.Skip(); - break; - } - - case "artist-credit": - { - using (var subReader = reader.ReadSubtree()) - { - var artist = ParseArtistCredit(subReader); - - if (!string.IsNullOrEmpty(artist.Item1)) - { - result.Artists.Add(artist); - } - } - - break; - } - - default: - { - reader.Skip(); - break; - } - } - } - else - { - reader.Read(); - } - } - - return result; - } - } - private static (string, string) ParseArtistCredit(XmlReader reader) { reader.MoveToContent(); @@ -517,18 +317,16 @@ namespace MediaBrowser.Providers.Music switch (reader.Name) { case "name-credit": - { - using (var subReader = reader.ReadSubtree()) - { - return ParseArtistNameCredit(subReader); - } - } + { + using var subReader = reader.ReadSubtree(); + return ParseArtistNameCredit(subReader); + } default: - { - reader.Skip(); - break; - } + { + reader.Skip(); + break; + } } } else @@ -557,10 +355,8 @@ namespace MediaBrowser.Providers.Music case "artist": { var id = reader.GetAttribute("id"); - using (var subReader = reader.ReadSubtree()) - { - return ParseArtistArtistCredit(subReader, id); - } + using var subReader = reader.ReadSubtree(); + return ParseArtistArtistCredit(subReader, id); } default: @@ -622,7 +418,7 @@ namespace MediaBrowser.Providers.Music var url = "/ws/2/release?release-group=" + releaseGroupId.ToString(CultureInfo.InvariantCulture); using var response = await GetMusicBrainzResponse(url, cancellationToken).ConfigureAwait(false); - await using var stream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false); + await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); using var oReader = new StreamReader(stream, Encoding.UTF8); var settings = new XmlReaderSettings { @@ -649,7 +445,7 @@ namespace MediaBrowser.Providers.Music var url = "/ws/2/release-group/?query=reid:" + releaseEntryId.ToString(CultureInfo.InvariantCulture); using var response = await GetMusicBrainzResponse(url, cancellationToken).ConfigureAwait(false); - await using var stream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false); + await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); using var oReader = new StreamReader(stream, Encoding.UTF8); var settings = new XmlReaderSettings { @@ -659,47 +455,43 @@ namespace MediaBrowser.Providers.Music IgnoreComments = true }; - using (var reader = XmlReader.Create(oReader, settings)) - { - reader.MoveToContent(); - reader.Read(); + using var reader = XmlReader.Create(oReader, settings); + reader.MoveToContent(); + reader.Read(); - // Loop through each element - while (!reader.EOF && reader.ReadState == ReadState.Interactive) + // Loop through each element + while (!reader.EOF && reader.ReadState == ReadState.Interactive) + { + if (reader.NodeType == XmlNodeType.Element) { - if (reader.NodeType == XmlNodeType.Element) + switch (reader.Name) { - switch (reader.Name) + case "release-group-list": { - case "release-group-list": + if (reader.IsEmptyElement) { - if (reader.IsEmptyElement) - { - reader.Read(); - continue; - } - - using (var subReader = reader.ReadSubtree()) - { - return GetFirstReleaseGroupId(subReader); - } + reader.Read(); + continue; } - default: - { - reader.Skip(); - break; - } + using var subReader = reader.ReadSubtree(); + return GetFirstReleaseGroupId(subReader); + } + + default: + { + reader.Skip(); + break; } - } - else - { - reader.Read(); } } - - return null; + else + { + reader.Read(); + } } + + return null; } private string GetFirstReleaseGroupId(XmlReader reader) @@ -741,6 +533,9 @@ namespace MediaBrowser.Providers.Music /// A number of retries shall be made in order to try and satisfy the request before /// giving up and returning null. /// </summary> + /// <param name="url">Address of MusicBrainz server.</param> + /// <param name="cancellationToken">CancellationToken to use for method.</param> + /// <returns>Returns response from MusicBrainz service.</returns> internal async Task<HttpResponseMessage> GetMusicBrainzResponse(string url, CancellationToken cancellationToken) { await _apiRequestLock.WaitAsync(cancellationToken).ConfigureAwait(false); @@ -768,16 +563,10 @@ namespace MediaBrowser.Providers.Music _stopWatchMusicBrainz.Restart(); using var request = new HttpRequestMessage(HttpMethod.Get, requestUrl); - - // MusicBrainz request a contact email address is supplied, as comment, in user agent field: - // https://musicbrainz.org/doc/XML_Web_Service/Rate_Limiting#User-Agent . - request.Headers.UserAgent.ParseAdd(string.Format( - CultureInfo.InvariantCulture, - "{0} ( {1} )", - _appHost.ApplicationUserAgent, - _appHost.ApplicationUserAgentAddress)); - - response = await _httpClientFactory.CreateClient(NamedClient.Default).SendAsync(request).ConfigureAwait(false); + response = await _httpClientFactory + .CreateClient(NamedClient.MusicBrainz) + .SendAsync(request, cancellationToken) + .ConfigureAwait(false); // We retry a finite number of times, and only whilst MB is indicating 503 (throttling). } @@ -802,5 +591,195 @@ namespace MediaBrowser.Providers.Music { throw new NotImplementedException(); } + + protected virtual void Dispose(bool disposing) + { + if (disposing) + { + _apiRequestLock?.Dispose(); + } + } + + /// <inheritdoc /> + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + private class ReleaseResult + { + public string ReleaseId; + public string ReleaseGroupId; + public string Title; + public string Overview; + public int? Year; + + public List<ValueTuple<string, string>> Artists = new List<ValueTuple<string, string>>(); + + public static IEnumerable<ReleaseResult> Parse(XmlReader reader) + { + reader.MoveToContent(); + reader.Read(); + + // Loop through each element + while (!reader.EOF && reader.ReadState == ReadState.Interactive) + { + if (reader.NodeType == XmlNodeType.Element) + { + switch (reader.Name) + { + case "release-list": + { + if (reader.IsEmptyElement) + { + reader.Read(); + continue; + } + + using var subReader = reader.ReadSubtree(); + return ParseReleaseList(subReader).ToList(); + } + + default: + { + reader.Skip(); + break; + } + } + } + else + { + reader.Read(); + } + } + + return Enumerable.Empty<ReleaseResult>(); + } + + private static IEnumerable<ReleaseResult> ParseReleaseList(XmlReader reader) + { + reader.MoveToContent(); + reader.Read(); + + // Loop through each element + while (!reader.EOF && reader.ReadState == ReadState.Interactive) + { + if (reader.NodeType == XmlNodeType.Element) + { + switch (reader.Name) + { + case "release": + { + if (reader.IsEmptyElement) + { + reader.Read(); + continue; + } + + var releaseId = reader.GetAttribute("id"); + + using var subReader = reader.ReadSubtree(); + var release = ParseRelease(subReader, releaseId); + if (release != null) + { + yield return release; + } + + break; + } + + default: + { + reader.Skip(); + break; + } + } + } + else + { + reader.Read(); + } + } + } + + private static ReleaseResult ParseRelease(XmlReader reader, string releaseId) + { + var result = new ReleaseResult + { + ReleaseId = releaseId + }; + + reader.MoveToContent(); + reader.Read(); + + // http://stackoverflow.com/questions/2299632/why-does-xmlreader-skip-every-other-element-if-there-is-no-whitespace-separator + + // Loop through each element + while (!reader.EOF && reader.ReadState == ReadState.Interactive) + { + if (reader.NodeType == XmlNodeType.Element) + { + switch (reader.Name) + { + case "title": + { + result.Title = reader.ReadElementContentAsString(); + break; + } + + case "date": + { + var val = reader.ReadElementContentAsString(); + if (DateTime.TryParse(val, out var date)) + { + result.Year = date.Year; + } + + break; + } + + case "annotation": + { + result.Overview = reader.ReadElementContentAsString(); + break; + } + + case "release-group": + { + result.ReleaseGroupId = reader.GetAttribute("id"); + reader.Skip(); + break; + } + + case "artist-credit": + { + using var subReader = reader.ReadSubtree(); + var artist = ParseArtistCredit(subReader); + + if (!string.IsNullOrEmpty(artist.Item1)) + { + result.Artists.Add(artist); + } + + break; + } + + default: + { + reader.Skip(); + break; + } + } + } + else + { + reader.Read(); + } + } + + return result; + } + } } } diff --git a/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzArtistExternalId.cs b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzArtistExternalId.cs new file mode 100644 index 0000000000..d654e1372f --- /dev/null +++ b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzArtistExternalId.cs @@ -0,0 +1,28 @@ +#pragma warning disable CS1591 + +using MediaBrowser.Controller.Entities.Audio; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.Providers; +using MediaBrowser.Providers.Plugins.MusicBrainz; + +namespace MediaBrowser.Providers.Music +{ + public class MusicBrainzArtistExternalId : IExternalId + { + /// <inheritdoc /> + public string ProviderName => "MusicBrainz"; + + /// <inheritdoc /> + public string Key => MetadataProvider.MusicBrainzArtist.ToString(); + + /// <inheritdoc /> + public ExternalIdMediaType? Type => ExternalIdMediaType.Artist; + + /// <inheritdoc /> + public string UrlFormatString => Plugin.Instance.Configuration.Server + "/artist/{0}"; + + /// <inheritdoc /> + public bool Supports(IHasProviderIds item) => item is MusicArtist; + } +} diff --git a/MediaBrowser.Providers/Plugins/MusicBrainz/ArtistProvider.cs b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzArtistProvider.cs similarity index 69% rename from MediaBrowser.Providers/Plugins/MusicBrainz/ArtistProvider.cs rename to MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzArtistProvider.cs index f27da7ce61..7cff5f5952 100644 --- a/MediaBrowser.Providers/Plugins/MusicBrainz/ArtistProvider.cs +++ b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzArtistProvider.cs @@ -11,8 +11,8 @@ using System.Text; using System.Threading; using System.Threading.Tasks; using System.Xml; +using Diacritics.Extensions; using MediaBrowser.Controller.Entities.Audio; -using MediaBrowser.Controller.Extensions; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Entities; using MediaBrowser.Model.Providers; @@ -22,15 +22,11 @@ namespace MediaBrowser.Providers.Music { public class MusicBrainzArtistProvider : IRemoteMetadataProvider<MusicArtist, ArtistInfo> { + public string Name => "MusicBrainz"; + /// <inheritdoc /> public async Task<IEnumerable<RemoteSearchResult>> GetSearchResults(ArtistInfo searchInfo, CancellationToken cancellationToken) { - // TODO maybe remove when artist metadata can be disabled - if (!Plugin.Instance.Configuration.Enable) - { - return Enumerable.Empty<RemoteSearchResult>(); - } - var musicBrainzId = searchInfo.GetMusicBrainzArtistId(); if (!string.IsNullOrWhiteSpace(musicBrainzId)) @@ -38,7 +34,7 @@ namespace MediaBrowser.Providers.Music var url = "/ws/2/artist/?query=arid:{0}" + musicBrainzId.ToString(CultureInfo.InvariantCulture); using var response = await MusicBrainzAlbumProvider.Current.GetMusicBrainzResponse(url, cancellationToken).ConfigureAwait(false); - await using var stream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false); + await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); return GetResultsFromResponse(stream); } else @@ -49,7 +45,7 @@ namespace MediaBrowser.Providers.Music var url = string.Format(CultureInfo.InvariantCulture, "/ws/2/artist/?query=\"{0}\"&dismax=true", UrlEncode(nameToSearch)); using (var response = await MusicBrainzAlbumProvider.Current.GetMusicBrainzResponse(url, cancellationToken).ConfigureAwait(false)) - await using (var stream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false)) + await using (var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false)) { var results = GetResultsFromResponse(stream).ToList(); @@ -59,13 +55,13 @@ namespace MediaBrowser.Providers.Music } } - if (HasDiacritics(searchInfo.Name)) + if (searchInfo.Name.HasDiacritics()) { // Try again using the search with accent characters url url = string.Format(CultureInfo.InvariantCulture, "/ws/2/artist/?query=artistaccent:\"{0}\"", UrlEncode(nameToSearch)); using var response = await MusicBrainzAlbumProvider.Current.GetMusicBrainzResponse(url, cancellationToken).ConfigureAwait(false); - await using var stream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false); + await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); return GetResultsFromResponse(stream); } } @@ -75,58 +71,52 @@ namespace MediaBrowser.Providers.Music private IEnumerable<RemoteSearchResult> GetResultsFromResponse(Stream stream) { - using (var oReader = new StreamReader(stream, Encoding.UTF8)) + using var oReader = new StreamReader(stream, Encoding.UTF8); + var settings = new XmlReaderSettings() { - var settings = new XmlReaderSettings() - { - ValidationType = ValidationType.None, - CheckCharacters = false, - IgnoreProcessingInstructions = true, - IgnoreComments = true - }; + ValidationType = ValidationType.None, + CheckCharacters = false, + IgnoreProcessingInstructions = true, + IgnoreComments = true + }; - using (var reader = XmlReader.Create(oReader, settings)) - { - reader.MoveToContent(); - reader.Read(); + using var reader = XmlReader.Create(oReader, settings); + reader.MoveToContent(); + reader.Read(); - // Loop through each element - while (!reader.EOF && reader.ReadState == ReadState.Interactive) + // Loop through each element + while (!reader.EOF && reader.ReadState == ReadState.Interactive) + { + if (reader.NodeType == XmlNodeType.Element) + { + switch (reader.Name) { - if (reader.NodeType == XmlNodeType.Element) + case "artist-list": { - switch (reader.Name) + if (reader.IsEmptyElement) { - case "artist-list": - { - if (reader.IsEmptyElement) - { - reader.Read(); - continue; - } - - using (var subReader = reader.ReadSubtree()) - { - return ParseArtistList(subReader).ToList(); - } - } - - default: - { - reader.Skip(); - break; - } + reader.Read(); + continue; } + + using var subReader = reader.ReadSubtree(); + return ParseArtistList(subReader).ToList(); } - else + + default: { - reader.Read(); + reader.Skip(); + break; } } - - return Enumerable.Empty<RemoteSearchResult>(); + } + else + { + reader.Read(); } } + + return Enumerable.Empty<RemoteSearchResult>(); } private IEnumerable<RemoteSearchResult> ParseArtistList(XmlReader reader) @@ -151,13 +141,11 @@ namespace MediaBrowser.Providers.Music var mbzId = reader.GetAttribute("id"); - using (var subReader = reader.ReadSubtree()) + using var subReader = reader.ReadSubtree(); + var artist = ParseArtist(subReader, mbzId); + if (artist != null) { - var artist = ParseArtist(subReader, mbzId); - if (artist != null) - { - yield return artist; - } + yield return artist; } break; @@ -229,24 +217,19 @@ namespace MediaBrowser.Providers.Music return result; } - public async Task<MetadataResult<MusicArtist>> GetMetadata(ArtistInfo id, CancellationToken cancellationToken) + /// <inheritdoc /> + public async Task<MetadataResult<MusicArtist>> GetMetadata(ArtistInfo info, CancellationToken cancellationToken) { var result = new MetadataResult<MusicArtist> { Item = new MusicArtist() }; - // TODO maybe remove when artist metadata can be disabled - if (!Plugin.Instance.Configuration.Enable) - { - return result; - } - - var musicBrainzId = id.GetMusicBrainzArtistId(); + var musicBrainzId = info.GetMusicBrainzArtistId(); if (string.IsNullOrWhiteSpace(musicBrainzId)) { - var searchResults = await GetSearchResults(id, cancellationToken).ConfigureAwait(false); + var searchResults = await GetSearchResults(info, cancellationToken).ConfigureAwait(false); var singleResult = searchResults.FirstOrDefault(); @@ -271,16 +254,6 @@ namespace MediaBrowser.Providers.Music return result; } - /// <summary> - /// Determines whether the specified text has diacritics. - /// </summary> - /// <param name="text">The text.</param> - /// <returns><c>true</c> if the specified text has diacritics; otherwise, <c>false</c>.</returns> - private bool HasDiacritics(string text) - { - return !string.Equals(text, text.RemoveDiacritics(), StringComparison.Ordinal); - } - /// <summary> /// Encodes an URL. /// </summary> @@ -291,8 +264,6 @@ namespace MediaBrowser.Providers.Music return WebUtility.UrlEncode(name); } - public string Name => "MusicBrainz"; - public Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken cancellationToken) { throw new NotImplementedException(); diff --git a/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzOtherArtistExternalId.cs b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzOtherArtistExternalId.cs new file mode 100644 index 0000000000..f889a34b5c --- /dev/null +++ b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzOtherArtistExternalId.cs @@ -0,0 +1,28 @@ +#pragma warning disable CS1591 + +using MediaBrowser.Controller.Entities.Audio; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.Providers; +using MediaBrowser.Providers.Plugins.MusicBrainz; + +namespace MediaBrowser.Providers.Music +{ + public class MusicBrainzOtherArtistExternalId : IExternalId + { + /// <inheritdoc /> + public string ProviderName => "MusicBrainz"; + + /// <inheritdoc /> + public string Key => MetadataProvider.MusicBrainzArtist.ToString(); + + /// <inheritdoc /> + public ExternalIdMediaType? Type => ExternalIdMediaType.OtherArtist; + + /// <inheritdoc /> + public string UrlFormatString => Plugin.Instance.Configuration.Server + "/artist/{0}"; + + /// <inheritdoc /> + public bool Supports(IHasProviderIds item) => item is Audio || item is MusicAlbum; + } +} diff --git a/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzReleaseGroupExternalId.cs b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzReleaseGroupExternalId.cs new file mode 100644 index 0000000000..53783d2c0c --- /dev/null +++ b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzReleaseGroupExternalId.cs @@ -0,0 +1,28 @@ +#pragma warning disable CS1591 + +using MediaBrowser.Controller.Entities.Audio; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.Providers; +using MediaBrowser.Providers.Plugins.MusicBrainz; + +namespace MediaBrowser.Providers.Music +{ + public class MusicBrainzReleaseGroupExternalId : IExternalId + { + /// <inheritdoc /> + public string ProviderName => "MusicBrainz"; + + /// <inheritdoc /> + public string Key => MetadataProvider.MusicBrainzReleaseGroup.ToString(); + + /// <inheritdoc /> + public ExternalIdMediaType? Type => ExternalIdMediaType.ReleaseGroup; + + /// <inheritdoc /> + public string UrlFormatString => Plugin.Instance.Configuration.Server + "/release-group/{0}"; + + /// <inheritdoc /> + public bool Supports(IHasProviderIds item) => item is Audio || item is MusicAlbum; + } +} diff --git a/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzTrackId.cs b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzTrackId.cs new file mode 100644 index 0000000000..627f8f098d --- /dev/null +++ b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzTrackId.cs @@ -0,0 +1,28 @@ +#pragma warning disable CS1591 + +using MediaBrowser.Controller.Entities.Audio; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.Providers; +using MediaBrowser.Providers.Plugins.MusicBrainz; + +namespace MediaBrowser.Providers.Music +{ + public class MusicBrainzTrackId : IExternalId + { + /// <inheritdoc /> + public string ProviderName => "MusicBrainz"; + + /// <inheritdoc /> + public string Key => MetadataProvider.MusicBrainzTrack.ToString(); + + /// <inheritdoc /> + public ExternalIdMediaType? Type => ExternalIdMediaType.Track; + + /// <inheritdoc /> + public string UrlFormatString => Plugin.Instance.Configuration.Server + "/track/{0}"; + + /// <inheritdoc /> + public bool Supports(IHasProviderIds item) => item is Audio; + } +} diff --git a/MediaBrowser.Providers/Plugins/MusicBrainz/Plugin.cs b/MediaBrowser.Providers/Plugins/MusicBrainz/Plugin.cs index 90266e4409..69b69be428 100644 --- a/MediaBrowser.Providers/Plugins/MusicBrainz/Plugin.cs +++ b/MediaBrowser.Providers/Plugins/MusicBrainz/Plugin.cs @@ -1,4 +1,4 @@ -#pragma warning disable CS1591 +#pragma warning disable CS1591 using System; using System.Collections.Generic; @@ -11,6 +11,16 @@ namespace MediaBrowser.Providers.Plugins.MusicBrainz { public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages { + public const string DefaultServer = "https://musicbrainz.org"; + + public const long DefaultRateLimit = 2000u; + + public Plugin(IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer) + : base(applicationPaths, xmlSerializer) + { + Instance = this; + } + public static Plugin Instance { get; private set; } public override Guid Id => new Guid("8c95c4d2-e50c-4fb0-a4f3-6c06ff0f9a1a"); @@ -19,19 +29,9 @@ namespace MediaBrowser.Providers.Plugins.MusicBrainz public override string Description => "Get artist and album metadata from any MusicBrainz server."; - public const string DefaultServer = "https://musicbrainz.org"; - - public const long DefaultRateLimit = 2000u; - // TODO remove when plugin removed from server. public override string ConfigurationFileName => "Jellyfin.Plugin.MusicBrainz.xml"; - public Plugin(IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer) - : base(applicationPaths, xmlSerializer) - { - Instance = this; - } - public IEnumerable<PluginPageInfo> GetPages() { yield return new PluginPageInfo diff --git a/MediaBrowser.Providers/Plugins/Omdb/JsonOmdbNotAvailableInt32Converter.cs b/MediaBrowser.Providers/Plugins/Omdb/JsonOmdbNotAvailableInt32Converter.cs new file mode 100644 index 0000000000..268538815e --- /dev/null +++ b/MediaBrowser.Providers/Plugins/Omdb/JsonOmdbNotAvailableInt32Converter.cs @@ -0,0 +1,46 @@ +#nullable enable + +using System; +using System.ComponentModel; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace MediaBrowser.Providers.Plugins.Omdb +{ + /// <summary> + /// Converts a string <c>N/A</c> to <c>string.Empty</c>. + /// </summary> + public class JsonOmdbNotAvailableInt32Converter : JsonConverter<int?> + { + /// <inheritdoc /> + public override int? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType == JsonTokenType.String) + { + var str = reader.GetString(); + if (str != null && str.Equals("N/A", StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + var converter = TypeDescriptor.GetConverter(typeToConvert); + return (int?)converter.ConvertFromString(str); + } + + return JsonSerializer.Deserialize<int>(ref reader, options); + } + + /// <inheritdoc /> + public override void Write(Utf8JsonWriter writer, int? value, JsonSerializerOptions options) + { + if (value.HasValue) + { + writer.WriteNumberValue(value.Value); + } + else + { + writer.WriteNullValue(); + } + } + } +} diff --git a/MediaBrowser.Providers/Plugins/Omdb/JsonOmdbNotAvailableStringConverter.cs b/MediaBrowser.Providers/Plugins/Omdb/JsonOmdbNotAvailableStringConverter.cs new file mode 100644 index 0000000000..c19589d45b --- /dev/null +++ b/MediaBrowser.Providers/Plugins/Omdb/JsonOmdbNotAvailableStringConverter.cs @@ -0,0 +1,43 @@ +#nullable enable + +using System; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace MediaBrowser.Providers.Plugins.Omdb +{ + /// <summary> + /// Converts a string <c>N/A</c> to <c>string.Empty</c>. + /// </summary> + public class JsonOmdbNotAvailableStringConverter : JsonConverter<string?> + { + /// <inheritdoc /> + public override string? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType == JsonTokenType.Null) + { + return null; + } + + if (reader.TokenType == JsonTokenType.String) + { + // GetString can't return null here because we already handled it above + var str = reader.GetString()!; + if (str.Equals("N/A", StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + return str; + } + + return JsonSerializer.Deserialize<string?>(ref reader, options); + } + + /// <inheritdoc /> + public override void Write(Utf8JsonWriter writer, string? value, JsonSerializerOptions options) + { + writer.WriteStringValue(value); + } + } +} diff --git a/MediaBrowser.Providers/Plugins/Omdb/OmdbEpisodeProvider.cs b/MediaBrowser.Providers/Plugins/Omdb/OmdbEpisodeProvider.cs index bfc840ea59..24ef80a35d 100644 --- a/MediaBrowser.Providers/Plugins/Omdb/OmdbEpisodeProvider.cs +++ b/MediaBrowser.Providers/Plugins/Omdb/OmdbEpisodeProvider.cs @@ -12,13 +12,11 @@ using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Entities; using MediaBrowser.Model.IO; using MediaBrowser.Model.Providers; -using MediaBrowser.Model.Serialization; namespace MediaBrowser.Providers.Plugins.Omdb { public class OmdbEpisodeProvider : IRemoteMetadataProvider<Episode, EpisodeInfo>, IHasOrder { - private readonly IJsonSerializer _jsonSerializer; private readonly IHttpClientFactory _httpClientFactory; private readonly OmdbItemProvider _itemProvider; private readonly IFileSystem _fileSystem; @@ -26,19 +24,17 @@ namespace MediaBrowser.Providers.Plugins.Omdb private readonly IApplicationHost _appHost; public OmdbEpisodeProvider( - IJsonSerializer jsonSerializer, IApplicationHost appHost, IHttpClientFactory httpClientFactory, ILibraryManager libraryManager, IFileSystem fileSystem, IServerConfigurationManager configurationManager) { - _jsonSerializer = jsonSerializer; _httpClientFactory = httpClientFactory; _fileSystem = fileSystem; _configurationManager = configurationManager; _appHost = appHost; - _itemProvider = new OmdbItemProvider(jsonSerializer, _appHost, httpClientFactory, libraryManager, fileSystem, configurationManager); + _itemProvider = new OmdbItemProvider(_appHost, httpClientFactory, libraryManager, fileSystem, configurationManager); } // After TheTvDb @@ -69,7 +65,7 @@ namespace MediaBrowser.Providers.Plugins.Omdb { if (info.IndexNumber.HasValue && info.ParentIndexNumber.HasValue) { - result.HasMetadata = await new OmdbProvider(_jsonSerializer, _httpClientFactory, _fileSystem, _appHost, _configurationManager) + result.HasMetadata = await new OmdbProvider(_httpClientFactory, _fileSystem, _appHost, _configurationManager) .FetchEpisodeData(result, info.IndexNumber.Value, info.ParentIndexNumber.Value, info.GetProviderId(MetadataProvider.Imdb), seriesImdbId, info.MetadataLanguage, info.MetadataCountryCode, cancellationToken).ConfigureAwait(false); } } diff --git a/MediaBrowser.Providers/Plugins/Omdb/OmdbImageProvider.cs b/MediaBrowser.Providers/Plugins/Omdb/OmdbImageProvider.cs index 8f4240dc11..df67aff31c 100644 --- a/MediaBrowser.Providers/Plugins/Omdb/OmdbImageProvider.cs +++ b/MediaBrowser.Providers/Plugins/Omdb/OmdbImageProvider.cs @@ -1,8 +1,8 @@ #pragma warning disable CS1591 using System.Collections.Generic; -using System.Net.Http; using System.Globalization; +using System.Net.Http; using System.Threading; using System.Threading.Tasks; using MediaBrowser.Common; @@ -15,21 +15,18 @@ using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Entities; using MediaBrowser.Model.IO; using MediaBrowser.Model.Providers; -using MediaBrowser.Model.Serialization; namespace MediaBrowser.Providers.Plugins.Omdb { public class OmdbImageProvider : IRemoteImageProvider, IHasOrder { private readonly IHttpClientFactory _httpClientFactory; - private readonly IJsonSerializer _jsonSerializer; private readonly IFileSystem _fileSystem; private readonly IServerConfigurationManager _configurationManager; private readonly IApplicationHost _appHost; - public OmdbImageProvider(IJsonSerializer jsonSerializer, IApplicationHost appHost, IHttpClientFactory httpClientFactory, IFileSystem fileSystem, IServerConfigurationManager configurationManager) + public OmdbImageProvider(IApplicationHost appHost, IHttpClientFactory httpClientFactory, IFileSystem fileSystem, IServerConfigurationManager configurationManager) { - _jsonSerializer = jsonSerializer; _httpClientFactory = httpClientFactory; _fileSystem = fileSystem; _configurationManager = configurationManager; @@ -56,7 +53,7 @@ namespace MediaBrowser.Providers.Plugins.Omdb var list = new List<RemoteImageInfo>(); - var provider = new OmdbProvider(_jsonSerializer, _httpClientFactory, _fileSystem, _appHost, _configurationManager); + var provider = new OmdbProvider(_httpClientFactory, _fileSystem, _appHost, _configurationManager); if (!string.IsNullOrWhiteSpace(imdbId)) { diff --git a/MediaBrowser.Providers/Plugins/Omdb/OmdbItemProvider.cs b/MediaBrowser.Providers/Plugins/Omdb/OmdbItemProvider.cs index 705359d2c7..02e696de51 100644 --- a/MediaBrowser.Providers/Plugins/Omdb/OmdbItemProvider.cs +++ b/MediaBrowser.Providers/Plugins/Omdb/OmdbItemProvider.cs @@ -1,4 +1,4 @@ -#pragma warning disable CS1591 +#pragma warning disable CS1591, SA1300 using System; using System.Collections.Generic; @@ -6,8 +6,10 @@ using System.Globalization; using System.Linq; using System.Net; using System.Net.Http; +using System.Text.Json; using System.Threading; using System.Threading.Tasks; +using Jellyfin.Extensions.Json; using MediaBrowser.Common; using MediaBrowser.Common.Net; using MediaBrowser.Controller.Configuration; @@ -19,34 +21,35 @@ using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Entities; using MediaBrowser.Model.IO; using MediaBrowser.Model.Providers; -using MediaBrowser.Model.Serialization; namespace MediaBrowser.Providers.Plugins.Omdb { public class OmdbItemProvider : IRemoteMetadataProvider<Series, SeriesInfo>, IRemoteMetadataProvider<Movie, MovieInfo>, IRemoteMetadataProvider<Trailer, TrailerInfo>, IHasOrder { - private readonly IJsonSerializer _jsonSerializer; private readonly IHttpClientFactory _httpClientFactory; private readonly ILibraryManager _libraryManager; private readonly IFileSystem _fileSystem; private readonly IServerConfigurationManager _configurationManager; private readonly IApplicationHost _appHost; + private readonly JsonSerializerOptions _jsonOptions; public OmdbItemProvider( - IJsonSerializer jsonSerializer, IApplicationHost appHost, IHttpClientFactory httpClientFactory, ILibraryManager libraryManager, IFileSystem fileSystem, IServerConfigurationManager configurationManager) { - _jsonSerializer = jsonSerializer; _httpClientFactory = httpClientFactory; _libraryManager = libraryManager; _fileSystem = fileSystem; _configurationManager = configurationManager; _appHost = appHost; + + _jsonOptions = new JsonSerializerOptions(JsonDefaults.Options); + _jsonOptions.Converters.Add(new JsonOmdbNotAvailableStringConverter()); + _jsonOptions.Converters.Add(new JsonOmdbNotAvailableInt32Converter()); } public string Name => "The Open Movie Database"; @@ -133,12 +136,12 @@ namespace MediaBrowser.Providers.Plugins.Omdb var url = OmdbProvider.GetOmdbUrl(urlQuery); using var response = await OmdbProvider.GetOmdbResponse(_httpClientFactory.CreateClient(NamedClient.Default), url, cancellationToken).ConfigureAwait(false); - await using var stream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false); + await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); var resultList = new List<SearchResult>(); if (isSearch) { - var searchResultList = await _jsonSerializer.DeserializeFromStreamAsync<SearchResultList>(stream).ConfigureAwait(false); + var searchResultList = await JsonSerializer.DeserializeAsync<SearchResultList>(stream, _jsonOptions, cancellationToken).ConfigureAwait(false); if (searchResultList != null && searchResultList.Search != null) { resultList.AddRange(searchResultList.Search); @@ -146,7 +149,7 @@ namespace MediaBrowser.Providers.Plugins.Omdb } else { - var result = await _jsonSerializer.DeserializeFromStreamAsync<SearchResult>(stream).ConfigureAwait(false); + var result = await JsonSerializer.DeserializeAsync<SearchResult>(stream, _jsonOptions, cancellationToken).ConfigureAwait(false); if (string.Equals(result.Response, "true", StringComparison.OrdinalIgnoreCase)) { resultList.Add(result); @@ -221,7 +224,7 @@ namespace MediaBrowser.Providers.Plugins.Omdb result.Item.SetProviderId(MetadataProvider.Imdb, imdbId); result.HasMetadata = true; - await new OmdbProvider(_jsonSerializer, _httpClientFactory, _fileSystem, _appHost, _configurationManager).Fetch(result, imdbId, info.MetadataLanguage, info.MetadataCountryCode, cancellationToken).ConfigureAwait(false); + await new OmdbProvider(_httpClientFactory, _fileSystem, _appHost, _configurationManager).Fetch(result, imdbId, info.MetadataLanguage, info.MetadataCountryCode, cancellationToken).ConfigureAwait(false); } return result; @@ -253,7 +256,7 @@ namespace MediaBrowser.Providers.Plugins.Omdb result.Item.SetProviderId(MetadataProvider.Imdb, imdbId); result.HasMetadata = true; - await new OmdbProvider(_jsonSerializer, _httpClientFactory, _fileSystem, _appHost, _configurationManager).Fetch(result, imdbId, info.MetadataLanguage, info.MetadataCountryCode, cancellationToken).ConfigureAwait(false); + await new OmdbProvider(_httpClientFactory, _fileSystem, _appHost, _configurationManager).Fetch(result, imdbId, info.MetadataLanguage, info.MetadataCountryCode, cancellationToken).ConfigureAwait(false); } return result; diff --git a/MediaBrowser.Providers/Plugins/Omdb/OmdbProvider.cs b/MediaBrowser.Providers/Plugins/Omdb/OmdbProvider.cs index 32dab60a6b..1ae712e9e2 100644 --- a/MediaBrowser.Providers/Plugins/Omdb/OmdbProvider.cs +++ b/MediaBrowser.Providers/Plugins/Omdb/OmdbProvider.cs @@ -1,4 +1,4 @@ -#pragma warning disable CS1591 +#pragma warning disable CS159, SA1300 using System; using System.Collections.Generic; @@ -6,9 +6,10 @@ using System.Globalization; using System.IO; using System.Linq; using System.Net.Http; -using System.Text; +using System.Text.Json; using System.Threading; using System.Threading.Tasks; +using Jellyfin.Extensions.Json; using MediaBrowser.Common; using MediaBrowser.Common.Net; using MediaBrowser.Controller.Configuration; @@ -16,28 +17,44 @@ using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Entities; using MediaBrowser.Model.IO; -using MediaBrowser.Model.Serialization; namespace MediaBrowser.Providers.Plugins.Omdb { + /// <summary>Provider for OMDB service.</summary> public class OmdbProvider { - private readonly IJsonSerializer _jsonSerializer; private readonly IFileSystem _fileSystem; private readonly IServerConfigurationManager _configurationManager; private readonly IHttpClientFactory _httpClientFactory; private readonly CultureInfo _usCulture = new CultureInfo("en-US"); private readonly IApplicationHost _appHost; - - public OmdbProvider(IJsonSerializer jsonSerializer, IHttpClientFactory httpClientFactory, IFileSystem fileSystem, IApplicationHost appHost, IServerConfigurationManager configurationManager) + private readonly JsonSerializerOptions _jsonOptions; + + /// <summary>Initializes a new instance of the <see cref="OmdbProvider"/> class.</summary> + /// <param name="httpClientFactory">HttpClientFactory to use for calls to OMDB service.</param> + /// <param name="fileSystem">IFileSystem to use for store OMDB data.</param> + /// <param name="appHost">IApplicationHost to use.</param> + /// <param name="configurationManager">IServerConfigurationManager to use.</param> + public OmdbProvider(IHttpClientFactory httpClientFactory, IFileSystem fileSystem, IApplicationHost appHost, IServerConfigurationManager configurationManager) { - _jsonSerializer = jsonSerializer; _httpClientFactory = httpClientFactory; _fileSystem = fileSystem; _configurationManager = configurationManager; _appHost = appHost; + + _jsonOptions = new JsonSerializerOptions(JsonDefaults.Options); + _jsonOptions.Converters.Add(new JsonOmdbNotAvailableStringConverter()); + _jsonOptions.Converters.Add(new JsonOmdbNotAvailableInt32Converter()); } + /// <summary>Fetches data from OMDB service.</summary> + /// <param name="itemResult">Metadata about media item.</param> + /// <param name="imdbId">IMDB ID for media.</param> + /// <param name="language">Media language.</param> + /// <param name="country">Country of origin.</param> + /// <param name="cancellationToken">CancellationToken to use for operation.</param> + /// <typeparam name="T">The first generic type parameter.</typeparam> + /// <returns>Returns a Task object that can be awaited.</returns> public async Task Fetch<T>(MetadataResult<T> itemResult, string imdbId, string language, string country, CancellationToken cancellationToken) where T : BaseItem { @@ -50,7 +67,7 @@ namespace MediaBrowser.Providers.Plugins.Omdb var result = await GetRootObject(imdbId, cancellationToken).ConfigureAwait(false); - // Only take the name and rating if the user's language is set to english, since Omdb has no localization + // Only take the name and rating if the user's language is set to English, since Omdb has no localization if (string.Equals(language, "en", StringComparison.OrdinalIgnoreCase) || _configurationManager.Configuration.EnableNewOmdbSupport) { item.Name = result.Title; @@ -102,6 +119,17 @@ namespace MediaBrowser.Providers.Plugins.Omdb ParseAdditionalMetadata(itemResult, result); } + /// <summary>Gets data about an episode.</summary> + /// <param name="itemResult">Metadata about episode.</param> + /// <param name="episodeNumber">Episode number.</param> + /// <param name="seasonNumber">Season number.</param> + /// <param name="episodeImdbId">Episode ID.</param> + /// <param name="seriesImdbId">Season ID.</param> + /// <param name="language">Episode language.</param> + /// <param name="country">Country of origin.</param> + /// <param name="cancellationToken">CancellationToken to use for operation.</param> + /// <typeparam name="T">The first generic type parameter.</typeparam> + /// <returns>Whether operation was successful.</returns> public async Task<bool> FetchEpisodeData<T>(MetadataResult<T> itemResult, int episodeNumber, int seasonNumber, string episodeImdbId, string seriesImdbId, string language, string country, CancellationToken cancellationToken) where T : BaseItem { @@ -114,7 +142,7 @@ namespace MediaBrowser.Providers.Plugins.Omdb var seasonResult = await GetSeasonRootObject(seriesImdbId, seasonNumber, cancellationToken).ConfigureAwait(false); - if (seasonResult == null) + if (seasonResult?.Episodes == null) { return false; } @@ -151,7 +179,7 @@ namespace MediaBrowser.Providers.Plugins.Omdb return false; } - // Only take the name and rating if the user's language is set to english, since Omdb has no localization + // Only take the name and rating if the user's language is set to English, since Omdb has no localization if (string.Equals(language, "en", StringComparison.OrdinalIgnoreCase) || _configurationManager.Configuration.EnableNewOmdbSupport) { item.Name = result.Title; @@ -208,44 +236,20 @@ namespace MediaBrowser.Providers.Plugins.Omdb internal async Task<RootObject> GetRootObject(string imdbId, CancellationToken cancellationToken) { var path = await EnsureItemInfo(imdbId, cancellationToken).ConfigureAwait(false); - - string resultString; - - using (var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read)) - { - using (var reader = new StreamReader(stream, new UTF8Encoding(false))) - { - resultString = reader.ReadToEnd(); - resultString = resultString.Replace("\"N/A\"", "\"\""); - } - } - - var result = _jsonSerializer.DeserializeFromString<RootObject>(resultString); - return result; + await using var stream = File.OpenRead(path); + return await JsonSerializer.DeserializeAsync<RootObject>(stream, _jsonOptions, cancellationToken).ConfigureAwait(false); } internal async Task<SeasonRootObject> GetSeasonRootObject(string imdbId, int seasonId, CancellationToken cancellationToken) { var path = await EnsureSeasonInfo(imdbId, seasonId, cancellationToken).ConfigureAwait(false); - - string resultString; - - using (var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read)) - { - using (var reader = new StreamReader(stream, new UTF8Encoding(false))) - { - resultString = reader.ReadToEnd(); - resultString = resultString.Replace("\"N/A\"", "\"\""); - } - } - - var result = _jsonSerializer.DeserializeFromString<SeasonRootObject>(resultString); - return result; + await using var stream = File.OpenRead(path); + return await JsonSerializer.DeserializeAsync<SeasonRootObject>(stream, _jsonOptions, cancellationToken).ConfigureAwait(false); } internal static bool IsValidSeries(Dictionary<string, string> seriesProviderIds) { - if (seriesProviderIds.TryGetValue(MetadataProvider.Imdb.ToString(), out string id) && !string.IsNullOrEmpty(id)) + if (seriesProviderIds.TryGetValue(MetadataProvider.Imdb.ToString(), out string id)) { // This check should ideally never be necessary but we're seeing some cases of this and haven't tracked them down yet. if (!string.IsNullOrWhiteSpace(id)) @@ -257,6 +261,9 @@ namespace MediaBrowser.Providers.Plugins.Omdb return false; } + /// <summary>Gets OMDB URL.</summary> + /// <param name="query">Appends query string to URL.</param> + /// <returns>OMDB URL with optional query string.</returns> public static string GetOmdbUrl(string query) { const string Url = "https://www.omdbapi.com?apikey=2c9d9507"; @@ -290,6 +297,10 @@ namespace MediaBrowser.Providers.Plugins.Omdb return path; } } + else + { + Directory.CreateDirectory(Path.GetDirectoryName(path)); + } var url = GetOmdbUrl( string.Format( @@ -297,11 +308,9 @@ namespace MediaBrowser.Providers.Plugins.Omdb "i={0}&plot=short&tomatoes=true&r=json", imdbParam)); - using var response = await GetOmdbResponse(_httpClientFactory.CreateClient(NamedClient.Default), url, cancellationToken).ConfigureAwait(false); - await using var stream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false); - var rootObject = await _jsonSerializer.DeserializeFromStreamAsync<RootObject>(stream).ConfigureAwait(false); - Directory.CreateDirectory(Path.GetDirectoryName(path)); - _jsonSerializer.SerializeToFile(rootObject, path); + var rootObject = await GetDeserializedOmdbResponse<RootObject>(_httpClientFactory.CreateClient(NamedClient.Default), url, cancellationToken).ConfigureAwait(false); + await using FileStream jsonFileStream = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.None); + await JsonSerializer.SerializeAsync(jsonFileStream, rootObject, _jsonOptions, cancellationToken).ConfigureAwait(false); return path; } @@ -327,6 +336,10 @@ namespace MediaBrowser.Providers.Plugins.Omdb return path; } } + else + { + Directory.CreateDirectory(Path.GetDirectoryName(path)); + } var url = GetOmdbUrl( string.Format( @@ -335,15 +348,32 @@ namespace MediaBrowser.Providers.Plugins.Omdb imdbParam, seasonId)); - using var response = await GetOmdbResponse(_httpClientFactory.CreateClient(NamedClient.Default), url, cancellationToken).ConfigureAwait(false); - await using var stream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false); - var rootObject = await _jsonSerializer.DeserializeFromStreamAsync<SeasonRootObject>(stream).ConfigureAwait(false); - Directory.CreateDirectory(Path.GetDirectoryName(path)); - _jsonSerializer.SerializeToFile(rootObject, path); + var rootObject = await GetDeserializedOmdbResponse<SeasonRootObject>(_httpClientFactory.CreateClient(NamedClient.Default), url, cancellationToken).ConfigureAwait(false); + await using FileStream jsonFileStream = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.None); + await JsonSerializer.SerializeAsync(jsonFileStream, rootObject, _jsonOptions, cancellationToken).ConfigureAwait(false); return path; } + /// <summary>Gets response from OMDB service as type T.</summary> + /// <param name="httpClient">HttpClient instance to use for service call.</param> + /// <param name="url">Http URL to use for service call.</param> + /// <param name="cancellationToken">CancellationToken to use for service call.</param> + /// <typeparam name="T">The first generic type parameter.</typeparam> + /// <returns>OMDB service response as type T.</returns> + public async Task<T> GetDeserializedOmdbResponse<T>(HttpClient httpClient, string url, CancellationToken cancellationToken) + { + using var response = await GetOmdbResponse(httpClient, url, cancellationToken).ConfigureAwait(false); + await using Stream content = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); + + return await JsonSerializer.DeserializeAsync<T>(content, _jsonOptions, cancellationToken).ConfigureAwait(false); + } + + /// <summary>Gets response from OMDB service.</summary> + /// <param name="httpClient">HttpClient instance to use for service call.</param> + /// <param name="url">Http URL to use for service call.</param> + /// <param name="cancellationToken">CancellationToken to use for service call.</param> + /// <returns>OMDB service response as HttpResponseMessage.</returns> public static Task<HttpResponseMessage> GetOmdbResponse(HttpClient httpClient, string url, CancellationToken cancellationToken) { return httpClient.GetAsync(url, cancellationToken); @@ -385,13 +415,13 @@ namespace MediaBrowser.Providers.Plugins.Omdb var isConfiguredForEnglish = IsConfiguredForEnglish(item) || _configurationManager.Configuration.EnableNewOmdbSupport; // Grab series genres because IMDb data is better than TVDB. Leave movies alone - // But only do it if english is the preferred language because this data will not be localized + // But only do it if English is the preferred language because this data will not be localized if (isConfiguredForEnglish && !string.IsNullOrWhiteSpace(result.Genre)) { item.Genres = Array.Empty<string>(); foreach (var genre in result.Genre - .Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries) + .Split(',', StringSplitOptions.RemoveEmptyEntries) .Select(i => i.Trim()) .Where(i => !string.IsNullOrWhiteSpace(i))) { @@ -401,7 +431,7 @@ namespace MediaBrowser.Providers.Plugins.Omdb if (isConfiguredForEnglish) { - // Omdb is currently english only, so for other languages skip this and let secondary providers fill it in + // Omdb is currently English only, so for other languages skip this and let secondary providers fill it in item.Overview = result.Plot; } @@ -425,7 +455,7 @@ namespace MediaBrowser.Providers.Plugins.Omdb { var person = new PersonInfo { - Name = result.Director.Trim(), + Name = result.Writer.Trim(), Type = PersonType.Writer }; @@ -455,7 +485,7 @@ namespace MediaBrowser.Providers.Plugins.Omdb { var lang = item.GetPreferredMetadataLanguage(); - // The data isn't localized and so can only be used for english users + // The data isn't localized and so can only be used for English users return string.Equals(lang, "en", StringComparison.OrdinalIgnoreCase); } @@ -465,7 +495,7 @@ namespace MediaBrowser.Providers.Plugins.Omdb public string seriesID { get; set; } - public int Season { get; set; } + public int? Season { get; set; } public int? totalSeasons { get; set; } @@ -526,7 +556,7 @@ namespace MediaBrowser.Providers.Plugins.Omdb public string Response { get; set; } - public int Episode { get; set; } + public int? Episode { get; set; } public float? GetRottenTomatoScore() { @@ -547,10 +577,14 @@ namespace MediaBrowser.Providers.Plugins.Omdb } } +#pragma warning disable CA1034 + /// <summary>Describes OMDB rating.</summary> public class OmdbRating { + /// <summary>Gets or sets rating source.</summary> public string Source { get; set; } + /// <summary>Gets or sets rating value.</summary> public string Value { get; set; } } } diff --git a/MediaBrowser.Providers/Plugins/Omdb/Plugin.cs b/MediaBrowser.Providers/Plugins/Omdb/Plugin.cs index 41ca561643..047df4f33d 100644 --- a/MediaBrowser.Providers/Plugins/Omdb/Plugin.cs +++ b/MediaBrowser.Providers/Plugins/Omdb/Plugin.cs @@ -1,4 +1,4 @@ -#pragma warning disable CS1591 +#pragma warning disable CS1591 using System; using System.Collections.Generic; @@ -11,6 +11,12 @@ namespace MediaBrowser.Providers.Plugins.Omdb { public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages { + public Plugin(IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer) + : base(applicationPaths, xmlSerializer) + { + Instance = this; + } + public static Plugin Instance { get; private set; } public override Guid Id => new Guid("a628c0da-fac5-4c7e-9d1a-7134223f14c8"); @@ -22,12 +28,6 @@ namespace MediaBrowser.Providers.Plugins.Omdb // TODO remove when plugin removed from server. public override string ConfigurationFileName => "Jellyfin.Plugin.Omdb.xml"; - public Plugin(IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer) - : base(applicationPaths, xmlSerializer) - { - Instance = this; - } - public IEnumerable<PluginPageInfo> GetPages() { yield return new PluginPageInfo diff --git a/MediaBrowser.Providers/Plugins/TheTvdb/Configuration/PluginConfiguration.cs b/MediaBrowser.Providers/Plugins/TheTvdb/Configuration/PluginConfiguration.cs deleted file mode 100644 index 690a52c4d3..0000000000 --- a/MediaBrowser.Providers/Plugins/TheTvdb/Configuration/PluginConfiguration.cs +++ /dev/null @@ -1,10 +0,0 @@ -#pragma warning disable CS1591 - -using MediaBrowser.Model.Plugins; - -namespace MediaBrowser.Providers.Plugins.TheTvdb -{ - public class PluginConfiguration : BasePluginConfiguration - { - } -} diff --git a/MediaBrowser.Providers/Plugins/TheTvdb/Plugin.cs b/MediaBrowser.Providers/Plugins/TheTvdb/Plugin.cs deleted file mode 100644 index e7079ed3cd..0000000000 --- a/MediaBrowser.Providers/Plugins/TheTvdb/Plugin.cs +++ /dev/null @@ -1,29 +0,0 @@ -#pragma warning disable CS1591 - -using System; -using MediaBrowser.Common.Configuration; -using MediaBrowser.Common.Plugins; -using MediaBrowser.Model.Serialization; - -namespace MediaBrowser.Providers.Plugins.TheTvdb -{ - public class Plugin : BasePlugin<PluginConfiguration> - { - public static Plugin Instance { get; private set; } - - public override Guid Id => new Guid("a677c0da-fac5-4cde-941a-7134223f14c8"); - - public override string Name => "TheTVDB"; - - public override string Description => "Get metadata for movies and other video content from TheTVDB."; - - // TODO remove when plugin removed from server. - public override string ConfigurationFileName => "Jellyfin.Plugin.TheTvdb.xml"; - - public Plugin(IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer) - : base(applicationPaths, xmlSerializer) - { - Instance = this; - } - } -} diff --git a/MediaBrowser.Providers/Plugins/TheTvdb/TvdbClientManager.cs b/MediaBrowser.Providers/Plugins/TheTvdb/TvdbClientManager.cs deleted file mode 100644 index ce0dab701d..0000000000 --- a/MediaBrowser.Providers/Plugins/TheTvdb/TvdbClientManager.cs +++ /dev/null @@ -1,289 +0,0 @@ -#pragma warning disable CS1591 - -using System; -using System.Collections.Generic; -using System.Globalization; -using System.Linq; -using System.Reflection; -using System.Runtime.CompilerServices; -using System.Threading; -using System.Threading.Tasks; -using MediaBrowser.Controller.Providers; -using MediaBrowser.Model.Entities; -using Microsoft.Extensions.Caching.Memory; -using TvDbSharper; -using TvDbSharper.Dto; - -namespace MediaBrowser.Providers.Plugins.TheTvdb -{ - public class TvdbClientManager - { - private const string DefaultLanguage = "en"; - - private readonly IMemoryCache _cache; - private readonly TvDbClient _tvDbClient; - private DateTime _tokenCreatedAt; - - public TvdbClientManager(IMemoryCache memoryCache) - { - _cache = memoryCache; - _tvDbClient = new TvDbClient(); - } - - private TvDbClient TvDbClient - { - get - { - if (string.IsNullOrEmpty(_tvDbClient.Authentication.Token)) - { - _tvDbClient.Authentication.AuthenticateAsync(TvdbUtils.TvdbApiKey).GetAwaiter().GetResult(); - _tokenCreatedAt = DateTime.Now; - } - - // Refresh if necessary - if (_tokenCreatedAt < DateTime.Now.Subtract(TimeSpan.FromHours(20))) - { - try - { - _tvDbClient.Authentication.RefreshTokenAsync().GetAwaiter().GetResult(); - } - catch - { - _tvDbClient.Authentication.AuthenticateAsync(TvdbUtils.TvdbApiKey).GetAwaiter().GetResult(); - } - - _tokenCreatedAt = DateTime.Now; - } - - return _tvDbClient; - } - } - - public Task<TvDbResponse<SeriesSearchResult[]>> GetSeriesByNameAsync(string name, string language, - CancellationToken cancellationToken) - { - var cacheKey = GenerateKey("series", name, language); - return TryGetValue(cacheKey, language, () => TvDbClient.Search.SearchSeriesByNameAsync(name, cancellationToken)); - } - - public Task<TvDbResponse<Series>> GetSeriesByIdAsync(int tvdbId, string language, - CancellationToken cancellationToken) - { - var cacheKey = GenerateKey("series", tvdbId, language); - return TryGetValue(cacheKey, language, () => TvDbClient.Series.GetAsync(tvdbId, cancellationToken)); - } - - public Task<TvDbResponse<EpisodeRecord>> GetEpisodesAsync(int episodeTvdbId, string language, - CancellationToken cancellationToken) - { - var cacheKey = GenerateKey("episode", episodeTvdbId, language); - return TryGetValue(cacheKey, language, () => TvDbClient.Episodes.GetAsync(episodeTvdbId, cancellationToken)); - } - - public Task<TvDbResponse<SeriesSearchResult[]>> GetSeriesByImdbIdAsync( - string imdbId, - string language, - CancellationToken cancellationToken) - { - var cacheKey = GenerateKey("series", imdbId, language); - return TryGetValue(cacheKey, language, () => TvDbClient.Search.SearchSeriesByImdbIdAsync(imdbId, cancellationToken)); - } - - public Task<TvDbResponse<SeriesSearchResult[]>> GetSeriesByZap2ItIdAsync( - string zap2ItId, - string language, - CancellationToken cancellationToken) - { - var cacheKey = GenerateKey("series", zap2ItId, language); - return TryGetValue(cacheKey, language, () => TvDbClient.Search.SearchSeriesByZap2ItIdAsync(zap2ItId, cancellationToken)); - } - - public Task<TvDbResponse<Actor[]>> GetActorsAsync( - int tvdbId, - string language, - CancellationToken cancellationToken) - { - var cacheKey = GenerateKey("actors", tvdbId, language); - return TryGetValue(cacheKey, language, () => TvDbClient.Series.GetActorsAsync(tvdbId, cancellationToken)); - } - - public Task<TvDbResponse<Image[]>> GetImagesAsync( - int tvdbId, - ImagesQuery imageQuery, - string language, - CancellationToken cancellationToken) - { - var cacheKey = GenerateKey("images", tvdbId, language, imageQuery); - return TryGetValue(cacheKey, language, () => TvDbClient.Series.GetImagesAsync(tvdbId, imageQuery, cancellationToken)); - } - - public Task<TvDbResponse<Language[]>> GetLanguagesAsync(CancellationToken cancellationToken) - { - return TryGetValue("languages", null, () => TvDbClient.Languages.GetAllAsync(cancellationToken)); - } - - public Task<TvDbResponse<EpisodesSummary>> GetSeriesEpisodeSummaryAsync( - int tvdbId, - string language, - CancellationToken cancellationToken) - { - var cacheKey = GenerateKey("seriesepisodesummary", tvdbId, language); - return TryGetValue(cacheKey, language, - () => TvDbClient.Series.GetEpisodesSummaryAsync(tvdbId, cancellationToken)); - } - - public Task<TvDbResponse<EpisodeRecord[]>> GetEpisodesPageAsync( - int tvdbId, - int page, - EpisodeQuery episodeQuery, - string language, - CancellationToken cancellationToken) - { - var cacheKey = GenerateKey(language, tvdbId, episodeQuery); - - return TryGetValue(cacheKey, language, - () => TvDbClient.Series.GetEpisodesAsync(tvdbId, page, episodeQuery, cancellationToken)); - } - - public Task<string> GetEpisodeTvdbId( - EpisodeInfo searchInfo, - string language, - CancellationToken cancellationToken) - { - searchInfo.SeriesProviderIds.TryGetValue( - nameof(MetadataProvider.Tvdb), - out var seriesTvdbId); - - var episodeQuery = new EpisodeQuery(); - - // Prefer SxE over premiere date as it is more robust - if (searchInfo.IndexNumber.HasValue && searchInfo.ParentIndexNumber.HasValue) - { - switch (searchInfo.SeriesDisplayOrder) - { - case "dvd": - episodeQuery.DvdEpisode = searchInfo.IndexNumber.Value; - episodeQuery.DvdSeason = searchInfo.ParentIndexNumber.Value; - break; - case "absolute": - episodeQuery.AbsoluteNumber = searchInfo.IndexNumber.Value; - break; - default: - // aired order - episodeQuery.AiredEpisode = searchInfo.IndexNumber.Value; - episodeQuery.AiredSeason = searchInfo.ParentIndexNumber.Value; - break; - } - } - else if (searchInfo.PremiereDate.HasValue) - { - // tvdb expects yyyy-mm-dd format - episodeQuery.FirstAired = searchInfo.PremiereDate.Value.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture); - } - - return GetEpisodeTvdbId(Convert.ToInt32(seriesTvdbId, CultureInfo.InvariantCulture), episodeQuery, language, cancellationToken); - } - - public async Task<string> GetEpisodeTvdbId( - int seriesTvdbId, - EpisodeQuery episodeQuery, - string language, - CancellationToken cancellationToken) - { - var episodePage = - await GetEpisodesPageAsync(Convert.ToInt32(seriesTvdbId), episodeQuery, language, cancellationToken) - .ConfigureAwait(false); - return episodePage.Data.FirstOrDefault()?.Id.ToString(CultureInfo.InvariantCulture); - } - - public Task<TvDbResponse<EpisodeRecord[]>> GetEpisodesPageAsync( - int tvdbId, - EpisodeQuery episodeQuery, - string language, - CancellationToken cancellationToken) - { - return GetEpisodesPageAsync(tvdbId, 1, episodeQuery, language, cancellationToken); - } - - public async IAsyncEnumerable<KeyType> GetImageKeyTypesForSeriesAsync(int tvdbId, string language, [EnumeratorCancellation] CancellationToken cancellationToken) - { - var cacheKey = GenerateKey(nameof(TvDbClient.Series.GetImagesSummaryAsync), tvdbId); - var imagesSummary = await TryGetValue(cacheKey, language, () => TvDbClient.Series.GetImagesSummaryAsync(tvdbId, cancellationToken)).ConfigureAwait(false); - - if (imagesSummary.Data.Fanart > 0) - { - yield return KeyType.Fanart; - } - - if (imagesSummary.Data.Series > 0) - { - yield return KeyType.Series; - } - - if (imagesSummary.Data.Poster > 0) - { - yield return KeyType.Poster; - } - } - - public async IAsyncEnumerable<KeyType> GetImageKeyTypesForSeasonAsync(int tvdbId, string language, [EnumeratorCancellation] CancellationToken cancellationToken) - { - var cacheKey = GenerateKey(nameof(TvDbClient.Series.GetImagesSummaryAsync), tvdbId); - var imagesSummary = await TryGetValue(cacheKey, language, () => TvDbClient.Series.GetImagesSummaryAsync(tvdbId, cancellationToken)).ConfigureAwait(false); - - if (imagesSummary.Data.Season > 0) - { - yield return KeyType.Season; - } - - if (imagesSummary.Data.Fanart > 0) - { - yield return KeyType.Fanart; - } - - // TODO seasonwide is not supported in TvDbSharper - } - - private async Task<T> TryGetValue<T>(string key, string language, Func<Task<T>> resultFactory) - { - if (_cache.TryGetValue(key, out T cachedValue)) - { - return cachedValue; - } - - _tvDbClient.AcceptedLanguage = TvdbUtils.NormalizeLanguage(language) ?? DefaultLanguage; - var result = await resultFactory.Invoke().ConfigureAwait(false); - _cache.Set(key, result, TimeSpan.FromHours(1)); - return result; - } - - private static string GenerateKey(params object[] objects) - { - var key = string.Empty; - - foreach (var obj in objects) - { - var objType = obj.GetType(); - if (objType.IsPrimitive || objType == typeof(string)) - { - key += obj + ";"; - } - else - { - foreach (PropertyInfo propertyInfo in objType.GetProperties()) - { - var currentValue = propertyInfo.GetValue(obj, null); - if (currentValue == null) - { - continue; - } - - key += propertyInfo.Name + "=" + currentValue + ";"; - } - } - } - - return key; - } - } -} diff --git a/MediaBrowser.Providers/Plugins/TheTvdb/TvdbEpisodeImageProvider.cs b/MediaBrowser.Providers/Plugins/TheTvdb/TvdbEpisodeImageProvider.cs deleted file mode 100644 index 50a876d6c5..0000000000 --- a/MediaBrowser.Providers/Plugins/TheTvdb/TvdbEpisodeImageProvider.cs +++ /dev/null @@ -1,130 +0,0 @@ -#pragma warning disable CS1591 - -using System; -using System.Collections.Generic; -using System.Net.Http; -using System.Globalization; -using System.Threading; -using System.Threading.Tasks; -using MediaBrowser.Common.Net; -using MediaBrowser.Controller.Entities; -using MediaBrowser.Controller.Entities.TV; -using MediaBrowser.Controller.Providers; -using MediaBrowser.Model.Entities; -using MediaBrowser.Model.Providers; -using Microsoft.Extensions.Logging; -using TvDbSharper; -using TvDbSharper.Dto; - -namespace MediaBrowser.Providers.Plugins.TheTvdb -{ - public class TvdbEpisodeImageProvider : IRemoteImageProvider - { - private readonly IHttpClientFactory _httpClientFactory; - private readonly ILogger<TvdbEpisodeImageProvider> _logger; - private readonly TvdbClientManager _tvdbClientManager; - - public TvdbEpisodeImageProvider(IHttpClientFactory httpClientFactory, ILogger<TvdbEpisodeImageProvider> logger, TvdbClientManager tvdbClientManager) - { - _httpClientFactory = httpClientFactory; - _logger = logger; - _tvdbClientManager = tvdbClientManager; - } - - public string Name => "TheTVDB"; - - public bool Supports(BaseItem item) - { - return item is Episode; - } - - public IEnumerable<ImageType> GetSupportedImages(BaseItem item) - { - return new List<ImageType> - { - ImageType.Primary - }; - } - - public async Task<IEnumerable<RemoteImageInfo>> GetImages(BaseItem item, CancellationToken cancellationToken) - { - var episode = (Episode)item; - var series = episode.Series; - var imageResult = new List<RemoteImageInfo>(); - var language = item.GetPreferredMetadataLanguage(); - if (series != null && TvdbSeriesProvider.IsValidSeries(series.ProviderIds)) - { - // Process images - try - { - string episodeTvdbId = null; - - if (episode.IndexNumber.HasValue && episode.ParentIndexNumber.HasValue) - { - var episodeInfo = new EpisodeInfo - { - IndexNumber = episode.IndexNumber.Value, - ParentIndexNumber = episode.ParentIndexNumber.Value, - SeriesProviderIds = series.ProviderIds, - SeriesDisplayOrder = series.DisplayOrder - }; - - episodeTvdbId = await _tvdbClientManager - .GetEpisodeTvdbId(episodeInfo, language, cancellationToken).ConfigureAwait(false); - } - - if (string.IsNullOrEmpty(episodeTvdbId)) - { - _logger.LogError( - "Episode {SeasonNumber}x{EpisodeNumber} not found for series {SeriesTvdbId}", - episode.ParentIndexNumber, - episode.IndexNumber, - series.GetProviderId(MetadataProvider.Tvdb)); - return imageResult; - } - - var episodeResult = - await _tvdbClientManager - .GetEpisodesAsync(Convert.ToInt32(episodeTvdbId, CultureInfo.InvariantCulture), language, cancellationToken) - .ConfigureAwait(false); - - var image = GetImageInfo(episodeResult.Data); - if (image != null) - { - imageResult.Add(image); - } - } - catch (TvDbServerException e) - { - _logger.LogError(e, "Failed to retrieve episode images for series {TvDbId}", series.GetProviderId(MetadataProvider.Tvdb)); - } - } - - return imageResult; - } - - private RemoteImageInfo GetImageInfo(EpisodeRecord episode) - { - if (string.IsNullOrEmpty(episode.Filename)) - { - return null; - } - - return new RemoteImageInfo - { - Width = Convert.ToInt32(episode.ThumbWidth, CultureInfo.InvariantCulture), - Height = Convert.ToInt32(episode.ThumbHeight, CultureInfo.InvariantCulture), - ProviderName = Name, - Url = TvdbUtils.BannerUrl + episode.Filename, - Type = ImageType.Primary - }; - } - - public int Order => 0; - - public Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken cancellationToken) - { - return _httpClientFactory.CreateClient(NamedClient.Default).GetAsync(url, cancellationToken); - } - } -} diff --git a/MediaBrowser.Providers/Plugins/TheTvdb/TvdbEpisodeProvider.cs b/MediaBrowser.Providers/Plugins/TheTvdb/TvdbEpisodeProvider.cs deleted file mode 100644 index fd72ea4a81..0000000000 --- a/MediaBrowser.Providers/Plugins/TheTvdb/TvdbEpisodeProvider.cs +++ /dev/null @@ -1,262 +0,0 @@ -#pragma warning disable CS1591 - -using System; -using System.Collections.Generic; -using System.Net.Http; -using System.Threading; -using System.Threading.Tasks; -using MediaBrowser.Common.Net; -using MediaBrowser.Controller.Entities; -using MediaBrowser.Controller.Entities.TV; -using MediaBrowser.Controller.Providers; -using MediaBrowser.Model.Entities; -using MediaBrowser.Model.Providers; -using Microsoft.Extensions.Logging; -using TvDbSharper; -using TvDbSharper.Dto; - -namespace MediaBrowser.Providers.Plugins.TheTvdb -{ - /// <summary> - /// Class RemoteEpisodeProvider. - /// </summary> - public class TvdbEpisodeProvider : IRemoteMetadataProvider<Episode, EpisodeInfo>, IHasOrder - { - private readonly IHttpClientFactory _httpClientFactory; - private readonly ILogger<TvdbEpisodeProvider> _logger; - private readonly TvdbClientManager _tvdbClientManager; - - public TvdbEpisodeProvider(IHttpClientFactory httpClientFactory, ILogger<TvdbEpisodeProvider> logger, TvdbClientManager tvdbClientManager) - { - _httpClientFactory = httpClientFactory; - _logger = logger; - _tvdbClientManager = tvdbClientManager; - } - - public async Task<IEnumerable<RemoteSearchResult>> GetSearchResults(EpisodeInfo searchInfo, CancellationToken cancellationToken) - { - var list = new List<RemoteSearchResult>(); - - // Either an episode number or date must be provided; and the dictionary of provider ids must be valid - if ((searchInfo.IndexNumber == null && searchInfo.PremiereDate == null) - || !TvdbSeriesProvider.IsValidSeries(searchInfo.SeriesProviderIds)) - { - return list; - } - - var metadataResult = await GetEpisode(searchInfo, cancellationToken).ConfigureAwait(false); - - if (!metadataResult.HasMetadata) - { - return list; - } - - var item = metadataResult.Item; - - list.Add(new RemoteSearchResult - { - IndexNumber = item.IndexNumber, - Name = item.Name, - ParentIndexNumber = item.ParentIndexNumber, - PremiereDate = item.PremiereDate, - ProductionYear = item.ProductionYear, - ProviderIds = item.ProviderIds, - SearchProviderName = Name, - IndexNumberEnd = item.IndexNumberEnd - }); - - return list; - } - - public string Name => "TheTVDB"; - - public async Task<MetadataResult<Episode>> GetMetadata(EpisodeInfo searchInfo, CancellationToken cancellationToken) - { - var result = new MetadataResult<Episode> - { - QueriedById = true - }; - - if (TvdbSeriesProvider.IsValidSeries(searchInfo.SeriesProviderIds) && - (searchInfo.IndexNumber.HasValue || searchInfo.PremiereDate.HasValue)) - { - result = await GetEpisode(searchInfo, cancellationToken).ConfigureAwait(false); - } - else - { - _logger.LogDebug("No series identity found for {EpisodeName}", searchInfo.Name); - } - - return result; - } - - private async Task<MetadataResult<Episode>> GetEpisode(EpisodeInfo searchInfo, CancellationToken cancellationToken) - { - var result = new MetadataResult<Episode> - { - QueriedById = true - }; - - string seriesTvdbId = searchInfo.GetProviderId(MetadataProvider.Tvdb); - string episodeTvdbId = null; - try - { - episodeTvdbId = await _tvdbClientManager - .GetEpisodeTvdbId(searchInfo, searchInfo.MetadataLanguage, cancellationToken) - .ConfigureAwait(false); - if (string.IsNullOrEmpty(episodeTvdbId)) - { - _logger.LogError( - "Episode {SeasonNumber}x{EpisodeNumber} not found for series {SeriesTvdbId}", - searchInfo.ParentIndexNumber, searchInfo.IndexNumber, seriesTvdbId); - return result; - } - - var episodeResult = await _tvdbClientManager.GetEpisodesAsync( - Convert.ToInt32(episodeTvdbId), searchInfo.MetadataLanguage, - cancellationToken).ConfigureAwait(false); - - result = MapEpisodeToResult(searchInfo, episodeResult.Data); - } - catch (TvDbServerException e) - { - _logger.LogError(e, "Failed to retrieve episode with id {EpisodeTvDbId}, series id {SeriesTvdbId}", episodeTvdbId, seriesTvdbId); - } - - return result; - } - - private static MetadataResult<Episode> MapEpisodeToResult(EpisodeInfo id, EpisodeRecord episode) - { - var result = new MetadataResult<Episode> - { - HasMetadata = true, - Item = new Episode - { - IndexNumber = id.IndexNumber, - ParentIndexNumber = id.ParentIndexNumber, - IndexNumberEnd = id.IndexNumberEnd, - AirsBeforeEpisodeNumber = episode.AirsBeforeEpisode, - AirsAfterSeasonNumber = episode.AirsAfterSeason, - AirsBeforeSeasonNumber = episode.AirsBeforeSeason, - Name = episode.EpisodeName, - Overview = episode.Overview, - CommunityRating = (float?)episode.SiteRating, - OfficialRating = episode.ContentRating, - } - }; - result.ResetPeople(); - - var item = result.Item; - item.SetProviderId(MetadataProvider.Tvdb, episode.Id.ToString()); - item.SetProviderId(MetadataProvider.Imdb, episode.ImdbId); - - if (string.Equals(id.SeriesDisplayOrder, "dvd", StringComparison.OrdinalIgnoreCase)) - { - item.IndexNumber = Convert.ToInt32(episode.DvdEpisodeNumber ?? episode.AiredEpisodeNumber); - item.ParentIndexNumber = episode.DvdSeason ?? episode.AiredSeason; - } - else if (string.Equals(id.SeriesDisplayOrder, "absolute", StringComparison.OrdinalIgnoreCase)) - { - if (episode.AbsoluteNumber.GetValueOrDefault() != 0) - { - item.IndexNumber = episode.AbsoluteNumber; - } - } - else if (episode.AiredEpisodeNumber.HasValue) - { - item.IndexNumber = episode.AiredEpisodeNumber; - } - else if (episode.AiredSeason.HasValue) - { - item.ParentIndexNumber = episode.AiredSeason; - } - - if (DateTime.TryParse(episode.FirstAired, out var date)) - { - // dates from tvdb are UTC but without offset or Z - item.PremiereDate = date; - item.ProductionYear = date.Year; - } - - foreach (var director in episode.Directors) - { - result.AddPerson(new PersonInfo - { - Name = director, - Type = PersonType.Director - }); - } - - // GuestStars is a weird list of names and roles - // Example: - // 1: Some Actor (Role1 - // 2: Role2 - // 3: Role3) - // 4: Another Actor (Role1 - // ... - for (var i = 0; i < episode.GuestStars.Length; ++i) - { - var currentActor = episode.GuestStars[i]; - var roleStartIndex = currentActor.IndexOf('(', StringComparison.Ordinal); - - if (roleStartIndex == -1) - { - result.AddPerson(new PersonInfo - { - Type = PersonType.GuestStar, - Name = currentActor, - Role = string.Empty - }); - continue; - } - - var roles = new List<string> { currentActor.Substring(roleStartIndex + 1) }; - - // Fetch all roles - for (var j = i + 1; j < episode.GuestStars.Length; ++j) - { - var currentRole = episode.GuestStars[j]; - var roleEndIndex = currentRole.IndexOf(')', StringComparison.Ordinal); - - if (roleEndIndex == -1) - { - roles.Add(currentRole); - continue; - } - - roles.Add(currentRole.TrimEnd(')')); - // Update the outer index (keep in mind it adds 1 after the iteration) - i = j; - break; - } - - result.AddPerson(new PersonInfo - { - Type = PersonType.GuestStar, - Name = currentActor.Substring(0, roleStartIndex).Trim(), - Role = string.Join(", ", roles) - }); - } - - foreach (var writer in episode.Writers) - { - result.AddPerson(new PersonInfo - { - Name = writer, - Type = PersonType.Writer - }); - } - - result.ResultLanguage = episode.Language.EpisodeName; - return result; - } - - public Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken cancellationToken) - { - return _httpClientFactory.CreateClient(NamedClient.Default).GetAsync(url, cancellationToken); - } - - public int Order => 0; - } -} diff --git a/MediaBrowser.Providers/Plugins/TheTvdb/TvdbPersonImageProvider.cs b/MediaBrowser.Providers/Plugins/TheTvdb/TvdbPersonImageProvider.cs deleted file mode 100644 index dc3c60dee0..0000000000 --- a/MediaBrowser.Providers/Plugins/TheTvdb/TvdbPersonImageProvider.cs +++ /dev/null @@ -1,113 +0,0 @@ -#pragma warning disable CS1591 - -using System; -using System.Collections.Generic; -using System.Linq; -using System.Net.Http; -using System.Threading; -using System.Threading.Tasks; -using MediaBrowser.Common.Net; -using MediaBrowser.Controller.Dto; -using MediaBrowser.Controller.Entities; -using MediaBrowser.Controller.Entities.TV; -using MediaBrowser.Controller.Library; -using MediaBrowser.Controller.Providers; -using MediaBrowser.Model.Entities; -using MediaBrowser.Model.Providers; -using Microsoft.Extensions.Logging; -using TvDbSharper; - -namespace MediaBrowser.Providers.Plugins.TheTvdb -{ - public class TvdbPersonImageProvider : IRemoteImageProvider, IHasOrder - { - private readonly IHttpClientFactory _httpClientFactory; - private readonly ILogger<TvdbPersonImageProvider> _logger; - private readonly ILibraryManager _libraryManager; - private readonly TvdbClientManager _tvdbClientManager; - - public TvdbPersonImageProvider(ILibraryManager libraryManager, IHttpClientFactory httpClientFactory, ILogger<TvdbPersonImageProvider> logger, TvdbClientManager tvdbClientManager) - { - _libraryManager = libraryManager; - _httpClientFactory = httpClientFactory; - _logger = logger; - _tvdbClientManager = tvdbClientManager; - } - - /// <inheritdoc /> - public string Name => "TheTVDB"; - - /// <inheritdoc /> - public int Order => 1; - - /// <inheritdoc /> - public bool Supports(BaseItem item) => item is Person; - - /// <inheritdoc /> - public IEnumerable<ImageType> GetSupportedImages(BaseItem item) - { - yield return ImageType.Primary; - } - - /// <inheritdoc /> - public async Task<IEnumerable<RemoteImageInfo>> GetImages(BaseItem item, CancellationToken cancellationToken) - { - var seriesWithPerson = _libraryManager.GetItemList(new InternalItemsQuery - { - IncludeItemTypes = new[] { typeof(Series).Name }, - PersonIds = new[] { item.Id }, - DtoOptions = new DtoOptions(false) - { - EnableImages = false - } - }).Cast<Series>() - .Where(i => TvdbSeriesProvider.IsValidSeries(i.ProviderIds)) - .ToList(); - - var infos = (await Task.WhenAll(seriesWithPerson.Select(async i => - await GetImageFromSeriesData(i, item.Name, cancellationToken).ConfigureAwait(false))) - .ConfigureAwait(false)) - .Where(i => i != null) - .Take(1); - - return infos; - } - - private async Task<RemoteImageInfo> GetImageFromSeriesData(Series series, string personName, CancellationToken cancellationToken) - { - var tvdbId = Convert.ToInt32(series.GetProviderId(MetadataProvider.Tvdb)); - - try - { - var actorsResult = await _tvdbClientManager - .GetActorsAsync(tvdbId, series.GetPreferredMetadataLanguage(), cancellationToken) - .ConfigureAwait(false); - var actor = actorsResult.Data.FirstOrDefault(a => - string.Equals(a.Name, personName, StringComparison.OrdinalIgnoreCase) && - !string.IsNullOrEmpty(a.Image)); - if (actor == null) - { - return null; - } - - return new RemoteImageInfo - { - Url = TvdbUtils.BannerUrl + actor.Image, - Type = ImageType.Primary, - ProviderName = Name - }; - } - catch (TvDbServerException e) - { - _logger.LogError(e, "Failed to retrieve actor {ActorName} from series {SeriesTvdbId}", personName, tvdbId); - return null; - } - } - - /// <inheritdoc /> - public Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken cancellationToken) - { - return _httpClientFactory.CreateClient(NamedClient.Default).GetAsync(url, cancellationToken); - } - } -} diff --git a/MediaBrowser.Providers/Plugins/TheTvdb/TvdbSeasonImageProvider.cs b/MediaBrowser.Providers/Plugins/TheTvdb/TvdbSeasonImageProvider.cs deleted file mode 100644 index 49576d488d..0000000000 --- a/MediaBrowser.Providers/Plugins/TheTvdb/TvdbSeasonImageProvider.cs +++ /dev/null @@ -1,155 +0,0 @@ -#pragma warning disable CS1591 - -using System; -using System.Collections.Generic; -using System.Linq; -using System.Net.Http; -using System.Threading; -using System.Threading.Tasks; -using MediaBrowser.Common.Net; -using MediaBrowser.Controller.Entities; -using MediaBrowser.Controller.Entities.TV; -using MediaBrowser.Controller.Providers; -using MediaBrowser.Model.Entities; -using MediaBrowser.Model.Providers; -using Microsoft.Extensions.Logging; -using TvDbSharper; -using TvDbSharper.Dto; -using RatingType = MediaBrowser.Model.Dto.RatingType; - -namespace MediaBrowser.Providers.Plugins.TheTvdb -{ - public class TvdbSeasonImageProvider : IRemoteImageProvider, IHasOrder - { - private readonly IHttpClientFactory _httpClientFactory; - private readonly ILogger<TvdbSeasonImageProvider> _logger; - private readonly TvdbClientManager _tvdbClientManager; - - public TvdbSeasonImageProvider(IHttpClientFactory httpClientFactory, ILogger<TvdbSeasonImageProvider> logger, TvdbClientManager tvdbClientManager) - { - _httpClientFactory = httpClientFactory; - _logger = logger; - _tvdbClientManager = tvdbClientManager; - } - - public string Name => ProviderName; - - public static string ProviderName => "TheTVDB"; - - public bool Supports(BaseItem item) - { - return item is Season; - } - - public IEnumerable<ImageType> GetSupportedImages(BaseItem item) - { - return new List<ImageType> - { - ImageType.Primary, - ImageType.Banner, - ImageType.Backdrop - }; - } - - public async Task<IEnumerable<RemoteImageInfo>> GetImages(BaseItem item, CancellationToken cancellationToken) - { - var season = (Season)item; - var series = season.Series; - - if (series == null || !season.IndexNumber.HasValue || !TvdbSeriesProvider.IsValidSeries(series.ProviderIds)) - { - return Array.Empty<RemoteImageInfo>(); - } - - var tvdbId = Convert.ToInt32(series.GetProviderId(MetadataProvider.Tvdb)); - var seasonNumber = season.IndexNumber.Value; - var language = item.GetPreferredMetadataLanguage(); - var remoteImages = new List<RemoteImageInfo>(); - - var keyTypes = _tvdbClientManager.GetImageKeyTypesForSeasonAsync(tvdbId, language, cancellationToken).ConfigureAwait(false); - await foreach (var keyType in keyTypes) - { - var imageQuery = new ImagesQuery - { - KeyType = keyType, - SubKey = seasonNumber.ToString() - }; - try - { - var imageResults = await _tvdbClientManager - .GetImagesAsync(tvdbId, imageQuery, language, cancellationToken).ConfigureAwait(false); - remoteImages.AddRange(GetImages(imageResults.Data, language)); - } - catch (TvDbServerException) - { - _logger.LogDebug("No images of type {KeyType} found for series {TvdbId}", keyType, tvdbId); - } - } - - return remoteImages; - } - - private IEnumerable<RemoteImageInfo> GetImages(Image[] images, string preferredLanguage) - { - var list = new List<RemoteImageInfo>(); - // any languages with null ids are ignored - var languages = _tvdbClientManager.GetLanguagesAsync(CancellationToken.None).Result.Data.Where(x => x.Id.HasValue); - foreach (Image image in images) - { - var imageInfo = new RemoteImageInfo - { - RatingType = RatingType.Score, - CommunityRating = (double?)image.RatingsInfo.Average, - VoteCount = image.RatingsInfo.Count, - Url = TvdbUtils.BannerUrl + image.FileName, - ProviderName = ProviderName, - Language = languages.FirstOrDefault(lang => lang.Id == image.LanguageId)?.Abbreviation, - ThumbnailUrl = TvdbUtils.BannerUrl + image.Thumbnail - }; - - var resolution = image.Resolution.Split('x'); - if (resolution.Length == 2) - { - imageInfo.Width = Convert.ToInt32(resolution[0]); - imageInfo.Height = Convert.ToInt32(resolution[1]); - } - - imageInfo.Type = TvdbUtils.GetImageTypeFromKeyType(image.KeyType); - list.Add(imageInfo); - } - - var isLanguageEn = string.Equals(preferredLanguage, "en", StringComparison.OrdinalIgnoreCase); - return list.OrderByDescending(i => - { - if (string.Equals(preferredLanguage, i.Language, StringComparison.OrdinalIgnoreCase)) - { - return 3; - } - - if (!isLanguageEn) - { - if (string.Equals("en", i.Language, StringComparison.OrdinalIgnoreCase)) - { - return 2; - } - } - - if (string.IsNullOrEmpty(i.Language)) - { - return isLanguageEn ? 3 : 2; - } - - return 0; - }) - .ThenByDescending(i => i.CommunityRating ?? 0) - .ThenByDescending(i => i.VoteCount ?? 0); - } - - public int Order => 0; - - public Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken cancellationToken) - { - return _httpClientFactory.CreateClient(NamedClient.Default).GetAsync(url, cancellationToken); - } - } -} diff --git a/MediaBrowser.Providers/Plugins/TheTvdb/TvdbSeriesImageProvider.cs b/MediaBrowser.Providers/Plugins/TheTvdb/TvdbSeriesImageProvider.cs deleted file mode 100644 index d96840e51c..0000000000 --- a/MediaBrowser.Providers/Plugins/TheTvdb/TvdbSeriesImageProvider.cs +++ /dev/null @@ -1,153 +0,0 @@ -#pragma warning disable CS1591 - -using System; -using System.Collections.Generic; -using System.Linq; -using System.Net.Http; -using System.Threading; -using System.Threading.Tasks; -using MediaBrowser.Common.Net; -using MediaBrowser.Controller.Entities; -using MediaBrowser.Controller.Providers; -using MediaBrowser.Model.Entities; -using MediaBrowser.Model.Providers; -using Microsoft.Extensions.Logging; -using TvDbSharper; -using TvDbSharper.Dto; -using RatingType = MediaBrowser.Model.Dto.RatingType; -using Series = MediaBrowser.Controller.Entities.TV.Series; - -namespace MediaBrowser.Providers.Plugins.TheTvdb -{ - public class TvdbSeriesImageProvider : IRemoteImageProvider, IHasOrder - { - private readonly IHttpClientFactory _httpClientFactory; - private readonly ILogger<TvdbSeriesImageProvider> _logger; - private readonly TvdbClientManager _tvdbClientManager; - - public TvdbSeriesImageProvider(IHttpClientFactory httpClientFactory, ILogger<TvdbSeriesImageProvider> logger, TvdbClientManager tvdbClientManager) - { - _httpClientFactory = httpClientFactory; - _logger = logger; - _tvdbClientManager = tvdbClientManager; - } - - public string Name => ProviderName; - - public static string ProviderName => "TheTVDB"; - - public bool Supports(BaseItem item) - { - return item is Series; - } - - public IEnumerable<ImageType> GetSupportedImages(BaseItem item) - { - return new List<ImageType> - { - ImageType.Primary, - ImageType.Banner, - ImageType.Backdrop - }; - } - - public async Task<IEnumerable<RemoteImageInfo>> GetImages(BaseItem item, CancellationToken cancellationToken) - { - if (!TvdbSeriesProvider.IsValidSeries(item.ProviderIds)) - { - return Array.Empty<RemoteImageInfo>(); - } - - var language = item.GetPreferredMetadataLanguage(); - var remoteImages = new List<RemoteImageInfo>(); - var tvdbId = Convert.ToInt32(item.GetProviderId(MetadataProvider.Tvdb)); - var allowedKeyTypes = _tvdbClientManager.GetImageKeyTypesForSeriesAsync(tvdbId, language, cancellationToken) - .ConfigureAwait(false); - await foreach (KeyType keyType in allowedKeyTypes) - { - var imageQuery = new ImagesQuery - { - KeyType = keyType - }; - try - { - var imageResults = - await _tvdbClientManager.GetImagesAsync(tvdbId, imageQuery, language, cancellationToken) - .ConfigureAwait(false); - - remoteImages.AddRange(GetImages(imageResults.Data, language)); - } - catch (TvDbServerException) - { - _logger.LogDebug("No images of type {KeyType} exist for series {TvDbId}", keyType, - tvdbId); - } - } - - return remoteImages; - } - - private IEnumerable<RemoteImageInfo> GetImages(Image[] images, string preferredLanguage) - { - var list = new List<RemoteImageInfo>(); - var languages = _tvdbClientManager.GetLanguagesAsync(CancellationToken.None).Result.Data; - - foreach (Image image in images) - { - var imageInfo = new RemoteImageInfo - { - RatingType = RatingType.Score, - CommunityRating = (double?)image.RatingsInfo.Average, - VoteCount = image.RatingsInfo.Count, - Url = TvdbUtils.BannerUrl + image.FileName, - ProviderName = Name, - Language = languages.FirstOrDefault(lang => lang.Id == image.LanguageId)?.Abbreviation, - ThumbnailUrl = TvdbUtils.BannerUrl + image.Thumbnail - }; - - var resolution = image.Resolution.Split('x'); - if (resolution.Length == 2) - { - imageInfo.Width = Convert.ToInt32(resolution[0]); - imageInfo.Height = Convert.ToInt32(resolution[1]); - } - - imageInfo.Type = TvdbUtils.GetImageTypeFromKeyType(image.KeyType); - list.Add(imageInfo); - } - - var isLanguageEn = string.Equals(preferredLanguage, "en", StringComparison.OrdinalIgnoreCase); - return list.OrderByDescending(i => - { - if (string.Equals(preferredLanguage, i.Language, StringComparison.OrdinalIgnoreCase)) - { - return 3; - } - - if (!isLanguageEn) - { - if (string.Equals("en", i.Language, StringComparison.OrdinalIgnoreCase)) - { - return 2; - } - } - - if (string.IsNullOrEmpty(i.Language)) - { - return isLanguageEn ? 3 : 2; - } - - return 0; - }) - .ThenByDescending(i => i.CommunityRating ?? 0) - .ThenByDescending(i => i.VoteCount ?? 0); - } - - public int Order => 0; - - public Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken cancellationToken) - { - return _httpClientFactory.CreateClient(NamedClient.Default).GetAsync(url, cancellationToken); - } - } -} diff --git a/MediaBrowser.Providers/Plugins/TheTvdb/TvdbSeriesProvider.cs b/MediaBrowser.Providers/Plugins/TheTvdb/TvdbSeriesProvider.cs deleted file mode 100644 index b34e52235a..0000000000 --- a/MediaBrowser.Providers/Plugins/TheTvdb/TvdbSeriesProvider.cs +++ /dev/null @@ -1,419 +0,0 @@ -#pragma warning disable CS1591 - -using System; -using System.Collections.Generic; -using System.Globalization; -using System.Linq; -using System.Net.Http; -using System.Text; -using System.Text.RegularExpressions; -using System.Threading; -using System.Threading.Tasks; -using MediaBrowser.Common.Net; -using MediaBrowser.Controller.Entities; -using MediaBrowser.Controller.Library; -using MediaBrowser.Controller.Providers; -using MediaBrowser.Model.Entities; -using MediaBrowser.Model.Globalization; -using MediaBrowser.Model.Providers; -using Microsoft.Extensions.Logging; -using TvDbSharper; -using TvDbSharper.Dto; -using Series = MediaBrowser.Controller.Entities.TV.Series; - -namespace MediaBrowser.Providers.Plugins.TheTvdb -{ - public class TvdbSeriesProvider : IRemoteMetadataProvider<Series, SeriesInfo>, IHasOrder - { - internal static TvdbSeriesProvider Current { get; private set; } - - private readonly IHttpClientFactory _httpClientFactory; - private readonly ILogger<TvdbSeriesProvider> _logger; - private readonly ILibraryManager _libraryManager; - private readonly ILocalizationManager _localizationManager; - private readonly TvdbClientManager _tvdbClientManager; - - public TvdbSeriesProvider(IHttpClientFactory httpClientFactory, ILogger<TvdbSeriesProvider> logger, ILibraryManager libraryManager, ILocalizationManager localizationManager, TvdbClientManager tvdbClientManager) - { - _httpClientFactory = httpClientFactory; - _logger = logger; - _libraryManager = libraryManager; - _localizationManager = localizationManager; - Current = this; - _tvdbClientManager = tvdbClientManager; - } - - public async Task<IEnumerable<RemoteSearchResult>> GetSearchResults(SeriesInfo searchInfo, CancellationToken cancellationToken) - { - if (IsValidSeries(searchInfo.ProviderIds)) - { - var metadata = await GetMetadata(searchInfo, cancellationToken).ConfigureAwait(false); - - if (metadata.HasMetadata) - { - return new List<RemoteSearchResult> - { - new RemoteSearchResult - { - Name = metadata.Item.Name, - PremiereDate = metadata.Item.PremiereDate, - ProductionYear = metadata.Item.ProductionYear, - ProviderIds = metadata.Item.ProviderIds, - SearchProviderName = Name - } - }; - } - } - - return await FindSeries(searchInfo.Name, searchInfo.Year, searchInfo.MetadataLanguage, cancellationToken).ConfigureAwait(false); - } - - public async Task<MetadataResult<Series>> GetMetadata(SeriesInfo itemId, CancellationToken cancellationToken) - { - var result = new MetadataResult<Series> - { - QueriedById = true - }; - - if (!IsValidSeries(itemId.ProviderIds)) - { - result.QueriedById = false; - await Identify(itemId).ConfigureAwait(false); - } - - cancellationToken.ThrowIfCancellationRequested(); - - if (IsValidSeries(itemId.ProviderIds)) - { - result.Item = new Series(); - result.HasMetadata = true; - - await FetchSeriesData(result, itemId.MetadataLanguage, itemId.ProviderIds, cancellationToken) - .ConfigureAwait(false); - } - - return result; - } - - private async Task FetchSeriesData(MetadataResult<Series> result, string metadataLanguage, Dictionary<string, string> seriesProviderIds, CancellationToken cancellationToken) - { - var series = result.Item; - - if (seriesProviderIds.TryGetValue(MetadataProvider.Tvdb.ToString(), out var tvdbId) && !string.IsNullOrEmpty(tvdbId)) - { - series.SetProviderId(MetadataProvider.Tvdb, tvdbId); - } - - if (seriesProviderIds.TryGetValue(MetadataProvider.Imdb.ToString(), out var imdbId) && !string.IsNullOrEmpty(imdbId)) - { - series.SetProviderId(MetadataProvider.Imdb, imdbId); - tvdbId = await GetSeriesByRemoteId(imdbId, MetadataProvider.Imdb.ToString(), metadataLanguage, - cancellationToken).ConfigureAwait(false); - } - - if (seriesProviderIds.TryGetValue(MetadataProvider.Zap2It.ToString(), out var zap2It) && !string.IsNullOrEmpty(zap2It)) - { - series.SetProviderId(MetadataProvider.Zap2It, zap2It); - tvdbId = await GetSeriesByRemoteId(zap2It, MetadataProvider.Zap2It.ToString(), metadataLanguage, - cancellationToken).ConfigureAwait(false); - } - - try - { - var seriesResult = - await _tvdbClientManager - .GetSeriesByIdAsync(Convert.ToInt32(tvdbId), metadataLanguage, cancellationToken) - .ConfigureAwait(false); - await MapSeriesToResult(result, seriesResult.Data, metadataLanguage).ConfigureAwait(false); - } - catch (TvDbServerException e) - { - _logger.LogError(e, "Failed to retrieve series with id {TvdbId}", tvdbId); - return; - } - - cancellationToken.ThrowIfCancellationRequested(); - - result.ResetPeople(); - - try - { - var actorsResult = await _tvdbClientManager - .GetActorsAsync(Convert.ToInt32(tvdbId), metadataLanguage, cancellationToken).ConfigureAwait(false); - MapActorsToResult(result, actorsResult.Data); - } - catch (TvDbServerException e) - { - _logger.LogError(e, "Failed to retrieve actors for series {TvdbId}", tvdbId); - } - } - - private async Task<string> GetSeriesByRemoteId(string id, string idType, string language, CancellationToken cancellationToken) - { - TvDbResponse<SeriesSearchResult[]> result = null; - - try - { - if (string.Equals(idType, MetadataProvider.Zap2It.ToString(), StringComparison.OrdinalIgnoreCase)) - { - result = await _tvdbClientManager.GetSeriesByZap2ItIdAsync(id, language, cancellationToken) - .ConfigureAwait(false); - } - else - { - result = await _tvdbClientManager.GetSeriesByImdbIdAsync(id, language, cancellationToken) - .ConfigureAwait(false); - } - } - catch (TvDbServerException e) - { - _logger.LogError(e, "Failed to retrieve series with remote id {RemoteId}", id); - } - - return result?.Data.First().Id.ToString(); - } - - /// <summary> - /// Check whether a dictionary of provider IDs includes an entry for a valid TV metadata provider. - /// </summary> - /// <param name="seriesProviderIds">The dictionary to check.</param> - /// <returns>True, if the dictionary contains a valid TV provider ID, otherwise false.</returns> - internal static bool IsValidSeries(Dictionary<string, string> seriesProviderIds) - { - return seriesProviderIds.ContainsKey(MetadataProvider.Tvdb.ToString()) || - seriesProviderIds.ContainsKey(MetadataProvider.Imdb.ToString()) || - seriesProviderIds.ContainsKey(MetadataProvider.Zap2It.ToString()); - } - - /// <summary> - /// Finds the series. - /// </summary> - /// <param name="name">The name.</param> - /// <param name="year">The year.</param> - /// <param name="language">The language.</param> - /// <param name="cancellationToken">The cancellation token.</param> - /// <returns>Task{System.String}.</returns> - private async Task<IEnumerable<RemoteSearchResult>> FindSeries(string name, int? year, string language, CancellationToken cancellationToken) - { - var results = await FindSeriesInternal(name, language, cancellationToken).ConfigureAwait(false); - - if (results.Count == 0) - { - var parsedName = _libraryManager.ParseName(name); - var nameWithoutYear = parsedName.Name; - - if (!string.IsNullOrWhiteSpace(nameWithoutYear) && !string.Equals(nameWithoutYear, name, StringComparison.OrdinalIgnoreCase)) - { - results = await FindSeriesInternal(nameWithoutYear, language, cancellationToken).ConfigureAwait(false); - } - } - - return results.Where(i => - { - if (year.HasValue && i.ProductionYear.HasValue) - { - // Allow one year tolerance - return Math.Abs(year.Value - i.ProductionYear.Value) <= 1; - } - - return true; - }); - } - - private async Task<List<RemoteSearchResult>> FindSeriesInternal(string name, string language, CancellationToken cancellationToken) - { - var comparableName = GetComparableName(name); - var list = new List<Tuple<List<string>, RemoteSearchResult>>(); - TvDbResponse<SeriesSearchResult[]> result; - try - { - result = await _tvdbClientManager.GetSeriesByNameAsync(comparableName, language, cancellationToken) - .ConfigureAwait(false); - } - catch (TvDbServerException e) - { - _logger.LogError(e, "No series results found for {Name}", comparableName); - return new List<RemoteSearchResult>(); - } - - foreach (var seriesSearchResult in result.Data) - { - var tvdbTitles = new List<string> - { - GetComparableName(seriesSearchResult.SeriesName) - }; - tvdbTitles.AddRange(seriesSearchResult.Aliases.Select(GetComparableName)); - - DateTime.TryParse(seriesSearchResult.FirstAired, out var firstAired); - var remoteSearchResult = new RemoteSearchResult - { - Name = tvdbTitles.FirstOrDefault(), - ProductionYear = firstAired.Year, - SearchProviderName = Name - }; - - if (!string.IsNullOrEmpty(seriesSearchResult.Banner)) - { - // Results from their Search endpoints already include the /banners/ part in the url, because reasons... - remoteSearchResult.ImageUrl = TvdbUtils.TvdbImageBaseUrl + seriesSearchResult.Banner; - } - - try - { - var seriesSesult = - await _tvdbClientManager.GetSeriesByIdAsync(seriesSearchResult.Id, language, cancellationToken) - .ConfigureAwait(false); - remoteSearchResult.SetProviderId(MetadataProvider.Imdb, seriesSesult.Data.ImdbId); - remoteSearchResult.SetProviderId(MetadataProvider.Zap2It, seriesSesult.Data.Zap2itId); - } - catch (TvDbServerException e) - { - _logger.LogError(e, "Unable to retrieve series with id {TvdbId}", seriesSearchResult.Id); - } - - remoteSearchResult.SetProviderId(MetadataProvider.Tvdb, seriesSearchResult.Id.ToString()); - list.Add(new Tuple<List<string>, RemoteSearchResult>(tvdbTitles, remoteSearchResult)); - } - - return list - .OrderBy(i => i.Item1.Contains(comparableName, StringComparer.OrdinalIgnoreCase) ? 0 : 1) - .ThenBy(i => list.IndexOf(i)) - .Select(i => i.Item2) - .ToList(); - } - - /// <summary> - /// Gets the name of the comparable. - /// </summary> - /// <param name="name">The name.</param> - /// <returns>System.String.</returns> - private string GetComparableName(string name) - { - name = name.ToLowerInvariant(); - name = name.Normalize(NormalizationForm.FormKD); - name = name.Replace(", the", string.Empty).Replace("the ", " ").Replace(" the ", " "); - name = name.Replace("&", " and " ); - name = Regex.Replace(name, @"[\p{Lm}\p{Mn}]", string.Empty); // Remove diacritics, etc - name = Regex.Replace(name, @"[\W\p{Pc}]+", " "); // Replace sequences of non-word characters and _ with " " - return name.Trim(); - } - - private async Task MapSeriesToResult(MetadataResult<Series> result, TvDbSharper.Dto.Series tvdbSeries, string metadataLanguage) - { - Series series = result.Item; - series.SetProviderId(MetadataProvider.Tvdb, tvdbSeries.Id.ToString()); - series.Name = tvdbSeries.SeriesName; - series.Overview = (tvdbSeries.Overview ?? string.Empty).Trim(); - result.ResultLanguage = metadataLanguage; - series.AirDays = TVUtils.GetAirDays(tvdbSeries.AirsDayOfWeek); - series.AirTime = tvdbSeries.AirsTime; - series.CommunityRating = (float?)tvdbSeries.SiteRating; - series.SetProviderId(MetadataProvider.Imdb, tvdbSeries.ImdbId); - series.SetProviderId(MetadataProvider.Zap2It, tvdbSeries.Zap2itId); - if (Enum.TryParse(tvdbSeries.Status, true, out SeriesStatus seriesStatus)) - { - series.Status = seriesStatus; - } - - if (DateTime.TryParse(tvdbSeries.FirstAired, out var date)) - { - // dates from tvdb are UTC but without offset or Z - series.PremiereDate = date; - series.ProductionYear = date.Year; - } - - if (!string.IsNullOrEmpty(tvdbSeries.Runtime) && double.TryParse(tvdbSeries.Runtime, out double runtime)) - { - series.RunTimeTicks = TimeSpan.FromMinutes(runtime).Ticks; - } - - foreach (var genre in tvdbSeries.Genre) - { - series.AddGenre(genre); - } - - if (!string.IsNullOrEmpty(tvdbSeries.Network)) - { - series.AddStudio(tvdbSeries.Network); - } - - if (result.Item.Status.HasValue && result.Item.Status.Value == SeriesStatus.Ended) - { - try - { - var episodeSummary = await _tvdbClientManager.GetSeriesEpisodeSummaryAsync(tvdbSeries.Id, metadataLanguage, CancellationToken.None).ConfigureAwait(false); - - if (episodeSummary.Data.AiredSeasons.Length != 0) - { - var maxSeasonNumber = episodeSummary.Data.AiredSeasons.Max(s => Convert.ToInt32(s, CultureInfo.InvariantCulture)); - var episodeQuery = new EpisodeQuery - { - AiredSeason = maxSeasonNumber - }; - var episodesPage = await _tvdbClientManager.GetEpisodesPageAsync(tvdbSeries.Id, episodeQuery, metadataLanguage, CancellationToken.None).ConfigureAwait(false); - - result.Item.EndDate = episodesPage.Data - .Select(e => DateTime.TryParse(e.FirstAired, out var firstAired) ? firstAired : (DateTime?)null) - .Max(); - } - } - catch (TvDbServerException e) - { - _logger.LogError(e, "Failed to find series end date for series {TvdbId}", tvdbSeries.Id); - } - } - } - - private static void MapActorsToResult(MetadataResult<Series> result, IEnumerable<Actor> actors) - { - foreach (Actor actor in actors) - { - var personInfo = new PersonInfo - { - Type = PersonType.Actor, - Name = (actor.Name ?? string.Empty).Trim(), - Role = actor.Role, - SortOrder = actor.SortOrder - }; - - if (!string.IsNullOrEmpty(actor.Image)) - { - personInfo.ImageUrl = TvdbUtils.BannerUrl + actor.Image; - } - - if (!string.IsNullOrWhiteSpace(personInfo.Name)) - { - result.AddPerson(personInfo); - } - } - } - - public string Name => "TheTVDB"; - - public async Task Identify(SeriesInfo info) - { - if (!string.IsNullOrWhiteSpace(info.GetProviderId(MetadataProvider.Tvdb))) - { - return; - } - - var srch = await FindSeries(info.Name, info.Year, info.MetadataLanguage, CancellationToken.None) - .ConfigureAwait(false); - - var entry = srch.FirstOrDefault(); - - if (entry != null) - { - var id = entry.GetProviderId(MetadataProvider.Tvdb); - info.SetProviderId(MetadataProvider.Tvdb, id); - } - } - - public int Order => 0; - - public Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken cancellationToken) - { - return _httpClientFactory.CreateClient(NamedClient.Default).GetAsync(url, cancellationToken); - } - } -} diff --git a/MediaBrowser.Providers/Plugins/TheTvdb/TvdbUtils.cs b/MediaBrowser.Providers/Plugins/TheTvdb/TvdbUtils.cs deleted file mode 100644 index 37a8d04a6f..0000000000 --- a/MediaBrowser.Providers/Plugins/TheTvdb/TvdbUtils.cs +++ /dev/null @@ -1,39 +0,0 @@ -#pragma warning disable CS1591 - -using System; -using MediaBrowser.Model.Entities; - -namespace MediaBrowser.Providers.Plugins.TheTvdb -{ - public static class TvdbUtils - { - public const string TvdbApiKey = "OG4V3YJ3FAP7FP2K"; - public const string TvdbBaseUrl = "https://www.thetvdb.com/"; - public const string TvdbImageBaseUrl = "https://www.thetvdb.com"; - public const string BannerUrl = TvdbImageBaseUrl + "/banners/"; - - public static ImageType GetImageTypeFromKeyType(string keyType) - { - switch (keyType.ToLowerInvariant()) - { - case "poster": - case "season": return ImageType.Primary; - case "series": - case "seasonwide": return ImageType.Banner; - case "fanart": return ImageType.Backdrop; - default: throw new ArgumentException($"Invalid or unknown keytype: {keyType}", nameof(keyType)); - } - } - - public static string NormalizeLanguage(string language) - { - if (string.IsNullOrWhiteSpace(language)) - { - return null; - } - - // pt-br is just pt to tvdb - return language.Split('-')[0].ToLowerInvariant(); - } - } -} diff --git a/MediaBrowser.Providers/Plugins/Tmdb/BoxSets/TmdbBoxSetImageProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/BoxSets/TmdbBoxSetImageProvider.cs index df1e12240d..5ad61c567f 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/BoxSets/TmdbBoxSetImageProvider.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/BoxSets/TmdbBoxSetImageProvider.cs @@ -58,7 +58,8 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.BoxSets var language = item.GetPreferredMetadataLanguage(); - var collection = await _tmdbClientManager.GetCollectionAsync(tmdbId, language, TmdbUtils.GetImageLanguagesParam(language), cancellationToken).ConfigureAwait(false); + // TODO use image languages if All Languages isn't toggled, but there's currently no way to get that value in here + var collection = await _tmdbClientManager.GetCollectionAsync(tmdbId, null, null, cancellationToken).ConfigureAwait(false); if (collection?.Images == null) { diff --git a/MediaBrowser.Providers/Plugins/Tmdb/BoxSets/TmdbBoxSetProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/BoxSets/TmdbBoxSetProvider.cs index fcd8e614c1..5dd1f0b73a 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/BoxSets/TmdbBoxSetProvider.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/BoxSets/TmdbBoxSetProvider.cs @@ -9,6 +9,7 @@ using System.Threading; using System.Threading.Tasks; using MediaBrowser.Common.Net; using MediaBrowser.Controller.Entities.Movies; +using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Entities; using MediaBrowser.Model.Providers; @@ -19,11 +20,13 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.BoxSets { private readonly IHttpClientFactory _httpClientFactory; private readonly TmdbClientManager _tmdbClientManager; + private readonly ILibraryManager _libraryManager; - public TmdbBoxSetProvider(IHttpClientFactory httpClientFactory, TmdbClientManager tmdbClientManager) + public TmdbBoxSetProvider(IHttpClientFactory httpClientFactory, TmdbClientManager tmdbClientManager, ILibraryManager libraryManager) { _httpClientFactory = httpClientFactory; _tmdbClientManager = tmdbClientManager; + _libraryManager = libraryManager; } public string Name => TmdbUtils.ProviderName; @@ -76,14 +79,18 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.BoxSets return collections; } - public async Task<MetadataResult<BoxSet>> GetMetadata(BoxSetInfo id, CancellationToken cancellationToken) + public async Task<MetadataResult<BoxSet>> GetMetadata(BoxSetInfo info, CancellationToken cancellationToken) { - var tmdbId = Convert.ToInt32(id.GetProviderId(MetadataProvider.Tmdb), CultureInfo.InvariantCulture); - var language = id.MetadataLanguage; + var tmdbId = Convert.ToInt32(info.GetProviderId(MetadataProvider.Tmdb), CultureInfo.InvariantCulture); + var language = info.MetadataLanguage; // We don't already have an Id, need to fetch it if (tmdbId <= 0) { - var searchResults = await _tmdbClientManager.SearchCollectionAsync(id.Name, language, cancellationToken).ConfigureAwait(false); + // ParseName is required here. + // Caller provides the filename with extension stripped and NOT the parsed filename + var parsedName = _libraryManager.ParseName(info.Name); + var cleanedName = TmdbUtils.CleanName(parsedName.Name); + var searchResults = await _tmdbClientManager.SearchCollectionAsync(cleanedName, language, cancellationToken).ConfigureAwait(false); if (searchResults != null && searchResults.Count > 0) { diff --git a/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieImageProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieImageProvider.cs index dac9e961c6..f34d689c1a 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieImageProvider.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieImageProvider.cs @@ -73,8 +73,9 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.Movies return Enumerable.Empty<RemoteImageInfo>(); } + // TODO use image languages if All Languages isn't toggled, but there's currently no way to get that value in here var movie = await _tmdbClientManager - .GetMovieAsync(movieTmdbId, language, TmdbUtils.GetImageLanguagesParam(language), cancellationToken) + .GetMovieAsync(movieTmdbId, null, null, cancellationToken) .ConfigureAwait(false); if (movie?.Images == null) diff --git a/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieProvider.cs index 3984e49537..54f8d450ac 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieProvider.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieProvider.cs @@ -14,6 +14,8 @@ using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Entities; using MediaBrowser.Model.Providers; +using TMDbLib.Objects.Find; +using TMDbLib.Objects.Search; namespace MediaBrowser.Providers.Plugins.Tmdb.Movies { @@ -43,63 +45,89 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.Movies public async Task<IEnumerable<RemoteSearchResult>> GetSearchResults(MovieInfo searchInfo, CancellationToken cancellationToken) { - var tmdbId = Convert.ToInt32(searchInfo.GetProviderId(MetadataProvider.Tmdb), CultureInfo.InvariantCulture); - - if (tmdbId == 0) + if (searchInfo.TryGetProviderId(MetadataProvider.Tmdb, out var id)) { - var movieResults = await _tmdbClientManager - .SearchMovieAsync(searchInfo.Name, searchInfo.MetadataLanguage, cancellationToken) + var movie = await _tmdbClientManager + .GetMovieAsync( + int.Parse(id, CultureInfo.InvariantCulture), + searchInfo.MetadataLanguage, + TmdbUtils.GetImageLanguagesParam(searchInfo.MetadataLanguage), + cancellationToken) .ConfigureAwait(false); - var remoteSearchResults = new List<RemoteSearchResult>(); - for (var i = 0; i < movieResults.Count; i++) + + var remoteResult = new RemoteSearchResult { - var movieResult = movieResults[i]; - var remoteSearchResult = new RemoteSearchResult - { - Name = movieResult.Title ?? movieResult.OriginalTitle, - ImageUrl = _tmdbClientManager.GetPosterUrl(movieResult.PosterPath), - Overview = movieResult.Overview, - SearchProviderName = Name - }; + Name = movie.Title ?? movie.OriginalTitle, + SearchProviderName = Name, + ImageUrl = _tmdbClientManager.GetPosterUrl(movie.PosterPath), + Overview = movie.Overview + }; - var releaseDate = movieResult.ReleaseDate?.ToUniversalTime(); - remoteSearchResult.PremiereDate = releaseDate; - remoteSearchResult.ProductionYear = releaseDate?.Year; + if (movie.ReleaseDate != null) + { + var releaseDate = movie.ReleaseDate.Value.ToUniversalTime(); + remoteResult.PremiereDate = releaseDate; + remoteResult.ProductionYear = releaseDate.Year; + } - remoteSearchResult.SetProviderId(MetadataProvider.Tmdb, movieResult.Id.ToString(CultureInfo.InvariantCulture)); - remoteSearchResults.Add(remoteSearchResult); + remoteResult.SetProviderId(MetadataProvider.Tmdb, movie.Id.ToString(CultureInfo.InvariantCulture)); + + if (!string.IsNullOrWhiteSpace(movie.ImdbId)) + { + remoteResult.SetProviderId(MetadataProvider.Imdb, movie.ImdbId); } - return remoteSearchResults; + return new[] { remoteResult }; } - var movie = await _tmdbClientManager - .GetMovieAsync(tmdbId, searchInfo.MetadataLanguage, TmdbUtils.GetImageLanguagesParam(searchInfo.MetadataLanguage), cancellationToken) - .ConfigureAwait(false); - - var remoteResult = new RemoteSearchResult + IReadOnlyList<SearchMovie> movieResults; + if (searchInfo.TryGetProviderId(MetadataProvider.Imdb, out id)) { - Name = movie.Title ?? movie.OriginalTitle, - SearchProviderName = Name, - ImageUrl = _tmdbClientManager.GetPosterUrl(movie.PosterPath), - Overview = movie.Overview - }; - - if (movie.ReleaseDate != null) + var result = await _tmdbClientManager.FindByExternalIdAsync( + id, + FindExternalSource.Imdb, + TmdbUtils.GetImageLanguagesParam(searchInfo.MetadataLanguage), + cancellationToken).ConfigureAwait(false); + movieResults = result.MovieResults; + } + else if (searchInfo.TryGetProviderId(MetadataProvider.Tvdb, out id)) { - var releaseDate = movie.ReleaseDate.Value.ToUniversalTime(); - remoteResult.PremiereDate = releaseDate; - remoteResult.ProductionYear = releaseDate.Year; + var result = await _tmdbClientManager.FindByExternalIdAsync( + id, + FindExternalSource.TvDb, + TmdbUtils.GetImageLanguagesParam(searchInfo.MetadataLanguage), + cancellationToken).ConfigureAwait(false); + movieResults = result.MovieResults; + } + else + { + movieResults = await _tmdbClientManager + .SearchMovieAsync(searchInfo.Name, searchInfo.Year ?? 0, searchInfo.MetadataLanguage, cancellationToken) + .ConfigureAwait(false); } - remoteResult.SetProviderId(MetadataProvider.Tmdb, movie.Id.ToString(CultureInfo.InvariantCulture)); - - if (!string.IsNullOrWhiteSpace(movie.ImdbId)) + var len = movieResults.Count; + var remoteSearchResults = new RemoteSearchResult[len]; + for (var i = 0; i < len; i++) { - remoteResult.SetProviderId(MetadataProvider.Imdb, movie.ImdbId); + var movieResult = movieResults[i]; + var remoteSearchResult = new RemoteSearchResult + { + Name = movieResult.Title ?? movieResult.OriginalTitle, + ImageUrl = _tmdbClientManager.GetPosterUrl(movieResult.PosterPath), + Overview = movieResult.Overview, + SearchProviderName = Name + }; + + var releaseDate = movieResult.ReleaseDate?.ToUniversalTime(); + remoteSearchResult.PremiereDate = releaseDate; + remoteSearchResult.ProductionYear = releaseDate?.Year; + + remoteSearchResult.SetProviderId(MetadataProvider.Tmdb, movieResult.Id.ToString(CultureInfo.InvariantCulture)); + remoteSearchResults[i] = remoteSearchResult; } - return new[] { remoteResult }; + return remoteSearchResults; } public async Task<MetadataResult<Movie>> GetMetadata(MovieInfo info, CancellationToken cancellationToken) @@ -112,7 +140,8 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.Movies // ParseName is required here. // Caller provides the filename with extension stripped and NOT the parsed filename var parsedName = _libraryManager.ParseName(info.Name); - var searchResults = await _tmdbClientManager.SearchMovieAsync(parsedName.Name, parsedName.Year ?? 0, info.MetadataLanguage, cancellationToken).ConfigureAwait(false); + var cleanedName = TmdbUtils.CleanName(parsedName.Name); + var searchResults = await _tmdbClientManager.SearchMovieAsync(cleanedName, info.Year ?? parsedName.Year ?? 0, info.MetadataLanguage, cancellationToken).ConfigureAwait(false); if (searchResults.Count > 0) { @@ -120,6 +149,15 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.Movies } } + if (string.IsNullOrEmpty(tmdbId) && !string.IsNullOrEmpty(imdbId)) + { + var movieResultFromImdbId = await _tmdbClientManager.FindByExternalIdAsync(imdbId, FindExternalSource.Imdb, info.MetadataLanguage, cancellationToken).ConfigureAwait(false); + if (movieResultFromImdbId?.MovieResults.Count > 0) + { + tmdbId = movieResultFromImdbId.MovieResults[0].Id.ToString(CultureInfo.InvariantCulture); + } + } + if (string.IsNullOrEmpty(tmdbId)) { return new MetadataResult<Movie>(); @@ -129,9 +167,15 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.Movies .GetMovieAsync(Convert.ToInt32(tmdbId, CultureInfo.InvariantCulture), info.MetadataLanguage, TmdbUtils.GetImageLanguagesParam(info.MetadataLanguage), cancellationToken) .ConfigureAwait(false); + if (movieResult == null) + { + return new MetadataResult<Movie>(); + } + var movie = new Movie { Name = movieResult.Title ?? movieResult.OriginalTitle, + OriginalTitle = movieResult.OriginalTitle, Overview = movieResult.Overview?.Replace("\n\n", "\n", StringComparison.InvariantCulture), Tagline = movieResult.Tagline, ProductionLocations = movieResult.ProductionCountries.Select(pc => pc.Name).ToArray() @@ -162,12 +206,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.Movies if (ourRelease != null) { - var ratingPrefix = string.Equals(info.MetadataCountryCode, "us", StringComparison.OrdinalIgnoreCase) ? string.Empty : info.MetadataCountryCode + "-"; - var newRating = ratingPrefix + ourRelease.Certification; - - newRating = newRating.Replace("de-", "FSK-", StringComparison.OrdinalIgnoreCase); - - movie.OfficialRating = newRating; + movie.OfficialRating = TmdbUtils.BuildParentalRating(ourRelease.Iso_3166_1, ourRelease.Certification); } else if (usRelease != null) { @@ -266,7 +305,6 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.Movies } } - if (movieResult.Videos?.Results != null) { var trailers = new List<MediaUrl>(); diff --git a/MediaBrowser.Providers/Plugins/Tmdb/People/TmdbPersonImageProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/People/TmdbPersonImageProvider.cs index 3f57c4bc4c..e4c908a62c 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/People/TmdbPersonImageProvider.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/People/TmdbPersonImageProvider.cs @@ -1,6 +1,5 @@ #pragma warning disable CS1591 -using System; using System.Collections.Generic; using System.Globalization; using System.Linq; @@ -49,37 +48,36 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.People public async Task<IEnumerable<RemoteImageInfo>> GetImages(BaseItem item, CancellationToken cancellationToken) { var person = (Person)item; - var personTmdbId = Convert.ToInt32(person.GetProviderId(MetadataProvider.Tmdb), CultureInfo.InvariantCulture); - if (personTmdbId > 0) + if (!person.TryGetProviderId(MetadataProvider.Tmdb, out var personTmdbId)) { - var personResult = await _tmdbClientManager.GetPersonAsync(personTmdbId, cancellationToken).ConfigureAwait(false); - if (personResult?.Images?.Profiles == null) - { - return Enumerable.Empty<RemoteImageInfo>(); - } + return Enumerable.Empty<RemoteImageInfo>(); + } - var remoteImages = new List<RemoteImageInfo>(); - var language = item.GetPreferredMetadataLanguage(); + var language = item.GetPreferredMetadataLanguage(); + var personResult = await _tmdbClientManager.GetPersonAsync(int.Parse(personTmdbId, CultureInfo.InvariantCulture), language, cancellationToken).ConfigureAwait(false); + if (personResult?.Images?.Profiles == null) + { + return Enumerable.Empty<RemoteImageInfo>(); + } - for (var i = 0; i < personResult.Images.Profiles.Count; i++) - { - var image = personResult.Images.Profiles[i]; - remoteImages.Add(new RemoteImageInfo - { - ProviderName = Name, - Type = ImageType.Primary, - Width = image.Width, - Height = image.Height, - Language = TmdbUtils.AdjustImageLanguage(image.Iso_639_1, language), - Url = _tmdbClientManager.GetProfileUrl(image.FilePath) - }); - } + var remoteImages = new RemoteImageInfo[personResult.Images.Profiles.Count]; - return remoteImages.OrderByLanguageDescending(language); + for (var i = 0; i < personResult.Images.Profiles.Count; i++) + { + var image = personResult.Images.Profiles[i]; + remoteImages[i] = new RemoteImageInfo + { + ProviderName = Name, + Type = ImageType.Primary, + Width = image.Width, + Height = image.Height, + Language = TmdbUtils.AdjustImageLanguage(image.Iso_639_1, language), + Url = _tmdbClientManager.GetProfileUrl(image.FilePath) + }; } - return Enumerable.Empty<RemoteImageInfo>(); + return remoteImages.OrderByLanguageDescending(language); } public Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken cancellationToken) diff --git a/MediaBrowser.Providers/Plugins/Tmdb/People/TmdbPersonProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/People/TmdbPersonProvider.cs index 4384c203e5..dac1183889 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/People/TmdbPersonProvider.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/People/TmdbPersonProvider.cs @@ -3,7 +3,6 @@ using System; using System.Collections.Generic; using System.Globalization; -using System.Linq; using System.Net.Http; using System.Threading; using System.Threading.Tasks; @@ -30,11 +29,9 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.People public async Task<IEnumerable<RemoteSearchResult>> GetSearchResults(PersonLookupInfo searchInfo, CancellationToken cancellationToken) { - var personTmdbId = Convert.ToInt32(searchInfo.GetProviderId(MetadataProvider.Tmdb), CultureInfo.InvariantCulture); - - if (personTmdbId <= 0) + if (searchInfo.TryGetProviderId(MetadataProvider.Tmdb, out var personTmdbId)) { - var personResult = await _tmdbClientManager.GetPersonAsync(personTmdbId, cancellationToken).ConfigureAwait(false); + var personResult = await _tmdbClientManager.GetPersonAsync(int.Parse(personTmdbId, CultureInfo.InvariantCulture), searchInfo.MetadataLanguage, cancellationToken).ConfigureAwait(false); if (personResult != null) { @@ -51,19 +48,15 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.People } result.SetProviderId(MetadataProvider.Tmdb, personResult.Id.ToString(CultureInfo.InvariantCulture)); - result.SetProviderId(MetadataProvider.Imdb, personResult.ExternalIds.ImdbId); + if (!string.IsNullOrEmpty(personResult.ExternalIds.ImdbId)) + { + result.SetProviderId(MetadataProvider.Imdb, personResult.ExternalIds.ImdbId); + } return new[] { result }; } } - // TODO why? Because of the old rate limit? - if (searchInfo.IsAutomated) - { - // Don't hammer moviedb searching by name - return Enumerable.Empty<RemoteSearchResult>(); - } - var personSearchResult = await _tmdbClientManager.SearchPersonAsync(searchInfo.Name, cancellationToken).ConfigureAwait(false); var remoteSearchResults = new List<RemoteSearchResult>(); @@ -84,14 +77,14 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.People return remoteSearchResults; } - public async Task<MetadataResult<Person>> GetMetadata(PersonLookupInfo id, CancellationToken cancellationToken) + public async Task<MetadataResult<Person>> GetMetadata(PersonLookupInfo info, CancellationToken cancellationToken) { - var personTmdbId = Convert.ToInt32(id.GetProviderId(MetadataProvider.Tmdb), CultureInfo.InvariantCulture); + var personTmdbId = Convert.ToInt32(info.GetProviderId(MetadataProvider.Tmdb), CultureInfo.InvariantCulture); // We don't already have an Id, need to fetch it if (personTmdbId <= 0) { - var personSearchResults = await _tmdbClientManager.SearchPersonAsync(id.Name, cancellationToken).ConfigureAwait(false); + var personSearchResults = await _tmdbClientManager.SearchPersonAsync(info.Name, cancellationToken).ConfigureAwait(false); if (personSearchResults.Count > 0) { personTmdbId = personSearchResults[0].Id; @@ -102,7 +95,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.People if (personTmdbId > 0) { - var person = await _tmdbClientManager.GetPersonAsync(personTmdbId, cancellationToken).ConfigureAwait(false); + var person = await _tmdbClientManager.GetPersonAsync(personTmdbId, info.MetadataLanguage, cancellationToken).ConfigureAwait(false); result.HasMetadata = true; @@ -110,7 +103,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.People { // Take name from incoming info, don't rename the person // TODO: This should go in PersonMetadataService, not each person provider - Name = id.Name, + Name = info.Name, HomePageUrl = person.Homepage, Overview = person.Biography, PremiereDate = person.Birthday?.ToUniversalTime(), diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbEpisodeImageProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbEpisodeImageProvider.cs index 3b7a0b254e..ba18c542fe 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbEpisodeImageProvider.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbEpisodeImageProvider.cs @@ -63,8 +63,9 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV var language = item.GetPreferredMetadataLanguage(); + // TODO use image languages if All Languages isn't toggled, but there's currently no way to get that value in here var episodeResult = await _tmdbClientManager - .GetEpisodeAsync(seriesTmdbId, seasonNumber.Value, episodeNumber.Value, language, TmdbUtils.GetImageLanguagesParam(language), cancellationToken) + .GetEpisodeAsync(seriesTmdbId, seasonNumber.Value, episodeNumber.Value, series.DisplayOrder, null, null, cancellationToken) .ConfigureAwait(false); var stills = episodeResult?.Images?.Stills; diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbEpisodeProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbEpisodeProvider.cs index 93998a1102..8ec8f64641 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbEpisodeProvider.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbEpisodeProvider.cs @@ -92,7 +92,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV } var episodeResult = await _tmdbClientManager - .GetEpisodeAsync(seriesTmdbId, seasonNumber.Value, episodeNumber.Value, info.MetadataLanguage, TmdbUtils.GetImageLanguagesParam(info.MetadataLanguage), cancellationToken) + .GetEpisodeAsync(seriesTmdbId, seasonNumber.Value, episodeNumber.Value, info.SeriesDisplayOrder, info.MetadataLanguage, TmdbUtils.GetImageLanguagesParam(info.MetadataLanguage), cancellationToken) .ConfigureAwait(false); if (episodeResult == null) @@ -111,24 +111,31 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV var item = new Episode { - Name = info.Name, IndexNumber = info.IndexNumber, ParentIndexNumber = info.ParentIndexNumber, - IndexNumberEnd = info.IndexNumberEnd + IndexNumberEnd = info.IndexNumberEnd, + Name = episodeResult.Name, + PremiereDate = episodeResult.AirDate, + ProductionYear = episodeResult.AirDate?.Year, + Overview = episodeResult.Overview, + CommunityRating = Convert.ToSingle(episodeResult.VoteAverage) }; - if (!string.IsNullOrEmpty(episodeResult.ExternalIds?.TvdbId)) + var externalIds = episodeResult.ExternalIds; + if (!string.IsNullOrEmpty(externalIds?.TvdbId)) { - item.SetProviderId(MetadataProvider.Tvdb, episodeResult.ExternalIds.TvdbId); + item.SetProviderId(MetadataProvider.Tvdb, externalIds.TvdbId); } - item.PremiereDate = episodeResult.AirDate; - item.ProductionYear = episodeResult.AirDate?.Year; - - item.Name = episodeResult.Name; - item.Overview = episodeResult.Overview; + if (!string.IsNullOrEmpty(externalIds?.ImdbId)) + { + item.SetProviderId(MetadataProvider.Imdb, externalIds.ImdbId); + } - item.CommunityRating = Convert.ToSingle(episodeResult.VoteAverage); + if (!string.IsNullOrEmpty(externalIds?.TvrageId)) + { + item.SetProviderId(MetadataProvider.TvRage, externalIds.TvrageId); + } if (episodeResult.Videos?.Results != null) { diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeasonImageProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeasonImageProvider.cs index f4ed480aef..0d23c7872f 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeasonImageProvider.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeasonImageProvider.cs @@ -52,8 +52,9 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV var language = item.GetPreferredMetadataLanguage(); + // TODO use image languages if All Languages isn't toggled, but there's currently no way to get that value in here var seasonResult = await _tmdbClientManager - .GetSeasonAsync(seriesTmdbId, season.IndexNumber.Value, language, TmdbUtils.GetImageLanguagesParam(language), cancellationToken) + .GetSeasonAsync(seriesTmdbId, season.IndexNumber.Value, null, null, cancellationToken) .ConfigureAwait(false); var posters = seasonResult?.Images?.Posters; diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeasonProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeasonProvider.cs index 6ca462474a..66e30115dc 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeasonProvider.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeasonProvider.cs @@ -54,9 +54,8 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV result.HasMetadata = true; result.Item = new Season { - Name = info.Name, IndexNumber = seasonNumber, - Overview = seasonResult?.Overview + Overview = seasonResult.Overview }; if (!string.IsNullOrEmpty(seasonResult.ExternalIds?.TvdbId)) diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesImageProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesImageProvider.cs index d0c6b8b886..326c116b3b 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesImageProvider.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesImageProvider.cs @@ -54,13 +54,14 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV if (string.IsNullOrEmpty(tmdbId)) { - return null; + return Enumerable.Empty<RemoteImageInfo>(); } var language = item.GetPreferredMetadataLanguage(); + // TODO use image languages if All Languages isn't toggled, but there's currently no way to get that value in here var series = await _tmdbClientManager - .GetSeriesAsync(Convert.ToInt32(tmdbId, CultureInfo.InvariantCulture), language, TmdbUtils.GetImageLanguagesParam(language), cancellationToken) + .GetSeriesAsync(Convert.ToInt32(tmdbId, CultureInfo.InvariantCulture), null, null, cancellationToken) .ConfigureAwait(false); if (series?.Images == null) diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesProvider.cs index 942c85b90d..da76345b5b 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesProvider.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesProvider.cs @@ -10,6 +10,7 @@ using System.Threading.Tasks; using MediaBrowser.Common.Net; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.TV; +using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Entities; using MediaBrowser.Model.Providers; @@ -22,15 +23,17 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV public class TmdbSeriesProvider : IRemoteMetadataProvider<Series, SeriesInfo>, IHasOrder { private readonly IHttpClientFactory _httpClientFactory; + private readonly ILibraryManager _libraryManager; private readonly TmdbClientManager _tmdbClientManager; public TmdbSeriesProvider( + ILibraryManager libraryManager, IHttpClientFactory httpClientFactory, TmdbClientManager tmdbClientManager) { + _libraryManager = libraryManager; _httpClientFactory = httpClientFactory; _tmdbClientManager = tmdbClientManager; - Current = this; } public string Name => TmdbUtils.ProviderName; @@ -38,13 +41,9 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV // After TheTVDB public int Order => 1; - internal static TmdbSeriesProvider Current { get; private set; } - public async Task<IEnumerable<RemoteSearchResult>> GetSearchResults(SeriesInfo searchInfo, CancellationToken cancellationToken) { - var tmdbId = searchInfo.GetProviderId(MetadataProvider.Tmdb); - - if (!string.IsNullOrEmpty(tmdbId)) + if (searchInfo.TryGetProviderId(MetadataProvider.Tmdb, out var tmdbId)) { var series = await _tmdbClientManager .GetSeriesAsync(Convert.ToInt32(tmdbId, CultureInfo.InvariantCulture), searchInfo.MetadataLanguage, searchInfo.MetadataLanguage, cancellationToken) @@ -58,9 +57,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV } } - var imdbId = searchInfo.GetProviderId(MetadataProvider.Imdb); - - if (!string.IsNullOrEmpty(imdbId)) + if (searchInfo.TryGetProviderId(MetadataProvider.Imdb, out var imdbId)) { var findResult = await _tmdbClientManager .FindByExternalIdAsync(imdbId, FindExternalSource.Imdb, searchInfo.MetadataLanguage, cancellationToken) @@ -81,9 +78,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV } } - var tvdbId = searchInfo.GetProviderId(MetadataProvider.Tvdb); - - if (!string.IsNullOrEmpty(tvdbId)) + if (searchInfo.TryGetProviderId(MetadataProvider.Tvdb, out var tvdbId)) { var findResult = await _tmdbClientManager .FindByExternalIdAsync(tvdbId, FindExternalSource.TvDb, searchInfo.MetadataLanguage, cancellationToken) @@ -104,7 +99,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV } } - var tvSearchResults = await _tmdbClientManager.SearchSeriesAsync(searchInfo.Name, searchInfo.MetadataLanguage, cancellationToken) + var tvSearchResults = await _tmdbClientManager.SearchSeriesAsync(searchInfo.Name, searchInfo.MetadataLanguage, cancellationToken: cancellationToken) .ConfigureAwait(false); var remoteResults = new RemoteSearchResult[tvSearchResults.Count]; @@ -170,40 +165,32 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV var tmdbId = info.GetProviderId(MetadataProvider.Tmdb); - if (string.IsNullOrEmpty(tmdbId)) + if (string.IsNullOrEmpty(tmdbId) && info.TryGetProviderId(MetadataProvider.Imdb, out var imdbId)) { - var imdbId = info.GetProviderId(MetadataProvider.Imdb); - - if (!string.IsNullOrEmpty(imdbId)) + var searchResult = await _tmdbClientManager.FindByExternalIdAsync(imdbId, FindExternalSource.Imdb, info.MetadataLanguage, cancellationToken).ConfigureAwait(false); + if (searchResult?.TvResults.Count > 0) { - var searchResult = await _tmdbClientManager.FindByExternalIdAsync(imdbId, FindExternalSource.Imdb, info.MetadataLanguage, cancellationToken).ConfigureAwait(false); - - if (searchResult != null) - { - tmdbId = searchResult.TvResults.FirstOrDefault()?.Id.ToString(CultureInfo.InvariantCulture); - } + tmdbId = searchResult.TvResults[0].Id.ToString(CultureInfo.InvariantCulture); } } - if (string.IsNullOrEmpty(tmdbId)) + if (string.IsNullOrEmpty(tmdbId) && info.TryGetProviderId(MetadataProvider.Tvdb, out var tvdbId)) { - var tvdbId = info.GetProviderId(MetadataProvider.Tvdb); - - if (!string.IsNullOrEmpty(tvdbId)) + var searchResult = await _tmdbClientManager.FindByExternalIdAsync(tvdbId, FindExternalSource.TvDb, info.MetadataLanguage, cancellationToken).ConfigureAwait(false); + if (searchResult?.TvResults.Count > 0) { - var searchResult = await _tmdbClientManager.FindByExternalIdAsync(tvdbId, FindExternalSource.TvDb, info.MetadataLanguage, cancellationToken).ConfigureAwait(false); - - if (searchResult != null) - { - tmdbId = searchResult.TvResults.FirstOrDefault()?.Id.ToString(CultureInfo.InvariantCulture); - } + tmdbId = searchResult.TvResults[0].Id.ToString(CultureInfo.InvariantCulture); } } if (string.IsNullOrEmpty(tmdbId)) { result.QueriedById = false; - var searchResults = await _tmdbClientManager.SearchSeriesAsync(info.Name, info.MetadataLanguage, cancellationToken).ConfigureAwait(false); + // ParseName is required here. + // Caller provides the filename with extension stripped and NOT the parsed filename + var parsedName = _libraryManager.ParseName(info.Name); + var cleanedName = TmdbUtils.CleanName(parsedName.Name); + var searchResults = await _tmdbClientManager.SearchSeriesAsync(cleanedName, info.MetadataLanguage, info.Year ?? parsedName.Year ?? 0, cancellationToken).ConfigureAwait(false); if (searchResults.Count > 0) { @@ -211,32 +198,34 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV } } - if (!string.IsNullOrEmpty(tmdbId)) + if (string.IsNullOrEmpty(tmdbId)) { - cancellationToken.ThrowIfCancellationRequested(); + return result; + } - var tvShow = await _tmdbClientManager - .GetSeriesAsync(Convert.ToInt32(tmdbId, CultureInfo.InvariantCulture), info.MetadataLanguage, TmdbUtils.GetImageLanguagesParam(info.MetadataLanguage), cancellationToken) - .ConfigureAwait(false); + cancellationToken.ThrowIfCancellationRequested(); - result = new MetadataResult<Series> - { - Item = MapTvShowToSeries(tvShow, info.MetadataCountryCode), - ResultLanguage = info.MetadataLanguage ?? tvShow.OriginalLanguage - }; + var tvShow = await _tmdbClientManager + .GetSeriesAsync(Convert.ToInt32(tmdbId, CultureInfo.InvariantCulture), info.MetadataLanguage, TmdbUtils.GetImageLanguagesParam(info.MetadataLanguage), cancellationToken) + .ConfigureAwait(false); - foreach (var person in GetPersons(tvShow)) - { - result.AddPerson(person); - } + result = new MetadataResult<Series> + { + Item = MapTvShowToSeries(tvShow, info.MetadataCountryCode), + ResultLanguage = info.MetadataLanguage ?? tvShow.OriginalLanguage + }; - result.HasMetadata = result.Item != null; + foreach (var person in GetPersons(tvShow)) + { + result.AddPerson(person); } + result.HasMetadata = result.Item != null; + return result; } - private Series MapTvShowToSeries(TvShow seriesResult, string preferredCountryCode) + private static Series MapTvShowToSeries(TvShow seriesResult, string preferredCountryCode) { var series = new Series { @@ -311,7 +300,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV if (ourRelease != null) { - series.OfficialRating = ourRelease.Rating; + series.OfficialRating = TmdbUtils.BuildParentalRating(ourRelease.Iso_3166_1, ourRelease.Rating); } else if (usRelease != null) { diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TmdbClientManager.cs b/MediaBrowser.Providers/Plugins/Tmdb/TmdbClientManager.cs index 2dc5cd55da..4de4bf4db6 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/TmdbClientManager.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/TmdbClientManager.cs @@ -18,7 +18,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb /// <summary> /// Manager class for abstracting the TMDb API client library. /// </summary> - public class TmdbClientManager + public class TmdbClientManager : IDisposable { private const int CacheDurationInHours = 1; @@ -125,7 +125,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb tmdbId, language: TmdbUtils.NormalizeLanguage(language), includeImageLanguage: imageLanguages, - extraMethods: TvShowMethods.Credits | TvShowMethods.Images | TvShowMethods.Keywords | TvShowMethods.ExternalIds | TvShowMethods.Videos | TvShowMethods.ContentRatings, + extraMethods: TvShowMethods.Credits | TvShowMethods.Images | TvShowMethods.Keywords | TvShowMethods.ExternalIds | TvShowMethods.Videos | TvShowMethods.ContentRatings | TvShowMethods.EpisodeGroups, cancellationToken: cancellationToken).ConfigureAwait(false); if (series != null) @@ -136,6 +136,56 @@ namespace MediaBrowser.Providers.Plugins.Tmdb return series; } + /// <summary> + /// Gets a tv show episode group from the TMDb API based on the show id and the display order. + /// </summary> + /// <param name="tvShowId">The tv show's TMDb id.</param> + /// <param name="displayOrder">The display order.</param> + /// <param name="language">The tv show's language.</param> + /// <param name="imageLanguages">A comma-separated list of image languages.</param> + /// <param name="cancellationToken">The cancellation token.</param> + /// <returns>The TMDb tv show episode group information or null if not found.</returns> + private async Task<TvGroupCollection> GetSeriesGroupAsync(int tvShowId, string displayOrder, string language, string imageLanguages, CancellationToken cancellationToken) + { + TvGroupType? groupType = + string.Equals(displayOrder, "absolute", StringComparison.Ordinal) ? TvGroupType.Absolute : + string.Equals(displayOrder, "dvd", StringComparison.Ordinal) ? TvGroupType.DVD : + null; + + if (groupType == null) + { + return null; + } + + var key = $"group-{tvShowId.ToString(CultureInfo.InvariantCulture)}-{displayOrder}-{language}"; + if (_memoryCache.TryGetValue(key, out TvGroupCollection group)) + { + return group; + } + + await EnsureClientConfigAsync().ConfigureAwait(false); + + var series = await GetSeriesAsync(tvShowId, language, imageLanguages, cancellationToken).ConfigureAwait(false); + var episodeGroupId = series?.EpisodeGroups.Results.Find(g => g.Type == groupType)?.Id; + + if (episodeGroupId == null) + { + return null; + } + + group = await _tmDbClient.GetTvEpisodeGroupsAsync( + episodeGroupId, + language: TmdbUtils.NormalizeLanguage(language), + cancellationToken: cancellationToken).ConfigureAwait(false); + + if (group != null) + { + _memoryCache.Set(key, group, TimeSpan.FromHours(CacheDurationInHours)); + } + + return group; + } + /// <summary> /// Gets a tv season from the TMDb API based on the tv show's TMDb id. /// </summary> @@ -177,13 +227,14 @@ namespace MediaBrowser.Providers.Plugins.Tmdb /// <param name="tvShowId">The tv show's TMDb id.</param> /// <param name="seasonNumber">The season number.</param> /// <param name="episodeNumber">The episode number.</param> + /// <param name="displayOrder">The display order.</param> /// <param name="language">The episode's language.</param> /// <param name="imageLanguages">A comma-separated list of image languages.</param> /// <param name="cancellationToken">The cancellation token.</param> /// <returns>The TMDb tv episode information or null if not found.</returns> - public async Task<TvEpisode> GetEpisodeAsync(int tvShowId, int seasonNumber, int episodeNumber, string language, string imageLanguages, CancellationToken cancellationToken) + public async Task<TvEpisode> GetEpisodeAsync(int tvShowId, int seasonNumber, int episodeNumber, string displayOrder, string language, string imageLanguages, CancellationToken cancellationToken) { - var key = $"episode-{tvShowId.ToString(CultureInfo.InvariantCulture)}-s{seasonNumber.ToString(CultureInfo.InvariantCulture)}e{episodeNumber.ToString(CultureInfo.InvariantCulture)}-{language}"; + var key = $"episode-{tvShowId.ToString(CultureInfo.InvariantCulture)}-s{seasonNumber.ToString(CultureInfo.InvariantCulture)}e{episodeNumber.ToString(CultureInfo.InvariantCulture)}-{displayOrder}-{language}"; if (_memoryCache.TryGetValue(key, out TvEpisode episode)) { return episode; @@ -191,6 +242,19 @@ namespace MediaBrowser.Providers.Plugins.Tmdb await EnsureClientConfigAsync().ConfigureAwait(false); + var group = await GetSeriesGroupAsync(tvShowId, displayOrder, language, imageLanguages, cancellationToken).ConfigureAwait(false); + if (group != null) + { + var season = group.Groups.Find(s => s.Order == seasonNumber); + // Episode order starts at 0 + var ep = season?.Episodes.Find(e => e.Order == episodeNumber - 1); + if (ep != null) + { + seasonNumber = ep.SeasonNumber; + episodeNumber = ep.EpisodeNumber; + } + } + episode = await _tmDbClient.GetTvEpisodeAsync( tvShowId, seasonNumber, @@ -212,11 +276,12 @@ namespace MediaBrowser.Providers.Plugins.Tmdb /// Gets a person eg. cast or crew member from the TMDb API based on its TMDb id. /// </summary> /// <param name="personTmdbId">The person's TMDb id.</param> + /// <param name="language">The episode's language.</param> /// <param name="cancellationToken">The cancellation token.</param> /// <returns>The TMDb person information or null if not found.</returns> - public async Task<Person> GetPersonAsync(int personTmdbId, CancellationToken cancellationToken) + public async Task<Person> GetPersonAsync(int personTmdbId, string language, CancellationToken cancellationToken) { - var key = $"person-{personTmdbId.ToString(CultureInfo.InvariantCulture)}"; + var key = $"person-{personTmdbId.ToString(CultureInfo.InvariantCulture)}-{language}"; if (_memoryCache.TryGetValue(key, out Person person)) { return person; @@ -226,6 +291,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb person = await _tmDbClient.GetPersonAsync( personTmdbId, + TmdbUtils.NormalizeLanguage(language), PersonMethods.TvCredits | PersonMethods.MovieCredits | PersonMethods.Images | PersonMethods.ExternalIds, cancellationToken).ConfigureAwait(false); @@ -278,9 +344,10 @@ namespace MediaBrowser.Providers.Plugins.Tmdb /// </summary> /// <param name="name">The name of the tv show.</param> /// <param name="language">The tv show's language.</param> + /// <param name="year">The year the tv show first aired.</param> /// <param name="cancellationToken">The cancellation token.</param> /// <returns>The TMDb tv show information.</returns> - public async Task<IReadOnlyList<SearchTv>> SearchSeriesAsync(string name, string language, CancellationToken cancellationToken) + public async Task<IReadOnlyList<SearchTv>> SearchSeriesAsync(string name, string language, int year = 0, CancellationToken cancellationToken = default) { var key = $"searchseries-{name}-{language}"; if (_memoryCache.TryGetValue(key, out SearchContainer<SearchTv> series)) @@ -291,7 +358,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb await EnsureClientConfigAsync().ConfigureAwait(false); var searchResults = await _tmDbClient - .SearchTvShowAsync(name, TmdbUtils.NormalizeLanguage(language), cancellationToken: cancellationToken) + .SearchTvShowAsync(name, TmdbUtils.NormalizeLanguage(language), firstAirDateYear: year, cancellationToken: cancellationToken) .ConfigureAwait(false); if (searchResults.Results.Count > 0) @@ -465,5 +532,25 @@ namespace MediaBrowser.Providers.Plugins.Tmdb { return !_tmDbClient.HasConfig ? _tmDbClient.GetConfigAsync() : Task.CompletedTask; } + + /// <inheritdoc /> + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + +/// <summary> + /// Releases unmanaged and - optionally - managed resources. + /// </summary> + /// <param name="disposing"><c>true</c> to release both managed and unmanaged resources; <c>false</c> to release only unmanaged resources.</param> + protected virtual void Dispose(bool disposing) + { + if (disposing) + { + _memoryCache?.Dispose(); + _tmDbClient?.Dispose(); + } + } } } diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TmdbUtils.cs b/MediaBrowser.Providers/Plugins/Tmdb/TmdbUtils.cs index b754a07953..b713736a0f 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/TmdbUtils.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/TmdbUtils.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; +using System.Text.RegularExpressions; using MediaBrowser.Model.Entities; using TMDbLib.Objects.General; @@ -12,6 +13,8 @@ namespace MediaBrowser.Providers.Plugins.Tmdb /// </summary> public static class TmdbUtils { + private static readonly Regex _nonWords = new (@"[\W_]+", RegexOptions.Compiled); + /// <summary> /// URL of the TMDB instance to use. /// </summary> @@ -42,6 +45,17 @@ namespace MediaBrowser.Providers.Plugins.Tmdb PersonType.Producer }; + /// <summary> + /// Cleans the name according to TMDb requirements. + /// </summary> + /// <param name="name">The name of the entity.</param> + /// <returns>The cleaned name.</returns> + public static string CleanName(string name) + { + // TMDb expects a space separated list of words make sure that is the case + return _nonWords.Replace(name, " "); + } + /// <summary> /// Maps the TMDB provided roles for crew members to Jellyfin roles. /// </summary> @@ -49,19 +63,19 @@ namespace MediaBrowser.Providers.Plugins.Tmdb /// <returns>The Jellyfin person type.</returns> public static string MapCrewToPersonType(Crew crew) { - if (crew.Department.Equals("production", StringComparison.InvariantCultureIgnoreCase) - && crew.Job.Contains("director", StringComparison.InvariantCultureIgnoreCase)) + if (crew.Department.Equals("production", StringComparison.OrdinalIgnoreCase) + && crew.Job.Contains("director", StringComparison.OrdinalIgnoreCase)) { return PersonType.Director; } - if (crew.Department.Equals("production", StringComparison.InvariantCultureIgnoreCase) - && crew.Job.Contains("producer", StringComparison.InvariantCultureIgnoreCase)) + if (crew.Department.Equals("production", StringComparison.OrdinalIgnoreCase) + && crew.Job.Contains("producer", StringComparison.OrdinalIgnoreCase)) { return PersonType.Producer; } - if (crew.Department.Equals("writing", StringComparison.InvariantCultureIgnoreCase)) + if (crew.Department.Equals("writing", StringComparison.OrdinalIgnoreCase)) { return PersonType.Writer; } @@ -98,7 +112,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb if (preferredLanguage.Length == 5) // like en-US { - // Currenty, TMDB supports 2-letter language codes only + // Currently, TMDB supports 2-letter language codes only // They are planning to change this in the future, thus we're // supplying both codes if we're having a 5-letter code. languages.Add(preferredLanguage.Substring(0, 2)); @@ -134,6 +148,12 @@ namespace MediaBrowser.Providers.Plugins.Tmdb if (parts.Length == 2) { + // TMDB doesn't support Switzerland (de-CH, it-CH or fr-CH) so use the language (de, it or fr) without country code + if (string.Equals(parts[1], "CH", StringComparison.OrdinalIgnoreCase)) + { + return parts[0]; + } + language = parts[0] + "-" + parts[1].ToUpperInvariant(); } @@ -159,5 +179,20 @@ namespace MediaBrowser.Providers.Plugins.Tmdb return imageLanguage; } + + /// <summary> + /// Combines the metadata country code and the parental rating from the Api into the value we store in our database. + /// </summary> + /// <param name="countryCode">The Iso 3166-1 country code of the rating country.</param> + /// <param name="ratingValue">The rating value returned by the Tmdb Api.</param> + /// <returns>The combined parental rating of country code+rating value.</returns> + public static string BuildParentalRating(string countryCode, string ratingValue) + { + // exclude US because we store us values as TV-14 without the country code. + var ratingPrefix = string.Equals(countryCode, "US", StringComparison.OrdinalIgnoreCase) ? string.Empty : countryCode + "-"; + var newRating = ratingPrefix + ratingValue; + + return newRating.Replace("DE-", "FSK-", StringComparison.OrdinalIgnoreCase); + } } } diff --git a/MediaBrowser.Providers/Properties/AssemblyInfo.cs b/MediaBrowser.Providers/Properties/AssemblyInfo.cs index f1c46899ce..bd301b5f08 100644 --- a/MediaBrowser.Providers/Properties/AssemblyInfo.cs +++ b/MediaBrowser.Providers/Properties/AssemblyInfo.cs @@ -1,5 +1,6 @@ using System.Reflection; using System.Resources; +using System.Runtime.CompilerServices; using System.Runtime.InteropServices; // General Information about an assembly is controlled through the following @@ -14,6 +15,7 @@ using System.Runtime.InteropServices; [assembly: AssemblyTrademark("")] [assembly: AssemblyCulture("")] [assembly: NeutralResourcesLanguage("en")] +[assembly: InternalsVisibleTo("Jellyfin.Providers.Tests")] // Setting ComVisible to false makes the types in this assembly not visible // to COM components. If you need to access a type in this assembly from diff --git a/MediaBrowser.Providers/Studios/StudioMetadataService.cs b/MediaBrowser.Providers/Studios/StudioMetadataService.cs index 78042b40de..091b33ce0e 100644 --- a/MediaBrowser.Providers/Studios/StudioMetadataService.cs +++ b/MediaBrowser.Providers/Studios/StudioMetadataService.cs @@ -17,7 +17,8 @@ namespace MediaBrowser.Providers.Studios IServerConfigurationManager serverConfigurationManager, ILogger<StudioMetadataService> logger, IProviderManager providerManager, - IFileSystem fileSystem, ILibraryManager libraryManager) + IFileSystem fileSystem, + ILibraryManager libraryManager) : base(serverConfigurationManager, logger, providerManager, fileSystem, libraryManager) { } diff --git a/MediaBrowser.Providers/Studios/StudiosImageProvider.cs b/MediaBrowser.Providers/Studios/StudiosImageProvider.cs index 90e13f12f8..63e78d15e8 100644 --- a/MediaBrowser.Providers/Studios/StudiosImageProvider.cs +++ b/MediaBrowser.Providers/Studios/StudiosImageProvider.cs @@ -8,6 +8,7 @@ using System.Linq; using System.Net.Http; using System.Threading; using System.Threading.Tasks; +using Jellyfin.Extensions; using MediaBrowser.Common.Net; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Entities; @@ -144,7 +145,7 @@ namespace MediaBrowser.Providers.Studios var httpClient = _httpClientFactory.CreateClient(NamedClient.Default); Directory.CreateDirectory(Path.GetDirectoryName(file)); - await using var response = await httpClient.GetStreamAsync(url).ConfigureAwait(false); + await using var response = await httpClient.GetStreamAsync(url, cancellationToken).ConfigureAwait(false); await using var fileStream = new FileStream(file, FileMode.Create); await response.CopyToAsync(fileStream, cancellationToken).ConfigureAwait(false); } @@ -171,25 +172,19 @@ namespace MediaBrowser.Providers.Studios public IEnumerable<string> GetAvailableImages(string file) { - using (var fileStream = new FileStream(file, FileMode.Open, FileAccess.Read, FileShare.Read)) + using var fileStream = new FileStream(file, FileMode.Open, FileAccess.Read, FileShare.Read); + using var reader = new StreamReader(fileStream); + var lines = new List<string>(); + + foreach (var line in reader.ReadAllLines()) { - using (var reader = new StreamReader(fileStream)) + if (!string.IsNullOrWhiteSpace(line)) { - var lines = new List<string>(); - - while (!reader.EndOfStream) - { - var text = reader.ReadLine(); - - if (!string.IsNullOrWhiteSpace(text)) - { - lines.Add(text); - } - } - - return lines; + lines.Add(line); } } + + return lines; } } } diff --git a/MediaBrowser.Providers/Subtitles/SubtitleManager.cs b/MediaBrowser.Providers/Subtitles/SubtitleManager.cs index f25d3d5ee7..0c791a2fee 100644 --- a/MediaBrowser.Providers/Subtitles/SubtitleManager.cs +++ b/MediaBrowser.Providers/Subtitles/SubtitleManager.cs @@ -147,40 +147,14 @@ namespace MediaBrowser.Providers.Subtitles string subtitleId, CancellationToken cancellationToken) { - var parts = subtitleId.Split(new[] { '_' }, 2); + var parts = subtitleId.Split('_', 2); var provider = GetProvider(parts[0]); - var saveInMediaFolder = libraryOptions.SaveSubtitlesWithMedia; - try { var response = await GetRemoteSubtitles(subtitleId, cancellationToken).ConfigureAwait(false); - using (var stream = response.Stream) - using (var memoryStream = new MemoryStream()) - { - await stream.CopyToAsync(memoryStream).ConfigureAwait(false); - memoryStream.Position = 0; - - var savePaths = new List<string>(); - var saveFileName = Path.GetFileNameWithoutExtension(video.Path) + "." + response.Language.ToLowerInvariant(); - - if (response.IsForced) - { - saveFileName += ".forced"; - } - - saveFileName += "." + response.Format.ToLowerInvariant(); - - if (saveInMediaFolder) - { - savePaths.Add(Path.Combine(video.ContainingFolderPath, saveFileName)); - } - - savePaths.Add(Path.Combine(video.GetInternalMetadataPath(), saveFileName)); - - await TrySaveToFiles(memoryStream, savePaths).ConfigureAwait(false); - } + await TrySaveSubtitle(video, libraryOptions, response).ConfigureAwait(false); } catch (RateLimitExceededException) { @@ -199,9 +173,66 @@ namespace MediaBrowser.Providers.Subtitles } } + /// <inheritdoc /> + public Task UploadSubtitle(Video video, SubtitleResponse response) + { + var libraryOptions = BaseItem.LibraryManager.GetLibraryOptions(video); + return TrySaveSubtitle(video, libraryOptions, response); + } + + private async Task TrySaveSubtitle( + Video video, + LibraryOptions libraryOptions, + SubtitleResponse response) + { + var saveInMediaFolder = libraryOptions.SaveSubtitlesWithMedia; + + using var stream = response.Stream; + using var memoryStream = new MemoryStream(); + await stream.CopyToAsync(memoryStream).ConfigureAwait(false); + memoryStream.Position = 0; + + var savePaths = new List<string>(); + var saveFileName = Path.GetFileNameWithoutExtension(video.Path) + "." + response.Language.ToLowerInvariant(); + + if (response.IsForced) + { + saveFileName += ".forced"; + } + + saveFileName += "." + response.Format.ToLowerInvariant(); + + if (saveInMediaFolder) + { + var mediaFolderPath = Path.GetFullPath(Path.Combine(video.ContainingFolderPath, saveFileName)); + // TODO: Add some error handling to the API user: return BadRequest("Could not save subtitle, bad path."); + if (mediaFolderPath.StartsWith(video.ContainingFolderPath, StringComparison.Ordinal)) + { + savePaths.Add(mediaFolderPath); + } + } + + var internalPath = Path.GetFullPath(Path.Combine(video.GetInternalMetadataPath(), saveFileName)); + + // TODO: Add some error to the user: return BadRequest("Could not save subtitle, bad path."); + if (internalPath.StartsWith(video.GetInternalMetadataPath(), StringComparison.Ordinal)) + { + savePaths.Add(internalPath); + } + + if (savePaths.Count > 0) + { + await TrySaveToFiles(memoryStream, savePaths).ConfigureAwait(false); + } + else + { + _logger.LogError("An uploaded subtitle could not be saved because the resulting paths were invalid."); + } + } + private async Task TrySaveToFiles(Stream stream, List<string> savePaths) { - Exception exceptionToThrow = null; + List<Exception> exs = null; foreach (var savePath in savePaths) { @@ -213,20 +244,23 @@ namespace MediaBrowser.Providers.Subtitles { Directory.CreateDirectory(Path.GetDirectoryName(savePath)); - using (var fs = new FileStream(savePath, FileMode.Create, FileAccess.Write, FileShare.Read, FileStreamBufferSize, true)) - { - await stream.CopyToAsync(fs).ConfigureAwait(false); - } + // use FileShare.None as this bypasses dotnet bug dotnet/runtime#42790 . + using var fs = new FileStream(savePath, FileMode.Create, FileAccess.Write, FileShare.None, FileStreamBufferSize, true); + await stream.CopyToAsync(fs).ConfigureAwait(false); return; } catch (Exception ex) { - if (exceptionToThrow == null) - { - exceptionToThrow = ex; - } - } +// Bug in analyzer -- https://github.com/dotnet/roslyn-analyzers/issues/5160 +#pragma warning disable CA1508 + exs ??= new List<Exception>() + { + ex + }; +#pragma warning restore CA1508 + + } finally { _monitor.ReportFileSystemChangeComplete(savePath, false); @@ -235,9 +269,9 @@ namespace MediaBrowser.Providers.Subtitles stream.Position = 0; } - if (exceptionToThrow != null) + if (exs != null) { - throw exceptionToThrow; + throw new AggregateException(exs); } } @@ -314,7 +348,7 @@ namespace MediaBrowser.Providers.Subtitles Index = index, ItemId = item.Id, Type = MediaStreamType.Subtitle - }).First(); + })[0]; var path = stream.Path; _monitor.ReportFileSystemChangeBeginning(path); @@ -334,24 +368,24 @@ namespace MediaBrowser.Providers.Subtitles /// <inheritdoc /> public Task<SubtitleResponse> GetRemoteSubtitles(string id, CancellationToken cancellationToken) { - var parts = id.Split(new[] { '_' }, 2); + var parts = id.Split('_', 2); var provider = GetProvider(parts[0]); - id = parts.Last(); + id = parts[^1]; return provider.GetSubtitles(id, cancellationToken); } /// <inheritdoc /> - public SubtitleProviderInfo[] GetSupportedProviders(BaseItem video) + public SubtitleProviderInfo[] GetSupportedProviders(BaseItem item) { VideoContentType mediaType; - if (video is Episode) + if (item is Episode) { mediaType = VideoContentType.Episode; } - else if (video is Movie) + else if (item is Movie) { mediaType = VideoContentType.Movie; } diff --git a/MediaBrowser.Providers/TV/EpisodeMetadataService.cs b/MediaBrowser.Providers/TV/EpisodeMetadataService.cs index 170f1bdd8c..08cb6ced90 100644 --- a/MediaBrowser.Providers/TV/EpisodeMetadataService.cs +++ b/MediaBrowser.Providers/TV/EpisodeMetadataService.cs @@ -25,46 +25,46 @@ namespace MediaBrowser.Providers.TV } /// <inheritdoc /> - protected override ItemUpdateType BeforeSaveInternal(Episode item, bool isFullRefresh, ItemUpdateType currentUpdateType) + protected override ItemUpdateType BeforeSaveInternal(Episode item, bool isFullRefresh, ItemUpdateType updateType) { - var updateType = base.BeforeSaveInternal(item, isFullRefresh, currentUpdateType); + var updatedType = base.BeforeSaveInternal(item, isFullRefresh, updateType); var seriesName = item.FindSeriesName(); if (!string.Equals(item.SeriesName, seriesName, StringComparison.Ordinal)) { item.SeriesName = seriesName; - updateType |= ItemUpdateType.MetadataImport; + updatedType |= ItemUpdateType.MetadataImport; } var seasonName = item.FindSeasonName(); if (!string.Equals(item.SeasonName, seasonName, StringComparison.Ordinal)) { item.SeasonName = seasonName; - updateType |= ItemUpdateType.MetadataImport; + updatedType |= ItemUpdateType.MetadataImport; } var seriesId = item.FindSeriesId(); if (!item.SeriesId.Equals(seriesId)) { item.SeriesId = seriesId; - updateType |= ItemUpdateType.MetadataImport; + updatedType |= ItemUpdateType.MetadataImport; } var seasonId = item.FindSeasonId(); if (!item.SeasonId.Equals(seasonId)) { item.SeasonId = seasonId; - updateType |= ItemUpdateType.MetadataImport; + updatedType |= ItemUpdateType.MetadataImport; } var seriesPresentationUniqueKey = item.FindSeriesPresentationUniqueKey(); if (!string.Equals(item.SeriesPresentationUniqueKey, seriesPresentationUniqueKey, StringComparison.Ordinal)) { item.SeriesPresentationUniqueKey = seriesPresentationUniqueKey; - updateType |= ItemUpdateType.MetadataImport; + updatedType |= ItemUpdateType.MetadataImport; } - return updateType; + return updatedType; } /// <inheritdoc /> diff --git a/MediaBrowser.Providers/TV/SeasonMetadataService.cs b/MediaBrowser.Providers/TV/SeasonMetadataService.cs index 4e59f78bc4..0f22f8a9b6 100644 --- a/MediaBrowser.Providers/TV/SeasonMetadataService.cs +++ b/MediaBrowser.Providers/TV/SeasonMetadataService.cs @@ -31,9 +31,9 @@ namespace MediaBrowser.Providers.TV protected override bool EnableUpdatingPremiereDateFromChildren => true; /// <inheritdoc /> - protected override ItemUpdateType BeforeSaveInternal(Season item, bool isFullRefresh, ItemUpdateType currentUpdateType) + protected override ItemUpdateType BeforeSaveInternal(Season item, bool isFullRefresh, ItemUpdateType updateType) { - var updateType = base.BeforeSaveInternal(item, isFullRefresh, currentUpdateType); + var updatedType = base.BeforeSaveInternal(item, isFullRefresh, updateType); if (item.IndexNumber.HasValue && item.IndexNumber.Value == 0) { @@ -42,7 +42,7 @@ namespace MediaBrowser.Providers.TV if (!string.Equals(item.Name, seasonZeroDisplayName, StringComparison.OrdinalIgnoreCase)) { item.Name = seasonZeroDisplayName; - updateType = updateType | ItemUpdateType.MetadataEdit; + updatedType = updatedType | ItemUpdateType.MetadataEdit; } } @@ -50,24 +50,24 @@ namespace MediaBrowser.Providers.TV if (!string.Equals(item.SeriesName, seriesName, StringComparison.Ordinal)) { item.SeriesName = seriesName; - updateType |= ItemUpdateType.MetadataImport; + updatedType |= ItemUpdateType.MetadataImport; } var seriesPresentationUniqueKey = item.FindSeriesPresentationUniqueKey(); if (!string.Equals(item.SeriesPresentationUniqueKey, seriesPresentationUniqueKey, StringComparison.Ordinal)) { item.SeriesPresentationUniqueKey = seriesPresentationUniqueKey; - updateType |= ItemUpdateType.MetadataImport; + updatedType |= ItemUpdateType.MetadataImport; } var seriesId = item.FindSeriesId(); if (!item.SeriesId.Equals(seriesId)) { item.SeriesId = seriesId; - updateType |= ItemUpdateType.MetadataImport; + updatedType |= ItemUpdateType.MetadataImport; } - return updateType; + return updatedType; } /// <inheritdoc /> diff --git a/MediaBrowser.Providers/TV/SeriesMetadataService.cs b/MediaBrowser.Providers/TV/SeriesMetadataService.cs index c8fc568a22..dcb6934086 100644 --- a/MediaBrowser.Providers/TV/SeriesMetadataService.cs +++ b/MediaBrowser.Providers/TV/SeriesMetadataService.cs @@ -1,10 +1,16 @@ #pragma warning disable CS1591 +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Entities.TV; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Entities; +using MediaBrowser.Model.Globalization; using MediaBrowser.Model.IO; using MediaBrowser.Providers.Manager; using Microsoft.Extensions.Logging; @@ -13,14 +19,27 @@ namespace MediaBrowser.Providers.TV { public class SeriesMetadataService : MetadataService<Series, SeriesInfo> { + private readonly ILocalizationManager _localizationManager; + public SeriesMetadataService( IServerConfigurationManager serverConfigurationManager, ILogger<SeriesMetadataService> logger, IProviderManager providerManager, IFileSystem fileSystem, - ILibraryManager libraryManager) + ILibraryManager libraryManager, + ILocalizationManager localizationManager) : base(serverConfigurationManager, logger, providerManager, fileSystem, libraryManager) { + _localizationManager = localizationManager; + } + + /// <inheritdoc /> + protected override async Task AfterMetadataRefresh(Series item, MetadataRefreshOptions refreshOptions, CancellationToken cancellationToken) + { + await base.AfterMetadataRefresh(item, refreshOptions, cancellationToken).ConfigureAwait(false); + + RemoveObsoleteSeasons(item); + await FillInMissingSeasonsAsync(item, cancellationToken).ConfigureAwait(false); } /// <inheritdoc /> @@ -62,5 +81,117 @@ namespace MediaBrowser.Providers.TV targetItem.AirDays = sourceItem.AirDays; } } + + private void RemoveObsoleteSeasons(Series series) + { + // TODO Legacy. It's not really "physical" seasons as any virtual seasons are always converted to non-virtual in FillInMissingSeasonsAsync. + var physicalSeasonNumbers = new HashSet<int>(); + var virtualSeasons = new List<Season>(); + foreach (var existingSeason in series.Children.OfType<Season>()) + { + if (existingSeason.LocationType != LocationType.Virtual && existingSeason.IndexNumber.HasValue) + { + physicalSeasonNumbers.Add(existingSeason.IndexNumber.Value); + } + else if (existingSeason.LocationType == LocationType.Virtual) + { + virtualSeasons.Add(existingSeason); + } + } + + foreach (var virtualSeason in virtualSeasons) + { + var seasonNumber = virtualSeason.IndexNumber; + // If there's a physical season with the same number or no episodes in the season, delete it + if ((seasonNumber.HasValue && physicalSeasonNumbers.Contains(seasonNumber.Value)) + || !virtualSeason.GetEpisodes().Any()) + { + Logger.LogInformation("Removing virtual season {SeasonNumber} in series {SeriesName}", virtualSeason.IndexNumber, series.Name); + + LibraryManager.DeleteItem( + virtualSeason, + new DeleteOptions + { + DeleteFileLocation = true + }, + false); + } + } + } + + /// <summary> + /// Creates seasons for all episodes that aren't in a season folder. + /// If no season number can be determined, a dummy season will be created. + /// </summary> + /// <param name="series">The series.</param> + /// <param name="cancellationToken">The cancellation token.</param> + /// <returns>The async task.</returns> + private async Task FillInMissingSeasonsAsync(Series series, CancellationToken cancellationToken) + { + var episodesInSeriesFolder = series.GetRecursiveChildren(i => i is Episode) + .Cast<Episode>() + .Where(i => !i.IsInSeasonFolder); + + List<Season> seasons = series.Children.OfType<Season>().ToList(); + + // Loop through the unique season numbers + foreach (var episode in episodesInSeriesFolder) + { + // Null season numbers will have a 'dummy' season created because seasons are always required. + var seasonNumber = episode.ParentIndexNumber >= 0 ? episode.ParentIndexNumber : null; + var existingSeason = seasons.FirstOrDefault(i => i.IndexNumber == seasonNumber); + + if (existingSeason == null) + { + var season = await CreateSeasonAsync(series, seasonNumber, cancellationToken).ConfigureAwait(false); + seasons.Add(season); + } + else if (existingSeason.IsVirtualItem) + { + existingSeason.IsVirtualItem = false; + await existingSeason.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, cancellationToken).ConfigureAwait(false); + } + } + } + + /// <summary> + /// Creates a new season, adds it to the database by linking it to the [series] and refreshes the metadata. + /// </summary> + /// <param name="series">The series.</param> + /// <param name="seasonNumber">The season number.</param> + /// <param name="cancellationToken">The cancellation token.</param> + /// <returns>The newly created season.</returns> + private async Task<Season> CreateSeasonAsync( + Series series, + int? seasonNumber, + CancellationToken cancellationToken) + { + string seasonName = seasonNumber switch + { + null => _localizationManager.GetLocalizedString("NameSeasonUnknown"), + 0 => LibraryManager.GetLibraryOptions(series).SeasonZeroDisplayName, + _ => string.Format(CultureInfo.InvariantCulture, _localizationManager.GetLocalizedString("NameSeasonNumber"), seasonNumber.Value) + }; + + Logger.LogInformation("Creating Season {SeasonName} entry for {SeriesName}", seasonName, series.Name); + + var season = new Season + { + Name = seasonName, + IndexNumber = seasonNumber, + Id = LibraryManager.GetNewItemId( + series.Id + (seasonNumber ?? -1).ToString(CultureInfo.InvariantCulture) + seasonName, + typeof(Season)), + IsVirtualItem = false, + SeriesId = series.Id, + SeriesName = series.Name + }; + + series.AddChild(season); + + await season.RefreshMetadata(new MetadataRefreshOptions(new DirectoryService(FileSystem)), cancellationToken).ConfigureAwait(false); + + return season; + } } } diff --git a/MediaBrowser.Providers/TV/TvdbEpisodeExternalId.cs b/MediaBrowser.Providers/TV/TvdbEpisodeExternalId.cs deleted file mode 100644 index 40c5f2d785..0000000000 --- a/MediaBrowser.Providers/TV/TvdbEpisodeExternalId.cs +++ /dev/null @@ -1,28 +0,0 @@ -#pragma warning disable CS1591 - -using MediaBrowser.Controller.Entities.TV; -using MediaBrowser.Controller.Providers; -using MediaBrowser.Model.Entities; -using MediaBrowser.Model.Providers; -using MediaBrowser.Providers.Plugins.TheTvdb; - -namespace MediaBrowser.Providers.TV -{ - public class TvdbEpisodeExternalId : IExternalId - { - /// <inheritdoc /> - public string ProviderName => "TheTVDB"; - - /// <inheritdoc /> - public string Key => MetadataProvider.Tvdb.ToString(); - - /// <inheritdoc /> - public ExternalIdMediaType? Type => ExternalIdMediaType.Episode; - - /// <inheritdoc /> - public string UrlFormatString => TvdbUtils.TvdbBaseUrl + "?tab=episode&id={0}"; - - /// <inheritdoc /> - public bool Supports(IHasProviderIds item) => item is Episode; - } -} diff --git a/MediaBrowser.Providers/TV/TvdbExternalId.cs b/MediaBrowser.Providers/TV/TvdbExternalId.cs deleted file mode 100644 index 4c54de9f82..0000000000 --- a/MediaBrowser.Providers/TV/TvdbExternalId.cs +++ /dev/null @@ -1,28 +0,0 @@ -#pragma warning disable CS1591 - -using MediaBrowser.Controller.Entities.TV; -using MediaBrowser.Controller.Providers; -using MediaBrowser.Model.Entities; -using MediaBrowser.Model.Providers; -using MediaBrowser.Providers.Plugins.TheTvdb; - -namespace MediaBrowser.Providers.TV -{ - public class TvdbExternalId : IExternalId - { - /// <inheritdoc /> - public string ProviderName => "TheTVDB"; - - /// <inheritdoc /> - public string Key => MetadataProvider.Tvdb.ToString(); - - /// <inheritdoc /> - public ExternalIdMediaType? Type => null; - - /// <inheritdoc /> - public string UrlFormatString => TvdbUtils.TvdbBaseUrl + "?tab=series&id={0}"; - - /// <inheritdoc /> - public bool Supports(IHasProviderIds item) => item is Series; - } -} diff --git a/MediaBrowser.Providers/TV/TvdbSeasonExternalId.cs b/MediaBrowser.Providers/TV/TvdbSeasonExternalId.cs deleted file mode 100644 index 807ebb3eee..0000000000 --- a/MediaBrowser.Providers/TV/TvdbSeasonExternalId.cs +++ /dev/null @@ -1,28 +0,0 @@ -#pragma warning disable CS1591 - -using MediaBrowser.Controller.Entities.TV; -using MediaBrowser.Controller.Providers; -using MediaBrowser.Model.Entities; -using MediaBrowser.Model.Providers; -using MediaBrowser.Providers.Plugins.TheTvdb; - -namespace MediaBrowser.Providers.TV -{ - public class TvdbSeasonExternalId : IExternalId - { - /// <inheritdoc /> - public string ProviderName => "TheTVDB"; - - /// <inheritdoc /> - public string Key => MetadataProvider.Tvdb.ToString(); - - /// <inheritdoc /> - public ExternalIdMediaType? Type => ExternalIdMediaType.Season; - - /// <inheritdoc /> - public string UrlFormatString => null; - - /// <inheritdoc /> - public bool Supports(IHasProviderIds item) => item is Season; - } -} diff --git a/MediaBrowser.Providers/TV/Zap2ItExternalId.cs b/MediaBrowser.Providers/TV/Zap2ItExternalId.cs index c9f314af94..3cb18e4248 100644 --- a/MediaBrowser.Providers/TV/Zap2ItExternalId.cs +++ b/MediaBrowser.Providers/TV/Zap2ItExternalId.cs @@ -4,7 +4,6 @@ using MediaBrowser.Controller.Entities.TV; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Entities; using MediaBrowser.Model.Providers; -using MediaBrowser.Providers.Plugins.TheTvdb; namespace MediaBrowser.Providers.TV { diff --git a/MediaBrowser.XbmcMetadata/EntryPoint.cs b/MediaBrowser.XbmcMetadata/EntryPoint.cs index 11b36285c9..d02aea5566 100644 --- a/MediaBrowser.XbmcMetadata/EntryPoint.cs +++ b/MediaBrowser.XbmcMetadata/EntryPoint.cs @@ -41,7 +41,7 @@ namespace MediaBrowser.XbmcMetadata return Task.CompletedTask; } - private void OnUserDataSaved(object sender, UserDataSaveEventArgs e) + private void OnUserDataSaved(object? sender, UserDataSaveEventArgs e) { if (e.SaveReason == UserDataSaveReason.PlaybackFinished || e.SaveReason == UserDataSaveReason.TogglePlayed || e.SaveReason == UserDataSaveReason.UpdateUserRating) { @@ -60,17 +60,7 @@ namespace MediaBrowser.XbmcMetadata private void SaveMetadataForItem(BaseItem item, ItemUpdateType updateReason) { - if (!item.IsFileProtocol) - { - return; - } - - if (!item.SupportsLocalMetadata) - { - return; - } - - if (!item.IsSaveLocalMetadataEnabled()) + if (!item.IsFileProtocol || !item.SupportsLocalMetadata) { return; } diff --git a/MediaBrowser.XbmcMetadata/MediaBrowser.XbmcMetadata.csproj b/MediaBrowser.XbmcMetadata/MediaBrowser.XbmcMetadata.csproj index 45fd9add92..3e2a9bacf1 100644 --- a/MediaBrowser.XbmcMetadata/MediaBrowser.XbmcMetadata.csproj +++ b/MediaBrowser.XbmcMetadata/MediaBrowser.XbmcMetadata.csproj @@ -15,22 +15,16 @@ </ItemGroup> <PropertyGroup> - <TargetFramework>netstandard2.1</TargetFramework> + <TargetFramework>net5.0</TargetFramework> <GenerateAssemblyInfo>false</GenerateAssemblyInfo> <GenerateDocumentationFile>true</GenerateDocumentationFile> - <TreatWarningsAsErrors>true</TreatWarningsAsErrors> </PropertyGroup> <!-- Code Analyzers--> <ItemGroup Condition=" '$(Configuration)' == 'Debug' "> - <PackageReference Include="Microsoft.CodeAnalysis.FxCopAnalyzers" Version="2.9.8" PrivateAssets="All" /> <PackageReference Include="SerilogAnalyzer" Version="0.15.0" PrivateAssets="All" /> <PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="All" /> <PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" PrivateAssets="All" /> </ItemGroup> - <PropertyGroup Condition=" '$(Configuration)' == 'Debug' "> - <CodeAnalysisRuleSet>../jellyfin.ruleset</CodeAnalysisRuleSet> - </PropertyGroup> - </Project> diff --git a/MediaBrowser.XbmcMetadata/Parsers/BaseNfoParser.cs b/MediaBrowser.XbmcMetadata/Parsers/BaseNfoParser.cs index b064644090..1125154ac8 100644 --- a/MediaBrowser.XbmcMetadata/Parsers/BaseNfoParser.cs +++ b/MediaBrowser.XbmcMetadata/Parsers/BaseNfoParser.cs @@ -6,12 +6,14 @@ using System.Globalization; using System.IO; using System.Linq; using System.Text; -using System.Text.RegularExpressions; using System.Threading; using System.Xml; using MediaBrowser.Common.Configuration; +using MediaBrowser.Common.Providers; using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.Movies; using MediaBrowser.Controller.Entities.TV; +using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Entities; using MediaBrowser.XbmcMetadata.Configuration; @@ -24,19 +26,35 @@ namespace MediaBrowser.XbmcMetadata.Parsers where T : BaseItem { private readonly IConfigurationManager _config; + private readonly IUserManager _userManager; + private readonly IUserDataManager _userDataManager; + private readonly IDirectoryService _directoryService; private Dictionary<string, string> _validProviderIds; /// <summary> /// Initializes a new instance of the <see cref="BaseNfoParser{T}" /> class. /// </summary> - /// <param name="logger">The logger.</param> - /// <param name="config">the configuration manager.</param> - /// <param name="providerManager">The provider manager.</param> - public BaseNfoParser(ILogger logger, IConfigurationManager config, IProviderManager providerManager) + /// <param name="logger">Instance of the <see cref="ILogger"/> interface.</param> + /// <param name="config">Instance of the <see cref="IConfigurationManager"/> interface.</param> + /// <param name="providerManager">Instance of the <see cref="IProviderManager"/> interface.</param> + /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> + /// <param name="userDataManager">Instance of the <see cref="IUserDataManager"/> interface.</param> + /// <param name="directoryService">Instance of the <see cref="IDirectoryService"/> interface.</param> + public BaseNfoParser( + ILogger logger, + IConfigurationManager config, + IProviderManager providerManager, + IUserManager userManager, + IUserDataManager userDataManager, + IDirectoryService directoryService) { Logger = logger; _config = config; ProviderManager = providerManager; + _validProviderIds = new Dictionary<string, string>(); + _userManager = userManager; + _userDataManager = userDataManager; + _directoryService = directoryService; } protected CultureInfo UsCulture { get; } = new CultureInfo("en-US"); @@ -50,8 +68,6 @@ namespace MediaBrowser.XbmcMetadata.Parsers protected virtual bool SupportsUrlAfterClosingXmlTag => false; - protected virtual string MovieDbParserSearchString => "themoviedb.org/movie/"; - /// <summary> /// Fetches metadata for an item from one xml file. /// </summary> @@ -62,17 +78,17 @@ namespace MediaBrowser.XbmcMetadata.Parsers /// <exception cref="ArgumentException"><c>metadataFile</c> is <c>null</c> or empty.</exception> public void Fetch(MetadataResult<T> item, string metadataFile, CancellationToken cancellationToken) { - if (item == null) + if (item.Item == null) { - throw new ArgumentNullException(nameof(item)); + throw new ArgumentException("Item can't be null.", nameof(item)); } if (string.IsNullOrEmpty(metadataFile)) { - throw new ArgumentException("The metadata file was empty or null.", nameof(metadataFile)); + throw new ArgumentException("The metadata filepath was empty.", nameof(metadataFile)); } - _validProviderIds = _validProviderIds = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase); + _validProviderIds = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase); var idInfos = ProviderManager.GetExternalIdInfos(item.Item); @@ -132,126 +148,100 @@ namespace MediaBrowser.XbmcMetadata.Parsers return; } - using (var fileStream = File.OpenRead(metadataFile)) - using (var streamReader = new StreamReader(fileStream, Encoding.UTF8)) - { - item.ResetPeople(); - - // Need to handle a url after the xml data - // http://kodi.wiki/view/NFO_files/movies + item.ResetPeople(); - var xml = streamReader.ReadToEnd(); + // Need to handle a url after the xml data + // http://kodi.wiki/view/NFO_files/movies - // Find last closing Tag - // Need to do this in two steps to account for random > characters after the closing xml - var index = xml.LastIndexOf(@"</", StringComparison.Ordinal); + var xml = File.ReadAllText(metadataFile); - // If closing tag exists, move to end of Tag - if (index != -1) - { - index = xml.IndexOf('>', index); - } + // Find last closing Tag + // Need to do this in two steps to account for random > characters after the closing xml + var index = xml.LastIndexOf(@"</", StringComparison.Ordinal); - if (index != -1) - { - var endingXml = xml.Substring(index); + // If closing tag exists, move to end of Tag + if (index != -1) + { + index = xml.IndexOf('>', index); + } - ParseProviderLinks(item.Item, endingXml); + if (index != -1) + { + var endingXml = xml.AsSpan().Slice(index); - // If the file is just an imdb url, don't go any further - if (index == 0) - { - return; - } + ParseProviderLinks(item.Item, endingXml); - xml = xml.Substring(0, index + 1); - } - else + // If the file is just an imdb url, don't go any further + if (index == 0) { - // If the file is just an Imdb url, handle that - - ParseProviderLinks(item.Item, xml); - return; } - // These are not going to be valid xml so no sense in causing the provider to fail and spamming the log with exceptions - try + xml = xml.Substring(0, index + 1); + } + else + { + // If the file is just provider urls, handle that + ParseProviderLinks(item.Item, xml); + + return; + } + + // These are not going to be valid xml so no sense in causing the provider to fail and spamming the log with exceptions + try + { + using (var stringReader = new StringReader(xml)) + using (var reader = XmlReader.Create(stringReader, settings)) { - using (var stringReader = new StringReader(xml)) - using (var reader = XmlReader.Create(stringReader, settings)) + reader.MoveToContent(); + reader.Read(); + + // Loop through each element + while (!reader.EOF && reader.ReadState == ReadState.Interactive) { - reader.MoveToContent(); - reader.Read(); + cancellationToken.ThrowIfCancellationRequested(); - // Loop through each element - while (!reader.EOF && reader.ReadState == ReadState.Interactive) + if (reader.NodeType == XmlNodeType.Element) { - cancellationToken.ThrowIfCancellationRequested(); - - if (reader.NodeType == XmlNodeType.Element) - { - FetchDataFromXmlNode(reader, item); - } - else - { - reader.Read(); - } + FetchDataFromXmlNode(reader, item); + } + else + { + reader.Read(); } } } - catch (XmlException) - { - } + } + catch (XmlException) + { } } - protected void ParseProviderLinks(T item, string xml) + protected void ParseProviderLinks(T item, ReadOnlySpan<char> xml) { - // Look for a match for the Regex pattern "tt" followed by 7 or 8 digits - var m = Regex.Match(xml, "tt([0-9]{7,8})", RegexOptions.IgnoreCase); - if (m.Success) + if (ProviderIdParsers.TryFindImdbId(xml, out var imdbId)) { - item.SetProviderId(MetadataProvider.Imdb, m.Value); + item.SetProviderId(MetadataProvider.Imdb, imdbId.ToString()); } - // Support Tmdb - // https://www.themoviedb.org/movie/30287-fallo - var srch = MovieDbParserSearchString; - var index = xml.IndexOf(srch, StringComparison.OrdinalIgnoreCase); - - if (index != -1) + if (item is Movie) { - var tmdbId = xml.AsSpan().Slice(index + srch.Length).TrimEnd('/'); - index = tmdbId.IndexOf('-'); - if (index != -1) + if (ProviderIdParsers.TryFindTmdbMovieId(xml, out var tmdbId)) { - tmdbId = tmdbId.Slice(0, index); - } - - if (!tmdbId.IsEmpty - && !tmdbId.IsWhiteSpace() - && int.TryParse(tmdbId, NumberStyles.Integer, CultureInfo.InvariantCulture, out var value)) - { - item.SetProviderId(MetadataProvider.Tmdb, value.ToString(UsCulture)); + item.SetProviderId(MetadataProvider.Tmdb, tmdbId.ToString()); } } if (item is Series) { - srch = "thetvdb.com/?tab=series&id="; - - index = xml.IndexOf(srch, StringComparison.OrdinalIgnoreCase); + if (ProviderIdParsers.TryFindTmdbSeriesId(xml, out var tmdbId)) + { + item.SetProviderId(MetadataProvider.Tmdb, tmdbId.ToString()); + } - if (index != -1) + if (ProviderIdParsers.TryFindTvdbId(xml, out var tvdbId)) { - var tvdbId = xml.AsSpan().Slice(index + srch.Length).TrimEnd('/'); - if (!tvdbId.IsEmpty - && !tvdbId.IsWhiteSpace() - && int.TryParse(tvdbId, NumberStyles.Integer, CultureInfo.InvariantCulture, out var value)) - { - item.SetProviderId(MetadataProvider.Tvdb, value.ToString(UsCulture)); - } + item.SetProviderId(MetadataProvider.Tvdb, tvdbId.ToString()); } } } @@ -260,6 +250,14 @@ namespace MediaBrowser.XbmcMetadata.Parsers { var item = itemResult.Item; + var nfoConfiguration = _config.GetNfoConfiguration(); + UserItemData? userData = null; + if (!string.IsNullOrWhiteSpace(nfoConfiguration.UserId)) + { + var user = _userManager.GetUserById(Guid.Parse(nfoConfiguration.UserId)); + userData = _userDataManager.GetUserData(user, item); + } + switch (reader.Name) { // DateCreated @@ -269,17 +267,13 @@ namespace MediaBrowser.XbmcMetadata.Parsers if (!string.IsNullOrWhiteSpace(val)) { - if (DateTime.TryParseExact(val, BaseNfoSaver.DateAddedFormat, CultureInfo.InvariantCulture, DateTimeStyles.AssumeLocal, out var added)) - { - item.DateCreated = added.ToUniversalTime(); - } - else if (DateTime.TryParse(val, CultureInfo.InvariantCulture, DateTimeStyles.AssumeLocal, out added)) + if (DateTime.TryParse(val, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var added)) { item.DateCreated = added.ToUniversalTime(); } else { - Logger.LogWarning("Invalid Added value found: " + val); + Logger.LogWarning("Invalid Added value found: {Value}", val); } } @@ -298,11 +292,16 @@ namespace MediaBrowser.XbmcMetadata.Parsers break; } + case "name": case "title": case "localtitle": item.Name = reader.ReadElementContentAsString(); break; + case "sortname": + item.SortName = reader.ReadElementContentAsString(); + break; + case "criticrating": { var text = reader.ReadElementContentAsString(); @@ -353,6 +352,50 @@ namespace MediaBrowser.XbmcMetadata.Parsers break; } + case "watched": + { + var val = reader.ReadElementContentAsBoolean(); + + if (userData != null) + { + userData.Played = val; + } + + break; + } + + case "playcount": + { + var val = reader.ReadElementContentAsString(); + if (!string.IsNullOrWhiteSpace(val) && userData != null) + { + if (int.TryParse(val, NumberStyles.Integer, UsCulture, out var count)) + { + userData.PlayCount = count; + } + } + + break; + } + + case "lastplayed": + { + var val = reader.ReadElementContentAsString(); + if (!string.IsNullOrWhiteSpace(val) && userData != null) + { + if (DateTime.TryParse(val, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var added)) + { + userData.LastPlayedDate = added.ToUniversalTime(); + } + else + { + Logger.LogWarning("Invalid lastplayed value found: {Value}", val); + } + } + + break; + } + case "countrycode": { var val = reader.ReadElementContentAsString(); @@ -376,23 +419,15 @@ namespace MediaBrowser.XbmcMetadata.Parsers } return null; - }).Where(i => i.HasValue).Select(i => i.Value).ToArray(); + }).OfType<MetadataField>().ToArray(); } break; } case "tagline": - { - var val = reader.ReadElementContentAsString(); - - if (!string.IsNullOrWhiteSpace(val)) - { - item.Tagline = val; - } - - break; - } + item.Tagline = reader.ReadElementContentAsString(); + break; case "country": { @@ -623,18 +658,33 @@ namespace MediaBrowser.XbmcMetadata.Parsers break; } + case "ratings": + { + if (!reader.IsEmptyElement) + { + using var subtree = reader.ReadSubtree(); + FetchFromRatingsNode(subtree, item); + } + else + { + reader.Read(); + } + + break; + } + case "aired": case "formed": case "premiered": case "releasedate": { - var formatString = _config.GetNfoConfiguration().ReleaseDateFormat; + var formatString = nfoConfiguration.ReleaseDateFormat; var val = reader.ReadElementContentAsString(); if (!string.IsNullOrWhiteSpace(val)) { - if (DateTime.TryParseExact(val, formatString, CultureInfo.InvariantCulture, DateTimeStyles.AssumeLocal, out var date) && date.Year > 1850) + if (DateTime.TryParseExact(val, formatString, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var date) && date.Year > 1850) { item.PremiereDate = date.ToUniversalTime(); item.ProductionYear = date.Year; @@ -646,13 +696,13 @@ namespace MediaBrowser.XbmcMetadata.Parsers case "enddate": { - var formatString = _config.GetNfoConfiguration().ReleaseDateFormat; + var formatString = nfoConfiguration.ReleaseDateFormat; var val = reader.ReadElementContentAsString(); if (!string.IsNullOrWhiteSpace(val)) { - if (DateTime.TryParseExact(val, formatString, CultureInfo.InvariantCulture, DateTimeStyles.AssumeLocal, out var date) && date.Year > 1850) + if (DateTime.TryParseExact(val, formatString, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var date) && date.Year > 1850) { item.EndDate = date.ToUniversalTime(); } @@ -709,12 +759,88 @@ namespace MediaBrowser.XbmcMetadata.Parsers break; } + case "uniqueid": + { + if (reader.IsEmptyElement) + { + reader.Read(); + break; + } + + var provider = reader.GetAttribute("type"); + var id = reader.ReadElementContentAsString(); + if (!string.IsNullOrWhiteSpace(provider) && !string.IsNullOrWhiteSpace(id)) + { + item.SetProviderId(provider, id); + } + + break; + } + + case "thumb": + { + var artType = reader.GetAttribute("aspect"); + var val = reader.ReadElementContentAsString(); + + // skip: + // - empty aspect tag + // - empty uri + // - tag containing '.' because we can't set images for seasons, episodes or movie sets within series or movies + if (string.IsNullOrEmpty(artType) || string.IsNullOrEmpty(val) || artType.Contains('.', StringComparison.Ordinal)) + { + break; + } + + ImageType imageType = GetImageType(artType); + + if (!Uri.TryCreate(val, UriKind.Absolute, out var uri)) + { + Logger.LogError("Image location {Path} specified in nfo file for {ItemName} is not a valid URL or file path.", val, item.Name); + break; + } + + if (uri.IsFile) + { + // only allow one item of each type + if (itemResult.Images.Any(x => x.Type == imageType)) + { + break; + } + + var fileSystemMetadata = _directoryService.GetFile(val); + // non existing file returns null + if (fileSystemMetadata == null || !fileSystemMetadata.Exists) + { + Logger.LogWarning("Artwork file {Path} specified in nfo file for {ItemName} does not exist.", uri, item.Name); + break; + } + + itemResult.Images.Add(new LocalImageInfo() + { + FileInfo = fileSystemMetadata, + Type = imageType + }); + } + else + { + // only allow one item of each type + if (itemResult.RemoteImages.Any(x => x.type == imageType)) + { + break; + } + + itemResult.RemoteImages.Add((uri.ToString(), imageType)); + } + + break; + } + default: string readerName = reader.Name; - if (_validProviderIds.TryGetValue(readerName, out string providerIdValue)) + if (_validProviderIds.TryGetValue(readerName, out string? providerIdValue)) { var id = reader.ReadElementContentAsString(); - if (!string.IsNullOrWhiteSpace(id)) + if (!string.IsNullOrWhiteSpace(providerIdValue) && !string.IsNullOrWhiteSpace(id)) { item.SetProviderId(providerIdValue, id); } @@ -796,6 +922,22 @@ namespace MediaBrowser.XbmcMetadata.Parsers break; } + case "subtitle": + { + if (reader.IsEmptyElement) + { + reader.Read(); + continue; + } + + using (var subtree = reader.ReadSubtree()) + { + FetchFromSubtitleNode(subtree, item); + } + + break; + } + default: reader.Skip(); break; @@ -853,6 +995,176 @@ namespace MediaBrowser.XbmcMetadata.Parsers break; } + case "aspect": + { + var val = reader.ReadElementContentAsString(); + + if (item is Video video) + { + video.AspectRatio = val; + } + + break; + } + + case "width": + { + var val = reader.ReadElementContentAsInt(); + + if (item is Video video) + { + video.Width = val; + } + + break; + } + + case "height": + { + var val = reader.ReadElementContentAsInt(); + + if (item is Video video) + { + video.Height = val; + } + + break; + } + + case "durationinseconds": + { + var val = reader.ReadElementContentAsInt(); + + if (item is Video video) + { + video.RunTimeTicks = new TimeSpan(0, 0, val).Ticks; + } + + break; + } + + default: + reader.Skip(); + break; + } + } + else + { + reader.Read(); + } + } + } + + private void FetchFromSubtitleNode(XmlReader reader, T item) + { + reader.MoveToContent(); + reader.Read(); + + // Loop through each element + while (!reader.EOF && reader.ReadState == ReadState.Interactive) + { + if (reader.NodeType == XmlNodeType.Element) + { + switch (reader.Name) + { + case "language": + { + _ = reader.ReadElementContentAsString(); + + if (item is Video video) + { + video.HasSubtitles = true; + } + + break; + } + + default: + reader.Skip(); + break; + } + } + else + { + reader.Read(); + } + } + } + + private void FetchFromRatingsNode(XmlReader reader, T item) + { + reader.MoveToContent(); + reader.Read(); + + // Loop through each element + while (!reader.EOF && reader.ReadState == ReadState.Interactive) + { + if (reader.NodeType == XmlNodeType.Element) + { + switch (reader.Name) + { + case "rating": + { + if (reader.IsEmptyElement) + { + reader.Read(); + continue; + } + + var ratingName = reader.GetAttribute("name"); + + using var subtree = reader.ReadSubtree(); + FetchFromRatingNode(subtree, item, ratingName); + + break; + } + + default: + reader.Skip(); + break; + } + } + else + { + reader.Read(); + } + } + } + + private void FetchFromRatingNode(XmlReader reader, T item, string? ratingName) + { + reader.MoveToContent(); + reader.Read(); + + // Loop through each element + while (!reader.EOF && reader.ReadState == ReadState.Interactive) + { + if (reader.NodeType == XmlNodeType.Element) + { + switch (reader.Name) + { + case "value": + var val = reader.ReadElementContentAsString(); + + if (!string.IsNullOrWhiteSpace(val)) + { + if (float.TryParse(val, NumberStyles.AllowDecimalPoint, CultureInfo.InvariantCulture, out var ratingValue)) + { + // if ratingName contains tomato --> assume critic rating + if (ratingName != null && + ratingName.Contains("tomato", StringComparison.OrdinalIgnoreCase) && + !ratingName.Contains("audience", StringComparison.OrdinalIgnoreCase)) + { + item.CriticRating = ratingValue; + } + else + { + item.CommunityRating = ratingValue; + } + } + } + + break; default: reader.Skip(); break; @@ -876,6 +1188,7 @@ namespace MediaBrowser.XbmcMetadata.Parsers var type = PersonType.Actor; // If type is not specified assume actor var role = string.Empty; int? sortOrder = null; + string? imageUrl = null; reader.MoveToContent(); reader.Read(); @@ -888,7 +1201,7 @@ namespace MediaBrowser.XbmcMetadata.Parsers switch (reader.Name) { case "name": - name = reader.ReadElementContentAsString() ?? string.Empty; + name = reader.ReadElementContentAsString(); break; case "role": @@ -903,6 +1216,30 @@ namespace MediaBrowser.XbmcMetadata.Parsers break; } + case "type": + { + var val = reader.ReadElementContentAsString(); + + if (!string.IsNullOrWhiteSpace(val)) + { + type = val switch + { + PersonType.Composer => PersonType.Composer, + PersonType.Conductor => PersonType.Conductor, + PersonType.Director => PersonType.Director, + PersonType.Lyricist => PersonType.Lyricist, + PersonType.Producer => PersonType.Producer, + PersonType.Writer => PersonType.Writer, + PersonType.GuestStar => PersonType.GuestStar, + // unknown type --> actor + _ => PersonType.Actor + }; + } + + break; + } + + case "order": case "sortorder": { var val = reader.ReadElementContentAsString(); @@ -918,6 +1255,18 @@ namespace MediaBrowser.XbmcMetadata.Parsers break; } + case "thumb": + { + var val = reader.ReadElementContentAsString(); + + if (!string.IsNullOrWhiteSpace(val)) + { + imageUrl = val; + } + + break; + } + default: reader.Skip(); break; @@ -934,7 +1283,8 @@ namespace MediaBrowser.XbmcMetadata.Parsers Name = name.Trim(), Role = role, Type = type, - SortOrder = sortOrder + SortOrder = sortOrder, + ImageUrl = imageUrl }; } @@ -954,11 +1304,9 @@ namespace MediaBrowser.XbmcMetadata.Parsers /// <returns>IEnumerable{System.String}.</returns> private IEnumerable<string> SplitNames(string value) { - value = value ?? string.Empty; - // Only split by comma if there is no pipe in the string // We have to be careful to not split names like Matthew, Jr. - var separator = value.IndexOf('|', StringComparison.Ordinal) == -1 && value.IndexOf(';', StringComparison.Ordinal) == -1 + var separator = !value.Contains('|', StringComparison.Ordinal) && !value.Contains(';', StringComparison.Ordinal) ? new[] { ',' } : new[] { '|', ';' }; @@ -966,5 +1314,25 @@ namespace MediaBrowser.XbmcMetadata.Parsers return string.IsNullOrWhiteSpace(value) ? Array.Empty<string>() : value.Split(separator, StringSplitOptions.RemoveEmptyEntries); } + + /// <summary> + /// Parses the ImageType from the nfo aspect property. + /// </summary> + /// <param name="aspect">The nfo aspect property.</param> + /// <returns>The image type.</returns> + private static ImageType GetImageType(string aspect) + { + return aspect switch + { + "banner" => ImageType.Banner, + "clearlogo" => ImageType.Logo, + "discart" => ImageType.Disc, + "landscape" => ImageType.Thumb, + "clearart" => ImageType.Art, + "fanart" => ImageType.Backdrop, + // unknown type (including "poster") --> primary + _ => ImageType.Primary, + }; + } } } diff --git a/MediaBrowser.XbmcMetadata/Parsers/EpisodeNfoParser.cs b/MediaBrowser.XbmcMetadata/Parsers/EpisodeNfoParser.cs index 9cc0344c1c..ca3ec79b78 100644 --- a/MediaBrowser.XbmcMetadata/Parsers/EpisodeNfoParser.cs +++ b/MediaBrowser.XbmcMetadata/Parsers/EpisodeNfoParser.cs @@ -6,6 +6,7 @@ using System.Threading; using System.Xml; using MediaBrowser.Common.Configuration; using MediaBrowser.Controller.Entities.TV; +using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Providers; using Microsoft.Extensions.Logging; @@ -19,60 +20,88 @@ namespace MediaBrowser.XbmcMetadata.Parsers /// <summary> /// Initializes a new instance of the <see cref="EpisodeNfoParser"/> class. /// </summary> - /// <param name="logger">The logger.</param> - /// <param name="config">the configuration manager.</param> - /// <param name="providerManager">The provider manager.</param> - public EpisodeNfoParser(ILogger logger, IConfigurationManager config, IProviderManager providerManager) - : base(logger, config, providerManager) + /// <param name="logger">Instance of the <see cref="ILogger{BaseNfoParser}"/> interface.</param> + /// <param name="config">Instance of the <see cref="IConfigurationManager"/> interface.</param> + /// <param name="providerManager">Instance of the <see cref="IProviderManager"/> interface.</param> + /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> + /// <param name="userDataManager">Instance of the <see cref="IUserDataManager"/> interface.</param> + /// <param name="directoryService">Instance of the <see cref="IDirectoryService"/> interface.</param> + public EpisodeNfoParser( + ILogger logger, + IConfigurationManager config, + IProviderManager providerManager, + IUserManager userManager, + IUserDataManager userDataManager, + IDirectoryService directoryService) + : base(logger, config, providerManager, userManager, userDataManager, directoryService) { } /// <inheritdoc /> protected override void Fetch(MetadataResult<Episode> item, string metadataFile, XmlReaderSettings settings, CancellationToken cancellationToken) { - using (var fileStream = File.OpenRead(metadataFile)) - using (var streamReader = new StreamReader(fileStream, Encoding.UTF8)) - { - item.ResetPeople(); + item.ResetPeople(); + + var xmlFile = File.ReadAllText(metadataFile); - var xml = streamReader.ReadToEnd(); + var srch = "</episodedetails>"; + var index = xmlFile.IndexOf(srch, StringComparison.OrdinalIgnoreCase); - var srch = "</episodedetails>"; - var index = xml.IndexOf(srch, StringComparison.OrdinalIgnoreCase); + var xml = xmlFile; + + if (index != -1) + { + xml = xmlFile.Substring(0, index + srch.Length); + xmlFile = xmlFile.Substring(index + srch.Length); + } - if (index != -1) + // These are not going to be valid xml so no sense in causing the provider to fail and spamming the log with exceptions + try + { + // Extract episode details from the first episodedetails block + using (var stringReader = new StringReader(xml)) + using (var reader = XmlReader.Create(stringReader, settings)) { - xml = xml.Substring(0, index + srch.Length); + reader.MoveToContent(); + reader.Read(); + + // Loop through each element + while (!reader.EOF && reader.ReadState == ReadState.Interactive) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (reader.NodeType == XmlNodeType.Element) + { + FetchDataFromXmlNode(reader, item); + } + else + { + reader.Read(); + } + } } - // These are not going to be valid xml so no sense in causing the provider to fail and spamming the log with exceptions - try + // Extract the last episode number from nfo + // This is needed because XBMC metadata uses multiple episodedetails blocks instead of episodenumberend tag + while ((index = xmlFile.IndexOf(srch, StringComparison.OrdinalIgnoreCase)) != -1) { + xml = xmlFile.Substring(0, index + srch.Length); + xmlFile = xmlFile.Substring(index + srch.Length); + using (var stringReader = new StringReader(xml)) using (var reader = XmlReader.Create(stringReader, settings)) { reader.MoveToContent(); - reader.Read(); - // Loop through each element - while (!reader.EOF && reader.ReadState == ReadState.Interactive) + if (reader.ReadToDescendant("episode") && int.TryParse(reader.ReadElementContentAsString(), out var num)) { - cancellationToken.ThrowIfCancellationRequested(); - - if (reader.NodeType == XmlNodeType.Element) - { - FetchDataFromXmlNode(reader, item); - } - else - { - reader.Read(); - } + item.Item.IndexNumberEnd = Math.Max(num, item.Item.IndexNumberEnd ?? num); } } } - catch (XmlException) - { - } + } + catch (XmlException) + { } } @@ -134,7 +163,7 @@ namespace MediaBrowser.XbmcMetadata.Parsers if (!string.IsNullOrWhiteSpace(val)) { - // int.TryParse is local aware, so it can be probamatic, force us culture + // int.TryParse is local aware, so it can be problematic, force us culture if (int.TryParse(val, NumberStyles.Integer, UsCulture, out var rval)) { item.AirsBeforeEpisodeNumber = rval; @@ -150,7 +179,7 @@ namespace MediaBrowser.XbmcMetadata.Parsers if (!string.IsNullOrWhiteSpace(val)) { - // int.TryParse is local aware, so it can be probamatic, force us culture + // int.TryParse is local aware, so it can be problematic, force us culture if (int.TryParse(val, NumberStyles.Integer, UsCulture, out var rval)) { item.AirsAfterSeasonNumber = rval; @@ -166,7 +195,7 @@ namespace MediaBrowser.XbmcMetadata.Parsers if (!string.IsNullOrWhiteSpace(val)) { - // int.TryParse is local aware, so it can be probamatic, force us culture + // int.TryParse is local aware, so it can be problematic, force us culture if (int.TryParse(val, NumberStyles.Integer, UsCulture, out var rval)) { item.AirsBeforeSeasonNumber = rval; @@ -182,7 +211,7 @@ namespace MediaBrowser.XbmcMetadata.Parsers if (!string.IsNullOrWhiteSpace(val)) { - // int.TryParse is local aware, so it can be probamatic, force us culture + // int.TryParse is local aware, so it can be problematic, force us culture if (int.TryParse(val, NumberStyles.Integer, UsCulture, out var rval)) { item.AirsBeforeSeasonNumber = rval; @@ -198,7 +227,7 @@ namespace MediaBrowser.XbmcMetadata.Parsers if (!string.IsNullOrWhiteSpace(val)) { - // int.TryParse is local aware, so it can be probamatic, force us culture + // int.TryParse is local aware, so it can be problematic, force us culture if (int.TryParse(val, NumberStyles.Integer, UsCulture, out var rval)) { item.AirsBeforeEpisodeNumber = rval; @@ -208,6 +237,18 @@ namespace MediaBrowser.XbmcMetadata.Parsers break; } + case "showtitle": + { + var showtitle = reader.ReadElementContentAsString(); + + if (!string.IsNullOrWhiteSpace(showtitle)) + { + item.SeriesName = showtitle; + } + + break; + } + default: base.FetchDataFromXmlNode(reader, itemResult); break; diff --git a/MediaBrowser.XbmcMetadata/Parsers/MovieNfoParser.cs b/MediaBrowser.XbmcMetadata/Parsers/MovieNfoParser.cs index b74a9fd8ae..e510557253 100644 --- a/MediaBrowser.XbmcMetadata/Parsers/MovieNfoParser.cs +++ b/MediaBrowser.XbmcMetadata/Parsers/MovieNfoParser.cs @@ -5,6 +5,7 @@ using System.Xml; using MediaBrowser.Common.Configuration; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.Movies; +using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Entities; using Microsoft.Extensions.Logging; @@ -19,11 +20,20 @@ namespace MediaBrowser.XbmcMetadata.Parsers /// <summary> /// Initializes a new instance of the <see cref="MovieNfoParser"/> class. /// </summary> - /// <param name="logger">The logger.</param> - /// <param name="config">the configuration manager.</param> - /// <param name="providerManager">The provider manager.</param> - public MovieNfoParser(ILogger logger, IConfigurationManager config, IProviderManager providerManager) - : base(logger, config, providerManager) + /// <param name="logger">Instance of the <see cref="ILogger"/> interface.</param> + /// <param name="config">Instance of the <see cref="IConfigurationManager"/> interface.</param> + /// <param name="providerManager">Instance of the <see cref="IProviderManager"/> interface.</param> + /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> + /// <param name="userDataManager">Instance of the <see cref="IUserDataManager"/> interface.</param> + /// <param name="directoryService">Instance of the <see cref="DirectoryService"/> interface.</param> + public MovieNfoParser( + ILogger logger, + IConfigurationManager config, + IProviderManager providerManager, + IUserManager userManager, + IUserDataManager userDataManager, + IDirectoryService directoryService) + : base(logger, config, providerManager, userManager, userDataManager, directoryService) { } @@ -39,12 +49,19 @@ namespace MediaBrowser.XbmcMetadata.Parsers { case "id": { - string imdbId = reader.GetAttribute("IMDB"); - string tmdbId = reader.GetAttribute("TMDB"); + // get ids from attributes + string? imdbId = reader.GetAttribute("IMDB"); + string? tmdbId = reader.GetAttribute("TMDB"); - if (string.IsNullOrWhiteSpace(imdbId)) + // read id from content + var contentId = reader.ReadElementContentAsString(); + if (contentId.Contains("tt", StringComparison.Ordinal) && string.IsNullOrEmpty(imdbId)) { - imdbId = reader.ReadElementContentAsString(); + imdbId = contentId; + } + else if (string.IsNullOrEmpty(tmdbId)) + { + tmdbId = contentId; } if (!string.IsNullOrWhiteSpace(imdbId)) @@ -75,7 +92,7 @@ namespace MediaBrowser.XbmcMetadata.Parsers if (!string.IsNullOrWhiteSpace(val) && movie != null) { // TODO Handle this better later - if (val.IndexOf('<', StringComparison.Ordinal) == -1) + if (!val.Contains('<', StringComparison.Ordinal)) { movie.CollectionName = val; } diff --git a/MediaBrowser.XbmcMetadata/Parsers/SeasonNfoParser.cs b/MediaBrowser.XbmcMetadata/Parsers/SeasonNfoParser.cs index d8cd88b9a4..2f5fd40e2f 100644 --- a/MediaBrowser.XbmcMetadata/Parsers/SeasonNfoParser.cs +++ b/MediaBrowser.XbmcMetadata/Parsers/SeasonNfoParser.cs @@ -2,6 +2,7 @@ using System.Globalization; using System.Xml; using MediaBrowser.Common.Configuration; using MediaBrowser.Controller.Entities.TV; +using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Providers; using Microsoft.Extensions.Logging; @@ -15,11 +16,20 @@ namespace MediaBrowser.XbmcMetadata.Parsers /// <summary> /// Initializes a new instance of the <see cref="SeasonNfoParser"/> class. /// </summary> - /// <param name="logger">The logger.</param> - /// <param name="config">the configuration manager.</param> - /// <param name="providerManager">The provider manager.</param> - public SeasonNfoParser(ILogger logger, IConfigurationManager config, IProviderManager providerManager) - : base(logger, config, providerManager) + /// <param name="logger">Instance of the <see cref="ILogger"/> interface.</param> + /// <param name="config">Instance of the <see cref="IConfigurationManager"/> interface.</param> + /// <param name="providerManager">Instance of the <see cref="IProviderManager"/> interface.</param> + /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> + /// <param name="userDataManager">Instance of the <see cref="IUserDataManager"/> interface.</param> + /// <param name="directoryService">Instance of the <see cref="DirectoryService"/> interface.</param> + public SeasonNfoParser( + ILogger logger, + IConfigurationManager config, + IProviderManager providerManager, + IUserManager userManager, + IUserDataManager userDataManager, + IDirectoryService directoryService) + : base(logger, config, providerManager, userManager, userDataManager, directoryService) { } diff --git a/MediaBrowser.XbmcMetadata/Parsers/SeriesNfoParser.cs b/MediaBrowser.XbmcMetadata/Parsers/SeriesNfoParser.cs index f079d4a7e6..2c893ac9f0 100644 --- a/MediaBrowser.XbmcMetadata/Parsers/SeriesNfoParser.cs +++ b/MediaBrowser.XbmcMetadata/Parsers/SeriesNfoParser.cs @@ -17,20 +17,26 @@ namespace MediaBrowser.XbmcMetadata.Parsers /// <summary> /// Initializes a new instance of the <see cref="SeriesNfoParser"/> class. /// </summary> - /// <param name="logger">The logger.</param> - /// <param name="config">the configuration manager.</param> - /// <param name="providerManager">The provider manager.</param> - public SeriesNfoParser(ILogger logger, IConfigurationManager config, IProviderManager providerManager) - : base(logger, config, providerManager) + /// <param name="logger">Instance of the <see cref="ILogger"/> interface.</param> + /// <param name="config">Instance of the <see cref="IConfigurationManager"/> interface.</param> + /// <param name="providerManager">Instance of the <see cref="IProviderManager"/> interface.</param> + /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> + /// <param name="userDataManager">Instance of the <see cref="IUserDataManager"/> interface.</param> + /// <param name="directoryService">Instance of the <see cref="IDirectoryService"/> interface.</param> + public SeriesNfoParser( + ILogger logger, + IConfigurationManager config, + IProviderManager providerManager, + IUserManager userManager, + IUserDataManager userDataManager, + IDirectoryService directoryService) + : base(logger, config, providerManager, userManager, userDataManager, directoryService) { } /// <inheritdoc /> protected override bool SupportsUrlAfterClosingXmlTag => true; - /// <inheritdoc /> - protected override string MovieDbParserSearchString => "themoviedb.org/tv/"; - /// <inheritdoc /> protected override void FetchDataFromXmlNode(XmlReader reader, MetadataResult<Series> itemResult) { @@ -40,9 +46,9 @@ namespace MediaBrowser.XbmcMetadata.Parsers { case "id": { - string imdbId = reader.GetAttribute("IMDB"); - string tmdbId = reader.GetAttribute("TMDB"); - string tvdbId = reader.GetAttribute("TVDB"); + string? imdbId = reader.GetAttribute("IMDB"); + string? tmdbId = reader.GetAttribute("TMDB"); + string? tvdbId = reader.GetAttribute("TVDB"); if (string.IsNullOrWhiteSpace(tvdbId)) { diff --git a/MediaBrowser.XbmcMetadata/Properties/AssemblyInfo.cs b/MediaBrowser.XbmcMetadata/Properties/AssemblyInfo.cs index b3e2f27179..cfe06b940d 100644 --- a/MediaBrowser.XbmcMetadata/Properties/AssemblyInfo.cs +++ b/MediaBrowser.XbmcMetadata/Properties/AssemblyInfo.cs @@ -1,5 +1,6 @@ using System.Reflection; using System.Resources; +using System.Runtime.CompilerServices; using System.Runtime.InteropServices; // General Information about an assembly is controlled through the following @@ -14,6 +15,7 @@ using System.Runtime.InteropServices; [assembly: AssemblyTrademark("")] [assembly: AssemblyCulture("")] [assembly: NeutralResourcesLanguage("en")] +[assembly: InternalsVisibleTo("Jellyfin.XbmcMetadata.Tests")] // Setting ComVisible to false makes the types in this assembly not visible // to COM components. If you need to access a type in this assembly from diff --git a/MediaBrowser.XbmcMetadata/Providers/AlbumNfoProvider.cs b/MediaBrowser.XbmcMetadata/Providers/AlbumNfoProvider.cs index 433a936d91..f7a85a07e6 100644 --- a/MediaBrowser.XbmcMetadata/Providers/AlbumNfoProvider.cs +++ b/MediaBrowser.XbmcMetadata/Providers/AlbumNfoProvider.cs @@ -2,6 +2,7 @@ using System.IO; using System.Threading; using MediaBrowser.Common.Configuration; using MediaBrowser.Controller.Entities.Audio; +using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.IO; using MediaBrowser.XbmcMetadata.Parsers; @@ -17,34 +18,46 @@ namespace MediaBrowser.XbmcMetadata.Providers private readonly ILogger<AlbumNfoProvider> _logger; private readonly IConfigurationManager _config; private readonly IProviderManager _providerManager; + private readonly IUserManager _userManager; + private readonly IUserDataManager _userDataManager; + private readonly IDirectoryService _directoryService; /// <summary> /// Initializes a new instance of the <see cref="AlbumNfoProvider"/> class. /// </summary> - /// <param name="logger">The logger.</param> - /// <param name="fileSystem">The file system.</param> - /// <param name="config">the configuration manager.</param> - /// <param name="providerManager">The provider manager.</param> + /// <param name="logger">Instance of the <see cref="ILogger{AlbumNfoProvider}"/> interface.</param> + /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param> + /// <param name="config">Instance of the <see cref="IConfigurationManager"/> interface.</param> + /// <param name="providerManager">Instance of the <see cref="IProviderManager"/> interface.</param> + /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> + /// <param name="userDataManager">Instance of the <see cref="IUserDataManager"/> interface.</param> + /// <param name="directoryService">Instance of the <see cref="IDirectoryService"/> interface.</param> public AlbumNfoProvider( ILogger<AlbumNfoProvider> logger, IFileSystem fileSystem, IConfigurationManager config, - IProviderManager providerManager) + IProviderManager providerManager, + IUserManager userManager, + IUserDataManager userDataManager, + IDirectoryService directoryService) : base(fileSystem) { _logger = logger; _config = config; _providerManager = providerManager; + _userManager = userManager; + _userDataManager = userDataManager; + _directoryService = directoryService; } /// <inheritdoc /> protected override void Fetch(MetadataResult<MusicAlbum> result, string path, CancellationToken cancellationToken) { - new BaseNfoParser<MusicAlbum>(_logger, _config, _providerManager).Fetch(result, path, cancellationToken); + new BaseNfoParser<MusicAlbum>(_logger, _config, _providerManager, _userManager, _userDataManager, _directoryService).Fetch(result, path, cancellationToken); } /// <inheritdoc /> - protected override FileSystemMetadata GetXmlFile(ItemInfo info, IDirectoryService directoryService) + protected override FileSystemMetadata? GetXmlFile(ItemInfo info, IDirectoryService directoryService) => directoryService.GetFile(Path.Combine(info.Path, "album.nfo")); } } diff --git a/MediaBrowser.XbmcMetadata/Providers/ArtistNfoProvider.cs b/MediaBrowser.XbmcMetadata/Providers/ArtistNfoProvider.cs index d69cdc90ae..2b563b7fa6 100644 --- a/MediaBrowser.XbmcMetadata/Providers/ArtistNfoProvider.cs +++ b/MediaBrowser.XbmcMetadata/Providers/ArtistNfoProvider.cs @@ -2,6 +2,7 @@ using System.IO; using System.Threading; using MediaBrowser.Common.Configuration; using MediaBrowser.Controller.Entities.Audio; +using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.IO; using MediaBrowser.XbmcMetadata.Parsers; @@ -17,34 +18,46 @@ namespace MediaBrowser.XbmcMetadata.Providers private readonly ILogger<ArtistNfoProvider> _logger; private readonly IConfigurationManager _config; private readonly IProviderManager _providerManager; + private readonly IUserManager _userManager; + private readonly IUserDataManager _userDataManager; + private readonly IDirectoryService _directoryService; /// <summary> /// Initializes a new instance of the <see cref="ArtistNfoProvider"/> class. /// </summary> - /// <param name="logger">The logger.</param> - /// <param name="fileSystem">The file system.</param> - /// <param name="config">the configuration manager.</param> - /// <param name="providerManager">The provider manager.</param> + /// <param name="logger">Instance of the <see cref="ILogger{ArtistNfoProvider}"/> interface.</param> + /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param> + /// <param name="config">Instance of the <see cref="IConfigurationManager"/> interface.</param> + /// <param name="providerManager">Instance of the <see cref="IProviderManager"/> interface.</param> + /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> + /// <param name="userDataManager">Instance of the <see cref="IUserDataManager"/> interface.</param> + /// <param name="directoryService">Instance of the <see cref="IDirectoryService"/> interface.</param> public ArtistNfoProvider( IFileSystem fileSystem, ILogger<ArtistNfoProvider> logger, IConfigurationManager config, - IProviderManager providerManager) + IProviderManager providerManager, + IUserManager userManager, + IUserDataManager userDataManager, + IDirectoryService directoryService) : base(fileSystem) { _logger = logger; _config = config; _providerManager = providerManager; + _userManager = userManager; + _userDataManager = userDataManager; + _directoryService = directoryService; } /// <inheritdoc /> protected override void Fetch(MetadataResult<MusicArtist> result, string path, CancellationToken cancellationToken) { - new BaseNfoParser<MusicArtist>(_logger, _config, _providerManager).Fetch(result, path, cancellationToken); + new BaseNfoParser<MusicArtist>(_logger, _config, _providerManager, _userManager, _userDataManager, _directoryService).Fetch(result, path, cancellationToken); } /// <inheritdoc /> - protected override FileSystemMetadata GetXmlFile(ItemInfo info, IDirectoryService directoryService) + protected override FileSystemMetadata? GetXmlFile(ItemInfo info, IDirectoryService directoryService) => directoryService.GetFile(Path.Combine(info.Path, "artist.nfo")); } } diff --git a/MediaBrowser.XbmcMetadata/Providers/BaseNfoProvider.cs b/MediaBrowser.XbmcMetadata/Providers/BaseNfoProvider.cs index 6ad6c18a5b..abd3e78d76 100644 --- a/MediaBrowser.XbmcMetadata/Providers/BaseNfoProvider.cs +++ b/MediaBrowser.XbmcMetadata/Providers/BaseNfoProvider.cs @@ -74,6 +74,6 @@ namespace MediaBrowser.XbmcMetadata.Providers protected abstract void Fetch(MetadataResult<T> result, string path, CancellationToken cancellationToken); - protected abstract FileSystemMetadata GetXmlFile(ItemInfo info, IDirectoryService directoryService); + protected abstract FileSystemMetadata? GetXmlFile(ItemInfo info, IDirectoryService directoryService); } } diff --git a/MediaBrowser.XbmcMetadata/Providers/BaseVideoNfoProvider.cs b/MediaBrowser.XbmcMetadata/Providers/BaseVideoNfoProvider.cs index 2b1589d47b..8574be3f3a 100644 --- a/MediaBrowser.XbmcMetadata/Providers/BaseVideoNfoProvider.cs +++ b/MediaBrowser.XbmcMetadata/Providers/BaseVideoNfoProvider.cs @@ -4,6 +4,7 @@ using System.Linq; using System.Threading; using MediaBrowser.Common.Configuration; using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.IO; using MediaBrowser.XbmcMetadata.Parsers; @@ -18,17 +19,26 @@ namespace MediaBrowser.XbmcMetadata.Providers private readonly ILogger<BaseVideoNfoProvider<T>> _logger; private readonly IConfigurationManager _config; private readonly IProviderManager _providerManager; + private readonly IUserManager _userManager; + private readonly IUserDataManager _userDataManager; + private readonly IDirectoryService _directoryService; - public BaseVideoNfoProvider( + protected BaseVideoNfoProvider( ILogger<BaseVideoNfoProvider<T>> logger, IFileSystem fileSystem, IConfigurationManager config, - IProviderManager providerManager) + IProviderManager providerManager, + IUserManager userManager, + IUserDataManager userDataManager, + IDirectoryService directoryService) : base(fileSystem) { _logger = logger; _config = config; _providerManager = providerManager; + _userManager = userManager; + _userDataManager = userDataManager; + _directoryService = directoryService; } /// <inheritdoc /> @@ -38,10 +48,12 @@ namespace MediaBrowser.XbmcMetadata.Providers { Item = result.Item }; - new MovieNfoParser(_logger, _config, _providerManager).Fetch(tmpItem, path, cancellationToken); + new MovieNfoParser(_logger, _config, _providerManager, _userManager, _userDataManager, _directoryService).Fetch(tmpItem, path, cancellationToken); result.Item = (T)tmpItem.Item; result.People = tmpItem.People; + result.Images = tmpItem.Images; + result.RemoteImages = tmpItem.RemoteImages; if (tmpItem.UserDataList != null) { @@ -50,7 +62,7 @@ namespace MediaBrowser.XbmcMetadata.Providers } /// <inheritdoc /> - protected override FileSystemMetadata GetXmlFile(ItemInfo info, IDirectoryService directoryService) + protected override FileSystemMetadata? GetXmlFile(ItemInfo info, IDirectoryService directoryService) { return MovieNfoSaver.GetMovieSavePaths(info) .Select(directoryService.GetFile) diff --git a/MediaBrowser.XbmcMetadata/Providers/EpisodeNfoProvider.cs b/MediaBrowser.XbmcMetadata/Providers/EpisodeNfoProvider.cs index 26983b1a69..f6c5877b40 100644 --- a/MediaBrowser.XbmcMetadata/Providers/EpisodeNfoProvider.cs +++ b/MediaBrowser.XbmcMetadata/Providers/EpisodeNfoProvider.cs @@ -2,6 +2,7 @@ using System.IO; using System.Threading; using MediaBrowser.Common.Configuration; using MediaBrowser.Controller.Entities.TV; +using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.IO; using MediaBrowser.XbmcMetadata.Parsers; @@ -17,34 +18,46 @@ namespace MediaBrowser.XbmcMetadata.Providers private readonly ILogger<EpisodeNfoProvider> _logger; private readonly IConfigurationManager _config; private readonly IProviderManager _providerManager; + private readonly IUserManager _userManager; + private readonly IUserDataManager _userDataManager; + private readonly IDirectoryService _directoryService; /// <summary> /// Initializes a new instance of the <see cref="EpisodeNfoProvider"/> class. /// </summary> - /// <param name="logger">The logger.</param> - /// <param name="fileSystem">The file system.</param> - /// <param name="config">the configuration manager.</param> - /// <param name="providerManager">The provider manager.</param> + /// <param name="logger">Instance of the <see cref="ILogger{EpisodeNfoProvider}"/> interface.</param> + /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param> + /// <param name="config">Instance of the <see cref="IConfigurationManager"/> interface.</param> + /// <param name="providerManager">Instance of the <see cref="IProviderManager"/> interface.</param> + /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> + /// <param name="userDataManager">Instance of the <see cref="IUserDataManager"/> interface.</param> + /// <param name="directoryService">Instance of the <see cref="IDirectoryService"/> interface.</param> public EpisodeNfoProvider( ILogger<EpisodeNfoProvider> logger, IFileSystem fileSystem, IConfigurationManager config, - IProviderManager providerManager) + IProviderManager providerManager, + IUserManager userManager, + IUserDataManager userDataManager, + IDirectoryService directoryService) : base(fileSystem) { _logger = logger; _config = config; _providerManager = providerManager; + _userManager = userManager; + _userDataManager = userDataManager; + _directoryService = directoryService; } /// <inheritdoc /> protected override void Fetch(MetadataResult<Episode> result, string path, CancellationToken cancellationToken) { - new EpisodeNfoParser(_logger, _config, _providerManager).Fetch(result, path, cancellationToken); + new EpisodeNfoParser(_logger, _config, _providerManager, _userManager, _userDataManager, _directoryService).Fetch(result, path, cancellationToken); } /// <inheritdoc /> - protected override FileSystemMetadata GetXmlFile(ItemInfo info, IDirectoryService directoryService) + protected override FileSystemMetadata? GetXmlFile(ItemInfo info, IDirectoryService directoryService) { var path = Path.ChangeExtension(info.Path, ".nfo"); diff --git a/MediaBrowser.XbmcMetadata/Providers/MovieNfoProvider.cs b/MediaBrowser.XbmcMetadata/Providers/MovieNfoProvider.cs index e3f6bada1b..cdbc5a9187 100644 --- a/MediaBrowser.XbmcMetadata/Providers/MovieNfoProvider.cs +++ b/MediaBrowser.XbmcMetadata/Providers/MovieNfoProvider.cs @@ -1,5 +1,6 @@ using MediaBrowser.Common.Configuration; using MediaBrowser.Controller.Entities.Movies; +using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.IO; using Microsoft.Extensions.Logging; @@ -14,16 +15,22 @@ namespace MediaBrowser.XbmcMetadata.Providers /// <summary> /// Initializes a new instance of the <see cref="MovieNfoProvider"/> class. /// </summary> - /// <param name="logger">The logger.</param> - /// <param name="fileSystem">The file system.</param> - /// <param name="config">the configuration manager.</param> - /// <param name="providerManager">The provider manager.</param> + /// <param name="logger">Instance of the <see cref="ILogger{MovieNfoProvider}"/> interface.</param> + /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param> + /// <param name="config">Instance of the <see cref="IConfigurationManager"/> interface.</param> + /// <param name="providerManager">Instance of the <see cref="IProviderManager"/> interface.</param> + /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> + /// <param name="userDataManager">Instance of the <see cref="IUserDataManager"/> interface.</param> + /// <param name="directoryService">Instance of the <see cref="IDirectoryService"/> interface.</param> public MovieNfoProvider( ILogger<MovieNfoProvider> logger, IFileSystem fileSystem, IConfigurationManager config, - IProviderManager providerManager) - : base(logger, fileSystem, config, providerManager) + IProviderManager providerManager, + IUserManager userManager, + IUserDataManager userDataManager, + IDirectoryService directoryService) + : base(logger, fileSystem, config, providerManager, userManager, userDataManager, directoryService) { } } diff --git a/MediaBrowser.XbmcMetadata/Providers/MusicVideoNfoProvider.cs b/MediaBrowser.XbmcMetadata/Providers/MusicVideoNfoProvider.cs index b490a7120c..9d1f3e61d1 100644 --- a/MediaBrowser.XbmcMetadata/Providers/MusicVideoNfoProvider.cs +++ b/MediaBrowser.XbmcMetadata/Providers/MusicVideoNfoProvider.cs @@ -1,5 +1,6 @@ using MediaBrowser.Common.Configuration; using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.IO; using Microsoft.Extensions.Logging; @@ -14,12 +15,22 @@ namespace MediaBrowser.XbmcMetadata.Providers /// <summary> /// Initializes a new instance of the <see cref="MusicVideoNfoProvider"/> class. /// </summary> - /// <param name="logger">The logger.</param> - /// <param name="fileSystem">The file system.</param> - /// <param name="config">the configuration manager.</param> - /// <param name="providerManager">The provider manager.</param> - public MusicVideoNfoProvider(ILogger<MusicVideoNfoProvider> logger, IFileSystem fileSystem, IConfigurationManager config, IProviderManager providerManager) - : base(logger, fileSystem, config, providerManager) + /// <param name="logger">Instance of the <see cref="ILogger{SeasonNfoProvider}"/> interface.</param> + /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param> + /// <param name="config">Instance of the <see cref="IConfigurationManager"/> interface.</param> + /// <param name="providerManager">Instance of the <see cref="IProviderManager"/> interface.</param> + /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> + /// <param name="userDataManager">Instance of the <see cref="IUserDataManager"/> interface.</param> + /// <param name="directoryService">Instance of the <see cref="IDirectoryService"/> interface.</param> + public MusicVideoNfoProvider( + ILogger<MusicVideoNfoProvider> logger, + IFileSystem fileSystem, + IConfigurationManager config, + IProviderManager providerManager, + IUserManager userManager, + IUserDataManager userDataManager, + IDirectoryService directoryService) + : base(logger, fileSystem, config, providerManager, userManager, userDataManager, directoryService) { } } diff --git a/MediaBrowser.XbmcMetadata/Providers/SeasonNfoProvider.cs b/MediaBrowser.XbmcMetadata/Providers/SeasonNfoProvider.cs index 0603fd0d13..d01abadf67 100644 --- a/MediaBrowser.XbmcMetadata/Providers/SeasonNfoProvider.cs +++ b/MediaBrowser.XbmcMetadata/Providers/SeasonNfoProvider.cs @@ -2,6 +2,7 @@ using System.IO; using System.Threading; using MediaBrowser.Common.Configuration; using MediaBrowser.Controller.Entities.TV; +using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.IO; using MediaBrowser.XbmcMetadata.Parsers; @@ -17,34 +18,46 @@ namespace MediaBrowser.XbmcMetadata.Providers private readonly ILogger<SeasonNfoProvider> _logger; private readonly IConfigurationManager _config; private readonly IProviderManager _providerManager; + private readonly IUserManager _userManager; + private readonly IUserDataManager _userDataManager; + private readonly IDirectoryService _directoryService; /// <summary> /// Initializes a new instance of the <see cref="SeasonNfoProvider"/> class. /// </summary> - /// <param name="logger">The logger.</param> - /// <param name="fileSystem">The file system.</param> - /// <param name="config">the configuration manager.</param> - /// <param name="providerManager">The provider manager.</param> + /// <param name="logger">Instance of the <see cref="ILogger{SeasonNfoProvider}"/> interface.</param> + /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param> + /// <param name="config">Instance of the <see cref="IConfigurationManager"/> interface.</param> + /// <param name="providerManager">Instance of the <see cref="IProviderManager"/> interface.</param> + /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> + /// <param name="userDataManager">Instance of the <see cref="IUserDataManager"/> interface.</param> + /// <param name="directoryService">Instance of the <see cref="IDirectoryService"/> interface.</param> public SeasonNfoProvider( ILogger<SeasonNfoProvider> logger, IFileSystem fileSystem, IConfigurationManager config, - IProviderManager providerManager) + IProviderManager providerManager, + IUserManager userManager, + IUserDataManager userDataManager, + IDirectoryService directoryService) : base(fileSystem) { _logger = logger; _config = config; _providerManager = providerManager; + _userManager = userManager; + _userDataManager = userDataManager; + _directoryService = directoryService; } /// <inheritdoc /> protected override void Fetch(MetadataResult<Season> result, string path, CancellationToken cancellationToken) { - new SeasonNfoParser(_logger, _config, _providerManager).Fetch(result, path, cancellationToken); + new SeasonNfoParser(_logger, _config, _providerManager, _userManager, _userDataManager, _directoryService).Fetch(result, path, cancellationToken); } /// <inheritdoc /> - protected override FileSystemMetadata GetXmlFile(ItemInfo info, IDirectoryService directoryService) + protected override FileSystemMetadata? GetXmlFile(ItemInfo info, IDirectoryService directoryService) => directoryService.GetFile(Path.Combine(info.Path, "season.nfo")); } } diff --git a/MediaBrowser.XbmcMetadata/Providers/SeriesNfoProvider.cs b/MediaBrowser.XbmcMetadata/Providers/SeriesNfoProvider.cs index 7e059e0aae..002b944631 100644 --- a/MediaBrowser.XbmcMetadata/Providers/SeriesNfoProvider.cs +++ b/MediaBrowser.XbmcMetadata/Providers/SeriesNfoProvider.cs @@ -2,6 +2,7 @@ using System.IO; using System.Threading; using MediaBrowser.Common.Configuration; using MediaBrowser.Controller.Entities.TV; +using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.IO; using MediaBrowser.XbmcMetadata.Parsers; @@ -17,34 +18,46 @@ namespace MediaBrowser.XbmcMetadata.Providers private readonly ILogger<SeriesNfoProvider> _logger; private readonly IConfigurationManager _config; private readonly IProviderManager _providerManager; + private readonly IUserManager _userManager; + private readonly IUserDataManager _userDataManager; + private readonly IDirectoryService _directoryService; /// <summary> /// Initializes a new instance of the <see cref="SeriesNfoProvider"/> class. /// </summary> - /// <param name="logger">The logger.</param> - /// <param name="fileSystem">The file system.</param> - /// <param name="config">the configuration manager.</param> - /// <param name="providerManager">The provider manager.</param> + /// <param name="logger">Instance of the <see cref="ILogger{SeriesNfoProvider}"/> interface.</param> + /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param> + /// <param name="config">Instance of the <see cref="IConfigurationManager"/> interface.</param> + /// <param name="providerManager">Instance of the <see cref="IProviderManager"/> interface.</param> + /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> + /// <param name="userDataManager">Instance of the <see cref="IUserDataManager"/> interface.</param> + /// <param name="directoryService">Instance of the <see cref="IDirectoryService"/> interface.</param> public SeriesNfoProvider( ILogger<SeriesNfoProvider> logger, IFileSystem fileSystem, IConfigurationManager config, - IProviderManager providerManager) + IProviderManager providerManager, + IUserManager userManager, + IUserDataManager userDataManager, + IDirectoryService directoryService) : base(fileSystem) { _logger = logger; _config = config; _providerManager = providerManager; + _userManager = userManager; + _userDataManager = userDataManager; + _directoryService = directoryService; } /// <inheritdoc /> protected override void Fetch(MetadataResult<Series> result, string path, CancellationToken cancellationToken) { - new SeriesNfoParser(_logger, _config, _providerManager).Fetch(result, path, cancellationToken); + new SeriesNfoParser(_logger, _config, _providerManager, _userManager, _userDataManager, _directoryService).Fetch(result, path, cancellationToken); } /// <inheritdoc /> - protected override FileSystemMetadata GetXmlFile(ItemInfo info, IDirectoryService directoryService) + protected override FileSystemMetadata? GetXmlFile(ItemInfo info, IDirectoryService directoryService) => directoryService.GetFile(Path.Combine(info.Path, "tvshow.nfo")); } } diff --git a/MediaBrowser.XbmcMetadata/Providers/VideoNfoProvider.cs b/MediaBrowser.XbmcMetadata/Providers/VideoNfoProvider.cs index f66ad30ca6..93b1be62fc 100644 --- a/MediaBrowser.XbmcMetadata/Providers/VideoNfoProvider.cs +++ b/MediaBrowser.XbmcMetadata/Providers/VideoNfoProvider.cs @@ -1,5 +1,6 @@ using MediaBrowser.Common.Configuration; using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.IO; using Microsoft.Extensions.Logging; @@ -14,12 +15,22 @@ namespace MediaBrowser.XbmcMetadata.Providers /// <summary> /// Initializes a new instance of the <see cref="VideoNfoProvider"/> class. /// </summary> - /// <param name="logger">The logger.</param> - /// <param name="fileSystem">The file system.</param> - /// <param name="config">the configuration manager.</param> - /// <param name="providerManager">The provider manager.</param> - public VideoNfoProvider(ILogger<VideoNfoProvider> logger, IFileSystem fileSystem, IConfigurationManager config, IProviderManager providerManager) - : base(logger, fileSystem, config, providerManager) + /// <param name="logger">Instance of the <see cref="ILogger{VideoNfoProvider}"/> interface.</param> + /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param> + /// <param name="config">Instance of the <see cref="IConfigurationManager"/> interface.</param> + /// <param name="providerManager">Instance of the <see cref="IProviderManager"/> interface.</param> + /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> + /// <param name="userDataManager">Instance of the <see cref="IUserDataManager"/> interface.</param> + /// <param name="directoryService">Instance of the <see cref="IDirectoryService"/> interface.</param> + public VideoNfoProvider( + ILogger<VideoNfoProvider> logger, + IFileSystem fileSystem, + IConfigurationManager config, + IProviderManager providerManager, + IUserManager userManager, + IUserDataManager userDataManager, + IDirectoryService directoryService) + : base(logger, fileSystem, config, providerManager, userManager, userDataManager, directoryService) { } } diff --git a/MediaBrowser.XbmcMetadata/Savers/AlbumNfoSaver.cs b/MediaBrowser.XbmcMetadata/Savers/AlbumNfoSaver.cs index c22f77dcd5..2385e70485 100644 --- a/MediaBrowser.XbmcMetadata/Savers/AlbumNfoSaver.cs +++ b/MediaBrowser.XbmcMetadata/Savers/AlbumNfoSaver.cs @@ -96,18 +96,16 @@ namespace MediaBrowser.XbmcMetadata.Savers } /// <inheritdoc /> - protected override List<string> GetTagsUsed(BaseItem item) + protected override IEnumerable<string> GetTagsUsed(BaseItem item) { - var list = base.GetTagsUsed(item); - list.AddRange( - new string[] - { - "track", - "artist", - "albumartist" - }); + foreach (var tag in base.GetTagsUsed(item)) + { + yield return tag; + } - return list; + yield return "track"; + yield return "artist"; + yield return "albumartist"; } } } diff --git a/MediaBrowser.XbmcMetadata/Savers/ArtistNfoSaver.cs b/MediaBrowser.XbmcMetadata/Savers/ArtistNfoSaver.cs index 6365cdecb4..71b58cddb9 100644 --- a/MediaBrowser.XbmcMetadata/Savers/ArtistNfoSaver.cs +++ b/MediaBrowser.XbmcMetadata/Savers/ArtistNfoSaver.cs @@ -88,16 +88,15 @@ namespace MediaBrowser.XbmcMetadata.Savers } /// <inheritdoc /> - protected override List<string> GetTagsUsed(BaseItem item) + protected override IEnumerable<string> GetTagsUsed(BaseItem item) { - var list = base.GetTagsUsed(item); - list.AddRange(new string[] + foreach (var tag in base.GetTagsUsed(item)) { - "album", - "disbanded" - }); + yield return tag; + } - return list; + yield return "album"; + yield return "disbanded"; } } } diff --git a/MediaBrowser.XbmcMetadata/Savers/BaseNfoSaver.cs b/MediaBrowser.XbmcMetadata/Savers/BaseNfoSaver.cs index d8230d188e..3be35e2d9b 100644 --- a/MediaBrowser.XbmcMetadata/Savers/BaseNfoSaver.cs +++ b/MediaBrowser.XbmcMetadata/Savers/BaseNfoSaver.cs @@ -166,19 +166,16 @@ namespace MediaBrowser.XbmcMetadata.Savers /// <inheritdoc /> public abstract bool IsEnabledFor(BaseItem item, ItemUpdateType updateType); - protected virtual List<string> GetTagsUsed(BaseItem item) + protected virtual IEnumerable<string> GetTagsUsed(BaseItem item) { - var list = new List<string>(); foreach (var providerKey in item.ProviderIds.Keys) { var providerIdTagName = GetTagForProviderKey(providerKey); if (!_commonTags.Contains(providerIdTagName)) { - list.Add(providerIdTagName); + yield return providerIdTagName; } } - - return list; } /// <inheritdoc /> @@ -200,12 +197,14 @@ namespace MediaBrowser.XbmcMetadata.Savers private void SaveToFile(Stream stream, string path) { - Directory.CreateDirectory(Path.GetDirectoryName(path)); + var directory = Path.GetDirectoryName(path) ?? throw new ArgumentException($"Provided path ({path}) is not valid.", nameof(path)); + Directory.CreateDirectory(directory); - // On Windows, savint the file will fail if the file is hidden or readonly + // On Windows, saving the file will fail if the file is hidden or readonly FileSystem.SetAttributes(path, false, false); - using (var filestream = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.Read)) + // use FileShare.None as this bypasses dotnet bug dotnet/runtime#42790 . + using (var filestream = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.None)) { stream.CopyTo(filestream); } @@ -259,7 +258,7 @@ namespace MediaBrowser.XbmcMetadata.Savers AddMediaInfo(hasMediaSources, writer); } - var tagsUsed = GetTagsUsed(item); + var tagsUsed = GetTagsUsed(item).ToList(); try { @@ -349,10 +348,7 @@ namespace MediaBrowser.XbmcMetadata.Savers } var scanType = stream.IsInterlaced ? "interlaced" : "progressive"; - if (!string.IsNullOrEmpty(scanType)) - { - writer.WriteElementString("scantype", scanType); - } + writer.WriteElementString("scantype", scanType); if (stream.Channels.HasValue) { @@ -449,15 +445,7 @@ namespace MediaBrowser.XbmcMetadata.Savers writer.WriteElementString("plot", overview); } - if (item is Video) - { - var outline = (item.Tagline ?? string.Empty) - .StripHtml() - .Replace(""", "'", StringComparison.Ordinal); - - writer.WriteElementString("outline", outline); - } - else + if (item is not Video) { writer.WriteElementString("outline", overview); } @@ -471,7 +459,7 @@ namespace MediaBrowser.XbmcMetadata.Savers if (item.LockedFields.Length > 0) { - writer.WriteElementString("lockedfields", string.Join("|", item.LockedFields)); + writer.WriteElementString("lockedfields", string.Join('|', item.LockedFields)); } writer.WriteElementString("dateadded", item.DateCreated.ToLocalTime().ToString(DateAddedFormat, CultureInfo.InvariantCulture)); @@ -974,7 +962,7 @@ namespace MediaBrowser.XbmcMetadata.Savers => string.Equals(person.Type, type, StringComparison.OrdinalIgnoreCase) || string.Equals(person.Role, type, StringComparison.OrdinalIgnoreCase); - private void AddCustomTags(string path, List<string> xmlTagsUsed, XmlWriter writer, ILogger<BaseNfoSaver> logger) + private void AddCustomTags(string path, IReadOnlyCollection<string> xmlTagsUsed, XmlWriter writer, ILogger<BaseNfoSaver> logger) { var settings = new XmlReaderSettings() { diff --git a/MediaBrowser.XbmcMetadata/Savers/EpisodeNfoSaver.cs b/MediaBrowser.XbmcMetadata/Savers/EpisodeNfoSaver.cs index ac2fbb8d24..62f80e81bd 100644 --- a/MediaBrowser.XbmcMetadata/Savers/EpisodeNfoSaver.cs +++ b/MediaBrowser.XbmcMetadata/Savers/EpisodeNfoSaver.cs @@ -56,6 +56,8 @@ namespace MediaBrowser.XbmcMetadata.Savers { var episode = (Episode)item; + writer.WriteElementString("showtitle", episode.SeriesName); + if (episode.IndexNumber.HasValue) { writer.WriteElementString("episode", episode.IndexNumber.Value.ToString(_usCulture)); @@ -109,23 +111,23 @@ namespace MediaBrowser.XbmcMetadata.Savers } /// <inheritdoc /> - protected override List<string> GetTagsUsed(BaseItem item) + protected override IEnumerable<string> GetTagsUsed(BaseItem item) { - var list = base.GetTagsUsed(item); - list.AddRange(new string[] + foreach (var tag in base.GetTagsUsed(item)) { - "aired", - "season", - "episode", - "episodenumberend", - "airsafter_season", - "airsbefore_episode", - "airsbefore_season", - "displayseason", - "displayepisode" - }); - - return list; + yield return tag; + } + + yield return "aired"; + yield return "season"; + yield return "episode"; + yield return "episodenumberend"; + yield return "airsafter_season"; + yield return "airsbefore_episode"; + yield return "airsbefore_season"; + yield return "displayseason"; + yield return "displayepisode"; + yield return "showtitle"; } } } diff --git a/MediaBrowser.XbmcMetadata/Savers/MovieNfoSaver.cs b/MediaBrowser.XbmcMetadata/Savers/MovieNfoSaver.cs index dca796415a..412e8031b2 100644 --- a/MediaBrowser.XbmcMetadata/Savers/MovieNfoSaver.cs +++ b/MediaBrowser.XbmcMetadata/Savers/MovieNfoSaver.cs @@ -41,7 +41,7 @@ namespace MediaBrowser.XbmcMetadata.Savers /// <inheritdoc /> protected override string GetLocalSavePath(BaseItem item) - => GetMovieSavePaths(new ItemInfo(item)).FirstOrDefault(); + => GetMovieSavePaths(new ItemInfo(item)).FirstOrDefault() ?? Path.ChangeExtension(item.Path, ".nfo"); internal static IEnumerable<string> GetMovieSavePaths(ItemInfo item) { @@ -123,18 +123,17 @@ namespace MediaBrowser.XbmcMetadata.Savers } /// <inheritdoc /> - protected override List<string> GetTagsUsed(BaseItem item) + protected override IEnumerable<string> GetTagsUsed(BaseItem item) { - var list = base.GetTagsUsed(item); - list.AddRange(new string[] + foreach (var tag in base.GetTagsUsed(item)) { - "album", - "artist", - "set", - "id" - }); + yield return tag; + } - return list; + yield return "album"; + yield return "artist"; + yield return "set"; + yield return "id"; } } } diff --git a/MediaBrowser.XbmcMetadata/Savers/SeasonNfoSaver.cs b/MediaBrowser.XbmcMetadata/Savers/SeasonNfoSaver.cs index 925a230bdb..b9d73ba822 100644 --- a/MediaBrowser.XbmcMetadata/Savers/SeasonNfoSaver.cs +++ b/MediaBrowser.XbmcMetadata/Savers/SeasonNfoSaver.cs @@ -72,15 +72,14 @@ namespace MediaBrowser.XbmcMetadata.Savers } /// <inheritdoc /> - protected override List<string> GetTagsUsed(BaseItem item) + protected override IEnumerable<string> GetTagsUsed(BaseItem item) { - var list = base.GetTagsUsed(item); - list.AddRange(new string[] + foreach (var tag in base.GetTagsUsed(item)) { - "seasonnumber" - }); + yield return tag; + } - return list; + yield return "seasonnumber"; } } } diff --git a/MediaBrowser.XbmcMetadata/Savers/SeriesNfoSaver.cs b/MediaBrowser.XbmcMetadata/Savers/SeriesNfoSaver.cs index 42285db76d..083f22e5d2 100644 --- a/MediaBrowser.XbmcMetadata/Savers/SeriesNfoSaver.cs +++ b/MediaBrowser.XbmcMetadata/Savers/SeriesNfoSaver.cs @@ -90,20 +90,19 @@ namespace MediaBrowser.XbmcMetadata.Savers } /// <inheritdoc /> - protected override List<string> GetTagsUsed(BaseItem item) + protected override IEnumerable<string> GetTagsUsed(BaseItem item) { - var list = base.GetTagsUsed(item); - list.AddRange(new string[] + foreach (var tag in base.GetTagsUsed(item)) { - "id", - "episodeguide", - "season", - "episode", - "status", - "displayorder" - }); + yield return tag; + } - return list; + yield return "id"; + yield return "episodeguide"; + yield return "season"; + yield return "episode"; + yield return "status"; + yield return "displayorder"; } } } diff --git a/MediaBrowser.sln.GhostDoc.xml b/MediaBrowser.sln.GhostDoc.xml deleted file mode 100644 index eafee0bf55..0000000000 --- a/MediaBrowser.sln.GhostDoc.xml +++ /dev/null @@ -1,35 +0,0 @@ -<GhostDoc> - <SpellChecker> - <IncludeExtensions> - </IncludeExtensions> - <IgnoreExtensions> - </IgnoreExtensions> - <IgnoreFiles> - </IgnoreFiles> - </SpellChecker> - <HelpConfigurations selected="HelpFile"> - <HelpConfiguration name="HelpFile"> - <OutputPath>D:\Development\MediaBrowser\Help</OutputPath> - <ImageFolderPath /> - <HtmlFormats> - <HtmlHelp>true</HtmlHelp> - <MSHelpViewer>false</MSHelpViewer> - <MSHelp2>false</MSHelp2> - <Website>false</Website> - </HtmlFormats> - <IncludeScopes> - <Public>true</Public> - <Internal>false</Internal> - <Protected>false</Protected> - <Private>false</Private> - <Inherited>true</Inherited> - <EnableTags>false</EnableTags> - <TagList /> - </IncludeScopes> - <ResolveCrefLinks>true</ResolveCrefLinks> - <HeaderText /> - <FooterText /> - <SelectedProjects /> - </HelpConfiguration> - </HelpConfigurations> -</GhostDoc> diff --git a/README.md b/README.md index 435e709b33..3aef84b991 100644 --- a/README.md +++ b/README.md @@ -29,9 +29,6 @@ <a href="https://features.jellyfin.org"> <img alt="Submit Feature Requests" src="https://img.shields.io/badge/fider-vote%20on%20features-success.svg"/> </a> -<a href="https://forum.jellyfin.org"> -<img alt="Discuss on our Forum" src="https://img.shields.io/discourse/https/forum.jellyfin.org/users.svg"/> -</a> <a href="https://matrix.to/#/+jellyfin:matrix.org"> <img alt="Chat on Matrix" src="https://img.shields.io/matrix/jellyfin:matrix.org.svg?logo=matrix"/> </a> @@ -44,6 +41,9 @@ <a href="https://github.com/jellyfin/jellyfin/commits/master.atom"> <img alt="Master Commits RSS Feed"" src="https://img.shields.io/badge/rss-commits-ffa500?logo=rss" /> </a> +<a href="https://lgtm.com/projects/g/jellyfin/jellyfin/alerts/"> +<img alt="Total LGTM alerts" src="https://img.shields.io/lgtm/alerts/g/jellyfin/jellyfin.svg?logo=lgtm&logoWidth=18"/> +</a> </p> --- @@ -71,6 +71,8 @@ Check out our <a href="https://translate.jellyfin.org">Weblate instance</a> to h <img src="https://translate.jellyfin.org/widgets/jellyfin/-/jellyfin-web/multi-auto.svg" alt="Detailed Translation Status"/> </a> +--- + ## Jellyfin Server This repository contains the code for Jellyfin's backend server. Note that this is only one of many projects under the Jellyfin GitHub [organization](https://github.com/jellyfin/) on GitHub. If you want to contribute, you can start by checking out our [documentation](https://jellyfin.org/docs/general/contributing/index.html) to see what to work on. @@ -81,10 +83,12 @@ These instructions will help you get set up with a local development environment ### Prerequisites -Before the project can be built, you must first install the [.NET Core 3.1 SDK](https://dotnet.microsoft.com/download) on your system. +Before the project can be built, you must first install the [.NET 5.0 SDK](https://dotnet.microsoft.com/download) on your system. Instructions to run this project from the command line are included here, but you will also need to install an IDE if you want to debug the server while it is running. Any IDE that supports .NET Core development will work, but two options are recent versions of [Visual Studio](https://visualstudio.microsoft.com/downloads/) (at least 2017) and [Visual Studio Code](https://code.visualstudio.com/Download). +[ffmpeg](https://github.com/jellyfin/jellyfin-ffmpeg) will also need to be installed. + ### Cloning the Repository After dependencies are installed you will need to clone a local copy of this repository. If you just want to run the server from source you can clone this repository directly, but if you are intending to contribute code changes to the project, you should [set up your own fork](https://jellyfin.org/docs/general/contributing/development.html#set-up-your-copy-of-the-repo) of the repository. The following example shows how you can clone the repository directly over HTTPS. @@ -105,12 +109,6 @@ There are three options to get the files for the web client. 2. Build them from source following the instructions on the [jellyfin-web repository](https://github.com/jellyfin/jellyfin-web) 3. Get the pre-built files from an existing installation of the server. For example, with a Windows server installation the client files are located at `C:\Program Files\Jellyfin\Server\jellyfin-web` -Once you have a copy of the built web client files, you need to copy them into a specific directory. - -> `<repository root>/Mediabrowser.WebDashboard/jellyfin-web` - -As part of the build process, this folder will be copied to the build output directory, where it can be accessed by the server. - ### Running The Server The following instructions will help you get the project up and running via the command line, or your preferred IDE. @@ -133,7 +131,7 @@ To run the server from the command line you can use the `dotnet run` command. Th ```bash cd jellyfin # Move into the repository directory -dotnet run --project Jellyfin.Server # Run the server startup project +dotnet run --project Jellyfin.Server --webdir /absolute/path/to/jellyfin-web/dist # Run the server startup project ``` A second option is to build the project and then run the resulting executable file directly. When running the executable directly you can easily add command line options. Add the `--help` flag to list details on all the supported command line options. @@ -142,7 +140,7 @@ A second option is to build the project and then run the resulting executable fi ```bash dotnet build # Build the project - cd bin/Debug/netcoreapp3.1 # Change into the build output directory + cd bin/Debug/net5.0 # Change into the build output directory ``` 2. Execute the build output. On Linux, Mac, etc. use `./jellyfin` and on Windows use `jellyfin.exe`. @@ -169,3 +167,13 @@ switch `--nowebclient` or the environment variable `JELLYFIN_NOWEBCONTENT=true`. Since this is a common scenario, there is also a separate launch profile defined for Visual Studio called `Jellyfin.Server (nowebcontent)` that can be selected from the 'Start Debugging' dropdown in the main toolbar. **NOTE:** The setup wizard can not be run if the web client is hosted separately. + +--- +<p align="center"> +This project is supported by: +<br/> +<br/> +<a href="https://www.digitalocean.com"><img src="https://opensource.nyc3.cdn.digitaloceanspaces.com/attribution/assets/SVG/DO_Logo_horizontal_blue.svg" height="50px" alt="DigitalOcean"></a> +   +<a href="https://www.jetbrains.com"><img src="https://gist.githubusercontent.com/anthonylavado/e8b2403deee9581e0b4cb8cd675af7db/raw/fa104b7d73f759d7262794b94569f1b89df41c0b/jetbrains.svg" height="50px" alt="JetBrains logo"></a> +</p> diff --git a/RSSDP/DeviceAvailableEventArgs.cs b/RSSDP/DeviceAvailableEventArgs.cs index b7d22a7df5..04b14c4dca 100644 --- a/RSSDP/DeviceAvailableEventArgs.cs +++ b/RSSDP/DeviceAvailableEventArgs.cs @@ -8,7 +8,7 @@ namespace Rssdp /// </summary> public sealed class DeviceAvailableEventArgs : EventArgs { - public IPAddress LocalIpAddress { get; set; } + public IPAddress RemoteIpAddress { get; set; } private readonly DiscoveredSsdpDevice _DiscoveredDevice; diff --git a/RSSDP/DisposableManagedObjectBase.cs b/RSSDP/DisposableManagedObjectBase.cs index 745ec359c9..7d6a471f95 100644 --- a/RSSDP/DisposableManagedObjectBase.cs +++ b/RSSDP/DisposableManagedObjectBase.cs @@ -5,7 +5,7 @@ using System.Text; namespace Rssdp.Infrastructure { /// <summary> - /// Correclty implements the <see cref="IDisposable"/> interface and pattern for an object containing only managed resources, and adds a few common niceities not on the interface such as an <see cref="IsDisposed"/> property. + /// Correctly implements the <see cref="IDisposable"/> interface and pattern for an object containing only managed resources, and adds a few common niceties not on the interface such as an <see cref="IsDisposed"/> property. /// </summary> public abstract class DisposableManagedObjectBase : IDisposable { @@ -61,10 +61,10 @@ namespace Rssdp.Infrastructure /// Disposes this object instance and all internally managed resources. /// </summary> /// <remarks> - /// <para>Sets the <see cref="IsDisposed"/> property to true. Does not explicitly throw an exception if called multiple times, but makes no promises about behaviour of derived classes.</para> + /// <para>Sets the <see cref="IsDisposed"/> property to true. Does not explicitly throw an exception if called multiple times, but makes no promises about behavior of derived classes.</para> /// </remarks> /// <seealso cref="IsDisposed"/> - [System.Diagnostics.CodeAnalysis.SuppressMessage("Microsoft.Design", "CA1063:ImplementIDisposableCorrectly", Justification = "We do exactly as asked, but CA doesn't seem to like us also setting the IsDisposed property. Too bad, it's a good idea and shouldn't cause an exception or anything likely to interfer with the dispose process.")] + [System.Diagnostics.CodeAnalysis.SuppressMessage("Microsoft.Design", "CA1063:ImplementIDisposableCorrectly", Justification = "We do exactly as asked, but CA doesn't seem to like us also setting the IsDisposed property. Too bad, it's a good idea and shouldn't cause an exception or anything likely to interfere with the dispose process.")] public void Dispose() { IsDisposed = true; diff --git a/RSSDP/HttpParserBase.cs b/RSSDP/HttpParserBase.cs index a40612bc29..c56249523d 100644 --- a/RSSDP/HttpParserBase.cs +++ b/RSSDP/HttpParserBase.cs @@ -105,7 +105,7 @@ namespace Rssdp.Infrastructure var headerName = line.Substring(0, headerKeySeparatorIndex).Trim(); var headerValue = line.Substring(headerKeySeparatorIndex + 1).Trim(); - // Not sure how to determine where request headers and and content headers begin, + // Not sure how to determine where request headers and content headers begin, // at least not without a known set of headers (general headers first the content headers) // which seems like a bad way of doing it. So we'll assume if it's a known content header put it there // else use request headers. @@ -119,7 +119,7 @@ namespace Rssdp.Infrastructure } else { - headersToAddTo.TryAddWithoutValidation(headerName, values.First()); + headersToAddTo.TryAddWithoutValidation(headerName, values[0]); } } @@ -151,7 +151,7 @@ namespace Rssdp.Infrastructure return lineIndex; } - private IList<string> ParseValues(string headerValue) + private List<string> ParseValues(string headerValue) { // This really should be better and match the HTTP 1.1 spec, // but this should actually be good enough for SSDP implementations @@ -160,7 +160,7 @@ namespace Rssdp.Infrastructure if (headerValue == "\"\"") { - values.Add(String.Empty); + values.Add(string.Empty); return values; } @@ -172,7 +172,7 @@ namespace Rssdp.Infrastructure else { var segments = headerValue.Split(SeparatorCharacters); - if (headerValue.Contains("\"")) + if (headerValue.Contains('"')) { for (int segmentIndex = 0; segmentIndex < segments.Length; segmentIndex++) { diff --git a/RSSDP/ISsdpDeviceLocator.cs b/RSSDP/ISsdpDeviceLocator.cs index 4130556434..4df166cd26 100644 --- a/RSSDP/ISsdpDeviceLocator.cs +++ b/RSSDP/ISsdpDeviceLocator.cs @@ -52,7 +52,7 @@ namespace Rssdp.Infrastructure } /// <summary> - /// Aynchronously performs a search for all devices using the default search timeout, and returns an awaitable task that can be used to retrieve the results. + /// Asynchronously performs a search for all devices using the default search timeout, and returns an awaitable task that can be used to retrieve the results. /// </summary> /// <returns>A task whose result is an <see cref="System.Collections.Generic.IEnumerable{T}"/> of <see cref="DiscoveredSsdpDevice" /> instances, representing all found devices.</returns> System.Threading.Tasks.Task<System.Collections.Generic.IEnumerable<DiscoveredSsdpDevice>> SearchAsync(); @@ -83,7 +83,7 @@ namespace Rssdp.Infrastructure /// </param> /// <param name="searchWaitTime">The amount of time to wait for network responses to the search request. Longer values will likely return more devices, but increase search time. A value between 1 and 5 is recommended by the UPnP 1.1 specification. Specify TimeSpan.Zero to return only devices already in the cache.</param> /// <remarks> - /// <para>By design RSSDP does not support 'publishing services' as it is intended for use with non-standard UPnP devices that don't publish UPnP style services. However, it is still possible to use RSSDP to search for devices implemetning these services if you know the service type.</para> + /// <para>By design RSSDP does not support 'publishing services' as it is intended for use with non-standard UPnP devices that don't publish UPnP style services. However, it is still possible to use RSSDP to search for devices implementing these services if you know the service type.</para> /// </remarks> /// <returns>A task whose result is an <see cref="System.Collections.Generic.IEnumerable{T}"/> of <see cref="DiscoveredSsdpDevice" /> instances, representing all found devices.</returns> System.Threading.Tasks.Task<System.Collections.Generic.IEnumerable<DiscoveredSsdpDevice>> SearchAsync(string searchTarget, TimeSpan searchWaitTime); diff --git a/RSSDP/RSSDP.csproj b/RSSDP/RSSDP.csproj index 664663bd76..54113d4644 100644 --- a/RSSDP/RSSDP.csproj +++ b/RSSDP/RSSDP.csproj @@ -6,13 +6,16 @@ </PropertyGroup> <ItemGroup> + <ProjectReference Include="..\Jellyfin.Networking\Jellyfin.Networking.csproj" /> <ProjectReference Include="..\MediaBrowser.Common\MediaBrowser.Common.csproj" /> </ItemGroup> <PropertyGroup> - <TargetFramework>netstandard2.1</TargetFramework> + <TargetFramework>net5.0</TargetFramework> <GenerateAssemblyInfo>false</GenerateAssemblyInfo> - <TreatWarningsAsErrors>true</TreatWarningsAsErrors> + <AnalysisMode>AllDisabledByDefault</AnalysisMode> + <Nullable>disable</Nullable> + <NoWarn>CA2016</NoWarn> </PropertyGroup> </Project> diff --git a/RSSDP/SsdpCommunicationsServer.cs b/RSSDP/SsdpCommunicationsServer.cs index a4be32e7d2..e49c0e77be 100644 --- a/RSSDP/SsdpCommunicationsServer.cs +++ b/RSSDP/SsdpCommunicationsServer.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using System.Linq; using System.Net; using System.Net.Http; @@ -42,7 +43,7 @@ namespace Rssdp.Infrastructure private HttpResponseParser _ResponseParser; private readonly ILogger _logger; private ISocketFactory _SocketFactory; - private readonly INetworkManager _networkManager; + private readonly INetworkManager _networkManager; private int _LocalPort; private int _MulticastTtl; @@ -68,7 +69,7 @@ namespace Rssdp.Infrastructure INetworkManager networkManager, ILogger logger, bool enableMultiSocketBinding) : this(socketFactory, 0, SsdpConstants.SsdpDefaultMulticastTimeToLive, networkManager, logger, enableMultiSocketBinding) { - + } /// <summary> @@ -352,17 +353,17 @@ namespace Rssdp.Infrastructure if (_enableMultiSocketBinding) { - foreach (var address in _networkManager.GetLocalIpAddresses()) + foreach (var address in _networkManager.GetInternalBindAddresses()) { if (address.AddressFamily == AddressFamily.InterNetworkV6) { // Not support IPv6 right now continue; - } + } try { - sockets.Add(_SocketFactory.CreateSsdpUdpSocket(address, _LocalPort)); + sockets.Add(_SocketFactory.CreateSsdpUdpSocket(address.Address, _LocalPort)); } catch (Exception ex) { @@ -415,10 +416,7 @@ namespace Rssdp.Infrastructure { lock (_SendSocketSynchroniser) { - if (_sendSockets == null) - { - _sendSockets = CreateSocketAndListenForResponsesAsync(); - } + _sendSockets ??= CreateSocketAndListenForResponsesAsync(); } } } diff --git a/RSSDP/SsdpDeviceLocator.cs b/RSSDP/SsdpDeviceLocator.cs index bfad6de97a..188e298e2a 100644 --- a/RSSDP/SsdpDeviceLocator.cs +++ b/RSSDP/SsdpDeviceLocator.cs @@ -97,6 +97,11 @@ namespace Rssdp.Infrastructure private async void OnBroadcastTimerCallback(object state) { + if (IsDisposed) + { + return; + } + StartListeningForNotifications(); RemoveExpiredDevicesFromCache(); @@ -180,8 +185,6 @@ namespace Rssdp.Infrastructure /// <exception cref="ObjectDisposedException">Throw if the <see cref="DisposableManagedObjectBase.IsDisposed"/> ty is true.</exception> public void StartListeningForNotifications() { - ThrowIfDisposed(); - _CommunicationsServer.RequestReceived -= CommsServer_RequestReceived; _CommunicationsServer.RequestReceived += CommsServer_RequestReceived; _CommunicationsServer.BeginListeningForBroadcasts(); @@ -208,7 +211,7 @@ namespace Rssdp.Infrastructure /// Raises the <see cref="DeviceAvailable"/> event. /// </summary> /// <seealso cref="DeviceAvailable"/> - protected virtual void OnDeviceAvailable(DiscoveredSsdpDevice device, bool isNewDevice, IPAddress localIpAddress) + protected virtual void OnDeviceAvailable(DiscoveredSsdpDevice device, bool isNewDevice, IPAddress IpAddress) { if (this.IsDisposed) { @@ -220,7 +223,7 @@ namespace Rssdp.Infrastructure { handlers(this, new DeviceAvailableEventArgs(device, isNewDevice) { - LocalIpAddress = localIpAddress + RemoteIpAddress = IpAddress }); } } @@ -286,7 +289,7 @@ namespace Rssdp.Infrastructure } } - private void AddOrUpdateDiscoveredDevice(DiscoveredSsdpDevice device, IPAddress localIpAddress) + private void AddOrUpdateDiscoveredDevice(DiscoveredSsdpDevice device, IPAddress IpAddress) { bool isNewDevice = false; lock (_Devices) @@ -304,17 +307,17 @@ namespace Rssdp.Infrastructure } } - DeviceFound(device, isNewDevice, localIpAddress); + DeviceFound(device, isNewDevice, IpAddress); } - private void DeviceFound(DiscoveredSsdpDevice device, bool isNewDevice, IPAddress localIpAddress) + private void DeviceFound(DiscoveredSsdpDevice device, bool isNewDevice, IPAddress IpAddress) { if (!NotificationTypeMatchesFilter(device)) { return; } - OnDeviceAvailable(device, isNewDevice, localIpAddress); + OnDeviceAvailable(device, isNewDevice, IpAddress); } private bool NotificationTypeMatchesFilter(DiscoveredSsdpDevice device) @@ -347,13 +350,13 @@ namespace Rssdp.Infrastructure return _CommunicationsServer.SendMulticastMessage(message, null, cancellationToken); } - private void ProcessSearchResponseMessage(HttpResponseMessage message, IPAddress localIpAddress) + private void ProcessSearchResponseMessage(HttpResponseMessage message, IPAddress IpAddress) { if (!message.IsSuccessStatusCode) { return; } - + var location = GetFirstHeaderUriValue("Location", message); if (location != null) { @@ -367,11 +370,11 @@ namespace Rssdp.Infrastructure ResponseHeaders = message.Headers }; - AddOrUpdateDiscoveredDevice(device, localIpAddress); + AddOrUpdateDiscoveredDevice(device, IpAddress); } } - private void ProcessNotificationMessage(HttpRequestMessage message, IPAddress localIpAddress) + private void ProcessNotificationMessage(HttpRequestMessage message, IPAddress IpAddress) { if (String.Compare(message.Method.Method, "Notify", StringComparison.OrdinalIgnoreCase) != 0) { @@ -381,7 +384,7 @@ namespace Rssdp.Infrastructure var notificationType = GetFirstHeaderStringValue("NTS", message); if (String.Compare(notificationType, SsdpConstants.SsdpKeepAliveNotification, StringComparison.OrdinalIgnoreCase) == 0) { - ProcessAliveNotification(message, localIpAddress); + ProcessAliveNotification(message, IpAddress); } else if (String.Compare(notificationType, SsdpConstants.SsdpByeByeNotification, StringComparison.OrdinalIgnoreCase) == 0) { @@ -389,7 +392,7 @@ namespace Rssdp.Infrastructure } } - private void ProcessAliveNotification(HttpRequestMessage message, IPAddress localIpAddress) + private void ProcessAliveNotification(HttpRequestMessage message, IPAddress IpAddress) { var location = GetFirstHeaderUriValue("Location", message); if (location != null) @@ -404,7 +407,7 @@ namespace Rssdp.Infrastructure ResponseHeaders = message.Headers }; - AddOrUpdateDiscoveredDevice(device, localIpAddress); + AddOrUpdateDiscoveredDevice(device, IpAddress); } } @@ -515,11 +518,6 @@ namespace Rssdp.Infrastructure private void RemoveExpiredDevicesFromCache() { - if (this.IsDisposed) - { - return; - } - DiscoveredSsdpDevice[] expiredDevices = null; lock (_Devices) { @@ -630,7 +628,7 @@ namespace Rssdp.Infrastructure private void CommsServer_RequestReceived(object sender, RequestReceivedEventArgs e) { - ProcessNotificationMessage(e.Message, e.LocalIpAddress); + ProcessNotificationMessage(e.Message, e.ReceivedFrom.Address); } } } diff --git a/RSSDP/SsdpDevicePublisher.cs b/RSSDP/SsdpDevicePublisher.cs index 1a8577d8da..c9e795d565 100644 --- a/RSSDP/SsdpDevicePublisher.cs +++ b/RSSDP/SsdpDevicePublisher.cs @@ -102,7 +102,7 @@ namespace Rssdp.Infrastructure /// <param name="device">The <see cref="SsdpDevice"/> instance to add.</param> /// <exception cref="ArgumentNullException">Thrown if the <paramref name="device"/> argument is null.</exception> /// <exception cref="InvalidOperationException">Thrown if the <paramref name="device"/> contains property values that are not acceptable to the UPnP 1.0 specification.</exception> - [System.Diagnostics.CodeAnalysis.SuppressMessage("Microsoft.Performance", "CA1804:RemoveUnusedLocals", MessageId = "t", Justification = "Capture task to local variable supresses compiler warning, but task is not really needed.")] + [System.Diagnostics.CodeAnalysis.SuppressMessage("Microsoft.Performance", "CA1804:RemoveUnusedLocals", MessageId = "t", Justification = "Capture task to local variable suppresses compiler warning, but task is not really needed.")] public void AddDevice(SsdpRootDevice device) { if (device == null) @@ -180,7 +180,7 @@ namespace Rssdp.Infrastructure /// </summary> /// <remarks> /// <para>Enabling this option will cause devices to show up in Microsoft Windows Explorer's network screens (if discovery is enabled etc.). Windows Explorer appears to search only for pnp:rootdeivce and not upnp:rootdevice.</para> - /// <para>If false, the system will only use upnp:rootdevice for notifiation broadcasts and and search responses, which is correct according to the UPnP/SSDP spec.</para> + /// <para>If false, the system will only use upnp:rootdevice for notification broadcasts and and search responses, which is correct according to the UPnP/SSDP spec.</para> /// </remarks> public bool SupportPnpRootDevice { @@ -300,17 +300,15 @@ namespace Rssdp.Infrastructure foreach (var device in deviceList) { - if (!_sendOnlyMatchedHost || - _networkManager.IsInSameSubnet(device.ToRootDevice().Address, remoteEndPoint.Address, device.ToRootDevice().SubnetMask)) + var root = device.ToRootDevice(); + var source = new IPNetAddress(root.Address, root.PrefixLength); + var destination = new IPNetAddress(remoteEndPoint.Address, root.PrefixLength); + if (!_sendOnlyMatchedHost || source.NetworkAddress.Equals(destination.NetworkAddress)) { SendDeviceSearchResponses(device, remoteEndPoint, receivedOnlocalIpAddress, cancellationToken); } } } - else - { - // WriteTrace(String.Format("Sending 0 search responses.")); - } }); } diff --git a/RSSDP/SsdpRootDevice.cs b/RSSDP/SsdpRootDevice.cs index 8937ec331f..5ecb1f86f6 100644 --- a/RSSDP/SsdpRootDevice.cs +++ b/RSSDP/SsdpRootDevice.cs @@ -25,7 +25,7 @@ namespace Rssdp /// Specifies how long clients can cache this device's details for. Optional but defaults to <see cref="TimeSpan.Zero"/> which means no-caching. Recommended value is half an hour. /// </summary> /// <remarks> - /// <para>Specifiy <see cref="TimeSpan.Zero"/> to indicate no caching allowed.</para> + /// <para>Specify <see cref="TimeSpan.Zero"/> to indicate no caching allowed.</para> /// <para>Also used to specify how often to rebroadcast alive notifications.</para> /// <para>The UPnP/SSDP specifications indicate this should not be less than 1800 seconds (half an hour), but this is not enforced by this library.</para> /// </remarks> @@ -45,12 +45,12 @@ namespace Rssdp public IPAddress Address { get; set; } /// <summary> - /// Gets or sets the SubnetMask used to check if the received message from same interface with this device/tree. Required. + /// Gets or sets the prefix length used to check if the received message from same interface with this device/tree. Required. /// </summary> - public IPAddress SubnetMask { get; set; } + public byte PrefixLength { get; set; } /// <summary> - /// The base URL to use for all relative url's provided in other propertise (and those of child devices). Optional. + /// The base URL to use for all relative url's provided in other properties (and those of child devices). Optional. /// </summary> /// <remarks> /// <para>Defines the base URL. Used to construct fully-qualified URLs. All relative URLs that appear elsewhere in the description are combined with this base URL. If URLBase is empty or not given, the base URL is the URL from which the device description was retrieved (which is the preferred implementation; use of URLBase is no longer recommended). Specified by UPnP vendor. Single URL.</para> diff --git a/SharedVersion.cs b/SharedVersion.cs index d17074fc0d..5e2f151a22 100644 --- a/SharedVersion.cs +++ b/SharedVersion.cs @@ -1,4 +1,4 @@ using System.Reflection; -[assembly: AssemblyVersion("10.7.0")] -[assembly: AssemblyFileVersion("10.7.0")] +[assembly: AssemblyVersion("10.8.0")] +[assembly: AssemblyFileVersion("10.8.0")] diff --git a/apiclient/.openapi-generator-ignore b/apiclient/.openapi-generator-ignore deleted file mode 100644 index f3802cf541..0000000000 --- a/apiclient/.openapi-generator-ignore +++ /dev/null @@ -1,2 +0,0 @@ -# Prevent generator from creating these files: -git_push.sh diff --git a/apiclient/templates/typescript/axios/generate.sh b/apiclient/templates/typescript/axios/generate.sh deleted file mode 100644 index 8c4d742825..0000000000 --- a/apiclient/templates/typescript/axios/generate.sh +++ /dev/null @@ -1,19 +0,0 @@ -#!/bin/bash - -artifactsDirectory="${1}" -buildNumber="${2}" -if [[ -n ${buildNumber} ]]; then - # Unstable build - additionalProperties=",snapshotVersion=-SNAPSHOT.${buildNumber},npmRepository=https://pkgs.dev.azure.com/jellyfin-project/jellyfin/_packaging/unstable/npm/registry/" -else - # Stable build - additionalProperties="" -fi - -java -jar openapi-generator-cli.jar generate \ - --input-spec ${artifactsDirectory}/openapispec/openapi.json \ - --generator-name typescript-axios \ - --output ./apiclient/generated/typescript/axios \ - --template-dir ./apiclient/templates/typescript/axios \ - --ignore-file-override ./apiclient/.openapi-generator-ignore \ - --additional-properties=useSingleRequestParameter="true",withSeparateModelsAndApi="true",modelPackage="models",apiPackage="api",npmName="axios"${additionalProperties} diff --git a/apiclient/templates/typescript/axios/package.mustache b/apiclient/templates/typescript/axios/package.mustache deleted file mode 100644 index 7bfab08cbb..0000000000 --- a/apiclient/templates/typescript/axios/package.mustache +++ /dev/null @@ -1,30 +0,0 @@ -{ - "name": "@jellyfin/client-axios", - "version": "10.7.0{{snapshotVersion}}", - "description": "Jellyfin api client using axios", - "author": "Jellyfin Contributors", - "keywords": [ - "axios", - "typescript", - "jellyfin" - ], - "license": "GPL-3.0-only", - "main": "./dist/index.js", - "typings": "./dist/index.d.ts", - "scripts": { - "build": "tsc --outDir dist/", - "prepublishOnly": "npm run build" - }, - "dependencies": { - "axios": "^0.19.2" - }, - "devDependencies": { - "@types/node": "^12.11.5", - "typescript": "^3.6.4" - }{{#npmRepository}},{{/npmRepository}} -{{#npmRepository}} - "publishConfig": { - "registry": "{{npmRepository}}" - } -{{/npmRepository}} -} diff --git a/benches/Jellyfin.Common.Benches/HexDecodeBenches.cs b/benches/Jellyfin.Common.Benches/HexDecodeBenches.cs deleted file mode 100644 index d9a107b696..0000000000 --- a/benches/Jellyfin.Common.Benches/HexDecodeBenches.cs +++ /dev/null @@ -1,45 +0,0 @@ -using System; -using System.Globalization; -using BenchmarkDotNet.Attributes; -using BenchmarkDotNet.Running; -using MediaBrowser.Common; - -namespace Jellyfin.Common.Benches -{ - [MemoryDiagnoser] - public class HexDecodeBenches - { - private string _data; - - [Params(0, 10, 100, 1000, 10000, 1000000)] - public int N { get; set; } - - [GlobalSetup] - public void GlobalSetup() - { - var bytes = new byte[N]; - new Random(42).NextBytes(bytes); - _data = Hex.Encode(bytes); - } - - [Benchmark] - public byte[] Decode() => Hex.Decode(_data); - - [Benchmark] - public byte[] DecodeSubString() => DecodeSubString(_data); - - private static byte[] DecodeSubString(string str) - { - byte[] bytes = new byte[str.Length / 2]; - for (int i = 0; i < str.Length; i += 2) - { - bytes[i / 2] = byte.Parse( - str.Substring(i, 2), - NumberStyles.HexNumber, - CultureInfo.InvariantCulture); - } - - return bytes; - } - } -} diff --git a/benches/Jellyfin.Common.Benches/HexEncodeBenches.cs b/benches/Jellyfin.Common.Benches/HexEncodeBenches.cs deleted file mode 100644 index 7abf93c510..0000000000 --- a/benches/Jellyfin.Common.Benches/HexEncodeBenches.cs +++ /dev/null @@ -1,32 +0,0 @@ -using System; -using BenchmarkDotNet.Attributes; -using BenchmarkDotNet.Running; -using MediaBrowser.Common; - -namespace Jellyfin.Common.Benches -{ - [MemoryDiagnoser] - public class HexEncodeBenches - { - private byte[] _data; - - [Params(0, 10, 100, 1000, 10000, 1000000)] - public int N { get; set; } - - [GlobalSetup] - public void GlobalSetup() - { - _data = new byte[N]; - new Random(42).NextBytes(_data); - } - - [Benchmark] - public string HexEncode() => Hex.Encode(_data); - - [Benchmark] - public string BitConverterToString() => BitConverter.ToString(_data); - - [Benchmark] - public string BitConverterToStringWithReplace() => BitConverter.ToString(_data).Replace("-", ""); - } -} diff --git a/benches/Jellyfin.Common.Benches/Jellyfin.Common.Benches.csproj b/benches/Jellyfin.Common.Benches/Jellyfin.Common.Benches.csproj deleted file mode 100644 index 47aeed05ef..0000000000 --- a/benches/Jellyfin.Common.Benches/Jellyfin.Common.Benches.csproj +++ /dev/null @@ -1,16 +0,0 @@ -<Project Sdk="Microsoft.NET.Sdk"> - - <PropertyGroup> - <OutputType>Exe</OutputType> - <TargetFramework>netcoreapp3.1</TargetFramework> - </PropertyGroup> - - <ItemGroup> - <PackageReference Include="BenchmarkDotNet" Version="0.12.0" /> - </ItemGroup> - - <ItemGroup> - <ProjectReference Include="../../MediaBrowser.Common/MediaBrowser.Common.csproj" /> - </ItemGroup> - -</Project> diff --git a/benches/Jellyfin.Common.Benches/Program.cs b/benches/Jellyfin.Common.Benches/Program.cs deleted file mode 100644 index b218b0dc10..0000000000 --- a/benches/Jellyfin.Common.Benches/Program.cs +++ /dev/null @@ -1,14 +0,0 @@ -using System; -using BenchmarkDotNet.Running; - -namespace Jellyfin.Common.Benches -{ - public static class Program - { - public static void Main(string[] args) - { - _ = BenchmarkRunner.Run<HexEncodeBenches>(); - _ = BenchmarkRunner.Run<HexDecodeBenches>(); - } - } -} diff --git a/build.yaml b/build.yaml index 7c32d955c1..18434ee003 100644 --- a/build.yaml +++ b/build.yaml @@ -1,7 +1,7 @@ --- # We just wrap `build` so this is really it name: "jellyfin" -version: "10.7.0" +version: "10.8.0" packages: - debian.amd64 - debian.arm64 diff --git a/bump_version b/bump_version index d2de5a0bdc..8012ec07f1 100755 --- a/bump_version +++ b/bump_version @@ -49,7 +49,6 @@ sed -i "s/${old_version_sed}/${new_version}/g" ${build_file} # update nuget package version for subproject in ${jellyfin_subprojects[@]}; do -do echo ${subproject} # Parse the version from the *.csproj file old_version="$( diff --git a/debian/bin/restart.sh b/debian/bin/restart.sh index 9b64b6d728..34fce06708 100755 --- a/debian/bin/restart.sh +++ b/debian/bin/restart.sh @@ -24,13 +24,13 @@ cmd="$( get_service_command )" echo "Detected service control platform '$cmd'; using it to restart Jellyfin..." case $cmd in 'systemctl') - echo "sleep 2; /usr/bin/sudo $( which systemctl ) restart jellyfin" | at now + echo "sleep 0.5; /usr/bin/sudo $( which systemctl ) start jellyfin" | at now ;; 'service') - echo "sleep 2; /usr/bin/sudo $( which service ) jellyfin restart" | at now + echo "sleep 0.5; /usr/bin/sudo $( which service ) jellyfin start" | at now ;; 'sysv') - echo "sleep 2; /usr/bin/sudo /etc/init.d/jellyfin restart" | at now + echo "sleep 0.5; /usr/bin/sudo /etc/init.d/jellyfin start" | at now ;; esac exit 0 diff --git a/debian/changelog b/debian/changelog index 184143fe9a..430594cac2 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,3 +1,9 @@ +jellyfin-server (10.8.0-1) unstable; urgency=medium + + * Forthcoming stable release + + -- Jellyfin Packaging Team <packaging@jellyfin.org> Fri, 04 Dec 2020 21:55:12 -0500 + jellyfin-server (10.7.0-1) unstable; urgency=medium * Forthcoming stable release diff --git a/debian/conf/jellyfin b/debian/conf/jellyfin index 7cbfa88ee8..9ebaf2bd88 100644 --- a/debian/conf/jellyfin +++ b/debian/conf/jellyfin @@ -33,6 +33,11 @@ JELLYFIN_FFMPEG_OPT="--ffmpeg=/usr/lib/jellyfin-ffmpeg/ffmpeg" # [OPTIONAL] run Jellyfin without the web app #JELLYFIN_NOWEBAPP_OPT="--nowebclient" +# [OPTIONAL] run Jellyfin with ASP.NET Server Garbage Collection (uses more RAM and less CPU than Workstation GC) +# 0 = Workstation +# 1 = Server +#COMPlus_gcServer=1 + # # SysV init/Upstart options # diff --git a/debian/control b/debian/control index 39c2aa055b..9675d36ca6 100644 --- a/debian/control +++ b/debian/control @@ -3,7 +3,7 @@ Section: misc Priority: optional Maintainer: Jellyfin Team <team@jellyfin.org> Build-Depends: debhelper (>= 9), - dotnet-sdk-3.1, + dotnet-sdk-5.0, libc6-dev, libcurl4-openssl-dev, libfontconfig1-dev, @@ -20,7 +20,6 @@ Breaks: jellyfin (<<10.6.0) Architecture: any Depends: at, libsqlite3-0, - jellyfin-ffmpeg (>= 4.2.1-2), libfontconfig1, libfreetype6, libssl1.1 diff --git a/debian/jellyfin.service b/debian/jellyfin.service index f1a8f4652c..c9d1a4d130 100644 --- a/debian/jellyfin.service +++ b/debian/jellyfin.service @@ -1,6 +1,6 @@ [Unit] Description = Jellyfin Media Server -After = network.target +After = network-online.target [Service] Type = simple diff --git a/debian/metapackage/jellyfin b/debian/metapackage/jellyfin index 026fcb8217..a9a0ae5b01 100644 --- a/debian/metapackage/jellyfin +++ b/debian/metapackage/jellyfin @@ -5,7 +5,7 @@ Homepage: https://jellyfin.org Standards-Version: 3.9.2 Package: jellyfin -Version: 10.7.0 +Version: 10.8.0 Maintainer: Jellyfin Packaging Team <packaging@jellyfin.org> Depends: jellyfin-server, jellyfin-web Description: Provides the Jellyfin Free Software Media System diff --git a/debian/postinst b/debian/postinst index 860222e051..2f9c4cffbf 100644 --- a/debian/postinst +++ b/debian/postinst @@ -9,6 +9,8 @@ if [[ -f $DEFAULT_FILE ]]; then . $DEFAULT_FILE fi +JELLYFIN_USER=${JELLYFIN_USER:-jellyfin} + # Data directories for program data (cache, db), configs, and logs PROGRAMDATA=${JELLYFIN_DATA_DIRECTORY-/var/lib/$NAME} CONFIGDATA=${JELLYFIN_CONFIG_DIRECTORY-/etc/$NAME} @@ -18,12 +20,12 @@ CACHEDATA=${JELLYFIN_CACHE_DIRECTORY-/var/cache/$NAME} case "$1" in configure) # create jellyfin group if it does not exist - if [[ -z "$(getent group jellyfin)" ]]; then - addgroup --quiet --system jellyfin > /dev/null 2>&1 + if [[ -z "$(getent group ${JELLYFIN_USER})" ]]; then + addgroup --quiet --system ${JELLYFIN_USER} > /dev/null 2>&1 fi # create jellyfin user if it does not exist - if [[ -z "$(getent passwd jellyfin)" ]]; then - adduser --system --ingroup jellyfin --shell /bin/false jellyfin --no-create-home --home ${PROGRAMDATA} \ + if [[ -z "$(getent passwd ${JELLYFIN_USER})" ]]; then + adduser --system --ingroup ${JELLYFIN_USER} --shell /bin/false ${JELLYFIN_USER} --no-create-home --home ${PROGRAMDATA} \ --gecos "Jellyfin default user" > /dev/null 2>&1 fi # ensure $PROGRAMDATA exists @@ -43,7 +45,7 @@ case "$1" in mkdir $CACHEDATA fi # Ensure permissions are correct on all config directories - chown -R jellyfin $PROGRAMDATA $CONFIGDATA $LOGDATA $CACHEDATA + chown -R ${JELLYFIN_USER} $PROGRAMDATA $CONFIGDATA $LOGDATA $CACHEDATA chgrp adm $PROGRAMDATA $CONFIGDATA $LOGDATA $CACHEDATA chmod 0750 $PROGRAMDATA $CONFIGDATA $LOGDATA $CACHEDATA diff --git a/debian/rules b/debian/rules index 96541f41b8..64e2b48ea8 100755 --- a/debian/rules +++ b/debian/rules @@ -39,7 +39,7 @@ override_dh_auto_test: override_dh_clistrip: override_dh_auto_build: - dotnet publish --configuration $(CONFIG) --output='$(CURDIR)/usr/lib/jellyfin/bin' --self-contained --runtime $(DOTNETRUNTIME) \ + dotnet publish -maxcpucount:1 --configuration $(CONFIG) --output='$(CURDIR)/usr/lib/jellyfin/bin' --self-contained --runtime $(DOTNETRUNTIME) \ "-p:DebugSymbols=false;DebugType=none" Jellyfin.Server override_dh_auto_clean: diff --git a/deployment/Dockerfile.centos.amd64 b/deployment/Dockerfile.centos.amd64 index 39788cc0ee..326e995be6 100644 --- a/deployment/Dockerfile.centos.amd64 +++ b/deployment/Dockerfile.centos.amd64 @@ -2,28 +2,28 @@ FROM centos:7 # Docker build arguments ARG SOURCE_DIR=/jellyfin ARG ARTIFACT_DIR=/dist -ARG SDK_VERSION=3.1 +ARG SDK_VERSION=5.0 # Docker run environment ENV SOURCE_DIR=/jellyfin ENV ARTIFACT_DIR=/dist ENV IS_DOCKER=YES # Prepare CentOS environment -RUN yum update -y \ - && yum install -y epel-release \ - && yum install -y @buildsys-build rpmdevtools yum-plugins-core libcurl-devel fontconfig-devel freetype-devel openssl-devel glibc-devel libicu-devel git +RUN yum update -yq \ + && yum install -yq epel-release \ + && yum install -yq @buildsys-build rpmdevtools yum-plugins-core libcurl-devel fontconfig-devel freetype-devel openssl-devel glibc-devel libicu-devel git # Install DotNET SDK RUN rpm -Uvh https://packages.microsoft.com/config/rhel/7/packages-microsoft-prod.rpm \ - && rpmdev-setuptree \ - && yum install -y dotnet-sdk-${SDK_VERSION} + && rpmdev-setuptree \ + && yum install -yq dotnet-sdk-${SDK_VERSION} # Create symlinks and directories RUN ln -sf ${SOURCE_DIR}/deployment/build.centos.amd64 /build.sh \ - && mkdir -p ${SOURCE_DIR}/SPECS \ - && ln -s ${SOURCE_DIR}/fedora/jellyfin.spec ${SOURCE_DIR}/SPECS/jellyfin.spec \ - && mkdir -p ${SOURCE_DIR}/SOURCES \ - && ln -s ${SOURCE_DIR}/fedora ${SOURCE_DIR}/SOURCES + && mkdir -p ${SOURCE_DIR}/SPECS \ + && ln -s ${SOURCE_DIR}/fedora/jellyfin.spec ${SOURCE_DIR}/SPECS/jellyfin.spec \ + && mkdir -p ${SOURCE_DIR}/SOURCES \ + && ln -s ${SOURCE_DIR}/fedora ${SOURCE_DIR}/SOURCES VOLUME ${SOURCE_DIR}/ diff --git a/deployment/Dockerfile.debian.amd64 b/deployment/Dockerfile.debian.amd64 index aaca8fe01e..23b662526e 100644 --- a/deployment/Dockerfile.debian.amd64 +++ b/deployment/Dockerfile.debian.amd64 @@ -1,8 +1,7 @@ -FROM debian:10 +FROM mcr.microsoft.com/dotnet/sdk:5.0-buster-slim # Docker build arguments ARG SOURCE_DIR=/jellyfin ARG ARTIFACT_DIR=/dist -ARG SDK_VERSION=3.1 # Docker run environment ENV SOURCE_DIR=/jellyfin ENV ARTIFACT_DIR=/dist @@ -11,15 +10,11 @@ ENV ARCH=amd64 ENV IS_DOCKER=YES # Prepare Debian build environment -RUN apt-get update \ - && apt-get install -y apt-transport-https debhelper gnupg wget devscripts mmv libc6-dev libcurl4-openssl-dev libfontconfig1-dev libfreetype6-dev libssl-dev libssl1.1 liblttng-ust0 - -# Install dotnet repository -# https://dotnet.microsoft.com/download/linux-package-manager/debian9/sdk-current -RUN wget https://download.visualstudio.microsoft.com/download/pr/fdd9ecec-56b4-40f4-b762-d7efe24fc3cd/ffef51844c92afa6714528e10609a30f/dotnet-sdk-3.1.403-linux-x64.tar.gz -O dotnet-sdk.tar.gz \ - && mkdir -p dotnet-sdk \ - && tar -xzf dotnet-sdk.tar.gz -C dotnet-sdk \ - && ln -s $( pwd )/dotnet-sdk/dotnet /usr/bin/dotnet +RUN apt-get update -yqq \ + && apt-get install -yqq --no-install-recommends \ + apt-transport-https debhelper gnupg devscripts build-essential mmv \ + libcurl4-openssl-dev libfontconfig1-dev libfreetype6-dev libssl-dev \ + libssl1.1 liblttng-ust0 # Link to build script RUN ln -sf ${SOURCE_DIR}/deployment/build.debian.amd64 /build.sh diff --git a/deployment/Dockerfile.debian.arm64 b/deployment/Dockerfile.debian.arm64 index 594da04ceb..a33099031e 100644 --- a/deployment/Dockerfile.debian.arm64 +++ b/deployment/Dockerfile.debian.arm64 @@ -1,8 +1,7 @@ -FROM debian:10 +FROM mcr.microsoft.com/dotnet/sdk:5.0-buster-slim # Docker build arguments ARG SOURCE_DIR=/jellyfin ARG ARTIFACT_DIR=/dist -ARG SDK_VERSION=3.1 # Docker run environment ENV SOURCE_DIR=/jellyfin ENV ARTIFACT_DIR=/dist @@ -11,23 +10,24 @@ ENV ARCH=amd64 ENV IS_DOCKER=YES # Prepare Debian build environment -RUN apt-get update \ - && apt-get install -y apt-transport-https debhelper gnupg wget devscripts mmv - -# Install dotnet repository -# https://dotnet.microsoft.com/download/linux-package-manager/debian9/sdk-current -RUN wget https://download.visualstudio.microsoft.com/download/pr/fdd9ecec-56b4-40f4-b762-d7efe24fc3cd/ffef51844c92afa6714528e10609a30f/dotnet-sdk-3.1.403-linux-x64.tar.gz -O dotnet-sdk.tar.gz \ - && mkdir -p dotnet-sdk \ - && tar -xzf dotnet-sdk.tar.gz -C dotnet-sdk \ - && ln -s $( pwd )/dotnet-sdk/dotnet /usr/bin/dotnet +RUN apt-get update -yqq \ + && apt-get install -yqq --no-install-recommends \ + apt-transport-https debhelper gnupg devscripts build-essential mmv # Prepare the cross-toolchain RUN dpkg --add-architecture arm64 \ - && apt-get update \ - && apt-get install -y cross-gcc-dev \ - && TARGET_LIST="arm64" cross-gcc-gensource 8 \ - && cd cross-gcc-packages-amd64/cross-gcc-8-arm64 \ - && apt-get install -y gcc-8-source libstdc++-8-dev-arm64-cross binutils-aarch64-linux-gnu bison flex libtool gdb sharutils netbase libmpc-dev libmpfr-dev libgmp-dev systemtap-sdt-dev autogen expect chrpath zlib1g-dev zip libc6-dev:arm64 linux-libc-dev:arm64 libgcc1:arm64 libcurl4-openssl-dev:arm64 libfontconfig1-dev:arm64 libfreetype6-dev:arm64 libssl-dev:arm64 liblttng-ust0:arm64 libstdc++-8-dev:arm64 + && apt-get update -yqq \ + && apt-get install -yqq --no-install-recommends cross-gcc-dev \ + && TARGET_LIST="arm64" cross-gcc-gensource 8 \ + && cd cross-gcc-packages-amd64/cross-gcc-8-arm64 \ + && apt-get install -yqq --no-install-recommends \ + gcc-8-source libstdc++-8-dev-arm64-cross \ + binutils-aarch64-linux-gnu bison flex libtool \ + gdb sharutils netbase libmpc-dev libmpfr-dev libgmp-dev \ + systemtap-sdt-dev autogen expect chrpath zlib1g-dev zip \ + libc6-dev:arm64 linux-libc-dev:arm64 libgcc1:arm64 \ + libcurl4-openssl-dev:arm64 libfontconfig1-dev:arm64 \ + libfreetype6-dev:arm64 libssl-dev:arm64 liblttng-ust0:arm64 libstdc++-8-dev:arm64 # Link to build script RUN ln -sf ${SOURCE_DIR}/deployment/build.debian.arm64 /build.sh diff --git a/deployment/Dockerfile.debian.armhf b/deployment/Dockerfile.debian.armhf index 3e6e2d0d70..bc5e3543fa 100644 --- a/deployment/Dockerfile.debian.armhf +++ b/deployment/Dockerfile.debian.armhf @@ -1,8 +1,7 @@ -FROM debian:10 +FROM mcr.microsoft.com/dotnet/sdk:5.0-buster-slim # Docker build arguments ARG SOURCE_DIR=/jellyfin ARG ARTIFACT_DIR=/dist -ARG SDK_VERSION=3.1 # Docker run environment ENV SOURCE_DIR=/jellyfin ENV ARTIFACT_DIR=/dist @@ -11,23 +10,25 @@ ENV ARCH=amd64 ENV IS_DOCKER=YES # Prepare Debian build environment -RUN apt-get update \ - && apt-get install -y apt-transport-https debhelper gnupg wget devscripts mmv - -# Install dotnet repository -# https://dotnet.microsoft.com/download/linux-package-manager/debian9/sdk-current -RUN wget https://download.visualstudio.microsoft.com/download/pr/fdd9ecec-56b4-40f4-b762-d7efe24fc3cd/ffef51844c92afa6714528e10609a30f/dotnet-sdk-3.1.403-linux-x64.tar.gz -O dotnet-sdk.tar.gz \ - && mkdir -p dotnet-sdk \ - && tar -xzf dotnet-sdk.tar.gz -C dotnet-sdk \ - && ln -s $( pwd )/dotnet-sdk/dotnet /usr/bin/dotnet +RUN apt-get update -yqq \ + && apt-get install -yqq --no-install-recommends \ + apt-transport-https debhelper gnupg devscripts build-essential mmv # Prepare the cross-toolchain RUN dpkg --add-architecture armhf \ - && apt-get update \ - && apt-get install -y cross-gcc-dev \ - && TARGET_LIST="armhf" cross-gcc-gensource 8 \ - && cd cross-gcc-packages-amd64/cross-gcc-8-armhf \ - && apt-get install -y gcc-8-source libstdc++-8-dev-armhf-cross binutils-aarch64-linux-gnu bison flex libtool gdb sharutils netbase libmpc-dev libmpfr-dev libgmp-dev systemtap-sdt-dev autogen expect chrpath zlib1g-dev zip binutils-arm-linux-gnueabihf libc6-dev:armhf linux-libc-dev:armhf libgcc1:armhf libcurl4-openssl-dev:armhf libfontconfig1-dev:armhf libfreetype6-dev:armhf libssl-dev:armhf liblttng-ust0:armhf libstdc++-8-dev:armhf + && apt-get update -yqq \ + && apt-get install -yqq --no-install-recommends cross-gcc-dev \ + && TARGET_LIST="armhf" cross-gcc-gensource 8 \ + && cd cross-gcc-packages-amd64/cross-gcc-8-armhf \ + && apt-get install -yqq --no-install-recommends\ + gcc-8-source libstdc++-8-dev-armhf-cross \ + binutils-aarch64-linux-gnu bison flex libtool gdb \ + sharutils netbase libmpc-dev libmpfr-dev libgmp-dev \ + systemtap-sdt-dev autogen expect chrpath zlib1g-dev \ + zip binutils-arm-linux-gnueabihf libc6-dev:armhf \ + linux-libc-dev:armhf libgcc1:armhf libcurl4-openssl-dev:armhf \ + libfontconfig1-dev:armhf libfreetype6-dev:armhf libssl-dev:armhf \ + liblttng-ust0:armhf libstdc++-8-dev:armhf # Link to build script RUN ln -sf ${SOURCE_DIR}/deployment/build.debian.armhf /build.sh diff --git a/deployment/Dockerfile.docker.amd64 b/deployment/Dockerfile.docker.amd64 index e04442606e..0b1a57014f 100644 --- a/deployment/Dockerfile.docker.amd64 +++ b/deployment/Dockerfile.docker.amd64 @@ -1,6 +1,6 @@ -ARG DOTNET_VERSION=3.1 +ARG DOTNET_VERSION=5.0 -FROM mcr.microsoft.com/dotnet/core/sdk:${DOTNET_VERSION}-buster +FROM mcr.microsoft.com/dotnet/sdk:${DOTNET_VERSION}-buster-slim ARG SOURCE_DIR=/src ARG ARTIFACT_DIR=/jellyfin diff --git a/deployment/Dockerfile.docker.arm64 b/deployment/Dockerfile.docker.arm64 index a7ac40492a..583f53ca09 100644 --- a/deployment/Dockerfile.docker.arm64 +++ b/deployment/Dockerfile.docker.arm64 @@ -1,6 +1,6 @@ -ARG DOTNET_VERSION=3.1 +ARG DOTNET_VERSION=5.0 -FROM mcr.microsoft.com/dotnet/core/sdk:${DOTNET_VERSION}-buster +FROM mcr.microsoft.com/dotnet/sdk:${DOTNET_VERSION}-buster-slim ARG SOURCE_DIR=/src ARG ARTIFACT_DIR=/jellyfin diff --git a/deployment/Dockerfile.docker.armhf b/deployment/Dockerfile.docker.armhf index b5a42f55ff..177c117134 100644 --- a/deployment/Dockerfile.docker.armhf +++ b/deployment/Dockerfile.docker.armhf @@ -1,6 +1,6 @@ -ARG DOTNET_VERSION=3.1 +ARG DOTNET_VERSION=5.0 -FROM mcr.microsoft.com/dotnet/core/sdk:${DOTNET_VERSION}-buster +FROM mcr.microsoft.com/dotnet/sdk:${DOTNET_VERSION}-buster-slim ARG SOURCE_DIR=/src ARG ARTIFACT_DIR=/jellyfin diff --git a/deployment/Dockerfile.fedora.amd64 b/deployment/Dockerfile.fedora.amd64 index 01b99deb6d..590cde1677 100644 --- a/deployment/Dockerfile.fedora.amd64 +++ b/deployment/Dockerfile.fedora.amd64 @@ -1,28 +1,26 @@ -FROM fedora:31 +FROM fedora:33 # Docker build arguments ARG SOURCE_DIR=/jellyfin ARG ARTIFACT_DIR=/dist -ARG SDK_VERSION=3.1 +ARG SDK_VERSION=5.0 # Docker run environment ENV SOURCE_DIR=/jellyfin ENV ARTIFACT_DIR=/dist ENV IS_DOCKER=YES # Prepare Fedora environment -RUN dnf update -y \ - && dnf install -y @buildsys-build rpmdevtools git dnf-plugins-core libcurl-devel fontconfig-devel freetype-devel openssl-devel glibc-devel libicu-devel +RUN dnf update -yq \ + && dnf install -yq @buildsys-build rpmdevtools git dnf-plugins-core libcurl-devel fontconfig-devel freetype-devel openssl-devel glibc-devel libicu-devel systemd # Install DotNET SDK -RUN rpm --import https://packages.microsoft.com/keys/microsoft.asc \ - && curl -o /etc/yum.repos.d/microsoft-prod.repo https://packages.microsoft.com/config/fedora/$(rpm -E %fedora)/prod.repo \ - && dnf install -y dotnet-sdk-${SDK_VERSION} dotnet-runtime-${SDK_VERSION} +RUN dnf install -yq dotnet-sdk-${SDK_VERSION} dotnet-runtime-${SDK_VERSION} # Create symlinks and directories RUN ln -sf ${SOURCE_DIR}/deployment/build.fedora.amd64 /build.sh \ - && mkdir -p ${SOURCE_DIR}/SPECS \ - && ln -s ${SOURCE_DIR}/fedora/jellyfin.spec ${SOURCE_DIR}/SPECS/jellyfin.spec \ - && mkdir -p ${SOURCE_DIR}/SOURCES \ - && ln -s ${SOURCE_DIR}/fedora ${SOURCE_DIR}/SOURCES + && mkdir -p ${SOURCE_DIR}/SPECS \ + && ln -s ${SOURCE_DIR}/fedora/jellyfin.spec ${SOURCE_DIR}/SPECS/jellyfin.spec \ + && mkdir -p ${SOURCE_DIR}/SOURCES \ + && ln -s ${SOURCE_DIR}/fedora ${SOURCE_DIR}/SOURCES VOLUME ${SOURCE_DIR}/ diff --git a/deployment/Dockerfile.linux.amd64 b/deployment/Dockerfile.linux.amd64 index f98881ebfe..3c7e2b87f6 100644 --- a/deployment/Dockerfile.linux.amd64 +++ b/deployment/Dockerfile.linux.amd64 @@ -1,8 +1,7 @@ -FROM debian:10 +FROM mcr.microsoft.com/dotnet/sdk:5.0-buster-slim # Docker build arguments ARG SOURCE_DIR=/jellyfin ARG ARTIFACT_DIR=/dist -ARG SDK_VERSION=3.1 # Docker run environment ENV SOURCE_DIR=/jellyfin ENV ARTIFACT_DIR=/dist @@ -11,15 +10,11 @@ ENV ARCH=amd64 ENV IS_DOCKER=YES # Prepare Debian build environment -RUN apt-get update \ - && apt-get install -y apt-transport-https debhelper gnupg wget devscripts mmv libc6-dev libcurl4-openssl-dev libfontconfig1-dev libfreetype6-dev libssl-dev libssl1.1 liblttng-ust0 - -# Install dotnet repository -# https://dotnet.microsoft.com/download/linux-package-manager/debian9/sdk-current -RUN wget https://download.visualstudio.microsoft.com/download/pr/fdd9ecec-56b4-40f4-b762-d7efe24fc3cd/ffef51844c92afa6714528e10609a30f/dotnet-sdk-3.1.403-linux-x64.tar.gz -O dotnet-sdk.tar.gz \ - && mkdir -p dotnet-sdk \ - && tar -xzf dotnet-sdk.tar.gz -C dotnet-sdk \ - && ln -s $( pwd )/dotnet-sdk/dotnet /usr/bin/dotnet +RUN apt-get update -yqq \ + && apt-get install -yqq --no-install-recommends \ + apt-transport-https debhelper gnupg devscripts unzip \ + mmv libcurl4-openssl-dev libfontconfig1-dev \ + libfreetype6-dev libssl-dev libssl1.1 liblttng-ust0 # Link to docker-build script RUN ln -sf ${SOURCE_DIR}/deployment/build.linux.amd64 /build.sh diff --git a/deployment/Dockerfile.linux.amd64-musl b/deployment/Dockerfile.linux.amd64-musl new file mode 100644 index 0000000000..3cda9ad235 --- /dev/null +++ b/deployment/Dockerfile.linux.amd64-musl @@ -0,0 +1,26 @@ +FROM mcr.microsoft.com/dotnet/sdk:5.0-buster-slim +# Docker build arguments +ARG SOURCE_DIR=/jellyfin +ARG ARTIFACT_DIR=/dist +# Docker run environment +ENV SOURCE_DIR=/jellyfin +ENV ARTIFACT_DIR=/dist +ENV DEB_BUILD_OPTIONS=noddebs +ENV ARCH=amd64 +ENV IS_DOCKER=YES + +# Prepare Debian build environment +RUN apt-get update -yqq \ + && apt-get install -yqq --no-install-recommends \ + apt-transport-https debhelper gnupg devscripts unzip \ + mmv libcurl4-openssl-dev libfontconfig1-dev \ + libfreetype6-dev libssl-dev libssl1.1 liblttng-ust0 + +# Link to docker-build script +RUN ln -sf ${SOURCE_DIR}/deployment/build.linux.amd64-musl /build.sh + +VOLUME ${SOURCE_DIR}/ + +VOLUME ${ARTIFACT_DIR}/ + +ENTRYPOINT ["/build.sh"] diff --git a/deployment/Dockerfile.linux.arm64 b/deployment/Dockerfile.linux.arm64 new file mode 100644 index 0000000000..ddf97cbd1c --- /dev/null +++ b/deployment/Dockerfile.linux.arm64 @@ -0,0 +1,26 @@ +FROM mcr.microsoft.com/dotnet/sdk:5.0-buster-slim +# Docker build arguments +ARG SOURCE_DIR=/jellyfin +ARG ARTIFACT_DIR=/dist +# Docker run environment +ENV SOURCE_DIR=/jellyfin +ENV ARTIFACT_DIR=/dist +ENV DEB_BUILD_OPTIONS=noddebs +ENV ARCH=arm64 +ENV IS_DOCKER=YES + +# Prepare Debian build environment +RUN apt-get update -yqq \ + && apt-get install -yqq --no-install-recommends \ + apt-transport-https debhelper gnupg devscripts unzip \ + mmv libcurl4-openssl-dev libfontconfig1-dev \ + libfreetype6-dev libssl-dev libssl1.1 liblttng-ust0 + +# Link to docker-build script +RUN ln -sf ${SOURCE_DIR}/deployment/build.linux.arm64 /build.sh + +VOLUME ${SOURCE_DIR}/ + +VOLUME ${ARTIFACT_DIR}/ + +ENTRYPOINT ["/build.sh"] diff --git a/deployment/Dockerfile.linux.armhf b/deployment/Dockerfile.linux.armhf new file mode 100644 index 0000000000..49e1c7bbf5 --- /dev/null +++ b/deployment/Dockerfile.linux.armhf @@ -0,0 +1,26 @@ +FROM mcr.microsoft.com/dotnet/sdk:5.0-buster-slim +# Docker build arguments +ARG SOURCE_DIR=/jellyfin +ARG ARTIFACT_DIR=/dist +# Docker run environment +ENV SOURCE_DIR=/jellyfin +ENV ARTIFACT_DIR=/dist +ENV DEB_BUILD_OPTIONS=noddebs +ENV ARCH=armhf +ENV IS_DOCKER=YES + +# Prepare Debian build environment +RUN apt-get update -yqq \ + && apt-get install -yqq --no-install-recommends \ + apt-transport-https debhelper gnupg devscripts unzip \ + mmv libcurl4-openssl-dev libfontconfig1-dev \ + libfreetype6-dev libssl-dev libssl1.1 liblttng-ust0 + +# Link to docker-build script +RUN ln -sf ${SOURCE_DIR}/deployment/build.linux.armhf /build.sh + +VOLUME ${SOURCE_DIR}/ + +VOLUME ${ARTIFACT_DIR}/ + +ENTRYPOINT ["/build.sh"] diff --git a/deployment/Dockerfile.macos b/deployment/Dockerfile.macos index ec9d2d8c77..fad44ef833 100644 --- a/deployment/Dockerfile.macos +++ b/deployment/Dockerfile.macos @@ -1,8 +1,7 @@ -FROM debian:10 +FROM mcr.microsoft.com/dotnet/sdk:5.0-buster-slim # Docker build arguments ARG SOURCE_DIR=/jellyfin ARG ARTIFACT_DIR=/dist -ARG SDK_VERSION=3.1 # Docker run environment ENV SOURCE_DIR=/jellyfin ENV ARTIFACT_DIR=/dist @@ -11,15 +10,11 @@ ENV ARCH=amd64 ENV IS_DOCKER=YES # Prepare Debian build environment -RUN apt-get update \ - && apt-get install -y apt-transport-https debhelper gnupg wget devscripts mmv libc6-dev libcurl4-openssl-dev libfontconfig1-dev libfreetype6-dev libssl-dev libssl1.1 liblttng-ust0 - -# Install dotnet repository -# https://dotnet.microsoft.com/download/linux-package-manager/debian9/sdk-current -RUN wget https://download.visualstudio.microsoft.com/download/pr/fdd9ecec-56b4-40f4-b762-d7efe24fc3cd/ffef51844c92afa6714528e10609a30f/dotnet-sdk-3.1.403-linux-x64.tar.gz -O dotnet-sdk.tar.gz \ - && mkdir -p dotnet-sdk \ - && tar -xzf dotnet-sdk.tar.gz -C dotnet-sdk \ - && ln -s $( pwd )/dotnet-sdk/dotnet /usr/bin/dotnet +RUN apt-get update -yqq \ + && apt-get install -yqq --no-install-recommends \ + apt-transport-https debhelper gnupg devscripts \ + mmv libcurl4-openssl-dev libfontconfig1-dev \ + libfreetype6-dev libssl-dev libssl1.1 liblttng-ust0 # Link to docker-build script RUN ln -sf ${SOURCE_DIR}/deployment/build.macos /build.sh diff --git a/deployment/Dockerfile.portable b/deployment/Dockerfile.portable index 3523f8aceb..90cc0717b7 100644 --- a/deployment/Dockerfile.portable +++ b/deployment/Dockerfile.portable @@ -1,8 +1,7 @@ -FROM debian:10 +FROM mcr.microsoft.com/dotnet/sdk:5.0-buster-slim # Docker build arguments ARG SOURCE_DIR=/jellyfin ARG ARTIFACT_DIR=/dist -ARG SDK_VERSION=3.1 # Docker run environment ENV SOURCE_DIR=/jellyfin ENV ARTIFACT_DIR=/dist @@ -10,15 +9,11 @@ ENV DEB_BUILD_OPTIONS=noddebs ENV IS_DOCKER=YES # Prepare Debian build environment -RUN apt-get update \ - && apt-get install -y apt-transport-https debhelper gnupg wget devscripts mmv libc6-dev libcurl4-openssl-dev libfontconfig1-dev libfreetype6-dev libssl-dev libssl1.1 liblttng-ust0 - -# Install dotnet repository -# https://dotnet.microsoft.com/download/linux-package-manager/debian9/sdk-current -RUN wget https://download.visualstudio.microsoft.com/download/pr/fdd9ecec-56b4-40f4-b762-d7efe24fc3cd/ffef51844c92afa6714528e10609a30f/dotnet-sdk-3.1.403-linux-x64.tar.gz -O dotnet-sdk.tar.gz \ - && mkdir -p dotnet-sdk \ - && tar -xzf dotnet-sdk.tar.gz -C dotnet-sdk \ - && ln -s $( pwd )/dotnet-sdk/dotnet /usr/bin/dotnet +RUN apt-get update -yqq \ + && apt-get install -yqq --no-install-recommends \ + apt-transport-https debhelper gnupg devscripts \ + mmv libcurl4-openssl-dev libfontconfig1-dev \ + libfreetype6-dev libssl-dev libssl1.1 liblttng-ust0 # Link to docker-build script RUN ln -sf ${SOURCE_DIR}/deployment/build.portable /build.sh diff --git a/deployment/Dockerfile.ubuntu.amd64 b/deployment/Dockerfile.ubuntu.amd64 index 0a365e1aee..d88efcdc95 100644 --- a/deployment/Dockerfile.ubuntu.amd64 +++ b/deployment/Dockerfile.ubuntu.amd64 @@ -2,7 +2,7 @@ FROM ubuntu:bionic # Docker build arguments ARG SOURCE_DIR=/jellyfin ARG ARTIFACT_DIR=/dist -ARG SDK_VERSION=3.1 +ARG SDK_VERSION=5.0 # Docker run environment ENV SOURCE_DIR=/jellyfin ENV ARTIFACT_DIR=/dist @@ -11,15 +11,18 @@ ENV ARCH=amd64 ENV IS_DOCKER=YES # Prepare Debian build environment -RUN apt-get update \ - && apt-get install -y apt-transport-https debhelper gnupg wget devscripts mmv libc6-dev libcurl4-openssl-dev libfontconfig1-dev libfreetype6-dev libssl-dev libssl1.1 liblttng-ust0 +RUN apt-get update -yqq \ + && apt-get install -yqq --no-install-recommends \ + apt-transport-https debhelper gnupg wget ca-certificates devscripts \ + mmv build-essential libcurl4-openssl-dev libfontconfig1-dev \ + libfreetype6-dev libssl-dev libssl1.1 liblttng-ust0 # Install dotnet repository # https://dotnet.microsoft.com/download/linux-package-manager/debian9/sdk-current -RUN wget https://download.visualstudio.microsoft.com/download/pr/fdd9ecec-56b4-40f4-b762-d7efe24fc3cd/ffef51844c92afa6714528e10609a30f/dotnet-sdk-3.1.403-linux-x64.tar.gz -O dotnet-sdk.tar.gz \ - && mkdir -p dotnet-sdk \ - && tar -xzf dotnet-sdk.tar.gz -C dotnet-sdk \ - && ln -s $( pwd )/dotnet-sdk/dotnet /usr/bin/dotnet +RUN wget -q https://download.visualstudio.microsoft.com/download/pr/13b9d84c-a35b-4ffe-8f62-447a01403d64/1f9ae31daa0f7d98513e7551246899f2/dotnet-sdk-5.0.400-linux-x64.tar.gz -O dotnet-sdk.tar.gz \ + && mkdir -p dotnet-sdk \ + && tar -xzf dotnet-sdk.tar.gz -C dotnet-sdk \ + && ln -s $( pwd )/dotnet-sdk/dotnet /usr/bin/dotnet # Link to build script RUN ln -sf ${SOURCE_DIR}/deployment/build.ubuntu.amd64 /build.sh diff --git a/deployment/Dockerfile.ubuntu.arm64 b/deployment/Dockerfile.ubuntu.arm64 index ab3ec9b9f8..4f41bba2d9 100644 --- a/deployment/Dockerfile.ubuntu.arm64 +++ b/deployment/Dockerfile.ubuntu.arm64 @@ -2,7 +2,7 @@ FROM ubuntu:bionic # Docker build arguments ARG SOURCE_DIR=/jellyfin ARG ARTIFACT_DIR=/dist -ARG SDK_VERSION=3.1 +ARG SDK_VERSION=5.0 # Docker run environment ENV SOURCE_DIR=/jellyfin ENV ARTIFACT_DIR=/dist @@ -11,34 +11,41 @@ ENV ARCH=amd64 ENV IS_DOCKER=YES # Prepare Debian build environment -RUN apt-get update \ - && apt-get install -y apt-transport-https debhelper gnupg wget devscripts mmv +RUN apt-get update -yqq \ + && apt-get install -yqq --no-install-recommends \ + apt-transport-https debhelper gnupg wget ca-certificates devscripts \ + mmv build-essential lsb-release # Install dotnet repository # https://dotnet.microsoft.com/download/linux-package-manager/debian9/sdk-current -RUN wget https://download.visualstudio.microsoft.com/download/pr/fdd9ecec-56b4-40f4-b762-d7efe24fc3cd/ffef51844c92afa6714528e10609a30f/dotnet-sdk-3.1.403-linux-x64.tar.gz -O dotnet-sdk.tar.gz \ - && mkdir -p dotnet-sdk \ - && tar -xzf dotnet-sdk.tar.gz -C dotnet-sdk \ - && ln -s $( pwd )/dotnet-sdk/dotnet /usr/bin/dotnet +RUN wget -q https://download.visualstudio.microsoft.com/download/pr/13b9d84c-a35b-4ffe-8f62-447a01403d64/1f9ae31daa0f7d98513e7551246899f2/dotnet-sdk-5.0.400-linux-x64.tar.gz -O dotnet-sdk.tar.gz \ + && mkdir -p dotnet-sdk \ + && tar -xzf dotnet-sdk.tar.gz -C dotnet-sdk \ + && ln -s $( pwd )/dotnet-sdk/dotnet /usr/bin/dotnet # Prepare the cross-toolchain RUN rm /etc/apt/sources.list \ - && export CODENAME="$( lsb_release -c -s )" \ - && echo "deb [arch=amd64] http://archive.ubuntu.com/ubuntu/ ${CODENAME} main restricted universe multiverse" >>/etc/apt/sources.list.d/amd64.list \ - && echo "deb [arch=amd64] http://archive.ubuntu.com/ubuntu/ ${CODENAME}-updates main restricted universe multiverse" >>/etc/apt/sources.list.d/amd64.list \ - && echo "deb [arch=amd64] http://archive.ubuntu.com/ubuntu/ ${CODENAME}-backports main restricted universe multiverse" >>/etc/apt/sources.list.d/amd64.list \ - && echo "deb [arch=amd64] http://archive.ubuntu.com/ubuntu/ ${CODENAME}-security main restricted universe multiverse" >>/etc/apt/sources.list.d/amd64.list \ - && echo "deb [arch=arm64] http://ports.ubuntu.com/ ${CODENAME} main restricted universe multiverse" >>/etc/apt/sources.list.d/arm64.list \ - && echo "deb [arch=arm64] http://ports.ubuntu.com/ ${CODENAME}-updates main restricted universe multiverse" >>/etc/apt/sources.list.d/arm64.list \ - && echo "deb [arch=arm64] http://ports.ubuntu.com/ ${CODENAME}-backports main restricted universe multiverse" >>/etc/apt/sources.list.d/arm64.list \ - && echo "deb [arch=arm64] http://ports.ubuntu.com/ ${CODENAME}-security main restricted universe multiverse" >>/etc/apt/sources.list.d/arm64.list \ - && dpkg --add-architecture arm64 \ - && apt-get update \ - && apt-get install -y cross-gcc-dev \ - && TARGET_LIST="arm64" cross-gcc-gensource 6 \ - && cd cross-gcc-packages-amd64/cross-gcc-6-arm64 \ - && ln -fs /usr/share/zoneinfo/America/Toronto /etc/localtime \ - && apt-get install -y gcc-6-source libstdc++6-arm64-cross binutils-aarch64-linux-gnu bison flex libtool gdb sharutils netbase libcloog-isl-dev libmpc-dev libmpfr-dev libgmp-dev systemtap-sdt-dev autogen expect chrpath zlib1g-dev zip libc6-dev:arm64 linux-libc-dev:arm64 libgcc1:arm64 libcurl4-openssl-dev:arm64 libfontconfig1-dev:arm64 libfreetype6-dev:arm64 liblttng-ust0:arm64 libstdc++6:arm64 libssl-dev:arm64 + && export CODENAME="$( lsb_release -c -s )" \ + && echo "deb [arch=amd64] http://archive.ubuntu.com/ubuntu/ ${CODENAME} main restricted universe multiverse" >>/etc/apt/sources.list.d/amd64.list \ + && echo "deb [arch=amd64] http://archive.ubuntu.com/ubuntu/ ${CODENAME}-updates main restricted universe multiverse" >>/etc/apt/sources.list.d/amd64.list \ + && echo "deb [arch=amd64] http://archive.ubuntu.com/ubuntu/ ${CODENAME}-backports main restricted universe multiverse" >>/etc/apt/sources.list.d/amd64.list \ + && echo "deb [arch=amd64] http://archive.ubuntu.com/ubuntu/ ${CODENAME}-security main restricted universe multiverse" >>/etc/apt/sources.list.d/amd64.list \ + && echo "deb [arch=arm64] http://ports.ubuntu.com/ ${CODENAME} main restricted universe multiverse" >>/etc/apt/sources.list.d/arm64.list \ + && echo "deb [arch=arm64] http://ports.ubuntu.com/ ${CODENAME}-updates main restricted universe multiverse" >>/etc/apt/sources.list.d/arm64.list \ + && echo "deb [arch=arm64] http://ports.ubuntu.com/ ${CODENAME}-backports main restricted universe multiverse" >>/etc/apt/sources.list.d/arm64.list \ + && echo "deb [arch=arm64] http://ports.ubuntu.com/ ${CODENAME}-security main restricted universe multiverse" >>/etc/apt/sources.list.d/arm64.list \ + && dpkg --add-architecture arm64 \ + && apt-get update -yqq \ + && apt-get install -yqq --no-install-recommends cross-gcc-dev \ + && TARGET_LIST="arm64" cross-gcc-gensource 6 \ + && cd cross-gcc-packages-amd64/cross-gcc-6-arm64 \ + && ln -fs /usr/share/zoneinfo/America/Toronto /etc/localtime \ + && apt-get install -yqq --no-install-recommends \ + gcc-6-source libstdc++6-arm64-cross binutils-aarch64-linux-gnu \ + bison flex libtool gdb sharutils netbase libcloog-isl-dev libmpc-dev \ + libmpfr-dev libgmp-dev systemtap-sdt-dev autogen expect chrpath zlib1g-dev \ + zip libc6-dev:arm64 linux-libc-dev:arm64 libgcc1:arm64 libcurl4-openssl-dev:arm64 \ + libfontconfig1-dev:arm64 libfreetype6-dev:arm64 liblttng-ust0:arm64 libstdc++6:arm64 libssl-dev:arm64 # Link to build script RUN ln -sf ${SOURCE_DIR}/deployment/build.ubuntu.arm64 /build.sh diff --git a/deployment/Dockerfile.ubuntu.armhf b/deployment/Dockerfile.ubuntu.armhf index fa41bdf48a..01752d5367 100644 --- a/deployment/Dockerfile.ubuntu.armhf +++ b/deployment/Dockerfile.ubuntu.armhf @@ -2,7 +2,7 @@ FROM ubuntu:bionic # Docker build arguments ARG SOURCE_DIR=/jellyfin ARG ARTIFACT_DIR=/dist -ARG SDK_VERSION=3.1 +ARG SDK_VERSION=5.0 # Docker run environment ENV SOURCE_DIR=/jellyfin ENV ARTIFACT_DIR=/dist @@ -11,34 +11,41 @@ ENV ARCH=amd64 ENV IS_DOCKER=YES # Prepare Debian build environment -RUN apt-get update \ - && apt-get install -y apt-transport-https debhelper gnupg wget devscripts mmv +RUN apt-get update -yqq \ + && apt-get install -yqq --no-install-recommends \ + apt-transport-https debhelper gnupg wget ca-certificates devscripts \ + mmv build-essential lsb-release # Install dotnet repository # https://dotnet.microsoft.com/download/linux-package-manager/debian9/sdk-current -RUN wget https://download.visualstudio.microsoft.com/download/pr/fdd9ecec-56b4-40f4-b762-d7efe24fc3cd/ffef51844c92afa6714528e10609a30f/dotnet-sdk-3.1.403-linux-x64.tar.gz -O dotnet-sdk.tar.gz \ - && mkdir -p dotnet-sdk \ - && tar -xzf dotnet-sdk.tar.gz -C dotnet-sdk \ - && ln -s $( pwd )/dotnet-sdk/dotnet /usr/bin/dotnet +RUN wget -q https://download.visualstudio.microsoft.com/download/pr/13b9d84c-a35b-4ffe-8f62-447a01403d64/1f9ae31daa0f7d98513e7551246899f2/dotnet-sdk-5.0.400-linux-x64.tar.gz -O dotnet-sdk.tar.gz \ + && mkdir -p dotnet-sdk \ + && tar -xzf dotnet-sdk.tar.gz -C dotnet-sdk \ + && ln -s $( pwd )/dotnet-sdk/dotnet /usr/bin/dotnet # Prepare the cross-toolchain RUN rm /etc/apt/sources.list \ - && export CODENAME="$( lsb_release -c -s )" \ - && echo "deb [arch=amd64] http://archive.ubuntu.com/ubuntu/ ${CODENAME} main restricted universe multiverse" >>/etc/apt/sources.list.d/amd64.list \ - && echo "deb [arch=amd64] http://archive.ubuntu.com/ubuntu/ ${CODENAME}-updates main restricted universe multiverse" >>/etc/apt/sources.list.d/amd64.list \ - && echo "deb [arch=amd64] http://archive.ubuntu.com/ubuntu/ ${CODENAME}-backports main restricted universe multiverse" >>/etc/apt/sources.list.d/amd64.list \ - && echo "deb [arch=amd64] http://archive.ubuntu.com/ubuntu/ ${CODENAME}-security main restricted universe multiverse" >>/etc/apt/sources.list.d/amd64.list \ - && echo "deb [arch=armhf] http://ports.ubuntu.com/ ${CODENAME} main restricted universe multiverse" >>/etc/apt/sources.list.d/armhf.list \ - && echo "deb [arch=armhf] http://ports.ubuntu.com/ ${CODENAME}-updates main restricted universe multiverse" >>/etc/apt/sources.list.d/armhf.list \ - && echo "deb [arch=armhf] http://ports.ubuntu.com/ ${CODENAME}-backports main restricted universe multiverse" >>/etc/apt/sources.list.d/armhf.list \ - && echo "deb [arch=armhf] http://ports.ubuntu.com/ ${CODENAME}-security main restricted universe multiverse" >>/etc/apt/sources.list.d/armhf.list \ - && dpkg --add-architecture armhf \ - && apt-get update \ - && apt-get install -y cross-gcc-dev \ - && TARGET_LIST="armhf" cross-gcc-gensource 6 \ - && cd cross-gcc-packages-amd64/cross-gcc-6-armhf \ - && ln -fs /usr/share/zoneinfo/America/Toronto /etc/localtime \ - && apt-get install -y gcc-6-source libstdc++6-armhf-cross binutils-arm-linux-gnueabihf bison flex libtool gdb sharutils netbase libcloog-isl-dev libmpc-dev libmpfr-dev libgmp-dev systemtap-sdt-dev autogen expect chrpath zlib1g-dev zip libc6-dev:armhf linux-libc-dev:armhf libgcc1:armhf libcurl4-openssl-dev:armhf libfontconfig1-dev:armhf libfreetype6-dev:armhf liblttng-ust0:armhf libstdc++6:armhf libssl-dev:armhf + && export CODENAME="$( lsb_release -c -s )" \ + && echo "deb [arch=amd64] http://archive.ubuntu.com/ubuntu/ ${CODENAME} main restricted universe multiverse" >>/etc/apt/sources.list.d/amd64.list \ + && echo "deb [arch=amd64] http://archive.ubuntu.com/ubuntu/ ${CODENAME}-updates main restricted universe multiverse" >>/etc/apt/sources.list.d/amd64.list \ + && echo "deb [arch=amd64] http://archive.ubuntu.com/ubuntu/ ${CODENAME}-backports main restricted universe multiverse" >>/etc/apt/sources.list.d/amd64.list \ + && echo "deb [arch=amd64] http://archive.ubuntu.com/ubuntu/ ${CODENAME}-security main restricted universe multiverse" >>/etc/apt/sources.list.d/amd64.list \ + && echo "deb [arch=armhf] http://ports.ubuntu.com/ ${CODENAME} main restricted universe multiverse" >>/etc/apt/sources.list.d/armhf.list \ + && echo "deb [arch=armhf] http://ports.ubuntu.com/ ${CODENAME}-updates main restricted universe multiverse" >>/etc/apt/sources.list.d/armhf.list \ + && echo "deb [arch=armhf] http://ports.ubuntu.com/ ${CODENAME}-backports main restricted universe multiverse" >>/etc/apt/sources.list.d/armhf.list \ + && echo "deb [arch=armhf] http://ports.ubuntu.com/ ${CODENAME}-security main restricted universe multiverse" >>/etc/apt/sources.list.d/armhf.list \ + && dpkg --add-architecture armhf \ + && apt-get update -yqq \ + && apt-get install -yqq cross-gcc-dev \ + && TARGET_LIST="armhf" cross-gcc-gensource 6 \ + && cd cross-gcc-packages-amd64/cross-gcc-6-armhf \ + && ln -fs /usr/share/zoneinfo/America/Toronto /etc/localtime \ + && apt-get install -yqq --no-install-recommends \ + gcc-6-source libstdc++6-armhf-cross binutils-arm-linux-gnueabihf \ + bison flex libtool gdb sharutils netbase libcloog-isl-dev libmpc-dev \ + libmpfr-dev libgmp-dev systemtap-sdt-dev autogen expect chrpath zlib1g-dev \ + zip libc6-dev:armhf linux-libc-dev:armhf libgcc1:armhf libcurl4-openssl-dev:armhf \ + libfontconfig1-dev:armhf libfreetype6-dev:armhf liblttng-ust0:armhf libstdc++6:armhf libssl-dev:armhf # Link to build script RUN ln -sf ${SOURCE_DIR}/deployment/build.debian.armhf /build.sh diff --git a/deployment/Dockerfile.windows.amd64 b/deployment/Dockerfile.windows.amd64 index 7216b2363b..acd0e18549 100644 --- a/deployment/Dockerfile.windows.amd64 +++ b/deployment/Dockerfile.windows.amd64 @@ -1,8 +1,7 @@ -FROM debian:10 +FROM mcr.microsoft.com/dotnet/sdk:5.0-buster-slim # Docker build arguments ARG SOURCE_DIR=/jellyfin ARG ARTIFACT_DIR=/dist -ARG SDK_VERSION=3.1 # Docker run environment ENV SOURCE_DIR=/jellyfin ENV ARTIFACT_DIR=/dist @@ -10,15 +9,11 @@ ENV DEB_BUILD_OPTIONS=noddebs ENV IS_DOCKER=YES # Prepare Debian build environment -RUN apt-get update \ - && apt-get install -y apt-transport-https debhelper gnupg wget devscripts mmv libc6-dev libcurl4-openssl-dev libfontconfig1-dev libfreetype6-dev libssl-dev libssl1.1 liblttng-ust0 zip - -# Install dotnet repository -# https://dotnet.microsoft.com/download/linux-package-manager/debian9/sdk-current -RUN wget https://download.visualstudio.microsoft.com/download/pr/fdd9ecec-56b4-40f4-b762-d7efe24fc3cd/ffef51844c92afa6714528e10609a30f/dotnet-sdk-3.1.403-linux-x64.tar.gz -O dotnet-sdk.tar.gz \ - && mkdir -p dotnet-sdk \ - && tar -xzf dotnet-sdk.tar.gz -C dotnet-sdk \ - && ln -s $( pwd )/dotnet-sdk/dotnet /usr/bin/dotnet +RUN apt-get update -yqq \ + && apt-get install -yqq --no-install-recommends \ + apt-transport-https debhelper gnupg devscripts unzip \ + mmv libcurl4-openssl-dev libfontconfig1-dev \ + libfreetype6-dev libssl-dev libssl1.1 liblttng-ust0 zip # Link to docker-build script RUN ln -sf ${SOURCE_DIR}/deployment/build.windows.amd64 /build.sh diff --git a/deployment/build.debian.amd64 b/deployment/build.debian.amd64 index 012e1cebf6..145e28d871 100755 --- a/deployment/build.debian.amd64 +++ b/deployment/build.debian.amd64 @@ -9,9 +9,9 @@ set -o xtrace pushd ${SOURCE_DIR} if [[ ${IS_DOCKER} == YES ]]; then - # Remove build-dep for dotnet-sdk-3.1, since it's installed manually + # Remove build-dep for dotnet-sdk-5.0, since it's installed manually cp -a debian/control /tmp/control.orig - sed -i '/dotnet-sdk-3.1,/d' debian/control + sed -i '/dotnet-sdk-5.0,/d' debian/control fi # Modify changelog to unstable configuration if IS_UNSTABLE diff --git a/deployment/build.debian.arm64 b/deployment/build.debian.arm64 index 12ce3e874d..5699133a01 100755 --- a/deployment/build.debian.arm64 +++ b/deployment/build.debian.arm64 @@ -9,9 +9,9 @@ set -o xtrace pushd ${SOURCE_DIR} if [[ ${IS_DOCKER} == YES ]]; then - # Remove build-dep for dotnet-sdk-3.1, since it's installed manually + # Remove build-dep for dotnet-sdk-5.0, since it's installed manually cp -a debian/control /tmp/control.orig - sed -i '/dotnet-sdk-3.1,/d' debian/control + sed -i '/dotnet-sdk-5.0,/d' debian/control fi # Modify changelog to unstable configuration if IS_UNSTABLE diff --git a/deployment/build.debian.armhf b/deployment/build.debian.armhf index 3089eab585..20af2ddfbe 100755 --- a/deployment/build.debian.armhf +++ b/deployment/build.debian.armhf @@ -9,9 +9,9 @@ set -o xtrace pushd ${SOURCE_DIR} if [[ ${IS_DOCKER} == YES ]]; then - # Remove build-dep for dotnet-sdk-3.1, since it's installed manually + # Remove build-dep for dotnet-sdk-5.0, since it's installed manually cp -a debian/control /tmp/control.orig - sed -i '/dotnet-sdk-3.1,/d' debian/control + sed -i '/dotnet-sdk-5.0,/d' debian/control fi # Modify changelog to unstable configuration if IS_UNSTABLE diff --git a/deployment/build.linux.amd64-musl b/deployment/build.linux.amd64-musl new file mode 100755 index 0000000000..72444c05e7 --- /dev/null +++ b/deployment/build.linux.amd64-musl @@ -0,0 +1,31 @@ +#!/bin/bash + +#= Generic Linux amd64-musl .tar.gz + +set -o errexit +set -o xtrace + +# Move to source directory +pushd ${SOURCE_DIR} + +# Get version +if [[ ${IS_UNSTABLE} == 'yes' ]]; then + version="${BUILD_ID}" +else + version="$( grep "version:" ./build.yaml | sed -E 's/version: "([0-9\.]+.*)"/\1/' )" +fi + +# Build archives +dotnet publish Jellyfin.Server --configuration Release --self-contained --runtime linux-musl-x64 --output dist/jellyfin-server_${version}/ "-p:DebugSymbols=false;DebugType=none;UseAppHost=true" +tar -czf jellyfin-server_${version}_linux-amd64-musl.tar.gz -C dist jellyfin-server_${version} +rm -rf dist/jellyfin-server_${version} + +# Move the artifacts out +mkdir -p ${ARTIFACT_DIR}/ +mv jellyfin[-_]*.tar.gz ${ARTIFACT_DIR}/ + +if [[ ${IS_DOCKER} == YES ]]; then + chown -Rc $(stat -c %u:%g ${ARTIFACT_DIR}) ${ARTIFACT_DIR} +fi + +popd diff --git a/deployment/build.linux.arm64 b/deployment/build.linux.arm64 new file mode 100755 index 0000000000..e362607a76 --- /dev/null +++ b/deployment/build.linux.arm64 @@ -0,0 +1,31 @@ +#!/bin/bash + +#= Generic Linux arm64 .tar.gz + +set -o errexit +set -o xtrace + +# Move to source directory +pushd ${SOURCE_DIR} + +# Get version +if [[ ${IS_UNSTABLE} == 'yes' ]]; then + version="${BUILD_ID}" +else + version="$( grep "version:" ./build.yaml | sed -E 's/version: "([0-9\.]+.*)"/\1/' )" +fi + +# Build archives +dotnet publish Jellyfin.Server --configuration Release --self-contained --runtime linux-arm64 --output dist/jellyfin-server_${version}/ "-p:DebugSymbols=false;DebugType=none;UseAppHost=true" +tar -czf jellyfin-server_${version}_linux-arm64.tar.gz -C dist jellyfin-server_${version} +rm -rf dist/jellyfin-server_${version} + +# Move the artifacts out +mkdir -p ${ARTIFACT_DIR}/ +mv jellyfin[-_]*.tar.gz ${ARTIFACT_DIR}/ + +if [[ ${IS_DOCKER} == YES ]]; then + chown -Rc $(stat -c %u:%g ${ARTIFACT_DIR}) ${ARTIFACT_DIR} +fi + +popd diff --git a/deployment/build.linux.armhf b/deployment/build.linux.armhf new file mode 100755 index 0000000000..c0d0607fc7 --- /dev/null +++ b/deployment/build.linux.armhf @@ -0,0 +1,31 @@ +#!/bin/bash + +#= Generic Linux armhf .tar.gz + +set -o errexit +set -o xtrace + +# Move to source directory +pushd ${SOURCE_DIR} + +# Get version +if [[ ${IS_UNSTABLE} == 'yes' ]]; then + version="${BUILD_ID}" +else + version="$( grep "version:" ./build.yaml | sed -E 's/version: "([0-9\.]+.*)"/\1/' )" +fi + +# Build archives +dotnet publish Jellyfin.Server --configuration Release --self-contained --runtime linux-arm --output dist/jellyfin-server_${version}/ "-p:DebugSymbols=false;DebugType=none;UseAppHost=true" +tar -czf jellyfin-server_${version}_linux-armhf.tar.gz -C dist jellyfin-server_${version} +rm -rf dist/jellyfin-server_${version} + +# Move the artifacts out +mkdir -p ${ARTIFACT_DIR}/ +mv jellyfin[-_]*.tar.gz ${ARTIFACT_DIR}/ + +if [[ ${IS_DOCKER} == YES ]]; then + chown -Rc $(stat -c %u:%g ${ARTIFACT_DIR}) ${ARTIFACT_DIR} +fi + +popd diff --git a/deployment/build.portable b/deployment/build.portable index ea40ade5d9..a6c7418810 100755 --- a/deployment/build.portable +++ b/deployment/build.portable @@ -16,7 +16,7 @@ else fi # Build archives -dotnet publish Jellyfin.Server --configuration Release --output dist/jellyfin-server_${version}/ "-p:DebugSymbols=false;DebugType=none;UseAppHost=true" +dotnet publish Jellyfin.Server --configuration Release --output dist/jellyfin-server_${version}/ "-p:DebugSymbols=false;DebugType=none;UseAppHost=false" tar -czf jellyfin-server_${version}_portable.tar.gz -C dist jellyfin-server_${version} rm -rf dist/jellyfin-server_${version} diff --git a/deployment/build.ubuntu.amd64 b/deployment/build.ubuntu.amd64 index 0eac9cdd10..0c29286c02 100755 --- a/deployment/build.ubuntu.amd64 +++ b/deployment/build.ubuntu.amd64 @@ -9,9 +9,9 @@ set -o xtrace pushd ${SOURCE_DIR} if [[ ${IS_DOCKER} == YES ]]; then - # Remove build-dep for dotnet-sdk-3.1, since it's installed manually + # Remove build-dep for dotnet-sdk-5.0, since it's installed manually cp -a debian/control /tmp/control.orig - sed -i '/dotnet-sdk-3.1,/d' debian/control + sed -i '/dotnet-sdk-5.0,/d' debian/control fi # Modify changelog to unstable configuration if IS_UNSTABLE diff --git a/deployment/build.ubuntu.arm64 b/deployment/build.ubuntu.arm64 index 5b11fd543b..65d67f80f7 100755 --- a/deployment/build.ubuntu.arm64 +++ b/deployment/build.ubuntu.arm64 @@ -9,9 +9,9 @@ set -o xtrace pushd ${SOURCE_DIR} if [[ ${IS_DOCKER} == YES ]]; then - # Remove build-dep for dotnet-sdk-3.1, since it's installed manually + # Remove build-dep for dotnet-sdk-5.0, since it's installed manually cp -a debian/control /tmp/control.orig - sed -i '/dotnet-sdk-3.1,/d' debian/control + sed -i '/dotnet-sdk-5.0,/d' debian/control fi # Modify changelog to unstable configuration if IS_UNSTABLE diff --git a/deployment/build.ubuntu.armhf b/deployment/build.ubuntu.armhf index 4734cf6588..370370abc1 100755 --- a/deployment/build.ubuntu.armhf +++ b/deployment/build.ubuntu.armhf @@ -9,9 +9,9 @@ set -o xtrace pushd ${SOURCE_DIR} if [[ ${IS_DOCKER} == YES ]]; then - # Remove build-dep for dotnet-sdk-3.1, since it's installed manually + # Remove build-dep for dotnet-sdk-5.0, since it's installed manually cp -a debian/control /tmp/control.orig - sed -i '/dotnet-sdk-3.1,/d' debian/control + sed -i '/dotnet-sdk-5.0,/d' debian/control fi # Modify changelog to unstable configuration if IS_UNSTABLE diff --git a/fedora/jellyfin.env b/fedora/jellyfin.env index bf64acd3f9..56b7a3558d 100644 --- a/fedora/jellyfin.env +++ b/fedora/jellyfin.env @@ -35,3 +35,7 @@ JELLYFIN_RESTART_OPT="--restartpath=/usr/libexec/jellyfin/restart.sh" # [OPTIONAL] run Jellyfin without the web app #JELLYFIN_NOWEBAPP_OPT="--noautorunwebapp" +# [OPTIONAL] run Jellyfin with ASP.NET Server Garbage Collection (uses more RAM and less CPU than Workstation GC) +# 0 = Workstation +# 1 = Server +#COMPlus_gcServer=1 diff --git a/fedora/jellyfin.service b/fedora/jellyfin.service index b092ebf2f0..f706b0ad3f 100644 --- a/fedora/jellyfin.service +++ b/fedora/jellyfin.service @@ -1,5 +1,5 @@ [Unit] -After=network.target +After=network-online.target Description=Jellyfin is a free software media system that puts you in control of managing and streaming your media. [Service] diff --git a/fedora/jellyfin.spec b/fedora/jellyfin.spec index 93fb9fb411..928fe590f0 100644 --- a/fedora/jellyfin.spec +++ b/fedora/jellyfin.spec @@ -7,7 +7,7 @@ %endif Name: jellyfin -Version: 10.7.0 +Version: 10.8.0 Release: 1%{?dist} Summary: The Free Software Media System License: GPLv3 @@ -27,8 +27,8 @@ BuildRequires: libcurl-devel, fontconfig-devel, freetype-devel, openssl-devel, # Requirements not packaged in main repos # COPR @dotnet-sig/dotnet or # https://packages.microsoft.com/rhel/7/prod/ -BuildRequires: dotnet-runtime-3.1, dotnet-sdk-3.1 -Requires: %{name}-server = %{version}-%{release}, %{name}-web >= 10.6, %{name}-web < 10.7 +BuildRequires: dotnet-runtime-5.0, dotnet-sdk-5.0 +Requires: %{name}-server = %{version}-%{release}, %{name}-web = %{version}-%{release} # Disable Automatic Dependency Processing AutoReqProv: no @@ -40,7 +40,7 @@ Jellyfin is a free software media system that puts you in control of managing an Summary: The Free Software Media System Server backend Requires(pre): shadow-utils Requires: ffmpeg -Requires: libcurl, fontconfig, freetype, openssl, glibc libicu +Requires: libcurl, fontconfig, freetype, openssl, glibc, libicu, at %description server The Jellyfin media server backend. @@ -82,7 +82,6 @@ EOF %{_libdir}/jellyfin/* # Needs 755 else only root can run it since binary build by dotnet is 722 %attr(755,root,root) %{_libdir}/jellyfin/jellyfin -%{_libdir}/jellyfin/SOS_README.md %{_unitdir}/jellyfin.service %{_libexecdir}/jellyfin/restart.sh %{_prefix}/lib/firewalld/services/jellyfin.xml @@ -138,6 +137,8 @@ fi %systemd_postun_with_restart jellyfin.service %changelog +* Fri Dec 04 2020 Jellyfin Packaging Team <packaging@jellyfin.org> +- Forthcoming stable release * Mon Jul 27 2020 Jellyfin Packaging Team <packaging@jellyfin.org> - Forthcoming stable release * Mon Mar 23 2020 Jellyfin Packaging Team <packaging@jellyfin.org> diff --git a/fedora/restart.sh b/fedora/restart.sh index 9e53efecd0..34fce06708 100755 --- a/fedora/restart.sh +++ b/fedora/restart.sh @@ -24,13 +24,13 @@ cmd="$( get_service_command )" echo "Detected service control platform '$cmd'; using it to restart Jellyfin..." case $cmd in 'systemctl') - echo "sleep 2; /usr/bin/sudo $( which systemctl ) restart jellyfin" | at now + echo "sleep 0.5; /usr/bin/sudo $( which systemctl ) start jellyfin" | at now ;; 'service') - echo "sleep 2; /usr/bin/sudo $( which service ) jellyfin restart" | at now + echo "sleep 0.5; /usr/bin/sudo $( which service ) jellyfin start" | at now ;; 'sysv') - echo "sleep 2; /usr/bin/sudo /etc/init.d/jellyfin restart" | at now + echo "sleep 0.5; /usr/bin/sudo /etc/init.d/jellyfin start" | at now ;; esac exit 0 diff --git a/fuzz/.gitignore b/fuzz/.gitignore new file mode 100644 index 0000000000..652de0a45f --- /dev/null +++ b/fuzz/.gitignore @@ -0,0 +1 @@ +Findings diff --git a/fuzz/Emby.Server.Implementations.Fuzz/Emby.Server.Implementations.Fuzz.csproj b/fuzz/Emby.Server.Implementations.Fuzz/Emby.Server.Implementations.Fuzz.csproj new file mode 100644 index 0000000000..791cb140db --- /dev/null +++ b/fuzz/Emby.Server.Implementations.Fuzz/Emby.Server.Implementations.Fuzz.csproj @@ -0,0 +1,18 @@ +<Project Sdk="Microsoft.NET.Sdk"> + + <PropertyGroup> + <OutputType>Exe</OutputType> + <TargetFramework>net5.0</TargetFramework> + </PropertyGroup> + + <ItemGroup> + <Reference Include="Emby.Server.Implementations"> + <HintPath>Emby.Server.Implementations.dll</HintPath> + </Reference> + </ItemGroup> + + <ItemGroup> + <PackageReference Include="SharpFuzz" Version="1.6.2" /> + </ItemGroup> + +</Project> diff --git a/fuzz/Emby.Server.Implementations.Fuzz/Program.cs b/fuzz/Emby.Server.Implementations.Fuzz/Program.cs new file mode 100644 index 0000000000..a4a6f5f54d --- /dev/null +++ b/fuzz/Emby.Server.Implementations.Fuzz/Program.cs @@ -0,0 +1,32 @@ +using System; +using Emby.Server.Implementations.Library; +using SharpFuzz; + +namespace Emby.Server.Implementations.Fuzz +{ + public static class Program + { + public static void Main(string[] args) + { + switch (args[0]) + { + case "PathExtensions.TryReplaceSubPath": Run(PathExtensions_TryReplaceSubPath); return; + default: throw new ArgumentException($"Unknown fuzzing function: {args[0]}"); + } + } + + private static void Run(Action<string> action) => Fuzzer.OutOfProcess.Run(action); + + private static void PathExtensions_TryReplaceSubPath(string data) + { + // Stupid, but it worked + var parts = data.Split(':'); + if (parts.Length != 3) + { + return; + } + + _ = PathExtensions.TryReplaceSubPath(parts[0], parts[1], parts[2], out _); + } + } +} diff --git a/fuzz/Emby.Server.Implementations.Fuzz/Testcases/PathExtensions.TryReplaceSubPath/test1.txt b/fuzz/Emby.Server.Implementations.Fuzz/Testcases/PathExtensions.TryReplaceSubPath/test1.txt new file mode 100644 index 0000000000..aacf973d67 --- /dev/null +++ b/fuzz/Emby.Server.Implementations.Fuzz/Testcases/PathExtensions.TryReplaceSubPath/test1.txt @@ -0,0 +1 @@ +/fuzz/Emby.Server.Implementations.Fuzz/Testcases/PathExtensions.TryReplaceSubPath/test1.txt/:/home/bond/dev/jellyfin/:/srv/jellyfin/ diff --git a/fuzz/Emby.Server.Implementations.Fuzz/fuzz.sh b/fuzz/Emby.Server.Implementations.Fuzz/fuzz.sh new file mode 100755 index 0000000000..244f734026 --- /dev/null +++ b/fuzz/Emby.Server.Implementations.Fuzz/fuzz.sh @@ -0,0 +1,11 @@ +#!/bin/sh + +set -e + +dotnet build -c Release ../../Emby.Server.Implementations/Emby.Server.Implementations.csproj --output bin +sharpfuzz bin/Emby.Server.Implementations.dll +cp bin/Emby.Server.Implementations.dll . + +dotnet build +mkdir -p Findings +AFL_SKIP_BIN_CHECK=1 afl-fuzz -i "Testcases/$1" -o "Findings/$1" -t 5000 -m 10240 dotnet bin/Debug/net5.0/Emby.Server.Implementations.Fuzz.dll "$1" diff --git a/fuzz/Jellyfin.Server.Fuzz/Jellyfin.Server.Fuzz.csproj b/fuzz/Jellyfin.Server.Fuzz/Jellyfin.Server.Fuzz.csproj new file mode 100644 index 0000000000..6fcfbae0e6 --- /dev/null +++ b/fuzz/Jellyfin.Server.Fuzz/Jellyfin.Server.Fuzz.csproj @@ -0,0 +1,22 @@ +<Project Sdk="Microsoft.NET.Sdk.Web"> + + <PropertyGroup> + <OutputType>Exe</OutputType> + <TargetFramework>net5.0</TargetFramework> + </PropertyGroup> + + <ItemGroup> + <Reference Include="Jellyfin.Server"> + <HintPath>jellyfin.dll</HintPath> + </Reference> + </ItemGroup> + + <ItemGroup> + <ProjectReference Include="../../MediaBrowser.Common/MediaBrowser.Common.csproj" /> + </ItemGroup> + + <ItemGroup> + <PackageReference Include="SharpFuzz" Version="1.6.2" /> + </ItemGroup> + +</Project> diff --git a/fuzz/Jellyfin.Server.Fuzz/Program.cs b/fuzz/Jellyfin.Server.Fuzz/Program.cs new file mode 100644 index 0000000000..e47286c131 --- /dev/null +++ b/fuzz/Jellyfin.Server.Fuzz/Program.cs @@ -0,0 +1,33 @@ +using System; +using System.Collections.Generic; +using Jellyfin.Server.Middleware; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Features; +using Microsoft.Extensions.Primitives; +using SharpFuzz; + +namespace Emby.Server.Implementations.Fuzz +{ + public static class Program + { + public static void Main(string[] args) + { + switch (args[0]) + { + case "UrlDecodeQueryFeature": Run(UrlDecodeQueryFeature); return; + default: throw new ArgumentException($"Unknown fuzzing function: {args[0]}"); + } + } + + private static void Run(Action<string> action) => Fuzzer.OutOfProcess.Run(action); + + private static void UrlDecodeQueryFeature(string data) + { + var dict = new Dictionary<string, StringValues> + { + { data, StringValues.Empty } + }; + _ = new UrlDecodeQueryFeature(new QueryFeature(new QueryCollection(dict))); + } + } +} diff --git a/fuzz/Jellyfin.Server.Fuzz/Testcases/UrlDecodeQueryFeature/test1.txt b/fuzz/Jellyfin.Server.Fuzz/Testcases/UrlDecodeQueryFeature/test1.txt new file mode 100644 index 0000000000..73f356b936 --- /dev/null +++ b/fuzz/Jellyfin.Server.Fuzz/Testcases/UrlDecodeQueryFeature/test1.txt @@ -0,0 +1 @@ +a%3D1%26b%3D2%26c%3D3 diff --git a/fuzz/Jellyfin.Server.Fuzz/fuzz.sh b/fuzz/Jellyfin.Server.Fuzz/fuzz.sh new file mode 100755 index 0000000000..ad81e2c355 --- /dev/null +++ b/fuzz/Jellyfin.Server.Fuzz/fuzz.sh @@ -0,0 +1,11 @@ +#!/bin/sh + +set -e + +dotnet build -c Release ../../Jellyfin.Server/Jellyfin.Server.csproj --output bin +sharpfuzz bin/jellyfin.dll +cp bin/jellyfin.dll . + +dotnet build +mkdir -p Findings +AFL_SKIP_BIN_CHECK=1 afl-fuzz -i "Testcases/$1" -o "Findings/$1" -t 5000 -m 10240 dotnet bin/Debug/net5.0/Jellyfin.Server.Fuzz.dll "$1" diff --git a/hooks/pre_build b/hooks/pre_build deleted file mode 100644 index 2fd6136c53..0000000000 --- a/hooks/pre_build +++ /dev/null @@ -1,6 +0,0 @@ -#!/bin/bash -git submodule update --init --recursive - -# Register qemu-*-static for all supported processors except the -# current one, but also remove all registered binfmt_misc before -docker run --rm --privileged multiarch/qemu-user-static:register --reset diff --git a/jellyfin.ruleset b/jellyfin.ruleset index 45ab725eb2..a2fc7bc8da 100644 --- a/jellyfin.ruleset +++ b/jellyfin.ruleset @@ -1,15 +1,13 @@ <?xml version="1.0" encoding="utf-8"?> <RuleSet Name="Rules for Jellyfin.Server" Description="Code analysis rules for Jellyfin.Server.csproj" ToolsVersion="14.0"> <Rules AnalyzerId="StyleCop.Analyzers" RuleNamespace="StyleCop.Analyzers"> - <!-- disable warning SA1202: 'public' members must come before 'private' members --> - <Rule Id="SA1202" Action="Info" /> - <!-- disable warning SA1204: Static members must appear before non-static members --> - <Rule Id="SA1204" Action="Info" /> - <!-- disable warning SA1404: Code analysis suppression should have justification --> - <Rule Id="SA1404" Action="Info" /> + <!-- disable warning CA1040: Avoid empty interfaces --> + <Rule Id="CA1040" Action="Info" /> <!-- disable warning SA1009: Closing parenthesis should be followed by a space. --> <Rule Id="SA1009" Action="None" /> + <!-- disable warning SA1011: Closing square bracket should be followed by a space. --> + <Rule Id="SA1011" Action="None" /> <!-- disable warning SA1101: Prefix local calls with 'this.' --> <Rule Id="SA1101" Action="None" /> <!-- disable warning SA1108: Block statements should not contain embedded comments --> @@ -20,6 +18,10 @@ <Rule Id="SA1130" Action="None" /> <!-- disable warning SA1200: 'using' directive must appear within a namespace declaration --> <Rule Id="SA1200" Action="None" /> + <!-- disable warning SA1202: 'public' members must come before 'private' members --> + <Rule Id="SA1202" Action="None" /> + <!-- disable warning SA1204: Static members must appear before non-static members --> + <Rule Id="SA1204" Action="None" /> <!-- disable warning SA1309: Fields must not begin with an underscore --> <Rule Id="SA1309" Action="None" /> <!-- disable warning SA1413: Use trailing comma in multi-line initializers --> @@ -30,11 +32,21 @@ <Rule Id="SA1515" Action="None" /> <!-- disable warning SA1600: Elements should be documented --> <Rule Id="SA1600" Action="None" /> + <!-- disable warning SA1602: Enumeration items should be documented --> + <Rule Id="SA1602" Action="None" /> <!-- disable warning SA1633: The file header is missing or not located at the top of the file --> <Rule Id="SA1633" Action="None" /> </Rules> - <Rules AnalyzerId="Microsoft.CodeAnalysis.FxCopAnalyzers" RuleNamespace="Microsoft.Design"> + <Rules AnalyzerId="Microsoft.CodeAnalysis.NetAnalyzers" RuleNamespace="Microsoft.Design"> + <!-- error on CA2016: Forward the CancellationToken parameter to methods that take one + or pass in 'CancellationToken.None' explicitly to indicate intentionally not propagating the token --> + <Rule Id="CA2016" Action="Error" /> + + <!-- disable warning CA1014: Mark assemblies with CLSCompliantAttribute --> + <Rule Id="CA1014" Action="Info" /> + <!-- disable warning CA1024: Use properties where appropriate --> + <Rule Id="CA1024" Action="Info" /> <!-- disable warning CA1031: Do not catch general exception types --> <Rule Id="CA1031" Action="Info" /> <!-- disable warning CA1032: Implement standard exception constructors --> @@ -45,6 +57,8 @@ <Rule Id="CA1716" Action="Info" /> <!-- disable warning CA1720: Identifiers should not contain type names --> <Rule Id="CA1720" Action="Info" /> + <!-- disable warning CA1805: Do not initialize unnecessarily --> + <Rule Id="CA1805" Action="Info" /> <!-- disable warning CA1812: internal class that is apparently never instantiated. If so, remove the code from the assembly. If this class is intended to contain only static members, make it static --> @@ -53,6 +67,8 @@ <Rule Id="CA1822" Action="Info" /> <!-- disable warning CA2000: Dispose objects before losing scope --> <Rule Id="CA2000" Action="Info" /> + <!-- disable warning CA5394: Do not use insecure randomness --> + <Rule Id="CA5394" Action="Info" /> <!-- disable warning CA1054: Change the type of parameter url from string to System.Uri --> <Rule Id="CA1054" Action="None" /> @@ -64,5 +80,9 @@ <Rule Id="CA1303" Action="None" /> <!-- disable warning CA1308: Normalize strings to uppercase --> <Rule Id="CA1308" Action="None" /> + <!-- disable warning CA2101: Specify marshaling for P/Invoke string arguments --> + <Rule Id="CA2101" Action="None" /> + <!-- disable warning CA2234: Pass System.Uri objects instead of strings --> + <Rule Id="CA2234" Action="None" /> </Rules> </RuleSet> diff --git a/MediaBrowser.Controller/Sorting/AlphanumComparator.cs b/src/Jellyfin.Extensions/AlphanumericComparator.cs similarity index 83% rename from MediaBrowser.Controller/Sorting/AlphanumComparator.cs rename to src/Jellyfin.Extensions/AlphanumericComparator.cs index 70cb9eebe0..e3c81eba8c 100644 --- a/MediaBrowser.Controller/Sorting/AlphanumComparator.cs +++ b/src/Jellyfin.Extensions/AlphanumericComparator.cs @@ -1,14 +1,19 @@ -#pragma warning disable CS1591 - -#nullable enable - using System; using System.Collections.Generic; -namespace MediaBrowser.Controller.Sorting +namespace Jellyfin.Extensions { - public class AlphanumComparator : IComparer<string?> + /// <summary> + /// Alphanumeric <see cref="IComparer{T}" />. + /// </summary> + public class AlphanumericComparator : IComparer<string?> { + /// <summary> + /// Compares two objects and returns a value indicating whether one is less than, equal to, or greater than the other. + /// </summary> + /// <param name="s1">The first object to compare.</param> + /// <param name="s2">The second object to compare.</param> + /// <returns>A signed integer that indicates the relative values of <c>x</c> and <c>y</c>.</returns> public static int CompareValues(string? s1, string? s2) { if (s1 == null && s2 == null) @@ -123,7 +128,9 @@ namespace MediaBrowser.Controller.Sorting return result; } } +#pragma warning disable SA1500 // TODO remove with StyleCop.Analyzers v1.2.0 https://github.com/DotNetAnalyzers/StyleCopAnalyzers/pull/3196 } while (pos1 < len1 && pos2 < len2); +#pragma warning restore SA1500 return len1 - len2; } diff --git a/MediaBrowser.Common/Extensions/CopyToExtensions.cs b/src/Jellyfin.Extensions/CopyToExtensions.cs similarity index 95% rename from MediaBrowser.Common/Extensions/CopyToExtensions.cs rename to src/Jellyfin.Extensions/CopyToExtensions.cs index 94bf7c7401..72d37b5b6a 100644 --- a/MediaBrowser.Common/Extensions/CopyToExtensions.cs +++ b/src/Jellyfin.Extensions/CopyToExtensions.cs @@ -1,8 +1,6 @@ -#nullable enable - using System.Collections.Generic; -namespace MediaBrowser.Common.Extensions +namespace Jellyfin.Extensions { /// <summary> /// Provides <c>CopyTo</c> extensions methods for <see cref="IReadOnlyList{T}" />. diff --git a/src/Jellyfin.Extensions/DictionaryExtensions.cs b/src/Jellyfin.Extensions/DictionaryExtensions.cs new file mode 100644 index 0000000000..43ed41ab18 --- /dev/null +++ b/src/Jellyfin.Extensions/DictionaryExtensions.cs @@ -0,0 +1,65 @@ +using System; +using System.Collections.Generic; + +namespace Jellyfin.Extensions +{ + /// <summary> + /// Static extensions for the <see cref="IReadOnlyDictionary{TKey,TValue}"/> interface. + /// </summary> + public static class DictionaryExtensions + { + /// <summary> + /// Gets a string from a string dictionary, checking all keys sequentially, + /// stopping at the first key that returns a result that's neither null nor blank. + /// </summary> + /// <param name="dictionary">The dictionary.</param> + /// <param name="key1">The first checked key.</param> + /// <returns>System.String.</returns> + public static string? GetFirstNotNullNorWhiteSpaceValue(this IReadOnlyDictionary<string, string> dictionary, string key1) + { + return dictionary.GetFirstNotNullNorWhiteSpaceValue(key1, string.Empty, string.Empty); + } + + /// <summary> + /// Gets a string from a string dictionary, checking all keys sequentially, + /// stopping at the first key that returns a result that's neither null nor blank. + /// </summary> + /// <param name="dictionary">The dictionary.</param> + /// <param name="key1">The first checked key.</param> + /// <param name="key2">The second checked key.</param> + /// <returns>System.String.</returns> + public static string? GetFirstNotNullNorWhiteSpaceValue(this IReadOnlyDictionary<string, string> dictionary, string key1, string key2) + { + return dictionary.GetFirstNotNullNorWhiteSpaceValue(key1, key2, string.Empty); + } + + /// <summary> + /// Gets a string from a string dictionary, checking all keys sequentially, + /// stopping at the first key that returns a result that's neither null nor blank. + /// </summary> + /// <param name="dictionary">The dictionary.</param> + /// <param name="key1">The first checked key.</param> + /// <param name="key2">The second checked key.</param> + /// <param name="key3">The third checked key.</param> + /// <returns>System.String.</returns> + public static string? GetFirstNotNullNorWhiteSpaceValue(this IReadOnlyDictionary<string, string> dictionary, string key1, string key2, string key3) + { + if (dictionary.TryGetValue(key1, out var val) && !string.IsNullOrWhiteSpace(val)) + { + return val; + } + + if (!string.IsNullOrEmpty(key2) && dictionary.TryGetValue(key2, out val) && !string.IsNullOrWhiteSpace(val)) + { + return val; + } + + if (!string.IsNullOrEmpty(key3) && dictionary.TryGetValue(key3, out val) && !string.IsNullOrWhiteSpace(val)) + { + return val; + } + + return null; + } + } +} diff --git a/src/Jellyfin.Extensions/EnumerableExtensions.cs b/src/Jellyfin.Extensions/EnumerableExtensions.cs new file mode 100644 index 0000000000..b5fe933571 --- /dev/null +++ b/src/Jellyfin.Extensions/EnumerableExtensions.cs @@ -0,0 +1,51 @@ +using System; +using System.Collections.Generic; + +namespace Jellyfin.Extensions +{ + /// <summary> + /// Static extensions for the <see cref="IEnumerable{T}"/> interface. + /// </summary> + public static class EnumerableExtensions + { + /// <summary> + /// Determines whether the value is contained in the source collection. + /// </summary> + /// <param name="source">An instance of the <see cref="IEnumerable{String}"/> interface.</param> + /// <param name="value">The value to look for in the collection.</param> + /// <param name="stringComparison">The string comparison.</param> + /// <returns>A value indicating whether the value is contained in the collection.</returns> + /// <exception cref="ArgumentNullException">The source is null.</exception> + public static bool Contains(this IEnumerable<string> source, ReadOnlySpan<char> value, StringComparison stringComparison) + { + if (source == null) + { + throw new ArgumentNullException(nameof(source)); + } + + if (source is IList<string> list) + { + int len = list.Count; + for (int i = 0; i < len; i++) + { + if (value.Equals(list[i], stringComparison)) + { + return true; + } + } + + return false; + } + + foreach (string element in source) + { + if (value.Equals(element, stringComparison)) + { + return true; + } + } + + return false; + } + } +} diff --git a/src/Jellyfin.Extensions/Jellyfin.Extensions.csproj b/src/Jellyfin.Extensions/Jellyfin.Extensions.csproj new file mode 100644 index 0000000000..981b796e03 --- /dev/null +++ b/src/Jellyfin.Extensions/Jellyfin.Extensions.csproj @@ -0,0 +1,26 @@ +<Project Sdk="Microsoft.NET.Sdk"> + + <PropertyGroup> + <TargetFramework>net5.0</TargetFramework> + <GenerateAssemblyInfo>false</GenerateAssemblyInfo> + <GenerateDocumentationFile>true</GenerateDocumentationFile> + </PropertyGroup> + + <PropertyGroup> + <Authors>Jellyfin Contributors</Authors> + <RepositoryUrl>https://github.com/jellyfin/jellyfin</RepositoryUrl> + <PackageLicenseExpression>GPL-3.0-only</PackageLicenseExpression> + </PropertyGroup> + + <ItemGroup> + <Compile Include="../../SharedVersion.cs" /> + </ItemGroup> + + <!-- Code Analyzers--> + <ItemGroup> + <PackageReference Include="SerilogAnalyzer" Version="0.15.0" PrivateAssets="All" /> + <PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="All" /> + <PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" PrivateAssets="All" /> + </ItemGroup> + +</Project> diff --git a/src/Jellyfin.Extensions/Json/Converters/JsonBoolNumberConverter.cs b/src/Jellyfin.Extensions/Json/Converters/JsonBoolNumberConverter.cs new file mode 100644 index 0000000000..c2543cf7ce --- /dev/null +++ b/src/Jellyfin.Extensions/Json/Converters/JsonBoolNumberConverter.cs @@ -0,0 +1,30 @@ +using System; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Jellyfin.Extensions.Json.Converters +{ + /// <summary> + /// Converts a number to a boolean. + /// This is needed for HDHomerun. + /// </summary> + public class JsonBoolNumberConverter : JsonConverter<bool> + { + /// <inheritdoc /> + public override bool Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType == JsonTokenType.Number) + { + return Convert.ToBoolean(reader.GetInt32()); + } + + return reader.GetBoolean(); + } + + /// <inheritdoc /> + public override void Write(Utf8JsonWriter writer, bool value, JsonSerializerOptions options) + { + writer.WriteBooleanValue(value); + } + } +} diff --git a/src/Jellyfin.Extensions/Json/Converters/JsonCommaDelimitedArrayConverter.cs b/src/Jellyfin.Extensions/Json/Converters/JsonCommaDelimitedArrayConverter.cs new file mode 100644 index 0000000000..44980ec02f --- /dev/null +++ b/src/Jellyfin.Extensions/Json/Converters/JsonCommaDelimitedArrayConverter.cs @@ -0,0 +1,24 @@ +using System; +using System.ComponentModel; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Jellyfin.Extensions.Json.Converters +{ + /// <summary> + /// Convert comma delimited string to array of type. + /// </summary> + /// <typeparam name="T">Type to convert to.</typeparam> + public sealed class JsonCommaDelimitedArrayConverter<T> : JsonDelimitedArrayConverter<T> + { + /// <summary> + /// Initializes a new instance of the <see cref="JsonCommaDelimitedArrayConverter{T}"/> class. + /// </summary> + public JsonCommaDelimitedArrayConverter() : base() + { + } + + /// <inheritdoc /> + protected override char Delimiter => ','; + } +} diff --git a/src/Jellyfin.Extensions/Json/Converters/JsonCommaDelimitedArrayConverterFactory.cs b/src/Jellyfin.Extensions/Json/Converters/JsonCommaDelimitedArrayConverterFactory.cs new file mode 100644 index 0000000000..cc9311a24c --- /dev/null +++ b/src/Jellyfin.Extensions/Json/Converters/JsonCommaDelimitedArrayConverterFactory.cs @@ -0,0 +1,28 @@ +using System; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Jellyfin.Extensions.Json.Converters +{ + /// <summary> + /// Json comma delimited array converter factory. + /// </summary> + /// <remarks> + /// This must be applied as an attribute, adding to the JsonConverter list causes stack overflow. + /// </remarks> + public class JsonCommaDelimitedArrayConverterFactory : JsonConverterFactory + { + /// <inheritdoc /> + public override bool CanConvert(Type typeToConvert) + { + return true; + } + + /// <inheritdoc /> + public override JsonConverter? CreateConverter(Type typeToConvert, JsonSerializerOptions options) + { + var structType = typeToConvert.GetElementType() ?? typeToConvert.GenericTypeArguments[0]; + return (JsonConverter?)Activator.CreateInstance(typeof(JsonCommaDelimitedArrayConverter<>).MakeGenericType(structType)); + } + } +} diff --git a/src/Jellyfin.Extensions/Json/Converters/JsonDateTimeConverter.cs b/src/Jellyfin.Extensions/Json/Converters/JsonDateTimeConverter.cs new file mode 100644 index 0000000000..8ae080b15a --- /dev/null +++ b/src/Jellyfin.Extensions/Json/Converters/JsonDateTimeConverter.cs @@ -0,0 +1,34 @@ +using System; +using System.Globalization; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Jellyfin.Extensions.Json.Converters +{ + /// <summary> + /// Legacy DateTime converter. + /// Milliseconds aren't output if zero by default. + /// </summary> + public class JsonDateTimeConverter : JsonConverter<DateTime> + { + /// <inheritdoc /> + public override DateTime Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + return reader.GetDateTime(); + } + + /// <inheritdoc /> + public override void Write(Utf8JsonWriter writer, DateTime value, JsonSerializerOptions options) + { + if (value.Millisecond == 0) + { + // Remaining ticks value will be 0, manually format. + writer.WriteStringValue(value.ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fffffffZ", CultureInfo.InvariantCulture)); + } + else + { + writer.WriteStringValue(value); + } + } + } +} diff --git a/src/Jellyfin.Extensions/Json/Converters/JsonDelimitedArrayConverter.cs b/src/Jellyfin.Extensions/Json/Converters/JsonDelimitedArrayConverter.cs new file mode 100644 index 0000000000..c39805aa35 --- /dev/null +++ b/src/Jellyfin.Extensions/Json/Converters/JsonDelimitedArrayConverter.cs @@ -0,0 +1,81 @@ +using System; +using System.ComponentModel; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Jellyfin.Extensions.Json.Converters +{ + /// <summary> + /// Convert delimited string to array of type. + /// </summary> + /// <typeparam name="T">Type to convert to.</typeparam> + public abstract class JsonDelimitedArrayConverter<T> : JsonConverter<T[]?> + { + private readonly TypeConverter _typeConverter; + + /// <summary> + /// Initializes a new instance of the <see cref="JsonDelimitedArrayConverter{T}"/> class. + /// </summary> + protected JsonDelimitedArrayConverter() + { + _typeConverter = TypeDescriptor.GetConverter(typeof(T)); + } + + /// <summary> + /// Gets the array delimiter. + /// </summary> + protected virtual char Delimiter { get; } + + /// <inheritdoc /> + public override T[]? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType == JsonTokenType.String) + { + // GetString can't return null here because we already handled it above + var stringEntries = reader.GetString()?.Split(Delimiter, StringSplitOptions.RemoveEmptyEntries); + if (stringEntries == null || stringEntries.Length == 0) + { + return Array.Empty<T>(); + } + + var parsedValues = new object[stringEntries.Length]; + var convertedCount = 0; + for (var i = 0; i < stringEntries.Length; i++) + { + try + { + parsedValues[i] = _typeConverter.ConvertFrom(stringEntries[i].Trim()); + convertedCount++; + } + catch (FormatException) + { + // TODO log when upgraded to .Net6 + // https://github.com/dotnet/runtime/issues/42975 + // _logger.LogDebug(e, "Error converting value."); + } + } + + var typedValues = new T[convertedCount]; + var typedValueIndex = 0; + for (var i = 0; i < stringEntries.Length; i++) + { + if (parsedValues[i] != null) + { + typedValues.SetValue(parsedValues[i], typedValueIndex); + typedValueIndex++; + } + } + + return typedValues; + } + + return JsonSerializer.Deserialize<T[]>(ref reader, options); + } + + /// <inheritdoc /> + public override void Write(Utf8JsonWriter writer, T[]? value, JsonSerializerOptions options) + { + throw new NotImplementedException(); + } + } +} diff --git a/MediaBrowser.Common/Json/Converters/JsonGuidConverter.cs b/src/Jellyfin.Extensions/Json/Converters/JsonGuidConverter.cs similarity index 61% rename from MediaBrowser.Common/Json/Converters/JsonGuidConverter.cs rename to src/Jellyfin.Extensions/Json/Converters/JsonGuidConverter.cs index d35a761f35..be94dd5192 100644 --- a/MediaBrowser.Common/Json/Converters/JsonGuidConverter.cs +++ b/src/Jellyfin.Extensions/Json/Converters/JsonGuidConverter.cs @@ -1,8 +1,9 @@ using System; +using System.Globalization; using System.Text.Json; using System.Text.Json.Serialization; -namespace MediaBrowser.Common.Json.Converters +namespace Jellyfin.Extensions.Json.Converters { /// <summary> /// Converts a GUID object or value to/from JSON. @@ -11,10 +12,15 @@ namespace MediaBrowser.Common.Json.Converters { /// <inheritdoc /> public override Guid Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) - => new Guid(reader.GetString()); + { + var guidStr = reader.GetString(); + return guidStr == null ? Guid.Empty : new Guid(guidStr); + } /// <inheritdoc /> public override void Write(Utf8JsonWriter writer, Guid value, JsonSerializerOptions options) - => writer.WriteStringValue(value); + { + writer.WriteStringValue(value.ToString("N", CultureInfo.InvariantCulture)); + } } } diff --git a/src/Jellyfin.Extensions/Json/Converters/JsonLowerCaseConverter.cs b/src/Jellyfin.Extensions/Json/Converters/JsonLowerCaseConverter.cs new file mode 100644 index 0000000000..cd582ced64 --- /dev/null +++ b/src/Jellyfin.Extensions/Json/Converters/JsonLowerCaseConverter.cs @@ -0,0 +1,25 @@ +using System; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Jellyfin.Extensions.Json.Converters +{ + /// <summary> + /// Converts an object to a lowercase string. + /// </summary> + /// <typeparam name="T">The object type.</typeparam> + public class JsonLowerCaseConverter<T> : JsonConverter<T> + { + /// <inheritdoc /> + public override T? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + return JsonSerializer.Deserialize<T>(ref reader, options); + } + + /// <inheritdoc /> + public override void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions options) + { + writer.WriteStringValue(value?.ToString()?.ToLowerInvariant()); + } + } +} diff --git a/src/Jellyfin.Extensions/Json/Converters/JsonNullableGuidConverter.cs b/src/Jellyfin.Extensions/Json/Converters/JsonNullableGuidConverter.cs new file mode 100644 index 0000000000..6192d1598d --- /dev/null +++ b/src/Jellyfin.Extensions/Json/Converters/JsonNullableGuidConverter.cs @@ -0,0 +1,33 @@ +using System; +using System.Globalization; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Jellyfin.Extensions.Json.Converters +{ + /// <summary> + /// Converts a GUID object or value to/from JSON. + /// </summary> + public class JsonNullableGuidConverter : JsonConverter<Guid?> + { + /// <inheritdoc /> + public override Guid? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var guidStr = reader.GetString(); + return guidStr == null ? null : new Guid(guidStr); + } + + /// <inheritdoc /> + public override void Write(Utf8JsonWriter writer, Guid? value, JsonSerializerOptions options) + { + if (value == null || value == Guid.Empty) + { + writer.WriteNullValue(); + } + else + { + writer.WriteStringValue(value.Value.ToString("N", CultureInfo.InvariantCulture)); + } + } + } +} diff --git a/MediaBrowser.Common/Json/Converters/JsonNullableStructConverter.cs b/src/Jellyfin.Extensions/Json/Converters/JsonNullableStructConverter.cs similarity index 96% rename from MediaBrowser.Common/Json/Converters/JsonNullableStructConverter.cs rename to src/Jellyfin.Extensions/Json/Converters/JsonNullableStructConverter.cs index 0501f7b2a1..6de238b39e 100644 --- a/MediaBrowser.Common/Json/Converters/JsonNullableStructConverter.cs +++ b/src/Jellyfin.Extensions/Json/Converters/JsonNullableStructConverter.cs @@ -2,7 +2,7 @@ using System.Text.Json; using System.Text.Json.Serialization; -namespace MediaBrowser.Common.Json.Converters +namespace Jellyfin.Extensions.Json.Converters { /// <summary> /// Converts a nullable struct or value to/from JSON. diff --git a/MediaBrowser.Common/Json/Converters/JsonNullableStructConverterFactory.cs b/src/Jellyfin.Extensions/Json/Converters/JsonNullableStructConverterFactory.cs similarity index 70% rename from MediaBrowser.Common/Json/Converters/JsonNullableStructConverterFactory.cs rename to src/Jellyfin.Extensions/Json/Converters/JsonNullableStructConverterFactory.cs index d5b54e3ca8..e7749589ae 100644 --- a/MediaBrowser.Common/Json/Converters/JsonNullableStructConverterFactory.cs +++ b/src/Jellyfin.Extensions/Json/Converters/JsonNullableStructConverterFactory.cs @@ -2,7 +2,7 @@ using System.Text.Json; using System.Text.Json.Serialization; -namespace MediaBrowser.Common.Json.Converters +namespace Jellyfin.Extensions.Json.Converters { /// <summary> /// Json nullable struct converter factory. @@ -18,10 +18,10 @@ namespace MediaBrowser.Common.Json.Converters } /// <inheritdoc /> - public override JsonConverter CreateConverter(Type typeToConvert, JsonSerializerOptions options) + public override JsonConverter? CreateConverter(Type typeToConvert, JsonSerializerOptions options) { var structType = typeToConvert.GenericTypeArguments[0]; - return (JsonConverter)Activator.CreateInstance(typeof(JsonNullableStructConverter<>).MakeGenericType(structType)); + return (JsonConverter?)Activator.CreateInstance(typeof(JsonNullableStructConverter<>).MakeGenericType(structType)); } } -} \ No newline at end of file +} diff --git a/src/Jellyfin.Extensions/Json/Converters/JsonPipeDelimitedArrayConverter.cs b/src/Jellyfin.Extensions/Json/Converters/JsonPipeDelimitedArrayConverter.cs new file mode 100644 index 0000000000..e3e492e24d --- /dev/null +++ b/src/Jellyfin.Extensions/Json/Converters/JsonPipeDelimitedArrayConverter.cs @@ -0,0 +1,24 @@ +using System; +using System.ComponentModel; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Jellyfin.Extensions.Json.Converters +{ + /// <summary> + /// Convert Pipe delimited string to array of type. + /// </summary> + /// <typeparam name="T">Type to convert to.</typeparam> + public sealed class JsonPipeDelimitedArrayConverter<T> : JsonDelimitedArrayConverter<T> + { + /// <summary> + /// Initializes a new instance of the <see cref="JsonPipeDelimitedArrayConverter{T}"/> class. + /// </summary> + public JsonPipeDelimitedArrayConverter() : base() + { + } + + /// <inheritdoc /> + protected override char Delimiter => '|'; + } +} diff --git a/src/Jellyfin.Extensions/Json/Converters/JsonPipeDelimitedArrayConverterFactory.cs b/src/Jellyfin.Extensions/Json/Converters/JsonPipeDelimitedArrayConverterFactory.cs new file mode 100644 index 0000000000..579674f18c --- /dev/null +++ b/src/Jellyfin.Extensions/Json/Converters/JsonPipeDelimitedArrayConverterFactory.cs @@ -0,0 +1,28 @@ +using System; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Jellyfin.Extensions.Json.Converters +{ + /// <summary> + /// Json Pipe delimited array converter factory. + /// </summary> + /// <remarks> + /// This must be applied as an attribute, adding to the JsonConverter list causes stack overflow. + /// </remarks> + public class JsonPipeDelimitedArrayConverterFactory : JsonConverterFactory + { + /// <inheritdoc /> + public override bool CanConvert(Type typeToConvert) + { + return true; + } + + /// <inheritdoc /> + public override JsonConverter? CreateConverter(Type typeToConvert, JsonSerializerOptions options) + { + var structType = typeToConvert.GetElementType() ?? typeToConvert.GenericTypeArguments[0]; + return (JsonConverter?)Activator.CreateInstance(typeof(JsonPipeDelimitedArrayConverter<>).MakeGenericType(structType)); + } + } +} diff --git a/src/Jellyfin.Extensions/Json/Converters/JsonStringConverter.cs b/src/Jellyfin.Extensions/Json/Converters/JsonStringConverter.cs new file mode 100644 index 0000000000..1a7a8c4f55 --- /dev/null +++ b/src/Jellyfin.Extensions/Json/Converters/JsonStringConverter.cs @@ -0,0 +1,39 @@ +using System; +using System.Buffers; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Jellyfin.Extensions.Json.Converters +{ + /// <summary> + /// Converter to allow the serializer to read strings. + /// </summary> + public class JsonStringConverter : JsonConverter<string?> + { + /// <inheritdoc /> + public override string? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + return reader.TokenType switch + { + JsonTokenType.Null => null, + JsonTokenType.String => reader.GetString(), + _ => GetRawValue(reader) + }; + } + + /// <inheritdoc /> + public override void Write(Utf8JsonWriter writer, string? value, JsonSerializerOptions options) + { + writer.WriteStringValue(value); + } + + private static string GetRawValue(Utf8JsonReader reader) + { + var utf8Bytes = reader.HasValueSequence + ? reader.ValueSequence.ToArray() + : reader.ValueSpan; + return Encoding.UTF8.GetString(utf8Bytes); + } + } +} diff --git a/src/Jellyfin.Extensions/Json/Converters/JsonVersionConverter.cs b/src/Jellyfin.Extensions/Json/Converters/JsonVersionConverter.cs new file mode 100644 index 0000000000..51ffec1cbf --- /dev/null +++ b/src/Jellyfin.Extensions/Json/Converters/JsonVersionConverter.cs @@ -0,0 +1,23 @@ +using System; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Jellyfin.Extensions.Json.Converters +{ + /// <summary> + /// Converts a Version object or value to/from JSON. + /// </summary> + /// <remarks> + /// Required to send <see cref="Version"/> as a string instead of an object. + /// </remarks> + public class JsonVersionConverter : JsonConverter<Version> + { + /// <inheritdoc /> + public override Version Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + => new Version(reader.GetString()!); // Will throw ArgumentNullException on null + + /// <inheritdoc /> + public override void Write(Utf8JsonWriter writer, Version value, JsonSerializerOptions options) + => writer.WriteStringValue(value.ToString()); + } +} diff --git a/src/Jellyfin.Extensions/Json/JsonDefaults.cs b/src/Jellyfin.Extensions/Json/JsonDefaults.cs new file mode 100644 index 0000000000..f4ec911231 --- /dev/null +++ b/src/Jellyfin.Extensions/Json/JsonDefaults.cs @@ -0,0 +1,90 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using Jellyfin.Extensions.Json.Converters; + +namespace Jellyfin.Extensions.Json +{ + /// <summary> + /// Helper class for having compatible JSON throughout the codebase. + /// </summary> + public static class JsonDefaults + { + /// <summary> + /// Pascal case json profile media type. + /// </summary> + public const string PascalCaseMediaType = "application/json; profile=\"PascalCase\""; + + /// <summary> + /// Camel case json profile media type. + /// </summary> + public const string CamelCaseMediaType = "application/json; profile=\"CamelCase\""; + + /// <summary> + /// When changing these options, update + /// Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs + /// -> AddJellyfinApi + /// -> AddJsonOptions. + /// </summary> + private static readonly JsonSerializerOptions _jsonSerializerOptions = new () + { + ReadCommentHandling = JsonCommentHandling.Disallow, + WriteIndented = false, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + NumberHandling = JsonNumberHandling.AllowReadingFromString, + Converters = + { + new JsonGuidConverter(), + new JsonNullableGuidConverter(), + new JsonVersionConverter(), + new JsonStringEnumConverter(), + new JsonNullableStructConverterFactory(), + new JsonBoolNumberConverter(), + new JsonDateTimeConverter(), + new JsonStringConverter() + } + }; + + private static readonly JsonSerializerOptions _pascalCaseJsonSerializerOptions = new (_jsonSerializerOptions) + { + PropertyNamingPolicy = null + }; + + private static readonly JsonSerializerOptions _camelCaseJsonSerializerOptions = new (_jsonSerializerOptions) + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }; + + /// <summary> + /// Gets the default <see cref="JsonSerializerOptions" /> options. + /// </summary> + /// <remarks> + /// The return value must not be modified. + /// If the defaults must be modified the author must use the copy constructor. + /// </remarks> + /// <returns>The default <see cref="JsonSerializerOptions" /> options.</returns> + public static JsonSerializerOptions Options + => _jsonSerializerOptions; + + /// <summary> + /// Gets camelCase json options. + /// </summary> + /// <remarks> + /// The return value must not be modified. + /// If the defaults must be modified the author must use the copy constructor. + /// </remarks> + /// <returns>The camelCase <see cref="JsonSerializerOptions" /> options.</returns> + public static JsonSerializerOptions CamelCaseOptions + => _camelCaseJsonSerializerOptions; + + /// <summary> + /// Gets PascalCase json options. + /// </summary> + /// <remarks> + /// The return value must not be modified. + /// If the defaults must be modified the author must use the copy constructor. + /// </remarks> + /// <returns>The PascalCase <see cref="JsonSerializerOptions" /> options.</returns> + public static JsonSerializerOptions PascalCaseOptions + => _pascalCaseJsonSerializerOptions; + } +} diff --git a/MediaBrowser.Common/Extensions/ShuffleExtensions.cs b/src/Jellyfin.Extensions/ShuffleExtensions.cs similarity index 90% rename from MediaBrowser.Common/Extensions/ShuffleExtensions.cs rename to src/Jellyfin.Extensions/ShuffleExtensions.cs index 459bec1105..4e481983f3 100644 --- a/MediaBrowser.Common/Extensions/ShuffleExtensions.cs +++ b/src/Jellyfin.Extensions/ShuffleExtensions.cs @@ -1,9 +1,7 @@ -#nullable enable - using System; using System.Collections.Generic; -namespace MediaBrowser.Common.Extensions +namespace Jellyfin.Extensions { /// <summary> /// Provides <c>Shuffle</c> extensions methods for <see cref="IList{T}" />. @@ -33,8 +31,7 @@ namespace MediaBrowser.Common.Extensions int n = list.Count; while (n > 1) { - n--; - int k = rng.Next(n + 1); + int k = rng.Next(n--); T value = list[k]; list[k] = list[n]; list[n] = value; diff --git a/src/Jellyfin.Extensions/SplitStringExtensions.cs b/src/Jellyfin.Extensions/SplitStringExtensions.cs new file mode 100644 index 0000000000..5fa5c01235 --- /dev/null +++ b/src/Jellyfin.Extensions/SplitStringExtensions.cs @@ -0,0 +1,115 @@ +/* +MIT License + +Copyright (c) 2019 Gérald Barré + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + */ + +// TODO: remove when analyzer is fixed: https://github.com/dotnet/roslyn-analyzers/issues/5158 +#pragma warning disable CA1034 // Nested types should not be visible + +using System; +using System.Diagnostics.Contracts; +using System.Runtime.InteropServices; + +namespace Jellyfin.Extensions +{ + /// <summary> + /// Extension class for splitting lines without unnecessary allocations. + /// </summary> + public static class SplitStringExtensions + { + /// <summary> + /// Creates a new string split enumerator. + /// </summary> + /// <param name="str">The string to split.</param> + /// <param name="separator">The separator to split on.</param> + /// <returns>The enumerator struct.</returns> + [Pure] + public static Enumerator SpanSplit(this string str, char separator) => new (str.AsSpan(), separator); + + /// <summary> + /// Creates a new span split enumerator. + /// </summary> + /// <param name="str">The span to split.</param> + /// <param name="separator">The separator to split on.</param> + /// <returns>The enumerator struct.</returns> + [Pure] + public static Enumerator Split(this ReadOnlySpan<char> str, char separator) => new (str, separator); + + /// <summary> + /// Provides an enumerator for the substrings seperated by the separator. + /// </summary> + [StructLayout(LayoutKind.Auto)] + public ref struct Enumerator + { + private readonly char _separator; + private ReadOnlySpan<char> _str; + + /// <summary> + /// Initializes a new instance of the <see cref="Enumerator"/> struct. + /// </summary> + /// <param name="str">The span to split.</param> + /// <param name="separator">The separator to split on.</param> + public Enumerator(ReadOnlySpan<char> str, char separator) + { + _str = str; + _separator = separator; + Current = default; + } + + /// <summary> + /// Gets a reference to the item at the current position of the enumerator. + /// </summary> + public ReadOnlySpan<char> Current { get; private set; } + + /// <summary> + /// Returns <c>this</c>. + /// </summary> + /// <returns><c>this</c>.</returns> + public readonly Enumerator GetEnumerator() => this; + + /// <summary> + /// Advances the enumerator to the next item. + /// </summary> + /// <returns><c>true</c> if there is a next element; otherwise <c>false</c>.</returns> + public bool MoveNext() + { + if (_str.Length == 0) + { + return false; + } + + var span = _str; + var index = span.IndexOf(_separator); + if (index == -1) + { + _str = ReadOnlySpan<char>.Empty; + Current = span; + return true; + } + + Current = span.Slice(0, index); + _str = span[(index + 1)..]; + return true; + } + } + } +} diff --git a/src/Jellyfin.Extensions/StreamExtensions.cs b/src/Jellyfin.Extensions/StreamExtensions.cs new file mode 100644 index 0000000000..9751d9d42a --- /dev/null +++ b/src/Jellyfin.Extensions/StreamExtensions.cs @@ -0,0 +1,63 @@ +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; + +namespace Jellyfin.Extensions +{ + /// <summary> + /// Class BaseExtensions. + /// </summary> + public static class StreamExtensions + { + /// <summary> + /// Reads all lines in the <see cref="Stream" />. + /// </summary> + /// <param name="stream">The <see cref="Stream" /> to read from.</param> + /// <returns>All lines in the stream.</returns> + public static string[] ReadAllLines(this Stream stream) + => ReadAllLines(stream, Encoding.UTF8); + + /// <summary> + /// Reads all lines in the <see cref="Stream" />. + /// </summary> + /// <param name="stream">The <see cref="Stream" /> to read from.</param> + /// <param name="encoding">The character encoding to use.</param> + /// <returns>All lines in the stream.</returns> + public static string[] ReadAllLines(this Stream stream, Encoding encoding) + { + using (StreamReader reader = new StreamReader(stream, encoding)) + { + return ReadAllLines(reader).ToArray(); + } + } + + /// <summary> + /// Reads all lines in the <see cref="TextReader" />. + /// </summary> + /// <param name="reader">The <see cref="TextReader" /> to read from.</param> + /// <returns>All lines in the stream.</returns> + public static IEnumerable<string> ReadAllLines(this TextReader reader) + { + string? line; + while ((line = reader.ReadLine()) != null) + { + yield return line; + } + } + + /// <summary> + /// Reads all lines in the <see cref="TextReader" />. + /// </summary> + /// <param name="reader">The <see cref="TextReader" /> to read from.</param> + /// <returns>All lines in the stream.</returns> + public static async IAsyncEnumerable<string> ReadAllLinesAsync(this TextReader reader) + { + string? line; + while ((line = await reader.ReadLineAsync().ConfigureAwait(false)) != null) + { + yield return line; + } + } + } +} diff --git a/src/Jellyfin.Extensions/StringBuilderExtensions.cs b/src/Jellyfin.Extensions/StringBuilderExtensions.cs new file mode 100644 index 0000000000..02ff7cc1f9 --- /dev/null +++ b/src/Jellyfin.Extensions/StringBuilderExtensions.cs @@ -0,0 +1,35 @@ +using System.Collections.Generic; +using System.Text; + +namespace Jellyfin.Extensions +{ + /// <summary> + /// Extension methods for the <see cref="StringBuilder"/> class. + /// </summary> + public static class StringBuilderExtensions + { + /// <summary> + /// Concatenates and appends the members of a collection in single quotes using the specified delimiter. + /// </summary> + /// <param name="builder">The string builder.</param> + /// <param name="delimiter">The character delimiter.</param> + /// <param name="values">The collection of strings to concatenate.</param> + /// <returns>The updated string builder.</returns> + public static StringBuilder AppendJoinInSingleQuotes(this StringBuilder builder, char delimiter, IReadOnlyList<string> values) + { + var len = values.Count; + for (var i = 0; i < len; i++) + { + builder.Append('\'') + .Append(values[i]) + .Append('\'') + .Append(delimiter); + } + + // remove last , + builder.Length--; + + return builder; + } + } +} diff --git a/src/Jellyfin.Extensions/StringExtensions.cs b/src/Jellyfin.Extensions/StringExtensions.cs new file mode 100644 index 0000000000..acc695ed2f --- /dev/null +++ b/src/Jellyfin.Extensions/StringExtensions.cs @@ -0,0 +1,31 @@ +using System; + +namespace Jellyfin.Extensions +{ + /// <summary> + /// Provides extensions methods for <see cref="string" />. + /// </summary> + public static class StringExtensions + { + /// <summary> + /// Counts the number of occurrences of [needle] in the string. + /// </summary> + /// <param name="value">The haystack to search in.</param> + /// <param name="needle">The character to search for.</param> + /// <returns>The number of occurrences of the [needle] character.</returns> + public static int Count(this ReadOnlySpan<char> value, char needle) + { + var count = 0; + var length = value.Length; + for (var i = 0; i < length; i++) + { + if (value[i] == needle) + { + count++; + } + } + + return count; + } + } +} diff --git a/tests/Jellyfin.Api.Tests/Auth/CustomAuthenticationHandlerTests.cs b/tests/Jellyfin.Api.Tests/Auth/CustomAuthenticationHandlerTests.cs index 4ea5094b66..de03aa5f5b 100644 --- a/tests/Jellyfin.Api.Tests/Auth/CustomAuthenticationHandlerTests.cs +++ b/tests/Jellyfin.Api.Tests/Auth/CustomAuthenticationHandlerTests.cs @@ -8,6 +8,7 @@ using Jellyfin.Api.Auth; using Jellyfin.Api.Constants; using Jellyfin.Data.Entities; using Jellyfin.Data.Enums; +using MediaBrowser.Controller.Authentication; using MediaBrowser.Controller.Net; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Http; @@ -68,19 +69,19 @@ namespace Jellyfin.Api.Tests.Auth } [Fact] - public async Task HandleAuthenticateAsyncShouldFailOnSecurityException() + public async Task HandleAuthenticateAsyncShouldProvideNoResultOnAuthenticationException() { var errorMessage = _fixture.Create<string>(); _jellyfinAuthServiceMock.Setup( a => a.Authenticate( It.IsAny<HttpRequest>())) - .Throws(new SecurityException(errorMessage)); + .Throws(new AuthenticationException(errorMessage)); var authenticateResult = await _sut.AuthenticateAsync(); Assert.False(authenticateResult.Succeeded); - Assert.Equal(errorMessage, authenticateResult.Failure.Message); + Assert.True(authenticateResult.None); } [Fact] @@ -99,7 +100,7 @@ namespace Jellyfin.Api.Tests.Auth var authorizationInfo = SetupUser(); var authenticateResult = await _sut.AuthenticateAsync(); - Assert.True(authenticateResult.Principal.HasClaim(ClaimTypes.Name, authorizationInfo.User.Username)); + Assert.True(authenticateResult.Principal?.HasClaim(ClaimTypes.Name, authorizationInfo.User.Username)); } [Theory] @@ -111,7 +112,7 @@ namespace Jellyfin.Api.Tests.Auth var authenticateResult = await _sut.AuthenticateAsync(); var expectedRole = authorizationInfo.User.HasPermission(PermissionKind.IsAdministrator) ? UserRoles.Administrator : UserRoles.User; - Assert.True(authenticateResult.Principal.HasClaim(ClaimTypes.Role, expectedRole)); + Assert.True(authenticateResult.Principal?.HasClaim(ClaimTypes.Role, expectedRole)); } [Fact] @@ -120,14 +121,17 @@ namespace Jellyfin.Api.Tests.Auth SetupUser(); var authenticatedResult = await _sut.AuthenticateAsync(); - Assert.Equal(_scheme.Name, authenticatedResult.Ticket.AuthenticationScheme); + Assert.Equal(_scheme.Name, authenticatedResult.Ticket?.AuthenticationScheme); } private AuthorizationInfo SetupUser(bool isAdmin = false) { var authorizationInfo = _fixture.Create<AuthorizationInfo>(); authorizationInfo.User = _fixture.Create<User>(); + authorizationInfo.User.AddDefaultPermissions(); + authorizationInfo.User.AddDefaultPreferences(); authorizationInfo.User.SetPermission(PermissionKind.IsAdministrator, isAdmin); + authorizationInfo.IsApiKey = false; _jellyfinAuthServiceMock.Setup( a => a.Authenticate( diff --git a/tests/Jellyfin.Api.Tests/Auth/LocalAccessPolicy/LocalAccessHandlerTests.cs b/tests/Jellyfin.Api.Tests/Auth/LocalAccessPolicy/LocalAccessHandlerTests.cs index 09ffa84689..5b3d784ffa 100644 --- a/tests/Jellyfin.Api.Tests/Auth/LocalAccessPolicy/LocalAccessHandlerTests.cs +++ b/tests/Jellyfin.Api.Tests/Auth/LocalAccessPolicy/LocalAccessHandlerTests.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using System.Net; using System.Threading.Tasks; using AutoFixture; using AutoFixture.AutoMoq; @@ -41,7 +42,7 @@ namespace Jellyfin.Api.Tests.Auth.LocalAccessPolicy public async Task LocalAccessOnly(bool isInLocalNetwork, bool shouldSucceed) { _networkManagerMock - .Setup(n => n.IsInLocalNetwork(It.IsAny<string>())) + .Setup(n => n.IsInLocalNetwork(It.IsAny<IPAddress>())) .Returns(isInLocalNetwork); TestHelpers.SetupConfigurationManager(_configurationManagerMock, true); diff --git a/tests/Jellyfin.Api.Tests/Controllers/DynamicHlsControllerTests.cs b/tests/Jellyfin.Api.Tests/Controllers/DynamicHlsControllerTests.cs new file mode 100644 index 0000000000..1170838156 --- /dev/null +++ b/tests/Jellyfin.Api.Tests/Controllers/DynamicHlsControllerTests.cs @@ -0,0 +1,58 @@ +using System; +using System.Collections.Generic; +using AutoFixture; +using AutoFixture.AutoMoq; +using Jellyfin.Api.Controllers; +using Jellyfin.Api.Helpers; +using Jellyfin.Api.Models.StreamingDtos; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.MediaEncoding; +using Moq; +using Xunit; + +namespace Jellyfin.Api.Tests.Controllers +{ + public class DynamicHlsControllerTests + { + [Theory] + [MemberData(nameof(GetSegmentLengths_Success_TestData))] + public void GetSegmentLengths_Success(long runtimeTicks, int segmentlength, double[] expected) + { + var res = DynamicHlsController.GetSegmentLengthsInternal(runtimeTicks, segmentlength); + Assert.Equal(expected.Length, res.Length); + for (int i = 0; i < expected.Length; i++) + { + Assert.Equal(expected[i], res[i]); + } + } + + public static IEnumerable<object[]> GetSegmentLengths_Success_TestData() + { + yield return new object[] { 0, 6, Array.Empty<double>() }; + yield return new object[] + { + TimeSpan.FromSeconds(3).Ticks, + 6, + new double[] { 3 } + }; + yield return new object[] + { + TimeSpan.FromSeconds(6).Ticks, + 6, + new double[] { 6 } + }; + yield return new object[] + { + TimeSpan.FromSeconds(3.3333333).Ticks, + 6, + new double[] { 3.3333333 } + }; + yield return new object[] + { + TimeSpan.FromSeconds(9.3333333).Ticks, + 6, + new double[] { 6, 3.3333333 } + }; + } + } +} diff --git a/tests/Jellyfin.Api.Tests/Helpers/RequestHelpersTests.cs b/tests/Jellyfin.Api.Tests/Helpers/RequestHelpersTests.cs new file mode 100644 index 0000000000..97e441b1dc --- /dev/null +++ b/tests/Jellyfin.Api.Tests/Helpers/RequestHelpersTests.cs @@ -0,0 +1,89 @@ +using System; +using System.Collections.Generic; +using Jellyfin.Api.Helpers; +using Jellyfin.Data.Enums; +using Xunit; + +namespace Jellyfin.Api.Tests.Helpers +{ + public static class RequestHelpersTests + { + [Theory] + [MemberData(nameof(GetOrderBy_Success_TestData))] + public static void GetOrderBy_Success(IReadOnlyList<string> sortBy, IReadOnlyList<SortOrder> requestedSortOrder, (string, SortOrder)[] expected) + { + Assert.Equal(expected, RequestHelpers.GetOrderBy(sortBy, requestedSortOrder)); + } + + public static IEnumerable<object[]> GetOrderBy_Success_TestData() + { + yield return new object[] + { + Array.Empty<string>(), + Array.Empty<SortOrder>(), + Array.Empty<(string, SortOrder)>() + }; + yield return new object[] + { + new string[] + { + "IsFavoriteOrLiked", + "Random" + }, + Array.Empty<SortOrder>(), + new (string, SortOrder)[] + { + ("IsFavoriteOrLiked", SortOrder.Ascending), + ("Random", SortOrder.Ascending), + } + }; + yield return new object[] + { + new string[] + { + "SortName", + "ProductionYear" + }, + new SortOrder[] + { + SortOrder.Descending + }, + new (string, SortOrder)[] + { + ("SortName", SortOrder.Descending), + ("ProductionYear", SortOrder.Descending), + } + }; + } + + [Fact] + public static void GetItemTypeStrings_Empty_Empty() + { + Assert.Empty(RequestHelpers.GetItemTypeStrings(Array.Empty<BaseItemKind>())); + } + + [Fact] + public static void GetItemTypeStrings_Valid_Success() + { + BaseItemKind[] input = + { + BaseItemKind.AggregateFolder, + BaseItemKind.Audio, + BaseItemKind.BasePluginFolder, + BaseItemKind.CollectionFolder + }; + + string[] expected = + { + "AggregateFolder", + "Audio", + "BasePluginFolder", + "CollectionFolder" + }; + + var res = RequestHelpers.GetItemTypeStrings(input); + + Assert.Equal(expected, res); + } + } +} diff --git a/tests/Jellyfin.Api.Tests/Jellyfin.Api.Tests.csproj b/tests/Jellyfin.Api.Tests/Jellyfin.Api.Tests.csproj index aae436fb73..0c36e81cca 100644 --- a/tests/Jellyfin.Api.Tests/Jellyfin.Api.Tests.csproj +++ b/tests/Jellyfin.Api.Tests/Jellyfin.Api.Tests.csproj @@ -6,39 +6,34 @@ </PropertyGroup> <PropertyGroup> - <TargetFramework>netcoreapp3.1</TargetFramework> + <TargetFramework>net5.0</TargetFramework> <IsPackable>false</IsPackable> - <TreatWarningsAsErrors>true</TreatWarningsAsErrors> - <Nullable>enable</Nullable> + <CodeAnalysisRuleSet>../jellyfin-tests.ruleset</CodeAnalysisRuleSet> </PropertyGroup> <ItemGroup> - <PackageReference Include="AutoFixture" Version="4.13.0" /> - <PackageReference Include="AutoFixture.AutoMoq" Version="4.13.0" /> - <PackageReference Include="AutoFixture.Xunit2" Version="4.13.0" /> - <PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="3.1.9" /> - <PackageReference Include="Microsoft.Extensions.Options" Version="3.1.9" /> - <PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.7.1" /> + <PackageReference Include="AutoFixture" Version="4.17.0" /> + <PackageReference Include="AutoFixture.AutoMoq" Version="4.17.0" /> + <PackageReference Include="AutoFixture.Xunit2" Version="4.17.0" /> + <PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="5.0.9" /> + <PackageReference Include="Microsoft.Extensions.Options" Version="5.0.0" /> + <PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.11.0" /> <PackageReference Include="xunit" Version="2.4.1" /> <PackageReference Include="xunit.runner.visualstudio" Version="2.4.3" /> - <PackageReference Include="coverlet.collector" Version="1.3.0" /> - <PackageReference Include="Moq" Version="4.14.6" /> + <PackageReference Include="coverlet.collector" Version="3.1.0" /> + <PackageReference Include="Moq" Version="4.16.1" /> </ItemGroup> <!-- Code Analyzers --> <ItemGroup Condition=" '$(Configuration)' == 'Debug' "> - <PackageReference Include="Microsoft.CodeAnalysis.FxCopAnalyzers" Version="2.9.8" PrivateAssets="All" /> <PackageReference Include="SerilogAnalyzer" Version="0.15.0" PrivateAssets="All" /> <PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="All" /> <PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" PrivateAssets="All" /> </ItemGroup> <ItemGroup> - <ProjectReference Include="..\..\Jellyfin.Server\Jellyfin.Server.csproj" /> + <ProjectReference Include="../../Jellyfin.Api/Jellyfin.Api.csproj" /> + <ProjectReference Include="../../Jellyfin.Server.Implementations/Jellyfin.Server.Implementations.csproj" /> </ItemGroup> - <PropertyGroup Condition=" '$(Configuration)' == 'Debug' "> - <CodeAnalysisRuleSet>../jellyfin-tests.ruleset</CodeAnalysisRuleSet> - </PropertyGroup> - </Project> diff --git a/tests/Jellyfin.Api.Tests/ModelBinders/CommaDelimitedArrayModelBinderTests.cs b/tests/Jellyfin.Api.Tests/ModelBinders/CommaDelimitedArrayModelBinderTests.cs index 89c7d62f79..3ae6ae5bdd 100644 --- a/tests/Jellyfin.Api.Tests/ModelBinders/CommaDelimitedArrayModelBinderTests.cs +++ b/tests/Jellyfin.Api.Tests/ModelBinders/CommaDelimitedArrayModelBinderTests.cs @@ -5,6 +5,7 @@ using System.Threading.Tasks; using Jellyfin.Api.ModelBinders; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc.ModelBinding; +using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Primitives; using Moq; using Xunit; @@ -17,11 +18,11 @@ namespace Jellyfin.Api.Tests.ModelBinders public async Task BindModelAsync_CorrectlyBindsValidCommaDelimitedStringArrayQuery() { var queryParamName = "test"; - var queryParamValues = new[] { "lol", "xd" }; + IReadOnlyList<string> queryParamValues = new[] { "lol", "xd" }; var queryParamString = "lol,xd"; var queryParamType = typeof(string[]); - var modelBinder = new CommaDelimitedArrayModelBinder(); + var modelBinder = new CommaDelimitedArrayModelBinder(new NullLogger<CommaDelimitedArrayModelBinder>()); var valueProvider = new QueryStringValueProvider( new BindingSource(string.Empty, string.Empty, false, false), new QueryCollection(new Dictionary<string, StringValues> { { queryParamName, new StringValues(queryParamString) } }), @@ -35,18 +36,18 @@ namespace Jellyfin.Api.Tests.ModelBinders await modelBinder.BindModelAsync(bindingContextMock.Object); Assert.True(bindingContextMock.Object.Result.IsModelSet); - Assert.Equal((string[])bindingContextMock.Object.Result.Model, queryParamValues); + Assert.Equal((IReadOnlyList<string>?)bindingContextMock.Object?.Result.Model, queryParamValues); } [Fact] public async Task BindModelAsync_CorrectlyBindsValidCommaDelimitedIntArrayQuery() { var queryParamName = "test"; - var queryParamValues = new[] { 42, 0 }; + IReadOnlyList<int> queryParamValues = new[] { 42, 0 }; var queryParamString = "42,0"; var queryParamType = typeof(int[]); - var modelBinder = new CommaDelimitedArrayModelBinder(); + var modelBinder = new CommaDelimitedArrayModelBinder(new NullLogger<CommaDelimitedArrayModelBinder>()); var valueProvider = new QueryStringValueProvider( new BindingSource(string.Empty, string.Empty, false, false), new QueryCollection(new Dictionary<string, StringValues> { { queryParamName, new StringValues(queryParamString) } }), @@ -60,18 +61,18 @@ namespace Jellyfin.Api.Tests.ModelBinders await modelBinder.BindModelAsync(bindingContextMock.Object); Assert.True(bindingContextMock.Object.Result.IsModelSet); - Assert.Equal((int[])bindingContextMock.Object.Result.Model, queryParamValues); + Assert.Equal((IReadOnlyList<int>?)bindingContextMock.Object.Result.Model, queryParamValues); } [Fact] public async Task BindModelAsync_CorrectlyBindsValidCommaDelimitedEnumArrayQuery() { var queryParamName = "test"; - var queryParamValues = new[] { TestType.How, TestType.Much }; + IReadOnlyList<TestType> queryParamValues = new[] { TestType.How, TestType.Much }; var queryParamString = "How,Much"; var queryParamType = typeof(TestType[]); - var modelBinder = new CommaDelimitedArrayModelBinder(); + var modelBinder = new CommaDelimitedArrayModelBinder(new NullLogger<CommaDelimitedArrayModelBinder>()); var valueProvider = new QueryStringValueProvider( new BindingSource(string.Empty, string.Empty, false, false), new QueryCollection(new Dictionary<string, StringValues> { { queryParamName, new StringValues(queryParamString) } }), @@ -85,18 +86,18 @@ namespace Jellyfin.Api.Tests.ModelBinders await modelBinder.BindModelAsync(bindingContextMock.Object); Assert.True(bindingContextMock.Object.Result.IsModelSet); - Assert.Equal((TestType[])bindingContextMock.Object.Result.Model, queryParamValues); + Assert.Equal((IReadOnlyList<TestType>?)bindingContextMock.Object.Result.Model, queryParamValues); } [Fact] public async Task BindModelAsync_CorrectlyBindsValidCommaDelimitedEnumArrayQueryWithDoubleCommas() { var queryParamName = "test"; - var queryParamValues = new[] { TestType.How, TestType.Much }; + IReadOnlyList<TestType> queryParamValues = new[] { TestType.How, TestType.Much }; var queryParamString = "How,,Much"; var queryParamType = typeof(TestType[]); - var modelBinder = new CommaDelimitedArrayModelBinder(); + var modelBinder = new CommaDelimitedArrayModelBinder(new NullLogger<CommaDelimitedArrayModelBinder>()); var valueProvider = new QueryStringValueProvider( new BindingSource(string.Empty, string.Empty, false, false), new QueryCollection(new Dictionary<string, StringValues> { { queryParamName, new StringValues(queryParamString) } }), @@ -110,19 +111,19 @@ namespace Jellyfin.Api.Tests.ModelBinders await modelBinder.BindModelAsync(bindingContextMock.Object); Assert.True(bindingContextMock.Object.Result.IsModelSet); - Assert.Equal((TestType[])bindingContextMock.Object.Result.Model, queryParamValues); + Assert.Equal((IReadOnlyList<TestType>?)bindingContextMock.Object.Result.Model, queryParamValues); } [Fact] public async Task BindModelAsync_CorrectlyBindsValidEnumArrayQuery() { var queryParamName = "test"; - var queryParamValues = new[] { TestType.How, TestType.Much }; + IReadOnlyList<TestType> queryParamValues = new[] { TestType.How, TestType.Much }; var queryParamString1 = "How"; var queryParamString2 = "Much"; var queryParamType = typeof(TestType[]); - var modelBinder = new CommaDelimitedArrayModelBinder(); + var modelBinder = new CommaDelimitedArrayModelBinder(new NullLogger<CommaDelimitedArrayModelBinder>()); var valueProvider = new QueryStringValueProvider( new BindingSource(string.Empty, string.Empty, false, false), @@ -140,17 +141,17 @@ namespace Jellyfin.Api.Tests.ModelBinders await modelBinder.BindModelAsync(bindingContextMock.Object); Assert.True(bindingContextMock.Object.Result.IsModelSet); - Assert.Equal((TestType[])bindingContextMock.Object.Result.Model, queryParamValues); + Assert.Equal((IReadOnlyList<TestType>?)bindingContextMock.Object.Result.Model, queryParamValues); } [Fact] public async Task BindModelAsync_CorrectlyBindsEmptyEnumArrayQuery() { var queryParamName = "test"; - var queryParamValues = Array.Empty<TestType>(); + IReadOnlyList<TestType> queryParamValues = Array.Empty<TestType>(); var queryParamType = typeof(TestType[]); - var modelBinder = new CommaDelimitedArrayModelBinder(); + var modelBinder = new CommaDelimitedArrayModelBinder(new NullLogger<CommaDelimitedArrayModelBinder>()); var valueProvider = new QueryStringValueProvider( new BindingSource(string.Empty, string.Empty, false, false), @@ -168,17 +169,17 @@ namespace Jellyfin.Api.Tests.ModelBinders await modelBinder.BindModelAsync(bindingContextMock.Object); Assert.True(bindingContextMock.Object.Result.IsModelSet); - Assert.Equal((TestType[])bindingContextMock.Object.Result.Model, queryParamValues); + Assert.Equal((IReadOnlyList<TestType>?)bindingContextMock.Object.Result.Model, queryParamValues); } [Fact] - public async Task BindModelAsync_ThrowsIfCommaDelimitedEnumArrayQueryIsInvalid() + public async Task BindModelAsync_EnumArrayQuery_BindValidOnly() { var queryParamName = "test"; var queryParamString = "🔥,😢"; - var queryParamType = typeof(TestType[]); + var queryParamType = typeof(IReadOnlyList<TestType>); - var modelBinder = new CommaDelimitedArrayModelBinder(); + var modelBinder = new CommaDelimitedArrayModelBinder(new NullLogger<CommaDelimitedArrayModelBinder>()); var valueProvider = new QueryStringValueProvider( new BindingSource(string.Empty, string.Empty, false, false), new QueryCollection(new Dictionary<string, StringValues> { { queryParamName, new StringValues(queryParamString) } }), @@ -189,20 +190,20 @@ namespace Jellyfin.Api.Tests.ModelBinders bindingContextMock.Setup(b => b.ModelType).Returns(queryParamType); bindingContextMock.SetupProperty(b => b.Result); - Func<Task> act = async () => await modelBinder.BindModelAsync(bindingContextMock.Object); - - await Assert.ThrowsAsync<FormatException>(act); + await modelBinder.BindModelAsync(bindingContextMock.Object); + Assert.True(bindingContextMock.Object.Result.IsModelSet); + Assert.Empty((IReadOnlyList<TestType>?)bindingContextMock.Object.Result.Model); } [Fact] - public async Task BindModelAsync_ThrowsIfCommaDelimitedEnumArrayQueryIsInvalid2() + public async Task BindModelAsync_EnumArrayQuery_BindValidOnly_2() { var queryParamName = "test"; var queryParamString1 = "How"; var queryParamString2 = "😱"; - var queryParamType = typeof(TestType[]); + var queryParamType = typeof(IReadOnlyList<TestType>); - var modelBinder = new CommaDelimitedArrayModelBinder(); + var modelBinder = new CommaDelimitedArrayModelBinder(new NullLogger<CommaDelimitedArrayModelBinder>()); var valueProvider = new QueryStringValueProvider( new BindingSource(string.Empty, string.Empty, false, false), @@ -217,9 +218,9 @@ namespace Jellyfin.Api.Tests.ModelBinders bindingContextMock.Setup(b => b.ModelType).Returns(queryParamType); bindingContextMock.SetupProperty(b => b.Result); - Func<Task> act = async () => await modelBinder.BindModelAsync(bindingContextMock.Object); - - await Assert.ThrowsAsync<FormatException>(act); + await modelBinder.BindModelAsync(bindingContextMock.Object); + Assert.True(bindingContextMock.Object.Result.IsModelSet); + Assert.Single((IReadOnlyList<TestType>?)bindingContextMock.Object.Result.Model); } } } diff --git a/tests/Jellyfin.Api.Tests/ModelBinders/PipeDelimitedArrayModelBinderTests.cs b/tests/Jellyfin.Api.Tests/ModelBinders/PipeDelimitedArrayModelBinderTests.cs new file mode 100644 index 0000000000..938d19a154 --- /dev/null +++ b/tests/Jellyfin.Api.Tests/ModelBinders/PipeDelimitedArrayModelBinderTests.cs @@ -0,0 +1,226 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Threading.Tasks; +using Jellyfin.Api.ModelBinders; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc.ModelBinding; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Primitives; +using Moq; +using Xunit; + +namespace Jellyfin.Api.Tests.ModelBinders +{ + public sealed class PipeDelimitedArrayModelBinderTests + { + [Fact] + public async Task BindModelAsync_CorrectlyBindsValidPipeDelimitedStringArrayQuery() + { + var queryParamName = "test"; + IReadOnlyList<string> queryParamValues = new[] { "lol", "xd" }; + var queryParamString = "lol|xd"; + var queryParamType = typeof(string[]); + + var modelBinder = new PipeDelimitedArrayModelBinder(new NullLogger<PipeDelimitedArrayModelBinder>()); + var valueProvider = new QueryStringValueProvider( + new BindingSource(string.Empty, string.Empty, false, false), + new QueryCollection(new Dictionary<string, StringValues> { { queryParamName, new StringValues(queryParamString) } }), + CultureInfo.InvariantCulture); + var bindingContextMock = new Mock<ModelBindingContext>(); + bindingContextMock.Setup(b => b.ValueProvider).Returns(valueProvider); + bindingContextMock.Setup(b => b.ModelName).Returns(queryParamName); + bindingContextMock.Setup(b => b.ModelType).Returns(queryParamType); + bindingContextMock.SetupProperty(b => b.Result); + + await modelBinder.BindModelAsync(bindingContextMock.Object); + + Assert.True(bindingContextMock.Object.Result.IsModelSet); + Assert.Equal((IReadOnlyList<string>?)bindingContextMock.Object?.Result.Model, queryParamValues); + } + + [Fact] + public async Task BindModelAsync_CorrectlyBindsValidDelimitedIntArrayQuery() + { + var queryParamName = "test"; + IReadOnlyList<int> queryParamValues = new[] { 42, 0 }; + var queryParamString = "42|0"; + var queryParamType = typeof(int[]); + + var modelBinder = new PipeDelimitedArrayModelBinder(new NullLogger<PipeDelimitedArrayModelBinder>()); + var valueProvider = new QueryStringValueProvider( + new BindingSource(string.Empty, string.Empty, false, false), + new QueryCollection(new Dictionary<string, StringValues> { { queryParamName, new StringValues(queryParamString) } }), + CultureInfo.InvariantCulture); + var bindingContextMock = new Mock<ModelBindingContext>(); + bindingContextMock.Setup(b => b.ValueProvider).Returns(valueProvider); + bindingContextMock.Setup(b => b.ModelName).Returns(queryParamName); + bindingContextMock.Setup(b => b.ModelType).Returns(queryParamType); + bindingContextMock.SetupProperty(b => b.Result); + + await modelBinder.BindModelAsync(bindingContextMock.Object); + + Assert.True(bindingContextMock.Object.Result.IsModelSet); + Assert.Equal((IReadOnlyList<int>?)bindingContextMock.Object.Result.Model, queryParamValues); + } + + [Fact] + public async Task BindModelAsync_CorrectlyBindsValidPipeDelimitedEnumArrayQuery() + { + var queryParamName = "test"; + IReadOnlyList<TestType> queryParamValues = new[] { TestType.How, TestType.Much }; + var queryParamString = "How|Much"; + var queryParamType = typeof(TestType[]); + + var modelBinder = new PipeDelimitedArrayModelBinder(new NullLogger<PipeDelimitedArrayModelBinder>()); + var valueProvider = new QueryStringValueProvider( + new BindingSource(string.Empty, string.Empty, false, false), + new QueryCollection(new Dictionary<string, StringValues> { { queryParamName, new StringValues(queryParamString) } }), + CultureInfo.InvariantCulture); + var bindingContextMock = new Mock<ModelBindingContext>(); + bindingContextMock.Setup(b => b.ValueProvider).Returns(valueProvider); + bindingContextMock.Setup(b => b.ModelName).Returns(queryParamName); + bindingContextMock.Setup(b => b.ModelType).Returns(queryParamType); + bindingContextMock.SetupProperty(b => b.Result); + + await modelBinder.BindModelAsync(bindingContextMock.Object); + + Assert.True(bindingContextMock.Object.Result.IsModelSet); + Assert.Equal((IReadOnlyList<TestType>?)bindingContextMock.Object.Result.Model, queryParamValues); + } + + [Fact] + public async Task BindModelAsync_CorrectlyBindsValidPipeDelimitedEnumArrayQueryWithDoublePipes() + { + var queryParamName = "test"; + IReadOnlyList<TestType> queryParamValues = new[] { TestType.How, TestType.Much }; + var queryParamString = "How||Much"; + var queryParamType = typeof(TestType[]); + + var modelBinder = new PipeDelimitedArrayModelBinder(new NullLogger<PipeDelimitedArrayModelBinder>()); + var valueProvider = new QueryStringValueProvider( + new BindingSource(string.Empty, string.Empty, false, false), + new QueryCollection(new Dictionary<string, StringValues> { { queryParamName, new StringValues(queryParamString) } }), + CultureInfo.InvariantCulture); + var bindingContextMock = new Mock<ModelBindingContext>(); + bindingContextMock.Setup(b => b.ValueProvider).Returns(valueProvider); + bindingContextMock.Setup(b => b.ModelName).Returns(queryParamName); + bindingContextMock.Setup(b => b.ModelType).Returns(queryParamType); + bindingContextMock.SetupProperty(b => b.Result); + + await modelBinder.BindModelAsync(bindingContextMock.Object); + + Assert.True(bindingContextMock.Object.Result.IsModelSet); + Assert.Equal((IReadOnlyList<TestType>?)bindingContextMock.Object.Result.Model, queryParamValues); + } + + [Fact] + public async Task BindModelAsync_CorrectlyBindsValidEnumArrayQuery() + { + var queryParamName = "test"; + IReadOnlyList<TestType> queryParamValues = new[] { TestType.How, TestType.Much }; + var queryParamString1 = "How"; + var queryParamString2 = "Much"; + var queryParamType = typeof(TestType[]); + + var modelBinder = new PipeDelimitedArrayModelBinder(new NullLogger<PipeDelimitedArrayModelBinder>()); + + var valueProvider = new QueryStringValueProvider( + new BindingSource(string.Empty, string.Empty, false, false), + new QueryCollection(new Dictionary<string, StringValues> + { + { queryParamName, new StringValues(new[] { queryParamString1, queryParamString2 }) }, + }), + CultureInfo.InvariantCulture); + var bindingContextMock = new Mock<ModelBindingContext>(); + bindingContextMock.Setup(b => b.ValueProvider).Returns(valueProvider); + bindingContextMock.Setup(b => b.ModelName).Returns(queryParamName); + bindingContextMock.Setup(b => b.ModelType).Returns(queryParamType); + bindingContextMock.SetupProperty(b => b.Result); + + await modelBinder.BindModelAsync(bindingContextMock.Object); + + Assert.True(bindingContextMock.Object.Result.IsModelSet); + Assert.Equal((IReadOnlyList<TestType>?)bindingContextMock.Object.Result.Model, queryParamValues); + } + + [Fact] + public async Task BindModelAsync_CorrectlyBindsEmptyEnumArrayQuery() + { + var queryParamName = "test"; + IReadOnlyList<TestType> queryParamValues = Array.Empty<TestType>(); + var queryParamType = typeof(TestType[]); + + var modelBinder = new PipeDelimitedArrayModelBinder(new NullLogger<PipeDelimitedArrayModelBinder>()); + + var valueProvider = new QueryStringValueProvider( + new BindingSource(string.Empty, string.Empty, false, false), + new QueryCollection(new Dictionary<string, StringValues> + { + { queryParamName, new StringValues(value: null) }, + }), + CultureInfo.InvariantCulture); + var bindingContextMock = new Mock<ModelBindingContext>(); + bindingContextMock.Setup(b => b.ValueProvider).Returns(valueProvider); + bindingContextMock.Setup(b => b.ModelName).Returns(queryParamName); + bindingContextMock.Setup(b => b.ModelType).Returns(queryParamType); + bindingContextMock.SetupProperty(b => b.Result); + + await modelBinder.BindModelAsync(bindingContextMock.Object); + + Assert.True(bindingContextMock.Object.Result.IsModelSet); + Assert.Equal((IReadOnlyList<TestType>?)bindingContextMock.Object.Result.Model, queryParamValues); + } + + [Fact] + public async Task BindModelAsync_EnumArrayQuery_BindValidOnly() + { + var queryParamName = "test"; + var queryParamString = "🔥|😢"; + var queryParamType = typeof(IReadOnlyList<TestType>); + + var modelBinder = new PipeDelimitedArrayModelBinder(new NullLogger<PipeDelimitedArrayModelBinder>()); + var valueProvider = new QueryStringValueProvider( + new BindingSource(string.Empty, string.Empty, false, false), + new QueryCollection(new Dictionary<string, StringValues> { { queryParamName, new StringValues(queryParamString) } }), + CultureInfo.InvariantCulture); + var bindingContextMock = new Mock<ModelBindingContext>(); + bindingContextMock.Setup(b => b.ValueProvider).Returns(valueProvider); + bindingContextMock.Setup(b => b.ModelName).Returns(queryParamName); + bindingContextMock.Setup(b => b.ModelType).Returns(queryParamType); + bindingContextMock.SetupProperty(b => b.Result); + + await modelBinder.BindModelAsync(bindingContextMock.Object); + Assert.True(bindingContextMock.Object.Result.IsModelSet); + Assert.Empty((IReadOnlyList<TestType>?)bindingContextMock.Object.Result.Model); + } + + [Fact] + public async Task BindModelAsync_EnumArrayQuery_BindValidOnly_2() + { + var queryParamName = "test"; + var queryParamString1 = "How"; + var queryParamString2 = "😱"; + var queryParamType = typeof(IReadOnlyList<TestType>); + + var modelBinder = new PipeDelimitedArrayModelBinder(new NullLogger<PipeDelimitedArrayModelBinder>()); + + var valueProvider = new QueryStringValueProvider( + new BindingSource(string.Empty, string.Empty, false, false), + new QueryCollection(new Dictionary<string, StringValues> + { + { queryParamName, new StringValues(new[] { queryParamString1, queryParamString2 }) }, + }), + CultureInfo.InvariantCulture); + var bindingContextMock = new Mock<ModelBindingContext>(); + bindingContextMock.Setup(b => b.ValueProvider).Returns(valueProvider); + bindingContextMock.Setup(b => b.ModelName).Returns(queryParamName); + bindingContextMock.Setup(b => b.ModelType).Returns(queryParamType); + bindingContextMock.SetupProperty(b => b.Result); + + await modelBinder.BindModelAsync(bindingContextMock.Object); + Assert.True(bindingContextMock.Object.Result.IsModelSet); + Assert.Single((IReadOnlyList<TestType>?)bindingContextMock.Object.Result.Model); + } + } +} diff --git a/tests/Jellyfin.Api.Tests/ModelBinders/TestType.cs b/tests/Jellyfin.Api.Tests/ModelBinders/TestType.cs index 544a74637a..92c534eaea 100644 --- a/tests/Jellyfin.Api.Tests/ModelBinders/TestType.cs +++ b/tests/Jellyfin.Api.Tests/ModelBinders/TestType.cs @@ -1,17 +1,11 @@ -using System; -using System.Collections.Generic; -using System.Text; - namespace Jellyfin.Api.Tests.ModelBinders { public enum TestType { -#pragma warning disable SA1602 // Enumeration items should be documented How, Much, Is, The, Fish -#pragma warning restore SA1602 // Enumeration items should be documented } } diff --git a/tests/Jellyfin.Api.Tests/TestHelpers.cs b/tests/Jellyfin.Api.Tests/TestHelpers.cs index a4dd4e4092..f9bca41465 100644 --- a/tests/Jellyfin.Api.Tests/TestHelpers.cs +++ b/tests/Jellyfin.Api.Tests/TestHelpers.cs @@ -26,8 +26,11 @@ namespace Jellyfin.Api.Tests { var user = new User( "jellyfin", - typeof(DefaultAuthenticationProvider).FullName, - typeof(DefaultPasswordResetProvider).FullName); + typeof(DefaultAuthenticationProvider).FullName!, + typeof(DefaultPasswordResetProvider).FullName!); + + user.AddDefaultPermissions(); + user.AddDefaultPreferences(); // Set administrator flag. user.SetPermission(PermissionKind.IsAdministrator, role.Equals(UserRoles.Administrator, StringComparison.OrdinalIgnoreCase)); @@ -45,7 +48,7 @@ namespace Jellyfin.Api.Tests { new Claim(ClaimTypes.Role, role), new Claim(ClaimTypes.Name, "jellyfin"), - new Claim(InternalClaimTypes.UserId, Guid.Empty.ToString("N", CultureInfo.InvariantCulture)), + new Claim(InternalClaimTypes.UserId, Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture)), new Claim(InternalClaimTypes.DeviceId, Guid.Empty.ToString("N", CultureInfo.InvariantCulture)), new Claim(InternalClaimTypes.Device, "test"), new Claim(InternalClaimTypes.Client, "test"), @@ -60,7 +63,7 @@ namespace Jellyfin.Api.Tests .Returns(user); httpContextAccessorMock - .Setup(h => h.HttpContext.Connection.RemoteIpAddress) + .Setup(h => h.HttpContext!.Connection.RemoteIpAddress) .Returns(new IPAddress(0)); return new ClaimsPrincipal(identity); diff --git a/tests/Jellyfin.Common.Tests/Crc32Tests.cs b/tests/Jellyfin.Common.Tests/Crc32Tests.cs new file mode 100644 index 0000000000..e95a2867fd --- /dev/null +++ b/tests/Jellyfin.Common.Tests/Crc32Tests.cs @@ -0,0 +1,33 @@ +using System; +using System.Text; +using MediaBrowser.Common; +using Xunit; + +namespace Jellyfin.Common.Tests +{ + public static class Crc32Tests + { + [Fact] + public static void Compute_Empty_Zero() + { + Assert.Equal<uint>(0, Crc32.Compute(Array.Empty<byte>())); + } + + [Theory] + [InlineData(0x414fa339, "The quick brown fox jumps over the lazy dog")] + public static void Compute_Valid_Success(uint expected, string data) + { + Assert.Equal(expected, Crc32.Compute(Encoding.UTF8.GetBytes(data))); + } + + [Theory] + [InlineData(0x414fa339, "54686520717569636B2062726F776E20666F78206A756D7073206F76657220746865206C617A7920646F67")] + [InlineData(0x190a55ad, "0000000000000000000000000000000000000000000000000000000000000000")] + [InlineData(0xff6cab0b, "FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF")] + [InlineData(0x91267e8a, "000102030405060708090A0B0C0D0E0F101112131415161718191A1B1C1D1E1F")] + public static void Compute_ValidHex_Success(uint expected, string data) + { + Assert.Equal(expected, Crc32.Compute(Convert.FromHexString(data))); + } + } +} diff --git a/tests/Jellyfin.Common.Tests/Cryptography/PasswordHashTests.cs b/tests/Jellyfin.Common.Tests/Cryptography/PasswordHashTests.cs new file mode 100644 index 0000000000..18d3f97638 --- /dev/null +++ b/tests/Jellyfin.Common.Tests/Cryptography/PasswordHashTests.cs @@ -0,0 +1,185 @@ +using System; +using System.Collections.Generic; +using MediaBrowser.Common.Cryptography; +using Xunit; + +namespace Jellyfin.Common.Tests.Cryptography +{ + public static class PasswordHashTests + { + [Fact] + public static void Ctor_Null_ThrowsArgumentNullException() + { + Assert.Throws<ArgumentNullException>(() => new PasswordHash(null!, Array.Empty<byte>())); + } + + [Fact] + public static void Ctor_Empty_ThrowsArgumentException() + { + Assert.Throws<ArgumentException>(() => new PasswordHash(string.Empty, Array.Empty<byte>())); + } + + public static IEnumerable<object[]> Parse_Valid_TestData() + { + // Id + yield return new object[] + { + "$PBKDF2", + new PasswordHash("PBKDF2", Array.Empty<byte>()) + }; + + // Id + parameter + yield return new object[] + { + "$PBKDF2$iterations=1000", + new PasswordHash( + "PBKDF2", + Array.Empty<byte>(), + Array.Empty<byte>(), + new Dictionary<string, string>() + { + { "iterations", "1000" }, + }) + }; + + // Id + parameters + yield return new object[] + { + "$PBKDF2$iterations=1000,m=120", + new PasswordHash( + "PBKDF2", + Array.Empty<byte>(), + Array.Empty<byte>(), + new Dictionary<string, string>() + { + { "iterations", "1000" }, + { "m", "120" } + }) + }; + + // Id + hash + yield return new object[] + { + "$PBKDF2$62FBA410AFCA5B4475F35137AB2E8596B127E4D927BA23F6CC05C067E897042D", + new PasswordHash( + "PBKDF2", + Convert.FromHexString("62FBA410AFCA5B4475F35137AB2E8596B127E4D927BA23F6CC05C067E897042D"), + Array.Empty<byte>(), + new Dictionary<string, string>()) + }; + + // Id + salt + hash + yield return new object[] + { + "$PBKDF2$69F420$62FBA410AFCA5B4475F35137AB2E8596B127E4D927BA23F6CC05C067E897042D", + new PasswordHash( + "PBKDF2", + Convert.FromHexString("62FBA410AFCA5B4475F35137AB2E8596B127E4D927BA23F6CC05C067E897042D"), + Convert.FromHexString("69F420"), + new Dictionary<string, string>()) + }; + + // Id + parameter + hash + yield return new object[] + { + "$PBKDF2$iterations=1000$62FBA410AFCA5B4475F35137AB2E8596B127E4D927BA23F6CC05C067E897042D", + new PasswordHash( + "PBKDF2", + Convert.FromHexString("62FBA410AFCA5B4475F35137AB2E8596B127E4D927BA23F6CC05C067E897042D"), + Array.Empty<byte>(), + new Dictionary<string, string>() + { + { "iterations", "1000" } + }) + }; + + // Id + parameters + hash + yield return new object[] + { + "$PBKDF2$iterations=1000,m=120$62FBA410AFCA5B4475F35137AB2E8596B127E4D927BA23F6CC05C067E897042D", + new PasswordHash( + "PBKDF2", + Convert.FromHexString("62FBA410AFCA5B4475F35137AB2E8596B127E4D927BA23F6CC05C067E897042D"), + Array.Empty<byte>(), + new Dictionary<string, string>() + { + { "iterations", "1000" }, + { "m", "120" } + }) + }; + + // Id + parameters + salt + hash + yield return new object[] + { + "$PBKDF2$iterations=1000,m=120$69F420$62FBA410AFCA5B4475F35137AB2E8596B127E4D927BA23F6CC05C067E897042D", + new PasswordHash( + "PBKDF2", + Convert.FromHexString("62FBA410AFCA5B4475F35137AB2E8596B127E4D927BA23F6CC05C067E897042D"), + Convert.FromHexString("69F420"), + new Dictionary<string, string>() + { + { "iterations", "1000" }, + { "m", "120" } + }) + }; + } + + [Theory] + [MemberData(nameof(Parse_Valid_TestData))] + public static void Parse_Valid_Success(string passwordHashString, PasswordHash expected) + { + var passwordHash = PasswordHash.Parse(passwordHashString); + Assert.Equal(expected.Id, passwordHash.Id); + Assert.Equal(expected.Parameters, passwordHash.Parameters); + Assert.Equal(expected.Salt.ToArray(), passwordHash.Salt.ToArray()); + Assert.Equal(expected.Hash.ToArray(), passwordHash.Hash.ToArray()); + Assert.Equal(expected.ToString(), passwordHash.ToString()); + } + + [Theory] + [InlineData("$PBKDF2")] + [InlineData("$PBKDF2$62FBA410AFCA5B4475F35137AB2E8596B127E4D927BA23F6CC05C067E897042D")] + [InlineData("$PBKDF2$69F420$62FBA410AFCA5B4475F35137AB2E8596B127E4D927BA23F6CC05C067E897042D")] + [InlineData("$PBKDF2$iterations=1000$62FBA410AFCA5B4475F35137AB2E8596B127E4D927BA23F6CC05C067E897042D")] + [InlineData("$PBKDF2$iterations=1000,m=120$62FBA410AFCA5B4475F35137AB2E8596B127E4D927BA23F6CC05C067E897042D")] + [InlineData("$PBKDF2$iterations=1000,m=120$69F420$62FBA410AFCA5B4475F35137AB2E8596B127E4D927BA23F6CC05C067E897042D")] + [InlineData("$PBKDF2$iterations=1000,m=120")] + public static void ToString_Roundtrip_Success(string passwordHash) + { + Assert.Equal(passwordHash, PasswordHash.Parse(passwordHash).ToString()); + } + + [Fact] + public static void Parse_Null_ThrowsArgumentException() + { + Assert.Throws<ArgumentException>(() => PasswordHash.Parse(null)); + } + + [Fact] + public static void Parse_Empty_ThrowsArgumentException() + { + Assert.Throws<ArgumentException>(() => PasswordHash.Parse(string.Empty)); + } + + [Theory] + [InlineData("$")] // No id + [InlineData("$$")] // Empty segments + [InlineData("PBKDF2$")] // Doesn't start with $ + [InlineData("$PBKDF2$$62FBA410AFCA5B4475F35137AB2E8596B127E4D927BA23F6CC05C067E897042D")] // Empty segment + [InlineData("$PBKDF2$iterations=1000$$62FBA410AFCA5B4475F35137AB2E8596B127E4D927BA23F6CC05C067E897042D")] // Empty salt segment + [InlineData("$PBKDF2$iterations=1000$69F420$")] // Empty hash segment + [InlineData("$PBKDF2$=$62FBA410AFCA5B4475F35137AB2E8596B127E4D927BA23F6CC05C067E897042D")] // Invalid parmeter + [InlineData("$PBKDF2$=1000$62FBA410AFCA5B4475F35137AB2E8596B127E4D927BA23F6CC05C067E897042D")] // Invalid parmeter + [InlineData("$PBKDF2$iterations=$62FBA410AFCA5B4475F35137AB2E8596B127E4D927BA23F6CC05C067E897042D")] // Invalid parmeter + [InlineData("$PBKDF2$iterations=1000$62FBA410AFCA5B4475F35137AB2E8596B127E4D927BA23F6CC05C067E897042D$")] // Ends on $ + [InlineData("$PBKDF2$iterations=1000$69F420$62FBA410AFCA5B4475F35137AB2E8596B127E4D927BA23F6CC05C067E897042D$")] // Extra segment + [InlineData("$PBKDF2$iterations=1000$69F420$62FBA410AFCA5B4475F35137AB2E8596B127E4D927BA23F6CC05C067E897042D$anotherone")] // Extra segment + [InlineData("$PBKDF2$iterations=1000$invalidstalt$62FBA410AFCA5B4475F35137AB2E8596B127E4D927BA23F6CC05C067E897042D")] // Invalid salt + [InlineData("$PBKDF2$iterations=1000$69F420$invalid hash")] // Invalid hash + [InlineData("$PBKDF2$69F420$")] // Empty hash + public static void Parse_InvalidFormat_ThrowsFormatException(string passwordHash) + { + Assert.Throws<FormatException>(() => PasswordHash.Parse(passwordHash)); + } + } +} diff --git a/tests/Jellyfin.Common.Tests/Extensions/StringExtensionsTests.cs b/tests/Jellyfin.Common.Tests/Extensions/StringExtensionsTests.cs deleted file mode 100644 index 8bf613f05f..0000000000 --- a/tests/Jellyfin.Common.Tests/Extensions/StringExtensionsTests.cs +++ /dev/null @@ -1,43 +0,0 @@ -using System; -using MediaBrowser.Common.Extensions; -using Xunit; - -namespace Jellyfin.Common.Tests.Extensions -{ - public class StringExtensionsTests - { - [Theory] - [InlineData("", 'q', "")] - [InlineData("Banana split", ' ', "Banana")] - [InlineData("Banana split", 'q', "Banana split")] - public void LeftPart_ValidArgsCharNeedle_Correct(string str, char needle, string expectedResult) - { - var result = str.AsSpan().LeftPart(needle).ToString(); - Assert.Equal(expectedResult, result); - } - - [Theory] - [InlineData("", "", "")] - [InlineData("", "q", "")] - [InlineData("Banana split", "", "")] - [InlineData("Banana split", " ", "Banana")] - [InlineData("Banana split test", " split", "Banana")] - public void LeftPart_ValidArgsWithoutStringComparison_Correct(string str, string needle, string expectedResult) - { - var result = str.AsSpan().LeftPart(needle).ToString(); - Assert.Equal(expectedResult, result); - } - - [Theory] - [InlineData("", "", StringComparison.Ordinal, "")] - [InlineData("Banana split", " ", StringComparison.Ordinal, "Banana")] - [InlineData("Banana split test", " split", StringComparison.Ordinal, "Banana")] - [InlineData("Banana split test", " Split", StringComparison.Ordinal, "Banana split test")] - [InlineData("Banana split test", " Splït", StringComparison.InvariantCultureIgnoreCase, "Banana split test")] - public void LeftPart_ValidArgs_Correct(string str, string needle, StringComparison stringComparison, string expectedResult) - { - var result = str.AsSpan().LeftPart(needle, stringComparison).ToString(); - Assert.Equal(expectedResult, result); - } - } -} diff --git a/tests/Jellyfin.Common.Tests/HexTests.cs b/tests/Jellyfin.Common.Tests/HexTests.cs deleted file mode 100644 index 5b578d38cb..0000000000 --- a/tests/Jellyfin.Common.Tests/HexTests.cs +++ /dev/null @@ -1,19 +0,0 @@ -using MediaBrowser.Common; -using Xunit; - -namespace Jellyfin.Common.Tests -{ - public class HexTests - { - [Theory] - [InlineData("")] - [InlineData("00")] - [InlineData("01")] - [InlineData("000102030405060708090a0b0c0d0e0f")] - [InlineData("0123456789abcdef")] - public void RoundTripTest(string data) - { - Assert.Equal(data, Hex.Encode(Hex.Decode(data))); - } - } -} diff --git a/tests/Jellyfin.Common.Tests/Jellyfin.Common.Tests.csproj b/tests/Jellyfin.Common.Tests/Jellyfin.Common.Tests.csproj index e3f87d29b7..bab5f9e369 100644 --- a/tests/Jellyfin.Common.Tests/Jellyfin.Common.Tests.csproj +++ b/tests/Jellyfin.Common.Tests/Jellyfin.Common.Tests.csproj @@ -6,22 +6,21 @@ </PropertyGroup> <PropertyGroup> - <TargetFramework>netcoreapp3.1</TargetFramework> + <TargetFramework>net5.0</TargetFramework> <IsPackable>false</IsPackable> - <TreatWarningsAsErrors>true</TreatWarningsAsErrors> - <Nullable>enable</Nullable> + <CodeAnalysisRuleSet>../jellyfin-tests.ruleset</CodeAnalysisRuleSet> </PropertyGroup> <ItemGroup> - <PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.7.1" /> + <PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.11.0" /> <PackageReference Include="xunit" Version="2.4.1" /> <PackageReference Include="xunit.runner.visualstudio" Version="2.4.3" /> - <PackageReference Include="coverlet.collector" Version="1.3.0" /> + <PackageReference Include="coverlet.collector" Version="3.1.0" /> + <PackageReference Include="FsCheck.Xunit" Version="2.16.0" /> </ItemGroup> <!-- Code Analyzers --> <ItemGroup Condition=" '$(Configuration)' == 'Debug' "> - <PackageReference Include="Microsoft.CodeAnalysis.FxCopAnalyzers" Version="2.9.8" PrivateAssets="All" /> <PackageReference Include="SerilogAnalyzer" Version="0.15.0" PrivateAssets="All" /> <PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="All" /> <PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" PrivateAssets="All" /> @@ -29,10 +28,7 @@ <ItemGroup> <ProjectReference Include="../../MediaBrowser.Common/MediaBrowser.Common.csproj" /> + <ProjectReference Include="../../MediaBrowser.Providers/MediaBrowser.Providers.csproj" /> </ItemGroup> - <PropertyGroup Condition=" '$(Configuration)' == 'Debug' "> - <CodeAnalysisRuleSet>../jellyfin-tests.ruleset</CodeAnalysisRuleSet> - </PropertyGroup> - </Project> diff --git a/tests/Jellyfin.Common.Tests/Json/JsonGuidConverterTests.cs b/tests/Jellyfin.Common.Tests/Json/JsonGuidConverterTests.cs deleted file mode 100644 index d9e66d6774..0000000000 --- a/tests/Jellyfin.Common.Tests/Json/JsonGuidConverterTests.cs +++ /dev/null @@ -1,32 +0,0 @@ -using System; -using System.Text.Json; -using MediaBrowser.Common.Json.Converters; -using Xunit; - -namespace Jellyfin.Common.Tests.Extensions -{ - public static class JsonGuidConverterTests - { - [Fact] - public static void Deserialize_Valid_Success() - { - var options = new JsonSerializerOptions(); - options.Converters.Add(new JsonGuidConverter()); - Guid value = JsonSerializer.Deserialize<Guid>(@"""a852a27afe324084ae66db579ee3ee18""", options); - Assert.Equal(new Guid("a852a27afe324084ae66db579ee3ee18"), value); - - value = JsonSerializer.Deserialize<Guid>(@"""e9b2dcaa-529c-426e-9433-5e9981f27f2e""", options); - Assert.Equal(new Guid("e9b2dcaa-529c-426e-9433-5e9981f27f2e"), value); - } - - [Fact] - public static void Roundtrip_Valid_Success() - { - var options = new JsonSerializerOptions(); - options.Converters.Add(new JsonGuidConverter()); - Guid guid = new Guid("a852a27afe324084ae66db579ee3ee18"); - string value = JsonSerializer.Serialize(guid, options); - Assert.Equal(guid, JsonSerializer.Deserialize<Guid>(value, options)); - } - } -} diff --git a/tests/Jellyfin.Common.Tests/PasswordHashTests.cs b/tests/Jellyfin.Common.Tests/PasswordHashTests.cs deleted file mode 100644 index 46926f4f81..0000000000 --- a/tests/Jellyfin.Common.Tests/PasswordHashTests.cs +++ /dev/null @@ -1,30 +0,0 @@ -using MediaBrowser.Common; -using MediaBrowser.Common.Cryptography; -using Xunit; - -namespace Jellyfin.Common.Tests -{ - public class PasswordHashTests - { - [Theory] - [InlineData( - "$PBKDF2$iterations=1000$62FBA410AFCA5B4475F35137AB2E8596B127E4D927BA23F6CC05C067E897042D", - "PBKDF2", - "", - "62FBA410AFCA5B4475F35137AB2E8596B127E4D927BA23F6CC05C067E897042D")] - public void ParseTest(string passwordHash, string id, string salt, string hash) - { - var pass = PasswordHash.Parse(passwordHash); - Assert.Equal(id, pass.Id); - Assert.Equal(salt, Hex.Encode(pass.Salt, false)); - Assert.Equal(hash, Hex.Encode(pass.Hash, false)); - } - - [Theory] - [InlineData("$PBKDF2$iterations=1000$62FBA410AFCA5B4475F35137AB2E8596B127E4D927BA23F6CC05C067E897042D")] - public void ToStringTest(string passwordHash) - { - Assert.Equal(passwordHash, PasswordHash.Parse(passwordHash).ToString()); - } - } -} diff --git a/tests/Jellyfin.Common.Tests/Providers/ProviderIdParserTests.cs b/tests/Jellyfin.Common.Tests/Providers/ProviderIdParserTests.cs new file mode 100644 index 0000000000..ef9d31cc19 --- /dev/null +++ b/tests/Jellyfin.Common.Tests/Providers/ProviderIdParserTests.cs @@ -0,0 +1,85 @@ +using System; +using MediaBrowser.Common.Providers; +using Xunit; + +namespace Jellyfin.Common.Tests.Providers +{ + public class ProviderIdParserTests + { + [Theory] + [InlineData("tt1234567", "tt1234567")] + [InlineData("tt12345678", "tt12345678")] + [InlineData("https://www.imdb.com/title/tt1234567", "tt1234567")] + [InlineData("https://www.imdb.com/title/tt12345678", "tt12345678")] + [InlineData(@"multiline\nhttps://www.imdb.com/title/tt1234567", "tt1234567")] + [InlineData(@"multiline\nhttps://www.imdb.com/title/tt12345678", "tt12345678")] + [InlineData("tt1234567tt7654321", "tt1234567")] + [InlineData("tt12345678tt7654321", "tt12345678")] + [InlineData("tt123456789", "tt12345678")] + public void FindImdbId_Valid_Success(string text, string expected) + { + Assert.True(ProviderIdParsers.TryFindImdbId(text, out ReadOnlySpan<char> parsedId)); + Assert.Equal(expected, parsedId.ToString()); + } + + [Theory] + [InlineData("tt123456")] + [InlineData("https://www.imdb.com/title/tt123456")] + [InlineData("Jellyfin")] + public void FindImdbId_Invalid_Success(string text) + { + Assert.False(ProviderIdParsers.TryFindImdbId(text, out _)); + } + + [Theory] + [InlineData("https://www.themoviedb.org/movie/30287-fallo", "30287")] + [InlineData("themoviedb.org/movie/30287", "30287")] + public void FindTmdbMovieId_Valid_Success(string text, string expected) + { + Assert.True(ProviderIdParsers.TryFindTmdbMovieId(text, out ReadOnlySpan<char> parsedId)); + Assert.Equal(expected, parsedId.ToString()); + } + + [Theory] + [InlineData("https://www.themoviedb.org/movie/fallo-30287")] + [InlineData("https://www.themoviedb.org/tv/1668-friends")] + public void FindTmdbMovieId_Invalid_Success(string text) + { + Assert.False(ProviderIdParsers.TryFindTmdbMovieId(text, out _)); + } + + [Theory] + [InlineData("https://www.themoviedb.org/tv/1668-friends", "1668")] + [InlineData("themoviedb.org/tv/1668", "1668")] + public void FindTmdbSeriesId_Valid_Success(string text, string expected) + { + Assert.True(ProviderIdParsers.TryFindTmdbSeriesId(text, out ReadOnlySpan<char> parsedId)); + Assert.Equal(expected, parsedId.ToString()); + } + + [Theory] + [InlineData("https://www.themoviedb.org/tv/friends-1668")] + [InlineData("https://www.themoviedb.org/movie/30287-fallo")] + public void FindTmdbSeriesId_Invalid_Success(string text) + { + Assert.False(ProviderIdParsers.TryFindTmdbSeriesId(text, out _)); + } + + [Theory] + [InlineData("https://www.thetvdb.com/?tab=series&id=121361", "121361")] + [InlineData("thetvdb.com/?tab=series&id=121361", "121361")] + public void FindTvdbId_Valid_Success(string text, string expected) + { + Assert.True(ProviderIdParsers.TryFindTvdbId(text, out ReadOnlySpan<char> parsedId)); + Assert.Equal(expected, parsedId.ToString()); + } + + [Theory] + [InlineData("thetvdb.com/?tab=series&id=Jellyfin121361")] + [InlineData("https://www.themoviedb.org/tv/1668-friends")] + public void FindTvdbId_Invalid_Success(string text) + { + Assert.False(ProviderIdParsers.TryFindTvdbId(text, out _)); + } + } +} diff --git a/tests/Jellyfin.Controller.Tests/DirectoryServiceTests.cs b/tests/Jellyfin.Controller.Tests/DirectoryServiceTests.cs new file mode 100644 index 0000000000..feffb50e82 --- /dev/null +++ b/tests/Jellyfin.Controller.Tests/DirectoryServiceTests.cs @@ -0,0 +1,200 @@ +using System.Linq; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.IO; +using Moq; +using Xunit; + +namespace Jellyfin.Controller.Tests +{ + public class DirectoryServiceTests + { + private const string LowerCasePath = "/music/someartist"; + private const string UpperCasePath = "/music/SOMEARTIST"; + + private static readonly FileSystemMetadata[] _lowerCaseFileSystemMetadata = + { + new () + { + FullName = LowerCasePath + "/Artwork", + IsDirectory = true + }, + new () + { + FullName = LowerCasePath + "/Some Other Folder", + IsDirectory = true + }, + new () + { + FullName = LowerCasePath + "/Song 2.mp3", + IsDirectory = false + }, + new () + { + FullName = LowerCasePath + "/Song 3.mp3", + IsDirectory = false + } + }; + + private static readonly FileSystemMetadata[] _upperCaseFileSystemMetadata = + { + new () + { + FullName = UpperCasePath + "/Lyrics", + IsDirectory = true + }, + new () + { + FullName = UpperCasePath + "/Song 1.mp3", + IsDirectory = false + } + }; + + [Fact] + public void GetFileSystemEntries_GivenPathsWithDifferentCasing_CachesAll() + { + var fileSystemMock = new Mock<IFileSystem>(); + fileSystemMock.Setup(f => f.GetFileSystemEntries(It.Is<string>(x => x == UpperCasePath), false)).Returns(_upperCaseFileSystemMetadata); + fileSystemMock.Setup(f => f.GetFileSystemEntries(It.Is<string>(x => x == LowerCasePath), false)).Returns(_lowerCaseFileSystemMetadata); + var directoryService = new DirectoryService(fileSystemMock.Object); + + var upperCaseResult = directoryService.GetFileSystemEntries(UpperCasePath); + var lowerCaseResult = directoryService.GetFileSystemEntries(LowerCasePath); + + Assert.Equal(_upperCaseFileSystemMetadata, upperCaseResult); + Assert.Equal(_lowerCaseFileSystemMetadata, lowerCaseResult); + } + + [Fact] + public void GetFiles_GivenPathsWithDifferentCasing_ReturnsCorrectFiles() + { + var fileSystemMock = new Mock<IFileSystem>(); + fileSystemMock.Setup(f => f.GetFileSystemEntries(It.Is<string>(x => x == UpperCasePath), false)).Returns(_upperCaseFileSystemMetadata); + fileSystemMock.Setup(f => f.GetFileSystemEntries(It.Is<string>(x => x == LowerCasePath), false)).Returns(_lowerCaseFileSystemMetadata); + var directoryService = new DirectoryService(fileSystemMock.Object); + + var upperCaseResult = directoryService.GetFiles(UpperCasePath); + var lowerCaseResult = directoryService.GetFiles(LowerCasePath); + + Assert.Equal(_upperCaseFileSystemMetadata.Where(f => !f.IsDirectory), upperCaseResult); + Assert.Equal(_lowerCaseFileSystemMetadata.Where(f => !f.IsDirectory), lowerCaseResult); + } + + [Fact] + public void GetFile_GivenFilePathsWithDifferentCasing_ReturnsCorrectFile() + { + const string lowerCasePath = "/music/someartist/song 1.mp3"; + var lowerCaseFileSystemMetadata = new FileSystemMetadata + { + FullName = lowerCasePath, + Exists = true + }; + const string upperCasePath = "/music/SOMEARTIST/SONG 1.mp3"; + var upperCaseFileSystemMetadata = new FileSystemMetadata + { + FullName = upperCasePath, + Exists = false + }; + var fileSystemMock = new Mock<IFileSystem>(); + fileSystemMock.Setup(f => f.GetFileInfo(It.Is<string>(x => x == upperCasePath))).Returns(upperCaseFileSystemMetadata); + fileSystemMock.Setup(f => f.GetFileInfo(It.Is<string>(x => x == lowerCasePath))).Returns(lowerCaseFileSystemMetadata); + var directoryService = new DirectoryService(fileSystemMock.Object); + + var lowerCaseResult = directoryService.GetFile(lowerCasePath); + var upperCaseResult = directoryService.GetFile(upperCasePath); + + Assert.Equal(lowerCaseFileSystemMetadata, lowerCaseResult); + Assert.Null(upperCaseResult); + } + + [Fact] + public void GetFile_GivenCachedPath_ReturnsCachedFile() + { + const string path = "/music/someartist/song 1.mp3"; + var cachedFileSystemMetadata = new FileSystemMetadata + { + FullName = path, + Exists = true + }; + var newFileSystemMetadata = new FileSystemMetadata + { + FullName = "/music/SOMEARTIST/song 1.mp3", + Exists = true + }; + + var fileSystemMock = new Mock<IFileSystem>(); + fileSystemMock.Setup(f => f.GetFileInfo(It.Is<string>(x => x == path))).Returns(cachedFileSystemMetadata); + var directoryService = new DirectoryService(fileSystemMock.Object); + + var result = directoryService.GetFile(path); + fileSystemMock.Setup(f => f.GetFileInfo(It.Is<string>(x => x == path))).Returns(newFileSystemMetadata); + var secondResult = directoryService.GetFile(path); + + Assert.Equal(cachedFileSystemMetadata, result); + Assert.Equal(cachedFileSystemMetadata, secondResult); + } + + [Fact] + public void GetFilePaths_GivenCachedFilePathWithoutClear_ReturnsOnlyCachedPaths() + { + const string path = "/music/someartist"; + + var cachedPaths = new[] + { + "/music/someartist/song 1.mp3", + "/music/someartist/song 2.mp3", + "/music/someartist/song 3.mp3", + "/music/someartist/song 4.mp3", + }; + var newPaths = new[] + { + "/music/someartist/song 5.mp3", + "/music/someartist/song 6.mp3", + "/music/someartist/song 7.mp3", + "/music/someartist/song 8.mp3", + }; + + var fileSystemMock = new Mock<IFileSystem>(); + fileSystemMock.Setup(f => f.GetFilePaths(It.Is<string>(x => x == path), false)).Returns(cachedPaths); + var directoryService = new DirectoryService(fileSystemMock.Object); + + var result = directoryService.GetFilePaths(path); + fileSystemMock.Setup(f => f.GetFilePaths(It.Is<string>(x => x == path), false)).Returns(newPaths); + var secondResult = directoryService.GetFilePaths(path); + + Assert.Equal(cachedPaths, result); + Assert.Equal(cachedPaths, secondResult); + } + + [Fact] + public void GetFilePaths_GivenCachedFilePathWithClear_ReturnsNewPaths() + { + const string path = "/music/someartist"; + + var cachedPaths = new[] + { + "/music/someartist/song 1.mp3", + "/music/someartist/song 2.mp3", + "/music/someartist/song 3.mp3", + "/music/someartist/song 4.mp3", + }; + var newPaths = new[] + { + "/music/someartist/song 5.mp3", + "/music/someartist/song 6.mp3", + "/music/someartist/song 7.mp3", + "/music/someartist/song 8.mp3", + }; + + var fileSystemMock = new Mock<IFileSystem>(); + fileSystemMock.Setup(f => f.GetFilePaths(It.Is<string>(x => x == path), false)).Returns(cachedPaths); + var directoryService = new DirectoryService(fileSystemMock.Object); + + var result = directoryService.GetFilePaths(path); + fileSystemMock.Setup(f => f.GetFilePaths(It.Is<string>(x => x == path), false)).Returns(newPaths); + var secondResult = directoryService.GetFilePaths(path, true); + + Assert.Equal(cachedPaths, result); + Assert.Equal(newPaths, secondResult); + } + } +} diff --git a/tests/Jellyfin.Controller.Tests/Jellyfin.Controller.Tests.csproj b/tests/Jellyfin.Controller.Tests/Jellyfin.Controller.Tests.csproj index 5de02a29ba..a5778b59c8 100644 --- a/tests/Jellyfin.Controller.Tests/Jellyfin.Controller.Tests.csproj +++ b/tests/Jellyfin.Controller.Tests/Jellyfin.Controller.Tests.csproj @@ -6,22 +6,21 @@ </PropertyGroup> <PropertyGroup> - <TargetFramework>netcoreapp3.1</TargetFramework> + <TargetFramework>net5.0</TargetFramework> <IsPackable>false</IsPackable> - <TreatWarningsAsErrors>true</TreatWarningsAsErrors> - <Nullable>enable</Nullable> + <CodeAnalysisRuleSet>../jellyfin-tests.ruleset</CodeAnalysisRuleSet> </PropertyGroup> <ItemGroup> - <PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.7.1" /> + <PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.11.0" /> + <PackageReference Include="Moq" Version="4.16.1" /> <PackageReference Include="xunit" Version="2.4.1" /> <PackageReference Include="xunit.runner.visualstudio" Version="2.4.3" /> - <PackageReference Include="coverlet.collector" Version="1.3.0" /> + <PackageReference Include="coverlet.collector" Version="3.1.0" /> </ItemGroup> <!-- Code Analyzers --> <ItemGroup Condition=" '$(Configuration)' == 'Debug' "> - <PackageReference Include="Microsoft.CodeAnalysis.FxCopAnalyzers" Version="2.9.8" PrivateAssets="All" /> <PackageReference Include="SerilogAnalyzer" Version="0.15.0" PrivateAssets="All" /> <PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="All" /> <PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" PrivateAssets="All" /> @@ -31,8 +30,4 @@ <ProjectReference Include="../../MediaBrowser.Controller/MediaBrowser.Controller.csproj" /> </ItemGroup> - <PropertyGroup Condition=" '$(Configuration)' == 'Debug' "> - <CodeAnalysisRuleSet>../jellyfin-tests.ruleset</CodeAnalysisRuleSet> - </PropertyGroup> - </Project> diff --git a/tests/Jellyfin.Dlna.Tests/DlnaManagerTests.cs b/tests/Jellyfin.Dlna.Tests/DlnaManagerTests.cs new file mode 100644 index 0000000000..668bd8f878 --- /dev/null +++ b/tests/Jellyfin.Dlna.Tests/DlnaManagerTests.cs @@ -0,0 +1,131 @@ +using Emby.Dlna; +using Emby.Dlna.PlayTo; +using MediaBrowser.Common.Configuration; +using MediaBrowser.Controller; +using MediaBrowser.Model.Dlna; +using MediaBrowser.Model.IO; +using MediaBrowser.Model.Serialization; +using Microsoft.Extensions.Logging; +using Moq; +using Xunit; + +namespace Jellyfin.Dlna.Tests +{ + public class DlnaManagerTests + { + private DlnaManager GetManager() + { + var xmlSerializer = new Mock<IXmlSerializer>(); + var fileSystem = new Mock<IFileSystem>(); + var appPaths = new Mock<IApplicationPaths>(); + var loggerFactory = new Mock<ILoggerFactory>(); + var appHost = new Mock<IServerApplicationHost>(); + + return new DlnaManager(xmlSerializer.Object, fileSystem.Object, appPaths.Object, loggerFactory.Object, appHost.Object); + } + + [Fact] + public void IsMatch_GivenMatchingName_ReturnsTrue() + { + var device = new DeviceInfo() + { + Name = "My Device", + Manufacturer = "LG Electronics", + ManufacturerUrl = "http://www.lge.com", + ModelDescription = "LG WebOSTV DMRplus", + ModelName = "LG TV", + ModelNumber = "1.0", + }; + + var profile = new DeviceProfile() + { + Name = "Test Profile", + FriendlyName = "My Device", + Manufacturer = "LG Electronics", + ManufacturerUrl = "http://www.lge.com", + ModelDescription = "LG WebOSTV DMRplus", + ModelName = "LG TV", + ModelNumber = "1.0", + Identification = new () + { + FriendlyName = "My Device", + Manufacturer = "LG Electronics", + ManufacturerUrl = "http://www.lge.com", + ModelDescription = "LG WebOSTV DMRplus", + ModelName = "LG TV", + ModelNumber = "1.0", + } + }; + + var profile2 = new DeviceProfile() + { + Name = "Test Profile", + FriendlyName = "My Device", + Identification = new DeviceIdentification() + { + FriendlyName = "My Device", + } + }; + + var deviceMatch = GetManager().IsMatch(device.ToDeviceIdentification(), profile2.Identification); + var deviceMatch2 = GetManager().IsMatch(device.ToDeviceIdentification(), profile.Identification); + + Assert.True(deviceMatch); + Assert.True(deviceMatch2); + } + + [Fact] + public void IsMatch_GivenNamesAndManufacturersDoNotMatch_ReturnsFalse() + { + var device = new DeviceInfo() + { + Name = "My Device", + Manufacturer = "JVC" + }; + + var profile = new DeviceProfile() + { + Name = "Test Profile", + FriendlyName = "My Device", + Manufacturer = "LG Electronics", + ManufacturerUrl = "http://www.lge.com", + ModelDescription = "LG WebOSTV DMRplus", + ModelName = "LG TV", + ModelNumber = "1.0", + Identification = new () + { + FriendlyName = "My Device", + Manufacturer = "LG Electronics", + ManufacturerUrl = "http://www.lge.com", + ModelDescription = "LG WebOSTV DMRplus", + ModelName = "LG TV", + ModelNumber = "1.0", + } + }; + + var deviceMatch = GetManager().IsMatch(device.ToDeviceIdentification(), profile.Identification); + + Assert.False(deviceMatch); + } + + [Fact] + public void IsMatch_GivenNamesAndRegExMatch_ReturnsTrue() + { + var device = new DeviceInfo() + { + Name = "My Device" + }; + + var profile = new DeviceProfile() + { + Name = "Test Profile", + FriendlyName = "My .*", + Identification = new () + }; + + var deviceMatch = GetManager().IsMatch(device.ToDeviceIdentification(), profile.Identification); + + Assert.True(deviceMatch); + } + } +} diff --git a/tests/Jellyfin.Dlna.Tests/GetUuidTests.cs b/tests/Jellyfin.Dlna.Tests/GetUuidTests.cs new file mode 100644 index 0000000000..7655e3f7ce --- /dev/null +++ b/tests/Jellyfin.Dlna.Tests/GetUuidTests.cs @@ -0,0 +1,17 @@ +using Emby.Dlna.PlayTo; +using Xunit; + +namespace Jellyfin.Dlna.Tests +{ + public static class GetUuidTests + { + [Theory] + [InlineData("uuid:fc4ec57e-b051-11db-88f8-0060085db3f6::urn:schemas-upnp-org:device:WANDevice:1", "fc4ec57e-b051-11db-88f8-0060085db3f6")] + [InlineData("uuid:IGD{8c80f73f-4ba0-45fa-835d-042505d052be}000000000000", "8c80f73f-4ba0-45fa-835d-042505d052be")] + [InlineData("uuid:IGD{8c80f73f-4ba0-45fa-835d-042505d052be}000000000000::urn:schemas-upnp-org:device:InternetGatewayDevice:1", "8c80f73f-4ba0-45fa-835d-042505d052be")] + [InlineData("uuid:00000000-0000-0000-0000-000000000000::upnp:rootdevice", "00000000-0000-0000-0000-000000000000")] + [InlineData("uuid:fc4ec57e-b051-11db-88f8-0060085db3f6", "fc4ec57e-b051-11db-88f8-0060085db3f6")] + public static void GetUuid_Valid_Success(string usn, string uuid) + => Assert.Equal(uuid, PlayToManager.GetUuid(usn)); + } +} diff --git a/tests/Jellyfin.Dlna.Tests/Jellyfin.Dlna.Tests.csproj b/tests/Jellyfin.Dlna.Tests/Jellyfin.Dlna.Tests.csproj new file mode 100644 index 0000000000..5a48631c29 --- /dev/null +++ b/tests/Jellyfin.Dlna.Tests/Jellyfin.Dlna.Tests.csproj @@ -0,0 +1,28 @@ +<Project Sdk="Microsoft.NET.Sdk"> + + <PropertyGroup> + <TargetFramework>net5.0</TargetFramework> + <IsPackable>false</IsPackable> + <CodeAnalysisRuleSet>../jellyfin-tests.ruleset</CodeAnalysisRuleSet> + </PropertyGroup> + + <ItemGroup> + <PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.11.0" /> + <PackageReference Include="Moq" Version="4.16.1" /> + <PackageReference Include="xunit" Version="2.4.1" /> + <PackageReference Include="xunit.runner.visualstudio" Version="2.4.3" /> + <PackageReference Include="coverlet.collector" Version="3.1.0" /> + </ItemGroup> + + <!-- Code Analyzers --> + <ItemGroup Condition=" '$(Configuration)' == 'Debug' "> + <PackageReference Include="SerilogAnalyzer" Version="0.15.0" PrivateAssets="All" /> + <PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="All" /> + <PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" PrivateAssets="All" /> + </ItemGroup> + + <ItemGroup> + <ProjectReference Include="../../Emby.Dlna/Emby.Dlna.csproj" /> + </ItemGroup> + +</Project> diff --git a/tests/Jellyfin.Controller.Tests/AlphanumComparatorTests.cs b/tests/Jellyfin.Extensions.Tests/AlphanumericComparatorTests.cs similarity index 64% rename from tests/Jellyfin.Controller.Tests/AlphanumComparatorTests.cs rename to tests/Jellyfin.Extensions.Tests/AlphanumericComparatorTests.cs index 929bb92aa8..7730841a1c 100644 --- a/tests/Jellyfin.Controller.Tests/AlphanumComparatorTests.cs +++ b/tests/Jellyfin.Extensions.Tests/AlphanumericComparatorTests.cs @@ -1,15 +1,11 @@ using System; using System.Linq; -using MediaBrowser.Common.Extensions; -using MediaBrowser.Controller.Sorting; using Xunit; -namespace Jellyfin.Controller.Tests +namespace Jellyfin.Extensions.Tests { - public class AlphanumComparatorTests + public class AlphanumericComparatorTests { - private readonly Random _rng = new Random(42); - // InlineData is pre-sorted [Theory] [InlineData(null, "", "1", "9", "10", "a", "z")] @@ -23,21 +19,10 @@ namespace Jellyfin.Controller.Tests [InlineData("12345678912345678912345678913234567891", "12345678912345678912345678913234567892")] [InlineData("12345678912345678912345678913234567891a", "12345678912345678912345678913234567891a")] [InlineData("12345678912345678912345678913234567891a", "12345678912345678912345678913234567891b")] - public void AlphanumComparatorTest(params string?[] strings) + public void AlphanumericComparatorTest(params string?[] strings) { - var copy = (string?[])strings.Clone(); - if (strings.Length == 2) - { - var tmp = copy[0]; - copy[0] = copy[1]; - copy[1] = tmp; - } - else - { - copy.Shuffle(_rng); - } - - Array.Sort(copy, new AlphanumComparator()); + var copy = strings.Reverse().ToArray(); + Array.Sort(copy, new AlphanumericComparator()); Assert.True(strings.SequenceEqual(copy)); } } diff --git a/tests/Jellyfin.Extensions.Tests/CopyToExtensionsTests.cs b/tests/Jellyfin.Extensions.Tests/CopyToExtensionsTests.cs new file mode 100644 index 0000000000..6fdca46944 --- /dev/null +++ b/tests/Jellyfin.Extensions.Tests/CopyToExtensionsTests.cs @@ -0,0 +1,39 @@ +using System; +using System.Collections.Generic; +using Xunit; + +namespace Jellyfin.Extensions.Tests +{ + public static class CopyToExtensionsTests + { + public static IEnumerable<object[]> CopyTo_Valid_Correct_TestData() + { + yield return new object[] { new[] { 0, 1, 2, 3, 4, 5 }, new[] { 0, 0, 0, 0, 0, 0 }, 0, new[] { 0, 1, 2, 3, 4, 5 } }; + yield return new object[] { new[] { 0, 1, 2 }, new[] { 5, 4, 3, 2, 1, 0 }, 2, new[] { 5, 4, 0, 1, 2, 0 } }; + } + + [Theory] + [MemberData(nameof(CopyTo_Valid_Correct_TestData))] + public static void CopyTo_Valid_Correct<T>(IReadOnlyList<T> source, IList<T> destination, int index, IList<T> expected) + { + source.CopyTo(destination, index); + Assert.Equal(expected, destination); + } + + public static IEnumerable<object[]> CopyTo_Invalid_ThrowsArgumentOutOfRangeException_TestData() + { + yield return new object[] { new[] { 0, 1, 2, 3, 4, 5 }, new[] { 0, 0, 0, 0, 0, 0 }, -1 }; + yield return new object[] { new[] { 0, 1, 2 }, new[] { 5, 4, 3, 2, 1, 0 }, 6 }; + yield return new object[] { new[] { 0, 1, 2 }, Array.Empty<int>(), 0 }; + yield return new object[] { new[] { 0, 1, 2, 3, 4, 5 }, new[] { 0 }, 0 }; + yield return new object[] { new[] { 0, 1, 2, 3, 4, 5 }, new[] { 0, 0, 0, 0, 0, 0 }, 1 }; + } + + [Theory] + [MemberData(nameof(CopyTo_Invalid_ThrowsArgumentOutOfRangeException_TestData))] + public static void CopyTo_Invalid_ThrowsArgumentOutOfRangeException<T>(IReadOnlyList<T> source, IList<T> destination, int index) + { + Assert.Throws<ArgumentOutOfRangeException>(() => source.CopyTo(destination, index)); + } + } +} diff --git a/tests/Jellyfin.Extensions.Tests/Jellyfin.Extensions.Tests.csproj b/tests/Jellyfin.Extensions.Tests/Jellyfin.Extensions.Tests.csproj new file mode 100644 index 0000000000..10ec31b838 --- /dev/null +++ b/tests/Jellyfin.Extensions.Tests/Jellyfin.Extensions.Tests.csproj @@ -0,0 +1,35 @@ +<Project Sdk="Microsoft.NET.Sdk"> + + <PropertyGroup> + <TargetFramework>net5.0</TargetFramework> + <IsPackable>false</IsPackable> + <CodeAnalysisRuleSet>../jellyfin-tests.ruleset</CodeAnalysisRuleSet> + </PropertyGroup> + + <ItemGroup> + <PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.11.0" /> + <PackageReference Include="xunit" Version="2.4.1" /> + <PackageReference Include="xunit.runner.visualstudio" Version="2.4.3"> + <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> + <PrivateAssets>all</PrivateAssets> + </PackageReference> + <PackageReference Include="coverlet.collector" Version="3.1.0"> + <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> + <PrivateAssets>all</PrivateAssets> + </PackageReference> + <PackageReference Include="FsCheck.Xunit" Version="2.16.0" /> + </ItemGroup> + + <!-- Code Analyzers --> + <ItemGroup Condition=" '$(Configuration)' == 'Debug' "> + <PackageReference Include="SerilogAnalyzer" Version="0.15.0" PrivateAssets="All" /> + <PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="All" /> + <PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" PrivateAssets="All" /> + </ItemGroup> + + <ItemGroup> + <ProjectReference Include="../../MediaBrowser.Model/MediaBrowser.Model.csproj" /> + <ProjectReference Include="../../src/Jellyfin.Extensions/Jellyfin.Extensions.csproj" /> + </ItemGroup> + +</Project> diff --git a/tests/Jellyfin.Extensions.Tests/Json/Converters/JsonBoolNumberTests.cs b/tests/Jellyfin.Extensions.Tests/Json/Converters/JsonBoolNumberTests.cs new file mode 100644 index 0000000000..d0e3e94568 --- /dev/null +++ b/tests/Jellyfin.Extensions.Tests/Json/Converters/JsonBoolNumberTests.cs @@ -0,0 +1,45 @@ +using System.Globalization; +using System.Text.Json; +using FsCheck; +using FsCheck.Xunit; +using Jellyfin.Extensions.Json.Converters; +using Xunit; + +namespace Jellyfin.Extensions.Tests.Json.Converters +{ + public class JsonBoolNumberTests + { + private readonly JsonSerializerOptions _jsonOptions = new JsonSerializerOptions() + { + Converters = + { + new JsonBoolNumberConverter() + } + }; + + [Theory] + [InlineData("1", true)] + [InlineData("0", false)] + [InlineData("2", true)] + [InlineData("true", true)] + [InlineData("false", false)] + public void Deserialize_Number_Valid_Success(string input, bool? output) + { + var value = JsonSerializer.Deserialize<bool>(input, _jsonOptions); + Assert.Equal(value, output); + } + + [Theory] + [InlineData(true, "true")] + [InlineData(false, "false")] + public void Serialize_Bool_Success(bool input, string output) + { + var value = JsonSerializer.Serialize(input, _jsonOptions); + Assert.Equal(value, output); + } + + [Property] + public Property Deserialize_NonZeroInt_True(NonZeroInt input) + => JsonSerializer.Deserialize<bool>(input.ToString(), _jsonOptions).ToProperty(); + } +} diff --git a/tests/Jellyfin.Extensions.Tests/Json/Converters/JsonCommaDelimitedArrayTests.cs b/tests/Jellyfin.Extensions.Tests/Json/Converters/JsonCommaDelimitedArrayTests.cs new file mode 100644 index 0000000000..f2ca2ff081 --- /dev/null +++ b/tests/Jellyfin.Extensions.Tests/Json/Converters/JsonCommaDelimitedArrayTests.cs @@ -0,0 +1,142 @@ +using System; +using System.Text.Json; +using System.Text.Json.Serialization; +using Jellyfin.Extensions.Tests.Json.Models; +using MediaBrowser.Model.Session; +using Xunit; + +namespace Jellyfin.Extensions.Tests.Json.Converters +{ + public static class JsonCommaDelimitedArrayTests + { + [Fact] + public static void Deserialize_String_Null_Success() + { + var options = new JsonSerializerOptions(); + var value = JsonSerializer.Deserialize<GenericBodyArrayModel<string>>(@"{ ""Value"": null }", options); + Assert.Null(value?.Value); + } + + [Fact] + public static void Deserialize_Empty_Success() + { + var desiredValue = new GenericBodyArrayModel<string> + { + Value = Array.Empty<string>() + }; + + var options = new JsonSerializerOptions(); + var value = JsonSerializer.Deserialize<GenericBodyArrayModel<string>>(@"{ ""Value"": """" }", options); + Assert.Equal(desiredValue.Value, value?.Value); + } + + [Fact] + public static void Deserialize_String_Valid_Success() + { + var desiredValue = new GenericBodyArrayModel<string> + { + Value = new[] { "a", "b", "c" } + }; + + var options = new JsonSerializerOptions(); + var value = JsonSerializer.Deserialize<GenericBodyArrayModel<string>>(@"{ ""Value"": ""a,b,c"" }", options); + Assert.Equal(desiredValue.Value, value?.Value); + } + + [Fact] + public static void Deserialize_String_Space_Valid_Success() + { + var desiredValue = new GenericBodyArrayModel<string> + { + Value = new[] { "a", "b", "c" } + }; + + var options = new JsonSerializerOptions(); + var value = JsonSerializer.Deserialize<GenericBodyArrayModel<string>>(@"{ ""Value"": ""a, b, c"" }", options); + Assert.Equal(desiredValue.Value, value?.Value); + } + + [Fact] + public static void Deserialize_GenericCommandType_Valid_Success() + { + var desiredValue = new GenericBodyArrayModel<GeneralCommandType> + { + Value = new[] { GeneralCommandType.MoveUp, GeneralCommandType.MoveDown } + }; + + var options = new JsonSerializerOptions(); + options.Converters.Add(new JsonStringEnumConverter()); + var value = JsonSerializer.Deserialize<GenericBodyArrayModel<GeneralCommandType>>(@"{ ""Value"": ""MoveUp,MoveDown"" }", options); + Assert.Equal(desiredValue.Value, value?.Value); + } + + [Fact] + public static void Deserialize_GenericCommandType_EmptyEntry_Success() + { + var desiredValue = new GenericBodyArrayModel<GeneralCommandType> + { + Value = new[] { GeneralCommandType.MoveUp, GeneralCommandType.MoveDown } + }; + + var options = new JsonSerializerOptions(); + options.Converters.Add(new JsonStringEnumConverter()); + var value = JsonSerializer.Deserialize<GenericBodyArrayModel<GeneralCommandType>>(@"{ ""Value"": ""MoveUp,,MoveDown"" }", options); + Assert.Equal(desiredValue.Value, value?.Value); + } + + [Fact] + public static void Deserialize_GenericCommandType_Invalid_Success() + { + var desiredValue = new GenericBodyArrayModel<GeneralCommandType> + { + Value = new[] { GeneralCommandType.MoveUp, GeneralCommandType.MoveDown } + }; + + var options = new JsonSerializerOptions(); + options.Converters.Add(new JsonStringEnumConverter()); + var value = JsonSerializer.Deserialize<GenericBodyArrayModel<GeneralCommandType>>(@"{ ""Value"": ""MoveUp,TotallyNotAVallidCommand,MoveDown"" }", options); + Assert.Equal(desiredValue.Value, value?.Value); + } + + [Fact] + public static void Deserialize_GenericCommandType_Space_Valid_Success() + { + var desiredValue = new GenericBodyArrayModel<GeneralCommandType> + { + Value = new[] { GeneralCommandType.MoveUp, GeneralCommandType.MoveDown } + }; + + var options = new JsonSerializerOptions(); + options.Converters.Add(new JsonStringEnumConverter()); + var value = JsonSerializer.Deserialize<GenericBodyArrayModel<GeneralCommandType>>(@"{ ""Value"": ""MoveUp, MoveDown"" }", options); + Assert.Equal(desiredValue.Value, value?.Value); + } + + [Fact] + public static void Deserialize_String_Array_Valid_Success() + { + var desiredValue = new GenericBodyArrayModel<string> + { + Value = new[] { "a", "b", "c" } + }; + + var options = new JsonSerializerOptions(); + var value = JsonSerializer.Deserialize<GenericBodyArrayModel<string>>(@"{ ""Value"": [""a"",""b"",""c""] }", options); + Assert.Equal(desiredValue.Value, value?.Value); + } + + [Fact] + public static void Deserialize_GenericCommandType_Array_Valid_Success() + { + var desiredValue = new GenericBodyArrayModel<GeneralCommandType> + { + Value = new[] { GeneralCommandType.MoveUp, GeneralCommandType.MoveDown } + }; + + var options = new JsonSerializerOptions(); + options.Converters.Add(new JsonStringEnumConverter()); + var value = JsonSerializer.Deserialize<GenericBodyArrayModel<GeneralCommandType>>(@"{ ""Value"": [""MoveUp"", ""MoveDown""] }", options); + Assert.Equal(desiredValue.Value, value?.Value); + } + } +} diff --git a/tests/Jellyfin.Extensions.Tests/Json/Converters/JsonCommaDelimitedIReadOnlyListTests.cs b/tests/Jellyfin.Extensions.Tests/Json/Converters/JsonCommaDelimitedIReadOnlyListTests.cs new file mode 100644 index 0000000000..92886dcd28 --- /dev/null +++ b/tests/Jellyfin.Extensions.Tests/Json/Converters/JsonCommaDelimitedIReadOnlyListTests.cs @@ -0,0 +1,92 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using Jellyfin.Extensions.Tests.Json.Models; +using MediaBrowser.Model.Session; +using Xunit; + +namespace Jellyfin.Extensions.Tests.Json.Converters +{ + public static class JsonCommaDelimitedIReadOnlyListTests + { + [Fact] + public static void Deserialize_String_Valid_Success() + { + var desiredValue = new GenericBodyIReadOnlyListModel<string> + { + Value = new[] { "a", "b", "c" } + }; + + var options = new JsonSerializerOptions(); + var value = JsonSerializer.Deserialize<GenericBodyIReadOnlyListModel<string>>(@"{ ""Value"": ""a,b,c"" }", options); + Assert.Equal(desiredValue.Value, value?.Value); + } + + [Fact] + public static void Deserialize_String_Space_Valid_Success() + { + var desiredValue = new GenericBodyIReadOnlyListModel<string> + { + Value = new[] { "a", "b", "c" } + }; + + var options = new JsonSerializerOptions(); + var value = JsonSerializer.Deserialize<GenericBodyIReadOnlyListModel<string>>(@"{ ""Value"": ""a, b, c"" }", options); + Assert.Equal(desiredValue.Value, value?.Value); + } + + [Fact] + public static void Deserialize_GenericCommandType_Valid_Success() + { + var desiredValue = new GenericBodyIReadOnlyListModel<GeneralCommandType> + { + Value = new[] { GeneralCommandType.MoveUp, GeneralCommandType.MoveDown } + }; + + var options = new JsonSerializerOptions(); + options.Converters.Add(new JsonStringEnumConverter()); + var value = JsonSerializer.Deserialize<GenericBodyIReadOnlyListModel<GeneralCommandType>>(@"{ ""Value"": ""MoveUp,MoveDown"" }", options); + Assert.Equal(desiredValue.Value, value?.Value); + } + + [Fact] + public static void Deserialize_GenericCommandType_Space_Valid_Success() + { + var desiredValue = new GenericBodyIReadOnlyListModel<GeneralCommandType> + { + Value = new[] { GeneralCommandType.MoveUp, GeneralCommandType.MoveDown } + }; + + var options = new JsonSerializerOptions(); + options.Converters.Add(new JsonStringEnumConverter()); + var value = JsonSerializer.Deserialize<GenericBodyIReadOnlyListModel<GeneralCommandType>>(@"{ ""Value"": ""MoveUp, MoveDown"" }", options); + Assert.Equal(desiredValue.Value, value?.Value); + } + + [Fact] + public static void Deserialize_String_Array_Valid_Success() + { + var desiredValue = new GenericBodyIReadOnlyListModel<string> + { + Value = new[] { "a", "b", "c" } + }; + + var options = new JsonSerializerOptions(); + var value = JsonSerializer.Deserialize<GenericBodyIReadOnlyListModel<string>>(@"{ ""Value"": [""a"",""b"",""c""] }", options); + Assert.Equal(desiredValue.Value, value?.Value); + } + + [Fact] + public static void Deserialize_GenericCommandType_Array_Valid_Success() + { + var desiredValue = new GenericBodyIReadOnlyListModel<GeneralCommandType> + { + Value = new[] { GeneralCommandType.MoveUp, GeneralCommandType.MoveDown } + }; + + var options = new JsonSerializerOptions(); + options.Converters.Add(new JsonStringEnumConverter()); + var value = JsonSerializer.Deserialize<GenericBodyIReadOnlyListModel<GeneralCommandType>>(@"{ ""Value"": [""MoveUp"", ""MoveDown""] }", options); + Assert.Equal(desiredValue.Value, value?.Value); + } + } +} diff --git a/tests/Jellyfin.Extensions.Tests/Json/Converters/JsonGuidConverterTests.cs b/tests/Jellyfin.Extensions.Tests/Json/Converters/JsonGuidConverterTests.cs new file mode 100644 index 0000000000..8465d465ab --- /dev/null +++ b/tests/Jellyfin.Extensions.Tests/Json/Converters/JsonGuidConverterTests.cs @@ -0,0 +1,68 @@ +using System; +using System.Text.Json; +using Jellyfin.Extensions.Json.Converters; +using Xunit; + +namespace Jellyfin.Extensions.Tests.Json.Converters +{ + public class JsonGuidConverterTests + { + private readonly JsonSerializerOptions _options; + + public JsonGuidConverterTests() + { + _options = new JsonSerializerOptions(); + _options.Converters.Add(new JsonGuidConverter()); + } + + [Fact] + public void Deserialize_Valid_Success() + { + Guid value = JsonSerializer.Deserialize<Guid>(@"""a852a27afe324084ae66db579ee3ee18""", _options); + Assert.Equal(new Guid("a852a27afe324084ae66db579ee3ee18"), value); + } + + [Fact] + public void Deserialize_ValidDashed_Success() + { + Guid value = JsonSerializer.Deserialize<Guid>(@"""e9b2dcaa-529c-426e-9433-5e9981f27f2e""", _options); + Assert.Equal(new Guid("e9b2dcaa-529c-426e-9433-5e9981f27f2e"), value); + } + + [Fact] + public void Roundtrip_Valid_Success() + { + Guid guid = new Guid("a852a27afe324084ae66db579ee3ee18"); + string value = JsonSerializer.Serialize(guid, _options); + Assert.Equal(guid, JsonSerializer.Deserialize<Guid>(value, _options)); + } + + [Fact] + public void Deserialize_Null_EmptyGuid() + { + Assert.Equal(Guid.Empty, JsonSerializer.Deserialize<Guid>("null", _options)); + } + + [Fact] + public void Serialize_EmptyGuid_EmptyGuid() + { + Assert.Equal($"\"{Guid.Empty:N}\"", JsonSerializer.Serialize(Guid.Empty, _options)); + } + + [Fact] + public void Serialize_Valid_NoDash_Success() + { + var guid = new Guid("531797E9-9457-40E0-88BC-B1D6D38752FA"); + var str = JsonSerializer.Serialize(guid, _options); + Assert.Equal($"\"{guid:N}\"", str); + } + + [Fact] + public void Serialize_Nullable_Success() + { + Guid? guid = new Guid("531797E9-9457-40E0-88BC-B1D6D38752FA"); + var str = JsonSerializer.Serialize(guid, _options); + Assert.Equal($"\"{guid:N}\"", str); + } + } +} diff --git a/tests/Jellyfin.Extensions.Tests/Json/Converters/JsonLowerCaseConverterTests.cs b/tests/Jellyfin.Extensions.Tests/Json/Converters/JsonLowerCaseConverterTests.cs new file mode 100644 index 0000000000..af9227de27 --- /dev/null +++ b/tests/Jellyfin.Extensions.Tests/Json/Converters/JsonLowerCaseConverterTests.cs @@ -0,0 +1,71 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using Jellyfin.Extensions.Json.Converters; +using MediaBrowser.Model.Entities; +using Xunit; + +namespace Jellyfin.Extensions.Tests.Json.Converters +{ + public class JsonLowerCaseConverterTests + { + private readonly JsonSerializerOptions _jsonOptions = new JsonSerializerOptions() + { + Converters = + { + new JsonStringEnumConverter() + } + }; + + [Theory] + [InlineData(null, "{\"CollectionType\":null}")] + [InlineData(CollectionTypeOptions.Movies, "{\"CollectionType\":\"movies\"}")] + [InlineData(CollectionTypeOptions.MusicVideos, "{\"CollectionType\":\"musicvideos\"}")] + public void Serialize_CollectionTypeOptions_Correct(CollectionTypeOptions? collectionType, string expected) + { + Assert.Equal(expected, JsonSerializer.Serialize(new TestContainer(collectionType), _jsonOptions)); + } + + [Theory] + [InlineData("{\"CollectionType\":null}", null)] + [InlineData("{\"CollectionType\":\"movies\"}", CollectionTypeOptions.Movies)] + [InlineData("{\"CollectionType\":\"musicvideos\"}", CollectionTypeOptions.MusicVideos)] + public void Deserialize_CollectionTypeOptions_Correct(string json, CollectionTypeOptions? result) + { + var res = JsonSerializer.Deserialize<TestContainer>(json, _jsonOptions); + Assert.NotNull(res); + Assert.Equal(result, res!.CollectionType); + } + + [Theory] + [InlineData(null)] + [InlineData(CollectionTypeOptions.Movies)] + [InlineData(CollectionTypeOptions.MusicVideos)] + public void RoundTrip_CollectionTypeOptions_Correct(CollectionTypeOptions? value) + { + var res = JsonSerializer.Deserialize<TestContainer>(JsonSerializer.Serialize(new TestContainer(value), _jsonOptions), _jsonOptions); + Assert.NotNull(res); + Assert.Equal(value, res!.CollectionType); + } + + [Theory] + [InlineData("{\"CollectionType\":null}")] + [InlineData("{\"CollectionType\":\"movies\"}")] + [InlineData("{\"CollectionType\":\"musicvideos\"}")] + public void RoundTrip_String_Correct(string json) + { + var res = JsonSerializer.Serialize(JsonSerializer.Deserialize<TestContainer>(json, _jsonOptions), _jsonOptions); + Assert.Equal(json, res); + } + + private class TestContainer + { + public TestContainer(CollectionTypeOptions? collectionType) + { + CollectionType = collectionType; + } + + [JsonConverter(typeof(JsonLowerCaseConverter<CollectionTypeOptions?>))] + public CollectionTypeOptions? CollectionType { get; set; } + } + } +} diff --git a/tests/Jellyfin.Extensions.Tests/Json/Converters/JsonNullableGuidConverterTests.cs b/tests/Jellyfin.Extensions.Tests/Json/Converters/JsonNullableGuidConverterTests.cs new file mode 100644 index 0000000000..b0dbc09e4f --- /dev/null +++ b/tests/Jellyfin.Extensions.Tests/Json/Converters/JsonNullableGuidConverterTests.cs @@ -0,0 +1,80 @@ +using System; +using System.Text.Json; +using Jellyfin.Extensions.Json.Converters; +using Xunit; + +namespace Jellyfin.Extensions.Tests.Json.Converters +{ + public class JsonNullableGuidConverterTests + { + private readonly JsonSerializerOptions _options; + + public JsonNullableGuidConverterTests() + { + _options = new JsonSerializerOptions(); + _options.Converters.Add(new JsonNullableGuidConverter()); + } + + [Fact] + public void Deserialize_Valid_Success() + { + Guid? value = JsonSerializer.Deserialize<Guid?>(@"""a852a27afe324084ae66db579ee3ee18""", _options); + Assert.Equal(new Guid("a852a27afe324084ae66db579ee3ee18"), value); + } + + [Fact] + public void Deserialize_ValidDashed_Success() + { + Guid? value = JsonSerializer.Deserialize<Guid?>(@"""e9b2dcaa-529c-426e-9433-5e9981f27f2e""", _options); + Assert.Equal(new Guid("e9b2dcaa-529c-426e-9433-5e9981f27f2e"), value); + } + + [Fact] + public void Roundtrip_Valid_Success() + { + Guid guid = new Guid("a852a27afe324084ae66db579ee3ee18"); + string value = JsonSerializer.Serialize(guid, _options); + Assert.Equal(guid, JsonSerializer.Deserialize<Guid?>(value, _options)); + } + + [Fact] + public void Deserialize_Null_Null() + { + Assert.Null(JsonSerializer.Deserialize<Guid?>("null", _options)); + } + + [Fact] + public void Deserialize_EmptyGuid_EmptyGuid() + { + Assert.Equal(Guid.Empty, JsonSerializer.Deserialize<Guid?>(@"""00000000-0000-0000-0000-000000000000""", _options)); + } + + [Fact] + public void Serialize_EmptyGuid_Null() + { + Assert.Equal("null", JsonSerializer.Serialize((Guid?)Guid.Empty, _options)); + } + + [Fact] + public void Serialize_Null_Null() + { + Assert.Equal("null", JsonSerializer.Serialize((Guid?)null, _options)); + } + + [Fact] + public void Serialize_Valid_NoDash_Success() + { + var guid = (Guid?)new Guid("531797E9-9457-40E0-88BC-B1D6D38752FA"); + var str = JsonSerializer.Serialize(guid, _options); + Assert.Equal($"\"{guid:N}\"", str); + } + + [Fact] + public void Serialize_Nullable_Success() + { + Guid? guid = new Guid("531797E9-9457-40E0-88BC-B1D6D38752FA"); + var str = JsonSerializer.Serialize(guid, _options); + Assert.Equal($"\"{guid:N}\"", str); + } + } +} diff --git a/tests/Jellyfin.Extensions.Tests/Json/Converters/JsonStringConverterTests.cs b/tests/Jellyfin.Extensions.Tests/Json/Converters/JsonStringConverterTests.cs new file mode 100644 index 0000000000..655e070742 --- /dev/null +++ b/tests/Jellyfin.Extensions.Tests/Json/Converters/JsonStringConverterTests.cs @@ -0,0 +1,38 @@ +using System.Text.Json; +using Jellyfin.Extensions.Json.Converters; +using Xunit; + +namespace Jellyfin.Extensions.Tests.Json.Converters +{ + public class JsonStringConverterTests + { + private readonly JsonSerializerOptions _jsonSerializerOptions = new () + { + Converters = + { + new JsonStringConverter() + } + }; + + [Theory] + [InlineData("\"test\"", "test")] + [InlineData("123", "123")] + [InlineData("123.45", "123.45")] + [InlineData("true", "true")] + [InlineData("false", "false")] + public void Deserialize_String_Valid_Success(string input, string output) + { + var deserialized = JsonSerializer.Deserialize<string>(input, _jsonSerializerOptions); + Assert.Equal(deserialized, output); + } + + [Fact] + public void Deserialize_Int32asInt32_Valid_Success() + { + const string? input = "123"; + const int output = 123; + var deserialized = JsonSerializer.Deserialize<int>(input, _jsonSerializerOptions); + Assert.Equal(deserialized, output); + } + } +} diff --git a/tests/Jellyfin.Extensions.Tests/Json/Converters/JsonVersionConverterTests.cs b/tests/Jellyfin.Extensions.Tests/Json/Converters/JsonVersionConverterTests.cs new file mode 100644 index 0000000000..5fbac7eab0 --- /dev/null +++ b/tests/Jellyfin.Extensions.Tests/Json/Converters/JsonVersionConverterTests.cs @@ -0,0 +1,36 @@ +using System; +using System.Text.Json; +using Jellyfin.Extensions.Json.Converters; +using Xunit; + +namespace Jellyfin.Extensions.Tests.Json.Converters +{ + public class JsonVersionConverterTests + { + private readonly JsonSerializerOptions _options; + + public JsonVersionConverterTests() + { + _options = new JsonSerializerOptions(); + _options.Converters.Add(new JsonVersionConverter()); + } + + [Fact] + public void Deserialize_Version_Success() + { + var input = "\"1.025.222\""; + var output = new Version(1, 25, 222); + var deserializedInput = JsonSerializer.Deserialize<Version>(input, _options); + Assert.Equal(output, deserializedInput); + } + + [Fact] + public void Serialize_Version_Success() + { + var input = new Version(1, 09, 59); + var output = "\"1.9.59\""; + var serializedInput = JsonSerializer.Serialize(input, _options); + Assert.Equal(output, serializedInput); + } + } +} diff --git a/tests/Jellyfin.Extensions.Tests/Json/Models/GenericBodyArrayModel.cs b/tests/Jellyfin.Extensions.Tests/Json/Models/GenericBodyArrayModel.cs new file mode 100644 index 0000000000..ef135278fd --- /dev/null +++ b/tests/Jellyfin.Extensions.Tests/Json/Models/GenericBodyArrayModel.cs @@ -0,0 +1,20 @@ +using System.Diagnostics.CodeAnalysis; +using System.Text.Json.Serialization; +using Jellyfin.Extensions.Json.Converters; + +namespace Jellyfin.Extensions.Tests.Json.Models +{ + /// <summary> + /// The generic body model. + /// </summary> + /// <typeparam name="T">The value type.</typeparam> + public class GenericBodyArrayModel<T> + { + /// <summary> + /// Gets or sets the value. + /// </summary> + [SuppressMessage("Microsoft.Performance", "CA1819:Properties should not return arrays", MessageId = "Value", Justification = "Imported from ServiceStack")] + [JsonConverter(typeof(JsonCommaDelimitedArrayConverterFactory))] + public T[] Value { get; set; } = default!; + } +} diff --git a/tests/Jellyfin.Extensions.Tests/Json/Models/GenericBodyIReadOnlyListModel.cs b/tests/Jellyfin.Extensions.Tests/Json/Models/GenericBodyIReadOnlyListModel.cs new file mode 100644 index 0000000000..8e7b5a35b4 --- /dev/null +++ b/tests/Jellyfin.Extensions.Tests/Json/Models/GenericBodyIReadOnlyListModel.cs @@ -0,0 +1,19 @@ +using System.Collections.Generic; +using System.Text.Json.Serialization; +using Jellyfin.Extensions.Json.Converters; + +namespace Jellyfin.Extensions.Tests.Json.Models +{ + /// <summary> + /// The generic body <c>IReadOnlyList</c> model. + /// </summary> + /// <typeparam name="T">The value type.</typeparam> + public class GenericBodyIReadOnlyListModel<T> + { + /// <summary> + /// Gets or sets the value. + /// </summary> + [JsonConverter(typeof(JsonCommaDelimitedArrayConverterFactory))] + public IReadOnlyList<T> Value { get; set; } = default!; + } +} diff --git a/tests/Jellyfin.Extensions.Tests/ShuffleExtensionsTests.cs b/tests/Jellyfin.Extensions.Tests/ShuffleExtensionsTests.cs new file mode 100644 index 0000000000..c72216d94b --- /dev/null +++ b/tests/Jellyfin.Extensions.Tests/ShuffleExtensionsTests.cs @@ -0,0 +1,21 @@ +using System; +using Xunit; + +namespace Jellyfin.Extensions.Tests +{ + public static class ShuffleExtensionsTests + { + private static readonly Random _rng = new Random(); + + [Fact] + public static void Shuffle_Valid_Correct() + { + byte[] original = new byte[1 << 6]; + _rng.NextBytes(original); + byte[] shuffled = (byte[])original.Clone(); + shuffled.Shuffle(); + + Assert.NotEqual(original, shuffled); + } + } +} diff --git a/tests/Jellyfin.Extensions.Tests/StringExtensionsTests.cs b/tests/Jellyfin.Extensions.Tests/StringExtensionsTests.cs new file mode 100644 index 0000000000..d1aa2e4764 --- /dev/null +++ b/tests/Jellyfin.Extensions.Tests/StringExtensionsTests.cs @@ -0,0 +1,18 @@ +using System; +using Xunit; + +namespace Jellyfin.Extensions.Tests +{ + public class StringExtensionsTests + { + [Theory] + [InlineData("", '_', 0)] + [InlineData("___", '_', 3)] + [InlineData("test\x00", '\x00', 1)] + [InlineData("Imdb=tt0119567|Tmdb=330|TmdbCollection=328", '|', 2)] + public void ReadOnlySpan_Count_Success(string str, char needle, int count) + { + Assert.Equal(count, str.AsSpan().Count(needle)); + } + } +} diff --git a/tests/Jellyfin.MediaEncoding.Tests/FFprobeParserTests.cs b/tests/Jellyfin.MediaEncoding.Tests/FFprobeParserTests.cs index c39ef0ce99..2955104a27 100644 --- a/tests/Jellyfin.MediaEncoding.Tests/FFprobeParserTests.cs +++ b/tests/Jellyfin.MediaEncoding.Tests/FFprobeParserTests.cs @@ -1,7 +1,7 @@ using System.IO; using System.Text.Json; using System.Threading.Tasks; -using MediaBrowser.Common.Json; +using Jellyfin.Extensions.Json; using MediaBrowser.MediaEncoding.Probing; using Xunit; @@ -14,9 +14,10 @@ namespace Jellyfin.MediaEncoding.Tests public async Task Test(string fileName) { var path = Path.Join("Test Data", fileName); - using (var stream = File.OpenRead(path)) + await using (var stream = File.OpenRead(path)) { - await JsonSerializer.DeserializeAsync<InternalMediaInfoResult>(stream, JsonDefaults.GetOptions()).ConfigureAwait(false); + var res = await JsonSerializer.DeserializeAsync<InternalMediaInfoResult>(stream, JsonDefaults.Options).ConfigureAwait(false); + Assert.NotNull(res); } } } diff --git a/tests/Jellyfin.MediaEncoding.Tests/Jellyfin.MediaEncoding.Tests.csproj b/tests/Jellyfin.MediaEncoding.Tests/Jellyfin.MediaEncoding.Tests.csproj index 3ac60819b4..7ea5039138 100644 --- a/tests/Jellyfin.MediaEncoding.Tests/Jellyfin.MediaEncoding.Tests.csproj +++ b/tests/Jellyfin.MediaEncoding.Tests/Jellyfin.MediaEncoding.Tests.csproj @@ -6,10 +6,9 @@ </PropertyGroup> <PropertyGroup> - <TargetFramework>netcoreapp3.1</TargetFramework> + <TargetFramework>net5.0</TargetFramework> <IsPackable>false</IsPackable> - <TreatWarningsAsErrors>true</TreatWarningsAsErrors> - <Nullable>enable</Nullable> + <CodeAnalysisRuleSet>../jellyfin-tests.ruleset</CodeAnalysisRuleSet> </PropertyGroup> <ItemGroup> @@ -19,15 +18,14 @@ </ItemGroup> <ItemGroup> - <PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.7.1" /> + <PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.11.0" /> <PackageReference Include="xunit" Version="2.4.1" /> <PackageReference Include="xunit.runner.visualstudio" Version="2.4.3" /> - <PackageReference Include="coverlet.collector" Version="1.3.0" /> + <PackageReference Include="coverlet.collector" Version="3.1.0" /> </ItemGroup> <!-- Code Analyzers --> <ItemGroup Condition=" '$(Configuration)' == 'Debug' "> - <PackageReference Include="Microsoft.CodeAnalysis.FxCopAnalyzers" Version="2.9.8" PrivateAssets="All" /> <PackageReference Include="SerilogAnalyzer" Version="0.15.0" PrivateAssets="All" /> <PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="All" /> <PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" PrivateAssets="All" /> @@ -37,8 +35,4 @@ <ProjectReference Include="../../MediaBrowser.MediaEncoding/MediaBrowser.MediaEncoding.csproj" /> </ItemGroup> - <PropertyGroup Condition=" '$(Configuration)' == 'Debug' "> - <CodeAnalysisRuleSet>../jellyfin-tests.ruleset</CodeAnalysisRuleSet> - </PropertyGroup> - </Project> diff --git a/tests/Jellyfin.MediaEncoding.Tests/Probing/ProbeResultNormalizerTests.cs b/tests/Jellyfin.MediaEncoding.Tests/Probing/ProbeResultNormalizerTests.cs new file mode 100644 index 0000000000..59037c2636 --- /dev/null +++ b/tests/Jellyfin.MediaEncoding.Tests/Probing/ProbeResultNormalizerTests.cs @@ -0,0 +1,95 @@ +using System; +using System.Globalization; +using System.IO; +using System.Text.Json; +using Jellyfin.Extensions.Json; +using MediaBrowser.MediaEncoding.Probing; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.MediaInfo; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace Jellyfin.MediaEncoding.Tests.Probing +{ + public class ProbeResultNormalizerTests + { + private readonly JsonSerializerOptions _jsonOptions = JsonDefaults.Options; + private readonly ProbeResultNormalizer _probeResultNormalizer = new ProbeResultNormalizer(new NullLogger<EncoderValidatorTests>(), null); + + [Fact] + public void GetMediaInfo_MetaData_Success() + { + var bytes = File.ReadAllBytes("Test Data/Probing/video_metadata.json"); + var internalMediaInfoResult = JsonSerializer.Deserialize<InternalMediaInfoResult>(bytes, _jsonOptions); + MediaInfo res = _probeResultNormalizer.GetMediaInfo(internalMediaInfoResult, VideoType.VideoFile, false, "Test Data/Probing/video_metadata.mkv", MediaProtocol.File); + + Assert.Single(res.MediaStreams); + + Assert.NotNull(res.VideoStream); + Assert.Equal("4:3", res.VideoStream.AspectRatio); + Assert.Equal(25f, res.VideoStream.AverageFrameRate); + Assert.Equal(8, res.VideoStream.BitDepth); + Assert.Equal(69432, res.VideoStream.BitRate); + Assert.Equal("h264", res.VideoStream.Codec); + Assert.Equal("1/50", res.VideoStream.CodecTimeBase); + Assert.Equal(240, res.VideoStream.Height); + Assert.Equal(320, res.VideoStream.Width); + Assert.Equal(0, res.VideoStream.Index); + Assert.False(res.VideoStream.IsAnamorphic); + Assert.True(res.VideoStream.IsAVC); + Assert.True(res.VideoStream.IsDefault); + Assert.False(res.VideoStream.IsExternal); + Assert.False(res.VideoStream.IsForced); + Assert.False(res.VideoStream.IsInterlaced); + Assert.False(res.VideoStream.IsTextSubtitleStream); + Assert.Equal(13d, res.VideoStream.Level); + Assert.Equal("4", res.VideoStream.NalLengthSize); + Assert.Equal("yuv444p", res.VideoStream.PixelFormat); + Assert.Equal("High 4:4:4 Predictive", res.VideoStream.Profile); + Assert.Equal(25f, res.VideoStream.RealFrameRate); + Assert.Equal(1, res.VideoStream.RefFrames); + Assert.Equal("1/1000", res.VideoStream.TimeBase); + Assert.Equal(MediaStreamType.Video, res.VideoStream.Type); + + Assert.Empty(res.Chapters); + Assert.Equal("Just color bars", res.Overview); + } + + [Fact] + public void GetMediaInfo_MusicVideo_Success() + { + var bytes = File.ReadAllBytes("Test Data/Probing/music_video_metadata.json"); + var internalMediaInfoResult = JsonSerializer.Deserialize<InternalMediaInfoResult>(bytes, _jsonOptions); + MediaInfo res = _probeResultNormalizer.GetMediaInfo(internalMediaInfoResult, VideoType.VideoFile, false, "Test Data/Probing/music_video.mkv", MediaProtocol.File); + + Assert.Equal("The Title", res.Name); + Assert.Equal("Title, The", res.ForcedSortName); + Assert.Single(res.Artists); + Assert.Equal("The Artist", res.Artists[0]); + Assert.Equal("Album", res.Album); + Assert.Equal(2021, res.ProductionYear); + Assert.True(res.PremiereDate.HasValue); + Assert.Equal(DateTime.Parse("2021-01-01T00:00Z", DateTimeFormatInfo.CurrentInfo).ToUniversalTime(), res.PremiereDate); + } + + [Fact] + public void GetMediaInfo_GivenOriginalDateContainsOnlyYear_Success() + { + var bytes = File.ReadAllBytes("Test Data/Probing/music_year_only_metadata.json"); + var internalMediaInfoResult = JsonSerializer.Deserialize<InternalMediaInfoResult>(bytes, _jsonOptions); + MediaInfo res = _probeResultNormalizer.GetMediaInfo(internalMediaInfoResult, null, true, "Test Data/Probing/music.flac", MediaProtocol.File); + + Assert.Equal("Baker Street", res.Name); + Assert.Single(res.Artists); + Assert.Equal("Gerry Rafferty", res.Artists[0]); + Assert.Equal("City to City", res.Album); + Assert.Equal(1978, res.ProductionYear); + Assert.True(res.PremiereDate.HasValue); + Assert.Equal(DateTime.Parse("1978-01-01T00:00Z", DateTimeFormatInfo.CurrentInfo).ToUniversalTime(), res.PremiereDate); + Assert.Contains("Electronic", res.Genres); + Assert.Contains("Ambient", res.Genres); + Assert.Contains("Pop", res.Genres); + Assert.Contains("Jazz", res.Genres); + } + } +} diff --git a/tests/Jellyfin.MediaEncoding.Tests/Subtitles/AssParserTests.cs b/tests/Jellyfin.MediaEncoding.Tests/Subtitles/AssParserTests.cs new file mode 100644 index 0000000000..3775555dec --- /dev/null +++ b/tests/Jellyfin.MediaEncoding.Tests/Subtitles/AssParserTests.cs @@ -0,0 +1,29 @@ +using System; +using System.Globalization; +using System.IO; +using System.Threading; +using MediaBrowser.MediaEncoding.Subtitles; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace Jellyfin.MediaEncoding.Subtitles.Tests +{ + public class AssParserTests + { + [Fact] + public void Parse_Valid_Success() + { + using (var stream = File.OpenRead("Test Data/example.ass")) + { + var parsed = new AssParser(new NullLogger<AssParser>()).Parse(stream, CancellationToken.None); + Assert.Single(parsed.TrackEvents); + var trackEvent = parsed.TrackEvents[0]; + + Assert.Equal("1", trackEvent.Id); + Assert.Equal(TimeSpan.Parse("00:00:01.18", CultureInfo.InvariantCulture).Ticks, trackEvent.StartPositionTicks); + Assert.Equal(TimeSpan.Parse("00:00:06.85", CultureInfo.InvariantCulture).Ticks, trackEvent.EndPositionTicks); + Assert.Equal("{\\pos(400,570)}Like an Angel with pity on nobody" + Environment.NewLine + "The second line in subtitle", trackEvent.Text); + } + } + } +} diff --git a/tests/Jellyfin.MediaEncoding.Tests/Subtitles/SrtParserTests.cs b/tests/Jellyfin.MediaEncoding.Tests/Subtitles/SrtParserTests.cs new file mode 100644 index 0000000000..537a944b03 --- /dev/null +++ b/tests/Jellyfin.MediaEncoding.Tests/Subtitles/SrtParserTests.cs @@ -0,0 +1,35 @@ +using System; +using System.Globalization; +using System.IO; +using System.Threading; +using MediaBrowser.MediaEncoding.Subtitles; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace Jellyfin.MediaEncoding.Subtitles.Tests +{ + public class SrtParserTests + { + [Fact] + public void Parse_Valid_Success() + { + using (var stream = File.OpenRead("Test Data/example.srt")) + { + var parsed = new SrtParser(new NullLogger<SrtParser>()).Parse(stream, CancellationToken.None); + Assert.Equal(2, parsed.TrackEvents.Count); + + var trackEvent1 = parsed.TrackEvents[0]; + Assert.Equal("1", trackEvent1.Id); + Assert.Equal(TimeSpan.Parse("00:02:17.440", CultureInfo.InvariantCulture).Ticks, trackEvent1.StartPositionTicks); + Assert.Equal(TimeSpan.Parse("00:02:20.375", CultureInfo.InvariantCulture).Ticks, trackEvent1.EndPositionTicks); + Assert.Equal("Senator, we're making" + Environment.NewLine + "our final approach into Coruscant.", trackEvent1.Text); + + var trackEvent2 = parsed.TrackEvents[1]; + Assert.Equal("2", trackEvent2.Id); + Assert.Equal(TimeSpan.Parse("00:02:20.476", CultureInfo.InvariantCulture).Ticks, trackEvent2.StartPositionTicks); + Assert.Equal(TimeSpan.Parse("00:02:22.501", CultureInfo.InvariantCulture).Ticks, trackEvent2.EndPositionTicks); + Assert.Equal("Very good, Lieutenant.", trackEvent2.Text); + } + } + } +} diff --git a/tests/Jellyfin.MediaEncoding.Tests/Subtitles/SsaParserTests.cs b/tests/Jellyfin.MediaEncoding.Tests/Subtitles/SsaParserTests.cs new file mode 100644 index 0000000000..5db80c3001 --- /dev/null +++ b/tests/Jellyfin.MediaEncoding.Tests/Subtitles/SsaParserTests.cs @@ -0,0 +1,88 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Text; +using System.Threading; +using MediaBrowser.MediaEncoding.Subtitles; +using MediaBrowser.Model.MediaInfo; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace Jellyfin.MediaEncoding.Subtitles.Tests +{ + public class SsaParserTests + { + private readonly SsaParser _parser = new SsaParser(new NullLogger<AssParser>()); + + [Theory] + [MemberData(nameof(Parse_MultipleDialogues_TestData))] + public void Parse_MultipleDialogues_Success(string ssa, IReadOnlyList<SubtitleTrackEvent> expectedSubtitleTrackEvents) + { + using (Stream stream = new MemoryStream(Encoding.UTF8.GetBytes(ssa))) + { + SubtitleTrackInfo subtitleTrackInfo = _parser.Parse(stream, CancellationToken.None); + + Assert.Equal(expectedSubtitleTrackEvents.Count, subtitleTrackInfo.TrackEvents.Count); + + for (int i = 0; i < expectedSubtitleTrackEvents.Count; ++i) + { + SubtitleTrackEvent expected = expectedSubtitleTrackEvents[i]; + SubtitleTrackEvent actual = subtitleTrackInfo.TrackEvents[i]; + + Assert.Equal(expected.Id, actual.Id); + Assert.Equal(expected.Text, actual.Text); + Assert.Equal(expected.StartPositionTicks, actual.StartPositionTicks); + Assert.Equal(expected.EndPositionTicks, actual.EndPositionTicks); + } + } + } + + public static IEnumerable<object[]> Parse_MultipleDialogues_TestData() + { + yield return new object[] + { + @"[Events] + Format: Layer, Start, End, Text + Dialogue: ,0:00:01.18,0:00:01.85,dialogue1 + Dialogue: ,0:00:02.18,0:00:02.85,dialogue2 + Dialogue: ,0:00:03.18,0:00:03.85,dialogue3 + ", + new List<SubtitleTrackEvent> + { + new SubtitleTrackEvent("1", "dialogue1") + { + StartPositionTicks = 11800000, + EndPositionTicks = 18500000 + }, + new SubtitleTrackEvent("2", "dialogue2") + { + StartPositionTicks = 21800000, + EndPositionTicks = 28500000 + }, + new SubtitleTrackEvent("3", "dialogue3") + { + StartPositionTicks = 31800000, + EndPositionTicks = 38500000 + } + } + }; + } + + [Fact] + public void Parse_Valid_Success() + { + using (var stream = File.OpenRead("Test Data/example.ssa")) + { + var parsed = _parser.Parse(stream, CancellationToken.None); + Assert.Single(parsed.TrackEvents); + var trackEvent = parsed.TrackEvents[0]; + + Assert.Equal("1", trackEvent.Id); + Assert.Equal(TimeSpan.Parse("00:00:01.18", CultureInfo.InvariantCulture).Ticks, trackEvent.StartPositionTicks); + Assert.Equal(TimeSpan.Parse("00:00:06.85", CultureInfo.InvariantCulture).Ticks, trackEvent.EndPositionTicks); + Assert.Equal("{\\pos(400,570)}Like an angel with pity on nobody", trackEvent.Text); + } + } + } +} diff --git a/tests/Jellyfin.MediaEncoding.Tests/Test Data/Probing/music_video_metadata.json b/tests/Jellyfin.MediaEncoding.Tests/Test Data/Probing/music_video_metadata.json new file mode 100644 index 0000000000..97d6600a4a --- /dev/null +++ b/tests/Jellyfin.MediaEncoding.Tests/Test Data/Probing/music_video_metadata.json @@ -0,0 +1,111 @@ +{ + "streams": [ + { + "index": 0, + "codec_name": "h264", + "codec_long_name": "H.264 / AVC / MPEG-4 AVC / MPEG-4 part 10", + "profile": "High", + "codec_type": "video", + "codec_time_base": "1001/48000", + "codec_tag_string": "[0][0][0][0]", + "codec_tag": "0x0000", + "width": 1920, + "height": 1080, + "coded_width": 1920, + "coded_height": 1088, + "closed_captions": 0, + "has_b_frames": 0, + "sample_aspect_ratio": "1:1", + "display_aspect_ratio": "16:9", + "pix_fmt": "yuv420p", + "level": 42, + "chroma_location": "left", + "field_order": "progressive", + "refs": 1, + "is_avc": "true", + "nal_length_size": "4", + "r_frame_rate": "24000/1001", + "avg_frame_rate": "24000/1001", + "time_base": "1/1000", + "start_pts": 0, + "start_time": "0.000000", + "bits_per_raw_sample": "8", + "disposition": { + "default": 1, + "dub": 0, + "original": 0, + "comment": 0, + "lyrics": 0, + "karaoke": 0, + "forced": 0, + "hearing_impaired": 0, + "visual_impaired": 0, + "clean_effects": 0, + "attached_pic": 0, + "timed_thumbnails": 0 + }, + "tags": { + "language": "eng" + } + }, + { + "index": 1, + "codec_name": "aac", + "codec_long_name": "AAC (Advanced Audio Coding)", + "profile": "LC", + "codec_type": "audio", + "codec_time_base": "1/48000", + "codec_tag_string": "[0][0][0][0]", + "codec_tag": "0x0000", + "sample_fmt": "fltp", + "sample_rate": "48000", + "channels": 2, + "channel_layout": "stereo", + "bits_per_sample": 0, + "r_frame_rate": "0/0", + "avg_frame_rate": "0/0", + "time_base": "1/1000", + "start_pts": 0, + "start_time": "0.000000", + "disposition": { + "default": 1, + "dub": 0, + "original": 0, + "comment": 0, + "lyrics": 0, + "karaoke": 0, + "forced": 0, + "hearing_impaired": 0, + "visual_impaired": 0, + "clean_effects": 0, + "attached_pic": 0, + "timed_thumbnails": 0 + }, + "tags": { + "language": "eng" + } + } + ], + "chapters": [ + ], + "format": { + "filename": "music_video.mkv", + "nb_streams": 2, + "nb_programs": 0, + "format_name": "matroska,webm", + "format_long_name": "Matroska / WebM", + "start_time": "0.000000", + "duration": "180.000000", + "size": "500000000", + "bit_rate": "22222222", + "probe_score": 100, + "tags": { + "TITLE-eng": "The Title", + "TITLESORT": "Title, The", + "ARTIST": "The Artist", + "ARTISTSORT": "Artist, The", + "ALBUM": "Album", + "DATE_RELEASED": "2021-01-01" + } + } +} diff --git a/tests/Jellyfin.MediaEncoding.Tests/Test Data/Probing/music_year_only_metadata.json b/tests/Jellyfin.MediaEncoding.Tests/Test Data/Probing/music_year_only_metadata.json new file mode 100644 index 0000000000..ddf890c453 --- /dev/null +++ b/tests/Jellyfin.MediaEncoding.Tests/Test Data/Probing/music_year_only_metadata.json @@ -0,0 +1,147 @@ +{ + "streams": [ + { + "index": 0, + "codec_name": "flac", + "codec_long_name": "FLAC (Free Lossless Audio Codec)", + "codec_type": "audio", + "codec_tag_string": "[0][0][0][0]", + "codec_tag": "0x0000", + "sample_fmt": "s16", + "sample_rate": "44100", + "channels": 2, + "channel_layout": "stereo", + "bits_per_sample": 0, + "r_frame_rate": "0/0", + "avg_frame_rate": "0/0", + "time_base": "1/44100", + "start_pts": 0, + "start_time": "0.000000", + "duration_ts": 16394616, + "duration": "371.760000", + "bits_per_raw_sample": "16", + "disposition": { + "default": 0, + "dub": 0, + "original": 0, + "comment": 0, + "lyrics": 0, + "karaoke": 0, + "forced": 0, + "hearing_impaired": 0, + "visual_impaired": 0, + "clean_effects": 0, + "attached_pic": 0, + "timed_thumbnails": 0 + } + }, + { + "index": 1, + "codec_name": "mjpeg", + "codec_long_name": "Motion JPEG", + "profile": "Baseline", + "codec_type": "video", + "codec_tag_string": "[0][0][0][0]", + "codec_tag": "0x0000", + "width": 500, + "height": 498, + "coded_width": 500, + "coded_height": 498, + "closed_captions": 0, + "has_b_frames": 0, + "sample_aspect_ratio": "1:1", + "display_aspect_ratio": "250:249", + "pix_fmt": "yuvj420p", + "level": -99, + "color_range": "pc", + "color_space": "bt470bg", + "chroma_location": "center", + "refs": 1, + "r_frame_rate": "90000/1", + "avg_frame_rate": "0/0", + "time_base": "1/90000", + "start_pts": 0, + "start_time": "0.000000", + "duration_ts": 33458400, + "duration": "371.760000", + "bits_per_raw_sample": "8", + "disposition": { + "default": 0, + "dub": 0, + "original": 0, + "comment": 0, + "lyrics": 0, + "karaoke": 0, + "forced": 0, + "hearing_impaired": 0, + "visual_impaired": 0, + "clean_effects": 0, + "attached_pic": 1, + "timed_thumbnails": 0 + }, + "tags": { + "comment": "Cover (front)" + } + } + ], + "format": { + "filename": "02 Baker Street.flac", + "nb_streams": 2, + "nb_programs": 0, + "format_name": "flac", + "format_long_name": "raw FLAC", + "start_time": "0.000000", + "duration": "371.760000", + "size": "37072649", + "bit_rate": "797775", + "probe_score": 100, + "tags": { + "MUSICBRAINZ_RELEASEGROUPID": "238c3fb4-5792-342b-b217-02f66298b424", + "ORIGINALDATE": "1978", + "ORIGINALYEAR": "1978", + "RELEASETYPE": "album", + "MUSICBRAINZ_ALBUMID": "30156786-e511-3106-ac95-66f0e880b24b", + "ASIN": "B000007O5H", + "MUSICBRAINZ_ALBUMARTISTID": "563201cb-721c-4cfb-acca-c1ba69e3d1fb", + "album_artist": "Gerry Rafferty", + "ALBUMARTISTSORT": "Rafferty, Gerry", + "LABEL": "Liberty EMI Records UK", + "CATALOGNUMBER": "CDP 7 46049 2", + "DATE": "1989-07-26", + "RELEASECOUNTRY": "GB", + "BARCODE": "077774604925", + "ALBUM": "City to City", + "SCRIPT": "Latn", + "RELEASESTATUS": "official", + "TOTALDISCS": "1", + "disc": "1", + "MEDIA": "CD", + "TOTALTRACKS": "10", + "MUSICBRAINZ_TRACKID": "9235e22e-afbd-48f7-b329-21dae6da2810", + "ISRC": "GBAYE1100924;GBAYE7800619", + "PERFORMER": "Hugh Burns (electric guitar);Nigel Jenkins (electric guitar);Tommy Eyre (keyboard and Moog);Glen LeFleur (percussion);Raphael Ravenscroft (saxophone);Henry Spinetti (drums (drum set));Gary Taylor (bass);Gerry Rafferty (lead vocals)", + "ARRANGER": "Graham Preskett", + "MIXER": "Declan O’Doherty", + "PRODUCER": "Hugh Murphy;Gerry Rafferty", + "MUSICBRAINZ_WORKID": "a9eb3c45-784c-3c32-860c-4b406f03961b", + "LANGUAGE": "eng", + "WORK": "Baker Street", + "COMPOSER": "Gerry Rafferty", + "COMPOSERSORT": "Rafferty, Gerry", + "LYRICIST": "Gerry Rafferty", + "TITLE": "Baker Street", + "MUSICBRAINZ_ARTISTID": "563201cb-721c-4cfb-acca-c1ba69e3d1fb", + "ARTIST": "Gerry Rafferty", + "ARTISTSORT": "Rafferty, Gerry", + "ARTISTS": "Gerry Rafferty", + "MUSICBRAINZ_RELEASETRACKID": "407cf7f7-440d-3e76-8b89-8686198868ea", + "track": "2", + "GENRE": "Electronic;Ambient;Pop;Jazz", + "WEBSITE": "http://www.gerryrafferty.com/", + "ACOUSTID_ID": "68f8d979-a659-4aa0-a216-eb3721a951eb", + "MOOD": "Acoustic;Not aggressive;Not electronic;Not happy;Party;Relaxed;Not sad", + "TRACKTOTAL": "10", + "DISCTOTAL": "1" + } + } +} diff --git a/tests/Jellyfin.MediaEncoding.Tests/Test Data/Probing/video_metadata.json b/tests/Jellyfin.MediaEncoding.Tests/Test Data/Probing/video_metadata.json new file mode 100644 index 0000000000..720fc5c8fa --- /dev/null +++ b/tests/Jellyfin.MediaEncoding.Tests/Test Data/Probing/video_metadata.json @@ -0,0 +1,74 @@ +{ + "streams": [ + { + "index": 0, + "codec_name": "h264", + "codec_long_name": "H.264 / AVC / MPEG-4 AVC / MPEG-4 part 10", + "profile": "High 4:4:4 Predictive", + "codec_type": "video", + "codec_time_base": "1/50", + "codec_tag_string": "[0][0][0][0]", + "codec_tag": "0x0000", + "width": 320, + "height": 240, + "coded_width": 320, + "coded_height": 240, + "closed_captions": 0, + "has_b_frames": 2, + "sample_aspect_ratio": "1:1", + "display_aspect_ratio": "4:3", + "pix_fmt": "yuv444p", + "level": 13, + "chroma_location": "left", + "field_order": "progressive", + "refs": 1, + "is_avc": "true", + "nal_length_size": "4", + "r_frame_rate": "25/1", + "avg_frame_rate": "25/1", + "time_base": "1/1000", + "start_pts": 0, + "start_time": "0.000000", + "bits_per_raw_sample": "8", + "disposition": { + "default": 1, + "dub": 0, + "original": 0, + "comment": 0, + "lyrics": 0, + "karaoke": 0, + "forced": 0, + "hearing_impaired": 0, + "visual_impaired": 0, + "clean_effects": 0, + "attached_pic": 0, + "timed_thumbnails": 0 + }, + "tags": { + "ENCODER": "Lavc57.107.100 libx264", + "DURATION": "00:00:01.000000000" + } + } + ], + "chapters": [ + + ], + "format": { + "filename": "some_metadata.mkv", + "nb_streams": 1, + "nb_programs": 0, + "format_name": "matroska,webm", + "format_long_name": "Matroska / WebM", + "start_time": "0.000000", + "duration": "1.000000", + "size": "8679", + "bit_rate": "69432", + "probe_score": 100, + "tags": { + "DESCRIPTION": "Just color bars", + "ARCHIVAL": "yes", + "PRESERVE_THIS": "okay", + "ENCODER": "Lavf57.83.100" + } + } +} diff --git a/tests/Jellyfin.MediaEncoding.Tests/Test Data/example.ass b/tests/Jellyfin.MediaEncoding.Tests/Test Data/example.ass new file mode 100644 index 0000000000..d5ac31d702 --- /dev/null +++ b/tests/Jellyfin.MediaEncoding.Tests/Test Data/example.ass @@ -0,0 +1,22 @@ +[Script Info] +; Script generated by Aegisub +; http://www.aegisub.org +Title: Neon Genesis Evangelion - Episode 26 (neutral Spanish) +Original Script: RoRo +Script Updated By: version 2.8.01 +ScriptType: v4.00+ +Collisions: Normal +PlayResY: 600 +PlayDepth: 0 +Timer: 100,0000 +Video Aspect Ratio: 0 +Video Zoom: 6 +Video Position: 0 + +[V4+ Styles] +Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding +Style: DefaultVCD, Arial,28,&H00B4FCFC,&H00B4FCFC,&H00000008,&H80000008,-1,0,0,0,100,100,0.00,0.00,1,1.00,2.00,2,30,30,30,0 + +[Events] +Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text +Dialogue: 0,0:00:01.18,0:00:06.85,DefaultVCD, NTP,0000,0000,0000,,{\pos(400,570)}Like an Angel with pity on nobody\NThe second line in subtitle diff --git a/tests/Jellyfin.MediaEncoding.Tests/Test Data/example.srt b/tests/Jellyfin.MediaEncoding.Tests/Test Data/example.srt new file mode 100644 index 0000000000..78d74014ef --- /dev/null +++ b/tests/Jellyfin.MediaEncoding.Tests/Test Data/example.srt @@ -0,0 +1,8 @@ +1 +00:02:17,440 --> 00:02:20,375 +Senator, we're making +our final approach into Coruscant. + +2 +00:02:20,476 --> 00:02:22,501 +Very good, Lieutenant. diff --git a/tests/Jellyfin.MediaEncoding.Tests/Test Data/example.ssa b/tests/Jellyfin.MediaEncoding.Tests/Test Data/example.ssa new file mode 100644 index 0000000000..dcbb972eb9 --- /dev/null +++ b/tests/Jellyfin.MediaEncoding.Tests/Test Data/example.ssa @@ -0,0 +1,20 @@ +[Script Info] +; This is a Sub Station Alpha v4 script. +; For Sub Station Alpha info and downloads, +; go to http://www.eswat.demon.co.uk/ +Title: Neon Genesis Evangelion - Episode 26 (neutral Spanish) +Original Script: RoRo +Script Updated By: version 2.8.01 +ScriptType: v4.00 +Collisions: Normal +PlayResY: 600 +PlayDepth: 0 +Timer: 100,0000 + +[V4 Styles] +Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, TertiaryColour, BackColour, Bold, Italic, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, AlphaLevel, Encoding +Style: DefaultVCD, Arial,28,11861244,11861244,11861244,-2147483640,-1,0,1,1,2,2,30,30,30,0,0 + +[Events] +Format: Marked, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text +Dialogue: Marked=0,0:00:01.18,0:00:06.85,DefaultVCD, NTP,0000,0000,0000,,{\pos(400,570)}Like an angel with pity on nobody diff --git a/tests/Jellyfin.Model.Tests/Dlna/ContainerProfileTests.cs b/tests/Jellyfin.Model.Tests/Dlna/ContainerProfileTests.cs new file mode 100644 index 0000000000..cca056c280 --- /dev/null +++ b/tests/Jellyfin.Model.Tests/Dlna/ContainerProfileTests.cs @@ -0,0 +1,19 @@ +using MediaBrowser.Model.Dlna; +using Xunit; + +namespace Jellyfin.Model.Tests.Dlna +{ + public class ContainerProfileTests + { + private readonly ContainerProfile _emptyContainerProfile = new ContainerProfile(); + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData("mp4")] + public void ContainsContainer_EmptyContainerProfile_True(string? containers) + { + Assert.True(_emptyContainerProfile.ContainsContainer(containers)); + } + } +} diff --git a/tests/Jellyfin.Model.Tests/Entities/MediaStreamTests.cs b/tests/Jellyfin.Model.Tests/Entities/MediaStreamTests.cs new file mode 100644 index 0000000000..e2274e19ee --- /dev/null +++ b/tests/Jellyfin.Model.Tests/Entities/MediaStreamTests.cs @@ -0,0 +1,76 @@ +using MediaBrowser.Model.Entities; +using Xunit; + +namespace Jellyfin.Model.Tests.Entities +{ + public class MediaStreamTests + { + [Theory] + [InlineData(null, null, false, null)] + [InlineData(null, 0, false, null)] + [InlineData(0, null, false, null)] + [InlineData(640, 480, false, "480p")] + [InlineData(640, 480, true, "480i")] + [InlineData(720, 576, false, "576p")] + [InlineData(720, 576, true, "576i")] + [InlineData(960, 540, false, "540p")] + [InlineData(960, 540, true, "540i")] + [InlineData(1280, 720, false, "720p")] + [InlineData(1280, 720, true, "720i")] + [InlineData(1920, 1080, false, "1080p")] + [InlineData(1920, 1080, true, "1080i")] + [InlineData(4096, 3072, false, "4K")] + [InlineData(8192, 6144, false, "8K")] + [InlineData(512, 384, false, "480p")] + [InlineData(576, 336, false, "480p")] + [InlineData(624, 352, false, "480p")] + [InlineData(640, 352, false, "480p")] + [InlineData(704, 396, false, "480p")] + [InlineData(720, 404, false, "480p")] + [InlineData(720, 480, false, "480p")] + [InlineData(768, 576, false, "576p")] + [InlineData(960, 720, false, "720p")] + [InlineData(1280, 528, false, "720p")] + [InlineData(1280, 532, false, "720p")] + [InlineData(1280, 534, false, "720p")] + [InlineData(1280, 536, false, "720p")] + [InlineData(1280, 544, false, "720p")] + [InlineData(1280, 690, false, "720p")] + [InlineData(1280, 694, false, "720p")] + [InlineData(1280, 696, false, "720p")] + [InlineData(1280, 716, false, "720p")] + [InlineData(1280, 718, false, "720p")] + [InlineData(1912, 792, false, "1080p")] + [InlineData(1916, 1076, false, "1080p")] + [InlineData(1918, 1080, false, "1080p")] + [InlineData(1920, 796, false, "1080p")] + [InlineData(1920, 800, false, "1080p")] + [InlineData(1920, 802, false, "1080p")] + [InlineData(1920, 804, false, "1080p")] + [InlineData(1920, 808, false, "1080p")] + [InlineData(1920, 816, false, "1080p")] + [InlineData(1920, 856, false, "1080p")] + [InlineData(1920, 960, false, "1080p")] + [InlineData(1920, 1024, false, "1080p")] + [InlineData(1920, 1040, false, "1080p")] + [InlineData(1920, 1072, false, "1080p")] + [InlineData(1440, 1072, false, "1080p")] + [InlineData(1440, 1080, false, "1080p")] + [InlineData(3840, 1600, false, "4K")] + [InlineData(3840, 1606, false, "4K")] + [InlineData(3840, 1608, false, "4K")] + [InlineData(3840, 2160, false, "4K")] + [InlineData(7680, 4320, false, "8K")] + public void GetResolutionText_Valid(int? width, int? height, bool interlaced, string expected) + { + var mediaStream = new MediaStream() + { + Width = width, + Height = height, + IsInterlaced = interlaced + }; + + Assert.Equal(expected, mediaStream.GetResolutionText()); + } + } +} diff --git a/tests/Jellyfin.Model.Tests/Entities/ProviderIdsExtensionsTests.cs b/tests/Jellyfin.Model.Tests/Entities/ProviderIdsExtensionsTests.cs new file mode 100644 index 0000000000..a1ace84769 --- /dev/null +++ b/tests/Jellyfin.Model.Tests/Entities/ProviderIdsExtensionsTests.cs @@ -0,0 +1,196 @@ +using System; +using System.Collections.Generic; +using MediaBrowser.Model.Entities; +using Xunit; + +namespace Jellyfin.Model.Tests.Entities +{ + public class ProviderIdsExtensionsTests + { + private const string ExampleImdbId = "tt0113375"; + + [Fact] + public void HasProviderId_NullInstance_ThrowsArgumentNullException() + { + Assert.Throws<ArgumentNullException>(() => ProviderIdsExtensions.HasProviderId(null!, MetadataProvider.Imdb)); + } + + [Fact] + public void HasProviderId_NullProvider_False() + { + var nullProvider = new ProviderIdsExtensionsTestsObject + { + ProviderIds = null! + }; + + Assert.False(nullProvider.HasProviderId(MetadataProvider.Imdb)); + } + + [Fact] + public void HasProviderId_NullName_ThrowsArgumentNullException() + { + Assert.Throws<ArgumentNullException>(() => ProviderIdsExtensionsTestsObject.Empty.HasProviderId(null!)); + } + + [Fact] + public void HasProviderId_NotFoundName_False() + { + Assert.False(ProviderIdsExtensionsTestsObject.Empty.HasProviderId(MetadataProvider.Imdb)); + } + + [Fact] + public void HasProviderId_FoundName_True() + { + var provider = new ProviderIdsExtensionsTestsObject(); + provider.ProviderIds[MetadataProvider.Imdb.ToString()] = ExampleImdbId; + + Assert.True(provider.HasProviderId(MetadataProvider.Imdb)); + } + + [Fact] + public void HasProviderId_FoundNameEmptyValue_False() + { + var provider = new ProviderIdsExtensionsTestsObject(); + provider.ProviderIds[MetadataProvider.Imdb.ToString()] = string.Empty; + + Assert.False(provider.HasProviderId(MetadataProvider.Imdb)); + } + + [Fact] + public void GetProviderId_NullInstance_ThrowsArgumentNullException() + { + Assert.Throws<ArgumentNullException>(() => ProviderIdsExtensions.GetProviderId(null!, MetadataProvider.Imdb)); + } + + [Fact] + public void GetProviderId_NullName_ThrowsArgumentNullException() + { + Assert.Throws<ArgumentNullException>(() => ProviderIdsExtensionsTestsObject.Empty.GetProviderId(null!)); + } + + [Fact] + public void GetProviderId_NotFoundName_Null() + { + Assert.Null(ProviderIdsExtensionsTestsObject.Empty.GetProviderId(MetadataProvider.Imdb)); + } + + [Fact] + public void GetProviderId_NullProvider_Null() + { + var nullProvider = new ProviderIdsExtensionsTestsObject + { + ProviderIds = null! + }; + + Assert.Null(nullProvider.GetProviderId(MetadataProvider.Imdb)); + } + + [Fact] + public void TryGetProviderId_NotFoundName_False() + { + Assert.False(ProviderIdsExtensionsTestsObject.Empty.TryGetProviderId(MetadataProvider.Imdb, out _)); + } + + [Fact] + public void TryGetProviderId_NullProvider_False() + { + var nullProvider = new ProviderIdsExtensionsTestsObject + { + ProviderIds = null! + }; + + Assert.False(nullProvider.TryGetProviderId(MetadataProvider.Imdb, out _)); + } + + [Fact] + public void GetProviderId_FoundName_Id() + { + var provider = new ProviderIdsExtensionsTestsObject(); + provider.ProviderIds[MetadataProvider.Imdb.ToString()] = ExampleImdbId; + + Assert.Equal(ExampleImdbId, provider.GetProviderId(MetadataProvider.Imdb)); + } + + [Fact] + public void TryGetProviderId_FoundName_True() + { + var provider = new ProviderIdsExtensionsTestsObject(); + provider.ProviderIds[MetadataProvider.Imdb.ToString()] = ExampleImdbId; + + Assert.True(provider.TryGetProviderId(MetadataProvider.Imdb, out var id)); + Assert.Equal(ExampleImdbId, id); + } + + [Fact] + public void TryGetProviderId_FoundNameEmptyValue_False() + { + var provider = new ProviderIdsExtensionsTestsObject(); + provider.ProviderIds[MetadataProvider.Imdb.ToString()] = string.Empty; + + Assert.False(provider.TryGetProviderId(MetadataProvider.Imdb, out var id)); + Assert.Null(id); + } + + [Fact] + public void SetProviderId_NullInstance_ThrowsArgumentNullException() + { + Assert.Throws<ArgumentNullException>(() => ProviderIdsExtensions.SetProviderId(null!, MetadataProvider.Imdb, ExampleImdbId)); + } + + [Fact] + public void SetProviderId_Null_Remove() + { + var provider = new ProviderIdsExtensionsTestsObject(); + provider.SetProviderId(MetadataProvider.Imdb, null!); + Assert.Empty(provider.ProviderIds); + } + + [Fact] + public void SetProviderId_EmptyName_Remove() + { + var provider = new ProviderIdsExtensionsTestsObject(); + provider.ProviderIds[MetadataProvider.Imdb.ToString()] = ExampleImdbId; + provider.SetProviderId(MetadataProvider.Imdb, string.Empty); + Assert.Empty(provider.ProviderIds); + } + + [Fact] + public void SetProviderId_NonEmptyId_Success() + { + var provider = new ProviderIdsExtensionsTestsObject(); + provider.SetProviderId(MetadataProvider.Imdb, ExampleImdbId); + Assert.Single(provider.ProviderIds); + } + + [Fact] + public void SetProviderId_NullProvider_Success() + { + var nullProvider = new ProviderIdsExtensionsTestsObject + { + ProviderIds = null! + }; + + nullProvider.SetProviderId(MetadataProvider.Imdb, ExampleImdbId); + Assert.Single(nullProvider.ProviderIds); + } + + [Fact] + public void SetProviderId_NullProviderAndEmptyName_Success() + { + var nullProvider = new ProviderIdsExtensionsTestsObject + { + ProviderIds = null! + }; + + nullProvider.SetProviderId(MetadataProvider.Imdb, string.Empty); + Assert.Null(nullProvider.ProviderIds); + } + + private class ProviderIdsExtensionsTestsObject : IHasProviderIds + { + public static readonly ProviderIdsExtensionsTestsObject Empty = new ProviderIdsExtensionsTestsObject(); + + public Dictionary<string, string> ProviderIds { get; set; } = new Dictionary<string, string>(); + } + } +} diff --git a/tests/Jellyfin.Model.Tests/Extensions/StringHelperTests.cs b/tests/Jellyfin.Model.Tests/Extensions/StringHelperTests.cs index 51633e157c..0a4e060df6 100644 --- a/tests/Jellyfin.Model.Tests/Extensions/StringHelperTests.cs +++ b/tests/Jellyfin.Model.Tests/Extensions/StringHelperTests.cs @@ -1,4 +1,6 @@ using System; +using FsCheck; +using FsCheck.Xunit; using MediaBrowser.Model.Extensions; using Xunit; @@ -11,9 +13,20 @@ namespace Jellyfin.Model.Tests.Extensions [InlineData("banana", "Banana")] [InlineData("Banana", "Banana")] [InlineData("ä", "Ä")] + [InlineData("\027", "\027")] public void StringHelper_ValidArgs_Success(string input, string expectedResult) { Assert.Equal(expectedResult, StringHelper.FirstToUpper(input)); } + + [Property] + public Property FirstToUpper_RandomArg_Correct(NonEmptyString input) + { + var result = StringHelper.FirstToUpper(input.Item); + + // We check IsLower instead of IsUpper because both return false for non-letters + return (!char.IsLower(result[0])).Label("First char is uppercase") + .And(input.Item.Length == 1 || result[1..].Equals(input.Item[1..], StringComparison.Ordinal)).Label("Remaining chars are unmodified"); + } } } diff --git a/tests/Jellyfin.Model.Tests/Jellyfin.Model.Tests.csproj b/tests/Jellyfin.Model.Tests/Jellyfin.Model.Tests.csproj index e933851366..5371853bc0 100644 --- a/tests/Jellyfin.Model.Tests/Jellyfin.Model.Tests.csproj +++ b/tests/Jellyfin.Model.Tests/Jellyfin.Model.Tests.csproj @@ -1,22 +1,21 @@ <Project Sdk="Microsoft.NET.Sdk"> <PropertyGroup> - <TargetFramework>netcoreapp3.1</TargetFramework> + <TargetFramework>net5.0</TargetFramework> <IsPackable>false</IsPackable> - <TreatWarningsAsErrors>true</TreatWarningsAsErrors> - <Nullable>enable</Nullable> + <CodeAnalysisRuleSet>../jellyfin-tests.ruleset</CodeAnalysisRuleSet> </PropertyGroup> <ItemGroup> - <PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.5.0" /> + <PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.11.0" /> <PackageReference Include="xunit" Version="2.4.1" /> <PackageReference Include="xunit.runner.visualstudio" Version="2.4.1" /> - <PackageReference Include="coverlet.collector" Version="1.2.1" /> + <PackageReference Include="coverlet.collector" Version="3.1.0" /> + <PackageReference Include="FsCheck.Xunit" Version="2.16.0" /> </ItemGroup> <!-- Code Analyzers --> <ItemGroup Condition=" '$(Configuration)' == 'Debug' "> - <PackageReference Include="Microsoft.CodeAnalysis.FxCopAnalyzers" Version="2.9.8" PrivateAssets="All" /> <PackageReference Include="SerilogAnalyzer" Version="0.15.0" PrivateAssets="All" /> <PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="All" /> <PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" PrivateAssets="All" /> @@ -26,8 +25,4 @@ <ProjectReference Include="../../MediaBrowser.Model/MediaBrowser.Model.csproj" /> </ItemGroup> - <PropertyGroup Condition=" '$(Configuration)' == 'Debug' "> - <CodeAnalysisRuleSet>../jellyfin-tests.ruleset</CodeAnalysisRuleSet> - </PropertyGroup> - </Project> diff --git a/tests/Jellyfin.Naming.Tests/AudioBook/AudioBookFileInfoTests.cs b/tests/Jellyfin.Naming.Tests/AudioBook/AudioBookFileInfoTests.cs index a214bc57c4..cf21f964e2 100644 --- a/tests/Jellyfin.Naming.Tests/AudioBook/AudioBookFileInfoTests.cs +++ b/tests/Jellyfin.Naming.Tests/AudioBook/AudioBookFileInfoTests.cs @@ -1,4 +1,4 @@ -using Emby.Naming.AudioBook; +using Emby.Naming.AudioBook; using Xunit; namespace Jellyfin.Naming.Tests.AudioBook @@ -8,22 +8,22 @@ namespace Jellyfin.Naming.Tests.AudioBook [Fact] public void CompareTo_Same_Success() { - var info = new AudioBookFileInfo(); + var info = new AudioBookFileInfo(string.Empty, string.Empty); Assert.Equal(0, info.CompareTo(info)); } [Fact] public void CompareTo_Null_Success() { - var info = new AudioBookFileInfo(); + var info = new AudioBookFileInfo(string.Empty, string.Empty); Assert.Equal(1, info.CompareTo(null)); } [Fact] public void CompareTo_Empty_Success() { - var info1 = new AudioBookFileInfo(); - var info2 = new AudioBookFileInfo(); + var info1 = new AudioBookFileInfo(string.Empty, string.Empty); + var info2 = new AudioBookFileInfo(string.Empty, string.Empty); Assert.Equal(0, info1.CompareTo(info2)); } } diff --git a/tests/Jellyfin.Naming.Tests/AudioBook/AudioBookListResolverTests.cs b/tests/Jellyfin.Naming.Tests/AudioBook/AudioBookListResolverTests.cs index 1084e20bda..d9e77dd2e0 100644 --- a/tests/Jellyfin.Naming.Tests/AudioBook/AudioBookListResolverTests.cs +++ b/tests/Jellyfin.Naming.Tests/AudioBook/AudioBookListResolverTests.cs @@ -1,4 +1,5 @@ -using System.Linq; +using System; +using System.Linq; using Emby.Naming.AudioBook; using Emby.Naming.Common; using MediaBrowser.Model.IO; @@ -18,11 +19,22 @@ namespace Jellyfin.Naming.Tests.AudioBook { "Harry Potter and the Deathly Hallows/Part 1.mp3", "Harry Potter and the Deathly Hallows/Part 2.mp3", - "Harry Potter and the Deathly Hallows/book.nfo", + "Harry Potter and the Deathly Hallows/Extra.mp3", "Batman/Chapter 1.mp3", "Batman/Chapter 2.mp3", "Batman/Chapter 3.mp3", + + "Badman/audiobook.mp3", + "Badman/extra.mp3", + + "Superman (2020)/Part 1.mp3", + "Superman (2020)/extra.mp3", + + "Ready Player One (2020)/audiobook.mp3", + "Ready Player One (2020)/extra.mp3", + + ".mp3" }; var resolver = GetResolver(); @@ -33,13 +45,141 @@ namespace Jellyfin.Naming.Tests.AudioBook FullName = i })).ToList(); + Assert.Equal(5, result.Count); + Assert.Equal(2, result[0].Files.Count); - // Assert.Empty(result[0].Extras); FIXME: AudioBookListResolver should resolve extra files properly + Assert.Single(result[0].Extras); Assert.Equal("Harry Potter and the Deathly Hallows", result[0].Name); Assert.Equal(3, result[1].Files.Count); Assert.Empty(result[1].Extras); Assert.Equal("Batman", result[1].Name); + + Assert.Single(result[2].Files); + Assert.Single(result[2].Extras); + Assert.Equal("Badman", result[2].Name); + + Assert.Single(result[3].Files); + Assert.Single(result[3].Extras); + Assert.Equal("Superman", result[3].Name); + + Assert.Single(result[4].Files); + Assert.Single(result[4].Extras); + Assert.Equal("Ready Player One", result[4].Name); + } + + [Fact] + public void TestAlternativeVersions() + { + var files = new[] + { + "Harry Potter and the Deathly Hallows/Chapter 1.ogg", + "Harry Potter and the Deathly Hallows/Chapter 1.mp3", + + "Deadpool.mp3", + "Deadpool [HQ].mp3", + + "Superman/audiobook.mp3", + "Superman/Superman.mp3", + "Superman/Superman [HQ].mp3", + "Superman/extra.mp3", + + "Batman/ Chapter 1 .mp3", + "Batman/Chapter 1[loss-less].mp3" + }; + + var resolver = GetResolver(); + + var result = resolver.Resolve(files.Select(i => new FileSystemMetadata + { + IsDirectory = false, + FullName = i + })).ToList(); + + Assert.Equal(5, result.Count); + // HP - Same name so we don't care which file is alternative + Assert.Single(result[0].AlternateVersions); + // DP + Assert.Empty(result[1].AlternateVersions); + // DP HQ (directory missing so we do not group deadpools together) + Assert.Empty(result[2].AlternateVersions); + // Superman + // Priority: + // 1. Name + // 2. audiobook + // 3. Names with modifiers + Assert.Equal(2, result[3].AlternateVersions.Count); + var paths = result[3].AlternateVersions.Select(x => x.Path).ToList(); + Assert.Contains("Superman/audiobook.mp3", paths); + Assert.Contains("Superman/Superman [HQ].mp3", paths); + // Batman + Assert.Single(result[4].AlternateVersions); + } + + [Fact] + public void TestNameYearExtraction() + { + var data = new[] + { + new NameYearPath + { + Name = "Harry Potter and the Deathly Hallows", + Path = "Harry Potter and the Deathly Hallows (2007)/Chapter 1.ogg", + Year = 2007 + }, + new NameYearPath + { + Name = "Batman", + Path = "Batman (2020).ogg", + Year = 2020 + }, + new NameYearPath + { + Name = "Batman", + Path = "Batman( 2021 ).mp3", + Year = 2021 + }, + new NameYearPath + { + Name = "Batman(*2021*)", + Path = "Batman(*2021*).mp3", + Year = null + }, + new NameYearPath + { + Name = "Batman", + Path = "Batman.mp3", + Year = null + }, + new NameYearPath + { + Name = "+ Batman .", + Path = " + Batman . .mp3", + Year = null + }, + new NameYearPath + { + Name = " ", + Path = " .mp3", + Year = null + } + }; + + var resolver = GetResolver(); + + var result = resolver.Resolve(data.Select(i => new FileSystemMetadata + { + IsDirectory = false, + FullName = i.Path + })).ToList(); + + Assert.Equal(data.Length, result.Count); + + for (int i = 0; i < data.Length; i++) + { + Assert.Equal(data[i].Name, result[i].Name); + Assert.Equal(data[i].Year, result[i].Year); + } } [Fact] @@ -82,9 +222,51 @@ namespace Jellyfin.Naming.Tests.AudioBook Assert.Single(result); } + [Fact] + public void TestWithoutFolder() + { + var files = new[] + { + "Harry Potter and the Deathly Hallows trailer.mp3" + }; + + var resolver = GetResolver(); + + var result = resolver.Resolve(files.Select(i => new FileSystemMetadata + { + IsDirectory = false, + FullName = i + })).ToList(); + + Assert.Single(result); + } + + [Fact] + public void TestEmpty() + { + var files = Array.Empty<string>(); + + var resolver = GetResolver(); + + var result = resolver.Resolve(files.Select(i => new FileSystemMetadata + { + IsDirectory = false, + FullName = i + })).ToList(); + + Assert.Empty(result); + } + private AudioBookListResolver GetResolver() { return new AudioBookListResolver(_namingOptions); } + + internal struct NameYearPath + { + public string Name; + public string Path; + public int? Year; + } } } diff --git a/tests/Jellyfin.Naming.Tests/AudioBook/AudioBookResolverTests.cs b/tests/Jellyfin.Naming.Tests/AudioBook/AudioBookResolverTests.cs index 673289436d..53b35c2d6c 100644 --- a/tests/Jellyfin.Naming.Tests/AudioBook/AudioBookResolverTests.cs +++ b/tests/Jellyfin.Naming.Tests/AudioBook/AudioBookResolverTests.cs @@ -1,4 +1,3 @@ -using System; using System.Collections.Generic; using Emby.Naming.AudioBook; using Emby.Naming.Common; @@ -10,39 +9,33 @@ namespace Jellyfin.Naming.Tests.AudioBook { private readonly NamingOptions _namingOptions = new NamingOptions(); - public static IEnumerable<object[]> GetResolveFileTestData() + public static IEnumerable<object[]> Resolve_ValidFileNameTestData() { yield return new object[] { - new AudioBookFileInfo() - { - Path = @"/server/AudioBooks/Larry Potter/Larry Potter.mp3", - Container = "mp3", - } + new AudioBookFileInfo( + @"/server/AudioBooks/Larry Potter/Larry Potter.mp3", + "mp3") }; yield return new object[] { - new AudioBookFileInfo() - { - Path = @"/server/AudioBooks/Berry Potter/Chapter 1 .ogg", - Container = "ogg", - ChapterNumber = 1 - } + new AudioBookFileInfo( + @"/server/AudioBooks/Berry Potter/Chapter 1 .ogg", + "ogg", + chapterNumber: 1) }; yield return new object[] { - new AudioBookFileInfo() - { - Path = @"/server/AudioBooks/Nerry Potter/Part 3 - Chapter 2.mp3", - Container = "mp3", - ChapterNumber = 2, - PartNumber = 3 - } + new AudioBookFileInfo( + @"/server/AudioBooks/Nerry Potter/Part 3 - Chapter 2.mp3", + "mp3", + chapterNumber: 2, + partNumber: 3) }; } [Theory] - [MemberData(nameof(GetResolveFileTestData))] + [MemberData(nameof(Resolve_ValidFileNameTestData))] public void Resolve_ValidFileName_Success(AudioBookFileInfo expectedResult) { var result = new AudioBookResolver(_namingOptions).Resolve(expectedResult.Path); @@ -52,13 +45,22 @@ namespace Jellyfin.Naming.Tests.AudioBook Assert.Equal(result!.Container, expectedResult.Container); Assert.Equal(result!.ChapterNumber, expectedResult.ChapterNumber); Assert.Equal(result!.PartNumber, expectedResult.PartNumber); - Assert.Equal(result!.IsDirectory, expectedResult.IsDirectory); } [Fact] - public void Resolve_EmptyFileName_ArgumentException() + public void Resolve_InvalidExtension() { - Assert.Throws<ArgumentException>(() => new AudioBookResolver(_namingOptions).Resolve(string.Empty)); + var result = new AudioBookResolver(_namingOptions).Resolve(@"/server/AudioBooks/Larry Potter/Larry Potter.mp9"); + + Assert.Null(result); + } + + [Fact] + public void Resolve_EmptyFileName() + { + var result = new AudioBookResolver(_namingOptions).Resolve(string.Empty); + + Assert.Null(result); } } } diff --git a/tests/Jellyfin.Naming.Tests/Common/NamingOptionsTest.cs b/tests/Jellyfin.Naming.Tests/Common/NamingOptionsTest.cs new file mode 100644 index 0000000000..3892d00f61 --- /dev/null +++ b/tests/Jellyfin.Naming.Tests/Common/NamingOptionsTest.cs @@ -0,0 +1,36 @@ +using Emby.Naming.Common; +using Xunit; + +namespace Jellyfin.Naming.Tests.Common +{ + public class NamingOptionsTest + { + [Fact] + public void TestNamingOptionsCompile() + { + var options = new NamingOptions(); + + Assert.NotEmpty(options.VideoFileStackingRegexes); + Assert.NotEmpty(options.CleanDateTimeRegexes); + Assert.NotEmpty(options.CleanStringRegexes); + Assert.NotEmpty(options.EpisodeWithoutSeasonRegexes); + Assert.NotEmpty(options.EpisodeMultiPartRegexes); + } + + [Fact] + public void TestNamingOptionsEpisodeExpressions() + { + var exp = new EpisodeExpression(string.Empty); + + Assert.False(exp.IsOptimistic); + exp.IsOptimistic = true; + Assert.True(exp.IsOptimistic); + + Assert.Equal(string.Empty, exp.Expression); + Assert.NotNull(exp.Regex); + exp.Expression = "test"; + Assert.Equal("test", exp.Expression); + Assert.NotNull(exp.Regex); + } + } +} diff --git a/tests/Jellyfin.Naming.Tests/Jellyfin.Naming.Tests.csproj b/tests/Jellyfin.Naming.Tests/Jellyfin.Naming.Tests.csproj index 37d0a9929a..a4ebab141e 100644 --- a/tests/Jellyfin.Naming.Tests/Jellyfin.Naming.Tests.csproj +++ b/tests/Jellyfin.Naming.Tests/Jellyfin.Naming.Tests.csproj @@ -6,17 +6,16 @@ </PropertyGroup> <PropertyGroup> - <TargetFramework>netcoreapp3.1</TargetFramework> + <TargetFramework>net5.0</TargetFramework> <IsPackable>false</IsPackable> - <Nullable>enable</Nullable> - <TreatWarningsAsErrors>true</TreatWarningsAsErrors> + <CodeAnalysisRuleSet>../jellyfin-tests.ruleset</CodeAnalysisRuleSet> </PropertyGroup> <ItemGroup> - <PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.7.1" /> + <PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.11.0" /> <PackageReference Include="xunit" Version="2.4.1" /> <PackageReference Include="xunit.runner.visualstudio" Version="2.4.3" /> - <PackageReference Include="coverlet.collector" Version="1.3.0" /> + <PackageReference Include="coverlet.collector" Version="3.1.0" /> </ItemGroup> <ItemGroup> @@ -25,14 +24,9 @@ <!-- Code Analyzers--> <ItemGroup Condition=" '$(Configuration)' == 'Debug' "> - <PackageReference Include="Microsoft.CodeAnalysis.FxCopAnalyzers" Version="2.9.8" PrivateAssets="All" /> <PackageReference Include="SerilogAnalyzer" Version="0.15.0" PrivateAssets="All" /> <PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="All" /> <PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" PrivateAssets="All" /> </ItemGroup> - <PropertyGroup Condition=" '$(Configuration)' == 'Debug' "> - <CodeAnalysisRuleSet>../jellyfin-tests.ruleset</CodeAnalysisRuleSet> - </PropertyGroup> - </Project> diff --git a/tests/Jellyfin.Naming.Tests/Subtitles/SubtitleParserTests.cs b/tests/Jellyfin.Naming.Tests/Subtitles/SubtitleParserTests.cs index d11809de11..2446660f32 100644 --- a/tests/Jellyfin.Naming.Tests/Subtitles/SubtitleParserTests.cs +++ b/tests/Jellyfin.Naming.Tests/Subtitles/SubtitleParserTests.cs @@ -1,4 +1,3 @@ -using System; using Emby.Naming.Common; using Emby.Naming.Subtitles; using Xunit; @@ -26,21 +25,17 @@ namespace Jellyfin.Naming.Tests.Subtitles Assert.Equal(language, result?.Language, true); Assert.Equal(isDefault, result?.IsDefault); Assert.Equal(isForced, result?.IsForced); + Assert.Equal(input, result?.Path); } [Theory] [InlineData("The Skin I Live In (2011).mp4")] + [InlineData("")] public void SubtitleParser_InvalidFileName_ReturnsNull(string input) { var parser = new SubtitleParser(_namingOptions); Assert.Null(parser.ParseFile(input)); } - - [Fact] - public void SubtitleParser_EmptyFileName_ThrowsArgumentException() - { - Assert.Throws<ArgumentException>(() => new SubtitleParser(_namingOptions).ParseFile(string.Empty)); - } } } diff --git a/tests/Jellyfin.Naming.Tests/TV/EpisodeNumberTests.cs b/tests/Jellyfin.Naming.Tests/TV/EpisodeNumberTests.cs index 5e023bdb06..2873f61613 100644 --- a/tests/Jellyfin.Naming.Tests/TV/EpisodeNumberTests.cs +++ b/tests/Jellyfin.Naming.Tests/TV/EpisodeNumberTests.cs @@ -66,12 +66,17 @@ namespace Jellyfin.Naming.Tests.TV [InlineData("Season 2/2. Infestation.avi", 2)] [InlineData("The Wonder Years/The.Wonder.Years.S04.PDTV.x264-JCH/The Wonder Years s04e07 Christmas Party NTSC PDTV.avi", 7)] [InlineData("Running Man/Running Man S2017E368.mkv", 368)] + [InlineData("Season 2/[HorribleSubs] Hunter X Hunter - 136 [720p].mkv", 136)] // triple digit episode number + [InlineData("Log Horizon 2/[HorribleSubs] Log Horizon 2 - 03 [720p].mkv", 3)] // digit in series name + [InlineData("Season 1/seriesname 05.mkv", 5)] // no hyphen between series name and episode number + [InlineData("[BBT-RMX] Ranma ½ - 154 [50AC421A].mkv", 154)] // hyphens in the pre-name info, triple digit episode number + [InlineData("Season 2/Episode 21 - 94 Meetings.mp4", 21)] // Title starts with a number + // [InlineData("Case Closed (1996-2007)/Case Closed - 317.mkv", 317)] // triple digit episode number // TODO: [InlineData("Season 2/16 12 Some Title.avi", 16)] // TODO: [InlineData("/The.Legend.of.Condor.Heroes.2017.V2.web-dl.1080p.h264.aac-hdctv/The.Legend.of.Condor.Heroes.2017.E07.V2.web-dl.1080p.h264.aac-hdctv.mkv", 7)] // TODO: [InlineData("Season 4/Uchuu.Senkan.Yamato.2199.E03.avi", 3)] // TODO: [InlineData("Season 2/7 12 Angry Men.avi", 7)] // TODO: [InlineData("Season 02/02x03x04x15 - Ep Name.mp4", 2)] - // TODO: [InlineData("Season 2/[HorribleSubs] Hunter X Hunter - 136 [720p].mkv", 136)] public void GetEpisodeNumberFromFileTest(string path, int? expected) { var result = new EpisodePathParser(_namingOptions) diff --git a/tests/Jellyfin.Naming.Tests/TV/EpisodePathParserTest.cs b/tests/Jellyfin.Naming.Tests/TV/EpisodePathParserTest.cs index 03aeb7f76b..12fc19bc48 100644 --- a/tests/Jellyfin.Naming.Tests/TV/EpisodePathParserTest.cs +++ b/tests/Jellyfin.Naming.Tests/TV/EpisodePathParserTest.cs @@ -1,4 +1,4 @@ -using Emby.Naming.Common; +using Emby.Naming.Common; using Emby.Naming.TV; using Xunit; @@ -7,43 +7,98 @@ namespace Jellyfin.Naming.Tests.TV public class EpisodePathParserTest { [Theory] - [InlineData("/media/Foo/Foo-S01E01", "Foo", 1, 1)] - [InlineData("/media/Foo - S04E011", "Foo", 4, 11)] - [InlineData("/media/Foo/Foo s01x01", "Foo", 1, 1)] - [InlineData("/media/Foo (2019)/Season 4/Foo (2019).S04E03", "Foo (2019)", 4, 3)] - [InlineData("D:\\media\\Foo\\Foo-S01E01", "Foo", 1, 1)] - [InlineData("D:\\media\\Foo - S04E011", "Foo", 4, 11)] - [InlineData("D:\\media\\Foo\\Foo s01x01", "Foo", 1, 1)] - [InlineData("D:\\media\\Foo (2019)\\Season 4\\Foo (2019).S04E03", "Foo (2019)", 4, 3)] - [InlineData("/Season 2/Elementary - 02x03-04-15 - Ep Name.mp4", "Elementary", 2, 3)] - [InlineData("/Season 1/seriesname S01E02 blah.avi", "seriesname", 1, 2)] - [InlineData("/Running Man/Running Man S2017E368.mkv", "Running Man", 2017, 368)] - [InlineData("/Season 1/seriesname 01x02 blah.avi", "seriesname", 1, 2)] - [InlineData("/Season 25/The Simpsons.S25E09.Steal this episode.mp4", "The Simpsons", 25, 9)] - [InlineData("/Season 1/seriesname S01x02 blah.avi", "seriesname", 1, 2)] - [InlineData("/Season 2/Elementary - 02x03 - 02x04 - 02x15 - Ep Name.mp4", "Elementary", 2, 3)] - [InlineData("/Season 1/seriesname S01xE02 blah.avi", "seriesname", 1, 2)] - [InlineData("/Season 02/Elementary - 02x03 - x04 - x15 - Ep Name.mp4", "Elementary", 2, 3)] - [InlineData("/Season 02/Elementary - 02x03x04x15 - Ep Name.mp4", "Elementary", 2, 3)] - [InlineData("/Season 02/Elementary - 02x03-E15 - Ep Name.mp4", "Elementary", 2, 3)] - [InlineData("/Season 1/Elementary - S01E23-E24-E26 - The Woman.mp4", "Elementary", 1, 23)] - [InlineData("/The Wonder Years/The.Wonder.Years.S04.PDTV.x264-JCH/The Wonder Years s04e07 Christmas Party NTSC PDTV.avi", "The Wonder Years", 4, 7)] + [InlineData("/media/Foo/Foo-S01E01", true, "Foo", 1, 1)] + [InlineData("/media/Foo - S04E011", true, "Foo", 4, 11)] + [InlineData("/media/Foo/Foo s01x01", true, "Foo", 1, 1)] + [InlineData("/media/Foo (2019)/Season 4/Foo (2019).S04E03", true, "Foo (2019)", 4, 3)] + [InlineData("D:\\media\\Foo\\Foo-S01E01", true, "Foo", 1, 1)] + [InlineData("D:\\media\\Foo - S04E011", true, "Foo", 4, 11)] + [InlineData("D:\\media\\Foo\\Foo s01x01", true, "Foo", 1, 1)] + [InlineData("D:\\media\\Foo (2019)\\Season 4\\Foo (2019).S04E03", true, "Foo (2019)", 4, 3)] + [InlineData("/Season 2/Elementary - 02x03-04-15 - Ep Name.mp4", false, "Elementary", 2, 3)] + [InlineData("/Season 1/seriesname S01E02 blah.avi", false, "seriesname", 1, 2)] + [InlineData("/Running Man/Running Man S2017E368.mkv", false, "Running Man", 2017, 368)] + [InlineData("/Season 1/seriesname 01x02 blah.avi", false, "seriesname", 1, 2)] + [InlineData("/Season 25/The Simpsons.S25E09.Steal this episode.mp4", false, "The Simpsons", 25, 9)] + [InlineData("/Season 1/seriesname S01x02 blah.avi", false, "seriesname", 1, 2)] + [InlineData("/Season 2/Elementary - 02x03 - 02x04 - 02x15 - Ep Name.mp4", false, "Elementary", 2, 3)] + [InlineData("/Season 1/seriesname S01xE02 blah.avi", false, "seriesname", 1, 2)] + [InlineData("/Season 02/Elementary - 02x03 - x04 - x15 - Ep Name.mp4", false, "Elementary", 2, 3)] + [InlineData("/Season 02/Elementary - 02x03x04x15 - Ep Name.mp4", false, "Elementary", 2, 3)] + [InlineData("/Season 02/Elementary - 02x03-E15 - Ep Name.mp4", false, "Elementary", 2, 3)] + [InlineData("/Season 1/Elementary - S01E23-E24-E26 - The Woman.mp4", false, "Elementary", 1, 23)] + [InlineData("/The Wonder Years/The.Wonder.Years.S04.PDTV.x264-JCH/The Wonder Years s04e07 Christmas Party NTSC PDTV.avi", false, "The Wonder Years", 4, 7)] // TODO: [InlineData("/Castle Rock 2x01 Que el rio siga su curso [WEB-DL HULU 1080p h264 Dual DD5.1 Subs].mkv", "Castle Rock", 2, 1)] // TODO: [InlineData("/After Life 1x06 Episodio 6 [WEB-DL NF 1080p h264 Dual DD 5.1 Sub].mkv", "After Life", 1, 6)] // TODO: [InlineData("/Season 4/Uchuu.Senkan.Yamato.2199.E03.avi", "Uchuu Senkan Yamoto 2199", 4, 3)] // TODO: [InlineData("The Daily Show/The Daily Show 25x22 - [WEBDL-720p][AAC 2.0][x264] Noah Baumbach-TBS.mkv", "The Daily Show", 25, 22)] // TODO: [InlineData("Watchmen (2019)/Watchmen 1x03 [WEBDL-720p][EAC3 5.1][h264][-TBS] - She Was Killed by Space Junk.mkv", "Watchmen (2019)", 1, 3)] // TODO: [InlineData("/The.Legend.of.Condor.Heroes.2017.V2.web-dl.1080p.h264.aac-hdctv/The.Legend.of.Condor.Heroes.2017.E07.V2.web-dl.1080p.h264.aac-hdctv.mkv", "The Legend of Condor Heroes 2017", 1, 7)] - public void ParseEpisodesCorrectly(string path, string name, int season, int episode) + public void ParseEpisodesCorrectly(string path, bool isDirectory, string name, int season, int episode) { NamingOptions o = new NamingOptions(); EpisodePathParser p = new EpisodePathParser(o); - var res = p.Parse(path, false); + var res = p.Parse(path, isDirectory); Assert.True(res.Success); Assert.Equal(name, res.SeriesName); Assert.Equal(season, res.SeasonNumber); Assert.Equal(episode, res.EpisodeNumber); } + + [Theory] + [InlineData("/test/01-03.avi", true, true)] + public void EpisodePathParserTest_DifferentExpressionsParameters(string path, bool? isNamed, bool? isOptimistic) + { + NamingOptions o = new NamingOptions(); + EpisodePathParser p = new EpisodePathParser(o); + var res = p.Parse(path, false, isNamed, isOptimistic); + + Assert.True(res.Success); + } + + [Fact] + public void EpisodePathParserTest_FalsePositivePixelRate() + { + NamingOptions o = new NamingOptions(); + EpisodePathParser p = new EpisodePathParser(o); + var res = p.Parse("Series Special (1920x1080).mkv", false); + + Assert.False(res.Success); + } + + [Fact] + public void EpisodeResolverTest_WrongExtension() + { + var res = new EpisodeResolver(new NamingOptions()).Resolve("test.mp3", false); + Assert.Null(res); + } + + [Fact] + public void EpisodeResolverTest_WrongExtensionStub() + { + var res = new EpisodeResolver(new NamingOptions()).Resolve("dvd.disc", false); + Assert.NotNull(res); + Assert.True(res!.IsStub); + } + + /* + * EpisodePathParser.cs:130 is currently unreachable, but the piece of code is useful and could be reached with addition of new EpisodeExpressions. + * In order to preserve it but achieve 100% code coverage the test case below with made up expressions and filename is used. + */ + [Fact] + public void EpisodePathParserTest_EmptyDateParsers() + { + NamingOptions o = new NamingOptions() + { + EpisodeExpressions = new[] { new EpisodeExpression("(([0-9]{4})-([0-9]{2})-([0-9]{2}) [0-9]{2}:[0-9]{2}:[0-9]{2})", true) } + }; + o.Compile(); + + EpisodePathParser p = new EpisodePathParser(o); + var res = p.Parse("ABC_2019_10_21 11:00:00", false); + + Assert.True(res.Success); + } } } diff --git a/tests/Jellyfin.Naming.Tests/TV/MultiEpisodeTests.cs b/tests/Jellyfin.Naming.Tests/TV/MultiEpisodeTests.cs index 3513050b64..58ea0bec59 100644 --- a/tests/Jellyfin.Naming.Tests/TV/MultiEpisodeTests.cs +++ b/tests/Jellyfin.Naming.Tests/TV/MultiEpisodeTests.cs @@ -74,7 +74,7 @@ namespace Jellyfin.Naming.Tests.TV var result = new EpisodePathParser(options) .Parse(filename, false); - Assert.Equal(result.EndingEpsiodeNumber, endingEpisodeNumber); + Assert.Equal(result.EndingEpisodeNumber, endingEpisodeNumber); } } } diff --git a/tests/Jellyfin.Naming.Tests/TV/SeasonFolderTests.cs b/tests/Jellyfin.Naming.Tests/TV/SeasonFolderTests.cs index 078f940b29..b7b5b54ec8 100644 --- a/tests/Jellyfin.Naming.Tests/TV/SeasonFolderTests.cs +++ b/tests/Jellyfin.Naming.Tests/TV/SeasonFolderTests.cs @@ -1,4 +1,4 @@ -using Emby.Naming.TV; +using Emby.Naming.TV; using Xunit; namespace Jellyfin.Naming.Tests.TV @@ -6,26 +6,30 @@ namespace Jellyfin.Naming.Tests.TV public class SeasonFolderTests { [Theory] - [InlineData(@"/Drive/Season 1", 1)] - [InlineData(@"/Drive/Season 2", 2)] - [InlineData(@"/Drive/Season 02", 2)] - [InlineData(@"/Drive/Seinfeld/S02", 2)] - [InlineData(@"/Drive/Seinfeld/2", 2)] - [InlineData(@"/Drive/Season 2009", 2009)] - [InlineData(@"/Drive/Season1", 1)] - [InlineData(@"The Wonder Years/The.Wonder.Years.S04.PDTV.x264-JCH", 4)] - [InlineData(@"/Drive/Season 7 (2016)", 7)] - [InlineData(@"/Drive/Staffel 7 (2016)", 7)] - [InlineData(@"/Drive/Stagione 7 (2016)", 7)] - [InlineData(@"/Drive/Season (8)", null)] - [InlineData(@"/Drive/3.Staffel", 3)] - [InlineData(@"/Drive/s06e05", null)] - [InlineData(@"/Drive/The.Legend.of.Condor.Heroes.2017.V2.web-dl.1080p.h264.aac-hdctv", null)] - public void GetSeasonNumberFromPathTest(string path, int? seasonNumber) + [InlineData(@"/Drive/Season 1", 1, true)] + [InlineData(@"/Drive/Season 2", 2, true)] + [InlineData(@"/Drive/Season 02", 2, true)] + [InlineData(@"/Drive/Seinfeld/S02", 2, true)] + [InlineData(@"/Drive/Seinfeld/2", 2, true)] + [InlineData(@"/Drive/Season 2009", 2009, true)] + [InlineData(@"/Drive/Season1", 1, true)] + [InlineData(@"The Wonder Years/The.Wonder.Years.S04.PDTV.x264-JCH", 4, true)] + [InlineData(@"/Drive/Season 7 (2016)", 7, false)] + [InlineData(@"/Drive/Staffel 7 (2016)", 7, false)] + [InlineData(@"/Drive/Stagione 7 (2016)", 7, false)] + [InlineData(@"/Drive/Season (8)", null, false)] + [InlineData(@"/Drive/3.Staffel", 3, false)] + [InlineData(@"/Drive/s06e05", null, false)] + [InlineData(@"/Drive/The.Legend.of.Condor.Heroes.2017.V2.web-dl.1080p.h264.aac-hdctv", null, false)] + [InlineData(@"/Drive/extras", 0, true)] + [InlineData(@"/Drive/specials", 0, true)] + public void GetSeasonNumberFromPathTest(string path, int? seasonNumber, bool isSeasonDirectory) { var result = SeasonPathParser.Parse(path, true, true); + Assert.Equal(result.SeasonNumber != null, result.Success); Assert.Equal(result.SeasonNumber, seasonNumber); + Assert.Equal(isSeasonDirectory, result.IsSeasonFolder); } } } diff --git a/tests/Jellyfin.Naming.Tests/TV/SimpleEpisodeTests.cs b/tests/Jellyfin.Naming.Tests/TV/SimpleEpisodeTests.cs index 40b41b9f3d..6d49ac832d 100644 --- a/tests/Jellyfin.Naming.Tests/TV/SimpleEpisodeTests.cs +++ b/tests/Jellyfin.Naming.Tests/TV/SimpleEpisodeTests.cs @@ -1,4 +1,5 @@ -using Emby.Naming.Common; +using System.IO; +using Emby.Naming.Common; using Emby.Naming.TV; using Xunit; @@ -15,25 +16,46 @@ namespace Jellyfin.Naming.Tests.TV [InlineData("/server/The Walking Dead 4x01.mp4", "The Walking Dead", 4, 1)] [InlineData("/server/the_simpsons-s02e01_18536.mp4", "the_simpsons", 2, 1)] [InlineData("/server/Temp/S01E02 foo.mp4", "", 1, 2)] - [InlineData("Series/4-12 - The Woman.mp4", "", 4, 12)] [InlineData("Series/4x12 - The Woman.mp4", "", 4, 12)] [InlineData("Series/LA X, Pt. 1_s06e32.mp4", "LA X, Pt. 1", 6, 32)] [InlineData("[Baz-Bar]Foo - [1080p][Multiple Subtitle]/[Baz-Bar] Foo - 05 [1080p][Multiple Subtitle].mkv", "Foo", null, 5)] [InlineData(@"/Foo/The.Series.Name.S01E04.WEBRip.x264-Baz[Bar]/the.series.name.s01e04.webrip.x264-Baz[Bar].mkv", "The.Series.Name", 1, 4)] [InlineData(@"Love.Death.and.Robots.S01.1080p.NF.WEB-DL.DDP5.1.x264-NTG/Love.Death.and.Robots.S01E01.Sonnies.Edge.1080p.NF.WEB-DL.DDP5.1.x264-NTG.mkv", "Love.Death.and.Robots", 1, 1)] - // TODO: [InlineData("[Baz-Bar]Foo - 01 - 12[1080p][Multiple Subtitle]/[Baz-Bar] Foo - 05 [1080p][Multiple Subtitle].mkv", "Foo", null, 5)] + [InlineData("[YuiSubs] Tensura Nikki - Tensei Shitara Slime Datta Ken/[YuiSubs] Tensura Nikki - Tensei Shitara Slime Datta Ken - 12 (NVENC H.265 1080p).mkv", "Tensura Nikki - Tensei Shitara Slime Datta Ken", null, 12)] + [InlineData("[Baz-Bar]Foo - 01 - 12[1080p][Multiple Subtitle]/[Baz-Bar] Foo - 05 [1080p][Multiple Subtitle].mkv", "Foo", null, 5)] // TODO: [InlineData("E:\\Anime\\Yahari Ore no Seishun Love Comedy wa Machigatteiru\\Yahari Ore no Seishun Love Comedy wa Machigatteiru. Zoku\\Oregairu Zoku 11 - Hayama Hayato Always Renconds to Everyone's Expectations..mkv", "Yahari Ore no Seishun Love Comedy wa Machigatteiru", null, 11)] // TODO: [InlineData(@"/Library/Series/The Grand Tour (2016)/Season 1/S01E01 The Holy Trinity.mkv", "The Grand Tour", 1, 1)] - public void Test(string path, string seriesName, int? seasonNumber, int? episodeNumber) + public void TestSimple(string path, string seriesName, int? seasonNumber, int? episodeNumber) + { + Test(path, seriesName, seasonNumber, episodeNumber, null); + } + + [Theory] + [InlineData("Series/4-12 - The Woman.mp4", "", 4, 12, 12)] + public void TestWithPossibleEpisodeEnd(string path, string seriesName, int? seasonNumber, int? episodeNumber, int? episodeEndNumber) + { + Test(path, seriesName, seasonNumber, episodeNumber, episodeEndNumber); + } + + private void Test(string path, string seriesName, int? seasonNumber, int? episodeNumber, int? episodeEndNumber) { var options = new NamingOptions(); var result = new EpisodeResolver(options) .Resolve(path, false); + Assert.NotNull(result); Assert.Equal(seasonNumber, result?.SeasonNumber); Assert.Equal(episodeNumber, result?.EpisodeNumber); Assert.Equal(seriesName, result?.SeriesName, true); + Assert.Equal(path, result?.Path); + Assert.Equal(Path.GetExtension(path).Substring(1), result?.Container); + Assert.Null(result?.Format3D); + Assert.False(result?.Is3D); + Assert.False(result?.IsStub); + Assert.Null(result?.StubType); + Assert.Equal(episodeEndNumber, result?.EndingEpisodeNumber); + Assert.False(result?.IsByDate); } } } diff --git a/tests/Jellyfin.Naming.Tests/Video/CleanDateTimeTests.cs b/tests/Jellyfin.Naming.Tests/Video/CleanDateTimeTests.cs index ed971eed7b..b1141df479 100644 --- a/tests/Jellyfin.Naming.Tests/Video/CleanDateTimeTests.cs +++ b/tests/Jellyfin.Naming.Tests/Video/CleanDateTimeTests.cs @@ -1,4 +1,4 @@ -using System.IO; +using System.IO; using Emby.Naming.Common; using Emby.Naming.Video; using Xunit; @@ -47,15 +47,18 @@ namespace Jellyfin.Naming.Tests.Video // FIXME: [InlineData("Robin Hood [Multi-Subs] [2018].mkv", "Robin Hood", 2018)] [InlineData(@"3.Days.to.Kill.2014.720p.BluRay.x264.YIFY.mkv", "3.Days.to.Kill", 2014)] // In this test case, running CleanDateTime first produces no date, so it will attempt to run CleanString first and then CleanDateTime again [InlineData("3 days to kill (2005).mkv", "3 days to kill", 2005)] + [InlineData(@"Rain Man 1988 REMASTERED 1080p BluRay x264 AAC - Ozlem.mp4", "Rain Man", 1988)] [InlineData("My Movie 2013.12.09", "My Movie 2013.12.09", null)] [InlineData("My Movie 2013-12-09", "My Movie 2013-12-09", null)] [InlineData("My Movie 20131209", "My Movie 20131209", null)] [InlineData("My Movie 2013-12-09 2013", "My Movie 2013-12-09", 2013)] + [InlineData(null, null, null)] + [InlineData("", "", null)] public void CleanDateTimeTest(string input, string expectedName, int? expectedYear) { input = Path.GetFileName(input); - var result = new VideoResolver(_namingOptions).CleanDateTime(input); + var result = VideoResolver.CleanDateTime(input, _namingOptions); Assert.Equal(expectedName, result.Name, true); Assert.Equal(expectedYear, result.Year); diff --git a/tests/Jellyfin.Naming.Tests/Video/CleanStringTests.cs b/tests/Jellyfin.Naming.Tests/Video/CleanStringTests.cs index fde06c5a17..fb050cf5a7 100644 --- a/tests/Jellyfin.Naming.Tests/Video/CleanStringTests.cs +++ b/tests/Jellyfin.Naming.Tests/Video/CleanStringTests.cs @@ -14,11 +14,6 @@ namespace Jellyfin.Naming.Tests.Video [InlineData("Super movie 480p 2001.mp4", "Super movie")] [InlineData("Super movie [480p].mp4", "Super movie")] [InlineData("480 Super movie [tmdbid=12345].mp4", "480 Super movie")] - [InlineData("Super movie(2009).mp4", "Super movie(2009).mp4")] - [InlineData("Run lola run (lola rennt) (2009).mp4", "Run lola run (lola rennt) (2009).mp4")] - [InlineData(@"American.Psycho.mkv", "American.Psycho.mkv")] - [InlineData(@"American Psycho.mkv", "American Psycho.mkv")] - [InlineData(@"[rec].mkv", "[rec].mkv")] [InlineData("Crouching.Tiger.Hidden.Dragon.4k.mkv", "Crouching.Tiger.Hidden.Dragon")] [InlineData("Crouching.Tiger.Hidden.Dragon.UltraHD.mkv", "Crouching.Tiger.Hidden.Dragon")] [InlineData("Crouching.Tiger.Hidden.Dragon.UHD.mkv", "Crouching.Tiger.Hidden.Dragon")] @@ -29,17 +24,25 @@ namespace Jellyfin.Naming.Tests.Video [InlineData("Crouching.Tiger.Hidden.Dragon.BDrip-HDC.mkv", "Crouching.Tiger.Hidden.Dragon")] [InlineData("Crouching.Tiger.Hidden.Dragon.4K.UltraHD.HDR.BDrip-HDC.mkv", "Crouching.Tiger.Hidden.Dragon")] // FIXME: [InlineData("After The Sunset - [0004].mkv", "After The Sunset")] - public void CleanStringTest(string input, string expectedName) + public void CleanStringTest_NeedsCleaning_Success(string input, string expectedName) { - if (new VideoResolver(_namingOptions).TryCleanString(input, out ReadOnlySpan<char> newName)) - { - // TODO: compare spans when XUnit supports it - Assert.Equal(expectedName, newName.ToString()); - } - else - { - Assert.Equal(expectedName, input); - } + Assert.True(VideoResolver.TryCleanString(input, _namingOptions, out ReadOnlySpan<char> newName)); + // TODO: compare spans when XUnit supports it + Assert.Equal(expectedName, newName.ToString()); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData("Super movie(2009).mp4")] + [InlineData("[rec].mkv")] + [InlineData("American.Psycho.mkv")] + [InlineData("American Psycho.mkv")] + [InlineData("Run lola run (lola rennt) (2009).mp4")] + public void CleanStringTest_DoesntNeedCleaning_False(string? input) + { + Assert.False(VideoResolver.TryCleanString(input, _namingOptions, out ReadOnlySpan<char> newName)); + Assert.True(newName.IsEmpty); } } } diff --git a/tests/Jellyfin.Naming.Tests/Video/ExtraTests.cs b/tests/Jellyfin.Naming.Tests/Video/ExtraTests.cs index 8dfb8f8591..f872f94f8e 100644 --- a/tests/Jellyfin.Naming.Tests/Video/ExtraTests.cs +++ b/tests/Jellyfin.Naming.Tests/Video/ExtraTests.cs @@ -1,7 +1,8 @@ -using Emby.Naming.Common; +using Emby.Naming.Common; using Emby.Naming.Video; using MediaBrowser.Model.Entities; using Xunit; +using MediaType = Emby.Naming.Common.MediaType; namespace Jellyfin.Naming.Tests.Video { @@ -93,6 +94,16 @@ namespace Jellyfin.Naming.Tests.Video } } + [Fact] + public void TestExtraInfo_InvalidRuleType() + { + var rule = new ExtraRule(ExtraType.Unknown, ExtraRuleType.Regex, @"([eE]x(tra)?\.\w+)", MediaType.Video); + var options = new NamingOptions { VideoExtraRules = new[] { rule } }; + var res = GetExtraTypeParser(options).GetExtraInfo("extra.mp4"); + + Assert.Equal(rule, res.Rule); + } + private ExtraResolver GetExtraTypeParser(NamingOptions videoOptions) { return new ExtraResolver(videoOptions); diff --git a/tests/Jellyfin.Naming.Tests/Video/Format3DTests.cs b/tests/Jellyfin.Naming.Tests/Video/Format3DTests.cs index 69de96a47a..1762b91b9d 100644 --- a/tests/Jellyfin.Naming.Tests/Video/Format3DTests.cs +++ b/tests/Jellyfin.Naming.Tests/Video/Format3DTests.cs @@ -22,8 +22,7 @@ namespace Jellyfin.Naming.Tests.Video [Fact] public void Test3DName() { - var result = - new VideoResolver(_namingOptions).ResolveFile(@"C:/Users/media/Desktop/Video Test/Movies/Oblivion/Oblivion.3d.hsbs.mkv"); + var result = VideoResolver.ResolveFile(@"C:/Users/media/Desktop/Video Test/Movies/Oblivion/Oblivion.3d.hsbs.mkv", _namingOptions); Assert.Equal("hsbs", result?.Format3D); Assert.Equal("Oblivion", result?.Name); @@ -58,15 +57,13 @@ namespace Jellyfin.Naming.Tests.Video private void Test(string input, bool is3D, string? format3D) { - var parser = new Format3DParser(_namingOptions); - - var result = parser.Parse(input); + var result = Format3DParser.Parse(input, _namingOptions); Assert.Equal(is3D, result.Is3D); if (format3D == null) { - Assert.Null(result.Format3D); + Assert.Null(result?.Format3D); } else { diff --git a/tests/Jellyfin.Naming.Tests/Video/MultiVersionTests.cs b/tests/Jellyfin.Naming.Tests/Video/MultiVersionTests.cs index 4198d69ff8..d02f8ae924 100644 --- a/tests/Jellyfin.Naming.Tests/Video/MultiVersionTests.cs +++ b/tests/Jellyfin.Naming.Tests/Video/MultiVersionTests.cs @@ -1,4 +1,5 @@ -using System.Linq; +using System.Collections.Generic; +using System.Linq; using Emby.Naming.Common; using Emby.Naming.Video; using MediaBrowser.Model.IO; @@ -10,9 +11,8 @@ namespace Jellyfin.Naming.Tests.Video { private readonly NamingOptions _namingOptions = new NamingOptions(); - // FIXME - // [Fact] - private void TestMultiEdition1() + [Fact] + public void TestMultiEdition1() { var files = new[] { @@ -22,21 +22,20 @@ namespace Jellyfin.Naming.Tests.Video @"/movies/X-Men Days of Future Past/X-Men Days of Future Past [hsbs].mkv" }; - var resolver = GetResolver(); - - var result = resolver.Resolve(files.Select(i => new FileSystemMetadata - { - IsDirectory = false, - FullName = i - }).ToList()).ToList(); + var result = VideoListResolver.Resolve( + files.Select(i => new FileSystemMetadata + { + IsDirectory = false, + FullName = i + }).ToList(), + _namingOptions).ToList(); Assert.Single(result); Assert.Single(result[0].Extras); } - // FIXME - // [Fact] - private void TestMultiEdition2() + [Fact] + public void TestMultiEdition2() { var files = new[] { @@ -46,13 +45,13 @@ namespace Jellyfin.Naming.Tests.Video @"/movies/X-Men Days of Future Past/X-Men Days of Future Past [banana].mp4" }; - var resolver = GetResolver(); - - var result = resolver.Resolve(files.Select(i => new FileSystemMetadata - { - IsDirectory = false, - FullName = i - }).ToList()).ToList(); + var result = VideoListResolver.Resolve( + files.Select(i => new FileSystemMetadata + { + IsDirectory = false, + FullName = i + }).ToList(), + _namingOptions).ToList(); Assert.Single(result); Assert.Single(result[0].Extras); @@ -68,21 +67,20 @@ namespace Jellyfin.Naming.Tests.Video @"/movies/The Phantom of the Opera (1925)/The Phantom of the Opera (1925) - 1929 version.mkv" }; - var resolver = GetResolver(); - - var result = resolver.Resolve(files.Select(i => new FileSystemMetadata - { - IsDirectory = false, - FullName = i - }).ToList()).ToList(); + var result = VideoListResolver.Resolve( + files.Select(i => new FileSystemMetadata + { + IsDirectory = false, + FullName = i + }).ToList(), + _namingOptions).ToList(); Assert.Single(result); Assert.Single(result[0].AlternateVersions); } - // FIXME - // [Fact] - private void TestLetterFolders() + [Fact] + public void TestLetterFolders() { var files = new[] { @@ -95,22 +93,21 @@ namespace Jellyfin.Naming.Tests.Video @"/movies/M/Movie 7.mkv" }; - var resolver = GetResolver(); - - var result = resolver.Resolve(files.Select(i => new FileSystemMetadata - { - IsDirectory = false, - FullName = i - }).ToList()).ToList(); + var result = VideoListResolver.Resolve( + files.Select(i => new FileSystemMetadata + { + IsDirectory = false, + FullName = i + }).ToList(), + _namingOptions).ToList(); Assert.Equal(7, result.Count); Assert.Empty(result[0].Extras); Assert.Empty(result[0].AlternateVersions); } - // FIXME - // [Fact] - private void TestMultiVersionLimit() + [Fact] + public void TestMultiVersionLimit() { var files = new[] { @@ -124,22 +121,21 @@ namespace Jellyfin.Naming.Tests.Video @"/movies/Movie/Movie-8.mkv" }; - var resolver = GetResolver(); - - var result = resolver.Resolve(files.Select(i => new FileSystemMetadata - { - IsDirectory = false, - FullName = i - }).ToList()).ToList(); + var result = VideoListResolver.Resolve( + files.Select(i => new FileSystemMetadata + { + IsDirectory = false, + FullName = i + }).ToList(), + _namingOptions).ToList(); Assert.Single(result); Assert.Empty(result[0].Extras); Assert.Equal(7, result[0].AlternateVersions.Count); } - // FIXME - // [Fact] - private void TestMultiVersionLimit2() + [Fact] + public void TestMultiVersionLimit2() { var files = new[] { @@ -154,22 +150,21 @@ namespace Jellyfin.Naming.Tests.Video @"/movies/Mo/Movie 9.mkv" }; - var resolver = GetResolver(); - - var result = resolver.Resolve(files.Select(i => new FileSystemMetadata - { - IsDirectory = false, - FullName = i - }).ToList()).ToList(); + var result = VideoListResolver.Resolve( + files.Select(i => new FileSystemMetadata + { + IsDirectory = false, + FullName = i + }).ToList(), + _namingOptions).ToList(); Assert.Equal(9, result.Count); Assert.Empty(result[0].Extras); Assert.Empty(result[0].AlternateVersions); } - // FIXME - // [Fact] - private void TestMultiVersion3() + [Fact] + public void TestMultiVersion3() { var files = new[] { @@ -180,22 +175,21 @@ namespace Jellyfin.Naming.Tests.Video @"/movies/Movie/Movie 5.mkv" }; - var resolver = GetResolver(); - - var result = resolver.Resolve(files.Select(i => new FileSystemMetadata - { - IsDirectory = false, - FullName = i - }).ToList()).ToList(); + var result = VideoListResolver.Resolve( + files.Select(i => new FileSystemMetadata + { + IsDirectory = false, + FullName = i + }).ToList(), + _namingOptions).ToList(); Assert.Equal(5, result.Count); Assert.Empty(result[0].Extras); Assert.Empty(result[0].AlternateVersions); } - // FIXME - // [Fact] - private void TestMultiVersion4() + [Fact] + public void TestMultiVersion4() { // Test for false positive @@ -208,22 +202,21 @@ namespace Jellyfin.Naming.Tests.Video @"/movies/Iron Man/Iron Man (2011).mkv" }; - var resolver = GetResolver(); - - var result = resolver.Resolve(files.Select(i => new FileSystemMetadata - { - IsDirectory = false, - FullName = i - }).ToList()).ToList(); + var result = VideoListResolver.Resolve( + files.Select(i => new FileSystemMetadata + { + IsDirectory = false, + FullName = i + }).ToList(), + _namingOptions).ToList(); Assert.Equal(5, result.Count); Assert.Empty(result[0].Extras); Assert.Empty(result[0].AlternateVersions); } - // FIXME - // [Fact] - private void TestMultiVersion5() + [Fact] + public void TestMultiVersion5() { var files = new[] { @@ -237,13 +230,13 @@ namespace Jellyfin.Naming.Tests.Video @"/movies/Iron Man/Iron Man[test].mkv", }; - var resolver = GetResolver(); - - var result = resolver.Resolve(files.Select(i => new FileSystemMetadata - { - IsDirectory = false, - FullName = i - }).ToList()).ToList(); + var result = VideoListResolver.Resolve( + files.Select(i => new FileSystemMetadata + { + IsDirectory = false, + FullName = i + }).ToList(), + _namingOptions).ToList(); Assert.Single(result); Assert.Empty(result[0].Extras); @@ -253,9 +246,8 @@ namespace Jellyfin.Naming.Tests.Video Assert.True(result[0].AlternateVersions[4].Is3D); } - // FIXME - // [Fact] - private void TestMultiVersion6() + [Fact] + public void TestMultiVersion6() { var files = new[] { @@ -269,13 +261,13 @@ namespace Jellyfin.Naming.Tests.Video @"/movies/Iron Man/Iron Man [test].mkv" }; - var resolver = GetResolver(); - - var result = resolver.Resolve(files.Select(i => new FileSystemMetadata - { - IsDirectory = false, - FullName = i - }).ToList()).ToList(); + var result = VideoListResolver.Resolve( + files.Select(i => new FileSystemMetadata + { + IsDirectory = false, + FullName = i + }).ToList(), + _namingOptions).ToList(); Assert.Single(result); Assert.Empty(result[0].Extras); @@ -285,9 +277,8 @@ namespace Jellyfin.Naming.Tests.Video Assert.True(result[0].AlternateVersions[5].Is3D); } - // FIXME - // [Fact] - private void TestMultiVersion7() + [Fact] + public void TestMultiVersion7() { var files = new[] { @@ -295,23 +286,20 @@ namespace Jellyfin.Naming.Tests.Video @"/movies/Iron Man/Iron Man - C (2007).mkv" }; - var resolver = GetResolver(); - - var result = resolver.Resolve(files.Select(i => new FileSystemMetadata - { - IsDirectory = false, - FullName = i - }).ToList()).ToList(); + var result = VideoListResolver.Resolve( + files.Select(i => new FileSystemMetadata + { + IsDirectory = false, + FullName = i + }).ToList(), + _namingOptions).ToList(); Assert.Equal(2, result.Count); } - // FIXME - // [Fact] - private void TestMultiVersion8() + [Fact] + public void TestMultiVersion8() { - // This is not actually supported yet - var files = new[] { @"/movies/Iron Man/Iron Man.mkv", @@ -323,25 +311,21 @@ namespace Jellyfin.Naming.Tests.Video @"/movies/Iron Man/Iron Man_3d.hsbs.mkv" }; - var resolver = GetResolver(); - - var result = resolver.Resolve(files.Select(i => new FileSystemMetadata - { - IsDirectory = false, - FullName = i - }).ToList()).ToList(); + var result = VideoListResolver.Resolve( + files.Select(i => new FileSystemMetadata + { + IsDirectory = false, + FullName = i + }).ToList(), + _namingOptions).ToList(); - Assert.Single(result); + Assert.Equal(7, result.Count); Assert.Empty(result[0].Extras); - Assert.Equal(6, result[0].AlternateVersions.Count); - Assert.False(result[0].AlternateVersions[2].Is3D); - Assert.True(result[0].AlternateVersions[3].Is3D); - Assert.True(result[0].AlternateVersions[4].Is3D); + Assert.Empty(result[0].AlternateVersions); } - // FIXME - // [Fact] - private void TestMultiVersion9() + [Fact] + public void TestMultiVersion9() { // Test for false positive @@ -354,22 +338,21 @@ namespace Jellyfin.Naming.Tests.Video @"/movies/Iron Man/Iron Man (2011).mkv" }; - var resolver = GetResolver(); - - var result = resolver.Resolve(files.Select(i => new FileSystemMetadata - { - IsDirectory = false, - FullName = i - }).ToList()).ToList(); + var result = VideoListResolver.Resolve( + files.Select(i => new FileSystemMetadata + { + IsDirectory = false, + FullName = i + }).ToList(), + _namingOptions).ToList(); Assert.Equal(5, result.Count); Assert.Empty(result[0].Extras); Assert.Empty(result[0].AlternateVersions); } - // FIXME - // [Fact] - private void TestMultiVersion10() + [Fact] + public void TestMultiVersion10() { var files = new[] { @@ -377,47 +360,89 @@ namespace Jellyfin.Naming.Tests.Video @"/movies/Blade Runner (1982)/Blade Runner (1982) [EE by ADM] [480p HEVC AAC,AAC,AAC].mkv" }; - var resolver = GetResolver(); - - var result = resolver.Resolve(files.Select(i => new FileSystemMetadata - { - IsDirectory = false, - FullName = i - }).ToList()).ToList(); + var result = VideoListResolver.Resolve( + files.Select(i => new FileSystemMetadata + { + IsDirectory = false, + FullName = i + }).ToList(), + _namingOptions).ToList(); Assert.Single(result); Assert.Empty(result[0].Extras); Assert.Single(result[0].AlternateVersions); } - // FIXME - // [Fact] - private void TestMultiVersion11() + [Fact] + public void TestMultiVersion11() { - // Currently not supported but we should probably handle this. - var files = new[] { @"/movies/X-Men Apocalypse (2016)/X-Men Apocalypse (2016) [1080p] Blu-ray.x264.DTS.mkv", @"/movies/X-Men Apocalypse (2016)/X-Men Apocalypse (2016) [2160p] Blu-ray.x265.AAC.mkv" }; - var resolver = GetResolver(); + var result = VideoListResolver.Resolve( + files.Select(i => new FileSystemMetadata + { + IsDirectory = false, + FullName = i + }).ToList(), + _namingOptions).ToList(); + + Assert.Single(result); + Assert.Empty(result[0].Extras); + Assert.Single(result[0].AlternateVersions); + } - var result = resolver.Resolve(files.Select(i => new FileSystemMetadata + [Fact] + public void Resolve_GivenFolderNameWithBracketsAndHyphens_GroupsBasedOnFolderName() + { + var files = new[] { - IsDirectory = false, - FullName = i - }).ToList()).ToList(); + @"/movies/John Wick - Kapitel 3 (2019) [imdbid=tt6146586]/John Wick - Kapitel 3 (2019) [imdbid=tt6146586] - Version 1.mkv", + @"/movies/John Wick - Kapitel 3 (2019) [imdbid=tt6146586]/John Wick - Kapitel 3 (2019) [imdbid=tt6146586] - Version 2.mkv" + }; + + var result = VideoListResolver.Resolve( + files.Select(i => new FileSystemMetadata + { + IsDirectory = false, + FullName = i + }).ToList(), + _namingOptions).ToList(); Assert.Single(result); Assert.Empty(result[0].Extras); Assert.Single(result[0].AlternateVersions); } - private VideoListResolver GetResolver() + [Fact] + public void Resolve_GivenUnclosedBrackets_DoesNotGroup() + { + var files = new[] + { + @"/movies/John Wick - Chapter 3 (2019)/John Wick - Chapter 3 (2019) [Version 1].mkv", + @"/movies/John Wick - Chapter 3 (2019)/John Wick - Chapter 3 (2019) [Version 2.mkv" + }; + + var result = VideoListResolver.Resolve( + files.Select(i => new FileSystemMetadata + { + IsDirectory = false, + FullName = i + }).ToList(), + _namingOptions).ToList(); + + Assert.Equal(2, result.Count); + } + + [Fact] + public void TestEmptyList() { - return new VideoListResolver(_namingOptions); + var result = VideoListResolver.Resolve(new List<FileSystemMetadata>(), _namingOptions).ToList(); + + Assert.Empty(result); } } } diff --git a/tests/Jellyfin.Naming.Tests/Video/StubTests.cs b/tests/Jellyfin.Naming.Tests/Video/StubTests.cs index 30ba941365..1d50df7a60 100644 --- a/tests/Jellyfin.Naming.Tests/Video/StubTests.cs +++ b/tests/Jellyfin.Naming.Tests/Video/StubTests.cs @@ -1,4 +1,4 @@ -using Emby.Naming.Common; +using Emby.Naming.Common; using Emby.Naming.Video; using Xunit; @@ -23,13 +23,13 @@ namespace Jellyfin.Naming.Tests.Video Test("video.hdtv.disc", true, "tv"); Test("video.pdtv.disc", true, "tv"); Test("video.dsr.disc", true, "tv"); + Test(string.Empty, false, "tv"); } [Fact] public void TestStubName() { - var result = - new VideoResolver(_namingOptions).ResolveFile(@"C:/Users/media/Desktop/Video Test/Movies/Oblivion/Oblivion.dvd.disc"); + var result = VideoResolver.ResolveFile(@"C:/Users/media/Desktop/Video Test/Movies/Oblivion/Oblivion.dvd.disc", _namingOptions); Assert.Equal("Oblivion", result?.Name); } diff --git a/tests/Jellyfin.Naming.Tests/Video/VideoListResolverTests.cs b/tests/Jellyfin.Naming.Tests/Video/VideoListResolverTests.cs index 12c4a50fe3..9e0776c3cd 100644 --- a/tests/Jellyfin.Naming.Tests/Video/VideoListResolverTests.cs +++ b/tests/Jellyfin.Naming.Tests/Video/VideoListResolverTests.cs @@ -1,4 +1,5 @@ -using System.Linq; +using System; +using System.Linq; using Emby.Naming.Common; using Emby.Naming.Video; using MediaBrowser.Model.IO; @@ -10,9 +11,8 @@ namespace Jellyfin.Naming.Tests.Video { private readonly NamingOptions _namingOptions = new NamingOptions(); - // FIXME - // [Fact] - private void TestStackAndExtras() + [Fact] + public void TestStackAndExtras() { // No stacking here because there is no part/disc/etc var files = new[] @@ -40,23 +40,24 @@ namespace Jellyfin.Naming.Tests.Video "WillyWonka-trailer.mkv" }; - var resolver = GetResolver(); - - var result = resolver.Resolve(files.Select(i => new FileSystemMetadata - { - IsDirectory = false, - FullName = i - }).ToList()).ToList(); + var result = VideoListResolver.Resolve( + files.Select(i => new FileSystemMetadata + { + IsDirectory = false, + FullName = i + }).ToList(), + _namingOptions).ToList(); Assert.Equal(5, result.Count); - - Assert.Equal(3, result[1].Files.Count); - Assert.Equal(3, result[1].Extras.Count); - Assert.Equal("Batman", result[1].Name); - - Assert.Equal(4, result[2].Files.Count); - Assert.Equal(2, result[2].Extras.Count); - Assert.Equal("Harry Potter and the Deathly Hallows", result[2].Name); + var batman = result.FirstOrDefault(x => string.Equals(x.Name, "Batman", StringComparison.Ordinal)); + Assert.NotNull(batman); + Assert.Equal(3, batman!.Files.Count); + Assert.Equal(3, batman!.Extras.Count); + + var harry = result.FirstOrDefault(x => string.Equals(x.Name, "Harry Potter and the Deathly Hallows", StringComparison.Ordinal)); + Assert.NotNull(harry); + Assert.Equal(4, harry!.Files.Count); + Assert.Equal(2, harry!.Extras.Count); } [Fact] @@ -68,13 +69,13 @@ namespace Jellyfin.Naming.Tests.Video "300.nfo" }; - var resolver = GetResolver(); - - var result = resolver.Resolve(files.Select(i => new FileSystemMetadata - { - IsDirectory = false, - FullName = i - }).ToList()).ToList(); + var result = VideoListResolver.Resolve( + files.Select(i => new FileSystemMetadata + { + IsDirectory = false, + FullName = i + }).ToList(), + _namingOptions).ToList(); Assert.Single(result); } @@ -88,13 +89,13 @@ namespace Jellyfin.Naming.Tests.Video "300 trailer.mkv" }; - var resolver = GetResolver(); - - var result = resolver.Resolve(files.Select(i => new FileSystemMetadata - { - IsDirectory = false, - FullName = i - }).ToList()).ToList(); + var result = VideoListResolver.Resolve( + files.Select(i => new FileSystemMetadata + { + IsDirectory = false, + FullName = i + }).ToList(), + _namingOptions).ToList(); Assert.Single(result); } @@ -108,13 +109,13 @@ namespace Jellyfin.Naming.Tests.Video "X-Men Days of Future Past-trailer.mp4" }; - var resolver = GetResolver(); - - var result = resolver.Resolve(files.Select(i => new FileSystemMetadata - { - IsDirectory = false, - FullName = i - }).ToList()).ToList(); + var result = VideoListResolver.Resolve( + files.Select(i => new FileSystemMetadata + { + IsDirectory = false, + FullName = i + }).ToList(), + _namingOptions).ToList(); Assert.Single(result); } @@ -129,13 +130,13 @@ namespace Jellyfin.Naming.Tests.Video "X-Men Days of Future Past-trailer2.mp4" }; - var resolver = GetResolver(); - - var result = resolver.Resolve(files.Select(i => new FileSystemMetadata - { - IsDirectory = false, - FullName = i - }).ToList()).ToList(); + var result = VideoListResolver.Resolve( + files.Select(i => new FileSystemMetadata + { + IsDirectory = false, + FullName = i + }).ToList(), + _namingOptions).ToList(); Assert.Single(result); } @@ -149,13 +150,13 @@ namespace Jellyfin.Naming.Tests.Video "Looper.2012.bluray.720p.x264.mkv" }; - var resolver = GetResolver(); - - var result = resolver.Resolve(files.Select(i => new FileSystemMetadata - { - IsDirectory = false, - FullName = i - }).ToList()).ToList(); + var result = VideoListResolver.Resolve( + files.Select(i => new FileSystemMetadata + { + IsDirectory = false, + FullName = i + }).ToList(), + _namingOptions).ToList(); Assert.Single(result); } @@ -173,13 +174,13 @@ namespace Jellyfin.Naming.Tests.Video "My video 5.mkv" }; - var resolver = GetResolver(); - - var result = resolver.Resolve(files.Select(i => new FileSystemMetadata - { - IsDirectory = false, - FullName = i - }).ToList()).ToList(); + var result = VideoListResolver.Resolve( + files.Select(i => new FileSystemMetadata + { + IsDirectory = false, + FullName = i + }).ToList(), + _namingOptions).ToList(); Assert.Equal(5, result.Count); } @@ -193,13 +194,13 @@ namespace Jellyfin.Naming.Tests.Video @"M:/Movies (DVD)/Movies (Musical)/Sound of Music (1965)/Sound of Music Disc 2" }; - var resolver = GetResolver(); - - var result = resolver.Resolve(files.Select(i => new FileSystemMetadata - { - IsDirectory = true, - FullName = i - }).ToList()).ToList(); + var result = VideoListResolver.Resolve( + files.Select(i => new FileSystemMetadata + { + IsDirectory = true, + FullName = i + }).ToList(), + _namingOptions).ToList(); Assert.Single(result); } @@ -214,13 +215,13 @@ namespace Jellyfin.Naming.Tests.Video @"My movie #2.mp4" }; - var resolver = GetResolver(); - - var result = resolver.Resolve(files.Select(i => new FileSystemMetadata - { - IsDirectory = true, - FullName = i - }).ToList()).ToList(); + var result = VideoListResolver.Resolve( + files.Select(i => new FileSystemMetadata + { + IsDirectory = true, + FullName = i + }).ToList(), + _namingOptions).ToList(); Assert.Equal(2, result.Count); } @@ -235,13 +236,13 @@ namespace Jellyfin.Naming.Tests.Video @"No (2012) part1-trailer.mp4" }; - var resolver = GetResolver(); - - var result = resolver.Resolve(files.Select(i => new FileSystemMetadata - { - IsDirectory = false, - FullName = i - }).ToList()).ToList(); + var result = VideoListResolver.Resolve( + files.Select(i => new FileSystemMetadata + { + IsDirectory = false, + FullName = i + }).ToList(), + _namingOptions).ToList(); Assert.Single(result); } @@ -256,13 +257,13 @@ namespace Jellyfin.Naming.Tests.Video @"No (2012)-trailer.mp4" }; - var resolver = GetResolver(); - - var result = resolver.Resolve(files.Select(i => new FileSystemMetadata - { - IsDirectory = false, - FullName = i - }).ToList()).ToList(); + var result = VideoListResolver.Resolve( + files.Select(i => new FileSystemMetadata + { + IsDirectory = false, + FullName = i + }).ToList(), + _namingOptions).ToList(); Assert.Single(result); } @@ -278,13 +279,13 @@ namespace Jellyfin.Naming.Tests.Video @"trailer.mp4" }; - var resolver = GetResolver(); - - var result = resolver.Resolve(files.Select(i => new FileSystemMetadata - { - IsDirectory = false, - FullName = i - }).ToList()).ToList(); + var result = VideoListResolver.Resolve( + files.Select(i => new FileSystemMetadata + { + IsDirectory = false, + FullName = i + }).ToList(), + _namingOptions).ToList(); Assert.Single(result); } @@ -300,13 +301,13 @@ namespace Jellyfin.Naming.Tests.Video @"/MCFAMILY-PC/Private3$/Heterosexual/Breast In Class 2 Counterfeit Racks (2011)/Breast In Class 2 Disc 2 cd2.avi" }; - var resolver = GetResolver(); - - var result = resolver.Resolve(files.Select(i => new FileSystemMetadata - { - IsDirectory = false, - FullName = i - }).ToList()).ToList(); + var result = VideoListResolver.Resolve( + files.Select(i => new FileSystemMetadata + { + IsDirectory = false, + FullName = i + }).ToList(), + _namingOptions).ToList(); Assert.Equal(2, result.Count); } @@ -319,13 +320,13 @@ namespace Jellyfin.Naming.Tests.Video @"/nas-markrobbo78/Videos/INDEX HTPC/Movies/Watched/3 - ACTION/Argo (2012)/movie.mkv" }; - var resolver = GetResolver(); - - var result = resolver.Resolve(files.Select(i => new FileSystemMetadata - { - IsDirectory = false, - FullName = i - }).ToList()).ToList(); + var result = VideoListResolver.Resolve( + files.Select(i => new FileSystemMetadata + { + IsDirectory = false, + FullName = i + }).ToList(), + _namingOptions).ToList(); Assert.Single(result); } @@ -338,13 +339,13 @@ namespace Jellyfin.Naming.Tests.Video @"The Colony.mkv" }; - var resolver = GetResolver(); - - var result = resolver.Resolve(files.Select(i => new FileSystemMetadata - { - IsDirectory = false, - FullName = i - }).ToList()).ToList(); + var result = VideoListResolver.Resolve( + files.Select(i => new FileSystemMetadata + { + IsDirectory = false, + FullName = i + }).ToList(), + _namingOptions).ToList(); Assert.Single(result); } @@ -358,15 +359,35 @@ namespace Jellyfin.Naming.Tests.Video @"Four Sisters and a Wedding - B.avi" }; - var resolver = GetResolver(); + var result = VideoListResolver.Resolve( + files.Select(i => new FileSystemMetadata + { + IsDirectory = false, + FullName = i + }).ToList(), + _namingOptions).ToList(); + + Assert.Single(result); + } - var result = resolver.Resolve(files.Select(i => new FileSystemMetadata + [Fact] + public void TestFourRooms() + { + var files = new[] { - IsDirectory = false, - FullName = i - }).ToList()).ToList(); + @"Four Rooms - A.avi", + @"Four Rooms - A.mp4" + }; - Assert.Single(result); + var result = VideoListResolver.Resolve( + files.Select(i => new FileSystemMetadata + { + IsDirectory = false, + FullName = i + }).ToList(), + _namingOptions).ToList(); + + Assert.Equal(2, result.Count); } [Fact] @@ -378,13 +399,13 @@ namespace Jellyfin.Naming.Tests.Video @"/Server/Despicable Me/movie-trailer.mkv" }; - var resolver = GetResolver(); - - var result = resolver.Resolve(files.Select(i => new FileSystemMetadata - { - IsDirectory = false, - FullName = i - }).ToList()).ToList(); + var result = VideoListResolver.Resolve( + files.Select(i => new FileSystemMetadata + { + IsDirectory = false, + FullName = i + }).ToList(), + _namingOptions).ToList(); Assert.Single(result); } @@ -400,13 +421,13 @@ namespace Jellyfin.Naming.Tests.Video @"/Server/Despicable Me/Baywatch (2017) - Trailer.mkv" }; - var resolver = GetResolver(); - - var result = resolver.Resolve(files.Select(i => new FileSystemMetadata - { - IsDirectory = false, - FullName = i - }).ToList()).ToList(); + var result = VideoListResolver.Resolve( + files.Select(i => new FileSystemMetadata + { + IsDirectory = false, + FullName = i + }).ToList(), + _namingOptions).ToList(); Assert.Equal(4, result.Count); } @@ -420,20 +441,22 @@ namespace Jellyfin.Naming.Tests.Video @"/Movies/Despicable Me/trailers/trailer.mkv" }; - var resolver = GetResolver(); - - var result = resolver.Resolve(files.Select(i => new FileSystemMetadata - { - IsDirectory = false, - FullName = i - }).ToList()).ToList(); + var result = VideoListResolver.Resolve( + files.Select(i => new FileSystemMetadata + { + IsDirectory = false, + FullName = i + }).ToList(), + _namingOptions).ToList(); Assert.Single(result); } - private VideoListResolver GetResolver() + [Fact] + public void TestDirectoryStack() { - return new VideoListResolver(_namingOptions); + var stack = new FileStack(); + Assert.False(stack.ContainsFile("XX", true)); } } } diff --git a/tests/Jellyfin.Naming.Tests/Video/VideoResolverTests.cs b/tests/Jellyfin.Naming.Tests/Video/VideoResolverTests.cs index 99828b2eb7..ac5a7a21e6 100644 --- a/tests/Jellyfin.Naming.Tests/Video/VideoResolverTests.cs +++ b/tests/Jellyfin.Naming.Tests/Video/VideoResolverTests.cs @@ -1,4 +1,5 @@ -using System.Collections.Generic; +using System.Collections.Generic; +using System.Linq; using Emby.Naming.Common; using Emby.Naming.Video; using MediaBrowser.Model.Entities; @@ -8,192 +9,201 @@ namespace Jellyfin.Naming.Tests.Video { public class VideoResolverTests { - private readonly NamingOptions _namingOptions = new NamingOptions(); + private static NamingOptions _namingOptions = new NamingOptions(); - public static IEnumerable<object[]> GetResolveFileTestData() + public static IEnumerable<object[]> ResolveFile_ValidFileNameTestData() { yield return new object[] { - new VideoFileInfo() - { - Path = @"/server/Movies/7 Psychos.mkv/7 Psychos.mkv", - Container = "mkv", - Name = "7 Psychos" - } + new VideoFileInfo( + path: @"/server/Movies/7 Psychos.mkv/7 Psychos.mkv", + container: "mkv", + name: "7 Psychos") }; yield return new object[] { - new VideoFileInfo() - { - Path = @"/server/Movies/3 days to kill (2005)/3 days to kill (2005).mkv", - Container = "mkv", - Name = "3 days to kill", - Year = 2005 - } + new VideoFileInfo( + path: @"/server/Movies/3 days to kill (2005)/3 days to kill (2005).mkv", + container: "mkv", + name: "3 days to kill", + year: 2005) }; yield return new object[] { - new VideoFileInfo() - { - Path = @"/server/Movies/American Psycho/American.Psycho.mkv", - Container = "mkv", - Name = "American.Psycho", - } + new VideoFileInfo( + path: @"/server/Movies/American Psycho/American.Psycho.mkv", + container: "mkv", + name: "American.Psycho") }; yield return new object[] { - new VideoFileInfo() - { - Path = @"/server/Movies/brave (2007)/brave (2006).3d.sbs.mkv", - Container = "mkv", - Name = "brave", - Year = 2006, - Is3D = true, - Format3D = "sbs", - } + new VideoFileInfo( + path: @"/server/Movies/brave (2007)/brave (2006).3d.sbs.mkv", + container: "mkv", + name: "brave", + year: 2006, + is3D: true, + format3D: "sbs") }; yield return new object[] { - new VideoFileInfo() - { - Path = @"/server/Movies/300 (2007)/300 (2006).3d1.sbas.mkv", - Container = "mkv", - Name = "300", - Year = 2006 - } + new VideoFileInfo( + path: @"/server/Movies/300 (2007)/300 (2006).3d1.sbas.mkv", + container: "mkv", + name: "300", + year: 2006) }; yield return new object[] { - new VideoFileInfo() - { - Path = @"/server/Movies/300 (2007)/300 (2006).3d.sbs.mkv", - Container = "mkv", - Name = "300", - Year = 2006, - Is3D = true, - Format3D = "sbs", - } + new VideoFileInfo( + path: @"/server/Movies/300 (2007)/300 (2006).3d.sbs.mkv", + container: "mkv", + name: "300", + year: 2006, + is3D: true, + format3D: "sbs") }; yield return new object[] { - new VideoFileInfo() - { - Path = @"/server/Movies/brave (2007)/brave (2006)-trailer.bluray.disc", - Container = "disc", - Name = "brave", - Year = 2006, - IsStub = true, - StubType = "bluray", - } + new VideoFileInfo( + path: @"/server/Movies/brave (2007)/brave (2006)-trailer.bluray.disc", + container: "disc", + name: "brave", + year: 2006, + isStub: true, + stubType: "bluray") }; yield return new object[] { - new VideoFileInfo() - { - Path = @"/server/Movies/300 (2007)/300 (2006)-trailer.bluray.disc", - Container = "disc", - Name = "300", - Year = 2006, - IsStub = true, - StubType = "bluray", - } + new VideoFileInfo( + path: @"/server/Movies/300 (2007)/300 (2006)-trailer.bluray.disc", + container: "disc", + name: "300", + year: 2006, + isStub: true, + stubType: "bluray") }; yield return new object[] { - new VideoFileInfo() - { - Path = @"/server/Movies/Brave (2007)/Brave (2006).bluray.disc", - Container = "disc", - Name = "Brave", - Year = 2006, - IsStub = true, - StubType = "bluray", - } + new VideoFileInfo( + path: @"/server/Movies/Brave (2007)/Brave (2006).bluray.disc", + container: "disc", + name: "Brave", + year: 2006, + isStub: true, + stubType: "bluray") }; yield return new object[] { - new VideoFileInfo() - { - Path = @"/server/Movies/300 (2007)/300 (2006).bluray.disc", - Container = "disc", - Name = "300", - Year = 2006, - IsStub = true, - StubType = "bluray", - } + new VideoFileInfo( + path: @"/server/Movies/300 (2007)/300 (2006).bluray.disc", + container: "disc", + name: "300", + year: 2006, + isStub: true, + stubType: "bluray") }; yield return new object[] { - new VideoFileInfo() - { - Path = @"/server/Movies/300 (2007)/300 (2006)-trailer.mkv", - Container = "mkv", - Name = "300", - Year = 2006, - ExtraType = ExtraType.Trailer, - } + new VideoFileInfo( + path: @"/server/Movies/300 (2007)/300 (2006)-trailer.mkv", + container: "mkv", + name: "300", + year: 2006, + extraType: ExtraType.Trailer) }; yield return new object[] { - new VideoFileInfo() - { - Path = @"/server/Movies/Brave (2007)/Brave (2006)-trailer.mkv", - Container = "mkv", - Name = "Brave", - Year = 2006, - ExtraType = ExtraType.Trailer, - } + new VideoFileInfo( + path: @"/server/Movies/Brave (2007)/Brave (2006)-trailer.mkv", + container: "mkv", + name: "Brave", + year: 2006, + extraType: ExtraType.Trailer) }; yield return new object[] { - new VideoFileInfo() - { - Path = @"/server/Movies/300 (2007)/300 (2006).mkv", - Container = "mkv", - Name = "300", - Year = 2006 - } + new VideoFileInfo( + path: @"/server/Movies/300 (2007)/300 (2006).mkv", + container: "mkv", + name: "300", + year: 2006) }; yield return new object[] { - new VideoFileInfo() - { - Path = @"/server/Movies/Bad Boys (1995)/Bad Boys (1995).mkv", - Container = "mkv", - Name = "Bad Boys", - Year = 1995, - } + new VideoFileInfo( + path: @"/server/Movies/Bad Boys (1995)/Bad Boys (1995).mkv", + container: "mkv", + name: "Bad Boys", + year: 1995) }; yield return new object[] { - new VideoFileInfo() - { - Path = @"/server/Movies/Brave (2007)/Brave (2006).mkv", - Container = "mkv", - Name = "Brave", - Year = 2006, - } + new VideoFileInfo( + path: @"/server/Movies/Brave (2007)/Brave (2006).mkv", + container: "mkv", + name: "Brave", + year: 2006) + }; + yield return new object[] + { + new VideoFileInfo( + path: @"/server/Movies/Rain Man 1988 REMASTERED 1080p BluRay x264 AAC - JEFF/Rain Man 1988 REMASTERED 1080p BluRay x264 AAC - JEFF.mp4", + container: "mp4", + name: "Rain Man", + year: 1988) }; } [Theory] - [MemberData(nameof(GetResolveFileTestData))] + [MemberData(nameof(ResolveFile_ValidFileNameTestData))] public void ResolveFile_ValidFileName_Success(VideoFileInfo expectedResult) { - var result = new VideoResolver(_namingOptions).ResolveFile(expectedResult.Path); + var result = VideoResolver.ResolveFile(expectedResult.Path, _namingOptions); Assert.NotNull(result); - Assert.Equal(result?.Path, expectedResult.Path); - Assert.Equal(result?.Container, expectedResult.Container); - Assert.Equal(result?.Name, expectedResult.Name); - Assert.Equal(result?.Year, expectedResult.Year); - Assert.Equal(result?.ExtraType, expectedResult.ExtraType); - Assert.Equal(result?.Format3D, expectedResult.Format3D); - Assert.Equal(result?.Is3D, expectedResult.Is3D); - Assert.Equal(result?.IsStub, expectedResult.IsStub); - Assert.Equal(result?.StubType, expectedResult.StubType); - Assert.Equal(result?.IsDirectory, expectedResult.IsDirectory); - Assert.Equal(result?.FileNameWithoutExtension, expectedResult.FileNameWithoutExtension); + Assert.Equal(result!.Path, expectedResult.Path); + Assert.Equal(result.Container, expectedResult.Container); + Assert.Equal(result.Name, expectedResult.Name); + Assert.Equal(result.Year, expectedResult.Year); + Assert.Equal(result.ExtraType, expectedResult.ExtraType); + Assert.Equal(result.Format3D, expectedResult.Format3D); + Assert.Equal(result.Is3D, expectedResult.Is3D); + Assert.Equal(result.IsStub, expectedResult.IsStub); + Assert.Equal(result.StubType, expectedResult.StubType); + Assert.Equal(result.IsDirectory, expectedResult.IsDirectory); + Assert.Equal(result.FileNameWithoutExtension.ToString(), expectedResult.FileNameWithoutExtension.ToString()); + Assert.Equal(result.ToString(), expectedResult.ToString()); + } + + [Fact] + public void ResolveFile_EmptyPath() + { + var result = VideoResolver.ResolveFile(string.Empty, _namingOptions); + + Assert.Null(result); + } + + [Fact] + public void ResolveDirectoryTest() + { + var paths = new[] + { + @"/Server/Iron Man", + @"Batman", + string.Empty + }; + + var results = paths.Select(path => VideoResolver.ResolveDirectory(path, _namingOptions)).ToList(); + + Assert.Equal(3, results.Count); + Assert.NotNull(results[0]); + Assert.NotNull(results[1]); + Assert.Null(results[2]); + foreach (var result in results) + { + Assert.Null(result?.Container); + } } } } diff --git a/tests/Jellyfin.Networking.Tests/IPHostTests.cs b/tests/Jellyfin.Networking.Tests/IPHostTests.cs new file mode 100644 index 0000000000..ec3a1300c8 --- /dev/null +++ b/tests/Jellyfin.Networking.Tests/IPHostTests.cs @@ -0,0 +1,53 @@ +using FsCheck; +using FsCheck.Xunit; +using MediaBrowser.Common.Net; +using Xunit; + +namespace Jellyfin.Networking.Tests +{ + public static class IPHostTests + { + /// <summary> + /// Checks IP address formats. + /// </summary> + /// <param name="address">IP Address.</param> + [Theory] + [InlineData("127.0.0.1")] + [InlineData("127.0.0.1:123")] + [InlineData("localhost")] + [InlineData("localhost:1345")] + [InlineData("www.google.co.uk")] + [InlineData("fd23:184f:2029:0:3139:7386:67d7:d517")] + [InlineData("fd23:184f:2029:0:3139:7386:67d7:d517/56")] + [InlineData("[fd23:184f:2029:0:3139:7386:67d7:d517]:124")] + [InlineData("fe80::7add:12ff:febb:c67b%16")] + [InlineData("[fe80::7add:12ff:febb:c67b%16]:123")] + [InlineData("fe80::7add:12ff:febb:c67b%16:123")] + [InlineData("[fe80::7add:12ff:febb:c67b%16]")] + [InlineData("192.168.1.2/255.255.255.0")] + [InlineData("192.168.1.2/24")] + public static void TryParse_ValidHostStrings_True(string address) + => Assert.True(IPHost.TryParse(address, out _)); + + [Property] + public static Property TryParse_IPv4Address_True(IPv4Address address) + => IPHost.TryParse(address.Item.ToString(), out _).ToProperty(); + + [Property] + public static Property TryParse_IPv6Address_True(IPv6Address address) + => IPHost.TryParse(address.Item.ToString(), out _).ToProperty(); + + /// <summary> + /// All should be invalid address strings. + /// </summary> + /// <param name="address">Invalid address strings.</param> + [Theory] + [InlineData("256.128.0.0.0.1")] + [InlineData("127.0.0.1#")] + [InlineData("localhost!")] + [InlineData("fd23:184f:2029:0:3139:7386:67d7:d517:1231")] + [InlineData("[fd23:184f:2029:0:3139:7386:67d7:d517:1231]")] + public static void TryParse_InvalidAddressString_False(string address) + => Assert.False(IPHost.TryParse(address, out _)); + } +} diff --git a/tests/Jellyfin.Networking.Tests/IPNetAddressTests.cs b/tests/Jellyfin.Networking.Tests/IPNetAddressTests.cs new file mode 100644 index 0000000000..aa2dbc57a2 --- /dev/null +++ b/tests/Jellyfin.Networking.Tests/IPNetAddressTests.cs @@ -0,0 +1,49 @@ +using FsCheck; +using FsCheck.Xunit; +using MediaBrowser.Common.Net; +using Xunit; + +namespace Jellyfin.Networking.Tests +{ + public static class IPNetAddressTests + { + /// <summary> + /// Checks IP address formats. + /// </summary> + /// <param name="address">IP Address.</param> + [Theory] + [InlineData("127.0.0.1")] + [InlineData("fd23:184f:2029:0:3139:7386:67d7:d517")] + [InlineData("fd23:184f:2029:0:3139:7386:67d7:d517/56")] + [InlineData("[fd23:184f:2029:0:3139:7386:67d7:d517]")] + [InlineData("fe80::7add:12ff:febb:c67b%16")] + [InlineData("[fe80::7add:12ff:febb:c67b%16]:123")] + [InlineData("fe80::7add:12ff:febb:c67b%16:123")] + [InlineData("[fe80::7add:12ff:febb:c67b%16]")] + [InlineData("192.168.1.2/255.255.255.0")] + [InlineData("192.168.1.2/24")] + public static void TryParse_ValidIPStrings_True(string address) + => Assert.True(IPNetAddress.TryParse(address, out _)); + + [Property] + public static Property TryParse_IPv4Address_True(IPv4Address address) + => IPNetAddress.TryParse(address.Item.ToString(), out _).ToProperty(); + + [Property] + public static Property TryParse_IPv6Address_True(IPv6Address address) + => IPNetAddress.TryParse(address.Item.ToString(), out _).ToProperty(); + + /// <summary> + /// All should be invalid address strings. + /// </summary> + /// <param name="address">Invalid address strings.</param> + [Theory] + [InlineData("256.128.0.0.0.1")] + [InlineData("127.0.0.1#")] + [InlineData("localhost!")] + [InlineData("fd23:184f:2029:0:3139:7386:67d7:d517:1231")] + [InlineData("[fd23:184f:2029:0:3139:7386:67d7:d517:1231]")] + public static void TryParse_InvalidAddressString_False(string address) + => Assert.False(IPNetAddress.TryParse(address, out _)); + } +} diff --git a/tests/Jellyfin.Networking.Tests/Jellyfin.Networking.Tests.csproj b/tests/Jellyfin.Networking.Tests/Jellyfin.Networking.Tests.csproj new file mode 100644 index 0000000000..b2a6fdcf27 --- /dev/null +++ b/tests/Jellyfin.Networking.Tests/Jellyfin.Networking.Tests.csproj @@ -0,0 +1,39 @@ +<Project Sdk="Microsoft.NET.Sdk"> + + <!-- ProjectGuid is only included as a requirement for SonarQube analysis --> + <PropertyGroup> + <ProjectGuid>{42816EA8-4511-4CBF-A9C7-7791D5DDDAE6}</ProjectGuid> + </PropertyGroup> + + <PropertyGroup> + <TargetFramework>net5.0</TargetFramework> + <IsPackable>false</IsPackable> + <CodeAnalysisRuleSet>../jellyfin-tests.ruleset</CodeAnalysisRuleSet> + </PropertyGroup> + + <ItemGroup> + <PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.11.0" /> + <PackageReference Include="xunit" Version="2.4.1" /> + <PackageReference Include="xunit.runner.visualstudio" Version="2.4.1" /> + <PackageReference Include="coverlet.collector" Version="3.1.0" /> + <PackageReference Include="FsCheck.Xunit" Version="2.16.0" /> + <PackageReference Include="Moq" Version="4.16.1" /> + </ItemGroup> + + <!-- Code Analyzers--> + <ItemGroup Condition=" '$(Configuration)' == 'Debug' "> + <PackageReference Include="SerilogAnalyzer" Version="0.15.0" PrivateAssets="All" /> + <PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="All" /> + <PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" PrivateAssets="All" /> + </ItemGroup> + + <ItemGroup> + <ProjectReference Include="../../Emby.Server.Implementations/Emby.Server.Implementations.csproj" /> + <ProjectReference Include="../../MediaBrowser.Common/MediaBrowser.Common.csproj" /> + </ItemGroup> + + <PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'"> + <DefineConstants>DEBUG</DefineConstants> + </PropertyGroup> + +</Project> diff --git a/tests/Jellyfin.Networking.Tests/NetworkManagerTests.cs b/tests/Jellyfin.Networking.Tests/NetworkManagerTests.cs new file mode 100644 index 0000000000..1cad625b73 --- /dev/null +++ b/tests/Jellyfin.Networking.Tests/NetworkManagerTests.cs @@ -0,0 +1,63 @@ +using System.Net; +using Jellyfin.Networking.Configuration; +using Jellyfin.Networking.Manager; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace Jellyfin.Networking.Tests +{ + public class NetworkManagerTests + { + /// <summary> + /// Checks that the given IP address is in the specified network(s). + /// </summary> + /// <param name="network">Network address(es).</param> + /// <param name="value">The IP to check.</param> + [Theory] + [InlineData("192.168.2.1/24", "192.168.2.123")] + [InlineData("192.168.2.1/24, !192.168.2.122/32", "192.168.2.123")] + [InlineData("fd23:184f:2029:0::/56", "fd23:184f:2029:0:3139:7386:67d7:d517")] + [InlineData("fd23:184f:2029:0::/56, !fd23:184f:2029:0:3139:7386:67d7:d518/128", "fd23:184f:2029:0:3139:7386:67d7:d517")] + public void InNetwork_True_Success(string network, string value) + { + var ip = IPAddress.Parse(value); + var conf = new NetworkConfiguration() + { + EnableIPV6 = true, + EnableIPV4 = true, + LocalNetworkSubnets = network.Split(',') + }; + + using var networkManager = new NetworkManager(NetworkParseTests.GetMockConfig(conf), new NullLogger<NetworkManager>()); + + Assert.True(networkManager.IsInLocalNetwork(ip)); + } + + /// <summary> + /// Checks that thge given IP address is not in the network provided. + /// </summary> + /// <param name="network">Network address(es).</param> + /// <param name="value">The IP to check.</param> + [Theory] + [InlineData("192.168.10.0/24", "192.168.11.1")] + [InlineData("192.168.10.0/24, !192.168.10.60/32", "192.168.10.60")] + [InlineData("192.168.10.0/24", "fd23:184f:2029:0:3139:7386:67d7:d517")] + [InlineData("fd23:184f:2029:0::/56", "fd24:184f:2029:0:3139:7386:67d7:d517")] + [InlineData("fd23:184f:2029:0::/56, !fd23:184f:2029:0:3139:7386:67d7:d500/120", "fd23:184f:2029:0:3139:7386:67d7:d517")] + [InlineData("fd23:184f:2029:0::/56", "192.168.10.60")] + public void InNetwork_False_Success(string network, string value) + { + var ip = IPAddress.Parse(value); + var conf = new NetworkConfiguration() + { + EnableIPV6 = true, + EnableIPV4 = true, + LocalNetworkSubnets = network.Split(',') + }; + + using var nm = new NetworkManager(NetworkParseTests.GetMockConfig(conf), new NullLogger<NetworkManager>()); + + Assert.False(nm.IsInLocalNetwork(ip)); + } + } +} diff --git a/tests/Jellyfin.Networking.Tests/NetworkParseTests.cs b/tests/Jellyfin.Networking.Tests/NetworkParseTests.cs new file mode 100644 index 0000000000..97c14d463b --- /dev/null +++ b/tests/Jellyfin.Networking.Tests/NetworkParseTests.cs @@ -0,0 +1,480 @@ +using System; +using System.Collections.ObjectModel; +using System.Net; +using Jellyfin.Networking.Configuration; +using Jellyfin.Networking.Manager; +using MediaBrowser.Common.Configuration; +using MediaBrowser.Common.Net; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Xunit; + +namespace Jellyfin.Networking.Tests +{ + public class NetworkParseTests + { + internal static IConfigurationManager GetMockConfig(NetworkConfiguration conf) + { + var configManager = new Mock<IConfigurationManager> + { + CallBase = true + }; + configManager.Setup(x => x.GetConfiguration(It.IsAny<string>())).Returns(conf); + return (IConfigurationManager)configManager.Object; + } + + /// <summary> + /// Checks the ability to ignore virtual interfaces. + /// </summary> + /// <param name="interfaces">Mock network setup, in the format (IP address, interface index, interface name) | .... </param> + /// <param name="lan">LAN addresses.</param> + /// <param name="value">Bind addresses that are excluded.</param> + [Theory] + // All valid + [InlineData("192.168.1.208/24,-16,eth16|200.200.200.200/24,11,eth11", "192.168.1.0/24;200.200.200.0/24", "[192.168.1.208/24,200.200.200.200/24]")] + // eth16 only + [InlineData("192.168.1.208/24,-16,eth16|200.200.200.200/24,11,eth11", "192.168.1.0/24", "[192.168.1.208/24]")] + // All interfaces excluded. (including loopbacks) + [InlineData("192.168.1.208/24,-16,vEthernet1|192.168.2.208/24,-16,vEthernet212|200.200.200.200/24,11,eth11", "192.168.1.0/24", "[127.0.0.1/8,::1/128]")] + // vEthernet1 and vEthernet212 should be excluded. + [InlineData("192.168.1.200/24,-20,vEthernet1|192.168.2.208/24,-16,vEthernet212|200.200.200.200/24,11,eth11", "192.168.1.0/24;200.200.200.200/24", "[200.200.200.200/24,127.0.0.1/8,::1/128]")] + // Overlapping interface, + [InlineData("192.168.1.110/24,-20,br0|192.168.1.10/24,-16,br0|200.200.200.200/24,11,eth11", "192.168.1.0/24", "[192.168.1.110/24,192.168.1.10/24]")] + public void IgnoreVirtualInterfaces(string interfaces, string lan, string value) + { + var conf = new NetworkConfiguration() + { + EnableIPV6 = true, + EnableIPV4 = true, + LocalNetworkSubnets = lan?.Split(';') ?? throw new ArgumentNullException(nameof(lan)) + }; + + NetworkManager.MockNetworkSettings = interfaces; + using var nm = new NetworkManager(GetMockConfig(conf), new NullLogger<NetworkManager>()); + NetworkManager.MockNetworkSettings = string.Empty; + + Assert.Equal(nm.GetInternalBindAddresses().AsString(), value); + } + + /// <summary> + /// Test collection parsing. + /// </summary> + /// <param name="settings">Collection to parse.</param> + /// <param name="result1">Included addresses from the collection.</param> + /// <param name="result2">Included IP4 addresses from the collection.</param> + /// <param name="result3">Excluded addresses from the collection.</param> + /// <param name="result4">Excluded IP4 addresses from the collection.</param> + /// <param name="result5">Network addresses of the collection.</param> + [Theory] + [InlineData( + "127.0.0.1#", + "[]", + "[]", + "[]", + "[]", + "[]")] + [InlineData( + "!127.0.0.1", + "[]", + "[]", + "[127.0.0.1/32]", + "[127.0.0.1/32]", + "[]")] + [InlineData( + "", + "[]", + "[]", + "[]", + "[]", + "[]")] + [InlineData( + "192.158.1.2/16, localhost, fd23:184f:2029:0:3139:7386:67d7:d517, !10.10.10.10", + "[192.158.1.2/16,[127.0.0.1/32,::1/128],fd23:184f:2029:0:3139:7386:67d7:d517/128]", + "[192.158.1.2/16,127.0.0.1/32]", + "[10.10.10.10/32]", + "[10.10.10.10/32]", + "[192.158.0.0/16,127.0.0.1/32,::1/128,fd23:184f:2029:0:3139:7386:67d7:d517/128]")] + [InlineData( + "192.158.1.2/255.255.0.0,192.169.1.2/8", + "[192.158.1.2/16,192.169.1.2/8]", + "[192.158.1.2/16,192.169.1.2/8]", + "[]", + "[]", + "[192.158.0.0/16,192.0.0.0/8]")] + public void TestCollections(string settings, string result1, string result2, string result3, string result4, string result5) + { + if (settings == null) + { + throw new ArgumentNullException(nameof(settings)); + } + + var conf = new NetworkConfiguration() + { + EnableIPV6 = true, + EnableIPV4 = true, + }; + + using var nm = new NetworkManager(GetMockConfig(conf), new NullLogger<NetworkManager>()); + + // Test included. + Collection<IPObject> nc = nm.CreateIPCollection(settings.Split(','), false); + Assert.Equal(nc.AsString(), result1); + + // Test excluded. + nc = nm.CreateIPCollection(settings.Split(','), true); + Assert.Equal(nc.AsString(), result3); + + conf.EnableIPV6 = false; + nm.UpdateSettings(conf); + + // Test IP4 included. + nc = nm.CreateIPCollection(settings.Split(','), false); + Assert.Equal(nc.AsString(), result2); + + // Test IP4 excluded. + nc = nm.CreateIPCollection(settings.Split(','), true); + Assert.Equal(nc.AsString(), result4); + + conf.EnableIPV6 = true; + nm.UpdateSettings(conf); + + // Test network addresses of collection. + nc = nm.CreateIPCollection(settings.Split(','), false); + nc = nc.AsNetworks(); + Assert.Equal(nc.AsString(), result5); + } + + /// <summary> + /// Union two collections. + /// </summary> + /// <param name="settings">Source.</param> + /// <param name="compare">Destination.</param> + /// <param name="result">Result.</param> + [Theory] + [InlineData("127.0.0.1", "fd23:184f:2029:0:3139:7386:67d7:d517/64,fd23:184f:2029:0:c0f0:8a8a:7605:fffa/128,fe80::3139:7386:67d7:d517%16/64,192.168.1.208/24,::1/128,127.0.0.1/8", "[127.0.0.1/32]")] + [InlineData("127.0.0.1", "127.0.0.1/8", "[127.0.0.1/32]")] + public void UnionCheck(string settings, string compare, string result) + { + if (settings == null) + { + throw new ArgumentNullException(nameof(settings)); + } + + if (compare == null) + { + throw new ArgumentNullException(nameof(compare)); + } + + if (result == null) + { + throw new ArgumentNullException(nameof(result)); + } + + var conf = new NetworkConfiguration() + { + EnableIPV6 = true, + EnableIPV4 = true, + }; + + using var nm = new NetworkManager(GetMockConfig(conf), new NullLogger<NetworkManager>()); + + Collection<IPObject> nc1 = nm.CreateIPCollection(settings.Split(','), false); + Collection<IPObject> nc2 = nm.CreateIPCollection(compare.Split(','), false); + + Assert.Equal(nc1.ThatAreContainedInNetworks(nc2).AsString(), result); + } + + [Theory] + [InlineData("192.168.5.85/24", "192.168.5.1")] + [InlineData("192.168.5.85/24", "192.168.5.254")] + [InlineData("10.128.240.50/30", "10.128.240.48")] + [InlineData("10.128.240.50/30", "10.128.240.49")] + [InlineData("10.128.240.50/30", "10.128.240.50")] + [InlineData("10.128.240.50/30", "10.128.240.51")] + [InlineData("127.0.0.1/8", "127.0.0.1")] + public void IpV4SubnetMaskMatchesValidIpAddress(string netMask, string ipAddress) + { + var ipAddressObj = IPNetAddress.Parse(netMask); + Assert.True(ipAddressObj.Contains(IPAddress.Parse(ipAddress))); + } + + [Theory] + [InlineData("192.168.5.85/24", "192.168.4.254")] + [InlineData("192.168.5.85/24", "191.168.5.254")] + [InlineData("10.128.240.50/30", "10.128.240.47")] + [InlineData("10.128.240.50/30", "10.128.240.52")] + [InlineData("10.128.240.50/30", "10.128.239.50")] + [InlineData("10.128.240.50/30", "10.127.240.51")] + public void IpV4SubnetMaskDoesNotMatchInvalidIpAddress(string netMask, string ipAddress) + { + var ipAddressObj = IPNetAddress.Parse(netMask); + Assert.False(ipAddressObj.Contains(IPAddress.Parse(ipAddress))); + } + + [Theory] + [InlineData("2001:db8:abcd:0012::0/64", "2001:0DB8:ABCD:0012:0000:0000:0000:0000")] + [InlineData("2001:db8:abcd:0012::0/64", "2001:0DB8:ABCD:0012:FFFF:FFFF:FFFF:FFFF")] + [InlineData("2001:db8:abcd:0012::0/64", "2001:0DB8:ABCD:0012:0001:0000:0000:0000")] + [InlineData("2001:db8:abcd:0012::0/64", "2001:0DB8:ABCD:0012:FFFF:FFFF:FFFF:FFF0")] + [InlineData("2001:db8:abcd:0012::0/128", "2001:0DB8:ABCD:0012:0000:0000:0000:0000")] + public void IpV6SubnetMaskMatchesValidIpAddress(string netMask, string ipAddress) + { + var ipAddressObj = IPNetAddress.Parse(netMask); + Assert.True(ipAddressObj.Contains(IPAddress.Parse(ipAddress))); + } + + [Theory] + [InlineData("2001:db8:abcd:0012::0/64", "2001:0DB8:ABCD:0011:FFFF:FFFF:FFFF:FFFF")] + [InlineData("2001:db8:abcd:0012::0/64", "2001:0DB8:ABCD:0013:0000:0000:0000:0000")] + [InlineData("2001:db8:abcd:0012::0/64", "2001:0DB8:ABCD:0013:0001:0000:0000:0000")] + [InlineData("2001:db8:abcd:0012::0/64", "2001:0DB8:ABCD:0011:FFFF:FFFF:FFFF:FFF0")] + [InlineData("2001:db8:abcd:0012::0/128", "2001:0DB8:ABCD:0012:0000:0000:0000:0001")] + public void IpV6SubnetMaskDoesNotMatchInvalidIpAddress(string netMask, string ipAddress) + { + var ipAddressObj = IPNetAddress.Parse(netMask); + Assert.False(ipAddressObj.Contains(IPAddress.Parse(ipAddress))); + } + + [Theory] + [InlineData("10.0.0.0/255.0.0.0", "10.10.10.1/32")] + [InlineData("10.0.0.0/8", "10.10.10.1/32")] + [InlineData("10.0.0.0/255.0.0.0", "10.10.10.1")] + + [InlineData("10.10.0.0/255.255.0.0", "10.10.10.1/32")] + [InlineData("10.10.0.0/16", "10.10.10.1/32")] + [InlineData("10.10.0.0/255.255.0.0", "10.10.10.1")] + + [InlineData("10.10.10.0/255.255.255.0", "10.10.10.1/32")] + [InlineData("10.10.10.0/24", "10.10.10.1/32")] + [InlineData("10.10.10.0/255.255.255.0", "10.10.10.1")] + + public void TestSubnetContains(string network, string ip) + { + Assert.True(IPNetAddress.TryParse(network, out var networkObj)); + Assert.True(IPNetAddress.TryParse(ip, out var ipObj)); + Assert.True(networkObj.Contains(ipObj)); + } + + [Theory] + [InlineData("192.168.1.2/24,10.10.10.1/24,172.168.1.2/24", "172.168.1.2/24", "172.168.1.2/24")] + [InlineData("192.168.1.2/24,10.10.10.1/24,172.168.1.2/24", "172.168.1.2/24, 10.10.10.1", "172.168.1.2/24,10.10.10.1/24")] + [InlineData("192.168.1.2/24,10.10.10.1/24,172.168.1.2/24", "192.168.1.2/255.255.255.0, 10.10.10.1", "192.168.1.2/24,10.10.10.1/24")] + [InlineData("192.168.1.2/24,10.10.10.1/24,172.168.1.2/24", "192.168.1.2/24, 100.10.10.1", "192.168.1.2/24")] + [InlineData("192.168.1.2/24,10.10.10.1/24,172.168.1.2/24", "194.168.1.2/24, 100.10.10.1", "")] + + public void TestCollectionEquality(string source, string dest, string result) + { + if (source == null) + { + throw new ArgumentNullException(nameof(source)); + } + + if (dest == null) + { + throw new ArgumentNullException(nameof(dest)); + } + + if (result == null) + { + throw new ArgumentNullException(nameof(result)); + } + + var conf = new NetworkConfiguration() + { + EnableIPV6 = true, + EnableIPV4 = true + }; + + using var nm = new NetworkManager(GetMockConfig(conf), new NullLogger<NetworkManager>()); + + // Test included, IP6. + Collection<IPObject> ncSource = nm.CreateIPCollection(source.Split(',')); + Collection<IPObject> ncDest = nm.CreateIPCollection(dest.Split(',')); + Collection<IPObject> ncResult = ncSource.ThatAreContainedInNetworks(ncDest); + Collection<IPObject> resultCollection = nm.CreateIPCollection(result.Split(',')); + Assert.True(ncResult.Compare(resultCollection)); + } + + [Theory] + [InlineData("10.1.1.1/32", "10.1.1.1")] + [InlineData("192.168.1.254/32", "192.168.1.254/255.255.255.255")] + + public void TestEquals(string source, string dest) + { + Assert.True(IPNetAddress.Parse(source).Equals(IPNetAddress.Parse(dest))); + Assert.True(IPNetAddress.Parse(dest).Equals(IPNetAddress.Parse(source))); + } + + [Theory] + + // Testing bind interfaces. + // On my system eth16 is internal, eth11 external (Windows defines the indexes). + // + // This test is to replicate how DNLA requests work throughout the system. + + // User on internal network, we're bound internal and external - so result is internal. + [InlineData("192.168.1.1", "eth16,eth11", false, "eth16")] + // User on external network, we're bound internal and external - so result is external. + [InlineData("8.8.8.8", "eth16,eth11", false, "eth11")] + // User on internal network, we're bound internal only - so result is internal. + [InlineData("10.10.10.10", "eth16", false, "eth16")] + // User on internal network, no binding specified - so result is the 1st internal. + [InlineData("192.168.1.1", "", false, "eth16")] + // User on external network, internal binding only - so result is the 1st internal. + [InlineData("jellyfin.org", "eth16", false, "eth16")] + // User on external network, no binding - so result is the 1st external. + [InlineData("jellyfin.org", "", false, "eth11")] + // Dns failure - should skip the test. + // https://en.wikipedia.org/wiki/.test + [InlineData("invalid.domain.test", "", false, "eth11")] + // User assumed to be internal, no binding - so result is the 1st internal. + [InlineData("", "", false, "eth16")] + public void TestBindInterfaces(string source, string bindAddresses, bool ipv6enabled, string result) + { + if (source == null) + { + throw new ArgumentNullException(nameof(source)); + } + + if (bindAddresses == null) + { + throw new ArgumentNullException(nameof(bindAddresses)); + } + + if (result == null) + { + throw new ArgumentNullException(nameof(result)); + } + + var conf = new NetworkConfiguration() + { + LocalNetworkAddresses = bindAddresses.Split(','), + EnableIPV6 = ipv6enabled, + EnableIPV4 = true + }; + + NetworkManager.MockNetworkSettings = "192.168.1.208/24,-16,eth16|200.200.200.200/24,11,eth11"; + using var nm = new NetworkManager(GetMockConfig(conf), new NullLogger<NetworkManager>()); + NetworkManager.MockNetworkSettings = string.Empty; + + _ = nm.TryParseInterface(result, out Collection<IPObject>? resultObj); + + // Check to see if dns resolution is working. If not, skip test. + _ = IPHost.TryParse(source, out var host); + + if (resultObj != null && host?.HasAddress == true) + { + result = ((IPNetAddress)resultObj[0]).ToString(true); + var intf = nm.GetBindInterface(source, out _); + + Assert.Equal(intf, result); + } + } + + [Theory] + + // Testing bind interfaces. These are set for my system so won't work elsewhere. + // On my system eth16 is internal, eth11 external (Windows defines the indexes). + // + // This test is to replicate how subnet bound ServerPublisherUri work throughout the system. + + // User on internal network, we're bound internal and external - so result is internal override. + [InlineData("192.168.1.1", "192.168.1.0/24", "eth16,eth11", false, "192.168.1.0/24=internal.jellyfin", "internal.jellyfin")] + + // User on external network, we're bound internal and external - so result is override. + [InlineData("8.8.8.8", "192.168.1.0/24", "eth16,eth11", false, "0.0.0.0=http://helloworld.com", "http://helloworld.com")] + + // User on internal network, we're bound internal only, but the address isn't in the LAN - so return the override. + [InlineData("10.10.10.10", "192.168.1.0/24", "eth16", false, "0.0.0.0=http://internalButNotDefinedAsLan.com", "http://internalButNotDefinedAsLan.com")] + + // User on internal network, no binding specified - so result is the 1st internal. + [InlineData("192.168.1.1", "192.168.1.0/24", "", false, "0.0.0.0=http://helloworld.com", "eth16")] + + // User on external network, internal binding only - so assumption is a proxy forward, return external override. + [InlineData("jellyfin.org", "192.168.1.0/24", "eth16", false, "0.0.0.0=http://helloworld.com", "http://helloworld.com")] + + // User on external network, no binding - so result is the 1st external which is overriden. + [InlineData("jellyfin.org", "192.168.1.0/24", "", false, "0.0.0.0 = http://helloworld.com", "http://helloworld.com")] + + // User assumed to be internal, no binding - so result is the 1st internal. + [InlineData("", "192.168.1.0/24", "", false, "0.0.0.0=http://helloworld.com", "eth16")] + + // User is internal, no binding - so result is the 1st internal, which is then overridden. + [InlineData("192.168.1.1", "192.168.1.0/24", "", false, "eth16=http://helloworld.com", "http://helloworld.com")] + public void TestBindInterfaceOverrides(string source, string lan, string bindAddresses, bool ipv6enabled, string publishedServers, string result) + { + if (lan == null) + { + throw new ArgumentNullException(nameof(lan)); + } + + if (bindAddresses == null) + { + throw new ArgumentNullException(nameof(bindAddresses)); + } + + var conf = new NetworkConfiguration() + { + LocalNetworkSubnets = lan.Split(','), + LocalNetworkAddresses = bindAddresses.Split(','), + EnableIPV6 = ipv6enabled, + EnableIPV4 = true, + PublishedServerUriBySubnet = new string[] { publishedServers } + }; + + NetworkManager.MockNetworkSettings = "192.168.1.208/24,-16,eth16|200.200.200.200/24,11,eth11"; + using var nm = new NetworkManager(GetMockConfig(conf), new NullLogger<NetworkManager>()); + NetworkManager.MockNetworkSettings = string.Empty; + + if (nm.TryParseInterface(result, out Collection<IPObject>? resultObj) && resultObj != null) + { + // Parse out IPAddresses so we can do a string comparison. (Ignore subnet masks). + result = ((IPNetAddress)resultObj[0]).ToString(true); + } + + var intf = nm.GetBindInterface(source, out int? _); + + Assert.Equal(intf, result); + } + + [Theory] + [InlineData("185.10.10.10,200.200.200.200", "79.2.3.4", true)] + [InlineData("185.10.10.10", "185.10.10.10", false)] + [InlineData("", "100.100.100.100", false)] + + public void HasRemoteAccess_GivenWhitelist_AllowsOnlyIpsInWhitelist(string addresses, string remoteIp, bool denied) + { + // Comma separated list of IP addresses or IP/netmask entries for networks that will be allowed to connect remotely. + // If left blank, all remote addresses will be allowed. + var conf = new NetworkConfiguration() + { + EnableIPV4 = true, + RemoteIPFilter = addresses.Split(','), + IsRemoteIPFilterBlacklist = false + }; + using var nm = new NetworkManager(GetMockConfig(conf), new NullLogger<NetworkManager>()); + + Assert.NotEqual(nm.HasRemoteAccess(IPAddress.Parse(remoteIp)), denied); + } + + [Theory] + [InlineData("185.10.10.10", "79.2.3.4", false)] + [InlineData("185.10.10.10", "185.10.10.10", true)] + [InlineData("", "100.100.100.100", false)] + public void HasRemoteAccess_GivenBlacklist_BlacklistTheIps(string addresses, string remoteIp, bool denied) + { + // Comma separated list of IP addresses or IP/netmask entries for networks that will be allowed to connect remotely. + // If left blank, all remote addresses will be allowed. + var conf = new NetworkConfiguration() + { + EnableIPV4 = true, + RemoteIPFilter = addresses.Split(','), + IsRemoteIPFilterBlacklist = true + }; + + using var nm = new NetworkManager(GetMockConfig(conf), new NullLogger<NetworkManager>()); + + Assert.NotEqual(nm.HasRemoteAccess(IPAddress.Parse(remoteIp)), denied); + } + } +} diff --git a/tests/Jellyfin.Providers.Tests/Jellyfin.Providers.Tests.csproj b/tests/Jellyfin.Providers.Tests/Jellyfin.Providers.Tests.csproj new file mode 100644 index 0000000000..d9e33617bc --- /dev/null +++ b/tests/Jellyfin.Providers.Tests/Jellyfin.Providers.Tests.csproj @@ -0,0 +1,34 @@ +<Project Sdk="Microsoft.NET.Sdk"> + + <PropertyGroup> + <TargetFramework>net5.0</TargetFramework> + <IsPackable>false</IsPackable> + <CodeAnalysisRuleSet>../jellyfin-tests.ruleset</CodeAnalysisRuleSet> + </PropertyGroup> + + <ItemGroup> + <PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.11.0" /> + <PackageReference Include="Moq" Version="4.16.1" /> + <PackageReference Include="xunit" Version="2.4.1" /> + <PackageReference Include="xunit.runner.visualstudio" Version="2.4.3"> + <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> + <PrivateAssets>all</PrivateAssets> + </PackageReference> + <PackageReference Include="coverlet.collector" Version="3.1.0"> + <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> + <PrivateAssets>all</PrivateAssets> + </PackageReference> + </ItemGroup> + + <!-- Code Analyzers --> + <ItemGroup Condition=" '$(Configuration)' == 'Debug' "> + <PackageReference Include="SerilogAnalyzer" Version="0.15.0" PrivateAssets="All" /> + <PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="All" /> + <PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" PrivateAssets="All" /> + </ItemGroup> + + <ItemGroup> + <ProjectReference Include="../../MediaBrowser.Providers/MediaBrowser.Providers.csproj" /> + </ItemGroup> + +</Project> diff --git a/tests/Jellyfin.Providers.Tests/MediaInfo/SubtitleResolverTests.cs b/tests/Jellyfin.Providers.Tests/MediaInfo/SubtitleResolverTests.cs new file mode 100644 index 0000000000..b160e676e4 --- /dev/null +++ b/tests/Jellyfin.Providers.Tests/MediaInfo/SubtitleResolverTests.cs @@ -0,0 +1,96 @@ +#pragma warning disable CA1002 // Do not expose generic lists + +using System.Collections.Generic; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.Globalization; +using MediaBrowser.Providers.MediaInfo; +using Moq; +using Xunit; + +namespace Jellyfin.Providers.Tests.MediaInfo +{ + public class SubtitleResolverTests + { + public static IEnumerable<object[]> AddExternalSubtitleStreams_GivenMixedFilenames_ReturnsValidSubtitles_TestData() + { + var index = 0; + yield return new object[] + { + new List<MediaStream>(), + "/video/My.Video.mkv", + index, + new[] + { + "/video/My.Video.mp3", + "/video/My.Video.png", + "/video/My.Video.srt", + "/video/My.Video.txt", + "/video/My.Video.vtt", + "/video/My.Video.ass", + "/video/My.Video.sub", + "/video/My.Video.ssa", + "/video/My.Video.smi", + "/video/My.Video.sami", + "/video/My.Video.en.srt", + "/video/My.Video.default.en.srt", + "/video/My.Video.default.forced.en.srt", + "/video/My.Video.en.default.forced.srt", + "/video/My.Video.With.Additional.Garbage.en.srt", + "/video/My.Video With Additional Garbage.srt" + }, + new[] + { + CreateMediaStream("/video/My.Video.srt", "srt", null, index++), + CreateMediaStream("/video/My.Video.vtt", "vtt", null, index++), + CreateMediaStream("/video/My.Video.ass", "ass", null, index++), + CreateMediaStream("/video/My.Video.sub", "sub", null, index++), + CreateMediaStream("/video/My.Video.ssa", "ssa", null, index++), + CreateMediaStream("/video/My.Video.smi", "smi", null, index++), + CreateMediaStream("/video/My.Video.sami", "sami", null, index++), + CreateMediaStream("/video/My.Video.en.srt", "srt", "en", index++), + CreateMediaStream("/video/My.Video.default.en.srt", "srt", "en", index++, isDefault: true), + CreateMediaStream("/video/My.Video.default.forced.en.srt", "srt", "en", index++, isForced: true, isDefault: true), + CreateMediaStream("/video/My.Video.en.default.forced.srt", "srt", "en", index++, isForced: true, isDefault: true), + CreateMediaStream("/video/My.Video.With.Additional.Garbage.en.srt", "srt", "en", index), + } + }; + } + + [Theory] + [MemberData(nameof(AddExternalSubtitleStreams_GivenMixedFilenames_ReturnsValidSubtitles_TestData))] + public void AddExternalSubtitleStreams_GivenMixedFilenames_ReturnsValidSubtitles(List<MediaStream> streams, string videoPath, int startIndex, string[] files, MediaStream[] expectedResult) + { + new SubtitleResolver(Mock.Of<ILocalizationManager>()).AddExternalSubtitleStreams(streams, videoPath, startIndex, files); + + Assert.Equal(expectedResult.Length, streams.Count); + for (var i = 0; i < expectedResult.Length; i++) + { + var expected = expectedResult[i]; + var actual = streams[i]; + + Assert.Equal(expected.Index, actual.Index); + Assert.Equal(expected.Type, actual.Type); + Assert.Equal(expected.IsExternal, actual.IsExternal); + Assert.Equal(expected.Path, actual.Path); + Assert.Equal(expected.IsDefault, actual.IsDefault); + Assert.Equal(expected.IsForced, actual.IsForced); + Assert.Equal(expected.Language, actual.Language); + } + } + + private static MediaStream CreateMediaStream(string path, string codec, string? language, int index, bool isForced = false, bool isDefault = false) + { + return new () + { + Index = index, + Codec = codec, + Type = MediaStreamType.Subtitle, + IsExternal = true, + Path = path, + IsDefault = isDefault, + IsForced = isForced, + Language = language + }; + } + } +} diff --git a/tests/Jellyfin.Providers.Tests/Omdb/JsonOmdbConverterTests.cs b/tests/Jellyfin.Providers.Tests/Omdb/JsonOmdbConverterTests.cs new file mode 100644 index 0000000000..25900bc09e --- /dev/null +++ b/tests/Jellyfin.Providers.Tests/Omdb/JsonOmdbConverterTests.cs @@ -0,0 +1,86 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using MediaBrowser.Providers.Plugins.Omdb; +using Xunit; + +namespace Jellyfin.Providers.Tests.Omdb +{ + public class JsonOmdbConverterTests + { + private readonly JsonSerializerOptions _options; + + public JsonOmdbConverterTests() + { + _options = new JsonSerializerOptions(); + _options.Converters.Add(new JsonOmdbNotAvailableStringConverter()); + _options.Converters.Add(new JsonOmdbNotAvailableInt32Converter()); + _options.NumberHandling = JsonNumberHandling.AllowReadingFromString; + } + + [Fact] + public void Deserialize_Omdb_Response_Not_Available_Success() + { + const string Input = "{\"Title\":\"Chapter 1\",\"Year\":\"2013\",\"Rated\":\"TV-MA\",\"Released\":\"01 Feb 2013\",\"Season\":\"N/A\",\"Episode\":\"N/A\",\"Runtime\":\"55 min\",\"Genre\":\"Drama\",\"Director\":\"David Fincher\",\"Writer\":\"Michael Dobbs (based on the novels by), Andrew Davies (based on the mini-series by), Beau Willimon (created for television by), Beau Willimon, Sam Forman (staff writer)\",\"Actors\":\"Kevin Spacey, Robin Wright, Kate Mara, Corey Stoll\",\"Plot\":\"Congressman Francis Underwood has been declined the chair for Secretary of State. He's now gathering his own team to plot his revenge. Zoe Barnes, a reporter for the Washington Herald, will do anything to get her big break.\",\"Language\":\"English\",\"Country\":\"USA\",\"Awards\":\"N/A\",\"Poster\":\"https://m.media-amazon.com/images/M/MV5BMTY5MTU4NDQzNV5BMl5BanBnXkFtZTgwMzk2ODcxMzE@._V1_SX300.jpg\",\"Ratings\":[{\"Source\":\"Internet Movie Database\",\"Value\":\"8.7/10\"}],\"Metascore\":\"N/A\",\"imdbRating\":\"8.7\",\"imdbVotes\":\"6736\",\"imdbID\":\"tt2161930\",\"seriesID\":\"N/A\",\"Type\":\"episode\",\"Response\":\"True\"}"; + var seasonRootObject = JsonSerializer.Deserialize<OmdbProvider.RootObject>(Input, _options); + Assert.NotNull(seasonRootObject); + Assert.Null(seasonRootObject?.Awards); + Assert.Null(seasonRootObject?.Episode); + Assert.Null(seasonRootObject?.Metascore); + } + + [Theory] + [InlineData("\"N/A\"")] + [InlineData("null")] + public void Deserialization_To_Nullable_Int_Shoud_Be_Null(string input) + { + var result = JsonSerializer.Deserialize<int?>(input, _options); + Assert.Null(result); + } + + [Theory] + [InlineData("\"8\"", 8)] + [InlineData("8", 8)] + public void Deserialize_NullableInt_Success(string input, int? expected) + { + var result = JsonSerializer.Deserialize<int?>(input, _options); + Assert.Equal(result, expected); + } + + [Theory] + [InlineData("\"N/A\"")] + [InlineData("null")] + public void Deserialization_To_Nullable_String_Shoud_Be_Null(string input) + { + var result = JsonSerializer.Deserialize<string?>(input, _options); + Assert.Null(result); + } + + [Theory] + [InlineData("\"Jellyfin\"", "Jellyfin")] + public void Deserialize_Normal_String_Success(string input, string expected) + { + var result = JsonSerializer.Deserialize<string?>(input, _options); + Assert.Equal(expected, result); + } + + [Fact] + public void Roundtrip_Valid_Success() + { + const string Input = "{\"Title\":\"Chapter 1\",\"Year\":\"2013\",\"Rated\":\"TV-MA\",\"Released\":\"01 Feb 2013\",\"Season\":\"N/A\",\"Episode\":\"N/A\",\"Runtime\":\"55 min\",\"Genre\":\"Drama\",\"Director\":\"David Fincher\",\"Writer\":\"Michael Dobbs (based on the novels by), Andrew Davies (based on the mini-series by), Beau Willimon (created for television by), Beau Willimon, Sam Forman (staff writer)\",\"Actors\":\"Kevin Spacey, Robin Wright, Kate Mara, Corey Stoll\",\"Plot\":\"Congressman Francis Underwood has been declined the chair for Secretary of State. He's now gathering his own team to plot his revenge. Zoe Barnes, a reporter for the Washington Herald, will do anything to get her big break.\",\"Language\":\"English\",\"Country\":\"USA\",\"Awards\":\"N/A\",\"Poster\":\"https://m.media-amazon.com/images/M/MV5BMTY5MTU4NDQzNV5BMl5BanBnXkFtZTgwMzk2ODcxMzE@._V1_SX300.jpg\",\"Ratings\":[{\"Source\":\"Internet Movie Database\",\"Value\":\"8.7/10\"}],\"Metascore\":\"N/A\",\"imdbRating\":\"8.7\",\"imdbVotes\":\"6736\",\"imdbID\":\"tt2161930\",\"seriesID\":\"N/A\",\"Type\":\"episode\",\"Response\":\"True\"}"; + var trip1 = JsonSerializer.Deserialize<OmdbProvider.RootObject>(Input, _options); + Assert.NotNull(trip1); + Assert.NotNull(trip1?.Title); + Assert.Null(trip1?.Awards); + Assert.Null(trip1?.Episode); + Assert.Null(trip1?.Metascore); + + var serializedTrip1 = JsonSerializer.Serialize(trip1!, _options); + var trip2 = JsonSerializer.Deserialize<OmdbProvider.RootObject>(serializedTrip1, _options); + Assert.NotNull(trip2); + Assert.NotNull(trip2?.Title); + Assert.Null(trip2?.Awards); + Assert.Null(trip2?.Episode); + Assert.Null(trip2?.Metascore); + } + } +} diff --git a/tests/Jellyfin.Providers.Tests/Tmdb/TmdbUtilsTests.cs b/tests/Jellyfin.Providers.Tests/Tmdb/TmdbUtilsTests.cs new file mode 100644 index 0000000000..f6a7c676f4 --- /dev/null +++ b/tests/Jellyfin.Providers.Tests/Tmdb/TmdbUtilsTests.cs @@ -0,0 +1,27 @@ +using MediaBrowser.Providers.Plugins.Tmdb; +using Xunit; + +namespace Jellyfin.Providers.Tests.Tmdb +{ + public static class TmdbUtilsTests + { + [Theory] + [InlineData("de", "de")] + [InlineData("En", "En")] + [InlineData("de-de", "de-DE")] + [InlineData("en-US", "en-US")] + [InlineData("de-CH", "de")] + public static void NormalizeLanguage_Valid_Success(string input, string expected) + { + Assert.Equal(expected, TmdbUtils.NormalizeLanguage(input)); + } + + [Theory] + [InlineData(null, null)] + [InlineData("", "")] + public static void NormalizeLanguage_Invalid_Equal(string? input, string? expected) + { + Assert.Equal(expected, TmdbUtils.NormalizeLanguage(input!)); + } + } +} diff --git a/tests/Jellyfin.Server.Implementations.Tests/Data/SqliteItemRepositoryTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Data/SqliteItemRepositoryTests.cs new file mode 100644 index 0000000000..f312933fbf --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/Data/SqliteItemRepositoryTests.cs @@ -0,0 +1,299 @@ +using System; +using System.Collections.Generic; +using AutoFixture; +using AutoFixture.AutoMoq; +using Emby.Server.Implementations.Data; +using MediaBrowser.Controller; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Model.Entities; +using Moq; +using Xunit; + +namespace Jellyfin.Server.Implementations.Tests.Data +{ + public class SqliteItemRepositoryTests + { + public const string VirtualMetaDataPath = "%MetadataPath%"; + public const string MetaDataPath = "/meta/data/path"; + + private readonly IFixture _fixture; + private readonly SqliteItemRepository _sqliteItemRepository; + + public SqliteItemRepositoryTests() + { + var appHost = new Mock<IServerApplicationHost>(); + appHost.Setup(x => x.ExpandVirtualPath(It.IsAny<string>())) + .Returns((string x) => x.Replace(VirtualMetaDataPath, MetaDataPath, StringComparison.Ordinal)); + appHost.Setup(x => x.ReverseVirtualPath(It.IsAny<string>())) + .Returns((string x) => x.Replace(MetaDataPath, VirtualMetaDataPath, StringComparison.Ordinal)); + + _fixture = new Fixture().Customize(new AutoMoqCustomization { ConfigureMembers = true }); + _fixture.Inject(appHost); + _sqliteItemRepository = _fixture.Create<SqliteItemRepository>(); + } + + public static IEnumerable<object[]> ItemImageInfoFromValueString_Valid_TestData() + { + yield return new object[] + { + "/mnt/series/Family Guy/Season 1/Family Guy - S01E01-thumb.jpg*637452096478512963*Primary*1920*1080*WjQbtJtSO8nhNZ%L_Io#R/oaS6o}-;adXAoIn7j[%hW9s:WGw[nN", + new ItemImageInfo + { + Path = "/mnt/series/Family Guy/Season 1/Family Guy - S01E01-thumb.jpg", + Type = ImageType.Primary, + DateModified = new DateTime(637452096478512963, DateTimeKind.Utc), + Width = 1920, + Height = 1080, + BlurHash = "WjQbtJtSO8nhNZ%L_Io#R*oaS6o}-;adXAoIn7j[%hW9s:WGw[nN" + } + }; + + yield return new object[] + { + "https://image.tmdb.org/t/p/original/zhB5CHEgqqh4wnEqDNJLfWXJlcL.jpg*0*Primary*0*0", + new ItemImageInfo + { + Path = "https://image.tmdb.org/t/p/original/zhB5CHEgqqh4wnEqDNJLfWXJlcL.jpg", + Type = ImageType.Primary, + } + }; + + yield return new object[] + { + "https://image.tmdb.org/t/p/original/zhB5CHEgqqh4wnEqDNJLfWXJlcL.jpg*0*Primary", + new ItemImageInfo + { + Path = "https://image.tmdb.org/t/p/original/zhB5CHEgqqh4wnEqDNJLfWXJlcL.jpg", + Type = ImageType.Primary, + } + }; + + yield return new object[] + { + "https://image.tmdb.org/t/p/original/zhB5CHEgqqh4wnEqDNJLfWXJlcL.jpg*0*Primary*600", + new ItemImageInfo + { + Path = "https://image.tmdb.org/t/p/original/zhB5CHEgqqh4wnEqDNJLfWXJlcL.jpg", + Type = ImageType.Primary, + } + }; + + yield return new object[] + { + "%MetadataPath%/library/68/68578562b96c80a7ebd530848801f645/poster.jpg*637264380567586027*Primary*600*336", + new ItemImageInfo + { + Path = "/meta/data/path/library/68/68578562b96c80a7ebd530848801f645/poster.jpg", + Type = ImageType.Primary, + DateModified = new DateTime(637264380567586027, DateTimeKind.Utc), + Width = 600, + Height = 336 + } + }; + } + + [Theory] + [MemberData(nameof(ItemImageInfoFromValueString_Valid_TestData))] + public void ItemImageInfoFromValueString_Valid_Success(string value, ItemImageInfo expected) + { + var result = _sqliteItemRepository.ItemImageInfoFromValueString(value); + Assert.Equal(expected.Path, result.Path); + Assert.Equal(expected.Type, result.Type); + Assert.Equal(expected.DateModified, result.DateModified); + Assert.Equal(expected.Width, result.Width); + Assert.Equal(expected.Height, result.Height); + Assert.Equal(expected.BlurHash, result.BlurHash); + } + + [Theory] + [InlineData("")] + [InlineData("*")] + [InlineData("https://image.tmdb.org/t/p/original/zhB5CHEgqqh4wnEqDNJLfWXJlcL.jpg*0")] + public void ItemImageInfoFromValueString_Invalid_Null(string value) + { + Assert.Null(_sqliteItemRepository.ItemImageInfoFromValueString(value)); + } + + public static IEnumerable<object[]> DeserializeImages_Valid_TestData() + { + yield return new object[] + { + "/mnt/series/Family Guy/Season 1/Family Guy - S01E01-thumb.jpg*637452096478512963*Primary*1920*1080*WjQbtJtSO8nhNZ%L_Io#R/oaS6o}-;adXAoIn7j[%hW9s:WGw[nN", + new ItemImageInfo[] + { + new ItemImageInfo() + { + Path = "/mnt/series/Family Guy/Season 1/Family Guy - S01E01-thumb.jpg", + Type = ImageType.Primary, + DateModified = new DateTime(637452096478512963, DateTimeKind.Utc), + Width = 1920, + Height = 1080, + BlurHash = "WjQbtJtSO8nhNZ%L_Io#R*oaS6o}-;adXAoIn7j[%hW9s:WGw[nN" + } + } + }; + + yield return new object[] + { + "%MetadataPath%/library/2a/2a27372f1e9bc757b1db99721bbeae1e/poster.jpg*637261226720645297*Primary*0*0|%MetadataPath%/library/2a/2a27372f1e9bc757b1db99721bbeae1e/logo.png*637261226720805297*Logo*0*0|%MetadataPath%/library/2a/2a27372f1e9bc757b1db99721bbeae1e/landscape.jpg*637261226721285297*Thumb*0*0|%MetadataPath%/library/2a/2a27372f1e9bc757b1db99721bbeae1e/backdrop.jpg*637261226721685297*Backdrop*0*0", + new ItemImageInfo[] + { + new ItemImageInfo() + { + Path = "/meta/data/path/library/2a/2a27372f1e9bc757b1db99721bbeae1e/poster.jpg", + Type = ImageType.Primary, + DateModified = new DateTime(637261226720645297, DateTimeKind.Utc), + }, + new ItemImageInfo() + { + Path = "/meta/data/path/library/2a/2a27372f1e9bc757b1db99721bbeae1e/logo.png", + Type = ImageType.Logo, + DateModified = new DateTime(637261226720805297, DateTimeKind.Utc), + }, + new ItemImageInfo() + { + Path = "/meta/data/path/library/2a/2a27372f1e9bc757b1db99721bbeae1e/landscape.jpg", + Type = ImageType.Thumb, + DateModified = new DateTime(637261226721285297, DateTimeKind.Utc), + }, + new ItemImageInfo() + { + Path = "/meta/data/path/library/2a/2a27372f1e9bc757b1db99721bbeae1e/backdrop.jpg", + Type = ImageType.Backdrop, + DateModified = new DateTime(637261226721685297, DateTimeKind.Utc), + } + } + }; + } + + public static IEnumerable<object[]> DeserializeImages_ValidAndInvalid_TestData() + { + yield return new object[] + { + string.Empty, + Array.Empty<ItemImageInfo>() + }; + + yield return new object[] + { + "/mnt/series/Family Guy/Season 1/Family Guy - S01E01-thumb.jpg*637452096478512963*Primary*1920*1080*WjQbtJtSO8nhNZ%L_Io#R/oaS6o}-;adXAoIn7j[%hW9s:WGw[nN|test|1234||ss", + new ItemImageInfo[] + { + new () + { + Path = "/mnt/series/Family Guy/Season 1/Family Guy - S01E01-thumb.jpg", + Type = ImageType.Primary, + DateModified = new DateTime(637452096478512963, DateTimeKind.Utc), + Width = 1920, + Height = 1080, + BlurHash = "WjQbtJtSO8nhNZ%L_Io#R*oaS6o}-;adXAoIn7j[%hW9s:WGw[nN" + } + } + }; + + yield return new object[] + { + "|", + Array.Empty<ItemImageInfo>() + }; + } + + [Theory] + [MemberData(nameof(DeserializeImages_Valid_TestData))] + public void DeserializeImages_Valid_Success(string value, ItemImageInfo[] expected) + { + var result = _sqliteItemRepository.DeserializeImages(value); + Assert.Equal(expected.Length, result.Length); + for (int i = 0; i < expected.Length; i++) + { + Assert.Equal(expected[i].Path, result[i].Path); + Assert.Equal(expected[i].Type, result[i].Type); + Assert.Equal(expected[i].DateModified, result[i].DateModified); + Assert.Equal(expected[i].Width, result[i].Width); + Assert.Equal(expected[i].Height, result[i].Height); + Assert.Equal(expected[i].BlurHash, result[i].BlurHash); + } + } + + [Theory] + [MemberData(nameof(DeserializeImages_ValidAndInvalid_TestData))] + public void DeserializeImages_ValidAndInvalid_Success(string value, ItemImageInfo[] expected) + { + var result = _sqliteItemRepository.DeserializeImages(value); + Assert.Equal(expected.Length, result.Length); + for (int i = 0; i < expected.Length; i++) + { + Assert.Equal(expected[i].Path, result[i].Path); + Assert.Equal(expected[i].Type, result[i].Type); + Assert.Equal(expected[i].DateModified, result[i].DateModified); + Assert.Equal(expected[i].Width, result[i].Width); + Assert.Equal(expected[i].Height, result[i].Height); + Assert.Equal(expected[i].BlurHash, result[i].BlurHash); + } + } + + [Theory] + [MemberData(nameof(DeserializeImages_Valid_TestData))] + public void SerializeImages_Valid_Success(string expected, ItemImageInfo[] value) + { + Assert.Equal(expected, _sqliteItemRepository.SerializeImages(value)); + } + + public static IEnumerable<object[]> DeserializeProviderIds_Valid_TestData() + { + yield return new object[] + { + "Imdb=tt0119567", + new Dictionary<string, string>() + { + { "Imdb", "tt0119567" }, + } + }; + + yield return new object[] + { + "Imdb=tt0119567|Tmdb=330|TmdbCollection=328", + new Dictionary<string, string>() + { + { "Imdb", "tt0119567" }, + { "Tmdb", "330" }, + { "TmdbCollection", "328" }, + } + }; + + yield return new object[] + { + "MusicBrainzAlbum=9d363e43-f24f-4b39-bc5a-7ef305c677c7|MusicBrainzReleaseGroup=63eba062-847c-3b73-8b0f-6baf27bba6fa|AudioDbArtist=111352|AudioDbAlbum=2116560|MusicBrainzAlbumArtist=20244d07-534f-4eff-b4d4-930878889970", + new Dictionary<string, string>() + { + { "MusicBrainzAlbum", "9d363e43-f24f-4b39-bc5a-7ef305c677c7" }, + { "MusicBrainzReleaseGroup", "63eba062-847c-3b73-8b0f-6baf27bba6fa" }, + { "AudioDbArtist", "111352" }, + { "AudioDbAlbum", "2116560" }, + { "MusicBrainzAlbumArtist", "20244d07-534f-4eff-b4d4-930878889970" }, + } + }; + } + + [Theory] + [MemberData(nameof(DeserializeProviderIds_Valid_TestData))] + public void DeserializeProviderIds_Valid_Success(string value, Dictionary<string, string> expected) + { + var result = new ProviderIdsExtensionsTestsObject(); + SqliteItemRepository.DeserializeProviderIds(value, result); + Assert.Equal(expected, result.ProviderIds); + } + + [Theory] + [MemberData(nameof(DeserializeProviderIds_Valid_TestData))] + public void SerializeProviderIds_Valid_Success(string expected, Dictionary<string, string> values) + { + Assert.Equal(expected, SqliteItemRepository.SerializeProviderIds(values)); + } + + private class ProviderIdsExtensionsTestsObject : IHasProviderIds + { + public Dictionary<string, string> ProviderIds { get; set; } = new Dictionary<string, string>(); + } + } +} diff --git a/tests/Jellyfin.Server.Implementations.Tests/HttpServer/WebSocketConnectionTests.cs b/tests/Jellyfin.Server.Implementations.Tests/HttpServer/WebSocketConnectionTests.cs new file mode 100644 index 0000000000..1ce2096ea6 --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/HttpServer/WebSocketConnectionTests.cs @@ -0,0 +1,69 @@ +using System; +using System.Buffers; +using System.IO; +using System.Text.Json; +using Emby.Server.Implementations.HttpServer; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace Jellyfin.Server.Implementations.Tests.HttpServer +{ + public class WebSocketConnectionTests + { + [Fact] + public void DeserializeWebSocketMessage_SingleSegment_Success() + { + var con = new WebSocketConnection(new NullLogger<WebSocketConnection>(), null!, null!, null!); + var bytes = File.ReadAllBytes("Test Data/HttpServer/ForceKeepAlive.json"); + con.DeserializeWebSocketMessage(new ReadOnlySequence<byte>(bytes), out var bytesConsumed); + Assert.Equal(109, bytesConsumed); + } + + [Fact] + public void DeserializeWebSocketMessage_MultipleSegments_Success() + { + const int SplitPos = 64; + var con = new WebSocketConnection(new NullLogger<WebSocketConnection>(), null!, null!, null!); + var bytes = File.ReadAllBytes("Test Data/HttpServer/ForceKeepAlive.json"); + var seg1 = new BufferSegment(new Memory<byte>(bytes, 0, SplitPos)); + var seg2 = seg1.Append(new Memory<byte>(bytes, SplitPos, bytes.Length - SplitPos)); + con.DeserializeWebSocketMessage(new ReadOnlySequence<byte>(seg1, 0, seg2, seg2.Memory.Length - 1), out var bytesConsumed); + Assert.Equal(109, bytesConsumed); + } + + [Fact] + public void DeserializeWebSocketMessage_ValidPartial_Success() + { + var con = new WebSocketConnection(new NullLogger<WebSocketConnection>(), null!, null!, null!); + var bytes = File.ReadAllBytes("Test Data/HttpServer/ValidPartial.json"); + con.DeserializeWebSocketMessage(new ReadOnlySequence<byte>(bytes), out var bytesConsumed); + Assert.Equal(109, bytesConsumed); + } + + [Fact] + public void DeserializeWebSocketMessage_Partial_ThrowJsonException() + { + var con = new WebSocketConnection(new NullLogger<WebSocketConnection>(), null!, null!, null!); + var bytes = File.ReadAllBytes("Test Data/HttpServer/Partial.json"); + Assert.Throws<JsonException>(() => con.DeserializeWebSocketMessage(new ReadOnlySequence<byte>(bytes), out var bytesConsumed)); + } + + internal class BufferSegment : ReadOnlySequenceSegment<byte> + { + public BufferSegment(Memory<byte> memory) + { + Memory = memory; + } + + public BufferSegment Append(Memory<byte> memory) + { + var segment = new BufferSegment(memory) + { + RunningIndex = RunningIndex + Memory.Length + }; + Next = segment; + return segment; + } + } + } +} diff --git a/tests/Jellyfin.Server.Implementations.Tests/IO/ManagedFileSystemTests.cs b/tests/Jellyfin.Server.Implementations.Tests/IO/ManagedFileSystemTests.cs index 671c59b2ec..d991f55748 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/IO/ManagedFileSystemTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/IO/ManagedFileSystemTests.cs @@ -1,7 +1,10 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using System.IO; +using System.Runtime.InteropServices; using AutoFixture; using AutoFixture.AutoMoq; using Emby.Server.Implementations.IO; -using MediaBrowser.Model.System; using Xunit; namespace Jellyfin.Server.Implementations.Tests.IO @@ -28,7 +31,7 @@ namespace Jellyfin.Server.Implementations.Tests.IO { var generatedPath = _sut.MakeAbsolutePath(folderPath, filePath); - if (MediaBrowser.Common.System.OperatingSystem.Id == OperatingSystemId.Windows) + if (OperatingSystem.IsWindows()) { var expectedWindowsPath = expectedAbsolutePath.Replace('/', '\\'); Assert.Equal(expectedWindowsPath, generatedPath.Split(':')[1]); @@ -38,5 +41,36 @@ namespace Jellyfin.Server.Implementations.Tests.IO Assert.Equal(expectedAbsolutePath, generatedPath); } } + + [Theory] + [InlineData("ValidFileName", "ValidFileName")] + [InlineData("AC/DC", "AC DC")] + [InlineData("Invalid\0", "Invalid ")] + [InlineData("AC/DC\0KD/A", "AC DC KD A")] + public void GetValidFilename_ReturnsValidFilename(string filename, string expectedFileName) + { + Assert.Equal(expectedFileName, _sut.GetValidFilename(filename)); + } + + [SkippableFact] + public void GetFileInfo_DanglingSymlink_ExistsFalse() + { + Skip.If(OperatingSystem.IsWindows()); + + string testFileDir = Path.Combine(Path.GetTempPath(), "jellyfin-test-data"); + string testFileName = Path.Combine(testFileDir, Path.GetRandomFileName() + "-danglingsym.link"); + + Directory.CreateDirectory(testFileDir); + Assert.Equal(0, symlink("thispathdoesntexist", testFileName)); + Assert.True(File.Exists(testFileName)); + + var metadata = _sut.GetFileInfo(testFileName); + Assert.False(metadata.Exists); + } + + [SuppressMessage("Naming Rules", "SA1300:ElementMustBeginWithUpperCaseLetter", Justification = "Have to")] + [DllImport("libc", SetLastError = true, CharSet = CharSet.Ansi)] + [DefaultDllImportSearchPaths(DllImportSearchPath.UserDirectories)] + private static extern int symlink(string target, string linkpath); } } diff --git a/tests/Jellyfin.Server.Implementations.Tests/Jellyfin.Server.Implementations.Tests.csproj b/tests/Jellyfin.Server.Implementations.Tests/Jellyfin.Server.Implementations.Tests.csproj index 75b67f460f..9b6ab7bdf5 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/Jellyfin.Server.Implementations.Tests.csproj +++ b/tests/Jellyfin.Server.Implementations.Tests/Jellyfin.Server.Implementations.Tests.csproj @@ -6,26 +6,31 @@ </PropertyGroup> <PropertyGroup> - <TargetFramework>netcoreapp3.1</TargetFramework> + <TargetFramework>net5.0</TargetFramework> <IsPackable>false</IsPackable> - <TreatWarningsAsErrors>true</TreatWarningsAsErrors> - <Nullable>enable</Nullable> + <CodeAnalysisRuleSet>../jellyfin-tests.ruleset</CodeAnalysisRuleSet> <RootNamespace>Jellyfin.Server.Implementations.Tests</RootNamespace> </PropertyGroup> <ItemGroup> - <PackageReference Include="AutoFixture" Version="4.13.0" /> - <PackageReference Include="AutoFixture.AutoMoq" Version="4.13.0" /> - <PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.7.1" /> - <PackageReference Include="Moq" Version="4.14.6" /> + <None Include="Test Data\**\*.*"> + <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> + </None> + </ItemGroup> + + <ItemGroup> + <PackageReference Include="AutoFixture" Version="4.17.0" /> + <PackageReference Include="AutoFixture.AutoMoq" Version="4.17.0" /> + <PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.11.0" /> + <PackageReference Include="Moq" Version="4.16.1" /> <PackageReference Include="xunit" Version="2.4.1" /> <PackageReference Include="xunit.runner.visualstudio" Version="2.4.3" /> - <PackageReference Include="coverlet.collector" Version="1.3.0" /> + <PackageReference Include="Xunit.SkippableFact" Version="1.4.13" /> + <PackageReference Include="coverlet.collector" Version="3.1.0" /> </ItemGroup> <!-- Code Analyzers --> <ItemGroup Condition=" '$(Configuration)' == 'Debug' "> - <PackageReference Include="Microsoft.CodeAnalysis.FxCopAnalyzers" Version="2.9.8" PrivateAssets="All" /> <PackageReference Include="SerilogAnalyzer" Version="0.15.0" PrivateAssets="All" /> <PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="All" /> <PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" PrivateAssets="All" /> @@ -33,10 +38,8 @@ <ItemGroup> <ProjectReference Include="..\..\Emby.Server.Implementations\Emby.Server.Implementations.csproj" /> + <ProjectReference Include="..\..\Jellyfin.Server.Implementations\Jellyfin.Server.Implementations.csproj" /> + <ProjectReference Include="..\Jellyfin.Server.Integration.Tests\Jellyfin.Server.Integration.Tests.csproj" /> </ItemGroup> - <PropertyGroup Condition=" '$(Configuration)' == 'Debug' "> - <CodeAnalysisRuleSet>../jellyfin-tests.ruleset</CodeAnalysisRuleSet> - </PropertyGroup> - </Project> diff --git a/tests/Jellyfin.Server.Implementations.Tests/Library/EpisodeResolverTest.cs b/tests/Jellyfin.Server.Implementations.Tests/Library/EpisodeResolverTest.cs new file mode 100644 index 0000000000..c393742eb3 --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/Library/EpisodeResolverTest.cs @@ -0,0 +1,72 @@ +using System; +using Emby.Server.Implementations.Library.Resolvers.TV; +using MediaBrowser.Controller; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.TV; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.IO; +using Moq; +using Xunit; + +namespace Jellyfin.Server.Implementations.Tests.Library +{ + public class EpisodeResolverTest + { + [Fact] + public void Resolve_GivenVideoInExtrasFolder_DoesNotResolveToEpisode() + { + var season = new Season { Name = "Season 1" }; + var parent = new Folder { Name = "extras" }; + var libraryManagerMock = new Mock<ILibraryManager>(); + libraryManagerMock.Setup(x => x.GetItemById(It.IsAny<Guid>())).Returns(season); + + var episodeResolver = new EpisodeResolver(libraryManagerMock.Object); + var itemResolveArgs = new ItemResolveArgs( + Mock.Of<IServerApplicationPaths>(), + Mock.Of<IDirectoryService>()) + { + Parent = parent, + CollectionType = CollectionType.TvShows, + FileInfo = new FileSystemMetadata() + { + FullName = "All My Children/Season 01/Extras/All My Children S01E01 - Behind The Scenes.mkv" + } + }; + + Assert.Null(episodeResolver.Resolve(itemResolveArgs)); + } + + [Fact] + public void Resolve_GivenVideoInExtrasSeriesFolder_ResolvesToEpisode() + { + var series = new Series { Name = "Extras" }; + + // Have to create a mock because of moq proxies not being castable to a concrete implementation + // https://github.com/jellyfin/jellyfin/blob/ab0cff8556403e123642dc9717ba778329554634/Emby.Server.Implementations/Library/Resolvers/BaseVideoResolver.cs#L48 + var episodeResolver = new EpisodeResolverMock(Mock.Of<ILibraryManager>()); + var itemResolveArgs = new ItemResolveArgs( + Mock.Of<IServerApplicationPaths>(), + Mock.Of<IDirectoryService>()) + { + Parent = series, + CollectionType = CollectionType.TvShows, + FileInfo = new FileSystemMetadata() + { + FullName = "Extras/Extras S01E01.mkv" + } + }; + Assert.NotNull(episodeResolver.Resolve(itemResolveArgs)); + } + + private class EpisodeResolverMock : EpisodeResolver + { + public EpisodeResolverMock(ILibraryManager libraryManager) : base(libraryManager) + { + } + + protected override TVideoType ResolveVideo<TVideoType>(ItemResolveArgs args, bool parseName) => new (); + } + } +} diff --git a/tests/Jellyfin.Server.Implementations.Tests/Library/PathExtensionsTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Library/PathExtensionsTests.cs index 6d768af890..c5cc056f5d 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/Library/PathExtensionsTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/Library/PathExtensionsTests.cs @@ -24,5 +24,36 @@ namespace Jellyfin.Server.Implementations.Tests.Library { Assert.Throws<ArgumentException>(() => PathExtensions.GetAttributeValue(input, attribute)); } + + [Theory] + [InlineData("C:/Users/jeff/myfile.mkv", "C:/Users/jeff", "/home/jeff", "/home/jeff/myfile.mkv")] + [InlineData("C:/Users/jeff/myfile.mkv", "C:/Users/jeff/", "/home/jeff", "/home/jeff/myfile.mkv")] + [InlineData("/home/jeff/music/jeff's band/consistently inconsistent.mp3", "/home/jeff/music/jeff's band", "/home/not jeff", "/home/not jeff/consistently inconsistent.mp3")] + [InlineData("C:\\Users\\jeff\\myfile.mkv", "C:\\Users/jeff", "/home/jeff", "/home/jeff/myfile.mkv")] + [InlineData("C:\\Users\\jeff\\myfile.mkv", "C:\\Users/jeff", "/home/jeff/", "/home/jeff/myfile.mkv")] + [InlineData("C:\\Users\\jeff\\myfile.mkv", "C:\\Users/jeff/", "/home/jeff/", "/home/jeff/myfile.mkv")] + [InlineData("C:\\Users\\jeff\\myfile.mkv", "C:\\Users/jeff/", "/", "/myfile.mkv")] + [InlineData("/o", "/o", "/s", "/s")] // regression test for #5977 + public void TryReplaceSubPath_ValidArgs_Correct(string path, string subPath, string newSubPath, string? expectedResult) + { + Assert.True(PathExtensions.TryReplaceSubPath(path, subPath, newSubPath, out var result)); + Assert.Equal(expectedResult, result); + } + + [Theory] + [InlineData(null, null, null)] + [InlineData(null, "/my/path", "/another/path")] + [InlineData("/my/path", null, "/another/path")] + [InlineData("/my/path", "/another/path", null)] + [InlineData("", "", "")] + [InlineData("/my/path", "", "")] + [InlineData("", "/another/path", "")] + [InlineData("", "", "/new/subpath")] + [InlineData("/home/jeff/music/jeff's band/consistently inconsistent.mp3", "/home/jeff/music/not jeff's band", "/home/not jeff")] + public void TryReplaceSubPath_InvalidInput_ReturnsFalseAndNull(string? path, string? subPath, string? newSubPath) + { + Assert.False(PathExtensions.TryReplaceSubPath(path, subPath, newSubPath, out var result)); + Assert.Null(result); + } } } diff --git a/tests/Jellyfin.Server.Implementations.Tests/LiveTv/HdHomerunHostTests.cs b/tests/Jellyfin.Server.Implementations.Tests/LiveTv/HdHomerunHostTests.cs new file mode 100644 index 0000000000..c859d11c69 --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/LiveTv/HdHomerunHostTests.cs @@ -0,0 +1,156 @@ +using System; +using System.IO; +using System.Net.Http; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using AutoFixture; +using AutoFixture.AutoMoq; +using Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun; +using MediaBrowser.Model.LiveTv; +using Moq; +using Moq.Protected; +using Xunit; + +namespace Jellyfin.Server.Implementations.Tests.LiveTv +{ + public class HdHomerunHostTests + { + private readonly Fixture _fixture; + private readonly HdHomerunHost _hdHomerunHost; + + public HdHomerunHostTests() + { + var messageHandler = new Mock<HttpMessageHandler>(); + messageHandler.Protected() + .Setup<Task<HttpResponseMessage>>("SendAsync", ItExpr.IsAny<HttpRequestMessage>(), ItExpr.IsAny<CancellationToken>()) + .Returns<HttpRequestMessage, CancellationToken>( + (m, _) => + { + return Task.FromResult(new HttpResponseMessage() + { + Content = new StreamContent(File.OpenRead(Path.Combine("Test Data/LiveTv", m.RequestUri!.Host, m.RequestUri.Segments[^1]))) + }); + }); + + var http = new Mock<IHttpClientFactory>(); + http.Setup(x => x.CreateClient(It.IsAny<string>())) + .Returns(new HttpClient(messageHandler.Object)); + _fixture = new Fixture(); + _fixture.Customize(new AutoMoqCustomization + { + ConfigureMembers = true + }).Inject(http); + _hdHomerunHost = _fixture.Create<HdHomerunHost>(); + } + + [Fact] + public async Task GetModelInfo_Valid_Success() + { + var host = new TunerHostInfo() + { + Url = "192.168.1.182" + }; + + var modelInfo = await _hdHomerunHost.GetModelInfo(host, true, CancellationToken.None).ConfigureAwait(false); + Assert.Equal("HDHomeRun PRIME", modelInfo.FriendlyName); + Assert.Equal("HDHR3-CC", modelInfo.ModelNumber); + Assert.Equal("hdhomerun3_cablecard", modelInfo.FirmwareName); + Assert.Equal("20160630atest2", modelInfo.FirmwareVersion); + Assert.Equal("FFFFFFFF", modelInfo.DeviceID); + Assert.Equal("FFFFFFFF", modelInfo.DeviceAuth); + Assert.Equal(3, modelInfo.TunerCount); + Assert.Equal("http://192.168.1.182:80", modelInfo.BaseURL); + Assert.Equal("http://192.168.1.182:80/lineup.json", modelInfo.LineupURL); + } + + [Fact] + public async Task GetModelInfo_Legacy_Success() + { + var host = new TunerHostInfo() + { + Url = "10.10.10.100" + }; + + var modelInfo = await _hdHomerunHost.GetModelInfo(host, true, CancellationToken.None).ConfigureAwait(false); + Assert.Equal("HDHomeRun DUAL", modelInfo.FriendlyName); + Assert.Equal("HDHR3-US", modelInfo.ModelNumber); + Assert.Equal("hdhomerun3_atsc", modelInfo.FirmwareName); + Assert.Equal("20200225", modelInfo.FirmwareVersion); + Assert.Equal("10xxxxx5", modelInfo.DeviceID); + Assert.Null(modelInfo.DeviceAuth); + Assert.Equal(2, modelInfo.TunerCount); + Assert.Equal("http://10.10.10.100:80", modelInfo.BaseURL); + Assert.Null(modelInfo.LineupURL); + } + + [Fact] + public async Task GetModelInfo_EmptyUrl_ArgumentException() + { + var host = new TunerHostInfo() + { + Url = string.Empty + }; + + await Assert.ThrowsAsync<ArgumentException>(() => _hdHomerunHost.GetModelInfo(host, true, CancellationToken.None)); + } + + [Fact] + public async Task GetLineup_Valid_Success() + { + var host = new TunerHostInfo() + { + Url = "192.168.1.182" + }; + + var channels = await _hdHomerunHost.GetLineup(host, CancellationToken.None).ConfigureAwait(false); + Assert.Equal(6, channels.Count); + Assert.Equal("4.1", channels[0].GuideNumber); + Assert.Equal("WCMH-DT", channels[0].GuideName); + Assert.True(channels[0].HD); + Assert.True(channels[0].Favorite); + Assert.Equal("http://192.168.1.111:5004/auto/v4.1", channels[0].URL); + } + + [Fact] + public async Task GetLineup_Legacy_Success() + { + var host = new TunerHostInfo() + { + Url = "10.10.10.100" + }; + + // Placeholder json is invalid, just need to make sure we can reach it + await Assert.ThrowsAsync<JsonException>(() => _hdHomerunHost.GetLineup(host, CancellationToken.None)); + } + + [Fact] + public async Task GetLineup_ImportFavoritesOnly_Success() + { + var host = new TunerHostInfo() + { + Url = "192.168.1.182", + ImportFavoritesOnly = true + }; + + var channels = await _hdHomerunHost.GetLineup(host, CancellationToken.None).ConfigureAwait(false); + Assert.Single(channels); + Assert.Equal("4.1", channels[0].GuideNumber); + Assert.Equal("WCMH-DT", channels[0].GuideName); + Assert.True(channels[0].HD); + Assert.True(channels[0].Favorite); + Assert.Equal("http://192.168.1.111:5004/auto/v4.1", channels[0].URL); + } + + [Fact] + public async Task TryGetTunerHostInfo_Valid_Success() + { + var host = await _hdHomerunHost.TryGetTunerHostInfo("192.168.1.182", CancellationToken.None).ConfigureAwait(false); + Assert.Equal(_hdHomerunHost.Type, host.Type); + Assert.Equal("192.168.1.182", host.Url); + Assert.Equal("HDHomeRun PRIME", host.FriendlyName); + Assert.Equal("FFFFFFFF", host.DeviceId); + Assert.Equal(3, host.TunerCount); + } + } +} diff --git a/tests/Jellyfin.Server.Implementations.Tests/LiveTv/HdHomerunManagerTests.cs b/tests/Jellyfin.Server.Implementations.Tests/LiveTv/HdHomerunManagerTests.cs new file mode 100644 index 0000000000..fd499d9cf9 --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/LiveTv/HdHomerunManagerTests.cs @@ -0,0 +1,326 @@ +using System; +using System.Text; +using Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun; +using Xunit; + +namespace Jellyfin.Server.Implementations.Tests.LiveTv +{ + public class HdHomerunManagerTests + { + [Fact] + public void WriteNullTerminatedString_Empty_Success() + { + ReadOnlySpan<byte> expected = stackalloc byte[] + { + 1, 0 + }; + + Span<byte> buffer = stackalloc byte[128]; + int len = HdHomerunManager.WriteNullTerminatedString(buffer, string.Empty); + + Assert.Equal( + Convert.ToHexString(expected), + Convert.ToHexString(buffer.Slice(0, len))); + } + + [Fact] + public void WriteNullTerminatedString_Valid_Success() + { + ReadOnlySpan<byte> expected = stackalloc byte[] + { + 10, (byte)'T', (byte)'h', (byte)'e', (byte)' ', (byte)'q', (byte)'u', (byte)'i', (byte)'c', (byte)'k', 0 + }; + + Span<byte> buffer = stackalloc byte[128]; + int len = HdHomerunManager.WriteNullTerminatedString(buffer, "The quick"); + + Assert.Equal( + Convert.ToHexString(expected), + Convert.ToHexString(buffer.Slice(0, len))); + } + + [Fact] + public void WriteGetMessage_Valid_Success() + { + ReadOnlySpan<byte> expected = stackalloc byte[] + { + 0, 4, + 0, 12, + 3, + 10, (byte)'/', (byte)'t', (byte)'u', (byte)'n', (byte)'e', (byte)'r', (byte)'0', (byte)'/', (byte)'N', 0, + 0xc0, 0xc9, 0x87, 0x33 + }; + + Span<byte> buffer = stackalloc byte[128]; + int len = HdHomerunManager.WriteGetMessage(buffer, 0, "N"); + + Assert.Equal( + Convert.ToHexString(expected), + Convert.ToHexString(buffer.Slice(0, len))); + } + + [Fact] + public void WriteSetMessage_NoLockKey_Success() + { + ReadOnlySpan<byte> expected = stackalloc byte[] + { + 0, 4, + 0, 20, + 3, + 10, (byte)'/', (byte)'t', (byte)'u', (byte)'n', (byte)'e', (byte)'r', (byte)'0', (byte)'/', (byte)'N', 0, + 4, + 6, (byte)'v', (byte)'a', (byte)'l', (byte)'u', (byte)'e', 0, + 0xa9, 0x49, 0xd0, 0x68 + }; + + Span<byte> buffer = stackalloc byte[128]; + int len = HdHomerunManager.WriteSetMessage(buffer, 0, "N", "value", null); + + Assert.Equal( + Convert.ToHexString(expected), + Convert.ToHexString(buffer.Slice(0, len))); + } + + [Fact] + public void WriteSetMessage_LockKey_Success() + { + ReadOnlySpan<byte> expected = stackalloc byte[] + { + 0, 4, + 0, 26, + 3, + 10, (byte)'/', (byte)'t', (byte)'u', (byte)'n', (byte)'e', (byte)'r', (byte)'0', (byte)'/', (byte)'N', 0, + 4, + 6, (byte)'v', (byte)'a', (byte)'l', (byte)'u', (byte)'e', 0, + 21, + 4, 0x00, 0x01, 0x38, 0xd5, + 0x8e, 0xb6, 0x06, 0x82 + }; + + Span<byte> buffer = stackalloc byte[128]; + int len = HdHomerunManager.WriteSetMessage(buffer, 0, "N", "value", 80085); + + Assert.Equal( + Convert.ToHexString(expected), + Convert.ToHexString(buffer.Slice(0, len))); + } + + [Fact] + public void TryGetReturnValueOfGetSet_Valid_Success() + { + ReadOnlySpan<byte> packet = new byte[] + { + 0, 5, + 0, 20, + 3, + 10, (byte)'/', (byte)'t', (byte)'u', (byte)'n', (byte)'e', (byte)'r', (byte)'0', (byte)'/', (byte)'N', 0, + 4, + 6, (byte)'v', (byte)'a', (byte)'l', (byte)'u', (byte)'e', 0, + 0x7d, 0xa3, 0xa3, 0xf3 + }; + + Assert.True(HdHomerunManager.TryGetReturnValueOfGetSet(packet, out var value)); + Assert.Equal("value", Encoding.UTF8.GetString(value)); + } + + [Fact] + public void TryGetReturnValueOfGetSet_InvalidCrc_False() + { + ReadOnlySpan<byte> packet = new byte[] + { + 0, 5, + 0, 20, + 3, + 10, (byte)'/', (byte)'t', (byte)'u', (byte)'n', (byte)'e', (byte)'r', (byte)'0', (byte)'/', (byte)'N', 0, + 4, + 6, (byte)'v', (byte)'a', (byte)'l', (byte)'u', (byte)'e', 0, + 0x7d, 0xa3, 0xa3, 0xf4 + }; + + Assert.False(HdHomerunManager.TryGetReturnValueOfGetSet(packet, out _)); + } + + [Fact] + public void TryGetReturnValueOfGetSet_InvalidPacketType_False() + { + ReadOnlySpan<byte> packet = new byte[] + { + 0, 4, + 0, 20, + 3, + 10, (byte)'/', (byte)'t', (byte)'u', (byte)'n', (byte)'e', (byte)'r', (byte)'0', (byte)'/', (byte)'N', 0, + 4, + 6, (byte)'v', (byte)'a', (byte)'l', (byte)'u', (byte)'e', 0, + 0xa9, 0x49, 0xd0, 0x68 + }; + + Assert.False(HdHomerunManager.TryGetReturnValueOfGetSet(packet, out _)); + } + + [Fact] + public void TryGetReturnValueOfGetSet_InvalidPacket_False() + { + ReadOnlySpan<byte> packet = new byte[] + { + 0, 5, + 0, 20, + 0x7d, 0xa3, 0xa3 + }; + + Assert.False(HdHomerunManager.TryGetReturnValueOfGetSet(packet, out _)); + } + + [Fact] + public void TryGetReturnValueOfGetSet_TooSmallMessageLength_False() + { + ReadOnlySpan<byte> packet = new byte[] + { + 0, 5, + 0, 19, + 3, + 10, (byte)'/', (byte)'t', (byte)'u', (byte)'n', (byte)'e', (byte)'r', (byte)'0', (byte)'/', (byte)'N', 0, + 4, + 6, (byte)'v', (byte)'a', (byte)'l', (byte)'u', (byte)'e', 0, + 0x25, 0x25, 0x44, 0x9a + }; + + Assert.False(HdHomerunManager.TryGetReturnValueOfGetSet(packet, out _)); + } + + [Fact] + public void TryGetReturnValueOfGetSet_TooLargeMessageLength_False() + { + ReadOnlySpan<byte> packet = new byte[] + { + 0, 5, + 0, 21, + 3, + 10, (byte)'/', (byte)'t', (byte)'u', (byte)'n', (byte)'e', (byte)'r', (byte)'0', (byte)'/', (byte)'N', 0, + 4, + 6, (byte)'v', (byte)'a', (byte)'l', (byte)'u', (byte)'e', 0, + 0xe3, 0x20, 0x79, 0x6c + }; + + Assert.False(HdHomerunManager.TryGetReturnValueOfGetSet(packet, out _)); + } + + [Fact] + public void TryGetReturnValueOfGetSet_TooLargeNameLength_False() + { + ReadOnlySpan<byte> packet = new byte[] + { + 0, 5, + 0, 20, + 3, + 20, (byte)'/', (byte)'t', (byte)'u', (byte)'n', (byte)'e', (byte)'r', (byte)'0', (byte)'/', (byte)'N', 0, + 4, + 6, (byte)'v', (byte)'a', (byte)'l', (byte)'u', (byte)'e', 0, + 0xe1, 0x8e, 0x9c, 0x74 + }; + + Assert.False(HdHomerunManager.TryGetReturnValueOfGetSet(packet, out _)); + } + + [Fact] + public void TryGetReturnValueOfGetSet_InvalidGetSetNameTag_False() + { + ReadOnlySpan<byte> packet = new byte[] + { + 0, 5, + 0, 20, + 4, + 10, (byte)'/', (byte)'t', (byte)'u', (byte)'n', (byte)'e', (byte)'r', (byte)'0', (byte)'/', (byte)'N', 0, + 4, + 6, (byte)'v', (byte)'a', (byte)'l', (byte)'u', (byte)'e', 0, + 0xee, 0x05, 0xe7, 0x12 + }; + + Assert.False(HdHomerunManager.TryGetReturnValueOfGetSet(packet, out _)); + } + + [Fact] + public void TryGetReturnValueOfGetSet_InvalidGetSetValueTag_False() + { + ReadOnlySpan<byte> packet = new byte[] + { + 0, 5, + 0, 20, + 3, + 10, (byte)'/', (byte)'t', (byte)'u', (byte)'n', (byte)'e', (byte)'r', (byte)'0', (byte)'/', (byte)'N', 0, + 3, + 6, (byte)'v', (byte)'a', (byte)'l', (byte)'u', (byte)'e', 0, + 0x64, 0xaa, 0x66, 0xf9 + }; + + Assert.False(HdHomerunManager.TryGetReturnValueOfGetSet(packet, out _)); + } + + [Fact] + public void TryGetReturnValueOfGetSet_TooLargeValueLength_False() + { + ReadOnlySpan<byte> packet = new byte[] + { + 0, 5, + 0, 20, + 3, + 10, (byte)'/', (byte)'t', (byte)'u', (byte)'n', (byte)'e', (byte)'r', (byte)'0', (byte)'/', (byte)'N', 0, + 4, + 7, (byte)'v', (byte)'a', (byte)'l', (byte)'u', (byte)'e', 0, + 0xc9, 0xa8, 0xd4, 0x55 + }; + + Assert.False(HdHomerunManager.TryGetReturnValueOfGetSet(packet, out _)); + } + + [Fact] + public void VerifyReturnValueOfGetSet_Valid_True() + { + ReadOnlySpan<byte> packet = new byte[] + { + 0, 5, + 0, 20, + 3, + 10, (byte)'/', (byte)'t', (byte)'u', (byte)'n', (byte)'e', (byte)'r', (byte)'0', (byte)'/', (byte)'N', 0, + 4, + 6, (byte)'v', (byte)'a', (byte)'l', (byte)'u', (byte)'e', 0, + 0x7d, 0xa3, 0xa3, 0xf3 + }; + + Assert.True(HdHomerunManager.VerifyReturnValueOfGetSet(packet, "value")); + } + + [Fact] + public void VerifyReturnValueOfGetSet_WrongValue_False() + { + ReadOnlySpan<byte> packet = new byte[] + { + 0, 5, + 0, 20, + 3, + 10, (byte)'/', (byte)'t', (byte)'u', (byte)'n', (byte)'e', (byte)'r', (byte)'0', (byte)'/', (byte)'N', 0, + 4, + 6, (byte)'v', (byte)'a', (byte)'l', (byte)'u', (byte)'e', 0, + 0x7d, 0xa3, 0xa3, 0xf3 + }; + + Assert.False(HdHomerunManager.VerifyReturnValueOfGetSet(packet, "none")); + } + + [Fact] + public void VerifyReturnValueOfGetSet_InvalidPacket_False() + { + ReadOnlySpan<byte> packet = new byte[] + { + 0, 4, + 0, 20, + 3, + 10, (byte)'/', (byte)'t', (byte)'u', (byte)'n', (byte)'e', (byte)'r', (byte)'0', (byte)'/', (byte)'N', 0, + 4, + 6, (byte)'v', (byte)'a', (byte)'l', (byte)'u', (byte)'e', 0, + 0x7d, 0xa3, 0xa3, 0xf3 + }; + + Assert.False(HdHomerunManager.VerifyReturnValueOfGetSet(packet, "value")); + } + } +} diff --git a/tests/Jellyfin.Server.Implementations.Tests/LiveTv/RecordingHelperTests.cs b/tests/Jellyfin.Server.Implementations.Tests/LiveTv/RecordingHelperTests.cs new file mode 100644 index 0000000000..e8b93b437b --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/LiveTv/RecordingHelperTests.cs @@ -0,0 +1,115 @@ +using System; +using System.Collections.Generic; +using Emby.Server.Implementations.LiveTv.EmbyTV; +using MediaBrowser.Controller.LiveTv; +using Xunit; + +namespace Jellyfin.Server.Implementations.Tests.LiveTv +{ + public static class RecordingHelperTests + { + public static IEnumerable<object[]> GetRecordingName_Success_TestData() + { + yield return new object[] + { + "The Incredibles 2020_04_20_21_06_00", + new TimerInfo + { + Name = "The Incredibles", + StartDate = new DateTime(2020, 4, 20, 21, 6, 0, DateTimeKind.Local), + IsMovie = true + } + }; + + yield return new object[] + { + "The Incredibles (2004)", + new TimerInfo + { + Name = "The Incredibles", + IsMovie = true, + ProductionYear = 2004 + } + }; + + yield return new object[] + { + "The Big Bang Theory 2020_04_20_21_06_00", + new TimerInfo + { + Name = "The Big Bang Theory", + StartDate = new DateTime(2020, 4, 20, 21, 6, 0, DateTimeKind.Local), + IsProgramSeries = true, + } + }; + + yield return new object[] + { + "The Big Bang Theory S12E10", + new TimerInfo + { + Name = "The Big Bang Theory", + IsProgramSeries = true, + SeasonNumber = 12, + EpisodeNumber = 10 + } + }; + + yield return new object[] + { + "The Big Bang Theory S12E10 The VCR Illumination", + new TimerInfo + { + Name = "The Big Bang Theory", + IsProgramSeries = true, + SeasonNumber = 12, + EpisodeNumber = 10, + EpisodeTitle = "The VCR Illumination" + } + }; + + yield return new object[] + { + "The Big Bang Theory 2018-12-06", + new TimerInfo + { + Name = "The Big Bang Theory", + IsProgramSeries = true, + OriginalAirDate = new DateTime(2018, 12, 6) + } + }; + + yield return new object[] + { + "The Big Bang Theory 2018-12-06 - The VCR Illumination", + new TimerInfo + { + Name = "The Big Bang Theory", + IsProgramSeries = true, + OriginalAirDate = new DateTime(2018, 12, 6), + EpisodeTitle = "The VCR Illumination" + } + }; + + yield return new object[] + { + "The Big Bang Theory 2018_12_06_21_06_00 - The VCR Illumination", + new TimerInfo + { + Name = "The Big Bang Theory", + StartDate = new DateTime(2018, 12, 6, 21, 6, 0, DateTimeKind.Local), + IsProgramSeries = true, + OriginalAirDate = new DateTime(2018, 12, 6), + EpisodeTitle = "The VCR Illumination" + } + }; + } + + [Theory] + [MemberData(nameof(GetRecordingName_Success_TestData))] + public static void GetRecordingName_Success(string expected, TimerInfo timerInfo) + { + Assert.Equal(expected, RecordingHelper.GetRecordingName(timerInfo)); + } + } +} diff --git a/tests/Jellyfin.Server.Implementations.Tests/Localization/LocalizationManagerTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Localization/LocalizationManagerTests.cs new file mode 100644 index 0000000000..143020d436 --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/Localization/LocalizationManagerTests.cs @@ -0,0 +1,179 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using Emby.Server.Implementations.Localization; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Model.Configuration; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Xunit; + +namespace Jellyfin.Server.Implementations.Tests.Localization +{ + public class LocalizationManagerTests + { + [Fact] + public void GetCountries_All_Success() + { + var localizationManager = Setup(new ServerConfiguration + { + UICulture = "de-DE" + }); + var countries = localizationManager.GetCountries().ToList(); + + Assert.Equal(139, countries.Count); + + var germany = countries.FirstOrDefault(x => x.Name.Equals("DE", StringComparison.Ordinal)); + Assert.NotNull(germany); + Assert.Equal("Germany", germany!.DisplayName); + Assert.Equal("DEU", germany.ThreeLetterISORegionName); + Assert.Equal("DE", germany.TwoLetterISORegionName); + } + + [Fact] + public async Task GetCultures_All_Success() + { + var localizationManager = Setup(new ServerConfiguration + { + UICulture = "de-DE" + }); + await localizationManager.LoadAll(); + var cultures = localizationManager.GetCultures().ToList(); + + Assert.Equal(189, cultures.Count); + + var germany = cultures.FirstOrDefault(x => x.TwoLetterISOLanguageName.Equals("de", StringComparison.Ordinal)); + Assert.NotNull(germany); + Assert.Equal("ger", germany!.ThreeLetterISOLanguageName); + Assert.Equal("German", germany.DisplayName); + Assert.Equal("German", germany.Name); + Assert.Contains("deu", germany.ThreeLetterISOLanguageNames); + Assert.Contains("ger", germany.ThreeLetterISOLanguageNames); + } + + [Theory] + [InlineData("de")] + [InlineData("ger")] + [InlineData("german")] + public async Task FindLanguageInfo_Valid_Success(string identifier) + { + var localizationManager = Setup(new ServerConfiguration + { + UICulture = "de-DE" + }); + await localizationManager.LoadAll(); + + var germany = localizationManager.FindLanguageInfo(identifier); + Assert.NotNull(germany); + + Assert.Equal("ger", germany!.ThreeLetterISOLanguageName); + Assert.Equal("German", germany.DisplayName); + Assert.Equal("German", germany.Name); + Assert.Contains("deu", germany.ThreeLetterISOLanguageNames); + Assert.Contains("ger", germany.ThreeLetterISOLanguageNames); + } + + [Fact] + public async Task GetParentalRatings_Default_Success() + { + var localizationManager = Setup(new ServerConfiguration + { + UICulture = "de-DE" + }); + await localizationManager.LoadAll(); + var ratings = localizationManager.GetParentalRatings().ToList(); + + Assert.Equal(23, ratings.Count); + + var tvma = ratings.FirstOrDefault(x => x.Name.Equals("TV-MA", StringComparison.Ordinal)); + Assert.NotNull(tvma); + Assert.Equal(9, tvma!.Value); + } + + [Fact] + public async Task GetParentalRatings_ConfiguredCountryCode_Success() + { + var localizationManager = Setup(new ServerConfiguration() + { + MetadataCountryCode = "DE" + }); + await localizationManager.LoadAll(); + var ratings = localizationManager.GetParentalRatings().ToList(); + + Assert.Equal(10, ratings.Count); + + var fsk = ratings.FirstOrDefault(x => x.Name.Equals("FSK-12", StringComparison.Ordinal)); + Assert.NotNull(fsk); + Assert.Equal(7, fsk!.Value); + } + + [Theory] + [InlineData("CA-R", "CA", 10)] + [InlineData("FSK-16", "DE", 8)] + [InlineData("FSK-18", "DE", 9)] + [InlineData("FSK-18", "US", 9)] + [InlineData("TV-MA", "US", 9)] + [InlineData("XXX", "asdf", 100)] + [InlineData("Germany: FSK-18", "DE", 9)] + public async Task GetRatingLevel_GivenValidString_Success(string value, string countryCode, int expectedLevel) + { + var localizationManager = Setup(new ServerConfiguration() + { + MetadataCountryCode = countryCode + }); + await localizationManager.LoadAll(); + var level = localizationManager.GetRatingLevel(value); + Assert.NotNull(level); + Assert.Equal(expectedLevel, level!); + } + + [Fact] + public async Task GetRatingLevel_GivenUnratedString_Success() + { + var localizationManager = Setup(new ServerConfiguration() + { + UICulture = "de-DE" + }); + await localizationManager.LoadAll(); + Assert.Null(localizationManager.GetRatingLevel("n/a")); + } + + [Theory] + [InlineData("Default", "Default")] + [InlineData("HeaderLiveTV", "Live TV")] + public void GetLocalizedString_Valid_Success(string key, string expected) + { + var localizationManager = Setup(new ServerConfiguration() + { + UICulture = "en-US" + }); + + var translated = localizationManager.GetLocalizedString(key); + Assert.NotNull(translated); + Assert.Equal(expected, translated); + } + + [Fact] + public void GetLocalizedString_Invalid_Success() + { + var localizationManager = Setup(new ServerConfiguration() + { + UICulture = "en-US" + }); + + var key = "SuperInvalidTranslationKeyThatWillNeverBeAdded"; + + var translated = localizationManager.GetLocalizedString(key); + Assert.NotNull(translated); + Assert.Equal(key, translated); + } + + private LocalizationManager Setup(ServerConfiguration config) + { + var mockConfiguration = new Mock<IServerConfigurationManager>(); + mockConfiguration.SetupGet(x => x.Configuration).Returns(config); + + return new LocalizationManager(mockConfiguration.Object, new NullLogger<LocalizationManager>()); + } + } +} diff --git a/tests/Jellyfin.Server.Implementations.Tests/Plugins/PluginManagerTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Plugins/PluginManagerTests.cs new file mode 100644 index 0000000000..bc6a447410 --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/Plugins/PluginManagerTests.cs @@ -0,0 +1,45 @@ +using System; +using System.IO; +using Emby.Server.Implementations.Plugins; +using MediaBrowser.Common.Plugins; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace Jellyfin.Server.Implementations.Tests.Plugins +{ + public class PluginManagerTests + { + private static readonly string _testPathRoot = Path.Combine(Path.GetTempPath(), "jellyfin-test-data"); + + [Fact] + public void SaveManifest_RoundTrip_Success() + { + var pluginManager = new PluginManager(new NullLogger<PluginManager>(), null!, null!, null!, new Version(1, 0)); + var manifest = new PluginManifest() + { + Version = "1.0" + }; + + var tempPath = Path.Combine(_testPathRoot, "manifest-" + Path.GetRandomFileName()); + Directory.CreateDirectory(tempPath); + + Assert.True(pluginManager.SaveManifest(manifest, tempPath)); + + var res = pluginManager.LoadManifest(tempPath); + + Assert.Equal(manifest.Category, res.Manifest.Category); + Assert.Equal(manifest.Changelog, res.Manifest.Changelog); + Assert.Equal(manifest.Description, res.Manifest.Description); + Assert.Equal(manifest.Id, res.Manifest.Id); + Assert.Equal(manifest.Name, res.Manifest.Name); + Assert.Equal(manifest.Overview, res.Manifest.Overview); + Assert.Equal(manifest.Owner, res.Manifest.Owner); + Assert.Equal(manifest.TargetAbi, res.Manifest.TargetAbi); + Assert.Equal(manifest.Timestamp, res.Manifest.Timestamp); + Assert.Equal(manifest.Version, res.Manifest.Version); + Assert.Equal(manifest.Status, res.Manifest.Status); + Assert.Equal(manifest.AutoUpdate, res.Manifest.AutoUpdate); + Assert.Equal(manifest.ImagePath, res.Manifest.ImagePath); + } + } +} diff --git a/tests/Jellyfin.Server.Implementations.Tests/QuickConnect/QuickConnectManagerTests.cs b/tests/Jellyfin.Server.Implementations.Tests/QuickConnect/QuickConnectManagerTests.cs new file mode 100644 index 0000000000..365acfa341 --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/QuickConnect/QuickConnectManagerTests.cs @@ -0,0 +1,73 @@ +using System; +using AutoFixture; +using AutoFixture.AutoMoq; +using Emby.Server.Implementations.QuickConnect; +using MediaBrowser.Controller.Authentication; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Model.Configuration; +using Moq; +using Xunit; + +namespace Jellyfin.Server.Implementations.Tests.LiveTv +{ + public class QuickConnectManagerTests + { + private readonly Fixture _fixture; + private readonly ServerConfiguration _config; + private readonly QuickConnectManager _quickConnectManager; + + public QuickConnectManagerTests() + { + _config = new ServerConfiguration(); + var configManager = new Mock<IServerConfigurationManager>(); + configManager.Setup(x => x.Configuration).Returns(_config); + + _fixture = new Fixture(); + _fixture.Customize(new AutoMoqCustomization + { + ConfigureMembers = true + }).Inject(configManager.Object); + _quickConnectManager = _fixture.Create<QuickConnectManager>(); + } + + [Fact] + public void IsEnabled_QuickConnectUnavailable_False() + => Assert.False(_quickConnectManager.IsEnabled); + + [Fact] + public void TryConnect_QuickConnectUnavailable_ThrowsAuthenticationException() + => Assert.Throws<AuthenticationException>(_quickConnectManager.TryConnect); + + [Fact] + public void CheckRequestStatus_QuickConnectUnavailable_ThrowsAuthenticationException() + => Assert.Throws<AuthenticationException>(() => _quickConnectManager.CheckRequestStatus(string.Empty)); + + [Fact] + public void AuthorizeRequest_QuickConnectUnavailable_ThrowsAuthenticationException() + => Assert.Throws<AuthenticationException>(() => _quickConnectManager.AuthorizeRequest(Guid.Empty, string.Empty)); + + [Fact] + public void IsEnabled_QuickConnectAvailable_True() + { + _config.QuickConnectAvailable = true; + Assert.True(_quickConnectManager.IsEnabled); + } + + [Fact] + public void CheckRequestStatus_QuickConnectAvailable_Success() + { + _config.QuickConnectAvailable = true; + var res1 = _quickConnectManager.TryConnect(); + var res2 = _quickConnectManager.CheckRequestStatus(res1.Secret); + Assert.Equal(res1, res2); + } + + [Fact] + public void AuthorizeRequest_QuickConnectAvailable_Success() + { + _config.QuickConnectAvailable = true; + var res = _quickConnectManager.TryConnect(); + Assert.True(_quickConnectManager.AuthorizeRequest(Guid.Empty, res.Code)); + } + } +} diff --git a/tests/Jellyfin.Server.Implementations.Tests/Sorting/AiredEpisodeOrderComparerTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Sorting/AiredEpisodeOrderComparerTests.cs new file mode 100644 index 0000000000..d9b206f663 --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/Sorting/AiredEpisodeOrderComparerTests.cs @@ -0,0 +1,180 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using Emby.Server.Implementations.Sorting; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.Movies; +using MediaBrowser.Controller.Entities.TV; +using Xunit; + +namespace Jellyfin.Server.Implementations.Tests.Sorting +{ + public class AiredEpisodeOrderComparerTests + { + [Theory] + [ClassData(typeof(EpisodeBadData))] + public void Compare_GivenNull_ThrowsArgumentNullException(BaseItem x, BaseItem y) + { + var cmp = new AiredEpisodeOrderComparer(); + Assert.Throws<ArgumentNullException>(() => cmp.Compare(x, y)); + } + + [Theory] + [ClassData(typeof(EpisodeTestData))] + public void AiredEpisodeOrderCompareTest(BaseItem x, BaseItem y, int expected) + { + var cmp = new AiredEpisodeOrderComparer(); + + Assert.Equal(expected, cmp.Compare(x, y)); + Assert.Equal(-expected, cmp.Compare(y, x)); + } + + private class EpisodeBadData : IEnumerable<object?[]> + { + public IEnumerator<object?[]> GetEnumerator() + { + yield return new object?[] { null, new Episode() }; + yield return new object?[] { new Episode(), null }; + } + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + } + + private class EpisodeTestData : IEnumerable<object?[]> + { + public IEnumerator<object?[]> GetEnumerator() + { + yield return new object?[] + { + new Movie(), + new Movie(), + 0 + }; + yield return new object?[] + { + new Movie(), + new Episode(), + 1 + }; + // Good cases + yield return new object?[] + { + new Episode(), + new Episode(), + 0 + }; + yield return new object?[] + { + new Episode { ParentIndexNumber = 1, IndexNumber = 1 }, + new Episode { ParentIndexNumber = 1, IndexNumber = 1 }, + 0 + }; + yield return new object?[] + { + new Episode { ParentIndexNumber = 1, IndexNumber = 2 }, + new Episode { ParentIndexNumber = 1, IndexNumber = 1 }, + 1 + }; + yield return new object?[] + { + new Episode { ParentIndexNumber = 2, IndexNumber = 1 }, + new Episode { ParentIndexNumber = 1, IndexNumber = 1 }, + 1 + }; + // Good Specials + yield return new object?[] + { + new Episode { ParentIndexNumber = 0, IndexNumber = 1 }, + new Episode { ParentIndexNumber = 0, IndexNumber = 1 }, + 0 + }; + yield return new object?[] + { + new Episode { ParentIndexNumber = 0, IndexNumber = 2 }, + new Episode { ParentIndexNumber = 0, IndexNumber = 1 }, + 1 + }; + + // Specials to Episodes + yield return new object?[] + { + new Episode { ParentIndexNumber = 1, IndexNumber = 1 }, + new Episode { ParentIndexNumber = 0, IndexNumber = 1 }, + 1 + }; + yield return new object?[] + { + new Episode { ParentIndexNumber = 1, IndexNumber = 1 }, + new Episode { ParentIndexNumber = 0, IndexNumber = 2 }, + 1 + }; + yield return new object?[] + { + new Episode { ParentIndexNumber = 1, IndexNumber = 2 }, + new Episode { ParentIndexNumber = 0, IndexNumber = 1 }, + 1 + }; + + yield return new object?[] + { + new Episode { ParentIndexNumber = 1, IndexNumber = 2 }, + new Episode { ParentIndexNumber = 0, IndexNumber = 1 }, + 1 + }; + yield return new object?[] + { + new Episode { ParentIndexNumber = 1, IndexNumber = 1 }, + new Episode { ParentIndexNumber = 0, IndexNumber = 2 }, + 1 + }; + + yield return new object?[] + { + new Episode { ParentIndexNumber = 0, IndexNumber = 1, AirsAfterSeasonNumber = 1 }, + new Episode { ParentIndexNumber = 1, IndexNumber = 1 }, + 1 + }; + yield return new object?[] + { + new Episode { ParentIndexNumber = 3, IndexNumber = 1 }, + new Episode { ParentIndexNumber = 0, IndexNumber = 1, AirsAfterSeasonNumber = 1 }, + 1 + }; + + yield return new object?[] + { + new Episode { ParentIndexNumber = 3, IndexNumber = 1 }, + new Episode { ParentIndexNumber = 0, IndexNumber = 1, AirsAfterSeasonNumber = 1, AirsBeforeEpisodeNumber = 2 }, + 1 + }; + + yield return new object?[] + { + new Episode { ParentIndexNumber = 1, IndexNumber = 1 }, + new Episode { ParentIndexNumber = 0, IndexNumber = 1, AirsBeforeSeasonNumber = 1 }, + 1 + }; + yield return new object?[] + { + new Episode { ParentIndexNumber = 1, IndexNumber = 2 }, + new Episode { ParentIndexNumber = 0, IndexNumber = 1, AirsBeforeSeasonNumber = 1, AirsBeforeEpisodeNumber = 2 }, + 1 + }; + yield return new object?[] + { + new Episode { ParentIndexNumber = 1 }, + new Episode { ParentIndexNumber = 0, IndexNumber = 1, AirsBeforeSeasonNumber = 1, AirsBeforeEpisodeNumber = 2 }, + 0 + }; + yield return new object?[] + { + new Episode { ParentIndexNumber = 1, IndexNumber = 3 }, + new Episode { ParentIndexNumber = 0, IndexNumber = 1, AirsBeforeSeasonNumber = 1, AirsBeforeEpisodeNumber = 2 }, + 1 + }; + } + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + } + } +} diff --git a/tests/Jellyfin.Server.Implementations.Tests/Test Data/HttpServer/ForceKeepAlive.json b/tests/Jellyfin.Server.Implementations.Tests/Test Data/HttpServer/ForceKeepAlive.json new file mode 100644 index 0000000000..0472a3cd0c --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/Test Data/HttpServer/ForceKeepAlive.json @@ -0,0 +1 @@ +{"MessageType":"ForceKeepAlive","MessageId":"00000000-0000-0000-0000-000000000000","ServerId":null,"Data":60} diff --git a/tests/Jellyfin.Server.Implementations.Tests/Test Data/HttpServer/Partial.json b/tests/Jellyfin.Server.Implementations.Tests/Test Data/HttpServer/Partial.json new file mode 100644 index 0000000000..72f810725a --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/Test Data/HttpServer/Partial.json @@ -0,0 +1 @@ +{"MessageType":"KeepAlive","MessageId":"d29ef449-6965-4000 diff --git a/tests/Jellyfin.Server.Implementations.Tests/Test Data/HttpServer/ValidPartial.json b/tests/Jellyfin.Server.Implementations.Tests/Test Data/HttpServer/ValidPartial.json new file mode 100644 index 0000000000..62d9099c8b --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/Test Data/HttpServer/ValidPartial.json @@ -0,0 +1 @@ +{"MessageType":"ForceKeepAlive","MessageId":"00000000-0000-0000-0000-000000000000","ServerId":null,"Data":60}{"MessageType":"KeepAlive","MessageId":"d29ef449-6965-4000 diff --git a/tests/Jellyfin.Server.Implementations.Tests/Test Data/LiveTv/10.10.10.100/discover.json b/tests/Jellyfin.Server.Implementations.Tests/Test Data/LiveTv/10.10.10.100/discover.json new file mode 100644 index 0000000000..a4ad4ed444 --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/Test Data/LiveTv/10.10.10.100/discover.json @@ -0,0 +1 @@ +{"FriendlyName":"HDHomeRun DUAL","ModelNumber":"HDHR3-US","Legacy":1,"FirmwareName":"hdhomerun3_atsc","FirmwareVersion":"20200225","DeviceID":"10xxxxx5","TunerCount":2,"BaseURL":"http://10.10.10.100:80"} diff --git a/tests/Jellyfin.Server.Implementations.Tests/Test Data/LiveTv/10.10.10.100/lineup.json b/tests/Jellyfin.Server.Implementations.Tests/Test Data/LiveTv/10.10.10.100/lineup.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/Test Data/LiveTv/10.10.10.100/lineup.json @@ -0,0 +1 @@ +{} diff --git a/tests/Jellyfin.Server.Implementations.Tests/Test Data/LiveTv/192.168.1.182/discover.json b/tests/Jellyfin.Server.Implementations.Tests/Test Data/LiveTv/192.168.1.182/discover.json new file mode 100644 index 0000000000..851f17bb26 --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/Test Data/LiveTv/192.168.1.182/discover.json @@ -0,0 +1 @@ +{"FriendlyName":"HDHomeRun PRIME","ModelNumber":"HDHR3-CC","FirmwareName":"hdhomerun3_cablecard","FirmwareVersion":"20160630atest2","DeviceID":"FFFFFFFF","DeviceAuth":"FFFFFFFF","TunerCount":3,"ConditionalAccess":1,"BaseURL":"http://192.168.1.182:80","LineupURL":"http://192.168.1.182:80/lineup.json"} diff --git a/tests/Jellyfin.Server.Implementations.Tests/Test Data/LiveTv/192.168.1.182/lineup.json b/tests/Jellyfin.Server.Implementations.Tests/Test Data/LiveTv/192.168.1.182/lineup.json new file mode 100644 index 0000000000..4cb5ebc8ec --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/Test Data/LiveTv/192.168.1.182/lineup.json @@ -0,0 +1 @@ +[ { "GuideNumber": "4.1", "GuideName": "WCMH-DT", "HD": 1, "Favorite": 1, "URL": "http://192.168.1.111:5004/auto/v4.1" }, { "GuideNumber": "4.2", "GuideName": "MeTV", "URL": "http://192.168.1.111:5004/auto/v4.2" }, { "GuideNumber": "4.3", "GuideName": "ION TV", "URL": "http://192.168.1.111:5004/auto/v4.3" }, { "GuideNumber": "6.1", "GuideName": "WSYX DT", "HD": 1, "URL": "http://192.168.1.111:5004/auto/v6.1" }, { "GuideNumber": "6.2", "GuideName": "MYTV", "URL": "http://192.168.1.111:5004/auto/v6.2" }, { "GuideNumber": "6.3", "GuideName": "ANTENNA", "URL": "http://192.168.1.111:5004/auto/v6.3" } ] diff --git a/tests/Jellyfin.Server.Implementations.Tests/Test Data/Updates/manifest-stable.json b/tests/Jellyfin.Server.Implementations.Tests/Test Data/Updates/manifest-stable.json new file mode 100644 index 0000000000..b766e668e3 --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/Test Data/Updates/manifest-stable.json @@ -0,0 +1,684 @@ +[ + { + "guid": "a4df60c5-6ab4-412a-8f79-2cab93fb2bc5", + "name": "Anime", + "description": "Manage your anime in Jellyfin. This plugin supports several different metadata providers and options for organizing your collection.\n", + "overview": "Manage your anime from Jellyfin", + "owner": "jellyfin", + "category": "Metadata", + "versions": [ + { + "version": "10.0.0.0", + "changelog": "changelog\n", + "targetAbi": "10.6.0.0", + "sourceUrl": "https://repo.jellyfin.org/releases/plugin/anime/anime_10.0.0.0.zip", + "checksum": "93e969adeba1050423fc8817ed3c36f8", + "timestamp": "2020-08-17T01:41:13Z" + }, + { + "version": "9.0.0.0", + "changelog": "changelog\n", + "targetAbi": "10.6.0.0", + "sourceUrl": "https://repo.jellyfin.org/releases/plugin/anime/anime_9.0.0.0.zip", + "checksum": "9b1cebff835813e15f414f44b40c41c8", + "timestamp": "2020-07-20T01:30:16Z" + } + ] + }, + { + "guid": "70b7b43b-471b-4159-b4be-56750c795499", + "name": "Auto Organize", + "description": "Automatically organize your media", + "overview": "Automatically organize your media", + "owner": "jellyfin", + "category": "General", + "versions": [ + { + "version": "9.0.0.0", + "changelog": "changelog\n", + "targetAbi": "10.7.0.0", + "sourceUrl": "https://repo.jellyfin.org/releases/plugin/auto-organize/auto-organize_9.0.0.0.zip", + "checksum": "ff29ac3cbe05d208b6af94cd6d9dea39", + "timestamp": "2020-12-05T22:31:12Z" + }, + { + "version": "8.0.0.0", + "changelog": "changelog\n", + "targetAbi": "10.6.0.0", + "sourceUrl": "https://repo.jellyfin.org/releases/plugin/auto-organize/auto-organize_8.0.0.0.zip", + "checksum": "460bbb45e556464a8476b18e41c097f5", + "timestamp": "2020-07-20T01:30:25Z" + } + ] + }, + { + "guid": "9c4e63f1-031b-4f25-988b-4f7d78a8b53e", + "name": "Bookshelf", + "description": "Supports several different metadata providers and options for organizing your collection.\n", + "overview": "Manage your books", + "owner": "jellyfin", + "category": "Metadata", + "versions": [ + { + "version": "5.0.0.0", + "changelog": "changelog\n", + "targetAbi": "10.7.0.0", + "sourceUrl": "https://repo.jellyfin.org/releases/plugin/bookshelf/bookshelf_5.0.0.0.zip", + "checksum": "2063fb8ab317b8d77b200fde41eb5e1e", + "timestamp": "2020-12-05T22:03:13Z" + }, + { + "version": "4.0.0.0", + "changelog": "changelog\n", + "targetAbi": "10.6.0.0", + "sourceUrl": "https://repo.jellyfin.org/releases/plugin/bookshelf/bookshelf_4.0.0.0.zip", + "checksum": "fc9f76c0815d766491e5b0f30ede55ed", + "timestamp": "2020-07-20T01:30:33Z" + } + ] + }, + { + "guid": "cfa0f7f4-4155-4d71-849b-d6598dc4c5bb", + "name": "Email", + "description": "Send SMTP email notifications", + "overview": "Send SMTP email notifications", + "owner": "jellyfin", + "category": "Notifications", + "versions": [ + { + "version": "9.0.0.0", + "changelog": "changelog\n", + "targetAbi": "10.7.0.0", + "sourceUrl": "https://repo.jellyfin.org/releases/plugin/email/email_9.0.0.0.zip", + "checksum": "cfe7afc00f3fbd6d6ab8244d7ff968ce", + "timestamp": "2020-12-05T22:20:32Z" + }, + { + "version": "7.0.0.0", + "changelog": "changelog\n", + "targetAbi": "10.6.0.0", + "sourceUrl": "https://repo.jellyfin.org/releases/plugin/email/email_7.0.0.0.zip", + "checksum": "680ca511d8ad84923cb04f024fd8eb19", + "timestamp": "2020-07-20T01:30:40Z" + } + ] + }, + { + "guid": "170a157f-ac6c-437a-abdd-ca9c25cebd39", + "name": "Fanart", + "description": "Scrape poster images for movies, shows, and artists in your library.", + "overview": "Scrape poster images from Fanart", + "owner": "jellyfin", + "category": "Metadata", + "versions": [ + { + "version": "6.0.0.0", + "changelog": "changelog\n", + "targetAbi": "10.7.0.0", + "sourceUrl": "https://repo.jellyfin.org/releases/plugin/fanart/fanart_6.0.0.0.zip", + "checksum": "ee4360bfcc8722d5a3a54cfe7eef640f", + "timestamp": "2020-12-05T22:25:43Z" + }, + { + "version": "5.0.0.0", + "changelog": "changelog\n", + "targetAbi": "10.6.0.0", + "sourceUrl": "https://repo.jellyfin.org/releases/plugin/fanart/fanart_5.0.0.0.zip", + "checksum": "f842f7d65d23f377761c907d40b89647", + "timestamp": "2020-07-20T01:30:48Z" + } + ] + }, + { + "guid": "e29621a5-fa9e-4330-982e-ef6e54c0cad2", + "name": "Gotify Notification", + "description": "You must have a Gotify server to use this plugin!\n", + "overview": "Sends notifications to your Gotify server", + "owner": "crobibero", + "category": "Notifications", + "versions": [ + { + "version": "7.0.0.0", + "changelog": "changelog\n", + "targetAbi": "10.6.0.0", + "sourceUrl": "https://repo.jellyfin.org/releases/plugin/gotify-notification/gotify-notification_7.0.0.0.zip", + "checksum": "7c5ff9e8792c8cdee7e8a2aaeb6cc093", + "timestamp": "2020-07-20T01:30:56Z" + } + ] + }, + { + "guid": "a59b5c4b-05a8-488f-bfa8-7a63fffc7639", + "name": "IPTV", + "description": "Enable IPTV support in Jellyfin", + "overview": "Enable IPTV support in Jellyfin", + "owner": "jellyfin", + "category": "Channel", + "versions": [ + { + "version": "6.0.0.0", + "changelog": "changelog\n", + "targetAbi": "10.6.0.0", + "sourceUrl": "https://repo.jellyfin.org/releases/plugin/iptv/iptv_6.0.0.0.zip", + "checksum": "9cf103bf67a4eda7c3a42d9b235f6447", + "timestamp": "2020-07-20T01:31:05Z" + } + ] + }, + { + "guid": "4682DD4C-A675-4F1B-8E7C-79ADF137A8F8", + "name": "ISO Mounter", + "description": "Mount your ISO files for Jellyfin.\n", + "overview": "Mount your ISO files for Jellyfin", + "owner": "jellyfin", + "category": "Metadata", + "versions": [ + { + "version": "1.0.0.0", + "changelog": "changelog\n", + "targetAbi": "10.6.0.0", + "sourceUrl": "https://repo.jellyfin.org/releases/plugin/iso-mounter/iso-mounter_1.0.0.0.zip", + "checksum": "847e5bc7ac34c1bf4dc5b28173170fae", + "timestamp": "2020-07-20T01:31:13Z" + } + ] + }, + { + "guid": "771e19d6-5385-4caf-b35c-28a0e865cf63", + "name": "Kodi Sync Queue", + "description": "This plugin will track all media changes while Kodi clients are offline to decrease sync times.", + "overview": "Sync all media changes with Kodi clients", + "owner": "jellyfin", + "category": "General", + "versions": [ + { + "version": "6.0.0.0", + "changelog": "changelog\n", + "targetAbi": "10.7.0.0", + "sourceUrl": "https://repo.jellyfin.org/releases/plugin/kodi-sync-queue/kodi-sync-queue_6.0.0.0.zip", + "checksum": "787c856c0d2ad2224cdd8b3094cf0329", + "timestamp": "2020-12-05T22:10:37Z" + }, + { + "version": "5.0.0.0", + "changelog": "changelog\n", + "targetAbi": "10.6.0.0", + "sourceUrl": "https://repo.jellyfin.org/releases/plugin/kodi-sync-queue/kodi-sync-queue_5.0.0.0.zip", + "checksum": "08285397aecd93ea64a4f15d38b1bd7b", + "timestamp": "2020-07-20T01:31:22Z" + } + ] + }, + { + "guid": "958aad66-3784-4d2a-b89a-a7b6fab6e25c", + "name": "LDAP Authentication", + "description": "Authenticate your Jellyfin users against an LDAP database, and optionally create users who do not yet exist automatically.\nAllows the administrator to customize most aspects of the LDAP authentication process, including customizable search attributes, username attribute, and a search filter for administrative users (set on user creation). The user, via the \"Manual Login\" process, can enter any valid attribute value, which will be mapped back to the specified username attribute automatically as well.\n", + "overview": "Authenticate users against an LDAP database", + "owner": "jellyfin", + "category": "Authentication", + "versions": [ + { + "version": "10.0.0.0", + "changelog": "Update for 10.7 support\n", + "targetAbi": "10.7.0.0", + "sourceUrl": "https://repo.jellyfin.org/releases/plugin/ldap-authentication/ldap-authentication_10.0.0.0.zip", + "checksum": "62e7e1cd3ffae0944c14750a3c90df4f", + "timestamp": "2020-12-05T19:48:10Z" + }, + { + "version": "9.0.0.0", + "changelog": "changelog\n", + "targetAbi": "10.6.0.0", + "sourceUrl": "https://repo.jellyfin.org/releases/plugin/ldap-authentication/ldap-authentication_9.0.0.0.zip", + "checksum": "7f2f83587a65a43ebf168e4058421463", + "timestamp": "2020-07-22T15:42:57Z" + }, + { + "version": "8.0.0.0", + "changelog": "changelog\n", + "targetAbi": "10.6.0.0", + "sourceUrl": "https://repo.jellyfin.org/releases/plugin/ldap-authentication/ldap-authentication_8.0.0.0.zip", + "checksum": "8af8cee62717d63577f8b1e710839415", + "timestamp": "2020-07-20T01:31:30Z" + } + ] + }, + { + "guid": "9574ac10-bf23-49bc-949f-924f23cfa48f", + "name": "NextPVR", + "description": "Provides access to live TV, program guide, and recordings from NextPVR.\n", + "overview": "Live TV plugin for NextPVR", + "owner": "jellyfin", + "category": "LiveTV", + "versions": [ + { + "version": "5.0.0.0", + "changelog": "Updated to use NextPVR API v5, no longer compatable with API v4.\n", + "targetAbi": "10.7.0.0", + "sourceUrl": "https://repo.jellyfin.org/releases/plugin/nextpvr/nextpvr_5.0.0.0.zip", + "checksum": "d70f694d14bf9462ba2b2ebe110068d3", + "timestamp": "2020-12-05T22:24:03Z" + }, + { + "version": "4.0.0.0", + "changelog": "changelog\n", + "targetAbi": "10.6.0.0", + "sourceUrl": "https://repo.jellyfin.org/releases/plugin/nextpvr/nextpvr_4.0.0.0.zip", + "checksum": "b15949d895ac5a8c89496581db350478", + "timestamp": "2020-07-20T01:31:38Z" + } + ] + }, + { + "guid": "4b9ed42f-5185-48b5-9803-6ff2989014c4", + "name": "Open Subtitles", + "description": "Download subtitles from the internet to use with your media files.", + "overview": "Download subtitles for your media", + "owner": "jellyfin", + "category": "Metadata", + "versions": [ + { + "version": "10.0.0.0", + "changelog": "changelog\n", + "targetAbi": "10.7.0.0", + "sourceUrl": "https://repo.jellyfin.org/releases/plugin/open-subtitles/open-subtitles_10.0.0.0.zip", + "checksum": "ed99d03ec463bf15fca1256a113f57b4", + "timestamp": "2020-12-05T21:56:19Z" + }, + { + "version": "9.0.0.0", + "changelog": "changelog\n", + "targetAbi": "10.6.0.0", + "sourceUrl": "https://repo.jellyfin.org/releases/plugin/open-subtitles/open-subtitles_9.0.0.0.zip", + "checksum": "16789b26497cea0509daf6b18c579340", + "timestamp": "2020-07-20T01:32:00Z" + } + ] + }, + { + "guid": "5c534381-91a3-43cb-907a-35aa02eb9d2c", + "name": "Playback Reporting", + "description": "Collect and show user play statistics", + "overview": "Collect and show user play statistics", + "owner": "jellyfin", + "category": "General", + "versions": [ + { + "version": "9.0.0.0", + "changelog": "Add authentication to plugin endpoints\n", + "targetAbi": "10.7.0.0", + "sourceUrl": "https://repo.jellyfin.org/releases/plugin/playback-reporting/playback-reporting_9.0.0.0.zip", + "checksum": "ca323b3dcb2cb86cc2e72a7a0f1eee22", + "timestamp": "2020-12-05T22:15:48Z" + }, + { + "version": "8.0.0.0", + "changelog": "Add authentication to plugin endpoints\n", + "targetAbi": "10.6.0.0", + "sourceUrl": "https://repo.jellyfin.org/releases/plugin/playback-reporting/playback-reporting_8.0.0.0.zip", + "checksum": "58644c505586542ef0b8b65e2f704bd1", + "timestamp": "2020-11-18T03:01:51Z" + }, + { + "version": "7.0.0.0", + "changelog": "changelog\n", + "targetAbi": "10.6.0.0", + "sourceUrl": "https://repo.jellyfin.org/releases/plugin/playback-reporting/playback-reporting_7.0.0.0.zip", + "checksum": "6a361ef33bca97f9155856d02ff47380", + "timestamp": "2020-07-20T01:32:09Z" + } + ] + }, + { + "guid": "de228f12-e43e-4bd9-9fc0-2830819c3b92", + "name": "Pushbullet", + "description": "Get notifications via Pushbullet.\n", + "overview": "Pushbullet notification plugin", + "owner": "jellyfin", + "category": "Notifications", + "versions": [ + { + "version": "6.0.0.0", + "changelog": "changelog\n", + "targetAbi": "10.7.0.0", + "sourceUrl": "https://repo.jellyfin.org/releases/plugin/pushbullet/pushbullet_6.0.0.0.zip", + "checksum": "248cf3d56644f1d909e75aaddbdfb3a6", + "timestamp": "2020-12-06T02:47:53Z" + }, + { + "version": "5.0.0.0", + "changelog": "changelog\n", + "targetAbi": "10.6.0.0", + "sourceUrl": "https://repo.jellyfin.org/releases/plugin/pushbullet/pushbullet_5.0.0.0.zip", + "checksum": "dabbdd86328b2922a69dfa0c9e1c8343", + "timestamp": "2020-07-20T01:32:17Z" + } + ] + }, + { + "guid": "F240D6BE-5743-441B-87F1-A70ECAC42642", + "name": "Pushover", + "description": "Send messages to a wide range of devices through Pushover.", + "overview": "Send notifications via Pushover", + "owner": "crobibero", + "category": "Notifications", + "versions": [ + { + "version": "4.0.0.0", + "changelog": "changelog\n", + "targetAbi": "10.6.0.0", + "sourceUrl": "https://repo.jellyfin.org/releases/plugin/pushover/pushover_4.0.0.0.zip", + "checksum": "56a0da16c7e48cc184987737b7e155dd", + "timestamp": "2020-07-20T01:32:25Z" + } + ] + }, + { + "guid": "d4312cd9-5c90-4f38-82e8-51da566790e8", + "name": "Reports", + "description": "Generate reports of your media library", + "overview": "Generate reports of your media library", + "owner": "jellyfin", + "category": "General", + "versions": [ + { + "version": "11.0.0.0", + "changelog": "changelog\n", + "targetAbi": "10.7.0.0", + "sourceUrl": "https://repo.jellyfin.org/releases/plugin/reports/reports_11.0.0.0.zip", + "checksum": "d71bc6a4c008e58ee70ad44c83bfd310", + "timestamp": "2020-12-05T22:00:46Z" + }, + { + "version": "10.0.0.0", + "changelog": "changelog\n", + "targetAbi": "10.6.0.0", + "sourceUrl": "https://repo.jellyfin.org/releases/plugin/reports/reports_10.0.0.0.zip", + "checksum": "3917e75839337475b42daf2ba0b5bd7b", + "timestamp": "2020-10-19T19:30:41Z" + }, + { + "version": "9.0.0.0", + "changelog": "changelog\n", + "targetAbi": "10.6.0.0", + "sourceUrl": "https://repo.jellyfin.org/releases/plugin/reports/reports_9.0.0.0.zip", + "checksum": "5b5ad8d885616a21e8d1e8eecf5ea979", + "timestamp": "2020-10-16T23:52:37Z" + } + ] + }, + { + "guid": "1fc322a1-af2e-49a5-b2eb-a89b4240f700", + "name": "ServerWMC", + "description": "Provides access to Live TV, Program Guide and Recordings from your Windows MediaCenter Server running ServerWMC. Requires ServerWMC to be installed and running on your Windows MediaCenter machine.\n", + "overview": "Jellyfin Live TV plugin for Windows MediaCenter with ServerWMC", + "owner": "jellyfin", + "category": "LiveTV", + "versions": [ + { + "version": "6.0.0.0", + "changelog": "changelog\n", + "targetAbi": "10.7.0.0", + "sourceUrl": "https://repo.jellyfin.org/releases/plugin/serverwmc/serverwmc_6.0.0.0.zip", + "checksum": "3120af0cea2c1cb8b7cf578d9b4b862c", + "timestamp": "2020-12-05T22:28:15Z" + }, + { + "version": "5.0.0.0", + "changelog": "changelog\n", + "targetAbi": "10.6.0.0", + "sourceUrl": "https://repo.jellyfin.org/releases/plugin/serverwmc/serverwmc_5.0.0.0.zip", + "checksum": "dc44b039aa1b66eaf40a44fbf02d37e2", + "timestamp": "2020-07-20T01:32:42Z" + } + ] + }, + { + "guid": "94fb77c3-55ad-4c50-bf4e-4e5497467b79", + "name": "Slack Notifications", + "description": "Get notifications via Slack.\n", + "overview": "Get notifications via Slack", + "owner": "jellyfin", + "category": "Notifications", + "versions": [ + { + "version": "7.0.0.0", + "changelog": "changelog\n", + "targetAbi": "10.7.0.0", + "sourceUrl": "https://repo.jellyfin.org/releases/plugin/slack-notifications/slack-notifications_7.0.0.0.zip", + "checksum": "1d5330a77ce7b2a9ac8e5d58088a012c", + "timestamp": "2020-12-05T22:40:02Z" + }, + { + "version": "6.0.0.0", + "changelog": "changelog\n", + "targetAbi": "10.6.0.0", + "sourceUrl": "https://repo.jellyfin.org/releases/plugin/slack-notifications/slack-notifications_6.0.0.0.zip", + "checksum": "ede4cbe064542d1ecccc5823921bee4b", + "timestamp": "2020-07-20T01:32:50Z" + } + ] + }, + { + "guid": "bc4aad2e-d3d0-4725-a5e2-fd07949e5b42", + "name": "TMDb Box Sets", + "description": "Automatically create movie box sets based on TMDb collections", + "overview": "Automatically create movie box sets based on TMDb collections", + "owner": "jellyfin", + "category": "Metadata", + "versions": [ + { + "version": "7.0.0.0", + "changelog": "changelog\n", + "targetAbi": "10.7.0.0", + "sourceUrl": "https://repo.jellyfin.org/releases/plugin/tmdb-box-sets/tmdb-box-sets_7.0.0.0.zip", + "checksum": "1551792e6af4d36f2cead01153c73cf0", + "timestamp": "2020-12-05T22:07:21Z" + }, + { + "version": "6.0.0.0", + "changelog": "changelog\n", + "targetAbi": "10.6.0.0", + "sourceUrl": "https://repo.jellyfin.org/releases/plugin/tmdb-box-sets/tmdb-box-sets_6.0.0.0.zip", + "checksum": "b92b68a922c5fcbb8f4d47b8601b01b6", + "timestamp": "2020-07-20T01:32:58Z" + } + ] + }, + { + "guid": "4fe3201e-d6ae-4f2e-8917-e12bda571281", + "name": "Trakt", + "description": "Record your watched media with Trakt.\n", + "overview": "Record your watched media with Trakt", + "owner": "jellyfin", + "category": "General", + "versions": [ + { + "version": "11.0.0.0", + "changelog": "changelog\n", + "targetAbi": "10.7.0.0", + "sourceUrl": "https://repo.jellyfin.org/releases/plugin/trakt/trakt_11.0.0.0.zip", + "checksum": "2257ccde1e39114644a27e0966a0bf2d", + "timestamp": "2020-12-05T19:56:12Z" + }, + { + "version": "10.0.0.0", + "changelog": "changelog\n", + "targetAbi": "10.6.0.0", + "sourceUrl": "https://repo.jellyfin.org/releases/plugin/trakt/trakt_10.0.0.0.zip", + "checksum": "ab67e6b59ea2e7860a6a3ff7b8452759", + "timestamp": "2020-07-20T01:33:06Z" + } + ] + }, + { + "guid": "3fd018e5-5e78-4e58-b280-a0c068febee0", + "name": "TVHeadend", + "description": "Manage TVHeadend from Jellyfin", + "overview": "Manage TVHeadend from Jellyfin", + "owner": "jellyfin", + "category": "LiveTV", + "versions": [ + { + "version": "7.0.0.0", + "changelog": "changelog\n", + "targetAbi": "10.7.0.0", + "sourceUrl": "https://repo.jellyfin.org/releases/plugin/tvheadend/tvheadend_7.0.0.0.zip", + "checksum": "1abbfce737b6962f4b1b2255dc63e932", + "timestamp": "2021-01-05T16:20:33Z" + }, + { + "version": "6.0.0.0", + "changelog": "changelog\n", + "targetAbi": "10.6.0.0", + "sourceUrl": "https://repo.jellyfin.org/releases/plugin/tvheadend/tvheadend_6.0.0.0.zip", + "checksum": "143c34fd70d7173b8912cc03ce4b517d", + "timestamp": "2020-07-20T01:33:15Z" + } + ] + }, + { + "guid": "022a3003-993f-45f1-8565-87d12af2e12a", + "name": "InfuseSync", + "description": "This plugin will track all media changes while any Infuse clients are offline to decrease sync times when logging back in to your server.", + "overview": "Blazing fast indexing for Infuse", + "owner": "Firecore LLC", + "category": "General", + "versions": [ + { + "version": "1.2.4.0", + "changelog": "New Playlist support.\n", + "targetAbi": "10.6.0.0", + "sourceUrl": "https://github.com/firecore/InfuseSync/releases/download/v1.2.4/InfuseSync-jellyfin-1.2.4.zip", + "checksum": "7adde11b8c8404fd2923f59d98fb1a30", + "timestamp": "2020-10-12T08:00:00Z" + }, + { + "version": "1.2.1.3", + "changelog": "changelog\n", + "targetAbi": "10.6.0.0", + "sourceUrl": "https://github.com/firecore/InfuseSync/releases/download/v1.2.3/InfuseSync-jellyfin-1.2.3.zip", + "checksum": "d8e2c5fe736a302097bb3bac3d04b1c4", + "timestamp": "2020-09-18T12:19:00Z" + }, + { + "version": "1.2.1.0", + "changelog": "changelog\n", + "targetAbi": "10.6.0.0", + "sourceUrl": "https://github.com/firecore/InfuseSync/releases/download/v1.2.1/InfuseSync-jellyfin-1.2.1.zip", + "checksum": "1a853e926cc422f5d79d398d9ae18ee8", + "timestamp": "2020-08-21T10:48:00Z" + }, + { + "version": "1.2.0.0", + "changelog": "changelog\n", + "targetAbi": "10.6.0.0", + "sourceUrl": "https://github.com/firecore/InfuseSync/releases/download/v1.2.0/InfuseSync-jellyfin-1.2.0.zip", + "checksum": "2d3c7859852695a7f05adc6d3fcbc783", + "timestamp": "2020-07-20T11:51:00Z" + } + ] + }, + { + "guid": "8119f3c6-cfc2-4d9c-a0ba-028f1d93e526", + "name": "Cover Art Archive", + "description": "This plugin provides images from the Cover Art Archive https://musicbrainz.org/doc/Cover_Art_Archive and depends on the MusicBrainz metadata provider to know what images belong where\n", + "overview": "MusicBrainz Cover Art Archive", + "owner": "jellyfin", + "category": "Metadata", + "versions": [ + { + "version": "2.0.0.0", + "changelog": "changelog\n", + "targetAbi": "10.7.0.0", + "sourceUrl": "https://repo.jellyfin.org/releases/plugin/cover-art-archive/cover-art-archive_2.0.0.0.zip", + "checksum": "bea8fa4a37b3e7ed74e22266e7597a68", + "timestamp": "2020-12-06T02:51:03Z" + }, + { + "version": "1.0.0.3", + "changelog": "changelog\n", + "targetAbi": "10.6.0.0", + "sourceUrl": "https://repo.jellyfin.org/releases/plugin/cover-art-archive/cover-art-archive_1.0.0.3.zip", + "checksum": "c502a5c54b168810614c1c40709b9598", + "timestamp": "2020-08-06T21:21:22Z" + } + ] + }, + { + "guid": "A4A488D0-17A3-4919-8D82-7F3DE4F6B209", + "name": "TV Maze", + "description": "Get TV metadata from TV Maze\n", + "overview": "Get TV metadata from TV Maze", + "owner": "jellyfin", + "category": "Metadata", + "versions": [ + { + "version": "5.0.0.0", + "changelog": "Get additional image types\n", + "targetAbi": "10.7.0.0", + "sourceUrl": "https://repo.jellyfin.org/releases/plugin/tv-maze/tv-maze_5.0.0.0.zip", + "checksum": "509a85e40b1d1ac36eef45673deaf606", + "timestamp": "2020-12-06T02:51:56Z" + }, + { + "version": "4.0.0.0", + "changelog": "Get additional image types\n", + "targetAbi": "10.6.0.0", + "sourceUrl": "https://repo.jellyfin.org/releases/plugin/tv-maze/tv-maze_4.0.0.0.zip", + "checksum": "58ee9ab3f129151bdfff033ad889ad87", + "timestamp": "2020-11-24T14:44:37Z" + }, + { + "version": "3.0.0.0", + "changelog": "Remove unused dependencies \n", + "targetAbi": "10.6.0.0", + "sourceUrl": "https://repo.jellyfin.org/releases/plugin/tv-maze/tv-maze_3.0.0.0.zip", + "checksum": "f3b2c70b3e136fb15c917e4420f4fdec", + "timestamp": "2020-11-09T14:32:56Z" + }, + { + "version": "2.0.0.0", + "changelog": "Remove unused dependencies \n", + "targetAbi": "10.6.0.0", + "sourceUrl": "https://repo.jellyfin.org/releases/plugin/tv-maze/tv-maze_2.0.0.0.zip", + "checksum": "c7662ae8ae52ce8a4e8d685d55f36e80", + "timestamp": "2020-11-09T02:33:11Z" + }, + { + "version": "1.0.0.0", + "changelog": "Initial release.\n", + "targetAbi": "10.6.0.0", + "sourceUrl": "https://repo.jellyfin.org/releases/plugin/tv-maze/tv-maze_1.0.0.0.zip", + "checksum": "c90eee48c12f2c07880b4b28e507fd14", + "timestamp": "2020-11-08T19:05:32Z" + } + ] + }, + { + "guid": "a677c0da-fac5-4cde-941a-7134223f14c8", + "name": "TheTVDB", + "description": "Get TV metadata from TheTvdb\n", + "overview": "Get TV metadata from TheTvdb", + "owner": "jellyfin", + "category": "Metadata", + "versions": [ + { + "version": "2.0.0.0", + "changelog": "Remove from Jellyfin core.\n", + "targetAbi": "10.7.0.0", + "sourceUrl": "https://repo.jellyfin.org/releases/plugin/thetvdb/thetvdb_2.0.0.0.zip", + "checksum": "e46cee334476a1b475e5c553171c4cb6", + "timestamp": "2020-12-16T20:03:28Z" + }, + { + "version": "1.0.0.0", + "changelog": "Remove from Jellyfin core.\n", + "targetAbi": "10.7.0.0", + "sourceUrl": "https://repo.jellyfin.org/releases/plugin/thetvdb/thetvdb_1.0.0.0.zip", + "checksum": "5a3dca5c0db4824d83bfd4e7e2b7bf11", + "timestamp": "2020-12-06T02:56:40Z" + } + ] + } +] \ No newline at end of file diff --git a/tests/Jellyfin.Server.Implementations.Tests/Updates/InstallationManagerTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Updates/InstallationManagerTests.cs new file mode 100644 index 0000000000..70acbfc40e --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/Updates/InstallationManagerTests.cs @@ -0,0 +1,82 @@ +using System; +using System.IO; +using System.Linq; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using AutoFixture; +using AutoFixture.AutoMoq; +using Emby.Server.Implementations.Updates; +using MediaBrowser.Model.Updates; +using Moq; +using Moq.Protected; +using Xunit; + +namespace Jellyfin.Server.Implementations.Tests.Updates +{ + public class InstallationManagerTests + { + private readonly Fixture _fixture; + private readonly InstallationManager _installationManager; + + public InstallationManagerTests() + { + var messageHandler = new Mock<HttpMessageHandler>(); + messageHandler.Protected() + .Setup<Task<HttpResponseMessage>>("SendAsync", ItExpr.IsAny<HttpRequestMessage>(), ItExpr.IsAny<CancellationToken>()) + .Returns<HttpRequestMessage, CancellationToken>( + (m, _) => + { + return Task.FromResult(new HttpResponseMessage() + { + Content = new StreamContent(File.OpenRead("Test Data/Updates/" + m.RequestUri?.Segments[^1])) + }); + }); + + var http = new Mock<IHttpClientFactory>(); + http.Setup(x => x.CreateClient(It.IsAny<string>())) + .Returns(new HttpClient(messageHandler.Object)); + _fixture = new Fixture(); + _fixture.Customize(new AutoMoqCustomization + { + ConfigureMembers = true + }).Inject(http); + _installationManager = _fixture.Create<InstallationManager>(); + } + + [Fact] + public async Task GetPackages_Valid_Success() + { + PackageInfo[] packages = await _installationManager.GetPackages( + "Jellyfin Stable", + "https://repo.jellyfin.org/releases/plugin/manifest-stable.json", + false); + + Assert.Equal(25, packages.Length); + } + + [Fact] + public async Task FilterPackages_NameOnly_Success() + { + PackageInfo[] packages = await _installationManager.GetPackages( + "Jellyfin Stable", + "https://repo.jellyfin.org/releases/plugin/manifest-stable.json", + false); + + packages = _installationManager.FilterPackages(packages, "Anime").ToArray(); + Assert.Single(packages); + } + + [Fact] + public async Task FilterPackages_GuidOnly_Success() + { + PackageInfo[] packages = await _installationManager.GetPackages( + "Jellyfin Stable", + "https://repo.jellyfin.org/releases/plugin/manifest-stable.json", + false); + + packages = _installationManager.FilterPackages(packages, id: new Guid("a4df60c5-6ab4-412a-8f79-2cab93fb2bc5")).ToArray(); + Assert.Single(packages); + } + } +} diff --git a/tests/Jellyfin.Server.Implementations.Tests/Users/UserManagerTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Users/UserManagerTests.cs new file mode 100644 index 0000000000..867dda29d6 --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/Users/UserManagerTests.cs @@ -0,0 +1,28 @@ +using System; +using Jellyfin.Server.Implementations.Users; +using Xunit; + +namespace Jellyfin.Server.Implementations.Tests.Users +{ + public class UserManagerTests + { + [Theory] + [InlineData("this_is_valid")] + [InlineData("this is also valid")] + [InlineData("0@_-' .")] + public void ThrowIfInvalidUsername_WhenValidUsername_DoesNotThrowArgumentException(string username) + { + var ex = Record.Exception(() => UserManager.ThrowIfInvalidUsername(username)); + Assert.Null(ex); + } + + [Theory] + [InlineData(" ")] + [InlineData("")] + [InlineData("special characters like & $ ? are not allowed")] + public void ThrowIfInvalidUsername_WhenInvalidUsername_ThrowsArgumentException(string username) + { + Assert.Throws<ArgumentException>(() => UserManager.ThrowIfInvalidUsername(username)); + } + } +} diff --git a/tests/Jellyfin.Server.Integration.Tests/AuthHelper.cs b/tests/Jellyfin.Server.Integration.Tests/AuthHelper.cs new file mode 100644 index 0000000000..4ea05397dd --- /dev/null +++ b/tests/Jellyfin.Server.Integration.Tests/AuthHelper.cs @@ -0,0 +1,59 @@ +using System; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Net.Mime; +using System.Text.Json; +using System.Threading.Tasks; +using Jellyfin.Api.Models.StartupDtos; +using Jellyfin.Api.Models.UserDtos; +using Jellyfin.Extensions.Json; +using Xunit; + +namespace Jellyfin.Server.Integration.Tests +{ + public static class AuthHelper + { + public const string AuthHeaderName = "X-Emby-Authorization"; + public const string DummyAuthHeader = "MediaBrowser Client=\"Jellyfin.Server Integration Tests\", DeviceId=\"69420\", Device=\"Apple II\", Version=\"10.8.0\""; + + public static async Task<string> CompleteStartupAsync(HttpClient client) + { + var jsonOptions = JsonDefaults.Options; + var userResponse = await client.GetByteArrayAsync("/Startup/User").ConfigureAwait(false); + var user = JsonSerializer.Deserialize<StartupUserDto>(userResponse, jsonOptions); + + using var completeResponse = await client.PostAsync("/Startup/Complete", new ByteArrayContent(Array.Empty<byte>())).ConfigureAwait(false); + Assert.Equal(HttpStatusCode.NoContent, completeResponse.StatusCode); + + using var content = new ByteArrayContent(JsonSerializer.SerializeToUtf8Bytes( + new AuthenticateUserByName() + { + Username = user!.Name, + Pw = user.Password, + }, + jsonOptions)); + content.Headers.ContentType = MediaTypeHeaderValue.Parse(MediaTypeNames.Application.Json); + content.Headers.Add("X-Emby-Authorization", DummyAuthHeader); + + using var authResponse = await client.PostAsync("/Users/AuthenticateByName", content).ConfigureAwait(false); + var auth = await JsonSerializer.DeserializeAsync<AuthenticationResultDto>( + await authResponse.Content.ReadAsStreamAsync().ConfigureAwait(false), + jsonOptions).ConfigureAwait(false); + + return auth!.AccessToken; + } + + public static void AddAuthHeader(this HttpHeaders headers, string accessToken) + { + headers.Add(AuthHeaderName, DummyAuthHeader + $", Token={accessToken}"); + } + + private class AuthenticationResultDto + { + public string AccessToken { get; set; } = string.Empty; + + public string ServerId { get; set; } = string.Empty; + } + } +} diff --git a/tests/Jellyfin.Server.Integration.Tests/Controllers/ActivityLogControllerTests.cs b/tests/Jellyfin.Server.Integration.Tests/Controllers/ActivityLogControllerTests.cs new file mode 100644 index 0000000000..be89fbc9a9 --- /dev/null +++ b/tests/Jellyfin.Server.Integration.Tests/Controllers/ActivityLogControllerTests.cs @@ -0,0 +1,30 @@ +using System.Net; +using System.Net.Mime; +using System.Threading.Tasks; +using Xunit; + +namespace Jellyfin.Server.Integration.Tests.Controllers +{ + public sealed class ActivityLogControllerTests : IClassFixture<JellyfinApplicationFactory> + { + private readonly JellyfinApplicationFactory _factory; + private static string? _accessToken; + + public ActivityLogControllerTests(JellyfinApplicationFactory factory) + { + _factory = factory; + } + + [Fact] + public async Task ActivityLog_GetEntries_Ok() + { + var client = _factory.CreateClient(); + client.DefaultRequestHeaders.AddAuthHeader(_accessToken ??= await AuthHelper.CompleteStartupAsync(client).ConfigureAwait(false)); + + var response = await client.GetAsync("System/ActivityLog/Entries").ConfigureAwait(false); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal(MediaTypeNames.Application.Json, response.Content.Headers.ContentType?.MediaType); + } + } +} diff --git a/tests/Jellyfin.Server.Integration.Tests/Controllers/BaseJellyfinTestController.cs b/tests/Jellyfin.Server.Integration.Tests/Controllers/BaseJellyfinTestController.cs new file mode 100644 index 0000000000..9db8689a78 --- /dev/null +++ b/tests/Jellyfin.Server.Integration.Tests/Controllers/BaseJellyfinTestController.cs @@ -0,0 +1,14 @@ +using Jellyfin.Api; +using Microsoft.AspNetCore.Mvc; + +namespace Jellyfin.Server.Integration.Tests.Controllers +{ + /// <summary> + /// Base controller for testing infrastructure. + /// Automatically ignored in generated openapi spec. + /// </summary> + [ApiExplorerSettings(IgnoreApi = true)] + public class BaseJellyfinTestController : BaseJellyfinApiController + { + } +} diff --git a/tests/Jellyfin.Api.Tests/BrandingServiceTests.cs b/tests/Jellyfin.Server.Integration.Tests/Controllers/BrandingControllerTests.cs similarity index 57% rename from tests/Jellyfin.Api.Tests/BrandingServiceTests.cs rename to tests/Jellyfin.Server.Integration.Tests/Controllers/BrandingControllerTests.cs index 6fc287420b..87136dfc8a 100644 --- a/tests/Jellyfin.Api.Tests/BrandingServiceTests.cs +++ b/tests/Jellyfin.Server.Integration.Tests/Controllers/BrandingControllerTests.cs @@ -1,15 +1,18 @@ +using System.Net; +using System.Net.Mime; +using System.Text; using System.Text.Json; using System.Threading.Tasks; using MediaBrowser.Model.Branding; using Xunit; -namespace Jellyfin.Api.Tests +namespace Jellyfin.Server.Integration.Tests { - public sealed class BrandingServiceTests : IClassFixture<JellyfinApplicationFactory> + public sealed class BrandingControllerTests : IClassFixture<JellyfinApplicationFactory> { private readonly JellyfinApplicationFactory _factory; - public BrandingServiceTests(JellyfinApplicationFactory factory) + public BrandingControllerTests(JellyfinApplicationFactory factory) { _factory = factory; } @@ -24,8 +27,9 @@ namespace Jellyfin.Api.Tests var response = await client.GetAsync("/Branding/Configuration"); // Assert - response.EnsureSuccessStatusCode(); - Assert.Equal("application/json; charset=utf-8", response.Content.Headers.ContentType.ToString()); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal(MediaTypeNames.Application.Json, response.Content.Headers.ContentType?.MediaType); + Assert.Equal(Encoding.UTF8.BodyName, response.Content.Headers.ContentType?.CharSet); var responseBody = await response.Content.ReadAsStreamAsync(); _ = await JsonSerializer.DeserializeAsync<BrandingOptions>(responseBody); } @@ -42,8 +46,9 @@ namespace Jellyfin.Api.Tests var response = await client.GetAsync(url); // Assert - response.EnsureSuccessStatusCode(); - Assert.Equal("text/css; charset=utf-8", response.Content.Headers.ContentType.ToString()); + Assert.True(response.IsSuccessStatusCode); + Assert.Equal("text/css", response.Content.Headers.ContentType?.MediaType); + Assert.Equal(Encoding.UTF8.BodyName, response.Content.Headers.ContentType?.CharSet); } } } diff --git a/tests/Jellyfin.Server.Integration.Tests/Controllers/DashboardControllerTests.cs b/tests/Jellyfin.Server.Integration.Tests/Controllers/DashboardControllerTests.cs new file mode 100644 index 0000000000..8273653637 --- /dev/null +++ b/tests/Jellyfin.Server.Integration.Tests/Controllers/DashboardControllerTests.cs @@ -0,0 +1,86 @@ +using System.IO; +using System.Net; +using System.Net.Mime; +using System.Text; +using System.Text.Json; +using System.Threading.Tasks; +using Jellyfin.Api.Models; +using Jellyfin.Extensions.Json; +using Xunit; + +namespace Jellyfin.Server.Integration.Tests.Controllers +{ + public sealed class DashboardControllerTests : IClassFixture<JellyfinApplicationFactory> + { + private readonly JellyfinApplicationFactory _factory; + private readonly JsonSerializerOptions _jsonOpions = JsonDefaults.Options; + + public DashboardControllerTests(JellyfinApplicationFactory factory) + { + _factory = factory; + } + + [Fact] + public async Task GetDashboardConfigurationPage_NonExistingPage_NotFound() + { + var client = _factory.CreateClient(); + + var response = await client.GetAsync("web/ConfigurationPage?name=ThisPageDoesntExists").ConfigureAwait(false); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task GetDashboardConfigurationPage_ExistingPage_CorrectPage() + { + var client = _factory.CreateClient(); + + var response = await client.GetAsync("/web/ConfigurationPage?name=TestPlugin").ConfigureAwait(false); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal(MediaTypeNames.Text.Html, response.Content.Headers.ContentType?.MediaType); + StreamReader reader = new StreamReader(typeof(TestPlugin).Assembly.GetManifestResourceStream("Jellyfin.Server.Integration.Tests.TestPage.html")!); + Assert.Equal(await response.Content.ReadAsStringAsync(), reader.ReadToEnd()); + } + + [Fact] + public async Task GetDashboardConfigurationPage_BrokenPage_NotFound() + { + var client = _factory.CreateClient(); + + var response = await client.GetAsync("/web/ConfigurationPage?name=BrokenPage").ConfigureAwait(false); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task GetConfigurationPages_NoParams_AllConfigurationPages() + { + var client = _factory.CreateClient(); + + var response = await client.GetAsync("/web/ConfigurationPages").ConfigureAwait(false); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var res = await response.Content.ReadAsStreamAsync(); + _ = await JsonSerializer.DeserializeAsync<ConfigurationPageInfo[]>(res, _jsonOpions); + // TODO: check content + } + + [Fact] + public async Task GetConfigurationPages_True_MainMenuConfigurationPages() + { + var client = _factory.CreateClient(); + + var response = await client.GetAsync("/web/ConfigurationPages?enableInMainMenu=true").ConfigureAwait(false); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal(MediaTypeNames.Application.Json, response.Content.Headers.ContentType?.MediaType); + Assert.Equal(Encoding.UTF8.BodyName, response.Content.Headers.ContentType?.CharSet); + + var res = await response.Content.ReadAsStreamAsync(); + var data = await JsonSerializer.DeserializeAsync<ConfigurationPageInfo[]>(res, _jsonOpions); + Assert.Empty(data); + } + } +} diff --git a/tests/Jellyfin.Server.Integration.Tests/Controllers/EncoderController.cs b/tests/Jellyfin.Server.Integration.Tests/Controllers/EncoderController.cs new file mode 100644 index 0000000000..1a720c2f62 --- /dev/null +++ b/tests/Jellyfin.Server.Integration.Tests/Controllers/EncoderController.cs @@ -0,0 +1,53 @@ +using System.Collections.Generic; +using System.Linq; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +namespace Jellyfin.Server.Integration.Tests.Controllers +{ + /// <summary> + /// Controller for testing the encoded url. + /// </summary> + public class EncoderController : BaseJellyfinTestController + { + /// <summary> + /// Tests the url decoding. + /// </summary> + /// <param name="params">Parameters to echo back in the response.</param> + /// <returns>An <see cref="OkResult"/>.</returns> + /// <response code="200">Information retrieved.</response> + [HttpGet("UrlDecode")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ContentResult TestUrlDecoding([FromQuery] Dictionary<string, string>? @params = null) + { + return new ContentResult() + { + Content = (@params != null && @params.Count > 0) + ? string.Join("&", @params.Select(x => x.Key + "=" + x.Value)) + : string.Empty, + ContentType = "text/plain; charset=utf-8", + StatusCode = 200 + }; + } + + /// <summary> + /// Tests the url decoding. + /// </summary> + /// <param name="params">Parameters to echo back in the response.</param> + /// <returns>An <see cref="OkResult"/>.</returns> + /// <response code="200">Information retrieved.</response> + [HttpGet("UrlArrayDecode")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ContentResult TestUrlArrayDecoding([FromQuery] Dictionary<string, string[]>? @params = null) + { + return new ContentResult() + { + Content = (@params != null && @params.Count > 0) + ? string.Join("&", @params.Select(x => x.Key + "=" + string.Join(',', x.Value))) + : string.Empty, + ContentType = "text/plain; charset=utf-8", + StatusCode = 200 + }; + } + } +} diff --git a/tests/Jellyfin.Server.Integration.Tests/Controllers/MediaInfoControllerTests.cs b/tests/Jellyfin.Server.Integration.Tests/Controllers/MediaInfoControllerTests.cs new file mode 100644 index 0000000000..34d26680ad --- /dev/null +++ b/tests/Jellyfin.Server.Integration.Tests/Controllers/MediaInfoControllerTests.cs @@ -0,0 +1,61 @@ +using System.Globalization; +using System.Net; +using System.Net.Mime; +using System.Threading.Tasks; +using Xunit; + +namespace Jellyfin.Server.Integration.Tests.Controllers +{ + public sealed class MediaInfoControllerTests : IClassFixture<JellyfinApplicationFactory> + { + private readonly JellyfinApplicationFactory _factory; + private static string? _accessToken; + + public MediaInfoControllerTests(JellyfinApplicationFactory factory) + { + _factory = factory; + } + + [Fact] + public async Task BitrateTest_Default_Ok() + { + var client = _factory.CreateClient(); + client.DefaultRequestHeaders.AddAuthHeader(_accessToken ??= await AuthHelper.CompleteStartupAsync(client).ConfigureAwait(false)); + + var response = await client.GetAsync("Playback/BitrateTest").ConfigureAwait(false); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal(MediaTypeNames.Application.Octet, response.Content.Headers.ContentType?.MediaType); + Assert.NotNull(response.Content.Headers.ContentLength); + } + + [Theory] + [InlineData(102400)] + public async Task BitrateTest_WithValidParam_Ok(int size) + { + var client = _factory.CreateClient(); + client.DefaultRequestHeaders.AddAuthHeader(_accessToken ??= await AuthHelper.CompleteStartupAsync(client).ConfigureAwait(false)); + + var response = await client.GetAsync("Playback/BitrateTest?size=" + size.ToString(CultureInfo.InvariantCulture)).ConfigureAwait(false); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal(MediaTypeNames.Application.Octet, response.Content.Headers.ContentType?.MediaType); + Assert.NotNull(response.Content.Headers.ContentLength); + Assert.InRange(response.Content.Headers.ContentLength!.Value, size, long.MaxValue); + } + + [Theory] + [InlineData(0)] // Zero + [InlineData(-102400)] // Negative value + [InlineData(1000000000)] // Too large + public async Task BitrateTest_InvalidValue_BadRequest(int size) + { + var client = _factory.CreateClient(); + client.DefaultRequestHeaders.AddAuthHeader(_accessToken ??= await AuthHelper.CompleteStartupAsync(client).ConfigureAwait(false)); + + var response = await client.GetAsync("Playback/BitrateTest?size=" + size.ToString(CultureInfo.InvariantCulture)).ConfigureAwait(false); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + } +} diff --git a/tests/Jellyfin.Server.Integration.Tests/Controllers/StartupControllerTests.cs b/tests/Jellyfin.Server.Integration.Tests/Controllers/StartupControllerTests.cs new file mode 100644 index 0000000000..9c0fc72f65 --- /dev/null +++ b/tests/Jellyfin.Server.Integration.Tests/Controllers/StartupControllerTests.cs @@ -0,0 +1,119 @@ +using System; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Net.Mime; +using System.Text.Json; +using System.Threading.Tasks; +using Jellyfin.Api.Models.StartupDtos; +using Jellyfin.Extensions.Json; +using Xunit; +using Xunit.Priority; + +namespace Jellyfin.Server.Integration.Tests.Controllers +{ + [TestCaseOrderer(PriorityOrderer.Name, PriorityOrderer.Assembly)] + public sealed class StartupControllerTests : IClassFixture<JellyfinApplicationFactory> + { + private readonly JellyfinApplicationFactory _factory; + private readonly JsonSerializerOptions _jsonOptions = JsonDefaults.Options; + + public StartupControllerTests(JellyfinApplicationFactory factory) + { + _factory = factory; + } + + [Fact] + [Priority(-2)] + public async Task Configuration_EditConfig_Success() + { + var client = _factory.CreateClient(); + + var config = new StartupConfigurationDto() + { + UICulture = "NewCulture", + MetadataCountryCode = "be", + PreferredMetadataLanguage = "nl" + }; + + using var postContent = new ByteArrayContent(JsonSerializer.SerializeToUtf8Bytes(config, _jsonOptions)); + postContent.Headers.ContentType = MediaTypeHeaderValue.Parse(MediaTypeNames.Application.Json); + using var postResponse = await client.PostAsync("/Startup/Configuration", postContent).ConfigureAwait(false); + Assert.Equal(HttpStatusCode.NoContent, postResponse.StatusCode); + + using var getResponse = await client.GetAsync("/Startup/Configuration").ConfigureAwait(false); + Assert.Equal(HttpStatusCode.OK, getResponse.StatusCode); + Assert.Equal(MediaTypeNames.Application.Json, getResponse.Content.Headers.ContentType?.MediaType); + + using var responseStream = await getResponse.Content.ReadAsStreamAsync().ConfigureAwait(false); + var newConfig = await JsonSerializer.DeserializeAsync<StartupConfigurationDto>(responseStream, _jsonOptions).ConfigureAwait(false); + Assert.Equal(config.UICulture, newConfig!.UICulture); + Assert.Equal(config.MetadataCountryCode, newConfig.MetadataCountryCode); + Assert.Equal(config.PreferredMetadataLanguage, newConfig.PreferredMetadataLanguage); + } + + [Fact] + [Priority(-2)] + public async Task User_DefaultUser_NameWithoutPassword() + { + var client = _factory.CreateClient(); + + using var response = await client.GetAsync("/Startup/User").ConfigureAwait(false); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal(MediaTypeNames.Application.Json, response.Content.Headers.ContentType?.MediaType); + + using var contentStream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false); + var user = await JsonSerializer.DeserializeAsync<StartupUserDto>(contentStream, _jsonOptions).ConfigureAwait(false); + Assert.NotEmpty(user!.Name); + Assert.Null(user.Password); + } + + [Fact] + [Priority(-1)] + public async Task User_EditUser_Success() + { + var client = _factory.CreateClient(); + + var user = new StartupUserDto() + { + Name = "NewName", + Password = "NewPassword" + }; + + using var postContent = new ByteArrayContent(JsonSerializer.SerializeToUtf8Bytes(user, _jsonOptions)); + postContent.Headers.ContentType = MediaTypeHeaderValue.Parse(MediaTypeNames.Application.Json); + var postResponse = await client.PostAsync("/Startup/User", postContent).ConfigureAwait(false); + Assert.Equal(HttpStatusCode.NoContent, postResponse.StatusCode); + + var getResponse = await client.GetAsync("/Startup/User").ConfigureAwait(false); + Assert.Equal(HttpStatusCode.OK, getResponse.StatusCode); + Assert.Equal(MediaTypeNames.Application.Json, getResponse.Content.Headers.ContentType?.MediaType); + + var contentStream = await getResponse.Content.ReadAsStreamAsync().ConfigureAwait(false); + var newUser = await JsonSerializer.DeserializeAsync<StartupUserDto>(contentStream, _jsonOptions).ConfigureAwait(false); + Assert.Equal(user.Name, newUser!.Name); + Assert.NotEmpty(newUser.Password); + Assert.NotEqual(user.Password, newUser.Password); + } + + [Fact] + [Priority(0)] + public async Task CompleteWizard_Success() + { + var client = _factory.CreateClient(); + + var response = await client.PostAsync("/Startup/Complete", new ByteArrayContent(Array.Empty<byte>())).ConfigureAwait(false); + Assert.Equal(HttpStatusCode.NoContent, response.StatusCode); + } + + [Fact] + [Priority(1)] + public async Task GetFirstUser_CompleteWizard_Unauthorized() + { + var client = _factory.CreateClient(); + + using var response = await client.GetAsync("/Startup/User").ConfigureAwait(false); + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + } +} diff --git a/tests/Jellyfin.Server.Integration.Tests/Controllers/UserControllerTests.cs b/tests/Jellyfin.Server.Integration.Tests/Controllers/UserControllerTests.cs new file mode 100644 index 0000000000..8866ab53cd --- /dev/null +++ b/tests/Jellyfin.Server.Integration.Tests/Controllers/UserControllerTests.cs @@ -0,0 +1,170 @@ +using System; +using System.Globalization; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Net.Mime; +using System.Text.Json; +using System.Threading.Tasks; +using Jellyfin.Api.Models.UserDtos; +using Jellyfin.Extensions.Json; +using MediaBrowser.Model.Dto; +using Xunit; +using Xunit.Priority; + +namespace Jellyfin.Server.Integration.Tests.Controllers +{ + [TestCaseOrderer(PriorityOrderer.Name, PriorityOrderer.Assembly)] + public sealed class UserControllerTests : IClassFixture<JellyfinApplicationFactory> + { + private const string TestUsername = "testUser01"; + + private readonly JellyfinApplicationFactory _factory; + private readonly JsonSerializerOptions _jsonOpions = JsonDefaults.Options; + private static string? _accessToken; + private static Guid _testUserId = Guid.Empty; + + public UserControllerTests(JellyfinApplicationFactory factory) + { + _factory = factory; + } + + private Task<HttpResponseMessage> CreateUserByName(HttpClient httpClient, CreateUserByName request) + { + using var postContent = new ByteArrayContent(JsonSerializer.SerializeToUtf8Bytes(request, _jsonOpions)); + postContent.Headers.ContentType = MediaTypeHeaderValue.Parse(MediaTypeNames.Application.Json); + return httpClient.PostAsync("Users/New", postContent); + } + + private Task<HttpResponseMessage> UpdateUserPassword(HttpClient httpClient, Guid userId, UpdateUserPassword request) + { + using var postContent = new ByteArrayContent(JsonSerializer.SerializeToUtf8Bytes(request, _jsonOpions)); + postContent.Headers.ContentType = MediaTypeHeaderValue.Parse(MediaTypeNames.Application.Json); + return httpClient.PostAsync("Users/" + userId.ToString("N", CultureInfo.InvariantCulture) + "/Password", postContent); + } + + [Fact] + [Priority(-1)] + public async Task GetPublicUsers_Valid_Success() + { + var client = _factory.CreateClient(); + + using var response = await client.GetAsync("Users/Public").ConfigureAwait(false); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var users = await JsonSerializer.DeserializeAsync<UserDto[]>( + await response.Content.ReadAsStreamAsync().ConfigureAwait(false), _jsonOpions).ConfigureAwait(false); + // User are hidden by default + Assert.Empty(users); + } + + [Fact] + [Priority(-1)] + public async Task GetUsers_Valid_Success() + { + var client = _factory.CreateClient(); + client.DefaultRequestHeaders.AddAuthHeader(_accessToken ??= await AuthHelper.CompleteStartupAsync(client).ConfigureAwait(false)); + + using var response = await client.GetAsync("Users").ConfigureAwait(false); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var users = await JsonSerializer.DeserializeAsync<UserDto[]>( + await response.Content.ReadAsStreamAsync().ConfigureAwait(false), _jsonOpions).ConfigureAwait(false); + Assert.Single(users); + Assert.False(users![0].HasConfiguredPassword); + } + + [Fact] + [Priority(0)] + public async Task New_Valid_Success() + { + var client = _factory.CreateClient(); + + // access token can't be null here as the previous test populated it + client.DefaultRequestHeaders.AddAuthHeader(_accessToken!); + + var createRequest = new CreateUserByName() + { + Name = TestUsername + }; + + using var response = await CreateUserByName(client, createRequest).ConfigureAwait(false); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var user = await JsonSerializer.DeserializeAsync<UserDto>( + await response.Content.ReadAsStreamAsync().ConfigureAwait(false), _jsonOpions).ConfigureAwait(false); + Assert.Equal(TestUsername, user!.Name); + Assert.False(user.HasPassword); + Assert.False(user.HasConfiguredPassword); + + _testUserId = user.Id; + + Console.WriteLine(user.Id.ToString("N", CultureInfo.InvariantCulture)); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData("‼️")] + [Priority(0)] + public async Task New_Invalid_Fail(string? username) + { + var client = _factory.CreateClient(); + + // access token can't be null here as the previous test populated it + client.DefaultRequestHeaders.AddAuthHeader(_accessToken!); + + var createRequest = new CreateUserByName() + { + Name = username + }; + + using var response = await CreateUserByName(client, createRequest).ConfigureAwait(false); + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + [Priority(1)] + public async Task UpdateUserPassword_Valid_Success() + { + var client = _factory.CreateClient(); + client.DefaultRequestHeaders.AddAuthHeader(_accessToken!); + + var createRequest = new UpdateUserPassword() + { + NewPw = "4randomPa$$word" + }; + + using var response = await UpdateUserPassword(client, _testUserId, createRequest).ConfigureAwait(false); + Assert.Equal(HttpStatusCode.NoContent, response.StatusCode); + + var users = await JsonSerializer.DeserializeAsync<UserDto[]>( + await client.GetStreamAsync("Users").ConfigureAwait(false), _jsonOpions).ConfigureAwait(false); + var user = users!.First(x => x.Id == _testUserId); + Assert.True(user.HasPassword); + Assert.True(user.HasConfiguredPassword); + } + + [Fact] + [Priority(2)] + public async Task UpdateUserPassword_Empty_RemoveSetPassword() + { + var client = _factory.CreateClient(); + + client.DefaultRequestHeaders.AddAuthHeader(_accessToken!); + + var createRequest = new UpdateUserPassword() + { + CurrentPw = "4randomPa$$word", + }; + + using var response = await UpdateUserPassword(client, _testUserId, createRequest).ConfigureAwait(false); + Assert.Equal(HttpStatusCode.NoContent, response.StatusCode); + + var users = await JsonSerializer.DeserializeAsync<UserDto[]>( + await client.GetStreamAsync("Users").ConfigureAwait(false), _jsonOpions).ConfigureAwait(false); + var user = users!.First(x => x.Id == _testUserId); + Assert.False(user.HasPassword); + Assert.False(user.HasConfiguredPassword); + } + } +} diff --git a/tests/Jellyfin.Server.Integration.Tests/EncodedQueryStringTest.cs b/tests/Jellyfin.Server.Integration.Tests/EncodedQueryStringTest.cs new file mode 100644 index 0000000000..732b4f050d --- /dev/null +++ b/tests/Jellyfin.Server.Integration.Tests/EncodedQueryStringTest.cs @@ -0,0 +1,48 @@ +using System.Net; +using System.Threading.Tasks; +using Xunit; + +namespace Jellyfin.Server.Integration.Tests +{ + /// <summary> + /// Defines the test for encoded querystrings in the url. + /// </summary> + public class EncodedQueryStringTest : IClassFixture<JellyfinApplicationFactory> + { + private readonly JellyfinApplicationFactory _factory; + + public EncodedQueryStringTest(JellyfinApplicationFactory factory) + { + _factory = factory; + } + + [Theory] + [InlineData("a=1&b=2&c=3", "a=1&b=2&c=3")] // won't be processed as there is more than 1. + [InlineData("a=1", "a=1")] // won't be processed as it has a value + [InlineData("a%3D1%26b%3D2%26c%3D3", "a=1&b=2&c=3")] // will be processed. + [InlineData("a=b&a=c", "a=b")] + [InlineData("a%3Db%26a%3Dc", "a=b")] + public async Task Ensure_Decoding_Of_Urls_Is_Working(string sourceUrl, string unencodedUrl) + { + var client = _factory.CreateClient(); + + var response = await client.GetAsync("Encoder/UrlDecode?" + sourceUrl).ConfigureAwait(false); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + string reply = await response.Content.ReadAsStringAsync().ConfigureAwait(false); + Assert.Equal(unencodedUrl, reply); + } + + [Theory] + [InlineData("a=b&a=c", "a=b,c")] + [InlineData("a%3Db%26a%3Dc", "a=b,c")] + public async Task Ensure_Array_Decoding_Of_Urls_Is_Working(string sourceUrl, string unencodedUrl) + { + var client = _factory.CreateClient(); + + var response = await client.GetAsync("Encoder/UrlArrayDecode?" + sourceUrl).ConfigureAwait(false); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + string reply = await response.Content.ReadAsStringAsync().ConfigureAwait(false); + Assert.Equal(unencodedUrl, reply); + } + } +} diff --git a/tests/Jellyfin.Server.Integration.Tests/Jellyfin.Server.Integration.Tests.csproj b/tests/Jellyfin.Server.Integration.Tests/Jellyfin.Server.Integration.Tests.csproj new file mode 100644 index 0000000000..592b444c99 --- /dev/null +++ b/tests/Jellyfin.Server.Integration.Tests/Jellyfin.Server.Integration.Tests.csproj @@ -0,0 +1,44 @@ +<Project Sdk="Microsoft.NET.Sdk"> + <PropertyGroup> + <TargetFramework>net5.0</TargetFramework> + <IsPackable>false</IsPackable> + <CodeAnalysisRuleSet>../jellyfin-tests.ruleset</CodeAnalysisRuleSet> + </PropertyGroup> + + <ItemGroup> + <PackageReference Include="AutoFixture" Version="4.17.0" /> + <PackageReference Include="AutoFixture.AutoMoq" Version="4.17.0" /> + <PackageReference Include="AutoFixture.Xunit2" Version="4.17.0" /> + <PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="5.0.9" /> + <PackageReference Include="Microsoft.Extensions.Options" Version="5.0.0" /> + <PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.11.0" /> + <PackageReference Include="xunit" Version="2.4.1" /> + <PackageReference Include="xunit.runner.visualstudio" Version="2.4.3" /> + <PackageReference Include="Xunit.Priority" Version="1.1.6" /> + <PackageReference Include="coverlet.collector" Version="3.1.0" /> + <PackageReference Include="Moq" Version="4.16.0" /> + </ItemGroup> + + <ItemGroup> + <!-- Don't run tests in parallel --> + <None Update="xunit.runner.json"> + <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> + </None> + </ItemGroup> + + <!-- Code Analyzers --> + <ItemGroup Condition=" '$(Configuration)' == 'Debug' "> + <PackageReference Include="SerilogAnalyzer" Version="0.15.0" PrivateAssets="All" /> + <PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="All" /> + <PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" PrivateAssets="All" /> + </ItemGroup> + + <ItemGroup> + <ProjectReference Include="../../Jellyfin.Server/Jellyfin.Server.csproj" /> + </ItemGroup> + + <ItemGroup> + <EmbeddedResource Include="TestPage.html" /> + </ItemGroup> + +</Project> diff --git a/tests/Jellyfin.Api.Tests/JellyfinApplicationFactory.cs b/tests/Jellyfin.Server.Integration.Tests/JellyfinApplicationFactory.cs similarity index 85% rename from tests/Jellyfin.Api.Tests/JellyfinApplicationFactory.cs rename to tests/Jellyfin.Server.Integration.Tests/JellyfinApplicationFactory.cs index bd3d356870..976e19d468 100644 --- a/tests/Jellyfin.Api.Tests/JellyfinApplicationFactory.cs +++ b/tests/Jellyfin.Server.Integration.Tests/JellyfinApplicationFactory.cs @@ -1,21 +1,20 @@ using System; using System.Collections.Concurrent; using System.IO; +using System.Threading; using Emby.Server.Implementations; using Emby.Server.Implementations.IO; -using Emby.Server.Implementations.Networking; -using Jellyfin.Drawing.Skia; -using Jellyfin.Server; using MediaBrowser.Common; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Serilog; using Serilog.Extensions.Logging; -namespace Jellyfin.Api.Tests +namespace Jellyfin.Server.Integration.Tests { /// <summary> /// Factory for bootstrapping the Jellyfin application in memory for functional end to end tests. @@ -23,12 +22,12 @@ namespace Jellyfin.Api.Tests public class JellyfinApplicationFactory : WebApplicationFactory<Startup> { private static readonly string _testPathRoot = Path.Combine(Path.GetTempPath(), "jellyfin-test-data"); - private static readonly ConcurrentBag<IDisposable> _disposableComponents = new ConcurrentBag<IDisposable>(); + private readonly ConcurrentBag<IDisposable> _disposableComponents = new ConcurrentBag<IDisposable>(); /// <summary> - /// Initializes a new instance of the <see cref="JellyfinApplicationFactory"/> class. + /// Initializes static members of the <see cref="JellyfinApplicationFactory"/> class. /// </summary> - public JellyfinApplicationFactory() + static JellyfinApplicationFactory() { // Perform static initialization that only needs to happen once per test-run Log.Logger = new LoggerConfiguration().WriteTo.Console().CreateLogger(); @@ -45,10 +44,7 @@ namespace Jellyfin.Api.Tests protected override void ConfigureWebHost(IWebHostBuilder builder) { // Specify the startup command line options - var commandLineOpts = new StartupOptions - { - NoWebClient = true - }; + var commandLineOpts = new StartupOptions(); // Use a temporary directory for the application paths var webHostPathRoot = Path.Combine(_testPathRoot, "test-host-" + Path.GetFileNameWithoutExtension(Path.GetRandomFileName())); @@ -75,12 +71,12 @@ namespace Jellyfin.Api.Tests _disposableComponents.Add(loggerFactory); // Create the app host and initialize it - var appHost = new CoreAppHost( + var appHost = new TestAppHost( appPaths, loggerFactory, commandLineOpts, + new ConfigurationBuilder().Build(), new ManagedFileSystem(loggerFactory.CreateLogger<ManagedFileSystem>(), appPaths), - new NetworkManager(loggerFactory.CreateLogger<NetworkManager>()), serviceCollection); _disposableComponents.Add(appHost); appHost.Init(); @@ -96,10 +92,10 @@ namespace Jellyfin.Api.Tests var testServer = base.CreateServer(builder); // Finish initializing the app host - var appHost = (CoreAppHost)testServer.Services.GetRequiredService<IApplicationHost>(); + var appHost = (TestAppHost)testServer.Services.GetRequiredService<IApplicationHost>(); appHost.ServiceProvider = testServer.Services; appHost.InitializeServices().GetAwaiter().GetResult(); - appHost.RunStartupTasksAsync().GetAwaiter().GetResult(); + appHost.RunStartupTasksAsync(CancellationToken.None).GetAwaiter().GetResult(); return testServer; } diff --git a/tests/Jellyfin.Server.Integration.Tests/Middleware/RobotsRedirectionMiddlewareTests.cs b/tests/Jellyfin.Server.Integration.Tests/Middleware/RobotsRedirectionMiddlewareTests.cs new file mode 100644 index 0000000000..8c49a2e2b5 --- /dev/null +++ b/tests/Jellyfin.Server.Integration.Tests/Middleware/RobotsRedirectionMiddlewareTests.cs @@ -0,0 +1,32 @@ +using System.Net; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc.Testing; +using Xunit; + +namespace Jellyfin.Server.Integration.Tests.Middleware +{ + public sealed class RobotsRedirectionMiddlewareTests : IClassFixture<JellyfinApplicationFactory> + { + private readonly JellyfinApplicationFactory _factory; + + public RobotsRedirectionMiddlewareTests(JellyfinApplicationFactory factory) + { + _factory = factory; + } + + [Fact] + public async Task RobotsDotTxtRedirects() + { + var client = _factory.CreateClient( + new WebApplicationFactoryClientOptions() + { + AllowAutoRedirect = false + }); + + var response = await client.GetAsync("robots.txt").ConfigureAwait(false); + + Assert.Equal(HttpStatusCode.Redirect, response.StatusCode); + Assert.Equal("web/robots.txt", response.Headers.Location?.ToString()); + } + } +} diff --git a/tests/Jellyfin.Api.Tests/OpenApiSpecTests.cs b/tests/Jellyfin.Server.Integration.Tests/OpenApiSpecTests.cs similarity index 90% rename from tests/Jellyfin.Api.Tests/OpenApiSpecTests.cs rename to tests/Jellyfin.Server.Integration.Tests/OpenApiSpecTests.cs index 3a85b55141..0ade345a1a 100644 --- a/tests/Jellyfin.Api.Tests/OpenApiSpecTests.cs +++ b/tests/Jellyfin.Server.Integration.Tests/OpenApiSpecTests.cs @@ -1,12 +1,10 @@ using System.IO; using System.Reflection; -using System.Text.Json; using System.Threading.Tasks; -using MediaBrowser.Model.Branding; using Xunit; using Xunit.Abstractions; -namespace Jellyfin.Api.Tests +namespace Jellyfin.Server.Integration.Tests { public sealed class OpenApiSpecTests : IClassFixture<JellyfinApplicationFactory> { @@ -30,7 +28,7 @@ namespace Jellyfin.Api.Tests // Assert response.EnsureSuccessStatusCode(); - Assert.Equal("application/json; charset=utf-8", response.Content.Headers.ContentType.ToString()); + Assert.Equal("application/json; charset=utf-8", response.Content.Headers.ContentType?.ToString()); // Write out for publishing var responseBody = await response.Content.ReadAsStringAsync(); diff --git a/tests/Jellyfin.Server.Integration.Tests/TestAppHost.cs b/tests/Jellyfin.Server.Integration.Tests/TestAppHost.cs new file mode 100644 index 0000000000..0a463cfa39 --- /dev/null +++ b/tests/Jellyfin.Server.Integration.Tests/TestAppHost.cs @@ -0,0 +1,54 @@ +using System.Collections.Generic; +using System.Reflection; +using Emby.Server.Implementations; +using MediaBrowser.Controller; +using MediaBrowser.Model.IO; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Server.Integration.Tests +{ + /// <summary> + /// Implementation of the abstract <see cref="ApplicationHost" /> class. + /// </summary> + public class TestAppHost : CoreAppHost + { + /// <summary> + /// Initializes a new instance of the <see cref="TestAppHost" /> class. + /// </summary> + /// <param name="applicationPaths">The <see cref="ServerApplicationPaths" /> to be used by the <see cref="CoreAppHost" />.</param> + /// <param name="loggerFactory">The <see cref="ILoggerFactory" /> to be used by the <see cref="CoreAppHost" />.</param> + /// <param name="options">The <see cref="StartupOptions" /> to be used by the <see cref="CoreAppHost" />.</param> + /// <param name="startup">The <see cref="IConfiguration" /> to be used by the <see cref="CoreAppHost" />.</param> + /// <param name="fileSystem">The <see cref="IFileSystem" /> to be used by the <see cref="CoreAppHost" />.</param> + /// <param name="collection">The <see cref="IServiceCollection"/> to be used by the <see cref="CoreAppHost"/>.</param> + public TestAppHost( + IServerApplicationPaths applicationPaths, + ILoggerFactory loggerFactory, + IStartupOptions options, + IConfiguration startup, + IFileSystem fileSystem, + IServiceCollection collection) + : base( + applicationPaths, + loggerFactory, + options, + startup, + fileSystem, + collection) + { + } + + /// <inheritdoc /> + protected override IEnumerable<Assembly> GetAssembliesWithPartsInternal() + { + foreach (var a in base.GetAssembliesWithPartsInternal()) + { + yield return a; + } + + yield return typeof(TestPlugin).Assembly; + } + } +} diff --git a/tests/Jellyfin.Server.Integration.Tests/TestPage.html b/tests/Jellyfin.Server.Integration.Tests/TestPage.html new file mode 100644 index 0000000000..8037af8a60 --- /dev/null +++ b/tests/Jellyfin.Server.Integration.Tests/TestPage.html @@ -0,0 +1,9 @@ +<!DOCTYPE html> +<html> +<head> + <title>TestPlugin + + +

This is a Test Page.

+ + diff --git a/tests/Jellyfin.Server.Integration.Tests/TestPlugin.cs b/tests/Jellyfin.Server.Integration.Tests/TestPlugin.cs new file mode 100644 index 0000000000..1d67ac4870 --- /dev/null +++ b/tests/Jellyfin.Server.Integration.Tests/TestPlugin.cs @@ -0,0 +1,43 @@ +#pragma warning disable CS1591 + +using System; +using System.Collections.Generic; +using MediaBrowser.Common.Configuration; +using MediaBrowser.Common.Plugins; +using MediaBrowser.Model.Plugins; +using MediaBrowser.Model.Serialization; + +namespace Jellyfin.Server.Integration.Tests +{ + public class TestPlugin : BasePlugin, IHasWebPages + { + public TestPlugin(IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer) + : base(applicationPaths, xmlSerializer) + { + Instance = this; + } + + public static TestPlugin? Instance { get; private set; } + + public override Guid Id => new Guid("2d350a13-0bf7-4b61-859c-d5e601b5facf"); + + public override string Name => nameof(TestPlugin); + + public override string Description => "Server test Plugin."; + + public IEnumerable GetPages() + { + yield return new PluginPageInfo + { + Name = Name, + EmbeddedResourcePath = GetType().Namespace + ".TestPage.html" + }; + + yield return new PluginPageInfo + { + Name = "BrokenPage", + EmbeddedResourcePath = GetType().Namespace + ".foobar" + }; + } + } +} diff --git a/tests/Jellyfin.Server.Integration.Tests/TestPluginWithoutPages.cs b/tests/Jellyfin.Server.Integration.Tests/TestPluginWithoutPages.cs new file mode 100644 index 0000000000..ac10c47845 --- /dev/null +++ b/tests/Jellyfin.Server.Integration.Tests/TestPluginWithoutPages.cs @@ -0,0 +1,27 @@ +#pragma warning disable CS1591 + +using System; +using MediaBrowser.Common.Configuration; +using MediaBrowser.Common.Plugins; +using MediaBrowser.Model.Plugins; +using MediaBrowser.Model.Serialization; + +namespace Jellyfin.Server.Integration.Tests +{ + public class TestPluginWithoutPages : BasePlugin + { + public TestPluginWithoutPages(IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer) + : base(applicationPaths, xmlSerializer) + { + Instance = this; + } + + public static TestPluginWithoutPages? Instance { get; private set; } + + public override Guid Id => new Guid("ae95cbe6-bd3d-4d73-8596-490db334611e"); + + public override string Name => nameof(TestPluginWithoutPages); + + public override string Description => "Server test Plugin without web pages."; + } +} diff --git a/tests/Jellyfin.Server.Integration.Tests/WebSocketTests.cs b/tests/Jellyfin.Server.Integration.Tests/WebSocketTests.cs new file mode 100644 index 0000000000..ffdc04ebaf --- /dev/null +++ b/tests/Jellyfin.Server.Integration.Tests/WebSocketTests.cs @@ -0,0 +1,32 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Xunit; + +namespace Jellyfin.Server.Integration.Tests +{ + public sealed class WebSocketTests : IClassFixture + { + private readonly JellyfinApplicationFactory _factory; + + public WebSocketTests(JellyfinApplicationFactory factory) + { + _factory = factory; + } + + [Fact] + public async Task WebSocket_Unauthenticated_ThrowsInvalidOperationException() + { + var server = _factory.Server; + var client = server.CreateWebSocketClient(); + + await Assert.ThrowsAsync( + () => client.ConnectAsync( + new UriBuilder(server.BaseAddress) + { + Scheme = "ws", + Path = "websocket" + }.Uri, CancellationToken.None)); + } + } +} diff --git a/tests/Jellyfin.Server.Integration.Tests/xunit.runner.json b/tests/Jellyfin.Server.Integration.Tests/xunit.runner.json new file mode 100644 index 0000000000..809e880c71 --- /dev/null +++ b/tests/Jellyfin.Server.Integration.Tests/xunit.runner.json @@ -0,0 +1,4 @@ +{ + "parallelizeAssembly": false, + "parallelizeTestCollections": false +} diff --git a/tests/Jellyfin.Server.Tests/Jellyfin.Server.Tests.csproj b/tests/Jellyfin.Server.Tests/Jellyfin.Server.Tests.csproj new file mode 100644 index 0000000000..f249be674c --- /dev/null +++ b/tests/Jellyfin.Server.Tests/Jellyfin.Server.Tests.csproj @@ -0,0 +1,33 @@ + + + + net5.0 + false + ../jellyfin-tests.ruleset + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/Jellyfin.Server.Tests/ParseNetworkTests.cs b/tests/Jellyfin.Server.Tests/ParseNetworkTests.cs new file mode 100644 index 0000000000..b92cb165c9 --- /dev/null +++ b/tests/Jellyfin.Server.Tests/ParseNetworkTests.cs @@ -0,0 +1,129 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Net; +using System.Text; +using Jellyfin.Networking.Configuration; +using Jellyfin.Networking.Manager; +using Jellyfin.Server.Extensions; +using MediaBrowser.Common.Configuration; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.HttpOverrides; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Xunit; + +namespace Jellyfin.Server.Tests +{ + public class ParseNetworkTests + { + public static TheoryData TestNetworks_TestData() + { + var data = new TheoryData(); + data.Add( + true, + true, + new string[] { "192.168.t", "127.0.0.1", "1234.1232.12.1234" }, + new IPAddress[] { IPAddress.Loopback.MapToIPv6() }, + Array.Empty()); + + data.Add( + true, + false, + new string[] { "192.168.x", "127.0.0.1", "1234.1232.12.1234" }, + new IPAddress[] { IPAddress.Loopback }, + Array.Empty()); + + data.Add( + true, + true, + new string[] { "::1" }, + Array.Empty(), + new IPNetwork[] { new IPNetwork(IPAddress.IPv6Loopback, 128) }); + + data.Add( + false, + false, + new string[] { "localhost" }, + Array.Empty(), + Array.Empty()); + + data.Add( + true, + false, + new string[] { "localhost" }, + new IPAddress[] { IPAddress.Loopback }, + Array.Empty()); + + data.Add( + false, + true, + new string[] { "localhost" }, + Array.Empty(), + new IPNetwork[] { new IPNetwork(IPAddress.IPv6Loopback, 128) }); + + data.Add( + true, + true, + new string[] { "localhost" }, + new IPAddress[] { IPAddress.Loopback.MapToIPv6() }, + new IPNetwork[] { new IPNetwork(IPAddress.IPv6Loopback, 128) }); + return data; + } + + [Theory] + [MemberData(nameof(TestNetworks_TestData))] + public void TestNetworks(bool ip4, bool ip6, string[] hostList, IPAddress[] knownProxies, IPNetwork[] knownNetworks) + { + using var nm = CreateNetworkManager(); + + var settings = new NetworkConfiguration + { + EnableIPV4 = ip4, + EnableIPV6 = ip6 + }; + + ForwardedHeadersOptions options = new ForwardedHeadersOptions(); + + // Need this here as ::1 and 127.0.0.1 are in them by default. + options.KnownProxies.Clear(); + options.KnownNetworks.Clear(); + + ApiServiceCollectionExtensions.AddProxyAddresses(settings, hostList, options); + + Assert.Equal(knownProxies.Length, options.KnownProxies.Count); + foreach (var item in knownProxies) + { + Assert.True(options.KnownProxies.Contains(item)); + } + + Assert.Equal(knownNetworks.Length, options.KnownNetworks.Count); + foreach (var item in knownNetworks) + { + Assert.NotNull(options.KnownNetworks.FirstOrDefault(x => x.Prefix.Equals(item.Prefix) && x.PrefixLength == item.PrefixLength)); + } + } + + private static IConfigurationManager GetMockConfig(NetworkConfiguration conf) + { + var configManager = new Mock + { + CallBase = true + }; + configManager.Setup(x => x.GetConfiguration(It.IsAny())).Returns(conf); + return configManager.Object; + } + + private static NetworkManager CreateNetworkManager() + { + var conf = new NetworkConfiguration() + { + EnableIPV6 = true, + EnableIPV4 = true, + }; + + return new NetworkManager(GetMockConfig(conf), new NullLogger()); + } + } +} diff --git a/tests/Jellyfin.Server.Tests/UrlDecodeQueryFeatureTests.cs b/tests/Jellyfin.Server.Tests/UrlDecodeQueryFeatureTests.cs new file mode 100644 index 0000000000..419afb2dc4 --- /dev/null +++ b/tests/Jellyfin.Server.Tests/UrlDecodeQueryFeatureTests.cs @@ -0,0 +1,31 @@ +using System.Collections.Generic; +using System.Linq; +using Jellyfin.Server.Middleware; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Features; +using Microsoft.Extensions.Primitives; +using Xunit; + +namespace Jellyfin.Server.Tests +{ + public static class UrlDecodeQueryFeatureTests + { + [Theory] + [InlineData("e0a72cb2a2c7", "e0a72cb2a2c7")] // isn't encoded + [InlineData("random+test", "random test")] // encoded + [InlineData("random%20test", "random test")] // encoded + [InlineData("++", " ")] // encoded + public static void EmptyValueTest(string query, string key) + { + var dict = new Dictionary + { + { query, StringValues.Empty } + }; + var test = new UrlDecodeQueryFeature(new QueryFeature(new QueryCollection(dict))); + Assert.Single(test.Query); + var (k, v) = test.Query.First(); + Assert.Equal(key, k); + Assert.Empty(v); + } + } +} diff --git a/tests/Jellyfin.XbmcMetadata.Tests/Jellyfin.XbmcMetadata.Tests.csproj b/tests/Jellyfin.XbmcMetadata.Tests/Jellyfin.XbmcMetadata.Tests.csproj new file mode 100644 index 0000000000..e085907583 --- /dev/null +++ b/tests/Jellyfin.XbmcMetadata.Tests/Jellyfin.XbmcMetadata.Tests.csproj @@ -0,0 +1,35 @@ + + + + net5.0 + false + ../jellyfin-tests.ruleset + + + + + PreserveNewest + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/Jellyfin.XbmcMetadata.Tests/Location/MovieNfoLocationTests.cs b/tests/Jellyfin.XbmcMetadata.Tests/Location/MovieNfoLocationTests.cs new file mode 100644 index 0000000000..8019e0ab39 --- /dev/null +++ b/tests/Jellyfin.XbmcMetadata.Tests/Location/MovieNfoLocationTests.cs @@ -0,0 +1,65 @@ +using System; +using System.Linq; +using MediaBrowser.Controller.Entities.Movies; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.Entities; +using MediaBrowser.XbmcMetadata.Savers; +using Xunit; + +namespace Jellyfin.XbmcMetadata.Tests.Location +{ + public class MovieNfoLocationTests + { + [Fact] + public static void Movie_MixedFolder_Success() + { + var movie = new Movie() { Path = "/media/movies/Avengers Endgame.mp4", IsInMixedFolder = true }; + + var paths = MovieNfoSaver.GetMovieSavePaths(new ItemInfo(movie)).ToArray(); + Assert.Single(paths); + Assert.Contains("/media/movies/Avengers Endgame.nfo", paths); + } + + [Fact] + public static void Movie_SeparateFolder_Success() + { + var movie = new Movie() { Path = "/media/movies/Avengers Endgame/Avengers Endgame.mp4" }; + var path1 = "/media/movies/Avengers Endgame/Avengers Endgame.nfo"; + var path2 = "/media/movies/Avengers Endgame/movie.nfo"; + + // uses ContainingFolderPath which uses Operating system specific paths + if (OperatingSystem.IsWindows()) + { + movie.Path = movie.Path.Replace('/', '\\'); + path1 = path1.Replace('/', '\\'); + path2 = path2.Replace('/', '\\'); + } + + var paths = MovieNfoSaver.GetMovieSavePaths(new ItemInfo(movie)).ToArray(); + Assert.Equal(2, paths.Length); + Assert.Contains(path1, paths); + Assert.Contains(path2, paths); + } + + [Fact] + public void Movie_DVD_Success() + { + var movie = new Movie() { Path = "/media/movies/Avengers Endgame", VideoType = VideoType.Dvd }; + var path1 = "/media/movies/Avengers Endgame/Avengers Endgame.nfo"; + var path2 = "/media/movies/Avengers Endgame/VIDEO_TS/VIDEO_TS.nfo"; + + // uses ContainingFolderPath which uses Operating system specific paths + if (OperatingSystem.IsWindows()) + { + movie.Path = movie.Path.Replace('/', '\\'); + path1 = path1.Replace('/', '\\'); + path2 = path2.Replace('/', '\\'); + } + + var paths = MovieNfoSaver.GetMovieSavePaths(new ItemInfo(movie)).ToArray(); + Assert.Equal(2, paths.Length); + Assert.Contains(path1, paths); + Assert.Contains(path2, paths); + } + } +} diff --git a/tests/Jellyfin.XbmcMetadata.Tests/Parsers/EpisodeNfoProviderTests.cs b/tests/Jellyfin.XbmcMetadata.Tests/Parsers/EpisodeNfoProviderTests.cs new file mode 100644 index 0000000000..3e726f23d9 --- /dev/null +++ b/tests/Jellyfin.XbmcMetadata.Tests/Parsers/EpisodeNfoProviderTests.cs @@ -0,0 +1,144 @@ +using System; +using System.Linq; +using System.Threading; +using MediaBrowser.Common.Configuration; +using MediaBrowser.Controller.Entities.TV; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.Configuration; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.Providers; +using MediaBrowser.Providers.Movies; +using MediaBrowser.XbmcMetadata.Parsers; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Xunit; + +namespace Jellyfin.XbmcMetadata.Tests.Parsers +{ + public class EpisodeNfoProviderTests + { + private readonly EpisodeNfoParser _parser; + + public EpisodeNfoProviderTests() + { + var providerManager = new Mock(); + + var imdbExternalId = new ImdbExternalId(); + var externalIdInfo = new ExternalIdInfo(imdbExternalId.ProviderName, imdbExternalId.Key, imdbExternalId.Type, imdbExternalId.UrlFormatString); + + providerManager.Setup(x => x.GetExternalIdInfos(It.IsAny())) + .Returns(new[] { externalIdInfo }); + + var config = new Mock(); + config.Setup(x => x.GetConfiguration(It.IsAny())) + .Returns(new XbmcMetadataOptions()); + var user = new Mock(); + var userData = new Mock(); + var directoryService = new Mock(); + + _parser = new EpisodeNfoParser( + new NullLogger(), + config.Object, + providerManager.Object, + user.Object, + userData.Object, + directoryService.Object); + } + + [Fact] + public void Fetch_Valid_Success() + { + var result = new MetadataResult() + { + Item = new Episode() + }; + + _parser.Fetch(result, "Test Data/The Bone Orchard.nfo", CancellationToken.None); + + var item = result.Item; + Assert.Equal("The Bone Orchard", item.Name); + Assert.Equal("American Gods", item.SeriesName); + Assert.Equal(1, item.IndexNumber); + Assert.Equal(1, item.ParentIndexNumber); + Assert.Equal("When Shadow Moon is released from prison early after the death of his wife, he meets Mr. Wednesday and is recruited as his bodyguard. Shadow discovers that this may be more than he bargained for.", item.Overview); + Assert.Equal(0, item.RunTimeTicks); + Assert.Equal("16", item.OfficialRating); + Assert.Contains("Drama", item.Genres); + Assert.Contains("Mystery", item.Genres); + Assert.Contains("Sci-Fi & Fantasy", item.Genres); + Assert.Equal(new DateTime(2017, 4, 30), item.PremiereDate); + Assert.Equal(2017, item.ProductionYear); + Assert.Single(item.Studios); + Assert.Contains("Starz", item.Studios); + Assert.Equal(1, item.IndexNumberEnd); + Assert.Equal(2, item.AirsAfterSeasonNumber); + Assert.Equal(3, item.AirsBeforeSeasonNumber); + Assert.Equal(1, item.AirsBeforeEpisodeNumber); + Assert.Equal("tt5017734", item.ProviderIds[MetadataProvider.Imdb.ToString()]); + Assert.Equal("1276153", item.ProviderIds[MetadataProvider.Tmdb.ToString()]); + + // Credits + var writers = result.People.Where(x => x.Type == PersonType.Writer).ToArray(); + Assert.Equal(2, writers.Length); + Assert.Contains("Bryan Fuller", writers.Select(x => x.Name)); + Assert.Contains("Michael Green", writers.Select(x => x.Name)); + + // Direcotrs + var directors = result.People.Where(x => x.Type == PersonType.Director).ToArray(); + Assert.Single(directors); + Assert.Contains("David Slade", directors.Select(x => x.Name)); + + // Actors + var actors = result.People.Where(x => x.Type == PersonType.Actor).ToArray(); + Assert.Equal(11, actors.Length); + // Only test one actor + var shadow = actors.FirstOrDefault(x => x.Role.Equals("Shadow Moon", StringComparison.Ordinal)); + Assert.NotNull(shadow); + Assert.Equal("Ricky Whittle", shadow!.Name); + Assert.Equal(0, shadow!.SortOrder); + Assert.Equal("http://image.tmdb.org/t/p/original/cjeDbVfBp6Qvb3C74Dfy7BKDTQN.jpg", shadow!.ImageUrl); + + Assert.Equal(new DateTime(2017, 10, 7, 14, 25, 47), item.DateCreated); + } + + [Fact] + public void Fetch_Valid_MultiEpisode_Success() + { + var result = new MetadataResult() + { + Item = new Episode() + }; + + _parser.Fetch(result, "Test Data/Rising.nfo", CancellationToken.None); + + var item = result.Item; + Assert.Equal("Rising (1)", item.Name); + Assert.Equal(1, item.IndexNumber); + Assert.Equal(2, item.IndexNumberEnd); + Assert.Equal(1, item.ParentIndexNumber); + Assert.Equal("A new Stargate team embarks on a dangerous mission to a distant galaxy, where they discover a mythical lost city -- and a deadly new enemy.", item.Overview); + Assert.Equal(new DateTime(2004, 7, 16), item.PremiereDate); + Assert.Equal(2004, item.ProductionYear); + } + + [Fact] + public void Fetch_WithNullItem_ThrowsArgumentException() + { + var result = new MetadataResult(); + + Assert.Throws(() => _parser.Fetch(result, "Test Data/The Bone Orchard.nfo", CancellationToken.None)); + } + + [Fact] + public void Fetch_NullResult_ThrowsArgumentException() + { + var result = new MetadataResult() + { + Item = new Episode() + }; + + Assert.Throws(() => _parser.Fetch(result, string.Empty, CancellationToken.None)); + } + } +} diff --git a/tests/Jellyfin.XbmcMetadata.Tests/Parsers/MovieNfoParserTests.cs b/tests/Jellyfin.XbmcMetadata.Tests/Parsers/MovieNfoParserTests.cs new file mode 100644 index 0000000000..cbcce73eb6 --- /dev/null +++ b/tests/Jellyfin.XbmcMetadata.Tests/Parsers/MovieNfoParserTests.cs @@ -0,0 +1,244 @@ +using System; +using System.Linq; +using System.Threading; +using Jellyfin.Data.Entities; +using MediaBrowser.Common.Configuration; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.Movies; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.Configuration; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.IO; +using MediaBrowser.Model.Providers; +using MediaBrowser.Model.System; +using MediaBrowser.Providers.Plugins.Tmdb.Movies; +using MediaBrowser.XbmcMetadata.Parsers; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Xunit; + +namespace Jellyfin.XbmcMetadata.Tests.Parsers +{ + public class MovieNfoParserTests + { + private readonly MovieNfoParser _parser; + private readonly IUserDataManager _userDataManager; + private readonly User _testUser; + private readonly FileSystemMetadata _localImageFileMetadata; + + public MovieNfoParserTests() + { + _testUser = new User("Test User", "Auth provider", "Reset provider"); + + var providerManager = new Mock(); + + var tmdbExternalId = new TmdbMovieExternalId(); + var externalIdInfo = new ExternalIdInfo(tmdbExternalId.ProviderName, tmdbExternalId.Key, tmdbExternalId.Type, tmdbExternalId.UrlFormatString); + + providerManager.Setup(x => x.GetExternalIdInfos(It.IsAny())) + .Returns(new[] { externalIdInfo }); + + var nfoConfig = new XbmcMetadataOptions() + { + UserId = "F38E6443-090B-4F7A-BD12-9CFF5020F7BC" + }; + var configManager = new Mock(); + configManager.Setup(x => x.GetConfiguration(It.IsAny())) + .Returns(nfoConfig); + + var user = new Mock(); + user.Setup(x => x.GetUserById(It.IsAny())) + .Returns(_testUser); + + var userData = new Mock(); + userData.Setup(x => x.GetUserData(_testUser, It.IsAny())) + .Returns(new UserItemData()); + + var directoryService = new Mock(); + _localImageFileMetadata = new FileSystemMetadata() + { + Exists = true, + FullName = OperatingSystem.IsWindows() ? + "C:\\media\\movies\\Justice League (2017).jpg" + : "/media/movies/Justice League (2017).jpg" + }; + directoryService.Setup(x => x.GetFile(_localImageFileMetadata.FullName)) + .Returns(_localImageFileMetadata); + + _userDataManager = userData.Object; + _parser = new MovieNfoParser( + new NullLogger(), + configManager.Object, + providerManager.Object, + user.Object, + userData.Object, + directoryService.Object); + } + + [Fact] + public void Fetch_Valid_Success() + { + var result = new MetadataResult