diff --git a/.ci/azure-pipelines-abi.yml b/.ci/azure-pipelines-abi.yml index 8d0737b66c..cf74a4201b 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: 5.0.103 + default: 6.0.x jobs: - job: CompatibilityCheck diff --git a/.ci/azure-pipelines-api-client.yml b/.ci/azure-pipelines-api-client.yml deleted file mode 100644 index 0e944e6f47..0000000000 --- a/.ci/azure-pipelines-api-client.yml +++ /dev/null @@ -1,59 +0,0 @@ -parameters: - - name: LinuxImage - type: string - default: "ubuntu-latest" - - name: GeneratorVersion - type: string - default: "5.0.1" - -jobs: -- job: GenerateApiClients - displayName: 'Generate Api Clients' - condition: startsWith(variables['Build.SourceBranch'], 'refs/tags/v') - 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 - - task: CmdLine@2 - displayName: 'Build stable typescript axios client' - 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 - - task: Npm@1 - displayName: 'Publish stable typescript axios client' - inputs: - command: custom - customCommand: publish --access public - 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 4bc72f9eb0..b7112ba245 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: 5.0.103 + DotNetSdkVersion: 6.0.x jobs: - job: Build @@ -91,3 +91,10 @@ jobs: inputs: targetPath: '$(build.ArtifactStagingDirectory)/Jellyfin.Server/MediaBrowser.Common.dll' artifactName: 'Jellyfin.Common' + + - task: PublishPipelineArtifact@1 + displayName: 'Publish Artifact Extensions' + condition: and(succeeded(), eq(variables['BuildConfiguration'], 'Release')) + inputs: + targetPath: '$(build.ArtifactStagingDirectory)/Jellyfin.Server/Jellyfin.Extensions.dll' + artifactName: 'Jellyfin.Extensions' diff --git a/.ci/azure-pipelines-package.yml b/.ci/azure-pipelines-package.yml index 543fd7fc6d..81693452f3 100644 --- a/.ci/azure-pipelines-package.yml +++ b/.ci/azure-pipelines-package.yml @@ -181,7 +181,7 @@ jobs: inputs: sshEndpoint: repository runOptions: 'commands' - commands: nohup sudo /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) $(Build.SourceBranch) & - job: PublishNuget displayName: 'Publish NuGet packages' @@ -195,10 +195,10 @@ jobs: steps: - task: UseDotNet@2 - displayName: 'Use .NET 5.0 sdk' + displayName: 'Use .NET 6.0 sdk' inputs: packageType: 'sdk' - version: '5.0.x' + version: '6.0.x' - task: DotNetCoreCLI@2 displayName: 'Build Stable Nuget packages' @@ -211,6 +211,7 @@ jobs: MediaBrowser.Controller/MediaBrowser.Controller.csproj MediaBrowser.Model/MediaBrowser.Model.csproj Emby.Naming/Emby.Naming.csproj + src/Jellyfin.Extensions/Jellyfin.Extensions.csproj custom: 'pack' arguments: -o $(Build.ArtifactStagingDirectory) -p:Version=$(JellyfinVersion) @@ -225,6 +226,7 @@ jobs: MediaBrowser.Controller/MediaBrowser.Controller.csproj MediaBrowser.Model/MediaBrowser.Model.csproj Emby.Naming/Emby.Naming.csproj + src/Jellyfin.Extensions/Jellyfin.Extensions.csproj custom: 'pack' arguments: '--version-suffix $(Build.BuildNumber) -o $(Build.ArtifactStagingDirectory) -p:Stability=Unstable' diff --git a/.ci/azure-pipelines-test.yml b/.ci/azure-pipelines-test.yml index 7838b3b026..cc94dc2c5a 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: 5.0.103 + default: 6.0.x jobs: - job: Test @@ -94,5 +94,5 @@ jobs: displayName: 'Publish OpenAPI Artifact' condition: and(succeeded(), eq(variables['Agent.OS'], 'Linux')) inputs: - targetPath: "tests/Jellyfin.Server.Integration.Tests/bin/Release/net5.0/openapi.json" + targetPath: "tests/Jellyfin.Server.Integration.Tests/bin/Release/net6.0/openapi.json" artifactName: 'OpenAPI Spec' diff --git a/.ci/azure-pipelines.yml b/.ci/azure-pipelines.yml index 6430503f9a..19c9caacbb 100644 --- a/.ci/azure-pipelines.yml +++ b/.ci/azure-pipelines.yml @@ -5,8 +5,6 @@ variables: value: 'tests/**/*Tests.csproj' - name: RestoreBuildProjects value: 'Jellyfin.Server/Jellyfin.Server.csproj' -- name: DotNetSdkVersion - value: 5.0.103 pr: autoCancel: true @@ -57,10 +55,10 @@ jobs: Common: NugetPackageName: Jellyfin.Common AssemblyFileName: MediaBrowser.Common.dll + Extensions: + NugetPackageName: Jellyfin.Extensions + AssemblyFileName: Jellyfin.Extensions.dll LinuxImage: 'ubuntu-latest' - ${{ 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/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md deleted file mode 100644 index d67e1c98bf..0000000000 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ /dev/null @@ -1,43 +0,0 @@ ---- -name: Bug report -about: Create a bug report -title: '' -labels: bug -assignees: '' - ---- - -**Describe the bug** - - -**System (please complete the following information):** - - OS: [e.g. Debian, Windows] - - Virtualization: [e.g. Docker, KVM, LXC] - - Clients: [Browser, Android, Fire Stick, etc.] - - 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] - - Installed Plugins: [e.g. none, Fanart, Anime, etc.] - - Reverse Proxy: [e.g. none, nginx, apache, etc.] - - Base URL: [e.g. none, yes: /example] - - Networking: [e.g. Host, Bridge/NAT] - - Storage: [e.g. local, NFS, cloud] - -**To Reproduce** - -1. Go to '...' -2. Click on '....' -3. Scroll down to '....' -4. See error - -**Expected behavior** - - -**Logs** - - -**Screenshots** - - -**Additional context** - diff --git a/.github/ISSUE_TEMPLATE/issue report.yml b/.github/ISSUE_TEMPLATE/issue report.yml new file mode 100644 index 0000000000..63e0f0e22d --- /dev/null +++ b/.github/ISSUE_TEMPLATE/issue report.yml @@ -0,0 +1,106 @@ +name: Issue Report +description: File an issue report +title: "[Issue]: " +labels: [bug, triage] +body: + - type: markdown + attributes: + value: | + Thanks for taking the time to fill out this bug report! Please provide as much detail as necessary, most questions may not be applicable to you. If you need real-time help, join us on [Matrix](https://matrix.to/#/#jellyfin-troubleshooting:matrix.org) or [Discord](https://discord.gg/zHBxVSXdBV). + - type: textarea + id: what-happened + attributes: + label: Please describe your bug + description: Also tell us, what did you expect to happen? + placeholder: | + The more information that you are able to provide, the better. Did you do anything before this happened? Did you upgrade or change anything? Any screenshots or logs you can provide will be helpful. + + This is my issue. + + Steps to Reproduce + 1. In this environment... + 2. With this config... + 3. Run '...' + 4. See error... + validations: + required: true + - type: dropdown + id: version + attributes: + label: Jellyfin Version + description: What version of Jellyfin are you running? + options: + - 10.7.7 + - 10.7.z + - 10.6.4 + - Other + validations: + required: true + - type: input + id: version-other + attributes: + label: "if other:" + placeholder: Other + - type: textarea + attributes: + label: Environment + description: | + Examples: + - **OS**: [e.g. Debian, Windows] + - **Virtualization**: [e.g. Docker, KVM, LXC] + - **Clients**: [Browser, Android, Fire Stick, etc.] + - **Browser**: [e.g. Firefox 91, Chrome 93, Safari 13] + - **FFmpeg Version**: [e.g. 4.3.2-Jellyfin] + - **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] + - **Networking**: [e.g. Host, Bridge/NAT] + - **Storage**: [e.g. local, NFS, cloud] + value: | + - OS: + - Virtualization: + - Clients: + - Browser: + - FFmpeg Version: + - Playback Method: + - Hardware Acceleration: + - Plugins: + - Reverse Proxy: + - Base URL: + - Networking: + - Storage: + render: markdown + - type: textarea + id: logs + attributes: + label: Jellyfin logs + description: Please copy and paste any relevant log output. This can be found in Dashboard > Logs. + placeholder: For playback issues, browser/client and FFmpeg logs may be more useful. + render: shell + - type: textarea + id: ffmpeg-logs + attributes: + label: FFmpeg logs + description: Please copy and paste any relevant log output. This can be found in Dashboard > Logs. + placeholder: It's important to include the specific codec details. If no FFmpeg logs appear, the file was Direct Played and did not use FFmpeg. + render: shell + - type: textarea + id: browserlogs + attributes: + label: Please attach any browser or client logs here + placeholder: Access browser logs by using the F12 to bring up the console. Screenshots are typically easier to read than raw logs. For clients such as Android or iOS, please see our documentation. + - type: textarea + id: screenshots + attributes: + label: Please attach any screenshots here + placeholder: Images can be pasted directly into the textbox and will be hosted by github. + - type: checkboxes + id: terms + attributes: + label: Code of Conduct + description: By submitting this issue, you agree to follow our [Code of Conduct](https://jellyfin.org/docs/general/community-standards.html#code-of-conduct) + options: + - label: I agree to follow this project's Code of Conduct + required: true 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 index db34693cc7..20294843d5 100644 --- a/.github/workflows/automation.yml +++ b/.github/workflows/automation.yml @@ -1,46 +1,56 @@ name: Automation on: - pull_request: - issues: + push: + branches: + - master + pull_request_target: issue_comment: jobs: - main: + label: + name: Labeling runs-on: ubuntu-latest + if: ${{ github.repository == 'jellyfin/jellyfin' }} steps: - - name: Does PR has the stable backport label? - uses: Dreamcodeio/does-pr-has-label@v1.2 - id: checkLabel + - name: Apply label + uses: eps1lon/actions-label-merge-conflict@v2.0.1 + if: ${{ github.event_name == 'push' || github.event_name == 'pull_request_target'}} with: - label: stable backport + 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.5.1 - if: (github.event.pull_request || github.event.issue.pull_request) && !steps.checkLabel.outputs.hasLabel + 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.GH_TOKEN }} + repo-token: ${{ secrets.JF_BOT_TOKEN }} - name: Add to 'Release Next' project - uses: alex-page/github-project-automation-plus@v0.5.1 + 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.GH_TOKEN }} + repo-token: ${{ secrets.JF_BOT_TOKEN }} - name: Add to 'Current Release' project - uses: alex-page/github-project-automation-plus@v0.5.1 - if: (github.event.pull_request || github.event.issue.pull_request) && steps.checkLabel.outputs.hasLabel + 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.GH_TOKEN }} + 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' @@ -48,19 +58,19 @@ jobs: run: echo "::set-output name=number::$(curl -s ${{ github.event.issue.comments_url }} | jq '.[] | select(.author_association == "MEMBER") | .author_association' | wc -l)" - name: Move issue to needs triage - uses: alex-page/github-project-automation-plus@v0.5.1 + 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.GH_TOKEN }} + repo-token: ${{ secrets.JF_BOT_TOKEN }} - name: Add issue to triage project - uses: alex-page/github-project-automation-plus@v0.5.1 + 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.GH_TOKEN }} + repo-token: ${{ secrets.JF_BOT_TOKEN }} diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 3e456f9093..ea1d30cdfa 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -24,7 +24,8 @@ jobs: - name: Setup .NET Core uses: actions/setup-dotnet@v1 with: - dotnet-version: '5.0.x' + dotnet-version: '6.0.x' + - name: Initialize CodeQL uses: github/codeql-action/init@v1 with: diff --git a/.github/workflows/commands.yml b/.github/workflows/commands.yml new file mode 100644 index 0000000000..af4d8beb93 --- /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.5 + 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/.github/workflows/merge-conflicts.yml b/.github/workflows/merge-conflicts.yml deleted file mode 100644 index ce808617a1..0000000000 --- a/.github/workflows/merge-conflicts.yml +++ /dev/null @@ -1,17 +0,0 @@ -name: 'Merge Conflicts' - -on: - push: - branches: - - master - pull_request_target: - types: - - synchronize -jobs: - triage: - runs-on: ubuntu-latest - steps: - - uses: eps1lon/actions-label-merge-conflict@v2.0.1 - with: - dirtyLabel: 'merge conflict' - repoToken: ${{ secrets.GH_TOKEN }} diff --git a/.github/workflows/openapi.yml b/.github/workflows/openapi.yml new file mode 100644 index 0000000000..3e93468401 --- /dev/null +++ b/.github/workflows/openapi.yml @@ -0,0 +1,124 @@ +name: OpenAPI +on: + push: + branches: + - master + pull_request_target: + +jobs: + openapi-head: + name: OpenAPI - HEAD + runs-on: ubuntu-latest + permissions: read-all + steps: + - name: Checkout repository + uses: actions/checkout@v2 + with: + ref: ${{ github.event.pull_request.head.ref }} + repository: ${{ github.event.pull_request.head.repo.full_name }} + - name: Setup .NET Core + uses: actions/setup-dotnet@v1 + with: + dotnet-version: '6.0.x' + - name: Generate openapi.json + run: dotnet test tests/Jellyfin.Server.Integration.Tests/Jellyfin.Server.Integration.Tests.csproj -c Release --filter "Jellyfin.Server.Integration.Tests.OpenApiSpecTests" + - name: Upload openapi.json + uses: actions/upload-artifact@v2 + with: + name: openapi-head + retention-days: 14 + if-no-files-found: error + path: tests/Jellyfin.Server.Integration.Tests/bin/Release/net6.0/openapi.json + + openapi-base: + name: OpenAPI - BASE + if: ${{ github.base_ref != '' }} + runs-on: ubuntu-latest + permissions: read-all + steps: + - name: Checkout repository + uses: actions/checkout@v2 + with: + ref: ${{ github.base_ref }} + - name: Setup .NET Core + uses: actions/setup-dotnet@v1 + with: + dotnet-version: '6.0.x' + - name: Generate openapi.json + run: dotnet test tests/Jellyfin.Server.Integration.Tests/Jellyfin.Server.Integration.Tests.csproj -c Release --filter "Jellyfin.Server.Integration.Tests.OpenApiSpecTests" + - name: Upload openapi.json + uses: actions/upload-artifact@v2 + with: + name: openapi-base + retention-days: 14 + if-no-files-found: error + path: tests/Jellyfin.Server.Integration.Tests/bin/Release/net6.0/openapi.json + + openapi-diff: + name: OpenAPI - Difference + if: ${{ github.event_name == 'pull_request_target' }} + runs-on: ubuntu-latest + needs: + - openapi-head + - openapi-base + steps: + - name: Download openapi-head + uses: actions/download-artifact@v2 + with: + name: openapi-head + path: openapi-head + - name: Download openapi-base + uses: actions/download-artifact@v2 + with: + name: openapi-base + path: openapi-base + - name: Workaround openapi-diff issue + run: | + sed -i 's/"allOf"/"oneOf"/g' openapi-head/openapi.json + sed -i 's/"allOf"/"oneOf"/g' openapi-base/openapi.json + - name: Calculate OpenAPI difference + uses: docker://openapitools/openapi-diff + continue-on-error: true + with: + args: --fail-on-changed --markdown openapi-changes.md openapi-base/openapi.json openapi-head/openapi.json + - id: read-diff + name: Read openapi-diff output + run: | + body=$(cat openapi-changes.md) + body="${body//'%'/'%25'}" + body="${body//$'\n'/'%0A'}" + body="${body//$'\r'/'%0D'}" + echo ::set-output name=body::$body + - name: Find difference comment + uses: peter-evans/find-comment@v1 + id: find-comment + with: + issue-number: ${{ github.event.pull_request.number }} + direction: last + body-includes: openapi-diff-workflow-comment + - name: Reply or edit difference comment (changed) + uses: peter-evans/create-or-update-comment@v1.4.5 + if: ${{ steps.read-diff.outputs.body != '' }} + with: + issue-number: ${{ github.event.pull_request.number }} + comment-id: ${{ steps.find-comment.outputs.comment-id }} + edit-mode: replace + body: | + +
+ Changes in OpenAPI specification found. Expand to see details. + + ${{ steps.read-diff.outputs.body }} + +
+ - name: Edit difference comment (unchanged) + uses: peter-evans/create-or-update-comment@v1.4.5 + if: ${{ steps.read-diff.outputs.body == '' && steps.find-comment.outputs.comment-id != '' }} + with: + issue-number: ${{ github.event.pull_request.number }} + comment-id: ${{ steps.find-comment.outputs.comment-id }} + edit-mode: replace + body: | + + + No changes to OpenAPI specification found. See history of this comment for previous changes. diff --git a/.github/workflows/rebase.yml b/.github/workflows/rebase.yml deleted file mode 100644 index 3172ec0d9f..0000000000 --- a/.github/workflows/rebase.yml +++ /dev/null @@ -1,27 +0,0 @@ -name: Automatic Rebase -on: - issue_comment: - -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.GH_TOKEN }} - comment-id: ${{ github.event.comment.id }} - reactions: '+1' - - - name: Checkout the latest code - uses: actions/checkout@v2 - with: - token: ${{ secrets.GH_TOKEN }} - fetch-depth: 0 - - - name: Automatic Rebase - uses: cirrus-actions/rebase@1.4 - env: - GITHUB_TOKEN: ${{ secrets.GH_TOKEN }} diff --git a/.gitignore b/.gitignore index 7cd3d0068a..c2ae76c1e3 100644 --- a/.gitignore +++ b/.gitignore @@ -268,6 +268,7 @@ doc/ # Deployment artifacts dist *.exe +*.dll # BenchmarkDotNet artifacts BenchmarkDotNet.Artifacts @@ -277,3 +278,6 @@ web/ web-src.* MediaBrowser.WebDashboard/jellyfin-web apiclient/generated + +# Omnisharp crash logs +mono_crash.*.json diff --git a/.vscode/launch.json b/.vscode/launch.json index e55ea22485..b82956a721 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/net5.0/jellyfin.dll", + "program": "${workspaceFolder}/Jellyfin.Server/bin/Debug/net6.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/net5.0/jellyfin.dll", + "program": "${workspaceFolder}/Jellyfin.Server/bin/Debug/net6.0/jellyfin.dll", "args": ["--nowebclient"], "cwd": "${workspaceFolder}/Jellyfin.Server", "console": "internalConsole", diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index 7a763a46c1..5c4a031bc1 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -46,6 +46,7 @@ - [fruhnow](https://github.com/fruhnow) - [geilername](https://github.com/geilername) - [gnattu](https://github.com/gnattu) + - [GodTamIt](https://github.com/GodTamIt) - [grafixeyehero](https://github.com/grafixeyehero) - [h1nk](https://github.com/h1nk) - [hawken93](https://github.com/hawken93) @@ -70,6 +71,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) @@ -110,7 +112,7 @@ - [sorinyo2004](https://github.com/sorinyo2004) - [sparky8251](https://github.com/sparky8251) - [spookbits](https://github.com/spookbits) - - [ssenart] (https://github.com/ssenart) + - [ssenart](https://github.com/ssenart) - [stanionascu](https://github.com/stanionascu) - [stevehayles](https://github.com/stevehayles) - [SuperSandro2000](https://github.com/SuperSandro2000) @@ -146,6 +148,8 @@ - [nielsvanvelzen](https://github.com/nielsvanvelzen) - [skyfrk](https://github.com/skyfrk) - [ianjazz246](https://github.com/ianjazz246) + - [peterspenler](https://github.com/peterspenler) + - [MBR-0001](https://github.com/MBR-0001) # Emby Contributors @@ -210,3 +214,5 @@ - [Tim Hobbs](https://github.com/timhobbs) - [SvenVandenbrande](https://github.com/SvenVandenbrande) - [olsh](https://github.com/olsh) + - [lbenini](https://github.com/lbenini) + - [gnuyent](https://github.com/gnuyent) diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000000..d243cde2b7 --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,17 @@ + + + + + enable + $(MSBuildThisFileDirectory)/jellyfin.ruleset + + + + true + + + + AllEnabledByDefault + + + diff --git a/Dockerfile b/Dockerfile index ebe5eb00c6..e133c08193 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,22 +1,18 @@ -ARG DOTNET_VERSION=5.0 +# DESIGNED FOR BUILDING ON AMD64 ONLY +##################################### +# Requires binfm_misc registration +# https://github.com/multiarch/qemu-user-static#binfmt_misc-register +ARG DOTNET_VERSION=6.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-* \ - && npm ci --no-audit \ + && npm ci --no-audit --unsafe-perm \ && mv dist /dist -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 debian:buster-slim +FROM debian:stable-slim as app # https://askubuntu.com/questions/972516/debian-frontend-environment-variable ARG DEBIAN_FRONTEND="noninteractive" @@ -25,19 +21,17 @@ 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 +ARG GMMLIB_VERSION=21.2.1 +ARG IGC_VERSION=1.0.8517 +ARG NEO_VERSION=21.35.20826 +ARG LEVEL_ZERO_VERSION=1.2.20826 # Install dependencies: # mesa-va-drivers: needed for AMD VAAPI. Mesa >= 20.1 is required for HEVC transcoding. +# curl: healthcheck RUN apt-get update \ - && apt-get install --no-install-recommends --no-install-suggests -y ca-certificates gnupg wget apt-transport-https \ + && apt-get install --no-install-recommends --no-install-suggests -y ca-certificates gnupg wget apt-transport-https curl \ && wget -O - https://repo.jellyfin.org/jellyfin_team.gpg.key | apt-key add - \ && echo "deb [arch=$( dpkg --print-architecture )] https://repo.jellyfin.org/$( awk -F'=' '/^ID=/{ print $NF }' /etc/os-release ) $( awk -F'=' '/^VERSION_CODENAME=/{ print $NF }' /etc/os-release ) main" | tee /etc/apt/sources.list.d/jellyfin.list \ && apt-get update \ @@ -68,14 +62,32 @@ 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 -ENV DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1 +# 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 +# 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 + +ENV HEALTHCHECK_URL=http://localhost:8096/health + +COPY --from=builder /jellyfin /jellyfin +COPY --from=web-builder /dist /jellyfin/jellyfin-web + EXPOSE 8096 VOLUME /cache /config /media ENTRYPOINT ["./jellyfin/jellyfin", \ "--datadir", "/config", \ "--cachedir", "/cache", \ "--ffmpeg", "/usr/lib/jellyfin-ffmpeg/ffmpeg"] + +HEALTHCHECK --interval=30s --timeout=30s --start-period=10s --retries=3 \ + CMD curl -Lk "${HEALTHCHECK_URL}" || exit 1 diff --git a/Dockerfile.arm b/Dockerfile.arm index d63dbee758..a46fa331df 100644 --- a/Dockerfile.arm +++ b/Dockerfile.arm @@ -1,31 +1,20 @@ -# DESIGNED FOR BUILDING ON AMD64 ONLY +# DESIGNED FOR BUILDING ON ARM ONLY ##################################### # Requires binfm_misc registration # https://github.com/multiarch/qemu-user-static#binfmt_misc-register -ARG DOTNET_VERSION=5.0 +ARG DOTNET_VERSION=6.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-* \ - && npm ci --no-audit \ + && npm ci --no-audit --unsafe-perm \ && mv dist /dist - -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 multiarch/qemu-user-static:x86_64-arm as qemu -FROM arm32v7/debian:buster-slim +FROM arm32v7/debian:stable-slim as app # https://askubuntu.com/questions/972516/debian-frontend-environment-variable ARG DEBIAN_FRONTEND="noninteractive" @@ -35,6 +24,8 @@ ARG APT_KEY_DONT_WARN_ON_DANGEROUS_USAGE=DontWarn ENV NVIDIA_DRIVER_CAPABILITIES="compute,video,utility" COPY --from=qemu /usr/bin/qemu-arm-static /usr/bin + +# curl: setup & healthcheck RUN apt-get update \ && apt-get install --no-install-recommends --no-install-suggests -y ca-certificates gnupg curl && \ curl -ks https://repo.jellyfin.org/debian/jellyfin_team.gpg.key | apt-key add - && \ @@ -53,7 +44,7 @@ RUN apt-get update \ vainfo \ libva2 \ locales \ - && apt-get remove curl gnupg -y \ + && apt-get remove gnupg -y \ && apt-get clean autoclean -y \ && apt-get autoremove -y \ && rm -rf /var/lib/apt/lists/* \ @@ -61,17 +52,33 @@ 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 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 + +ENV HEALTHCHECK_URL=http://localhost:8096/health + +COPY --from=builder /jellyfin /jellyfin +COPY --from=web-builder /dist /jellyfin/jellyfin-web + EXPOSE 8096 VOLUME /cache /config /media ENTRYPOINT ["./jellyfin/jellyfin", \ "--datadir", "/config", \ "--cachedir", "/cache", \ "--ffmpeg", "/usr/lib/jellyfin-ffmpeg/ffmpeg"] + +HEALTHCHECK --interval=30s --timeout=30s --start-period=10s --retries=3 \ + CMD curl -Lk "${HEALTHCHECK_URL}" || exit 1 diff --git a/Dockerfile.arm64 b/Dockerfile.arm64 index e95999f2a6..1279c47f8e 100644 --- a/Dockerfile.arm64 +++ b/Dockerfile.arm64 @@ -1,30 +1,20 @@ -# DESIGNED FOR BUILDING ON AMD64 ONLY +# DESIGNED FOR BUILDING ON ARM64 ONLY ##################################### # Requires binfm_misc registration # https://github.com/multiarch/qemu-user-static#binfmt_misc-register -ARG DOTNET_VERSION=5.0 +ARG DOTNET_VERSION=6.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-* \ - && npm ci --no-audit \ + && npm ci --no-audit --unsafe-perm \ && mv dist /dist - -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 multiarch/qemu-user-static:x86_64-aarch64 as qemu -FROM arm64v8/debian:buster-slim +FROM arm64v8/debian:stable-slim as app # https://askubuntu.com/questions/972516/debian-frontend-environment-variable ARG DEBIAN_FRONTEND="noninteractive" @@ -34,6 +24,8 @@ ARG APT_KEY_DONT_WARN_ON_DANGEROUS_USAGE=DontWarn ENV NVIDIA_DRIVER_CAPABILITIES="compute,video,utility" COPY --from=qemu /usr/bin/qemu-aarch64-static /usr/bin + +# curl: healcheck RUN apt-get update && apt-get install --no-install-recommends --no-install-suggests -y \ ffmpeg \ libssl-dev \ @@ -43,6 +35,7 @@ RUN apt-get update && apt-get install --no-install-recommends --no-install-sugge libomxil-bellagio0 \ libomxil-bellagio-bin \ locales \ + curl \ && apt-get clean autoclean -y \ && apt-get autoremove -y \ && rm -rf /var/lib/apt/lists/* \ @@ -50,17 +43,33 @@ 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 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 + +ENV HEALTHCHECK_URL=http://localhost:8096/health + +COPY --from=builder /jellyfin /jellyfin +COPY --from=web-builder /dist /jellyfin/jellyfin-web + EXPOSE 8096 VOLUME /cache /config /media ENTRYPOINT ["./jellyfin/jellyfin", \ "--datadir", "/config", \ "--cachedir", "/cache", \ "--ffmpeg", "/usr/bin/ffmpeg"] + +HEALTHCHECK --interval=30s --timeout=30s --start-period=10s --retries=3 \ + CMD curl -Lk "${HEALTHCHECK_URL}" || exit 1 diff --git a/DvdLib/DvdLib.csproj b/DvdLib/DvdLib.csproj index 7bbd9acf82..755d29160c 100644 --- a/DvdLib/DvdLib.csproj +++ b/DvdLib/DvdLib.csproj @@ -10,10 +10,11 @@ - net5.0 + net6.0 false true - true + AllDisabledByDefault + disable diff --git a/DvdLib/Ifo/Dvd.cs b/DvdLib/Ifo/Dvd.cs index b4a11ed5d6..7f8ece47dc 100644 --- a/DvdLib/Ifo/Dvd.cs +++ b/DvdLib/Ifo/Dvd.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; +using System.Globalization; using System.IO; using System.Linq; @@ -76,7 +77,7 @@ namespace DvdLib.Ifo private void ReadVTS(ushort vtsNum, IReadOnlyList allFiles) { - var filename = string.Format("VTS_{0:00}_0.IFO", vtsNum); + var filename = string.Format(CultureInfo.InvariantCulture, "VTS_{0:00}_0.IFO", vtsNum); var vtsPath = allFiles.FirstOrDefault(i => string.Equals(i.Name, filename, StringComparison.OrdinalIgnoreCase)) ?? allFiles.FirstOrDefault(i => string.Equals(i.Name, Path.ChangeExtension(filename, ".bup"), StringComparison.OrdinalIgnoreCase)); diff --git a/Emby.Dlna/Configuration/DlnaOptions.cs b/Emby.Dlna/Configuration/DlnaOptions.cs index e63a858605..91fac4bef5 100644 --- a/Emby.Dlna/Configuration/DlnaOptions.cs +++ b/Emby.Dlna/Configuration/DlnaOptions.cs @@ -72,7 +72,7 @@ namespace Emby.Dlna.Configuration /// /// Gets or sets the default user account that the dlna server uses. /// - public string DefaultUserId { get; set; } + public string? DefaultUserId { get; set; } /// /// Gets or sets a value indicating whether playTo device profiles should be created. 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/ContentDirectory/ContentDirectoryService.cs b/Emby.Dlna/ContentDirectory/ContentDirectoryService.cs index 2f3107450c..9020dea994 100644 --- a/Emby.Dlna/ContentDirectory/ContentDirectoryService.cs +++ b/Emby.Dlna/ContentDirectory/ContentDirectoryService.cs @@ -138,7 +138,7 @@ namespace Emby.Dlna.ContentDirectory /// /// The . /// The . - private User GetUser(DeviceProfile profile) + private User? GetUser(DeviceProfile profile) { if (!string.IsNullOrEmpty(profile.UserId)) { diff --git a/Emby.Dlna/ContentDirectory/ControlHandler.cs b/Emby.Dlna/ContentDirectory/ControlHandler.cs index 90ba601b4a..26a816107a 100644 --- a/Emby.Dlna/ContentDirectory/ControlHandler.cs +++ b/Emby.Dlna/ContentDirectory/ControlHandler.cs @@ -1,3 +1,5 @@ +#nullable disable + using System; using System.Collections.Generic; using System.Globalization; @@ -23,6 +25,7 @@ using MediaBrowser.Controller.MediaEncoding; using MediaBrowser.Controller.Playlists; using MediaBrowser.Controller.TV; using MediaBrowser.Model.Dlna; +using MediaBrowser.Model.Dto; using MediaBrowser.Model.Entities; using MediaBrowser.Model.Globalization; using MediaBrowser.Model.Querying; @@ -48,7 +51,6 @@ namespace Emby.Dlna.ContentDirectory private readonly ILibraryManager _libraryManager; private readonly IUserDataManager _userDataManager; - private readonly IServerConfigurationManager _config; private readonly User _user; private readonly IUserViewManager _userViewManager; private readonly ITVSeriesManager _tvSeriesManager; @@ -102,7 +104,6 @@ namespace Emby.Dlna.ContentDirectory _userViewManager = userViewManager; _tvSeriesManager = tvSeriesManager; _profile = profile; - _config = config; _didlBuilder = new DidlBuilder( profile, @@ -286,21 +287,14 @@ namespace Emby.Dlna.ContentDirectory /// The xml feature list. private static 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(); + return "" + + "" + + "" + + "" + + "" + + "" + + "" + + ""; } /// @@ -335,75 +329,73 @@ namespace Emby.Dlna.ContentDirectory int totalCount; + var settings = new XmlWriterSettings + { + Encoding = Encoding.UTF8, + CloseOutput = false, + OmitXmlDeclaration = true, + ConformanceLevel = ConformanceLevel.Fragment + }; + using (StringWriter builder = new StringWriterWithEncoding(Encoding.UTF8)) + using (var writer = XmlWriter.Create(builder, settings)) { - var settings = new XmlWriterSettings() - { - Encoding = Encoding.UTF8, - CloseOutput = false, - OmitXmlDeclaration = true, - ConformanceLevel = ConformanceLevel.Fragment - }; + writer.WriteStartElement(string.Empty, "DIDL-Lite", NsDidl); - using (var writer = XmlWriter.Create(builder, settings)) - { - writer.WriteStartElement(string.Empty, "DIDL-Lite", NsDidl); + writer.WriteAttributeString("xmlns", "dc", null, NsDc); + writer.WriteAttributeString("xmlns", "dlna", null, NsDlna); + writer.WriteAttributeString("xmlns", "upnp", null, NsUpnp); - writer.WriteAttributeString("xmlns", "dc", null, NsDc); - writer.WriteAttributeString("xmlns", "dlna", null, NsDlna); - writer.WriteAttributeString("xmlns", "upnp", null, NsUpnp); + DidlBuilder.WriteXmlRootAttributes(_profile, writer); - DidlBuilder.WriteXmlRootAttributes(_profile, writer); + var serverItem = GetItemFromObjectId(id); + var item = serverItem.Item; - var serverItem = GetItemFromObjectId(id); - var item = serverItem.Item; + if (string.Equals(flag, "BrowseMetadata", StringComparison.Ordinal)) + { + totalCount = 1; - if (string.Equals(flag, "BrowseMetadata", StringComparison.Ordinal)) + if (item.IsDisplayedAsFolder || serverItem.StubType.HasValue) { - totalCount = 1; - - if (item.IsDisplayedAsFolder || serverItem.StubType.HasValue) - { - var childrenResult = GetUserItems(item, serverItem.StubType, _user, sortCriteria, start, requestedCount); - - _didlBuilder.WriteFolderElement(writer, item, serverItem.StubType, null, childrenResult.TotalRecordCount, filter, id); - } - else - { - _didlBuilder.WriteItemElement(writer, item, _user, null, null, deviceId, filter); - } + var childrenResult = GetUserItems(item, serverItem.StubType, _user, sortCriteria, start, requestedCount); - provided++; + _didlBuilder.WriteFolderElement(writer, item, serverItem.StubType, null, childrenResult.TotalRecordCount, filter, id); } else { - var childrenResult = GetUserItems(item, serverItem.StubType, _user, sortCriteria, start, requestedCount); - totalCount = childrenResult.TotalRecordCount; + _didlBuilder.WriteItemElement(writer, item, _user, null, null, deviceId, filter); + } + + provided++; + } + else + { + var childrenResult = GetUserItems(item, serverItem.StubType, _user, sortCriteria, start, requestedCount); + totalCount = childrenResult.TotalRecordCount; - provided = childrenResult.Items.Count; + provided = childrenResult.Items.Count; + + foreach (var i in childrenResult.Items) + { + var childItem = i.Item; + var displayStubType = i.StubType; + + if (childItem.IsDisplayedAsFolder || displayStubType.HasValue) + { + var childCount = GetUserItems(childItem, displayStubType, _user, sortCriteria, null, 0) + .TotalRecordCount; - foreach (var i in childrenResult.Items) + _didlBuilder.WriteFolderElement(writer, childItem, displayStubType, item, childCount, filter); + } + else { - var childItem = i.Item; - var displayStubType = i.StubType; - - if (childItem.IsDisplayedAsFolder || displayStubType.HasValue) - { - var childCount = GetUserItems(childItem, displayStubType, _user, sortCriteria, null, 0) - .TotalRecordCount; - - _didlBuilder.WriteFolderElement(writer, childItem, displayStubType, item, childCount, filter); - } - else - { - _didlBuilder.WriteItemElement(writer, childItem, _user, item, serverItem.StubType, deviceId, filter); - } + _didlBuilder.WriteItemElement(writer, childItem, _user, item, serverItem.StubType, deviceId, filter); } } - - writer.WriteFullEndElement(); } + writer.WriteFullEndElement(); + writer.Flush(); xmlWriter.WriteElementString("Result", builder.ToString()); } @@ -454,53 +446,46 @@ namespace Emby.Dlna.ContentDirectory } QueryResult childrenResult; + var settings = new XmlWriterSettings + { + Encoding = Encoding.UTF8, + CloseOutput = false, + OmitXmlDeclaration = true, + ConformanceLevel = ConformanceLevel.Fragment + }; using (StringWriter builder = new StringWriterWithEncoding(Encoding.UTF8)) + using (var writer = XmlWriter.Create(builder, settings)) { - var settings = new XmlWriterSettings() - { - Encoding = Encoding.UTF8, - CloseOutput = false, - OmitXmlDeclaration = true, - ConformanceLevel = ConformanceLevel.Fragment - }; + writer.WriteStartElement(string.Empty, "DIDL-Lite", NsDidl); + writer.WriteAttributeString("xmlns", "dc", null, NsDc); + writer.WriteAttributeString("xmlns", "dlna", null, NsDlna); + writer.WriteAttributeString("xmlns", "upnp", null, NsUpnp); - using (var writer = XmlWriter.Create(builder, settings)) - { - writer.WriteStartElement(string.Empty, "DIDL-Lite", NsDidl); + DidlBuilder.WriteXmlRootAttributes(_profile, writer); - writer.WriteAttributeString("xmlns", "dc", null, NsDc); - writer.WriteAttributeString("xmlns", "dlna", null, NsDlna); - writer.WriteAttributeString("xmlns", "upnp", null, NsUpnp); + var serverItem = GetItemFromObjectId(sparams["ContainerID"]); - DidlBuilder.WriteXmlRootAttributes(_profile, writer); + var item = serverItem.Item; - var serverItem = GetItemFromObjectId(sparams["ContainerID"]); - - var item = serverItem.Item; - - childrenResult = GetChildrenSorted(item, _user, searchCriteria, sortCriteria, start, requestedCount); - - var dlnaOptions = _config.GetDlnaConfiguration(); - - foreach (var i in childrenResult.Items) + childrenResult = GetChildrenSorted(item, _user, searchCriteria, sortCriteria, start, requestedCount); + foreach (var i in childrenResult.Items) + { + if (i.IsDisplayedAsFolder) { - if (i.IsDisplayedAsFolder) - { - var childCount = GetChildrenSorted(i, _user, searchCriteria, sortCriteria, null, 0) - .TotalRecordCount; + var childCount = GetChildrenSorted(i, _user, searchCriteria, sortCriteria, null, 0) + .TotalRecordCount; - _didlBuilder.WriteFolderElement(writer, i, null, item, childCount, filter); - } - else - { - _didlBuilder.WriteItemElement(writer, i, _user, item, serverItem.StubType, deviceId, filter); - } + _didlBuilder.WriteFolderElement(writer, i, null, item, childCount, filter); + } + else + { + _didlBuilder.WriteItemElement(writer, i, _user, item, serverItem.StubType, deviceId, filter); } - - writer.WriteFullEndElement(); } + writer.WriteFullEndElement(); + writer.Flush(); xmlWriter.WriteElementString("Result", builder.ToString()); } @@ -523,44 +508,34 @@ namespace Emby.Dlna.ContentDirectory { var folder = (Folder)item; - var sortOrders = folder.IsPreSorted - ? Array.Empty<(string, SortOrder)>() - : new[] { (ItemSortBy.SortName, sort.SortOrder) }; - string[] mediaTypes = Array.Empty(); bool? isFolder = null; - if (search.SearchType == SearchType.Audio) - { - mediaTypes = new[] { MediaType.Audio }; - isFolder = false; - } - else if (search.SearchType == SearchType.Video) - { - mediaTypes = new[] { MediaType.Video }; - isFolder = false; - } - else if (search.SearchType == SearchType.Image) + switch (search.SearchType) { - mediaTypes = new[] { MediaType.Photo }; - isFolder = false; - } - else if (search.SearchType == SearchType.Playlist) - { - // items = items.OfType(); - isFolder = true; - } - else if (search.SearchType == SearchType.MusicAlbum) - { - // items = items.OfType(); - isFolder = true; + case SearchType.Audio: + mediaTypes = new[] { MediaType.Audio }; + isFolder = false; + break; + case SearchType.Video: + mediaTypes = new[] { MediaType.Video }; + isFolder = false; + break; + case SearchType.Image: + mediaTypes = new[] { MediaType.Photo }; + isFolder = false; + break; + case SearchType.Playlist: + case SearchType.MusicAlbum: + isFolder = true; + break; } return folder.GetItems(new InternalItemsQuery { Limit = limit, StartIndex = startIndex, - OrderBy = sortOrders, + OrderBy = GetOrderBy(sort, folder.IsPreSorted), User = user, Recursive = true, IsMissing = false, @@ -592,52 +567,49 @@ namespace Emby.Dlna.ContentDirectory /// The . private QueryResult GetUserItems(BaseItem item, StubType? stubType, User user, SortCriteria sort, int? startIndex, int? limit) { - if (item is MusicGenre) - { - return GetMusicGenreItems(item, Guid.Empty, user, sort, startIndex, limit); - } - - if (item is MusicArtist) - { - return GetMusicArtistItems(item, Guid.Empty, user, sort, startIndex, limit); - } - - if (item is Genre) - { - return GetGenreItems(item, Guid.Empty, user, sort, startIndex, limit); + switch (item) + { + case MusicGenre: + return GetMusicGenreItems(item, user, sort, startIndex, limit); + case MusicArtist: + return GetMusicArtistItems(item, user, sort, startIndex, limit); + case Genre: + return GetGenreItems(item, user, sort, startIndex, limit); } - if ((!stubType.HasValue || stubType.Value != StubType.Folder) - && item is IHasCollectionType collectionFolder) + if (stubType != StubType.Folder && item is IHasCollectionType collectionFolder) { - if (string.Equals(CollectionType.Music, collectionFolder.CollectionType, StringComparison.OrdinalIgnoreCase)) + var collectionType = collectionFolder.CollectionType; + if (string.Equals(CollectionType.Music, collectionType, StringComparison.OrdinalIgnoreCase)) { return GetMusicFolders(item, user, stubType, sort, startIndex, limit); } - else if (string.Equals(CollectionType.Movies, collectionFolder.CollectionType, StringComparison.OrdinalIgnoreCase)) + + if (string.Equals(CollectionType.Movies, collectionType, StringComparison.OrdinalIgnoreCase)) { return GetMovieFolders(item, user, stubType, sort, startIndex, limit); } - else if (string.Equals(CollectionType.TvShows, collectionFolder.CollectionType, StringComparison.OrdinalIgnoreCase)) + + if (string.Equals(CollectionType.TvShows, collectionType, StringComparison.OrdinalIgnoreCase)) { return GetTvFolders(item, user, stubType, sort, startIndex, limit); } - else if (string.Equals(CollectionType.Folders, collectionFolder.CollectionType, StringComparison.OrdinalIgnoreCase)) + + if (string.Equals(CollectionType.Folders, collectionType, StringComparison.OrdinalIgnoreCase)) { return GetFolders(user, startIndex, limit); } - else if (string.Equals(CollectionType.LiveTv, collectionFolder.CollectionType, StringComparison.OrdinalIgnoreCase)) + + if (string.Equals(CollectionType.LiveTv, collectionType, StringComparison.OrdinalIgnoreCase)) { return GetLiveTvChannels(user, sort, startIndex, limit); } } - if (stubType.HasValue) + if (stubType.HasValue && stubType.Value != StubType.Folder) { - if (stubType.Value != StubType.Folder) - { - return ApplyPaging(new QueryResult(), startIndex, limit); - } + // TODO should this be doing something? + return new QueryResult(); } var folder = (Folder)item; @@ -649,11 +621,10 @@ namespace Emby.Dlna.ContentDirectory IsVirtualItem = false, ExcludeItemTypes = new[] { nameof(Book) }, IsPlaceHolder = false, - DtoOptions = GetDtoOptions() + DtoOptions = GetDtoOptions(), + OrderBy = GetOrderBy(sort, folder.IsPreSorted) }; - SetSorting(query, sort, folder.IsPreSorted); - var queryResult = folder.GetItems(query); return ToResult(queryResult); @@ -673,10 +644,9 @@ namespace Emby.Dlna.ContentDirectory { StartIndex = startIndex, Limit = limit, + IncludeItemTypes = new[] { nameof(LiveTvChannel) }, + OrderBy = GetOrderBy(sort, false) }; - query.IncludeItemTypes = new[] { nameof(LiveTvChannel) }; - - SetSorting(query, sort, false); var result = _libraryManager.GetItemsResult(query); @@ -698,117 +668,57 @@ namespace Emby.Dlna.ContentDirectory var query = new InternalItemsQuery(user) { StartIndex = startIndex, - Limit = limit + Limit = limit, + OrderBy = GetOrderBy(sort, false) }; - SetSorting(query, sort, false); - if (stubType.HasValue && stubType.Value == StubType.Latest) - { - return GetMusicLatest(item, user, query); - } - - if (stubType.HasValue && stubType.Value == StubType.Playlists) - { - return GetMusicPlaylists(user, query); + switch (stubType) + { + case StubType.Latest: + return GetLatest(item, query, nameof(Audio)); + case StubType.Playlists: + return GetMusicPlaylists(query); + case StubType.Albums: + return GetChildrenOfItem(item, query, nameof(MusicAlbum)); + case StubType.Artists: + return GetMusicArtists(item, query); + case StubType.AlbumArtists: + return GetMusicAlbumArtists(item, query); + case StubType.FavoriteAlbums: + return GetChildrenOfItem(item, query, nameof(MusicAlbum), true); + case StubType.FavoriteArtists: + return GetFavoriteArtists(item, query); + case StubType.FavoriteSongs: + return GetChildrenOfItem(item, query, nameof(Audio), true); + case StubType.Songs: + return GetChildrenOfItem(item, query, nameof(Audio)); + case StubType.Genres: + return GetMusicGenres(item, query); } - if (stubType.HasValue && stubType.Value == StubType.Albums) - { - return GetMusicAlbums(item, user, query); - } - - if (stubType.HasValue && stubType.Value == StubType.Artists) - { - return GetMusicArtists(item, user, query); - } - - if (stubType.HasValue && stubType.Value == StubType.AlbumArtists) - { - return GetMusicAlbumArtists(item, user, query); - } - - if (stubType.HasValue && stubType.Value == StubType.FavoriteAlbums) - { - return GetFavoriteAlbums(item, user, query); - } - - if (stubType.HasValue && stubType.Value == StubType.FavoriteArtists) - { - return GetFavoriteArtists(item, user, query); - } - - if (stubType.HasValue && stubType.Value == StubType.FavoriteSongs) - { - return GetFavoriteSongs(item, user, query); - } - - if (stubType.HasValue && stubType.Value == StubType.Songs) - { - return GetMusicSongs(item, user, query); - } + var serverItems = new ServerItem[] + { + new (item, StubType.Latest), + new (item, StubType.Playlists), + new (item, StubType.Albums), + new (item, StubType.AlbumArtists), + new (item, StubType.Artists), + new (item, StubType.Songs), + new (item, StubType.Genres), + new (item, StubType.FavoriteArtists), + new (item, StubType.FavoriteAlbums), + new (item, StubType.FavoriteSongs) + }; - if (stubType.HasValue && stubType.Value == StubType.Genres) + if (limit < serverItems.Length) { - return GetMusicGenres(item, user, query); + serverItems = serverItems[..limit.Value]; } - var list = new List - { - new ServerItem(item) - { - StubType = StubType.Latest - }, - - new ServerItem(item) - { - StubType = StubType.Playlists - }, - - new ServerItem(item) - { - StubType = StubType.Albums - }, - - new ServerItem(item) - { - StubType = StubType.AlbumArtists - }, - - new ServerItem(item) - { - StubType = StubType.Artists - }, - - new ServerItem(item) - { - StubType = StubType.Songs - }, - - new ServerItem(item) - { - StubType = StubType.Genres - }, - - new ServerItem(item) - { - StubType = StubType.FavoriteArtists - }, - - new ServerItem(item) - { - StubType = StubType.FavoriteAlbums - }, - - new ServerItem(item) - { - StubType = StubType.FavoriteSongs - } - }; - return new QueryResult { - Items = list, - TotalRecordCount = list.Count + Items = serverItems, + TotalRecordCount = serverItems.Length }; } @@ -827,68 +737,41 @@ namespace Emby.Dlna.ContentDirectory var query = new InternalItemsQuery(user) { StartIndex = startIndex, - Limit = limit + Limit = limit, + OrderBy = GetOrderBy(sort, false) }; - SetSorting(query, sort, false); - - if (stubType.HasValue && stubType.Value == StubType.ContinueWatching) - { - return GetMovieContinueWatching(item, user, query); - } - if (stubType.HasValue && stubType.Value == StubType.Latest) - { - return GetMovieLatest(item, user, query); - } - - if (stubType.HasValue && stubType.Value == StubType.Movies) - { - return GetMovieMovies(item, user, query); + switch (stubType) + { + case StubType.ContinueWatching: + return GetMovieContinueWatching(item, query); + case StubType.Latest: + return GetLatest(item, query, nameof(Movie)); + case StubType.Movies: + return GetChildrenOfItem(item, query, nameof(Movie)); + case StubType.Collections: + return GetMovieCollections(query); + case StubType.Favorites: + return GetChildrenOfItem(item, query, nameof(Movie), true); + case StubType.Genres: + return GetGenres(item, query); } - if (stubType.HasValue && stubType.Value == StubType.Collections) + var array = new ServerItem[] { - return GetMovieCollections(user, query); - } - - if (stubType.HasValue && stubType.Value == StubType.Favorites) - { - return GetMovieFavorites(item, user, query); - } + new (item, StubType.ContinueWatching), + new (item, StubType.Latest), + new (item, StubType.Movies), + new (item, StubType.Collections), + new (item, StubType.Favorites), + new (item, StubType.Genres) + }; - if (stubType.HasValue && stubType.Value == StubType.Genres) + if (limit < array.Length) { - return GetGenres(item, user, query); + array = array[..limit.Value]; } - var array = new[] - { - new ServerItem(item) - { - StubType = StubType.ContinueWatching - }, - new ServerItem(item) - { - StubType = StubType.Latest - }, - new ServerItem(item) - { - StubType = StubType.Movies - }, - new ServerItem(item) - { - StubType = StubType.Collections - }, - new ServerItem(item) - { - StubType = StubType.Favorites - }, - new ServerItem(item) - { - StubType = StubType.Genres - } - }; - return new QueryResult { Items = array, @@ -905,22 +788,21 @@ namespace Emby.Dlna.ContentDirectory /// The . private QueryResult GetFolders(User user, int? startIndex, int? limit) { - var folders = _libraryManager.GetUserRootFolder().GetChildren(user, true) + var folders = _libraryManager.GetUserRootFolder().GetChildren(user, true); + var totalRecordCount = folders.Count; + // Handle paging + var items = folders .OrderBy(i => i.SortName) - .Select(i => new ServerItem(i) - { - StubType = StubType.Folder - }) + .Skip(startIndex ?? 0) + .Take(limit ?? int.MaxValue) + .Select(i => new ServerItem(i, StubType.Folder)) .ToArray(); - return ApplyPaging( - new QueryResult - { - Items = folders, - TotalRecordCount = folders.Length - }, - startIndex, - limit); + return new QueryResult + { + Items = items, + TotalRecordCount = totalRecordCount + }; } /// @@ -938,87 +820,48 @@ namespace Emby.Dlna.ContentDirectory var query = new InternalItemsQuery(user) { StartIndex = startIndex, - Limit = limit + Limit = limit, + OrderBy = GetOrderBy(sort, false) }; - SetSorting(query, sort, false); - - if (stubType.HasValue && stubType.Value == StubType.ContinueWatching) - { - return GetMovieContinueWatching(item, user, query); - } - - if (stubType.HasValue && stubType.Value == StubType.NextUp) - { - return GetNextUp(item, query); - } - if (stubType.HasValue && stubType.Value == StubType.Latest) - { - return GetTvLatest(item, user, query); - } - - if (stubType.HasValue && stubType.Value == StubType.Series) - { - return GetSeries(item, user, query); - } - - if (stubType.HasValue && stubType.Value == StubType.FavoriteSeries) - { - return GetFavoriteSeries(item, user, query); + switch (stubType) + { + case StubType.ContinueWatching: + return GetMovieContinueWatching(item, query); + case StubType.NextUp: + return GetNextUp(item, query); + case StubType.Latest: + return GetLatest(item, query, nameof(Episode)); + case StubType.Series: + return GetChildrenOfItem(item, query, nameof(Series)); + case StubType.FavoriteSeries: + return GetChildrenOfItem(item, query, nameof(Series), true); + case StubType.FavoriteEpisodes: + return GetChildrenOfItem(item, query, nameof(Episode), true); + case StubType.Genres: + return GetGenres(item, query); } - if (stubType.HasValue && stubType.Value == StubType.FavoriteEpisodes) + var serverItems = new ServerItem[] { - return GetFavoriteEpisodes(item, user, query); - } + new (item, StubType.ContinueWatching), + new (item, StubType.NextUp), + new (item, StubType.Latest), + new (item, StubType.Series), + new (item, StubType.FavoriteSeries), + new (item, StubType.FavoriteEpisodes), + new (item, StubType.Genres) + }; - if (stubType.HasValue && stubType.Value == StubType.Genres) + if (limit < serverItems.Length) { - return GetGenres(item, user, query); + serverItems = serverItems[..limit.Value]; } - var list = new List - { - new ServerItem(item) - { - StubType = StubType.ContinueWatching - }, - - new ServerItem(item) - { - StubType = StubType.NextUp - }, - - new ServerItem(item) - { - StubType = StubType.Latest - }, - - new ServerItem(item) - { - StubType = StubType.Series - }, - - new ServerItem(item) - { - StubType = StubType.FavoriteSeries - }, - - new ServerItem(item) - { - StubType = StubType.FavoriteEpisodes - }, - - new ServerItem(item) - { - StubType = StubType.Genres - } - }; - return new QueryResult { - Items = list, - TotalRecordCount = list.Count + Items = serverItems, + TotalRecordCount = serverItems.Length }; } @@ -1026,14 +869,12 @@ 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) + private QueryResult GetMovieContinueWatching(BaseItem parent, InternalItemsQuery query) { query.Recursive = true; query.Parent = parent; - query.SetUser(user); query.OrderBy = new[] { @@ -1042,47 +883,7 @@ namespace Emby.Dlna.ContentDirectory }; query.IsResumable = true; - query.Limit = 10; - - var result = _libraryManager.GetItemsResult(query); - - 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[] { 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[] { nameof(Movie) }; + query.Limit ??= 10; var result = _libraryManager.GetItemsResult(query); @@ -1092,15 +893,11 @@ namespace Emby.Dlna.ContentDirectory /// /// Returns the Movie collections meeting the criteria. /// - /// The see cref="User"/>. /// The see cref="InternalItemsQuery"/>. /// The . - private QueryResult GetMovieCollections(User user, InternalItemsQuery query) + private QueryResult GetMovieCollections(InternalItemsQuery query) { query.Recursive = true; - // query.Parent = parent; - query.SetUser(user); - query.IncludeItemTypes = new[] { nameof(BoxSet) }; var result = _libraryManager.GetItemsResult(query); @@ -1109,139 +906,19 @@ namespace Emby.Dlna.ContentDirectory } /// - /// 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[] { nameof(MusicAlbum) }; - - var result = _libraryManager.GetItemsResult(query); - - return ToResult(result); - } - - /// - /// Returns the Music songs meeting the criteria. + /// Returns the children that meet the criteria. /// /// The . - /// The . /// The . + /// The item type. + /// A value indicating whether to only fetch favorite items. /// The . - private QueryResult GetMusicSongs(BaseItem parent, User user, InternalItemsQuery query) + private QueryResult GetChildrenOfItem(BaseItem parent, InternalItemsQuery query, string itemType, bool isFavorite = false) { query.Recursive = true; query.Parent = parent; - query.SetUser(user); - - 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[] { 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[] { 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[] { 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[] { 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[] { nameof(MusicAlbum) }; + query.IsFavorite = isFavorite; + query.IncludeItemTypes = new[] { itemType }; var result = _libraryManager.GetItemsResult(query); @@ -1253,139 +930,90 @@ namespace Emby.Dlna.ContentDirectory /// The GetGenres. /// /// The . - /// The . /// The . /// The . - private QueryResult GetGenres(BaseItem parent, User user, InternalItemsQuery query) + private QueryResult GetGenres(BaseItem parent, InternalItemsQuery query) { - var genresResult = _libraryManager.GetGenres(new InternalItemsQuery(user) - { - AncestorIds = new[] { parent.Id }, - StartIndex = query.StartIndex, - Limit = query.Limit - }); - - var result = new QueryResult - { - TotalRecordCount = genresResult.TotalRecordCount, - Items = genresResult.Items.Select(i => i.Item1).ToArray() - }; + // Don't sort + query.OrderBy = Array.Empty<(string, SortOrder)>(); + query.AncestorIds = new[] { parent.Id }; + var genresResult = _libraryManager.GetGenres(query); - return ToResult(result); + return ToResult(genresResult); } /// /// Returns the music genres meeting the criteria. /// /// The . - /// The . /// The . /// The . - private QueryResult GetMusicGenres(BaseItem parent, User user, InternalItemsQuery query) + private QueryResult GetMusicGenres(BaseItem parent, InternalItemsQuery query) { - var genresResult = _libraryManager.GetMusicGenres(new InternalItemsQuery(user) - { - AncestorIds = new[] { parent.Id }, - StartIndex = query.StartIndex, - Limit = query.Limit - }); - - var result = new QueryResult - { - TotalRecordCount = genresResult.TotalRecordCount, - Items = genresResult.Items.Select(i => i.Item1).ToArray() - }; + // Don't sort + query.OrderBy = Array.Empty<(string, SortOrder)>(); + query.AncestorIds = new[] { parent.Id }; + var genresResult = _libraryManager.GetMusicGenres(query); - return ToResult(result); + return ToResult(genresResult); } /// /// Returns the music albums by artist that meet the criteria. /// /// The . - /// The . /// The . /// The . - private QueryResult GetMusicAlbumArtists(BaseItem parent, User user, InternalItemsQuery query) + private QueryResult GetMusicAlbumArtists(BaseItem parent, InternalItemsQuery query) { - var artists = _libraryManager.GetAlbumArtists(new InternalItemsQuery(user) - { - AncestorIds = new[] { parent.Id }, - StartIndex = query.StartIndex, - Limit = query.Limit - }); - - var result = new QueryResult - { - TotalRecordCount = artists.TotalRecordCount, - Items = artists.Items.Select(i => i.Item1).ToArray() - }; + // Don't sort + query.OrderBy = Array.Empty<(string, SortOrder)>(); + query.AncestorIds = new[] { parent.Id }; + var artists = _libraryManager.GetAlbumArtists(query); - return ToResult(result); + return ToResult(artists); } /// /// Returns the music artists meeting the criteria. /// /// The . - /// The . /// The . /// The . - private QueryResult GetMusicArtists(BaseItem parent, User user, InternalItemsQuery query) + private QueryResult GetMusicArtists(BaseItem parent, InternalItemsQuery query) { - var artists = _libraryManager.GetArtists(new InternalItemsQuery(user) - { - AncestorIds = new[] { parent.Id }, - StartIndex = query.StartIndex, - Limit = query.Limit - }); - - var result = new QueryResult - { - TotalRecordCount = artists.TotalRecordCount, - Items = artists.Items.Select(i => i.Item1).ToArray() - }; - - return ToResult(result); + // Don't sort + query.OrderBy = Array.Empty<(string, SortOrder)>(); + query.AncestorIds = new[] { parent.Id }; + var artists = _libraryManager.GetArtists(query); + return ToResult(artists); } /// /// Returns the artists tagged as favourite that meet the criteria. /// /// The . - /// The . /// The . /// The . - private QueryResult GetFavoriteArtists(BaseItem parent, User user, InternalItemsQuery query) + private QueryResult GetFavoriteArtists(BaseItem parent, InternalItemsQuery query) { - var artists = _libraryManager.GetArtists(new InternalItemsQuery(user) - { - AncestorIds = new[] { parent.Id }, - StartIndex = query.StartIndex, - Limit = query.Limit, - IsFavorite = true - }); - - var result = new QueryResult - { - TotalRecordCount = artists.TotalRecordCount, - Items = artists.Items.Select(i => i.Item1).ToArray() - }; - - return ToResult(result); + // Don't sort + query.OrderBy = Array.Empty<(string, SortOrder)>(); + query.AncestorIds = new[] { parent.Id }; + query.IsFavorite = true; + var artists = _libraryManager.GetArtists(query); + return ToResult(artists); } /// /// Returns the music playlists meeting the criteria. /// - /// The user. /// The query. /// The . - private QueryResult GetMusicPlaylists(User user, InternalItemsQuery query) + private QueryResult GetMusicPlaylists(InternalItemsQuery query) { query.Parent = null; query.IncludeItemTypes = new[] { nameof(Playlist) }; - query.SetUser(user); query.Recursive = true; var result = _libraryManager.GetItemsResult(query); @@ -1393,31 +1021,6 @@ 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)>(); - - var items = _userViewManager.GetLatestItems( - new LatestItemsQuery - { - UserId = user.Id, - Limit = 50, - IncludeItemTypes = new[] { nameof(Audio) }, - ParentId = parent?.Id ?? Guid.Empty, - GroupItems = true - }, - query.DtoOptions).Select(i => i.Item1 ?? i.Item2.FirstOrDefault()).Where(i => i != null).ToArray(); - - return ToResult(items); - } - /// /// Returns the next up item meeting the criteria. /// @@ -1433,7 +1036,8 @@ namespace Emby.Dlna.ContentDirectory { Limit = query.Limit, StartIndex = query.StartIndex, - UserId = query.User.Id + // User cannot be null here as the caller has set it + UserId = query.User!.Id }, new[] { parent }, query.DtoOptions); @@ -1442,47 +1046,23 @@ namespace Emby.Dlna.ContentDirectory } /// - /// Returns the latest tv meeting the criteria. + /// Returns the latest items of [itemType] meeting the criteria. /// /// The . - /// The . /// The . + /// The item type. /// The . - private QueryResult GetTvLatest(BaseItem parent, User user, InternalItemsQuery query) + private QueryResult GetLatest(BaseItem parent, InternalItemsQuery query, string itemType) { query.OrderBy = Array.Empty<(string, SortOrder)>(); var items = _userViewManager.GetLatestItems( new LatestItemsQuery { - UserId = user.Id, - Limit = 50, - IncludeItemTypes = new[] { nameof(Episode) }, - ParentId = parent == null ? Guid.Empty : parent.Id, - GroupItems = false - }, - query.DtoOptions).Select(i => i.Item1 ?? i.Item2.FirstOrDefault()).Where(i => i != null).ToArray(); - - 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)>(); - - var items = _userViewManager.GetLatestItems( - new LatestItemsQuery - { - UserId = user.Id, - Limit = 50, - IncludeItemTypes = new[] { nameof(Movie) }, + // User cannot be null here as the caller has set it + UserId = query.User!.Id, + Limit = query.Limit ?? 50, + IncludeItemTypes = new[] { itemType }, ParentId = parent?.Id ?? Guid.Empty, GroupItems = true }, @@ -1495,27 +1075,24 @@ namespace Emby.Dlna.ContentDirectory /// 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) + private QueryResult GetMusicArtistItems(BaseItem item, User user, SortCriteria sort, int? startIndex, int? limit) { var query = new InternalItemsQuery(user) { Recursive = true, - ParentId = parentId, ArtistIds = new[] { item.Id }, IncludeItemTypes = new[] { nameof(MusicAlbum) }, Limit = limit, StartIndex = startIndex, - DtoOptions = GetDtoOptions() + DtoOptions = GetDtoOptions(), + OrderBy = GetOrderBy(sort, false) }; - SetSorting(query, sort, false); - var result = _libraryManager.GetItemsResult(query); return ToResult(result); @@ -1525,18 +1102,16 @@ namespace Emby.Dlna.ContentDirectory /// 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) + private QueryResult GetGenreItems(BaseItem item, User user, SortCriteria sort, int? startIndex, int? limit) { var query = new InternalItemsQuery(user) { Recursive = true, - ParentId = parentId, GenreIds = new[] { item.Id }, IncludeItemTypes = new[] { @@ -1545,11 +1120,10 @@ namespace Emby.Dlna.ContentDirectory }, Limit = limit, StartIndex = startIndex, - DtoOptions = GetDtoOptions() + DtoOptions = GetDtoOptions(), + OrderBy = GetOrderBy(sort, false) }; - SetSorting(query, sort, false); - var result = _libraryManager.GetItemsResult(query); return ToResult(result); @@ -1559,46 +1133,43 @@ namespace Emby.Dlna.ContentDirectory /// 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) + private QueryResult GetMusicGenreItems(BaseItem item, User user, SortCriteria sort, int? startIndex, int? limit) { var query = new InternalItemsQuery(user) { Recursive = true, - ParentId = parentId, GenreIds = new[] { item.Id }, IncludeItemTypes = new[] { nameof(MusicAlbum) }, Limit = limit, StartIndex = startIndex, - DtoOptions = GetDtoOptions() + DtoOptions = GetDtoOptions(), + OrderBy = GetOrderBy(sort, false) }; - SetSorting(query, sort, false); - var result = _libraryManager.GetItemsResult(query); return ToResult(result); } /// - /// Converts a array into a . + /// Converts into a . /// /// An array of . /// A . - private static QueryResult ToResult(BaseItem[] result) + private static QueryResult ToResult(IReadOnlyCollection result) { var serverItems = result - .Select(i => new ServerItem(i)) + .Select(i => new ServerItem(i, null)) .ToArray(); return new QueryResult { - TotalRecordCount = result.Length, + TotalRecordCount = result.Count, Items = serverItems }; } @@ -1610,10 +1181,12 @@ namespace Emby.Dlna.ContentDirectory /// The . private static QueryResult ToResult(QueryResult result) { - var serverItems = result - .Items - .Select(i => new ServerItem(i)) - .ToArray(); + var length = result.Items.Count; + var serverItems = new ServerItem[length]; + for (var i = 0; i < length; i++) + { + serverItems[i] = new ServerItem(result.Items[i], null); + } return new QueryResult { @@ -1623,35 +1196,34 @@ namespace Emby.Dlna.ContentDirectory } /// - /// Sets the sorting method on a query. + /// Converts a query result to a . /// - /// The . - /// The . - /// True if pre-sorted. - private static void SetSorting(InternalItemsQuery query, SortCriteria sort, bool isPreSorted) + /// A . + /// The . + private static QueryResult ToResult(QueryResult<(BaseItem, ItemCounts)> result) { - if (isPreSorted) + var length = result.Items.Count; + var serverItems = new ServerItem[length]; + for (var i = 0; i < length; i++) { - query.OrderBy = Array.Empty<(string, SortOrder)>(); + serverItems[i] = new ServerItem(result.Items[i].Item1, null); } - else + + return new QueryResult { - query.OrderBy = new[] { (ItemSortBy.SortName, sort.SortOrder) }; - } + TotalRecordCount = result.TotalRecordCount, + Items = serverItems + }; } /// - /// Apply paging to a query. + /// Gets the sorting method on a query. /// - /// The . - /// The start index. - /// The maximum number to return. - /// A . - private static QueryResult ApplyPaging(QueryResult result, int? startIndex, int? limit) + /// The . + /// True if pre-sorted. + private static (string, SortOrder)[] GetOrderBy(SortCriteria sort, bool isPreSorted) { - result.Items = result.Items.Skip(startIndex ?? 0).Take(limit ?? int.MaxValue).ToArray(); - - return result; + return isPreSorted ? Array.Empty<(string, SortOrder)>() : new[] { (ItemSortBy.SortName, sort.SortOrder) }; } /// @@ -1662,7 +1234,7 @@ namespace Emby.Dlna.ContentDirectory private ServerItem GetItemFromObjectId(string id) { return DidlBuilder.IsIdRoot(id) - ? new ServerItem(_libraryManager.GetUserRootFolder()) + ? new ServerItem(_libraryManager.GetUserRootFolder(), null) : ParseItemId(id); } @@ -1680,37 +1252,29 @@ namespace Emby.Dlna.ContentDirectory var paramsIndex = id.IndexOf(ParamsSrch, StringComparison.OrdinalIgnoreCase); if (paramsIndex != -1) { - id = id.Substring(paramsIndex + ParamsSrch.Length); + id = id[(paramsIndex + ParamsSrch.Length)..]; var parts = id.Split(';'); id = parts[23]; } - var enumNames = Enum.GetNames(typeof(StubType)); - foreach (var name in enumNames) + var dividerIndex = id.IndexOf('_', StringComparison.Ordinal); + if (dividerIndex != -1 && Enum.TryParse(id.AsSpan(0, dividerIndex), true, out var parsedStubType)) { - if (id.StartsWith(name + "_", StringComparison.OrdinalIgnoreCase)) - { - stubType = Enum.Parse(name, true); - id = id.Split('_', 2)[1]; - - break; - } + id = id[(dividerIndex + 1)..]; + stubType = parsedStubType; } if (Guid.TryParse(id, out var itemId)) { var item = _libraryManager.GetItemById(itemId); - return new ServerItem(item) - { - StubType = stubType - }; + return new ServerItem(item, stubType); } Logger.LogError("Error parsing item Id: {Id}. Returning user root folder.", id); - return new ServerItem(_libraryManager.GetUserRootFolder()); + return new ServerItem(_libraryManager.GetUserRootFolder(), null); } } } diff --git a/Emby.Dlna/ContentDirectory/ServerItem.cs b/Emby.Dlna/ContentDirectory/ServerItem.cs index 34244000c1..df05fa9666 100644 --- a/Emby.Dlna/ContentDirectory/ServerItem.cs +++ b/Emby.Dlna/ContentDirectory/ServerItem.cs @@ -1,5 +1,3 @@ -#pragma warning disable CS1591 - using MediaBrowser.Controller.Entities; namespace Emby.Dlna.ContentDirectory @@ -13,24 +11,29 @@ namespace Emby.Dlna.ContentDirectory /// Initializes a new instance of the class. /// /// The . - public ServerItem(BaseItem item) + /// The stub type. + public ServerItem(BaseItem item, StubType? stubType) { Item = item; - if (item is IItemByName && !(item is Folder)) + if (stubType.HasValue) + { + StubType = stubType; + } + else if (item is IItemByName and not Folder) { StubType = Dlna.ContentDirectory.StubType.Folder; } } /// - /// Gets or sets the underlying base item. + /// Gets the underlying base item. /// - public BaseItem Item { get; set; } + public BaseItem Item { get; } /// - /// Gets or sets the DLNA item type. + /// Gets the DLNA item type. /// - public StubType? StubType { get; set; } + public StubType? StubType { get; } } } 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 8b50d47fbd..b00e1c98af 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; @@ -39,8 +41,6 @@ namespace Emby.Dlna.Didl private const string NsUpnp = "urn:schemas-upnp-org:metadata-1-0/upnp/"; private const string NsDlna = "urn:schemas-dlna-org:metadata-1-0/"; - private readonly CultureInfo _usCulture = new CultureInfo("en-US"); - private readonly DeviceProfile _profile; private readonly IImageProcessor _imageProcessor; private readonly string _serverAddress; @@ -208,7 +208,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(), @@ -314,7 +315,7 @@ namespace Emby.Dlna.Didl if (mediaSource.RunTimeTicks.HasValue) { - writer.WriteAttributeString("duration", TimeSpan.FromTicks(mediaSource.RunTimeTicks.Value).ToString("c", _usCulture)); + writer.WriteAttributeString("duration", TimeSpan.FromTicks(mediaSource.RunTimeTicks.Value).ToString("c", CultureInfo.InvariantCulture)); } if (filter.Contains("res@size")) @@ -325,7 +326,7 @@ namespace Emby.Dlna.Didl if (size.HasValue) { - writer.WriteAttributeString("size", size.Value.ToString(_usCulture)); + writer.WriteAttributeString("size", size.Value.ToString(CultureInfo.InvariantCulture)); } } } @@ -339,7 +340,7 @@ namespace Emby.Dlna.Didl if (targetChannels.HasValue) { - writer.WriteAttributeString("nrAudioChannels", targetChannels.Value.ToString(_usCulture)); + writer.WriteAttributeString("nrAudioChannels", targetChannels.Value.ToString(CultureInfo.InvariantCulture)); } if (filter.Contains("res@resolution")) @@ -358,12 +359,12 @@ namespace Emby.Dlna.Didl if (targetSampleRate.HasValue) { - writer.WriteAttributeString("sampleFrequency", targetSampleRate.Value.ToString(_usCulture)); + writer.WriteAttributeString("sampleFrequency", targetSampleRate.Value.ToString(CultureInfo.InvariantCulture)); } if (totalBitrate.HasValue) { - writer.WriteAttributeString("bitrate", totalBitrate.Value.ToString(_usCulture)); + writer.WriteAttributeString("bitrate", totalBitrate.Value.ToString(CultureInfo.InvariantCulture)); } var mediaProfile = _profile.GetVideoMediaProfile( @@ -549,7 +550,7 @@ namespace Emby.Dlna.Didl if (mediaSource.RunTimeTicks.HasValue) { - writer.WriteAttributeString("duration", TimeSpan.FromTicks(mediaSource.RunTimeTicks.Value).ToString("c", _usCulture)); + writer.WriteAttributeString("duration", TimeSpan.FromTicks(mediaSource.RunTimeTicks.Value).ToString("c", CultureInfo.InvariantCulture)); } if (filter.Contains("res@size")) @@ -560,7 +561,7 @@ namespace Emby.Dlna.Didl if (size.HasValue) { - writer.WriteAttributeString("size", size.Value.ToString(_usCulture)); + writer.WriteAttributeString("size", size.Value.ToString(CultureInfo.InvariantCulture)); } } } @@ -572,17 +573,17 @@ namespace Emby.Dlna.Didl if (targetChannels.HasValue) { - writer.WriteAttributeString("nrAudioChannels", targetChannels.Value.ToString(_usCulture)); + writer.WriteAttributeString("nrAudioChannels", targetChannels.Value.ToString(CultureInfo.InvariantCulture)); } if (targetSampleRate.HasValue) { - writer.WriteAttributeString("sampleFrequency", targetSampleRate.Value.ToString(_usCulture)); + writer.WriteAttributeString("sampleFrequency", targetSampleRate.Value.ToString(CultureInfo.InvariantCulture)); } if (targetAudioBitrate.HasValue) { - writer.WriteAttributeString("bitrate", targetAudioBitrate.Value.ToString(_usCulture)); + writer.WriteAttributeString("bitrate", targetAudioBitrate.Value.ToString(CultureInfo.InvariantCulture)); } var mediaProfile = _profile.GetAudioMediaProfile( @@ -599,7 +600,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, @@ -635,7 +637,7 @@ namespace Emby.Dlna.Didl writer.WriteAttributeString("restricted", "1"); writer.WriteAttributeString("searchable", "1"); - writer.WriteAttributeString("childCount", childCount.ToString(_usCulture)); + writer.WriteAttributeString("childCount", childCount.ToString(CultureInfo.InvariantCulture)); var clientId = GetClientId(folder, stubType); @@ -727,7 +729,7 @@ namespace Emby.Dlna.Didl { if (item.PremiereDate.HasValue) { - AddValue(writer, "dc", "date", item.PremiereDate.Value.ToString("o", CultureInfo.InvariantCulture), NsDc); + AddValue(writer, "dc", "date", item.PremiereDate.Value.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture), NsDc); } } @@ -744,7 +746,7 @@ namespace Emby.Dlna.Didl AddValue(writer, "upnp", "publisher", studio, NsUpnp); } - if (!(item is Folder)) + if (item is not Folder) { if (filter.Contains("dc:description")) { @@ -927,11 +929,11 @@ namespace Emby.Dlna.Didl if (item.IndexNumber.HasValue) { - AddValue(writer, "upnp", "originalTrackNumber", item.IndexNumber.Value.ToString(_usCulture), NsUpnp); + AddValue(writer, "upnp", "originalTrackNumber", item.IndexNumber.Value.ToString(CultureInfo.InvariantCulture), NsUpnp); if (item is Episode) { - AddValue(writer, "upnp", "episodeNumber", item.IndexNumber.Value.ToString(_usCulture), NsUpnp); + AddValue(writer, "upnp", "episodeNumber", item.IndexNumber.Value.ToString(CultureInfo.InvariantCulture), NsUpnp); } } } @@ -974,15 +976,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) @@ -1033,8 +1048,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", @@ -1206,8 +1220,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/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 3417076dc1..93efa4b38d 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; @@ -12,9 +11,9 @@ 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.Common.Json; using MediaBrowser.Controller; using MediaBrowser.Controller.Dlna; using MediaBrowser.Controller.Drawing; @@ -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,109 +110,57 @@ 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); + _logger.LogInformation("No matching device profile found. The default will need to be used. \n{@Profile}", deviceInfo); } else { - LogUnmatchedProfile(deviceInfo); + _logger.LogDebug("Found matching device profile: {ProfileName}", profile.Name); } return profile; } - private void LogUnmatchedProfile(DeviceIdentification profile) + /// + /// 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) { - 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); - - _logger.LogInformation(builder.ToString()); + 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); } - private bool IsMatch(DeviceIdentification deviceInfo, DeviceIdentification profileInfo) + private bool IsRegexOrSubstringMatch(string input, string pattern) { - 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; - } - } - - if (!string.IsNullOrEmpty(profileInfo.ModelUrl)) + 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 +169,8 @@ namespace Emby.Dlna } } - public DeviceProfile GetProfile(IHeaderDictionary headers) + /// + public DeviceProfile? GetProfile(IHeaderDictionary headers) { if (headers == null) { @@ -228,15 +178,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 +234,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,7 +274,8 @@ namespace Emby.Dlna } } - public DeviceProfile GetProfile(string id) + /// + public DeviceProfile? GetProfile(string id) { if (string.IsNullOrEmpty(id)) { @@ -355,6 +304,7 @@ namespace Emby.Dlna } } + /// public IEnumerable GetProfileInfos() { return GetProfileInfosInternal().Select(i => i.Info); @@ -362,17 +312,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() @@ -392,16 +339,20 @@ namespace Emby.Dlna 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 length = stream.Length; var fileInfo = _fileSystem.GetFileInfo(path); - if (!fileInfo.Exists || fileInfo.Length != stream.Length) + if (!fileInfo.Exists || fileInfo.Length != length) { Directory.CreateDirectory(systemProfilesPath); - // use FileShare.None as this bypasses dotnet bug dotnet/runtime#42790 . - using (var fileStream = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.None)) + var fileOptions = AsyncFile.WriteOptions; + fileOptions.Mode = FileMode.Create; + fileOptions.PreallocationSize = length; + using (var fileStream = new FileStream(path, fileOptions)) { await stream.CopyToAsync(fileStream).ConfigureAwait(false); } @@ -413,6 +364,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)); @@ -430,6 +382,7 @@ namespace Emby.Dlna } } + /// public void CreateProfile(DeviceProfile profile) { profile = ReserializeProfile(profile); @@ -445,7 +398,8 @@ namespace Emby.Dlna SaveProfile(profile, path, DeviceProfileType.User); } - public void UpdateProfile(DeviceProfile profile) + /// + public void UpdateProfile(string profileId, DeviceProfile profile) { profile = ReserializeProfile(profile); @@ -459,7 +413,7 @@ namespace Emby.Dlna throw new ArgumentException("Profile is missing Name"); } - var current = GetProfileInfosInternal().First(i => string.Equals(i.Info.Id, profile.Id, StringComparison.OrdinalIgnoreCase)); + var current = GetProfileInfosInternal().First(i => string.Equals(i.Info.Id, profileId, StringComparison.OrdinalIgnoreCase)); var newFilename = _fileSystem.GetValidFilename(profile.Name) + ".xml"; var path = Path.Combine(UserProfilesPath, newFilename); @@ -503,9 +457,11 @@ namespace Emby.Dlna var json = JsonSerializer.Serialize(profile, _jsonOptions); - return JsonSerializer.Deserialize(json, _jsonOptions); + // 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(); @@ -515,26 +471,37 @@ namespace Emby.Dlna return new DescriptionXmlBuilder(profile, serverUuId, serverAddress, _appHost.FriendlyName, serverId).GetXml(); } - public ImageStream GetIcon(string filename) + /// + public ImageStream? GetIcon(string filename) { var format = filename.EndsWith(".png", StringComparison.OrdinalIgnoreCase) ? ImageFormat.Png : ImageFormat.Jpg; var resource = GetType().Namespace + ".Images." + filename.ToLowerInvariant(); + var stream = _assembly.GetManifestResourceStream(resource); + if (stream == null) + { + return null; + } - return new ImageStream + return new ImageStream(stream) { - Format = format, - Stream = _assembly.GetManifestResourceStream(resource) + Format = format }; } 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; } } } diff --git a/Emby.Dlna/Emby.Dlna.csproj b/Emby.Dlna/Emby.Dlna.csproj index 480621dd70..7fdbd44f00 100644 --- a/Emby.Dlna/Emby.Dlna.csproj +++ b/Emby.Dlna/Emby.Dlna.csproj @@ -17,10 +17,9 @@ - net5.0 + net6.0 false true - true @@ -30,10 +29,6 @@ - - ../jellyfin.ruleset - - @@ -77,7 +72,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 ff81e83b5e..d17e238715 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; @@ -9,6 +11,7 @@ using System.Net.Http; using System.Net.Mime; using System.Text; using System.Threading.Tasks; +using Jellyfin.Extensions; using MediaBrowser.Common.Extensions; using MediaBrowser.Common.Net; using Microsoft.Extensions.Logging; @@ -23,8 +26,6 @@ namespace Emby.Dlna.Eventing private readonly ILogger _logger; private readonly IHttpClientFactory _httpClientFactory; - private readonly CultureInfo _usCulture = new CultureInfo("en-US"); - public DlnaEventManager(ILogger logger, IHttpClientFactory httpClientFactory) { _httpClientFactory = httpClientFactory; @@ -49,11 +50,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) @@ -84,9 +81,7 @@ namespace Emby.Dlna.Eventing if (!string.IsNullOrEmpty(header)) { // Starts with SECOND- - header = header.Split('-')[^1]; - - if (int.TryParse(header, NumberStyles.Integer, _usCulture, out var val)) + if (int.TryParse(header.AsSpan().RightPart('-'), NumberStyles.Integer, CultureInfo.InvariantCulture, out var val)) { return val; } @@ -101,23 +96,15 @@ 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; + response.Headers["TIMEOUT"] = string.IsNullOrEmpty(requestedTimeoutString) ? ("SECOND-" + timeoutSeconds.ToString(CultureInfo.InvariantCulture)) : requestedTimeoutString; return response; } @@ -174,7 +161,7 @@ namespace Emby.Dlna.Eventing options.Headers.TryAddWithoutValidation("NT", subscription.NotificationType); options.Headers.TryAddWithoutValidation("NTS", "upnp:propchange"); options.Headers.TryAddWithoutValidation("SID", subscription.Id); - options.Headers.TryAddWithoutValidation("SEQ", subscription.TriggerCount.ToString(_usCulture)); + options.Headers.TryAddWithoutValidation("SEQ", subscription.TriggerCount.ToString(CultureInfo.InvariantCulture)); try { 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 bdfe430cf3..f35d90f21c 100644 --- a/Emby.Dlna/Main/DlnaEntryPoint.cs +++ b/Emby.Dlna/Main/DlnaEntryPoint.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System; @@ -25,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 { @@ -52,7 +52,6 @@ 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; @@ -125,8 +124,8 @@ namespace Emby.Dlna.Main config); Current = this; - _netConfig = config.GetConfiguration("network"); - _disabled = appHost.ListenWithHttps && _netConfig.RequireHttps; + var netConfig = config.GetConfiguration("network"); + _disabled = appHost.ListenWithHttps && netConfig.RequireHttps; if (_disabled && _config.GetDlnaConfiguration().EnableServer) { @@ -202,8 +201,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) { @@ -219,11 +218,6 @@ namespace Emby.Dlna.Main } } - private void LogMessage(string msg) - { - _logger.LogDebug(msg); - } - private void StartDeviceDiscovery(ISsdpCommunicationsServer communicationsServer) { try @@ -266,9 +260,13 @@ namespace Emby.Dlna.Main try { - _publisher = new SsdpDevicePublisher(_communicationsServer, _networkManager, OperatingSystem.Name, Environment.OSVersion.VersionString, _config.GetDlnaConfiguration().SendOnlyMatchedHost) + _publisher = new SsdpDevicePublisher( + _communicationsServer, + MediaBrowser.Common.System.OperatingSystem.Name, + Environment.OSVersion.VersionString, + _config.GetDlnaConfiguration().SendOnlyMatchedHost) { - LogFunction = LogMessage, + LogFunction = (msg) => _logger.LogDebug("{Msg}", msg), SupportPnpRootDevice = false }; @@ -313,15 +311,9 @@ namespace Emby.Dlna.Main var fullService = "urn:schemas-upnp-org:device:MediaServer:1"; - _logger.LogInformation("Registering publisher for {0} on {1}", fullService, address); + _logger.LogInformation("Registering publisher for {ResourceName} on {DeviceAddress}", fullService, address); - 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 uri = new UriBuilder(_appHost.GetApiUrlForLocalAccess(false) + descriptorUri); var device = new SsdpRootDevice { @@ -407,7 +399,6 @@ namespace Emby.Dlna.Main _imageProcessor, _deviceDiscovery, _httpClientFactory, - _config, _userDataManager, _localization, _mediaSourceManager, diff --git a/Emby.Dlna/PlayTo/Device.cs b/Emby.Dlna/PlayTo/Device.cs index abd99bbc3c..0b22880003 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; @@ -18,8 +20,6 @@ namespace Emby.Dlna.PlayTo { public class Device : IDisposable { - private static readonly CultureInfo UsCulture = new CultureInfo("en-US"); - private readonly IHttpClientFactory _httpClientFactory; private readonly ILogger _logger; @@ -368,6 +368,42 @@ namespace Emby.Dlna.PlayTo RestartTimer(true); } + /* + * 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)) @@ -602,7 +638,7 @@ namespace Emby.Dlna.PlayTo return; } - Volume = int.Parse(volumeValue, UsCulture); + Volume = int.Parse(volumeValue, CultureInfo.InvariantCulture); if (Volume > 0) { @@ -804,7 +840,7 @@ namespace Emby.Dlna.PlayTo if (!string.IsNullOrWhiteSpace(duration) && !string.Equals(duration, "NOT_IMPLEMENTED", StringComparison.OrdinalIgnoreCase)) { - Duration = TimeSpan.Parse(duration, UsCulture); + Duration = TimeSpan.Parse(duration, CultureInfo.InvariantCulture); } else { @@ -816,7 +852,7 @@ namespace Emby.Dlna.PlayTo if (!string.IsNullOrWhiteSpace(position) && !string.Equals(position, "NOT_IMPLEMENTED", StringComparison.OrdinalIgnoreCase)) { - Position = TimeSpan.Parse(position, UsCulture); + Position = TimeSpan.Parse(position, CultureInfo.InvariantCulture); } var track = result.Document.Descendants("TrackMetaData").FirstOrDefault(); @@ -1156,8 +1192,8 @@ namespace Emby.Dlna.PlayTo var depth = element.GetDescendantValue(UPnpNamespaces.Ud.GetName("depth")); var url = element.GetDescendantValue(UPnpNamespaces.Ud.GetName("url")); - var widthValue = int.Parse(width, NumberStyles.Integer, UsCulture); - var heightValue = int.Parse(height, NumberStyles.Integer, UsCulture); + var widthValue = int.Parse(width, NumberStyles.Integer, CultureInfo.InvariantCulture); + var heightValue = int.Parse(height, NumberStyles.Integer, CultureInfo.InvariantCulture); return new DeviceIcon { @@ -1222,10 +1258,7 @@ namespace Emby.Dlna.PlayTo return; } - PlaybackStart?.Invoke(this, new PlaybackStartEventArgs - { - MediaInfo = mediaInfo - }); + PlaybackStart?.Invoke(this, new PlaybackStartEventArgs(mediaInfo)); } private void OnPlaybackProgress(UBaseObject mediaInfo) @@ -1235,27 +1268,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 25ba18add8..f25d8017ef 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; @@ -28,8 +30,6 @@ namespace Emby.Dlna.PlayTo { public class PlayToController : ISessionController, IDisposable { - private static readonly CultureInfo _usCulture = CultureInfo.ReadOnly(new CultureInfo("en-US")); - private readonly SessionInfo _session; private readonly ISessionManager _sessionManager; private readonly ILibraryManager _libraryManager; @@ -102,6 +102,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 @@ -156,6 +172,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) { @@ -425,6 +450,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; } @@ -499,8 +529,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, @@ -514,8 +544,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(), @@ -623,6 +653,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)) { @@ -681,7 +714,7 @@ namespace Emby.Dlna.PlayTo case GeneralCommandType.SetAudioStreamIndex: if (command.Arguments.TryGetValue("Index", out string index)) { - if (int.TryParse(index, NumberStyles.Integer, _usCulture, out var val)) + if (int.TryParse(index, NumberStyles.Integer, CultureInfo.InvariantCulture, out var val)) { return SetAudioStreamIndex(val); } @@ -693,7 +726,7 @@ namespace Emby.Dlna.PlayTo case GeneralCommandType.SetSubtitleStreamIndex: if (command.Arguments.TryGetValue("Index", out index)) { - if (int.TryParse(index, NumberStyles.Integer, _usCulture, out var val)) + if (int.TryParse(index, NumberStyles.Integer, CultureInfo.InvariantCulture, out var val)) { return SetSubtitleStreamIndex(val); } @@ -705,7 +738,7 @@ namespace Emby.Dlna.PlayTo case GeneralCommandType.SetVolume: if (command.Arguments.TryGetValue("Volume", out string vol)) { - if (int.TryParse(vol, NumberStyles.Integer, _usCulture, out var volume)) + if (int.TryParse(vol, NumberStyles.Integer, CultureInfo.InvariantCulture, out var volume)) { return _device.SetVolume(volume, cancellationToken); } @@ -736,6 +769,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); @@ -761,6 +798,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); diff --git a/Emby.Dlna/PlayTo/PlayToManager.cs b/Emby.Dlna/PlayTo/PlayToManager.cs index 8272e505a0..294bda5b6a 100644 --- a/Emby.Dlna/PlayTo/PlayToManager.cs +++ b/Emby.Dlna/PlayTo/PlayToManager.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System; @@ -9,7 +11,6 @@ using System.Threading.Tasks; using Jellyfin.Data.Events; using MediaBrowser.Common.Extensions; using MediaBrowser.Controller; -using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Dlna; using MediaBrowser.Controller.Drawing; using MediaBrowser.Controller.Library; @@ -33,7 +34,6 @@ namespace Emby.Dlna.PlayTo private readonly IServerApplicationHost _appHost; private readonly IImageProcessor _imageProcessor; private readonly IHttpClientFactory _httpClientFactory; - private readonly IServerConfigurationManager _config; private readonly IUserDataManager _userDataManager; private readonly ILocalizationManager _localization; @@ -45,7 +45,7 @@ namespace Emby.Dlna.PlayTo private SemaphoreSlim _sessionLock = new SemaphoreSlim(1, 1); private CancellationTokenSource _disposeCancellationTokenSource = new CancellationTokenSource(); - public PlayToManager(ILogger logger, ISessionManager sessionManager, ILibraryManager libraryManager, IUserManager userManager, IDlnaManager dlnaManager, IServerApplicationHost appHost, IImageProcessor imageProcessor, IDeviceDiscovery deviceDiscovery, IHttpClientFactory httpClientFactory, IServerConfigurationManager config, IUserDataManager userDataManager, ILocalizationManager localization, IMediaSourceManager mediaSourceManager, IMediaEncoder mediaEncoder) + public PlayToManager(ILogger logger, ISessionManager sessionManager, ILibraryManager libraryManager, IUserManager userManager, IDlnaManager dlnaManager, IServerApplicationHost appHost, IImageProcessor imageProcessor, IDeviceDiscovery deviceDiscovery, IHttpClientFactory httpClientFactory, IUserDataManager userDataManager, ILocalizationManager localization, IMediaSourceManager mediaSourceManager, IMediaEncoder mediaEncoder) { _logger = logger; _sessionManager = sessionManager; @@ -56,7 +56,6 @@ namespace Emby.Dlna.PlayTo _imageProcessor = imageProcessor; _deviceDiscovery = deviceDiscovery; _httpClientFactory = httpClientFactory; - _config = config; _userDataManager = userDataManager; _localization = localization; _mediaSourceManager = mediaSourceManager; @@ -171,7 +170,9 @@ namespace Emby.Dlna.PlayTo uuid = uri.ToString().GetMD5().ToString("N", CultureInfo.InvariantCulture); } - var sessionInfo = _sessionManager.LogSessionActivity("DLNA", _appHost.ApplicationVersionString, uuid, null, uri.OriginalString, null); + var sessionInfo = await _sessionManager + .LogSessionActivity("DLNA", _appHost.ApplicationVersionString, uuid, null, uri.OriginalString, null) + .ConfigureAwait(false); var controller = sessionInfo.SessionControllers.OfType().FirstOrDefault(); @@ -188,7 +189,7 @@ namespace Emby.Dlna.PlayTo _sessionManager.UpdateDeviceName(sessionInfo.Id, deviceName); - string serverAddress = _appHost.GetSmartApiUrl(info.LocalIpAddress); + string serverAddress = _appHost.GetSmartApiUrl(info.RemoteIpAddress); controller = new PlayToController( sessionInfo, 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 e28840a896..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; diff --git a/Emby.Dlna/PlayTo/SsdpHttpClient.cs b/Emby.Dlna/PlayTo/SsdpHttpClient.cs index d9f1ce4907..cade7b4c2c 100644 --- a/Emby.Dlna/PlayTo/SsdpHttpClient.cs +++ b/Emby.Dlna/PlayTo/SsdpHttpClient.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System; @@ -18,8 +20,6 @@ namespace Emby.Dlna.PlayTo private const string USERAGENT = "Microsoft-Windows/6.2 UPnP/1.0 Microsoft-DLNA DLNADOC/1.50"; private const string FriendlyName = "Jellyfin"; - private readonly CultureInfo _usCulture = new CultureInfo("en-US"); - private readonly IHttpClientFactory _httpClientFactory; public SsdpHttpClient(IHttpClientFactory httpClientFactory) @@ -43,10 +43,12 @@ namespace Emby.Dlna.PlayTo header, cancellationToken) .ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); return await XDocument.LoadAsync( stream, - LoadOptions.PreserveWhitespace, + LoadOptions.None, cancellationToken).ConfigureAwait(false); } @@ -76,14 +78,15 @@ namespace Emby.Dlna.PlayTo { using var options = new HttpRequestMessage(new HttpMethod("SUBSCRIBE"), url); options.Headers.UserAgent.ParseAdd(USERAGENT); - options.Headers.TryAddWithoutValidation("HOST", ip + ":" + port.ToString(_usCulture)); - options.Headers.TryAddWithoutValidation("CALLBACK", "<" + localIp + ":" + eventport.ToString(_usCulture) + ">"); + options.Headers.TryAddWithoutValidation("HOST", ip + ":" + port.ToString(CultureInfo.InvariantCulture)); + options.Headers.TryAddWithoutValidation("CALLBACK", "<" + localIp + ":" + eventport.ToString(CultureInfo.InvariantCulture) + ">"); options.Headers.TryAddWithoutValidation("NT", "upnp:event"); - options.Headers.TryAddWithoutValidation("TIMEOUT", "Second-" + timeOut.ToString(_usCulture)); + options.Headers.TryAddWithoutValidation("TIMEOUT", "Second-" + timeOut.ToString(CultureInfo.InvariantCulture)); using var response = await _httpClientFactory.CreateClient(NamedClient.Default) .SendAsync(options, HttpCompletionOption.ResponseHeadersRead) .ConfigureAwait(false); + response.EnsureSuccessStatusCode(); } public async Task GetDataAsync(string url, CancellationToken cancellationToken) @@ -92,12 +95,13 @@ 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); + response.EnsureSuccessStatusCode(); await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); try { return await XDocument.LoadAsync( stream, - LoadOptions.PreserveWhitespace, + LoadOptions.None, cancellationToken).ConfigureAwait(false); } catch diff --git a/Emby.Dlna/PlayTo/TransportCommands.cs b/Emby.Dlna/PlayTo/TransportCommands.cs index cbcf66e45c..b58669355d 100644 --- a/Emby.Dlna/PlayTo/TransportCommands.cs +++ b/Emby.Dlna/PlayTo/TransportCommands.cs @@ -46,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; @@ -68,9 +68,9 @@ 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 }; } @@ -89,8 +89,8 @@ namespace Emby.Dlna.PlayTo return new StateVariable { - Name = container.GetValue(UPnpNamespaces.Svc + "name"), - DataType = container.GetValue(UPnpNamespaces.Svc + "dataType"), + Name = container.GetValue(UPnpNamespaces.Svc + "name") ?? string.Empty, + DataType = container.GetValue(UPnpNamespaces.Svc + "dataType") ?? string.Empty, AllowedValues = allowedValues }; } @@ -166,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/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/Server/DescriptionXmlBuilder.cs b/Emby.Dlna/Server/DescriptionXmlBuilder.cs index 09525aae4e..80a45f2b25 100644 --- a/Emby.Dlna/Server/DescriptionXmlBuilder.cs +++ b/Emby.Dlna/Server/DescriptionXmlBuilder.cs @@ -15,7 +15,6 @@ namespace Emby.Dlna.Server { private readonly DeviceProfile _profile; - private readonly CultureInfo _usCulture = new CultureInfo("en-US"); private readonly string _serverUdn; private readonly string _serverAddress; private readonly string _serverName; @@ -193,10 +192,10 @@ namespace Emby.Dlna.Server .Append(SecurityElement.Escape(icon.MimeType ?? string.Empty)) .Append(""); builder.Append("") - .Append(SecurityElement.Escape(icon.Width.ToString(_usCulture))) + .Append(SecurityElement.Escape(icon.Width.ToString(CultureInfo.InvariantCulture))) .Append(""); builder.Append("") - .Append(SecurityElement.Escape(icon.Height.ToString(_usCulture))) + .Append(SecurityElement.Escape(icon.Height.ToString(CultureInfo.InvariantCulture))) .Append(""); builder.Append("") .Append(SecurityElement.Escape(icon.Depth ?? string.Empty)) diff --git a/Emby.Dlna/Service/BaseControlHandler.cs b/Emby.Dlna/Service/BaseControlHandler.cs index fda8346f9e..780aad9c18 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,7 +47,7 @@ namespace Emby.Dlna.Service private async Task ProcessControlRequestInternalAsync(ControlRequest request) { - ControlRequestInfo requestInfo = null; + ControlRequestInfo? requestInfo = null; using (var streamReader = new StreamReader(request.InputXml, Encoding.UTF8)) { @@ -64,7 +64,7 @@ namespace Emby.Dlna.Service requestInfo = await ParseRequestAsync(reader).ConfigureAwait(false); } - Logger.LogDebug("Received control request {0}", requestInfo.LocalName); + Logger.LogDebug("Received control request {LocalName}, params: {@Headers}", requestInfo.LocalName, requestInfo.Headers); var settings = new XmlWriterSettings { @@ -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); diff --git a/Emby.Dlna/Service/BaseService.cs b/Emby.Dlna/Service/BaseService.cs index a97c4d63a6..68fd987585 100644 --- a/Emby.Dlna/Service/BaseService.cs +++ b/Emby.Dlna/Service/BaseService.cs @@ -23,14 +23,14 @@ namespace Emby.Dlna.Service return EventManager.CancelEventSubscription(subscriptionId); } - public EventSubscriptionResponse RenewEventSubscription(string subscriptionId, string notificationType, string timeoutString, string callbackUrl) + public EventSubscriptionResponse RenewEventSubscription(string subscriptionId, string notificationType, string requestedTimeoutString, string callbackUrl) { - return EventManager.RenewEventSubscription(subscriptionId, notificationType, timeoutString, callbackUrl); + return EventManager.RenewEventSubscription(subscriptionId, notificationType, requestedTimeoutString, callbackUrl); } - public EventSubscriptionResponse CreateEventSubscription(string notificationType, string timeoutString, string callbackUrl) + public EventSubscriptionResponse CreateEventSubscription(string notificationType, string requestedTimeoutString, string callbackUrl) { - return EventManager.CreateEventSubscription(notificationType, timeoutString, callbackUrl); + return EventManager.CreateEventSubscription(notificationType, requestedTimeoutString, callbackUrl); } } } 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 d13871add8..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; @@ -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 5c5afe1c6e..149e4b5d91 100644 --- a/Emby.Drawing/Emby.Drawing.csproj +++ b/Emby.Drawing/Emby.Drawing.csproj @@ -6,11 +6,9 @@ - net5.0 + net6.0 false true - true - enable @@ -30,8 +28,4 @@ - - ../jellyfin.ruleset - - diff --git a/Emby.Drawing/ImageProcessor.cs b/Emby.Drawing/ImageProcessor.cs index 7d952aa23b..3f75e4fc79 100644 --- a/Emby.Drawing/ImageProcessor.cs +++ b/Emby.Drawing/ImageProcessor.cs @@ -26,7 +26,7 @@ namespace Emby.Drawing public sealed class ImageProcessor : IImageProcessor, IDisposable { // Increment this when there's a change requiring caches to be invalidated - private const string Version = "3"; + private const char Version = '3'; private static readonly HashSet _transparentImageTypes = new HashSet(StringComparer.OrdinalIgnoreCase) { ".png", ".webp", ".gif" }; @@ -102,7 +102,7 @@ namespace Emby.Drawing { var file = await ProcessImage(options).ConfigureAwait(false); - using (var fileStream = new FileStream(file.Item1, FileMode.Open, FileAccess.Read, FileShare.Read, IODefaults.FileStreamBufferSize, true)) + using (var fileStream = AsyncFile.OpenRead(file.Item1)) { await fileStream.CopyToAsync(toStream).ConfigureAwait(false); } diff --git a/Emby.Naming/Audio/AudioFileParser.cs b/Emby.Naming/Audio/AudioFileParser.cs index 8b47dd12e4..2b610ec796 100644 --- a/Emby.Naming/Audio/AudioFileParser.cs +++ b/Emby.Naming/Audio/AudioFileParser.cs @@ -1,7 +1,7 @@ using System; using System.IO; -using System.Linq; using Emby.Naming.Common; +using Jellyfin.Extensions; namespace Emby.Naming.Audio { @@ -18,8 +18,8 @@ namespace Emby.Naming.Audio /// 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/AudioBookInfo.cs b/Emby.Naming/AudioBook/AudioBookInfo.cs index 15702ff2ca..acd8905af6 100644 --- a/Emby.Naming/AudioBook/AudioBookInfo.cs +++ b/Emby.Naming/AudioBook/AudioBookInfo.cs @@ -15,7 +15,7 @@ namespace Emby.Naming.AudioBook /// List of files composing the actual audiobook. /// List of extra files. /// Alternative version of files. - public AudioBookInfo(string name, int? year, List files, List extras, List alternateVersions) + public AudioBookInfo(string name, int? year, IReadOnlyList files, IReadOnlyList extras, IReadOnlyList alternateVersions) { Name = name; Year = year; @@ -39,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 ca53228903..1e4a8d2edc 100644 --- a/Emby.Naming/AudioBook/AudioBookListResolver.cs +++ b/Emby.Naming/AudioBook/AudioBookListResolver.cs @@ -87,7 +87,7 @@ namespace Emby.Naming.AudioBook foreach (var audioFile in group) { var name = Path.GetFileNameWithoutExtension(audioFile.Path); - if (name.Equals("audiobook") || + if (name.Equals("audiobook", StringComparison.OrdinalIgnoreCase) || name.Contains(nameParserResult.Name, StringComparison.OrdinalIgnoreCase) || name.Contains(nameWithReplacedDots, StringComparison.OrdinalIgnoreCase)) { diff --git a/Emby.Naming/Common/NamingOptions.cs b/Emby.Naming/Common/NamingOptions.cs index 22a3e8bb49..7bc9fbce84 100644 --- a/Emby.Naming/Common/NamingOptions.cs +++ b/Emby.Naming/Common/NamingOptions.cs @@ -1,3 +1,5 @@ +#pragma warning disable CA1819 + using System; using System.Linq; using System.Text.RegularExpressions; @@ -137,8 +139,11 @@ 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|blu-ray|x264|x265|h264|xvid|xvidvd|xxx|www.www|AAC|DTS|\[.*\])([ _\,\.\(\)\[\]\-]|$)", - @"(\[.*\])" + @"^\s*(?.+?)[ _\,\.\(\)\[\]\-](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|\[.*\])([ _\,\.\(\)\[\]\-]|$)", + @"^(?.+?)(\[.*\])", + @"^\s*(?.+?)\WE[0-9]+(-|~)E?[0-9]+(\W|$)", + @"^\s*\[[^\]]+\](?!\.\w+$)\s*(?.+)", + @"^\s*(?.+?)\s+-\s+[0-9]+\s*$" }; SubtitleFileExtensions = new[] @@ -250,6 +255,8 @@ namespace Emby.Naming.Common }, // new EpisodeExpression(@"[\._ -]()[Ee][Pp]_?([0-9]+)([^\\/]*)$"), + // + new EpisodeExpression(@"[^\\/]*?()\.?[Ee]([0-9]+)\.([^\\/]*)$"), new EpisodeExpression("(?[0-9]{4})[\\.-](?[0-9]{2})[\\.-](?[0-9]{2})", true) { DateTimeFormats = new[] @@ -277,14 +284,14 @@ 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 }, // 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(@".*?(\[.*?\])+.*?(?[\w\s]+?)[\s_]*-[\s_]*(?[0-9]+).*$") + new EpisodeExpression(@".*[\\\/]?.*?(\[.*?\])+.*?(?[-\w\s]+?)[\s_]*-[\s_]*(?[0-9]+).*$") { IsNamed = true }, @@ -305,6 +312,12 @@ namespace Emby.Naming.Common // *** End Kodi Standard Naming + // "Episode 16", "Episode 16 - Title" + new EpisodeExpression(@"[Ee]pisode (?[0-9]+)(-(?[0-9]+))?[^\\\/]*$") + { + IsNamed = true + }, + new EpisodeExpression(@".*(\\|\/)[sS]?(?[0-9]+)[xX](?[0-9]+)[^\\\/]*$") { IsNamed = true @@ -362,12 +375,20 @@ namespace Emby.Naming.Common IsOptimistic = true, IsNamed = true }, - // "Episode 16", "Episode 16 - Title" - new EpisodeExpression(@".*[\\\/][^\\\/]* (?[0-9]{1,3})(-(?[0-9]{2,3}))*[^\\\/]*$") + + // Series and season only expression + // "the show/season 1", "the show/s01" + new EpisodeExpression(@"(.*(\\|\/))*(?.+)\/[Ss](eason)?[\. _\-]*(?[0-9]+)") { - IsOptimistic = true, IsNamed = true - } + }, + + // Series and season only expression + // "the show S01", "the show season 1" + new EpisodeExpression(@"(.*(\\|\/))*(?.+)[\. _\-]+[sS](eason)?[\. _\-]*(?[0-9]+)") + { + IsNamed = true + }, }; EpisodeWithoutSeasonExpressions = new[] @@ -478,6 +499,12 @@ namespace Emby.Naming.Common "-deleted", MediaType.Video), + new ExtraRule( + ExtraType.DeletedScene, + ExtraRuleType.Suffix, + "-deletedscene", + MediaType.Video), + new ExtraRule( ExtraType.Clip, ExtraRuleType.Suffix, diff --git a/Emby.Naming/Emby.Naming.csproj b/Emby.Naming/Emby.Naming.csproj index 63116f3680..2bf8eacb1b 100644 --- a/Emby.Naming/Emby.Naming.csproj +++ b/Emby.Naming/Emby.Naming.csproj @@ -1,4 +1,4 @@ - + @@ -6,15 +6,13 @@ - net5.0 + net6.0 false true - true true true true snupkg - enable @@ -23,11 +21,12 @@ - + - + + @@ -39,7 +38,7 @@ - + @@ -49,8 +48,4 @@ - - ../jellyfin.ruleset - - diff --git a/Emby.Naming/TV/EpisodeResolver.cs b/Emby.Naming/TV/EpisodeResolver.cs index f7df587864..5e952e47b7 100644 --- a/Emby.Naming/TV/EpisodeResolver.cs +++ b/Emby.Naming/TV/EpisodeResolver.cs @@ -16,7 +16,7 @@ namespace Emby.Naming.TV /// /// Initializes a new instance of the class. /// - /// object containing VideoFileExtensions and passed to , , and . + /// object containing VideoFileExtensions and passed to , and . public EpisodeResolver(NamingOptions options) { _options = options; @@ -62,12 +62,16 @@ 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); + if (!parsingResult.Success && !isStub) + { + return null; + } + return new EpisodeInfo(path) { Container = container, diff --git a/Emby.Naming/TV/SeriesInfo.cs b/Emby.Naming/TV/SeriesInfo.cs new file mode 100644 index 0000000000..5d6cb4bd37 --- /dev/null +++ b/Emby.Naming/TV/SeriesInfo.cs @@ -0,0 +1,29 @@ +namespace Emby.Naming.TV +{ + /// + /// Holder object for Series information. + /// + public class SeriesInfo + { + /// + /// Initializes a new instance of the class. + /// + /// Path to the file. + public SeriesInfo(string path) + { + Path = path; + } + + /// + /// Gets or sets the path. + /// + /// The path. + public string Path { get; set; } + + /// + /// Gets or sets the name of the series. + /// + /// The name of the series. + public string? Name { get; set; } + } +} diff --git a/Emby.Naming/TV/SeriesPathParser.cs b/Emby.Naming/TV/SeriesPathParser.cs new file mode 100644 index 0000000000..a62e5f4d63 --- /dev/null +++ b/Emby.Naming/TV/SeriesPathParser.cs @@ -0,0 +1,61 @@ +using System.Globalization; +using Emby.Naming.Common; + +namespace Emby.Naming.TV +{ + /// + /// Used to parse information about series from paths containing more information that only the series name. + /// Uses the same regular expressions as the EpisodePathParser but have different success criteria. + /// + public static class SeriesPathParser + { + /// + /// Parses information about series from path. + /// + /// object containing EpisodeExpressions and MultipleEpisodeExpressions. + /// Path. + /// Returns object. + public static SeriesPathParserResult Parse(NamingOptions options, string path) + { + SeriesPathParserResult? result = null; + + foreach (var expression in options.EpisodeExpressions) + { + var currentResult = Parse(path, expression); + if (currentResult.Success) + { + result = currentResult; + break; + } + } + + if (result != null) + { + if (!string.IsNullOrEmpty(result.SeriesName)) + { + result.SeriesName = result.SeriesName.Trim(' ', '_', '.', '-'); + } + } + + return result ?? new SeriesPathParserResult(); + } + + private static SeriesPathParserResult Parse(string name, EpisodeExpression expression) + { + var result = new SeriesPathParserResult(); + + var match = expression.Regex.Match(name); + + if (match.Success && match.Groups.Count >= 3) + { + if (expression.IsNamed) + { + result.SeriesName = match.Groups["seriesname"].Value; + result.Success = !string.IsNullOrEmpty(result.SeriesName) && !string.IsNullOrEmpty(match.Groups["seasonnumber"]?.Value); + } + } + + return result; + } + } +} diff --git a/Emby.Naming/TV/SeriesPathParserResult.cs b/Emby.Naming/TV/SeriesPathParserResult.cs new file mode 100644 index 0000000000..44cd2fdfa1 --- /dev/null +++ b/Emby.Naming/TV/SeriesPathParserResult.cs @@ -0,0 +1,19 @@ +namespace Emby.Naming.TV +{ + /// + /// Holder object for result. + /// + public class SeriesPathParserResult + { + /// + /// Gets or sets the name of the series. + /// + /// The name of the series. + public string? SeriesName { get; set; } + + /// + /// Gets or sets a value indicating whether parsing was successful. + /// + public bool Success { get; set; } + } +} diff --git a/Emby.Naming/TV/SeriesResolver.cs b/Emby.Naming/TV/SeriesResolver.cs new file mode 100644 index 0000000000..156a03c9ed --- /dev/null +++ b/Emby.Naming/TV/SeriesResolver.cs @@ -0,0 +1,49 @@ +using System.IO; +using System.Text.RegularExpressions; +using Emby.Naming.Common; + +namespace Emby.Naming.TV +{ + /// + /// Used to resolve information about series from path. + /// + public static class SeriesResolver + { + /// + /// Regex that matches strings of at least 2 characters separated by a dot or underscore. + /// Used for removing separators between words, i.e turns "The_show" into "The show" while + /// preserving namings like "S.H.O.W". + /// + private static readonly Regex _seriesNameRegex = new Regex(@"((?[^\._]{2,})[\._]*)|([\._](?[^\._]{2,}))"); + + /// + /// Resolve information about series from path. + /// + /// object passed to . + /// Path to series. + /// SeriesInfo. + public static SeriesInfo Resolve(NamingOptions options, string path) + { + string seriesName = Path.GetFileName(path); + + SeriesPathParserResult result = SeriesPathParser.Parse(options, path); + if (result.Success) + { + if (!string.IsNullOrEmpty(result.SeriesName)) + { + seriesName = result.SeriesName; + } + } + + if (!string.IsNullOrEmpty(seriesName)) + { + seriesName = _seriesNameRegex.Replace(seriesName, "${a} ${b}").Trim(); + } + + return new SeriesInfo(path) + { + Name = seriesName + }; + } + } +} diff --git a/Emby.Naming/Video/CleanStringParser.cs b/Emby.Naming/Video/CleanStringParser.cs index 4eef3ebc5e..b813335008 100644 --- a/Emby.Naming/Video/CleanStringParser.cs +++ b/Emby.Naming/Video/CleanStringParser.cs @@ -17,38 +17,39 @@ namespace Emby.Naming.Video /// List of regex to parse name and year from. /// Parsing result string. /// True if parsing was successful. - public static bool TryClean([NotNullWhen(true)] string? name, IReadOnlyList expressions, out ReadOnlySpan newName) + public static bool TryClean([NotNullWhen(true)] string? name, IReadOnlyList expressions, out string newName) { if (string.IsNullOrEmpty(name)) { - newName = ReadOnlySpan.Empty; + newName = string.Empty; return false; } - var len = expressions.Count; - for (int i = 0; i < len; i++) + // Iteratively apply the regexps to clean the string. + bool cleaned = false; + for (int i = 0; i < expressions.Count; i++) { if (TryClean(name, expressions[i], out newName)) { - return true; + cleaned = true; + name = newName; } } - newName = ReadOnlySpan.Empty; - return false; + newName = cleaned ? name : string.Empty; + return cleaned; } - private static bool TryClean(string name, Regex expression, out ReadOnlySpan newName) + private static bool TryClean(string name, Regex expression, out string newName) { var match = expression.Match(name); - int index = match.Index; - if (match.Success && index != 0) + if (match.Success && match.Groups.TryGetValue("cleaned", out var cleaned)) { - newName = name.AsSpan().Slice(0, match.Index); + newName = cleaned.Value; return true; } - newName = ReadOnlySpan.Empty; + newName = string.Empty; return false; } } diff --git a/Emby.Naming/Video/ExtraResolver.cs b/Emby.Naming/Video/ExtraResolver.cs index 1d3b36a1ad..7bc226614e 100644 --- a/Emby.Naming/Video/ExtraResolver.cs +++ b/Emby.Naming/Video/ExtraResolver.cs @@ -1,6 +1,5 @@ using System; using System.IO; -using System.Linq; using System.Text.RegularExpressions; using Emby.Naming.Audio; using Emby.Naming.Common; @@ -12,6 +11,7 @@ namespace Emby.Naming.Video /// public class ExtraResolver { + private static readonly char[] _digits = new[] { '0', '1', '2', '3', '4', '5', '6', '7', '8', '9' }; private readonly NamingOptions _options; /// @@ -29,70 +29,74 @@ namespace Emby.Naming.Video /// Path to file. /// Returns object. 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; + } } - } - - 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; + // Trim the digits from the end of the filename so we can recognize things like -trailer2 + var filename = Path.GetFileNameWithoutExtension(pathSpan).TrimEnd(_digits); + + if (filename.EndsWith(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/FlagParser.cs b/Emby.Naming/Video/FlagParser.cs deleted file mode 100644 index 439de18138..0000000000 --- a/Emby.Naming/Video/FlagParser.cs +++ /dev/null @@ -1,53 +0,0 @@ -using System; -using System.IO; -using Emby.Naming.Common; - -namespace Emby.Naming.Video -{ - /// - /// Parses list of flags from filename based on delimiters. - /// - public class FlagParser - { - private readonly NamingOptions _options; - - /// - /// Initializes a new instance of the class. - /// - /// object containing VideoFlagDelimiters. - public FlagParser(NamingOptions options) - { - _options = options; - } - - /// - /// Calls GetFlags function with _options.VideoFlagDelimiters parameter. - /// - /// Path to file. - /// List of found flags. - public string[] GetFlags(string path) - { - return GetFlags(path, _options.VideoFlagDelimiters); - } - - /// - /// Parses flags from filename based on delimiters. - /// - /// Path to file. - /// Delimiters used to extract flags. - /// List of found flags. - public string[] GetFlags(string path, char[] delimiters) - { - if (string.IsNullOrEmpty(path)) - { - return Array.Empty(); - } - - // Note: the tags need be be surrounded be either a space ( ), hyphen -, dot . or underscore _. - - var file = Path.GetFileName(path); - - return file.Split(delimiters, StringSplitOptions.RemoveEmptyEntries); - } - } -} diff --git a/Emby.Naming/Video/Format3DParser.cs b/Emby.Naming/Video/Format3DParser.cs index 4fd5d78ba7..0890899894 100644 --- a/Emby.Naming/Video/Format3DParser.cs +++ b/Emby.Naming/Video/Format3DParser.cs @@ -1,45 +1,37 @@ using System; -using System.Linq; using Emby.Naming.Common; namespace Emby.Naming.Video { /// - /// Parste 3D format related flags. + /// Parse 3D format related flags. /// - public class Format3DParser + public static class Format3DParser { - private readonly NamingOptions _options; - - /// - /// Initializes a new instance of the class. - /// - /// object containing VideoFlagDelimiters and passes options to . - public Format3DParser(NamingOptions options) - { - _options = options; - } + // Static default result to save on allocation costs. + private static readonly Format3DResult _defaultResult = new (false, null); /// /// Parse 3D format related flags. /// /// Path to file. + /// The naming options. /// Returns object. - public Format3DResult Parse(string path) + public static Format3DResult Parse(ReadOnlySpan path, NamingOptions namingOptions) { - int oldLen = _options.VideoFlagDelimiters.Length; - var delimiters = new char[oldLen + 1]; - _options.VideoFlagDelimiters.CopyTo(delimiters, 0); + int oldLen = namingOptions.VideoFlagDelimiters.Length; + Span delimiters = stackalloc char[oldLen + 1]; + namingOptions.VideoFlagDelimiters.AsSpan().CopyTo(delimiters); delimiters[oldLen] = ' '; - return Parse(new FlagParser(_options).GetFlags(path, delimiters)); + return Parse(path, delimiters, namingOptions); } - internal Format3DResult Parse(string[] videoFlags) + private static Format3DResult Parse(ReadOnlySpan path, ReadOnlySpan 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) { @@ -47,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 path, Format3DRule rule, ReadOnlySpan delimiters) { - var result = new Format3DResult(); + bool is3D = false; + string? format3D = null; - if (string.IsNullOrEmpty(rule.PrecedingToken)) + // 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.PrecedingToken); + 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.PrecedingToken, 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 ac935f2030..aac959c133 100644 --- a/Emby.Naming/Video/Format3DResult.cs +++ b/Emby.Naming/Video/Format3DResult.cs @@ -1,5 +1,3 @@ -using System.Collections.Generic; - namespace Emby.Naming.Video { /// @@ -10,27 +8,24 @@ namespace Emby.Naming.Video /// /// Initializes a new instance of the class. /// - public Format3DResult() + /// A value indicating whether the parsed string contains 3D tokens. + /// The 3D format. Value might be null if [is3D] is false. + public Format3DResult(bool is3D, string? format3D) { - Tokens = new List(); + Is3D = is3D; + Format3D = format3D; } /// - /// Gets or sets a value indicating whether [is3 d]. + /// Gets a value indicating whether [is3 d]. /// /// true if [is3 d]; otherwise, false. - public bool Is3D { get; set; } + public bool Is3D { get; } /// - /// Gets or sets the format3 d. + /// Gets the format3 d. /// /// The format3 d. - public string? Format3D { get; set; } - - /// - /// Gets or sets the tokens. - /// - /// The tokens. - public List Tokens { get; set; } + public string? Format3D { get; } } } diff --git a/Emby.Naming/Video/StackResolver.cs b/Emby.Naming/Video/StackResolver.cs index 550c429614..36f65a5624 100644 --- a/Emby.Naming/Video/StackResolver.cs +++ b/Emby.Naming/Video/StackResolver.cs @@ -85,10 +85,8 @@ namespace Emby.Naming.Video /// Enumerable of videos. public IEnumerable Resolve(IEnumerable 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(); diff --git a/Emby.Naming/Video/VideoFileInfo.cs b/Emby.Naming/Video/VideoFileInfo.cs index 1457db7378..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 @@ -106,9 +107,9 @@ namespace Emby.Naming.Video /// Gets the file name without extension. /// /// The file name without extension. - public string FileNameWithoutExtension => !IsDirectory - ? System.IO.Path.GetFileNameWithoutExtension(Path) - : System.IO.Path.GetFileName(Path); + public ReadOnlySpan FileNameWithoutExtension => !IsDirectory + ? System.IO.Path.GetFileNameWithoutExtension(Path.AsSpan()) + : System.IO.Path.GetFileName(Path.AsSpan()); /// public override string ToString() diff --git a/Emby.Naming/Video/VideoListResolver.cs b/Emby.Naming/Video/VideoListResolver.cs index 7b6a1705ba..ed7d511a39 100644 --- a/Emby.Naming/Video/VideoListResolver.cs +++ b/Emby.Naming/Video/VideoListResolver.cs @@ -12,31 +12,19 @@ namespace Emby.Naming.Video /// /// Resolves alternative versions and extras from list of video files. /// - public class VideoListResolver + public static class VideoListResolver { - private readonly NamingOptions _options; - - /// - /// Initializes a new instance of the class. - /// - /// object containing CleanStringRegexes and VideoFlagDelimiters and passes options to and . - public VideoListResolver(NamingOptions options) - { - _options = options; - } - /// /// Resolves alternative versions and extras from list of video files. /// /// List of related video files. + /// The naming options. /// Indication we should consider multi-versions of content. /// Returns enumerable of which groups files together when related. - public IEnumerable Resolve(List files, bool supportMultiVersion = true) + public static IEnumerable Resolve(IEnumerable files, NamingOptions namingOptions, bool supportMultiVersion = true) { - var videoResolver = new VideoResolver(_options); - var videoInfos = files - .Select(i => videoResolver.Resolve(i.FullName, i.IsDirectory)) + .Select(i => VideoResolver.Resolve(i.FullName, i.IsDirectory, namingOptions)) .OfType() .ToList(); @@ -46,7 +34,7 @@ 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 @@ -59,23 +47,17 @@ namespace Emby.Naming.Video { var info = new VideoInfo(stack.Name) { - Files = stack.Files.Select(i => videoResolver.Resolve(i, stack.IsDirectoryStack)) + Files = stack.Files.Select(i => VideoResolver.Resolve(i, stack.IsDirectoryStack, namingOptions)) .OfType() .ToList() }; info.Year = info.Files[0].Year; - var extraBaseNames = new List { 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; } @@ -88,15 +70,12 @@ namespace Emby.Naming.Video foreach (var media in standaloneMedia) { - var info = new VideoInfo(media.Name) { Files = new List { media } }; + var info = new VideoInfo(media.Name) { Files = new[] { media } }; info.Year = info.Files[0].Year; - var extras = GetExtras(remainingFiles, new List { media.FileNameWithoutExtension }); - - remainingFiles = remainingFiles - .Except(extras.Concat(new[] { media })) - .ToList(); + remainingFiles.Remove(media); + var extras = ExtractExtras(remainingFiles, media.FileNameWithoutExtension, namingOptions.VideoFlagDelimiters); info.Extras = extras; @@ -105,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 @@ -114,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 { folderName }); - - remainingFiles = remainingFiles - .Except(extras) - .ToList(); - + var extras = ExtractExtras(remainingFiles, folderName, namingOptions.VideoFlagDelimiters); extras.AddRange(info.Extras); info.Extras = extras; } @@ -164,96 +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 { i }, + Files = new[] { i }, Year = i.Year })); return list; } - private IEnumerable GetVideosGroupedByVersion(List videos) + private static List GetVideosGroupedByVersion(List videos, NamingOptions namingOptions) { if (videos.Count == 0) { return videos; } - var list = new List(); - - var folderName = Path.GetFileName(Path.GetDirectoryName(videos[0].Files[0].Path)); + var folderName = Path.GetFileName(Path.GetDirectoryName(videos[0].Files[0].Path.AsSpan())); - 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 + { + videos[0] + }; - return list; + var alternateVersionsLen = videos.Count - 1; + var alternateVersions = new VideoFileInfo[alternateVersionsLen]; + var extras = new List(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; - private bool HaveSameYear(List videos) - { - return videos.Select(i => i.Year ?? -1).Distinct().Count() < 2; + return list; } - private bool IsEligibleForMultiVersion(string folderName, string testFilePath) + private static bool HaveSameYear(IReadOnlyList videos) { - string testFilename = Path.GetFileNameWithoutExtension(testFilePath); - if (testFilename.StartsWith(folderName, StringComparison.OrdinalIgnoreCase)) + if (videos.Count == 1) { - // 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 true; + } - if (CleanStringParser.TryClean(testFilename, _options.CleanStringRegexes, out var cleanName)) + var firstYear = videos[0].Year ?? -1; + for (var i = 1; i < videos.Count; i++) + { + if ((videos[i].Year ?? -1) != firstYear) { - testFilename = cleanName.Trim().ToString(); + return false; } + } - // The CleanStringParser should have removed common keywords etc. - return string.IsNullOrEmpty(testFilename) - || testFilename[0] == '-' - || Regex.IsMatch(testFilename, @"^\[([^]]*)\]"); + return true; + } + + private static bool IsEligibleForMultiVersion(ReadOnlySpan folderName, string testFilePath, NamingOptions namingOptions) + { + var testFilename = Path.GetFileNameWithoutExtension(testFilePath.AsSpan()); + if (!testFilename.StartsWith(folderName, StringComparison.OrdinalIgnoreCase)) + { + return false; } - return false; + // Remove the folder name before cleaning as we don't care about cleaning that part + if (folderName.Length <= testFilename.Length) + { + 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] == '-' + || Regex.IsMatch(tmpTestFilename, @"^\[([^]]*)\]", RegexOptions.Compiled); + } + + private static ReadOnlySpan TrimFilenameDelimiters(ReadOnlySpan name, ReadOnlySpan videoFlagDelimiters) + { + return name.IsEmpty ? name : name.TrimEnd().TrimEnd(videoFlagDelimiters).TrimEnd(); } - private List GetExtras(IEnumerable remainingFiles, List baseNames) + private static bool StartsWith(ReadOnlySpan fileName, ReadOnlySpan baseName, ReadOnlySpan trimmedBaseName) { - foreach (var name in baseNames.ToList()) + if (baseName.IsEmpty) { - var trimmedName = name.TrimEnd().TrimEnd(_options.VideoFlagDelimiters).TrimEnd(); - baseNames.Add(trimmedName); + return false; } - return remainingFiles - .Where(i => i.ExtraType != null) - .Where(i => baseNames.Any(b => - i.FileNameWithoutExtension.StartsWith(b, StringComparison.OrdinalIgnoreCase))) - .ToList(); + return fileName.StartsWith(baseName, StringComparison.OrdinalIgnoreCase) + || (!trimmedBaseName.IsEmpty && fileName.StartsWith(trimmedBaseName, StringComparison.OrdinalIgnoreCase)); + } + + /// + /// Finds similar filenames to that of [baseName] and removes any matches from [remainingFiles]. + /// + /// The list of remaining filenames. + /// The base name to use for the comparison. + /// The video flag delimiters. + /// A list of video extras for [baseName]. + private static List ExtractExtras(IList remainingFiles, ReadOnlySpan baseName, ReadOnlySpan videoFlagDelimiters) + { + return ExtractExtras(remainingFiles, baseName, ReadOnlySpan.Empty, videoFlagDelimiters); + } + + /// + /// Finds similar filenames to that of [firstBaseName] and [secondBaseName] and removes any matches from [remainingFiles]. + /// + /// The list of remaining filenames. + /// The first base name to use for the comparison. + /// The second base name to use for the comparison. + /// The video flag delimiters. + /// A list of video extras for [firstBaseName] and [secondBaseName]. + private static List ExtractExtras(IList remainingFiles, ReadOnlySpan firstBaseName, ReadOnlySpan secondBaseName, ReadOnlySpan videoFlagDelimiters) + { + var trimmedFirstBaseName = TrimFilenameDelimiters(firstBaseName, videoFlagDelimiters); + var trimmedSecondBaseName = TrimFilenameDelimiters(secondBaseName, videoFlagDelimiters); + + var result = new List(); + for (var pos = remainingFiles.Count - 1; pos >= 0; pos--) + { + 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 result; } } } diff --git a/Emby.Naming/Video/VideoResolver.cs b/Emby.Naming/Video/VideoResolver.cs index 79a6da8f7b..4c9df27f50 100644 --- a/Emby.Naming/Video/VideoResolver.cs +++ b/Emby.Naming/Video/VideoResolver.cs @@ -1,46 +1,36 @@ using System; using System.Diagnostics.CodeAnalysis; using System.IO; -using System.Linq; using Emby.Naming.Common; +using Jellyfin.Extensions; namespace Emby.Naming.Video { /// /// Resolves from file path. /// - public class VideoResolver + public static class VideoResolver { - private readonly NamingOptions _options; - - /// - /// Initializes a new instance of the class. - /// - /// object containing VideoFileExtensions, StubFileExtensions, CleanStringRegexes and CleanDateTimeRegexes - /// and passes options in , , and . - public VideoResolver(NamingOptions options) - { - _options = options; - } - /// /// Resolves the directory. /// /// The path. + /// The naming options. /// VideoFileInfo. - public VideoFileInfo? ResolveDirectory(string? path) + public static VideoFileInfo? ResolveDirectory(string? path, NamingOptions namingOptions) { - return Resolve(path, true); + return Resolve(path, true, namingOptions); } /// /// Resolves the file. /// /// The path. + /// The naming options. /// VideoFileInfo. - public VideoFileInfo? ResolveFile(string? path) + public static VideoFileInfo? ResolveFile(string? path, NamingOptions namingOptions) { - return Resolve(path, false); + return Resolve(path, false, namingOptions); } /// @@ -48,10 +38,11 @@ namespace Emby.Naming.Video /// /// The path. /// if set to true [is folder]. + /// The naming options. /// Whether or not the name should be parsed for info. /// VideoFileInfo. /// path is null. - 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)) { @@ -59,18 +50,18 @@ namespace Emby.Naming.Video } bool isStub = false; - string? container = null; + ReadOnlySpan container = ReadOnlySpan.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; } @@ -81,33 +72,30 @@ 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 newName)) + && TryCleanString(name, namingOptions, out var newName)) { - name = newName.ToString(); + name = newName; } } return new VideoFileInfo( path: path, - container: container, + container: container.IsEmpty ? null : container.ToString(), isStub: isStub, name: name, year: year, @@ -123,43 +111,47 @@ namespace Emby.Naming.Video /// Determines if path is video file based on extension. /// /// Path to file. + /// The naming options. /// True if is video file. - public bool IsVideoFile(string path) + public static bool IsVideoFile(string path, NamingOptions namingOptions) { - var extension = Path.GetExtension(path); - return _options.VideoFileExtensions.Contains(extension, StringComparer.OrdinalIgnoreCase); + var extension = Path.GetExtension(path.AsSpan()); + return namingOptions.VideoFileExtensions.Contains(extension, StringComparison.OrdinalIgnoreCase); } /// /// Determines if path is video file stub based on extension. /// /// Path to file. + /// The naming options. /// True if is video file stub. - public bool IsStubFile(string path) + public static bool IsStubFile(string path, NamingOptions namingOptions) { - var extension = Path.GetExtension(path); - return _options.StubFileExtensions.Contains(extension, StringComparer.OrdinalIgnoreCase); + var extension = Path.GetExtension(path.AsSpan()); + return namingOptions.StubFileExtensions.Contains(extension, StringComparison.OrdinalIgnoreCase); } /// /// Tries to clean name of clutter. /// /// Raw name. + /// The naming options. /// Clean name. /// True if cleaning of name was successful. - public bool TryCleanString([NotNullWhen(true)] string? name, out ReadOnlySpan newName) + public static bool TryCleanString([NotNullWhen(true)] string? name, NamingOptions namingOptions, out string newName) { - return CleanStringParser.TryClean(name, _options.CleanStringRegexes, out newName); + return CleanStringParser.TryClean(name, namingOptions.CleanStringRegexes, out newName); } /// /// Tries to get name and year from raw name. /// /// Raw name. + /// The naming options. /// Returns with name and optional year. - public CleanDateTimeResult CleanDateTime(string name) + 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/Emby.Notifications.csproj b/Emby.Notifications/Emby.Notifications.csproj index 5a2aea6423..d200682e65 100644 --- a/Emby.Notifications/Emby.Notifications.csproj +++ b/Emby.Notifications/Emby.Notifications.csproj @@ -6,13 +6,9 @@ - net5.0 + net6.0 false true - true - enable - AllEnabledByDefault - ../jellyfin.ruleset diff --git a/Emby.Notifications/NotificationEntryPoint.cs b/Emby.Notifications/NotificationEntryPoint.cs index 7433d3c8ae..e8ae14ff22 100644 --- a/Emby.Notifications/NotificationEntryPoint.cs +++ b/Emby.Notifications/NotificationEntryPoint.cs @@ -77,7 +77,6 @@ namespace Emby.Notifications { _libraryManager.ItemAdded += OnLibraryManagerItemAdded; _appHost.HasPendingRestartChanged += OnAppHostHasPendingRestartChanged; - _appHost.HasUpdateAvailableChanged += OnAppHostHasUpdateAvailableChanged; _activityManager.EntryCreated += OnActivityManagerEntryCreated; return Task.CompletedTask; @@ -132,25 +131,6 @@ namespace Emby.Notifications return _config.GetConfiguration("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) { if (!FilterItem(e.Item)) @@ -325,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 2b66181599..bf6252c195 100644 --- a/Emby.Photos/Emby.Photos.csproj +++ b/Emby.Photos/Emby.Photos.csproj @@ -19,13 +19,9 @@ - net5.0 + net6.0 false true - true - enable - AllEnabledByDefault - ../jellyfin.ruleset diff --git a/Emby.Server.Implementations/AppBase/BaseApplicationPaths.cs b/Emby.Server.Implementations/AppBase/BaseApplicationPaths.cs index 660bbb2deb..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; } /// @@ -55,11 +55,7 @@ namespace Emby.Server.Implementations.AppBase /// Gets the folder path to the data directory. /// /// The data directory. - public string DataPath - { - get => _dataPath; - private set => _dataPath = Directory.CreateDirectory(value).FullName; - } + public string DataPath => _dataPath; /// public string VirtualDataPath => "%AppDataPath%"; diff --git a/Emby.Server.Implementations/AppBase/BaseConfigurationManager.cs b/Emby.Server.Implementations/AppBase/BaseConfigurationManager.cs index 4f72c8ce15..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 _configurations = new ConcurrentDictionary(); + /// + /// The _configuration sync lock. + /// + private readonly object _configurationSyncLock = new object(); + private ConfigurationStore[] _configurationStores = Array.Empty(); private IConfigurationFactory[] _configurationFactories = Array.Empty(); @@ -31,11 +38,6 @@ namespace Emby.Server.Implementations.AppBase /// private bool _configurationLoaded; - /// - /// The _configuration sync lock. - /// - private readonly object _configurationSyncLock = new object(); - /// /// The _configuration. /// @@ -297,25 +299,29 @@ namespace Emby.Server.Implementations.AppBase /// 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 29bac66340..3a916e5c01 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,27 +33,27 @@ namespace Emby.Server.Implementations.AppBase } catch (Exception) { - configuration = Activator.CreateInstance(type) ?? throw new ArgumentException($"Provided path ({type}) is not valid.", nameof(type)); + // Note: CreateInstance returns null for Nullable, e.g. CreateInstance(typeof(int?)) returns null. + configuration = Activator.CreateInstance(type)!; } using var stream = new MemoryStream(buffer?.Length ?? 0); xmlSerializer.SerializeToStream(configuration, stream); // Take the object we just got and serialize it back to bytes - byte[] newBytes = stream.GetBuffer(); - int newBytesLen = (int)stream.Length; + Span newBytes = stream.GetBuffer().AsSpan(0, (int)stream.Length); // If the file didn't exist before, or if something has changed, re-save - if (buffer == null || !newBytes.AsSpan(0, newBytesLen).SequenceEqual(buffer)) + if (buffer == null || !newBytes.SequenceEqual(buffer)) { 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 - // 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); + fs.Write(newBytes); } } diff --git a/Emby.Server.Implementations/ApplicationHost.cs b/Emby.Server.Implementations/ApplicationHost.cs index 1ed74210dc..903c311334 100644 --- a/Emby.Server.Implementations/ApplicationHost.cs +++ b/Emby.Server.Implementations/ApplicationHost.cs @@ -1,6 +1,9 @@ +#nullable disable + #pragma warning disable CS1591 using System; +using System.Collections.Concurrent; using System.Collections.Generic; using System.Diagnostics; using System.Globalization; @@ -16,6 +19,7 @@ using Emby.Dlna; using Emby.Dlna.Main; using Emby.Dlna.Ssdp; using Emby.Drawing; +using Emby.Naming.Common; using Emby.Notifications; using Emby.Photos; using Emby.Server.Implementations.Archiving; @@ -36,7 +40,6 @@ using Emby.Server.Implementations.Playlists; using Emby.Server.Implementations.Plugins; using Emby.Server.Implementations.QuickConnect; using Emby.Server.Implementations.ScheduledTasks; -using Emby.Server.Implementations.Security; using Emby.Server.Implementations.Serialization; using Emby.Server.Implementations.Session; using Emby.Server.Implementations.SyncPlay; @@ -55,9 +58,9 @@ using MediaBrowser.Common.Updates; using MediaBrowser.Controller; using MediaBrowser.Controller.Channels; using MediaBrowser.Controller.Chapters; +using MediaBrowser.Controller.ClientEvent; using MediaBrowser.Controller.Collections; using MediaBrowser.Controller.Configuration; -using MediaBrowser.Controller.Devices; using MediaBrowser.Controller.Dlna; using MediaBrowser.Controller.Drawing; using MediaBrowser.Controller.Dto; @@ -73,7 +76,6 @@ using MediaBrowser.Controller.Plugins; using MediaBrowser.Controller.Providers; using MediaBrowser.Controller.QuickConnect; using MediaBrowser.Controller.Resolvers; -using MediaBrowser.Controller.Security; using MediaBrowser.Controller.Session; using MediaBrowser.Controller.Sorting; using MediaBrowser.Controller.Subtitles; @@ -101,7 +103,6 @@ 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 @@ -116,6 +117,11 @@ namespace Emby.Server.Implementations /// private static readonly string[] _relevantEnvVarPrefixes = { "JELLYFIN_", "DOTNET_", "ASPNETCORE_" }; + /// + /// The disposable parts. + /// + private readonly ConcurrentDictionary _disposableParts = new (); + private readonly IFileSystem _fileSystemManager; private readonly IConfiguration _startupConfig; private readonly IXmlSerializer _xmlSerializer; @@ -125,7 +131,57 @@ namespace Emby.Server.Implementations private List _creatingInstances; private IMediaEncoder _mediaEncoder; private ISessionManager _sessionManager; - private string[] _urlPrefixes; + + /// + /// Gets or sets all concrete types. + /// + /// All concrete types. + private Type[] _allConcreteTypes; + + private DeviceId _deviceId; + + private bool _disposed = false; + + /// + /// Initializes a new instance of the class. + /// + /// Instance of the interface. + /// Instance of the interface. + /// Instance of the interface. + /// The interface. + public ApplicationHost( + IServerApplicationPaths applicationPaths, + ILoggerFactory loggerFactory, + IStartupOptions options, + IConfiguration startupConfig) + { + ApplicationPaths = applicationPaths; + LoggerFactory = loggerFactory; + _startupOptions = options; + _startupConfig = startupConfig; + _fileSystemManager = new ManagedFileSystem(LoggerFactory.CreateLogger(), applicationPaths); + + Logger = LoggerFactory.CreateLogger(); + _fileSystemManager.AddShortcutHandler(new MbLinkShortcutHandler(_fileSystemManager)); + + 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(), + this, + ConfigurationManager.Configuration, + ApplicationPaths.PluginsPath, + ApplicationVersion); + } + + /// + /// Occurs when [has pending restart changed]. + /// + public event EventHandler HasPendingRestartChanged; /// /// Gets a value indicating whether this instance can self restart. @@ -148,25 +204,14 @@ namespace Emby.Server.Implementations return false; } - if (OperatingSystem.Id == OperatingSystemId.Windows - || OperatingSystem.Id == OperatingSystemId.Darwin) - { - return true; - } - - return false; + return OperatingSystem.IsWindows() || OperatingSystem.IsMacOS(); } } /// /// Gets the singleton instance. /// - public INetworkManager NetManager { get; internal set; } - - /// - /// Occurs when [has pending restart changed]. - /// - public event EventHandler HasPendingRestartChanged; + public INetworkManager NetManager { get; private set; } /// /// Gets a value indicating whether this instance has changes that require the entire application to restart. @@ -182,35 +227,22 @@ namespace Emby.Server.Implementations /// protected ILogger Logger { get; } - protected IServiceCollection ServiceCollection { get; } - /// /// Gets the logger factory. /// protected ILoggerFactory LoggerFactory { get; } /// - /// Gets or sets the application paths. + /// Gets the application paths. /// /// The application paths. - protected IServerApplicationPaths ApplicationPaths { get; set; } + protected IServerApplicationPaths ApplicationPaths { get; } /// - /// Gets or sets all concrete types. - /// - /// All concrete types. - private Type[] _allConcreteTypes; - - /// - /// The disposable parts. - /// - private readonly List _disposableParts = new List(); - - /// - /// Gets or sets the configuration manager. + /// Gets the configuration manager. /// /// The configuration manager. - public ServerConfigurationManager ConfigurationManager { get; set; } + public ServerConfigurationManager ConfigurationManager { get; } /// /// Gets or sets the service provider. @@ -232,79 +264,6 @@ namespace Emby.Server.Implementations /// public string PublishedServerUrl => _startupOptions.PublishedServerUrl ?? _startupConfig[UdpServer.AddressOverrideConfigKey]; - /// - /// Initializes a new instance of the class. - /// - /// Instance of the interface. - /// Instance of the interface. - /// Instance of the interface. - /// The interface. - /// Instance of the interface. - /// Instance of the interface. - public ApplicationHost( - IServerApplicationPaths applicationPaths, - ILoggerFactory loggerFactory, - IStartupOptions options, - IConfiguration startupConfig, - IFileSystem fileSystem, - IServiceCollection serviceCollection) - { - ApplicationPaths = applicationPaths; - LoggerFactory = loggerFactory; - _startupOptions = options; - _startupConfig = startupConfig; - _fileSystemManager = fileSystem; - ServiceCollection = serviceCollection; - - Logger = LoggerFactory.CreateLogger(); - fileSystem.AddShortcutHandler(new MbLinkShortcutHandler(fileSystem)); - - 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(), - this, - ConfigurationManager.Configuration, - ApplicationPaths.PluginsPath, - ApplicationVersion); - } - - /// - /// 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. - /// - 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) - { - var appPaths = ApplicationPaths; - - return path.Replace(appPaths.VirtualDataPath, appPaths.DataPath, StringComparison.OrdinalIgnoreCase) - .Replace(appPaths.VirtualInternalMetadataPath, appPaths.InternalMetadataPath, StringComparison.OrdinalIgnoreCase); - } - - public string ReverseVirtualPath(string path) - { - var appPaths = ApplicationPaths; - - return path.Replace(appPaths.DataPath, appPaths.VirtualDataPath, StringComparison.OrdinalIgnoreCase) - .Replace(appPaths.InternalMetadataPath, appPaths.VirtualInternalMetadataPath, StringComparison.OrdinalIgnoreCase); - } - /// public Version ApplicationVersion { get; } @@ -329,16 +288,11 @@ namespace Emby.Server.Implementations /// The application name. public string ApplicationProductName { get; } = FileVersionInfo.GetVersionInfo(Assembly.GetEntryAssembly().Location).ProductName; - private DeviceId _deviceId; - public string SystemId { get { - if (_deviceId == null) - { - _deviceId = new DeviceId(ApplicationPaths, LoggerFactory); - } + _deviceId ??= new DeviceId(ApplicationPaths, LoggerFactory); return _deviceId.Value; } @@ -347,21 +301,49 @@ namespace Emby.Server.Implementations /// public string Name => ApplicationProductName; - /// - /// Creates an instance of type and resolves all constructor dependencies. - /// - /// The type. - /// System.Object. - public object CreateInstance(Type type) - => ActivatorUtilities.CreateInstance(ServiceProvider, type); + private string CertificatePath { get; set; } + + public X509Certificate2 Certificate { get; private set; } + + /// + public bool ListenWithHttps => Certificate != null && ConfigurationManager.GetNetworkConfiguration().EnableHttps; + + public string FriendlyName => + string.IsNullOrEmpty(ConfigurationManager.Configuration.ServerName) + ? Environment.MachineName + : ConfigurationManager.Configuration.ServerName; /// - /// Creates an instance of type and resolves all constructor dependencies. + /// 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. /// - /// The type. - /// T. - public T CreateInstance() - => ActivatorUtilities.CreateInstance(ServiceProvider); + 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) + { + var appPaths = ApplicationPaths; + + return path.Replace(appPaths.VirtualDataPath, appPaths.DataPath, StringComparison.OrdinalIgnoreCase) + .Replace(appPaths.VirtualInternalMetadataPath, appPaths.InternalMetadataPath, StringComparison.OrdinalIgnoreCase); + } + + public string ReverseVirtualPath(string path) + { + var appPaths = ApplicationPaths; + + return path.Replace(appPaths.DataPath, appPaths.VirtualDataPath, StringComparison.OrdinalIgnoreCase) + .Replace(appPaths.InternalMetadataPath, appPaths.VirtualInternalMetadataPath, StringComparison.OrdinalIgnoreCase); + } /// /// Creates the instance safe. @@ -370,12 +352,9 @@ namespace Emby.Server.Implementations /// System.Object. protected object CreateInstanceSafe(Type type) { - if (_creatingInstances == null) - { - _creatingInstances = new List(); - } + _creatingInstances ??= new List(); - if (_creatingInstances.IndexOf(type) != -1) + if (_creatingInstances.Contains(type)) { Logger.LogError("DI Loop detected in the attempted creation of {Type}", type.FullName); foreach (var entry in _creatingInstances) @@ -385,7 +364,7 @@ namespace Emby.Server.Implementations _pluginManager.FailPlugin(type.Assembly); - throw new ExternalException("DI Loop detected."); + throw new TypeLoadException("DI Loop detected"); } try @@ -418,8 +397,15 @@ namespace Emby.Server.Implementations public IEnumerable GetExportTypes() { var currentType = typeof(T); - - return _allConcreteTypes.Where(i => currentType.IsAssignableFrom(i)); + var numberOfConcreteTypes = _allConcreteTypes.Length; + for (var i = 0; i < numberOfConcreteTypes; i++) + { + var type = _allConcreteTypes[i]; + if (currentType.IsAssignableFrom(type)) + { + yield return type; + } + } } /// @@ -434,9 +420,9 @@ namespace Emby.Server.Implementations if (manageLifetime) { - lock (_disposableParts) + foreach (var part in parts.OfType()) { - _disposableParts.AddRange(parts.OfType()); + _disposableParts.TryAdd(part, byte.MinValue); } } @@ -455,9 +441,9 @@ namespace Emby.Server.Implementations if (manageLifetime) { - lock (_disposableParts) + foreach (var part in parts.OfType()) { - _disposableParts.AddRange(parts.OfType()); + _disposableParts.TryAdd(part, byte.MinValue); } } @@ -467,6 +453,7 @@ namespace Emby.Server.Implementations /// /// Runs the startup tasks. /// + /// The cancellation token. /// . public async Task RunStartupTasksAsync(CancellationToken cancellationToken) { @@ -480,7 +467,7 @@ namespace Emby.Server.Implementations _mediaEncoder.SetFFmpegPath(); - Logger.LogInformation("ServerId: {0}", SystemId); + Logger.LogInformation("ServerId: {ServerId}", SystemId); var entryPoints = GetExports(); @@ -520,7 +507,7 @@ namespace Emby.Server.Implementations } /// - public void Init() + public void Init(IServiceCollection serviceCollection) { DiscoverTypes(); @@ -547,145 +534,133 @@ namespace Emby.Server.Implementations HttpsPort = NetworkConfiguration.DefaultHttpsPort; } - CertificateInfo = new CertificateInfo - { - Path = networkConfiguration.CertificatePath, - Password = networkConfiguration.CertificatePassword - }; - Certificate = GetCertificate(CertificateInfo); + CertificatePath = networkConfiguration.CertificatePath; + Certificate = GetCertificate(CertificatePath, networkConfiguration.CertificatePassword); - RegisterServices(); + RegisterServices(serviceCollection); - _pluginManager.RegisterServices(ServiceCollection); + _pluginManager.RegisterServices(serviceCollection); } /// /// Registers services/resources with the service collection that will be available via DI. /// - protected virtual void RegisterServices() + /// Instance of the interface. + protected virtual void RegisterServices(IServiceCollection serviceCollection) { - ServiceCollection.AddSingleton(_startupOptions); + serviceCollection.AddSingleton(_startupOptions); - ServiceCollection.AddMemoryCache(); + serviceCollection.AddMemoryCache(); - ServiceCollection.AddSingleton(ConfigurationManager); - ServiceCollection.AddSingleton(ConfigurationManager); - ServiceCollection.AddSingleton(this); - ServiceCollection.AddSingleton(_pluginManager); - ServiceCollection.AddSingleton(ApplicationPaths); + serviceCollection.AddSingleton(ConfigurationManager); + serviceCollection.AddSingleton(ConfigurationManager); + serviceCollection.AddSingleton(this); + serviceCollection.AddSingleton(_pluginManager); + serviceCollection.AddSingleton(ApplicationPaths); - ServiceCollection.AddSingleton(_fileSystemManager); - ServiceCollection.AddSingleton(); + serviceCollection.AddSingleton(_fileSystemManager); + serviceCollection.AddSingleton(); - ServiceCollection.AddSingleton(NetManager); + serviceCollection.AddSingleton(NetManager); - ServiceCollection.AddSingleton(); + serviceCollection.AddSingleton(); - ServiceCollection.AddSingleton(_xmlSerializer); + serviceCollection.AddSingleton(_xmlSerializer); - ServiceCollection.AddSingleton(); + serviceCollection.AddSingleton(); - ServiceCollection.AddSingleton(); + serviceCollection.AddSingleton(); - ServiceCollection.AddSingleton(); + serviceCollection.AddSingleton(); - ServiceCollection.AddSingleton(); + serviceCollection.AddSingleton(); - ServiceCollection.AddSingleton(); + serviceCollection.AddSingleton(); - ServiceCollection.AddSingleton(this); - ServiceCollection.AddSingleton(ApplicationPaths); + serviceCollection.AddSingleton(this); + serviceCollection.AddSingleton(ApplicationPaths); - ServiceCollection.AddSingleton(); + serviceCollection.AddSingleton(); - ServiceCollection.AddSingleton(); + serviceCollection.AddSingleton(); - ServiceCollection.AddSingleton(); - ServiceCollection.AddSingleton(); + serviceCollection.AddSingleton(); + serviceCollection.AddSingleton(); - ServiceCollection.AddSingleton(); + serviceCollection.AddSingleton(); - ServiceCollection.AddSingleton(); - - // TODO: Refactor to eliminate the circular dependency here so that Lazy isn't required - ServiceCollection.AddTransient(provider => new Lazy(provider.GetRequiredService)); - - ServiceCollection.AddSingleton(); - ServiceCollection.AddSingleton(); + serviceCollection.AddSingleton(); + serviceCollection.AddSingleton(); // TODO: Refactor to eliminate the circular dependencies here so that Lazy isn't required - ServiceCollection.AddTransient(provider => new Lazy(provider.GetRequiredService)); - ServiceCollection.AddTransient(provider => new Lazy(provider.GetRequiredService)); - ServiceCollection.AddTransient(provider => new Lazy(provider.GetRequiredService)); - ServiceCollection.AddSingleton(); + serviceCollection.AddTransient(provider => new Lazy(provider.GetRequiredService)); + serviceCollection.AddTransient(provider => new Lazy(provider.GetRequiredService)); + serviceCollection.AddTransient(provider => new Lazy(provider.GetRequiredService)); + serviceCollection.AddSingleton(); + serviceCollection.AddSingleton(); - ServiceCollection.AddSingleton(); + serviceCollection.AddSingleton(); - ServiceCollection.AddSingleton(); + serviceCollection.AddSingleton(); - ServiceCollection.AddSingleton(); + serviceCollection.AddSingleton(); - ServiceCollection.AddSingleton(); + serviceCollection.AddSingleton(); - ServiceCollection.AddSingleton(); + serviceCollection.AddSingleton(); - ServiceCollection.AddSingleton(); + serviceCollection.AddSingleton(); - ServiceCollection.AddSingleton(); + serviceCollection.AddSingleton(); - ServiceCollection.AddSingleton(); + serviceCollection.AddSingleton(); - ServiceCollection.AddSingleton(); - - ServiceCollection.AddSingleton(); + serviceCollection.AddSingleton(); // TODO: Refactor to eliminate the circular dependency here so that Lazy isn't required - ServiceCollection.AddTransient(provider => new Lazy(provider.GetRequiredService)); - ServiceCollection.AddSingleton(); - - ServiceCollection.AddSingleton(); + serviceCollection.AddTransient(provider => new Lazy(provider.GetRequiredService)); + serviceCollection.AddSingleton(); - ServiceCollection.AddSingleton(); + serviceCollection.AddSingleton(); - ServiceCollection.AddSingleton(); + serviceCollection.AddSingleton(); - ServiceCollection.AddSingleton(); + serviceCollection.AddSingleton(); - ServiceCollection.AddSingleton(); + serviceCollection.AddSingleton(); - ServiceCollection.AddSingleton(); + serviceCollection.AddSingleton(); - ServiceCollection.AddSingleton(); - ServiceCollection.AddSingleton(); + serviceCollection.AddSingleton(); - ServiceCollection.AddSingleton(); + serviceCollection.AddSingleton(); + serviceCollection.AddSingleton(); - ServiceCollection.AddSingleton(); + serviceCollection.AddSingleton(); - ServiceCollection.AddSingleton(); + serviceCollection.AddSingleton(); - ServiceCollection.AddSingleton(); + serviceCollection.AddSingleton(); - ServiceCollection.AddSingleton(); + serviceCollection.AddSingleton(); - ServiceCollection.AddSingleton(); - ServiceCollection.AddSingleton(); + serviceCollection.AddSingleton(); - ServiceCollection.AddSingleton(); - ServiceCollection.AddSingleton(); + serviceCollection.AddScoped(); - ServiceCollection.AddSingleton(); + serviceCollection.AddSingleton(); + serviceCollection.AddSingleton(); - ServiceCollection.AddSingleton(); + serviceCollection.AddSingleton(); - ServiceCollection.AddSingleton(); + serviceCollection.AddSingleton(); - ServiceCollection.AddSingleton(); - ServiceCollection.AddScoped(); - ServiceCollection.AddScoped(); - ServiceCollection.AddScoped(); - - ServiceCollection.AddSingleton(); + serviceCollection.AddSingleton(); + serviceCollection.AddScoped(); + serviceCollection.AddScoped(); + serviceCollection.AddScoped(); + serviceCollection.AddScoped(); + serviceCollection.AddSingleton(); } /// @@ -700,8 +675,6 @@ namespace Emby.Server.Implementations _mediaEncoder = Resolve(); _sessionManager = Resolve(); - ((AuthenticationRepository)Resolve()).Initialize(); - SetStaticProperties(); var userDataRepo = (SqliteUserDataRepository)Resolve(); @@ -730,7 +703,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); @@ -740,30 +713,27 @@ namespace Emby.Server.Implementations logger.LogInformation("Application directory: {ApplicationPath}", appPaths.ProgramSystemPath); } - private X509Certificate2 GetCertificate(CertificateInfo info) + private X509Certificate2 GetCertificate(string path, string password) { - var certificateLocation = info?.Path; - - if (string.IsNullOrWhiteSpace(certificateLocation)) + if (string.IsNullOrWhiteSpace(path)) { return null; } try { - if (!File.Exists(certificateLocation)) + if (!File.Exists(path)) { return null; } // Don't use an empty string password - var password = string.IsNullOrWhiteSpace(info.Password) ? null : info.Password; + password = string.IsNullOrWhiteSpace(password) ? null : password; - var localCert = new X509Certificate2(certificateLocation, password, X509KeyStorageFlags.UserKeySet); - // localCert.PrivateKey = PrivateKey.CreateFromFile(pvk_file).RSA; + var localCert = new X509Certificate2(path, password, X509KeyStorageFlags.UserKeySet); if (!localCert.HasPrivateKey) { - Logger.LogError("No private key included in SSL cert {CertificateLocation}.", certificateLocation); + Logger.LogError("No private key included in SSL cert {CertificateLocation}.", path); return null; } @@ -771,7 +741,7 @@ namespace Emby.Server.Implementations } catch (Exception ex) { - Logger.LogError(ex, "Error loading cert from {CertificateLocation}", certificateLocation); + Logger.LogError(ex, "Error loading cert from {CertificateLocation}", path); return null; } } @@ -813,8 +783,6 @@ namespace Emby.Server.Implementations _pluginManager.CreatePlugins(); - _urlPrefixes = GetUrlPrefixes().ToArray(); - Resolve().AddParts( GetExports(), GetExports(), @@ -882,36 +850,12 @@ namespace Emby.Server.Implementations } } - private CertificateInfo CertificateInfo { get; set; } - - public X509Certificate2 Certificate { get; private set; } - - private IEnumerable GetUrlPrefixes() - { - var hosts = new[] { "+" }; - - return hosts.SelectMany(i => - { - var prefixes = new List - { - "http://" + i + ":" + HttpPort + "/" - }; - - if (CertificateInfo != null) - { - prefixes.Add("https://" + i + ":" + HttpsPort + "/"); - } - - return prefixes; - }); - } - /// /// Called when [configuration updated]. /// /// The sender. /// The instance containing the event data. - protected void OnConfigurationUpdated(object sender, EventArgs e) + private void OnConfigurationUpdated(object sender, EventArgs e) { var requiresRestart = false; var networkConfiguration = ConfigurationManager.GetNetworkConfiguration(); @@ -920,8 +864,8 @@ namespace Emby.Server.Implementations if (HttpPort != 0 && HttpsPort != 0) { // Need to restart if ports have changed - if (networkConfiguration.HttpServerPortNumber != HttpPort || - networkConfiguration.HttpsPortNumber != HttpsPort) + if (networkConfiguration.HttpServerPortNumber != HttpPort + || networkConfiguration.HttpsPortNumber != HttpsPort) { if (ConfigurationManager.Configuration.IsPortAuthorized) { @@ -933,11 +877,6 @@ namespace Emby.Server.Implementations } } - if (!_urlPrefixes.SequenceEqual(GetUrlPrefixes(), StringComparer.OrdinalIgnoreCase)) - { - requiresRestart = true; - } - if (ValidateSslCertificate(networkConfiguration)) { requiresRestart = true; @@ -961,7 +900,7 @@ namespace Emby.Server.Implementations var newPath = networkConfig.CertificatePath; if (!string.IsNullOrWhiteSpace(newPath) - && !string.Equals(CertificateInfo?.Path, newPath, StringComparison.Ordinal)) + && !string.Equals(CertificatePath, newPath, StringComparison.Ordinal)) { if (File.Exists(newPath)) { @@ -979,7 +918,7 @@ namespace Emby.Server.Implementations } /// - /// Notifies that the kernel that a change has been made that requires a restart. + /// Notifies the kernel that a change has been made that requires a restart. /// public void NotifyPendingRestart() { @@ -1089,9 +1028,9 @@ namespace Emby.Server.Implementations /// /// Gets the system status. /// - /// Where this request originated. + /// Where this request originated. /// SystemInfo. - public SystemInfo GetSystemInfo(IPAddress source) + public SystemInfo GetSystemInfo(HttpRequest request) { return new SystemInfo { @@ -1107,45 +1046,35 @@ 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 = ConfigurationManager.GetTranscodePath(), ServerName = FriendlyName, - LocalAddress = GetSmartApiUrl(source), + LocalAddress = GetSmartApiUrl(request), SupportsLibraryMonitor = true, - EncoderLocation = _mediaEncoder.EncoderLocation, SystemArchitecture = RuntimeInformation.OSArchitecture, PackageName = _startupOptions.PackageName }; } - public IEnumerable GetWakeOnLanInfo() - => NetManager.GetMacAddresses() - .Select(i => new WakeOnLanInfo(i)) - .ToList(); - - public PublicSystemInfo GetPublicSystemInfo(IPAddress source) + public PublicSystemInfo GetPublicSystemInfo(HttpRequest request) { return new PublicSystemInfo { Version = ApplicationVersionString, ProductName = ApplicationProductName, Id = SystemId, - OperatingSystem = OperatingSystem.Id.ToString(), + OperatingSystem = MediaBrowser.Common.System.OperatingSystem.Id.ToString(), ServerName = FriendlyName, - LocalAddress = GetSmartApiUrl(source), + LocalAddress = GetSmartApiUrl(request), StartupWizardCompleted = ConfigurationManager.CommonConfiguration.IsStartupWizardCompleted }; } /// - public bool ListenWithHttps => Certificate != null && ConfigurationManager.GetNetworkConfiguration().EnableHttps; - - /// - public string GetSmartApiUrl(IPAddress ipAddress, int? port = null) + public string GetSmartApiUrl(IPAddress remoteAddr) { // Published server ends with a / if (!string.IsNullOrEmpty(PublishedServerUrl)) @@ -1154,19 +1083,25 @@ namespace Emby.Server.Implementations return PublishedServerUrl.Trim('/'); } - string smart = NetManager.GetBindInterface(ipAddress, out port); - // If the smartAPI doesn't start with http then treat it as a host or ip. - if (smart.StartsWith("http", StringComparison.OrdinalIgnoreCase)) - { - return smart.Trim('/'); - } - + string smart = NetManager.GetBindInterface(remoteAddr, out var port); return GetLocalApiUrl(smart.Trim('/'), null, port); } /// - public string GetSmartApiUrl(HttpRequest request, int? port = null) + public string GetSmartApiUrl(HttpRequest request) { + // Return the host in the HTTP request as the API url + if (ConfigurationManager.GetNetworkConfiguration().EnablePublishedServerUriByRequest) + { + int? requestPort = request.Host.Port; + if ((requestPort == 80 && string.Equals(request.Scheme, "http", StringComparison.OrdinalIgnoreCase)) || (requestPort == 443 && string.Equals(request.Scheme, "https", StringComparison.OrdinalIgnoreCase))) + { + requestPort = -1; + } + + return GetLocalApiUrl(request.Host.Host, request.Scheme, requestPort); + } + // Published server ends with a / if (!string.IsNullOrEmpty(PublishedServerUrl)) { @@ -1174,18 +1109,12 @@ namespace Emby.Server.Implementations return PublishedServerUrl.Trim('/'); } - 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)) - { - return smart.Trim('/'); - } - + string smart = NetManager.GetBindInterface(request, out var port); return GetLocalApiUrl(smart.Trim('/'), request.Scheme, port); } /// - public string GetSmartApiUrl(string hostname, int? port = null) + public string GetSmartApiUrl(string hostname) { // Published server ends with a / if (!string.IsNullOrEmpty(PublishedServerUrl)) @@ -1194,50 +1123,41 @@ namespace Emby.Server.Implementations return PublishedServerUrl.Trim('/'); } - string smart = NetManager.GetBindInterface(hostname, out port); - - // If the smartAPI doesn't start with http then treat it as a host or ip. - if (smart.StartsWith("http", StringComparison.OrdinalIgnoreCase)) - { - return smart.Trim('/'); - } - + string smart = NetManager.GetBindInterface(hostname, out var port); return GetLocalApiUrl(smart.Trim('/'), null, port); } /// - public string GetLoopbackHttpApiUrl() + public string GetApiUrlForLocalAccess(bool allowHttps = true) { - if (NetManager.IsIP6Enabled) - { - return GetLocalApiUrl("::1", Uri.UriSchemeHttp, HttpPort); - } - - return GetLocalApiUrl("127.0.0.1", Uri.UriSchemeHttp, HttpPort); + // With an empty source, the port will be null + string smart = NetManager.GetBindInterface(string.Empty, out _); + var scheme = !allowHttps ? Uri.UriSchemeHttp : null; + int? port = !allowHttps ? HttpPort : null; + return GetLocalApiUrl(smart.Trim('/'), scheme, port); } /// - public string GetLocalApiUrl(string host, string scheme = null, int? port = null) + public string GetLocalApiUrl(string hostname, string scheme = null, int? port = null) { + // If the smartAPI doesn't start with http then treat it as a host or ip. + if (hostname.StartsWith("http", StringComparison.OrdinalIgnoreCase)) + { + return hostname.TrimEnd('/'); + } + // 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 { Scheme = scheme ?? (ListenWithHttps ? Uri.UriSchemeHttps : Uri.UriSchemeHttp), - Host = host, + Host = hostname, Port = port ?? (ListenWithHttps ? HttpsPort : HttpPort), Path = ConfigurationManager.GetNetworkConfiguration().BaseUrl }.ToString().TrimEnd('/'); } - public string FriendlyName => - string.IsNullOrEmpty(ConfigurationManager.Configuration.ServerName) - ? Environment.MachineName - : ConfigurationManager.Configuration.ServerName; - - /// - /// Shuts down. - /// + /// public async Task Shutdown() { if (IsShuttingDown) @@ -1261,26 +1181,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); - } - } - } - public IEnumerable GetApiPluginAssemblies() { var assemblies = _allConcreteTypes @@ -1295,41 +1195,7 @@ namespace Emby.Server.Implementations } } - public virtual void LaunchUrl(string url) - { - if (!CanLaunchWebBrowser) - { - throw new NotSupportedException(); - } - - var process = new Process - { - StartInfo = new ProcessStartInfo - { - FileName = url, - UseShellExecute = true, - ErrorDialog = false - }, - EnableRaisingEvents = true - }; - process.Exited += (sender, args) => ((Process)sender).Dispose(); - - try - { - process.Start(); - } - catch (Exception ex) - { - Logger.LogError(ex, "Error launching url: {url}", url); - throw; - } - } - - private bool _disposed = false; - - /// - /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources. - /// + /// public void Dispose() { Dispose(true); @@ -1353,12 +1219,15 @@ namespace Emby.Server.Implementations Logger.LogInformation("Disposing {Type}", type.Name); - var parts = _disposableParts.Distinct().Where(i => i.GetType() != type).ToList(); - _disposableParts.Clear(); - - foreach (var part in parts) + foreach (var (part, _) in _disposableParts) { - Logger.LogInformation("Disposing {Type}", part.GetType().Name); + var partType = part.GetType(); + if (partType == type) + { + continue; + } + + Logger.LogInformation("Disposing {Type}", partType.Name); try { @@ -1366,19 +1235,14 @@ namespace Emby.Server.Implementations } catch (Exception ex) { - Logger.LogError(ex, "Error disposing {Type}", part.GetType().Name); + Logger.LogError(ex, "Error disposing {Type}", partType.Name); } } + + _disposableParts.Clear(); } _disposed = true; } } - - internal class CertificateInfo - { - public string Path { get; set; } - - public string Password { get; set; } - } } diff --git a/Emby.Server.Implementations/Archiving/ZipClient.cs b/Emby.Server.Implementations/Archiving/ZipClient.cs index 591ae547d6..9e1d550ebc 100644 --- a/Emby.Server.Implementations/Archiving/ZipClient.cs +++ b/Emby.Server.Implementations/Archiving/ZipClient.cs @@ -45,6 +45,7 @@ namespace Emby.Server.Implementations.Archiving options.Overwrite = true; } + Directory.CreateDirectory(targetPath); reader.WriteAllToDirectory(targetPath, options); } @@ -58,6 +59,7 @@ namespace Emby.Server.Implementations.Archiving Overwrite = overwriteExistingFiles }; + Directory.CreateDirectory(targetPath); reader.WriteAllToDirectory(targetPath, options); } @@ -71,6 +73,7 @@ namespace Emby.Server.Implementations.Archiving Overwrite = overwriteExistingFiles }; + Directory.CreateDirectory(targetPath); reader.WriteAllToDirectory(targetPath, options); } @@ -120,6 +123,7 @@ namespace Emby.Server.Implementations.Archiving Overwrite = overwriteExistingFiles }; + Directory.CreateDirectory(targetPath); reader.WriteAllToDirectory(targetPath, options); } @@ -151,6 +155,7 @@ namespace Emby.Server.Implementations.Archiving Overwrite = overwriteExistingFiles }; + Directory.CreateDirectory(targetPath); reader.WriteAllToDirectory(targetPath, options); } } diff --git a/Emby.Server.Implementations/Channels/ChannelManager.cs b/Emby.Server.Implementations/Channels/ChannelManager.cs index 7324b0ee9f..f65eaec1c8 100644 --- a/Emby.Server.Implementations/Channels/ChannelManager.cs +++ b/Emby.Server.Implementations/Channels/ChannelManager.cs @@ -1,3 +1,5 @@ +#nullable disable + using System; using System.Collections.Generic; using System.Globalization; @@ -8,8 +10,8 @@ using System.Threading; using System.Threading.Tasks; using Jellyfin.Data.Entities; using Jellyfin.Data.Enums; +using Jellyfin.Extensions.Json; using MediaBrowser.Common.Extensions; -using MediaBrowser.Common.Json; using MediaBrowser.Common.Progress; using MediaBrowser.Controller.Channels; using MediaBrowser.Controller.Configuration; @@ -100,7 +102,7 @@ namespace Emby.Server.Implementations.Channels var internalChannel = _libraryManager.GetItemById(item.ChannelId); var channel = Channels.FirstOrDefault(i => GetInternalChannelId(i.Name).Equals(internalChannel.Id)); - return !(channel is IDisableMediaSourceDisplay); + return channel is not IDisableMediaSourceDisplay; } /// @@ -584,7 +586,7 @@ namespace Emby.Server.Implementations.Channels { var supportsLatest = provider is ISupportsLatestMedia; - return new ChannelFeatures + return new ChannelFeatures(channel.Name, channel.Id) { CanFilter = !features.MaxPageSize.HasValue, CanSearch = provider is ISearchableChannel, @@ -594,8 +596,6 @@ namespace Emby.Server.Implementations.Channels MediaTypes = features.MediaTypes.ToArray(), SupportsSortOrderToggle = features.SupportsSortOrderToggle, SupportsLatestMedia = supportsLatest, - Name = channel.Name, - Id = channel.Id.ToString("N", CultureInfo.InvariantCulture), SupportsContentDownloading = features.SupportsContentDownloading, AutoRefreshLevels = features.AutoRefreshLevels }; @@ -813,7 +813,7 @@ namespace Emby.Server.Implementations.Channels { if (_fileSystem.GetLastWriteTimeUtc(cachePath).Add(cacheLength) > DateTime.UtcNow) { - await using FileStream jsonStream = File.OpenRead(cachePath); + await using FileStream jsonStream = AsyncFile.OpenRead(cachePath); var cachedResult = await JsonSerializer.DeserializeAsync(jsonStream, _jsonOptions, cancellationToken).ConfigureAwait(false); if (cachedResult != null) { @@ -836,7 +836,7 @@ namespace Emby.Server.Implementations.Channels { if (_fileSystem.GetLastWriteTimeUtc(cachePath).Add(cacheLength) > DateTime.UtcNow) { - await using FileStream jsonStream = File.OpenRead(cachePath); + await using FileStream jsonStream = AsyncFile.OpenRead(cachePath); var cachedResult = await JsonSerializer.DeserializeAsync(jsonStream, _jsonOptions, cancellationToken).ConfigureAwait(false); if (cachedResult != null) { @@ -878,7 +878,7 @@ namespace Emby.Server.Implementations.Channels } } - private async Task CacheResponse(object result, string path) + private async Task CacheResponse(ChannelItemResult result, string path) { try { @@ -1075,14 +1075,6 @@ namespace Emby.Server.Implementations.Channels forceUpdate = true; } - // was used for status - // if (!string.Equals(item.ExternalEtag ?? string.Empty, info.Etag ?? string.Empty, StringComparison.Ordinal)) - //{ - // item.ExternalEtag = info.Etag; - // forceUpdate = true; - // _logger.LogDebug("Forcing update due to ExternalEtag {0}", item.Name); - //} - if (!internalChannelId.Equals(item.ChannelId)) { forceUpdate = true; 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 } /// diff --git a/Emby.Server.Implementations/Collections/CollectionManager.cs b/Emby.Server.Implementations/Collections/CollectionManager.cs index 1b85a9d4ba..79ef70fffd 100644 --- a/Emby.Server.Implementations/Collections/CollectionManager.cs +++ b/Emby.Server.Implementations/Collections/CollectionManager.cs @@ -61,13 +61,13 @@ namespace Emby.Server.Implementations.Collections } /// - public event EventHandler CollectionCreated; + public event EventHandler? CollectionCreated; /// - public event EventHandler ItemsAddedToCollection; + public event EventHandler? ItemsAddedToCollection; /// - public event EventHandler ItemsRemovedFromCollection; + public event EventHandler? ItemsRemovedFromCollection; private IEnumerable FindFolders(string path) { @@ -78,14 +78,12 @@ namespace Emby.Server.Implementations.Collections .Where(i => _fileSystem.AreEqual(path, i.Path) || _fileSystem.ContainsSubPath(i.Path, path)); } - internal async Task EnsureLibraryFolder(string path, bool createIfNeeded) + internal async Task 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) @@ -97,7 +95,7 @@ namespace Emby.Server.Implementations.Collections var libraryOptions = new LibraryOptions { - PathInfos = new[] { new MediaPathInfo { Path = path } }, + PathInfos = new[] { new MediaPathInfo(path) }, EnableRealtimeMonitor = false, SaveLocalMetadata = true }; @@ -114,7 +112,7 @@ namespace Emby.Server.Implementations.Collections return Path.Combine(_appPaths.DataPath, "collections"); } - private Task GetCollectionsFolder(bool createIfNeeded) + private Task GetCollectionsFolder(bool createIfNeeded) { return EnsureLibraryFolder(GetCollectionsFolderPath(), createIfNeeded); } @@ -162,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, @@ -198,13 +196,12 @@ namespace Emby.Server.Implementations.Collections } /// - public Task AddToCollectionAsync(Guid collectionId, IEnumerable ids) - => AddToCollectionAsync(collectionId, ids, true, new MetadataRefreshOptions(new DirectoryService(_fileSystem))); + public Task AddToCollectionAsync(Guid collectionId, IEnumerable itemIds) + => AddToCollectionAsync(collectionId, itemIds, true, new MetadataRefreshOptions(new DirectoryService(_fileSystem))); private async Task AddToCollectionAsync(Guid collectionId, IEnumerable 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"); } @@ -248,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)); } } } @@ -260,9 +253,7 @@ namespace Emby.Server.Implementations.Collections /// public async Task RemoveFromCollectionAsync(Guid collectionId, IEnumerable 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"); } @@ -304,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)); } /// @@ -320,11 +307,7 @@ namespace Emby.Server.Implementations.Collections foreach (var item in items) { - if (item is not ISupportsBoxSetGrouping) - { - results[item.Id] = item; - } - else + if (item is ISupportsBoxSetGrouping) { var itemId = item.Id; @@ -348,6 +331,7 @@ namespace Emby.Server.Implementations.Collections } var alreadyInResults = false; + // 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) { @@ -363,11 +347,13 @@ namespace Emby.Server.Implementations.Collections } } - if (!alreadyInResults) + if (alreadyInResults) { - results[itemId] = 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 7a8ed8c29f..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; diff --git a/Emby.Server.Implementations/Cryptography/CryptographyProvider.cs b/Emby.Server.Implementations/Cryptography/CryptographyProvider.cs index 12a9e44e70..e9c005cea4 100644 --- a/Emby.Server.Implementations/Cryptography/CryptographyProvider.cs +++ b/Emby.Server.Implementations/Cryptography/CryptographyProvider.cs @@ -1,19 +1,20 @@ -#nullable enable - using System; using System.Collections.Generic; +using System.Globalization; using System.Security.Cryptography; +using System.Text; using MediaBrowser.Common.Extensions; using MediaBrowser.Model.Cryptography; -using static MediaBrowser.Common.Cryptography.Constants; +using static MediaBrowser.Model.Cryptography.Constants; namespace Emby.Server.Implementations.Cryptography { /// /// Class providing abstractions over cryptographic functions. /// - public class CryptographyProvider : ICryptoProvider, IDisposable + public class CryptographyProvider : ICryptoProvider { + // TODO: remove when not needed for backwards compat private static readonly HashSet _supportedHashMethods = new HashSet() { "MD5", @@ -32,71 +33,71 @@ namespace Emby.Server.Implementations.Cryptography "System.Security.Cryptography.SHA512" }; - private RandomNumberGenerator _randomNumberGenerator; - - private bool _disposed; + /// + public string DefaultHashMethod => "PBKDF2-SHA512"; - /// - /// Initializes a new instance of the class. - /// - public CryptographyProvider() + /// + public PasswordHash CreatePasswordHash(ReadOnlySpan password) { - // FIXME: When we get DotNet Standard 2.1 we need to revisit how we do the crypto - // Currently supported hash methods from https://docs.microsoft.com/en-us/dotnet/api/system.security.cryptography.cryptoconfig?view=netcore-2.1 - // there might be a better way to autogenerate this list as dotnet updates, but I couldn't find one - // Please note the default method of PBKDF2 is not included, it cannot be used to generate hashes cleanly as it is actually a pbkdf with sha1 - _randomNumberGenerator = RandomNumberGenerator.Create(); + byte[] salt = GenerateSalt(); + return new PasswordHash( + DefaultHashMethod, + Rfc2898DeriveBytes.Pbkdf2( + password, + salt, + DefaultIterations, + HashAlgorithmName.SHA512, + DefaultOutputLength), + salt, + new Dictionary + { + { "iterations", DefaultIterations.ToString(CultureInfo.InvariantCulture) } + }); } /// - public string DefaultHashMethod => "PBKDF2"; - - /// - public IEnumerable GetSupportedHashMethods() - => _supportedHashMethods; - - private byte[] PBKDF2(string method, byte[] bytes, byte[] salt, int iterations) + public bool Verify(PasswordHash hash, ReadOnlySpan password) { - // downgrading for now as we need this library to be dotnetstandard compliant - // with this downgrade we'll add a check to make sure we're on the downgrade method at the moment - if (method != DefaultHashMethod) + if (string.Equals(hash.Id, "PBKDF2", StringComparison.Ordinal)) { - throw new CryptographicException($"Cannot currently use PBKDF2 with requested hash method: {method}"); + return hash.Hash.SequenceEqual( + Rfc2898DeriveBytes.Pbkdf2( + password, + hash.Salt, + int.Parse(hash.Parameters["iterations"], CultureInfo.InvariantCulture), + HashAlgorithmName.SHA1, + 32)); } - using var r = new Rfc2898DeriveBytes(bytes, salt, iterations); - return r.GetBytes(32); - } - - /// - public byte[] ComputeHash(string hashMethod, byte[] bytes, byte[] salt) - { - if (hashMethod == DefaultHashMethod) + if (string.Equals(hash.Id, "PBKDF2-SHA512", StringComparison.Ordinal)) { - return PBKDF2(hashMethod, bytes, salt, DefaultIterations); + return hash.Hash.SequenceEqual( + Rfc2898DeriveBytes.Pbkdf2( + password, + hash.Salt, + int.Parse(hash.Parameters["iterations"], CultureInfo.InvariantCulture), + HashAlgorithmName.SHA512, + DefaultOutputLength)); } - if (!_supportedHashMethods.Contains(hashMethod)) + if (!_supportedHashMethods.Contains(hash.Id)) { - throw new CryptographicException($"Requested hash method is not supported: {hashMethod}"); + throw new CryptographicException($"Requested hash method is not supported: {hash.Id}"); } - using var h = HashAlgorithm.Create(hashMethod) ?? throw new ResourceNotFoundException($"Unknown hash method: {hashMethod}."); - if (salt.Length == 0) + using var h = HashAlgorithm.Create(hash.Id) ?? throw new ResourceNotFoundException($"Unknown hash method: {hash.Id}."); + var bytes = Encoding.UTF8.GetBytes(password.ToArray()); + if (hash.Salt.Length == 0) { - return h.ComputeHash(bytes); + return hash.Hash.SequenceEqual(h.ComputeHash(bytes)); } - byte[] salted = new byte[bytes.Length + salt.Length]; + byte[] salted = new byte[bytes.Length + hash.Salt.Length]; Array.Copy(bytes, salted, bytes.Length); - Array.Copy(salt, 0, salted, bytes.Length, salt.Length); - return h.ComputeHash(salted); + hash.Salt.CopyTo(salted.AsSpan(bytes.Length)); + return hash.Hash.SequenceEqual(h.ComputeHash(salted)); } - /// - public byte[] ComputeHashWithDefaultMethod(byte[] bytes, byte[] salt) - => PBKDF2(DefaultHashMethod, bytes, salt, DefaultIterations); - /// public byte[] GenerateSalt() => GenerateSalt(DefaultSaltLength); @@ -104,35 +105,10 @@ namespace Emby.Server.Implementations.Cryptography /// public byte[] GenerateSalt(int length) { - byte[] salt = new byte[length]; - _randomNumberGenerator.GetBytes(salt); + var salt = new byte[length]; + using var rng = RandomNumberGenerator.Create(); + rng.GetNonZeroBytes(salt); return salt; } - - /// - public void Dispose() - { - Dispose(true); - GC.SuppressFinalize(this); - } - - /// - /// Releases unmanaged and - optionally - managed resources. - /// - /// true to release both managed and unmanaged resources; false to release only unmanaged resources. - protected virtual void Dispose(bool disposing) - { - if (_disposed) - { - return; - } - - if (disposing) - { - _randomNumberGenerator.Dispose(); - } - - _disposed = true; - } } } diff --git a/Emby.Server.Implementations/Data/BaseSqliteRepository.cs b/Emby.Server.Implementations/Data/BaseSqliteRepository.cs index 8c756a7f4c..73c31f49d1 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; @@ -59,7 +61,7 @@ namespace Emby.Server.Implementations.Data protected virtual int? CacheSize => null; /// - /// Gets the journal mode. + /// Gets the journal mode. . /// /// The journal mode. protected virtual string JournalMode => "TRUNCATE"; @@ -96,7 +98,7 @@ namespace Emby.Server.Implementations.Data /// The write connection. protected SQLiteDatabaseConnection WriteConnection { get; set; } - protected ManagedConnection GetConnection(bool _ = false) + protected ManagedConnection GetConnection(bool readOnly = false) { WriteLock.Wait(); if (WriteConnection != null) @@ -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); } } @@ -249,55 +249,4 @@ namespace Emby.Server.Implementations.Data _disposed = true; } } - - /// - /// The disk synchronization mode, controls how aggressively SQLite will write data - /// all the way out to physical storage. - /// - public enum SynchronousMode - { - /// - /// SQLite continues without syncing as soon as it has handed data off to the operating system. - /// - Off = 0, - - /// - /// SQLite database engine will still sync at the most critical moments. - /// - Normal = 1, - - /// - /// SQLite database engine will use the xSync method of the VFS - /// to ensure that all content is safely written to the disk surface prior to continuing. - /// - Full = 2, - - /// - /// EXTRA synchronous is like FULL with the addition that the directory containing a rollback journal - /// is synced after that journal is unlinked to commit a transaction in DELETE mode. - /// - Extra = 3 - } - - /// - /// Storage mode used by temporary database files. - /// - public enum TempStoreMode - { - /// - /// The compile-time C preprocessor macro SQLITE_TEMP_STORE - /// is used to determine where temporary tables and indices are stored. - /// - Default = 0, - - /// - /// Temporary tables and indices are stored in a file. - /// - File = 1, - - /// - /// Temporary tables and indices are kept in as if they were pure in-memory databases memory. - /// - Memory = 2 - } } diff --git a/Emby.Server.Implementations/Data/ManagedConnection.cs b/Emby.Server.Implementations/Data/ManagedConnection.cs index 5c094ddd2d..11e33278d4 100644 --- a/Emby.Server.Implementations/Data/ManagedConnection.cs +++ b/Emby.Server.Implementations/Data/ManagedConnection.cs @@ -7,10 +7,12 @@ using SQLitePCL.pretty; namespace Emby.Server.Implementations.Data { - public class ManagedConnection : IDisposable + public sealed class ManagedConnection : IDisposable { - private SQLiteDatabaseConnection _db; private readonly SemaphoreSlim _writeLock; + + private SQLiteDatabaseConnection? _db; + private bool _disposed = false; public ManagedConnection(SQLiteDatabaseConnection db, SemaphoreSlim writeLock) @@ -54,12 +56,12 @@ namespace Emby.Server.Implementations.Data return _db.RunInTransaction(action, mode); } - public IEnumerable> Query(string sql) + public IEnumerable> Query(string sql) { return _db.Query(sql); } - public IEnumerable> Query(string sql, params object[] values) + public IEnumerable> 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 a04d630887..381eb92a88 100644 --- a/Emby.Server.Implementations/Data/SqliteExtensions.cs +++ b/Emby.Server.Implementations/Data/SqliteExtensions.cs @@ -1,3 +1,4 @@ +#nullable disable #pragma warning disable CS1591 using System; @@ -64,7 +65,7 @@ namespace Emby.Server.Implementations.Data }); } - public static Guid ReadGuidFromBlob(this IResultSetValue result) + public static Guid ReadGuidFromBlob(this ResultSetValue result) { return new Guid(result.ToBlob()); } @@ -85,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(); @@ -93,52 +94,142 @@ namespace Emby.Server.Implementations.Data dateText, _datetimeFormats, DateTimeFormatInfo.InvariantInfo, - DateTimeStyles.None).ToUniversalTime(); + DateTimeStyles.AdjustToUniversal); } - public static DateTime? TryReadDateTime(this IResultSetValue result) + public static bool TryReadDateTime(this IReadOnlyList 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.AdjustToUniversal, out var dateTimeResult)) + { + result = dateTimeResult; + return true; + } - if (DateTime.TryParseExact(dateText, _datetimeFormats, DateTimeFormatInfo.InvariantInfo, DateTimeStyles.None, out var dateTimeResult)) + result = default; + return false; + } + + public static bool TryGetGuid(this IReadOnlyList reader, int index, out Guid result) + { + var item = reader[index]; + if (item.IsDbNull()) { - return dateTimeResult.ToUniversalTime(); + result = default; + return false; } - return null; + result = item.ReadGuidFromBlob(); + return true; } - public static bool IsDBNull(this IReadOnlyList 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 result, int index) + public static string GetString(this IReadOnlyList result, int index) { return result[index].ToString(); } - public static bool GetBoolean(this IReadOnlyList result, int index) + public static bool TryGetString(this IReadOnlyList 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 result, int index) { return result[index].ToBool(); } - public static int GetInt32(this IReadOnlyList result, int index) + public static bool TryGetBoolean(this IReadOnlyList 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 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 result, int index) + public static long GetInt64(this IReadOnlyList result, int index) { return result[index].ToInt64(); } - public static float GetFloat(this IReadOnlyList result, int index) + public static bool TryGetInt64(this IReadOnlyList 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 reader, int index, out float result) + { + var item = reader[index]; + if (item.IsDbNull()) + { + result = default; + return false; + } + + result = item.ToFloat(); + return true; + } + + public static bool TryGetDouble(this IReadOnlyList reader, int index, out double result) { - return result[index].ToFloat(); + var item = reader[index]; + if (item.IsDbNull()) + { + result = default; + return false; + } + + result = item.ToDouble(); + return true; } - public static Guid GetGuid(this IReadOnlyList result, int index) + public static Guid GetGuid(this IReadOnlyList result, int index) { return result[index].ReadGuidFromBlob(); } @@ -350,7 +441,7 @@ namespace Emby.Server.Implementations.Data } } - public static IEnumerable> ExecuteQuery(this IStatement statement) + public static IEnumerable> ExecuteQuery(this IStatement statement) { while (statement.MoveNext()) { diff --git a/Emby.Server.Implementations/Data/SqliteItemRepository.cs b/Emby.Server.Implementations/Data/SqliteItemRepository.cs index 694805ebe9..13f1df7c86 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,11 @@ 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 MediaBrowser.Common.Extensions; -using MediaBrowser.Common.Json; +using Jellyfin.Extensions; +using Jellyfin.Extensions.Json; using MediaBrowser.Controller; using MediaBrowser.Controller.Channels; using MediaBrowser.Controller.Configuration; @@ -20,7 +24,6 @@ using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.Audio; using MediaBrowser.Controller.Entities.Movies; using MediaBrowser.Controller.Entities.TV; -using MediaBrowser.Controller.Extensions; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.LiveTv; using MediaBrowser.Controller.Persistence; @@ -40,8 +43,14 @@ namespace Emby.Server.Implementations.Data /// public class SqliteItemRepository : BaseSqliteRepository, IItemRepository { + private const string FromText = " from TypedBaseItems A"; private const string ChaptersTableName = "Chapters2"; + private const string SaveItemCommandText = + @"replace into TypedBaseItems + (guid,type,data,Path,StartDate,EndDate,ChannelId,IsMovie,IsSeries,EpisodeTitle,IsRepeat,CommunityRating,CustomRating,IndexNumber,IsLocked,Name,OfficialRating,MediaType,Overview,ParentIndexNumber,PremiereDate,ProductionYear,ParentId,Genres,InheritedParentalRatingValue,SortName,ForcedSortName,RunTimeTicks,Size,DateCreated,DateModified,PreferredMetadataLanguage,PreferredMetadataCountryCode,Width,Height,DateLastRefreshed,DateLastSaved,IsInMixedFolder,LockedFields,Studios,Audio,ExternalServiceId,Tags,IsFolder,UnratedType,TopParentId,TrailerTypes,CriticRating,CleanName,PresentationUniqueKey,OriginalTitle,PrimaryVersionId,DateLastMediaAdded,Album,IsVirtualItem,SeriesName,UserDataKey,SeasonName,SeasonId,SeriesId,ExternalSeriesId,Tagline,ProviderIds,Images,ProductionLocations,ExtraIds,TotalBitrate,ExtraType,Artists,AlbumArtists,ExternalId,SeriesPresentationUniqueKey,ShowId,OwnerId) + values (@guid,@type,@data,@Path,@StartDate,@EndDate,@ChannelId,@IsMovie,@IsSeries,@EpisodeTitle,@IsRepeat,@CommunityRating,@CustomRating,@IndexNumber,@IsLocked,@Name,@OfficialRating,@MediaType,@Overview,@ParentIndexNumber,@PremiereDate,@ProductionYear,@ParentId,@Genres,@InheritedParentalRatingValue,@SortName,@ForcedSortName,@RunTimeTicks,@Size,@DateCreated,@DateModified,@PreferredMetadataLanguage,@PreferredMetadataCountryCode,@Width,@Height,@DateLastRefreshed,@DateLastSaved,@IsInMixedFolder,@LockedFields,@Studios,@Audio,@ExternalServiceId,@Tags,@IsFolder,@UnratedType,@TopParentId,@TrailerTypes,@CriticRating,@CleanName,@PresentationUniqueKey,@OriginalTitle,@PrimaryVersionId,@DateLastMediaAdded,@Album,@IsVirtualItem,@SeriesName,@UserDataKey,@SeasonName,@SeasonId,@SeriesId,@ExternalSeriesId,@Tagline,@ProviderIds,@Images,@ProductionLocations,@ExtraIds,@TotalBitrate,@ExtraType,@Artists,@AlbumArtists,@ExternalId,@SeriesPresentationUniqueKey,@ShowId,@OwnerId)"; + private readonly IServerConfigurationManager _config; private readonly IServerApplicationHost _appHost; private readonly ILocalizationManager _localization; @@ -51,6 +60,231 @@ namespace Emby.Server.Implementations.Data private readonly TypeMapper _typeMapper; private readonly JsonSerializerOptions _jsonOptions; + private readonly ItemFields[] _allItemFields = Enum.GetValues(); + + private static readonly string[] _retriveItemColumns = + { + "type", + "data", + "StartDate", + "EndDate", + "ChannelId", + "IsMovie", + "IsSeries", + "EpisodeTitle", + "IsRepeat", + "CommunityRating", + "CustomRating", + "IndexNumber", + "IsLocked", + "PreferredMetadataLanguage", + "PreferredMetadataCountryCode", + "Width", + "Height", + "DateLastRefreshed", + "Name", + "Path", + "PremiereDate", + "Overview", + "ParentIndexNumber", + "ProductionYear", + "OfficialRating", + "ForcedSortName", + "RunTimeTicks", + "Size", + "DateCreated", + "DateModified", + "guid", + "Genres", + "ParentId", + "Audio", + "ExternalServiceId", + "IsInMixedFolder", + "DateLastSaved", + "LockedFields", + "Studios", + "Tags", + "TrailerTypes", + "OriginalTitle", + "PrimaryVersionId", + "DateLastMediaAdded", + "Album", + "CriticRating", + "IsVirtualItem", + "SeriesName", + "SeasonName", + "SeasonId", + "SeriesId", + "PresentationUniqueKey", + "InheritedParentalRatingValue", + "ExternalSeriesId", + "Tagline", + "ProviderIds", + "Images", + "ProductionLocations", + "ExtraIds", + "TotalBitrate", + "ExtraType", + "Artists", + "AlbumArtists", + "ExternalId", + "SeriesPresentationUniqueKey", + "ShowId", + "OwnerId" + }; + + private static readonly string _retriveItemColumnsSelectQuery = $"select {string.Join(',', _retriveItemColumns)} from TypedBaseItems where guid = @guid"; + + private static readonly string[] _mediaStreamSaveColumns = + { + "ItemId", + "StreamIndex", + "StreamType", + "Codec", + "Language", + "ChannelLayout", + "Profile", + "AspectRatio", + "Path", + "IsInterlaced", + "BitRate", + "Channels", + "SampleRate", + "IsDefault", + "IsForced", + "IsExternal", + "Height", + "Width", + "AverageFrameRate", + "RealFrameRate", + "Level", + "PixelFormat", + "BitDepth", + "IsAnamorphic", + "RefFrames", + "CodecTag", + "Comment", + "NalLengthSize", + "IsAvc", + "Title", + "TimeBase", + "CodecTimeBase", + "ColorPrimaries", + "ColorSpace", + "ColorTransfer" + }; + + private static readonly string _mediaStreamSaveColumnsInsertQuery = + $"insert into mediastreams ({string.Join(',', _mediaStreamSaveColumns)}) values "; + + private static readonly string _mediaStreamSaveColumnsSelectQuery = + $"select {string.Join(',', _mediaStreamSaveColumns)} from mediastreams where ItemId=@ItemId"; + + private static readonly string[] _mediaAttachmentSaveColumns = + { + "ItemId", + "AttachmentIndex", + "Codec", + "CodecTag", + "Comment", + "Filename", + "MIMEType" + }; + + private static readonly string _mediaAttachmentSaveColumnsSelectQuery = + $"select {string.Join(',', _mediaAttachmentSaveColumns)} from mediaattachments where ItemId=@ItemId"; + + private static readonly string _mediaAttachmentInsertPrefix; + + private static readonly HashSet _programTypes = new HashSet(StringComparer.OrdinalIgnoreCase) + { + "Program", + "TvChannel", + "LiveTvProgram", + "LiveTvTvChannel" + }; + + private static readonly HashSet _programExcludeParentTypes = new HashSet(StringComparer.OrdinalIgnoreCase) + { + "Series", + "Season", + "MusicAlbum", + "MusicArtist", + "PhotoAlbum" + }; + + private static readonly HashSet _serviceTypes = new HashSet(StringComparer.OrdinalIgnoreCase) + { + "TvChannel", + "LiveTvTvChannel" + }; + + private static readonly HashSet _startDateTypes = new HashSet(StringComparer.OrdinalIgnoreCase) + { + "Program", + "LiveTvProgram" + }; + + private static readonly HashSet _seriesTypes = new HashSet(StringComparer.OrdinalIgnoreCase) + { + "Book", + "AudioBook", + "Episode", + "Season" + }; + + private static readonly HashSet _artistExcludeParentTypes = new HashSet(StringComparer.OrdinalIgnoreCase) + { + "Series", + "Season", + "PhotoAlbum" + }; + + private static readonly HashSet _artistsTypes = new HashSet(StringComparer.OrdinalIgnoreCase) + { + "Audio", + "MusicAlbum", + "MusicVideo", + "AudioBook", + "AudioPodcast" + }; + + private static readonly Type[] _knownTypes = + { + typeof(LiveTvProgram), + typeof(LiveTvChannel), + typeof(Series), + typeof(Audio), + typeof(MusicAlbum), + typeof(MusicArtist), + typeof(MusicGenre), + typeof(MusicVideo), + typeof(Movie), + typeof(Playlist), + typeof(AudioBook), + typeof(Trailer), + typeof(BoxSet), + typeof(Episode), + typeof(Season), + typeof(Series), + typeof(Book), + typeof(CollectionFolder), + typeof(Folder), + typeof(Genre), + typeof(Person), + typeof(Photo), + typeof(PhotoAlbum), + typeof(Studio), + typeof(UserRootFolder), + typeof(UserView), + typeof(Video), + typeof(Year), + typeof(Channel), + typeof(AggregateFolder) + }; + + private readonly Dictionary _types = GetTypeMapDictionary(); + static SqliteItemRepository() { var queryPrefixText = new StringBuilder(); @@ -69,6 +303,12 @@ namespace Emby.Server.Implementations.Data /// /// Initializes a new instance of the class. /// + /// Instance of the interface. + /// Instance of the interface. + /// Instance of the interface. + /// Instance of the interface. + /// Instance of the interface. + /// config is null. public SqliteItemRepository( IServerConfigurationManager config, IServerApplicationHost appHost, @@ -105,6 +345,8 @@ namespace Emby.Server.Implementations.Data /// /// Opens the connection to the database. /// + /// The user data repository. + /// The user manager. public void Initialize(SqliteUserDataRepository userDataRepo, IUserManager userManager) { const string CreateMediaStreamsTableCommand @@ -144,7 +386,7 @@ namespace Emby.Server.Implementations.Data "drop index if exists idx_TypedBaseItems", "drop index if exists idx_mediastreams", "drop index if exists idx_mediastreams1", - "drop index if exists idx_"+ChaptersTableName, + "drop index if exists idx_" + ChaptersTableName, "drop index if exists idx_UserDataKeys1", "drop index if exists idx_UserDataKeys2", "drop index if exists idx_TypeTopParentId3", @@ -299,182 +541,43 @@ namespace Emby.Server.Implementations.Data AddColumn(db, "TypedBaseItems", "Height", "INT", existingColumnNames); AddColumn(db, "TypedBaseItems", "Size", "BIGINT", existingColumnNames); - existingColumnNames = GetColumnNames(db, "ItemValues"); - AddColumn(db, "ItemValues", "CleanValue", "Text", existingColumnNames); - - existingColumnNames = GetColumnNames(db, ChaptersTableName); - AddColumn(db, ChaptersTableName, "ImageDateModified", "DATETIME", existingColumnNames); - - existingColumnNames = GetColumnNames(db, "MediaStreams"); - AddColumn(db, "MediaStreams", "IsAvc", "BIT", existingColumnNames); - AddColumn(db, "MediaStreams", "TimeBase", "TEXT", existingColumnNames); - AddColumn(db, "MediaStreams", "CodecTimeBase", "TEXT", existingColumnNames); - AddColumn(db, "MediaStreams", "Title", "TEXT", existingColumnNames); - AddColumn(db, "MediaStreams", "NalLengthSize", "TEXT", existingColumnNames); - AddColumn(db, "MediaStreams", "Comment", "TEXT", existingColumnNames); - AddColumn(db, "MediaStreams", "CodecTag", "TEXT", existingColumnNames); - AddColumn(db, "MediaStreams", "PixelFormat", "TEXT", existingColumnNames); - AddColumn(db, "MediaStreams", "BitDepth", "INT", existingColumnNames); - AddColumn(db, "MediaStreams", "RefFrames", "INT", existingColumnNames); - AddColumn(db, "MediaStreams", "KeyFrames", "TEXT", existingColumnNames); - AddColumn(db, "MediaStreams", "IsAnamorphic", "BIT", existingColumnNames); - - AddColumn(db, "MediaStreams", "ColorPrimaries", "TEXT", existingColumnNames); - AddColumn(db, "MediaStreams", "ColorSpace", "TEXT", existingColumnNames); - AddColumn(db, "MediaStreams", "ColorTransfer", "TEXT", existingColumnNames); - }, TransactionMode); - - connection.RunQueries(postQueries); - } - - userDataRepo.Initialize(userManager, WriteLock, WriteConnection); - } - - private static readonly string[] _retriveItemColumns = - { - "type", - "data", - "StartDate", - "EndDate", - "ChannelId", - "IsMovie", - "IsSeries", - "EpisodeTitle", - "IsRepeat", - "CommunityRating", - "CustomRating", - "IndexNumber", - "IsLocked", - "PreferredMetadataLanguage", - "PreferredMetadataCountryCode", - "Width", - "Height", - "DateLastRefreshed", - "Name", - "Path", - "PremiereDate", - "Overview", - "ParentIndexNumber", - "ProductionYear", - "OfficialRating", - "ForcedSortName", - "RunTimeTicks", - "Size", - "DateCreated", - "DateModified", - "guid", - "Genres", - "ParentId", - "Audio", - "ExternalServiceId", - "IsInMixedFolder", - "DateLastSaved", - "LockedFields", - "Studios", - "Tags", - "TrailerTypes", - "OriginalTitle", - "PrimaryVersionId", - "DateLastMediaAdded", - "Album", - "CriticRating", - "IsVirtualItem", - "SeriesName", - "SeasonName", - "SeasonId", - "SeriesId", - "PresentationUniqueKey", - "InheritedParentalRatingValue", - "ExternalSeriesId", - "Tagline", - "ProviderIds", - "Images", - "ProductionLocations", - "ExtraIds", - "TotalBitrate", - "ExtraType", - "Artists", - "AlbumArtists", - "ExternalId", - "SeriesPresentationUniqueKey", - "ShowId", - "OwnerId" - }; - - private static readonly string _retriveItemColumnsSelectQuery = $"select {string.Join(',', _retriveItemColumns)} from TypedBaseItems where guid = @guid"; - - private static readonly string[] _mediaStreamSaveColumns = - { - "ItemId", - "StreamIndex", - "StreamType", - "Codec", - "Language", - "ChannelLayout", - "Profile", - "AspectRatio", - "Path", - "IsInterlaced", - "BitRate", - "Channels", - "SampleRate", - "IsDefault", - "IsForced", - "IsExternal", - "Height", - "Width", - "AverageFrameRate", - "RealFrameRate", - "Level", - "PixelFormat", - "BitDepth", - "IsAnamorphic", - "RefFrames", - "CodecTag", - "Comment", - "NalLengthSize", - "IsAvc", - "Title", - "TimeBase", - "CodecTimeBase", - "ColorPrimaries", - "ColorSpace", - "ColorTransfer" - }; - - private static readonly string _mediaStreamSaveColumnsInsertQuery = - $"insert into mediastreams ({string.Join(',', _mediaStreamSaveColumns)}) values "; + existingColumnNames = GetColumnNames(db, "ItemValues"); + AddColumn(db, "ItemValues", "CleanValue", "Text", existingColumnNames); - private static readonly string _mediaStreamSaveColumnsSelectQuery = - $"select {string.Join(',', _mediaStreamSaveColumns)} from mediastreams where ItemId=@ItemId"; + existingColumnNames = GetColumnNames(db, ChaptersTableName); + AddColumn(db, ChaptersTableName, "ImageDateModified", "DATETIME", existingColumnNames); - private static readonly string[] _mediaAttachmentSaveColumns = - { - "ItemId", - "AttachmentIndex", - "Codec", - "CodecTag", - "Comment", - "Filename", - "MIMEType" - }; + existingColumnNames = GetColumnNames(db, "MediaStreams"); + AddColumn(db, "MediaStreams", "IsAvc", "BIT", existingColumnNames); + AddColumn(db, "MediaStreams", "TimeBase", "TEXT", existingColumnNames); + AddColumn(db, "MediaStreams", "CodecTimeBase", "TEXT", existingColumnNames); + AddColumn(db, "MediaStreams", "Title", "TEXT", existingColumnNames); + AddColumn(db, "MediaStreams", "NalLengthSize", "TEXT", existingColumnNames); + AddColumn(db, "MediaStreams", "Comment", "TEXT", existingColumnNames); + AddColumn(db, "MediaStreams", "CodecTag", "TEXT", existingColumnNames); + AddColumn(db, "MediaStreams", "PixelFormat", "TEXT", existingColumnNames); + AddColumn(db, "MediaStreams", "BitDepth", "INT", existingColumnNames); + AddColumn(db, "MediaStreams", "RefFrames", "INT", existingColumnNames); + AddColumn(db, "MediaStreams", "KeyFrames", "TEXT", existingColumnNames); + AddColumn(db, "MediaStreams", "IsAnamorphic", "BIT", existingColumnNames); - private static readonly string _mediaAttachmentSaveColumnsSelectQuery = - $"select {string.Join(',', _mediaAttachmentSaveColumns)} from mediaattachments where ItemId=@ItemId"; + AddColumn(db, "MediaStreams", "ColorPrimaries", "TEXT", existingColumnNames); + AddColumn(db, "MediaStreams", "ColorSpace", "TEXT", existingColumnNames); + AddColumn(db, "MediaStreams", "ColorTransfer", "TEXT", existingColumnNames); + }, TransactionMode); - private static readonly string _mediaAttachmentInsertPrefix; + connection.RunQueries(postQueries); + } - private const string SaveItemCommandText = - @"replace into TypedBaseItems - (guid,type,data,Path,StartDate,EndDate,ChannelId,IsMovie,IsSeries,EpisodeTitle,IsRepeat,CommunityRating,CustomRating,IndexNumber,IsLocked,Name,OfficialRating,MediaType,Overview,ParentIndexNumber,PremiereDate,ProductionYear,ParentId,Genres,InheritedParentalRatingValue,SortName,ForcedSortName,RunTimeTicks,Size,DateCreated,DateModified,PreferredMetadataLanguage,PreferredMetadataCountryCode,Width,Height,DateLastRefreshed,DateLastSaved,IsInMixedFolder,LockedFields,Studios,Audio,ExternalServiceId,Tags,IsFolder,UnratedType,TopParentId,TrailerTypes,CriticRating,CleanName,PresentationUniqueKey,OriginalTitle,PrimaryVersionId,DateLastMediaAdded,Album,IsVirtualItem,SeriesName,UserDataKey,SeasonName,SeasonId,SeriesId,ExternalSeriesId,Tagline,ProviderIds,Images,ProductionLocations,ExtraIds,TotalBitrate,ExtraType,Artists,AlbumArtists,ExternalId,SeriesPresentationUniqueKey,ShowId,OwnerId) - values (@guid,@type,@data,@Path,@StartDate,@EndDate,@ChannelId,@IsMovie,@IsSeries,@EpisodeTitle,@IsRepeat,@CommunityRating,@CustomRating,@IndexNumber,@IsLocked,@Name,@OfficialRating,@MediaType,@Overview,@ParentIndexNumber,@PremiereDate,@ProductionYear,@ParentId,@Genres,@InheritedParentalRatingValue,@SortName,@ForcedSortName,@RunTimeTicks,@Size,@DateCreated,@DateModified,@PreferredMetadataLanguage,@PreferredMetadataCountryCode,@Width,@Height,@DateLastRefreshed,@DateLastSaved,@IsInMixedFolder,@LockedFields,@Studios,@Audio,@ExternalServiceId,@Tags,@IsFolder,@UnratedType,@TopParentId,@TrailerTypes,@CriticRating,@CleanName,@PresentationUniqueKey,@OriginalTitle,@PrimaryVersionId,@DateLastMediaAdded,@Album,@IsVirtualItem,@SeriesName,@UserDataKey,@SeasonName,@SeasonId,@SeriesId,@ExternalSeriesId,@Tagline,@ProviderIds,@Images,@ProductionLocations,@ExtraIds,@TotalBitrate,@ExtraType,@Artists,@AlbumArtists,@ExternalId,@SeriesPresentationUniqueKey,@ShowId,@OwnerId)"; + userDataRepo.Initialize(userManager, WriteLock, WriteConnection); + } /// /// Save a standard item in the repo. /// /// The item. /// The cancellation token. - /// item + /// is null. public void SaveItem(BaseItem item, CancellationToken cancellationToken) { if (item == null) @@ -499,10 +602,10 @@ namespace Emby.Server.Implementations.Data connection.RunInTransaction( db => { - using (var saveImagesStatement = base.PrepareStatement(db, "Update TypedBaseItems set Images=@Images where guid=@Id")) + using (var saveImagesStatement = 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(); } @@ -516,9 +619,7 @@ namespace Emby.Server.Implementations.Data /// The items. /// The cancellation token. /// - /// items - /// or - /// cancellationToken + /// or is null. /// public void SaveItems(IEnumerable items, CancellationToken cancellationToken) { @@ -897,8 +998,8 @@ 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) { @@ -968,10 +1069,10 @@ namespace Emby.Server.Implementations.Data saveItemStatement.MoveNext(); } - private static string SerializeProviderIds(BaseItem item) + internal static string SerializeProviderIds(Dictionary 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 +1096,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) - { - return; - } - - var parts = value.Split('|', StringSplitOptions.RemoveEmptyEntries); - - foreach (var part in parts) + foreach (var part in value.SpanSplit('|')) { - 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 +1136,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(); } - 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('|' , StringSplitOptions.RemoveEmptyEntries); - var list = new List(); - 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(); + } + + // 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 +1187,115 @@ 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 value) { - var parts = value.Split('*', StringSplitOptions.None); + var nextSegment = value.IndexOf('*'); + if (nextSegment == -1) + { + return null; + } - if (parts.Length < 3) + ReadOnlySpan path = value[..nextSegment]; + value = value[(nextSegment + 1)..]; + nextSegment = value.IndexOf('*'); + if (nextSegment == -1) { return null; } - var image = new ItemImageInfo(); + ReadOnlySpan dateModified = value[..nextSegment]; + value = value[(nextSegment + 1)..]; + nextSegment = value.IndexOf('*'); + if (nextSegment == -1) + { + nextSegment = value.Length; + } + + ReadOnlySpan 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) + && ticks >= DateTime.MinValue.Ticks + && ticks <= DateTime.MaxValue.Ticks) { image.DateModified = new DateTime(ticks, DateTimeKind.Utc); } + else + { + return null; + } - if (Enum.TryParse(parts[2], true, out ImageType type)) + if (Enum.TryParse(imageType, true, out ImageType type)) { image.Type = type; } + else + { + return null; + } - 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 widthSpan = value[..nextSegment]; + + value = value[(nextSegment + 1)..]; + nextSegment = value.IndexOf('*'); + if (nextSegment == -1) + { + nextSegment = value.Length; + } + + ReadOnlySpan 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 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); } } @@ -1140,8 +1307,8 @@ namespace Emby.Server.Implementations.Data /// /// The id. /// BaseItem. - /// id - /// + /// is null. + /// is . public BaseItem RetrieveItem(Guid id) { if (id == Guid.Empty) @@ -1241,12 +1408,12 @@ namespace Emby.Server.Implementations.Data return true; } - private BaseItem GetItem(IReadOnlyList reader, InternalItemsQuery query) + private BaseItem GetItem(IReadOnlyList 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 reader, InternalItemsQuery query, bool enableProgramAttributes, bool hasEpisodeAttributes, bool hasServiceName, bool queryHasStartDate, bool hasTrailerTypes, bool hasArtistFields, bool hasSeriesFields) + private BaseItem GetItem(IReadOnlyList reader, InternalItemsQuery query, bool enableProgramAttributes, bool hasEpisodeAttributes, bool hasServiceName, bool queryHasStartDate, bool hasTrailerTypes, bool hasArtistFields, bool hasSeriesFields) { var typeString = reader.GetString(0); @@ -1291,27 +1458,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 +1490,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,298 +1516,235 @@ 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('|', 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)) + 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 GetLockedFields(string s) + List fields = null; + foreach (var i in lockedFields.AsSpan().Split('|')) { - foreach (var i in s.Split('|', StringSplitOptions.RemoveEmptyEntries)) + if (Enum.TryParse(i, true, out MetadataField parsedValue)) { - if (Enum.TryParse(i, true, out MetadataField parsedValue)) - { - yield return parsedValue; - } + (fields ??= new List()).Add(parsedValue); } } - item.LockedFields = GetLockedFields(reader.GetString(index)).ToArray(); + item.LockedFields = fields?.ToArray() ?? Array.Empty(); } - - index++; } if (HasField(query, ItemFields.Studios)) { - if (!reader.IsDBNull(index)) + if (reader.TryGetString(index++, out var studios)) { - item.Studios = reader.GetString(index).Split('|', 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('|', 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 GetTrailerTypes(string s) + List types = null; + foreach (var i in trailerTypes.AsSpan().Split('|')) { - foreach (var i in s.Split('|', StringSplitOptions.RemoveEmptyEntries)) + if (Enum.TryParse(i, true, out TrailerType parsedValue)) { - if (Enum.TryParse(i, true, out TrailerType parsedValue)) - { - yield return parsedValue; - } + (types ??= new List()).Add(parsedValue); } } - trailer.TrailerTypes = GetTrailerTypes(reader.GetString(index)).ToArray(); + trailer.TrailerTypes = types?.ToArray() ?? Array.Empty(); } } @@ -1654,19 +1753,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 +1771,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 +1808,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 +1832,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 +1843,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 +1892,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('|', 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('|', 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('|', 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 +1956,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; } @@ -1915,12 +1986,7 @@ namespace Emby.Server.Implementations.Data return result; } - /// - /// Gets chapters for an item. - /// - /// The item. - /// IEnumerable{ChapterInfo}. - /// id + /// public List GetChapters(BaseItem item) { CheckDisposed(); @@ -1943,13 +2009,7 @@ namespace Emby.Server.Implementations.Data } } - /// - /// Gets a single chapter for an item. - /// - /// The item. - /// The index. - /// ChapterInfo. - /// id + /// public ChapterInfo GetChapter(BaseItem item, int index) { CheckDisposed(); @@ -1977,21 +2037,21 @@ namespace Emby.Server.Implementations.Data /// The reader. /// The item. /// ChapterInfo. - private ChapterInfo GetChapter(IReadOnlyList reader, BaseItem item) + private ChapterInfo GetChapter(IReadOnlyList 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 +2066,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; @@ -2017,6 +2077,8 @@ namespace Emby.Server.Implementations.Data /// /// Saves the chapters. /// + /// The item id. + /// The chapters. public void SaveChapters(Guid id, IReadOnlyList chapters) { CheckDisposed(); @@ -2061,7 +2123,7 @@ namespace Emby.Server.Implementations.Data for (var i = startIndex; i < endIndex; i++) { - insertText.AppendFormat("(@ItemId, @ChapterIndex{0}, @StartPositionTicks{0}, @Name{0}, @ImagePath{0}, @ImageDateModified{0}),", i.ToString(CultureInfo.InvariantCulture)); + insertText.AppendFormat(CultureInfo.InvariantCulture, "(@ItemId, @ChapterIndex{0}, @StartPositionTicks{0}, @Name{0}, @ImagePath{0}, @ImageDateModified{0}),", i.ToString(CultureInfo.InvariantCulture)); } insertText.Length -= 1; // Remove last , @@ -2116,31 +2178,6 @@ 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(); - default: - return new[] { field.ToString() }; - } - } - private bool HasField(InternalItemsQuery query, ItemFields name) { switch (name) @@ -2173,23 +2210,6 @@ namespace Emby.Server.Implementations.Data } } - private static readonly HashSet _programExcludeParentTypes = new HashSet(StringComparer.OrdinalIgnoreCase) - { - "Series", - "Season", - "MusicAlbum", - "MusicArtist", - "PhotoAlbum" - }; - - private static readonly HashSet _programTypes = new HashSet(StringComparer.OrdinalIgnoreCase) - { - "Program", - "TvChannel", - "LiveTvProgram", - "LiveTvTvChannel" - }; - private bool HasProgramAttributes(InternalItemsQuery query) { if (_programExcludeParentTypes.Contains(query.ParentType)) @@ -2205,12 +2225,6 @@ namespace Emby.Server.Implementations.Data return query.IncludeItemTypes.Any(x => _programTypes.Contains(x)); } - private static readonly HashSet _serviceTypes = new HashSet(StringComparer.OrdinalIgnoreCase) - { - "TvChannel", - "LiveTvTvChannel" - }; - private bool HasServiceName(InternalItemsQuery query) { if (_programExcludeParentTypes.Contains(query.ParentType)) @@ -2223,15 +2237,9 @@ namespace Emby.Server.Implementations.Data return true; } - return query.IncludeItemTypes.Any(x => _serviceTypes.Contains(x)); - } - - private static readonly HashSet _startDateTypes = new HashSet(StringComparer.OrdinalIgnoreCase) - { - "Program", - "LiveTvProgram" - }; - + return query.IncludeItemTypes.Any(x => _serviceTypes.Contains(x)); + } + private bool HasStartDate(InternalItemsQuery query) { if (_programExcludeParentTypes.Contains(query.ParentType)) @@ -2267,22 +2275,6 @@ namespace Emby.Server.Implementations.Data return query.IncludeItemTypes.Contains("Trailer", StringComparer.OrdinalIgnoreCase); } - private static readonly HashSet _artistExcludeParentTypes = new HashSet(StringComparer.OrdinalIgnoreCase) - { - "Series", - "Season", - "PhotoAlbum" - }; - - private static readonly HashSet _artistsTypes = new HashSet(StringComparer.OrdinalIgnoreCase) - { - "Audio", - "MusicAlbum", - "MusicVideo", - "AudioBook", - "AudioPodcast" - }; - private bool HasArtistFields(InternalItemsQuery query) { if (_artistExcludeParentTypes.Contains(query.ParentType)) @@ -2298,14 +2290,6 @@ namespace Emby.Server.Implementations.Data return query.IncludeItemTypes.Any(x => _artistsTypes.Contains(x)); } - private static readonly HashSet _seriesTypes = new HashSet(StringComparer.OrdinalIgnoreCase) - { - "Book", - "AudioBook", - "Episode", - "Season" - }; - private bool HasSeriesFields(InternalItemsQuery query) { if (string.Equals(query.ParentType, "PhotoAlbum", StringComparison.OrdinalIgnoreCase)) @@ -2321,77 +2305,98 @@ namespace Emby.Server.Implementations.Data return query.IncludeItemTypes.Any(x => _seriesTypes.Contains(x)); } - private List GetFinalColumnsToSelect(InternalItemsQuery query, IEnumerable startColumns) + private void SetFinalColumnsToSelect(InternalItemsQuery query, List columns) { - var list = startColumns.ToList(); - - foreach (var field in _allFields) + foreach (var field in _allItemFields) { 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) @@ -2439,7 +2444,7 @@ namespace Emby.Server.Implementations.Data builder.Append(") as SimilarityScore"); - list.Add(builder.ToString()); + columns.Add(builder.ToString()); var oldLen = query.ExcludeItemIds.Length; var newLen = oldLen + item.ExtraIds.Length + 1; @@ -2466,10 +2471,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) @@ -2535,31 +2538,25 @@ namespace Emby.Server.Implementations.Data private string GetGroupBy(InternalItemsQuery query) { - var groups = new List(); - - 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) @@ -2577,17 +2574,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 { "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)) { @@ -2629,20 +2630,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) { @@ -2650,15 +2654,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(); using (var connection = GetConnection(true)) { @@ -2721,87 +2728,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 items, BaseItem newItem) @@ -2879,20 +2821,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) { @@ -2900,43 +2849,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(); + 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 columnsToSelect; if (EnableGroupByPresentationUniqueKey(query)) { - commandText += " select " + string.Join(',', GetFinalColumnsToSelect(query, new[] { "count (distinct PresentationUniqueKey)" })) + GetFromText(); + columnsToSelect = new List { "count (distinct PresentationUniqueKey)" }; } else if (query.GroupBySeriesPresentationUniqueKey) { - commandText += " select " + string.Join(',', GetFinalColumnsToSelect(query, new[] { "count (distinct SeriesPresentationUniqueKey)" })) + GetFromText(); + columnsToSelect = new List { "count (distinct SeriesPresentationUniqueKey)" }; } else { - commandText += " select " + string.Join(',', GetFinalColumnsToSelect(query, new[] { "count (guid)" })) + GetFromText(); + columnsToSelect = new List { "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(); @@ -2946,11 +2910,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)) { @@ -2980,11 +2945,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)) { @@ -2999,11 +2967,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; } @@ -3136,19 +3105,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 { "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) { @@ -3156,15 +3128,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(); using (var connection = GetConnection(true)) { @@ -3203,7 +3178,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 { "guid", "path" }; + SetFinalColumnsToSelect(query, columns); + var commandText = "select " + string.Join(',', columns) + FromText; var whereClauses = GetWhereClauses(query, null); if (whereClauses.Count != 0) @@ -3245,12 +3222,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(id, path)); } @@ -3283,9 +3256,11 @@ namespace Emby.Server.Implementations.Data var now = DateTime.UtcNow; + var columns = new List { "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); @@ -3325,19 +3300,23 @@ namespace Emby.Server.Implementations.Data { commandText = string.Empty; + List columnsToSelect; if (EnableGroupByPresentationUniqueKey(query)) { - commandText += " select " + string.Join(',', GetFinalColumnsToSelect(query, new[] { "count (distinct PresentationUniqueKey)" })) + GetFromText(); + columnsToSelect = new List { "count (distinct PresentationUniqueKey)" }; } else if (query.GroupBySeriesPresentationUniqueKey) { - commandText += " select " + string.Join(',', GetFinalColumnsToSelect(query, new[] { "count (distinct SeriesPresentationUniqueKey)" })) + GetFromText(); + columnsToSelect = new List { "count (distinct SeriesPresentationUniqueKey)" }; } else { - commandText += " select " + string.Join(',', GetFinalColumnsToSelect(query, new[] { "count (guid)" })) + GetFromText(); + columnsToSelect = new List { "count (guid)" }; } + SetFinalColumnsToSelect(query, columnsToSelect); + commandText += " select " + string.Join(',', columnsToSelect) + FromText; + commandText += GetJoinUserDataText(query) + whereText; statementTexts.Add(commandText); @@ -3584,11 +3563,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"); @@ -4444,7 +4423,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(); @@ -4474,7 +4453,7 @@ namespace Emby.Server.Implementations.Data } } - if (query.HasAnyProviderId.Count > 0) + if (query.HasAnyProviderId != null && query.HasAnyProviderId.Count > 0) { var hasProviderIds = new List(); @@ -4532,56 +4511,50 @@ namespace Emby.Server.Implementations.Data 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 + ")"); } } @@ -4790,27 +4763,27 @@ namespace Emby.Server.Implementations.Data if (IsTypeInQuery(nameof(Person), query)) { - list.Add(nameof(Person)); + list.Add(typeof(Person).FullName); } if (IsTypeInQuery(nameof(Genre), query)) { - list.Add(nameof(Genre)); + list.Add(typeof(Genre).FullName); } if (IsTypeInQuery(nameof(MusicGenre), query)) { - list.Add(nameof(MusicGenre)); + list.Add(typeof(MusicGenre).FullName); } if (IsTypeInQuery(nameof(MusicArtist), query)) { - list.Add(nameof(MusicArtist)); + list.Add(typeof(MusicArtist).FullName); } if (IsTypeInQuery(nameof(Studio), query)) { - list.Add(nameof(Studio)); + list.Add(typeof(Studio).FullName); } return list; @@ -4863,17 +4836,12 @@ namespace Emby.Server.Implementations.Data return true; } - var types = new[] - { - nameof(Episode), - nameof(Video), - nameof(Movie), - nameof(MusicVideo), - nameof(Series), - nameof(Season) - }; - - 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; } @@ -4881,49 +4849,10 @@ namespace Emby.Server.Implementations.Data return false; } - private static readonly Type[] _knownTypes = - { - typeof(LiveTvProgram), - typeof(LiveTvChannel), - typeof(Series), - typeof(Audio), - typeof(MusicAlbum), - typeof(MusicArtist), - typeof(MusicGenre), - typeof(MusicVideo), - typeof(Movie), - typeof(Playlist), - typeof(AudioBook), - typeof(Trailer), - typeof(BoxSet), - typeof(Episode), - typeof(Season), - typeof(Series), - typeof(Book), - typeof(CollectionFolder), - typeof(Folder), - typeof(Genre), - typeof(Person), - typeof(Photo), - typeof(PhotoAlbum), - typeof(Studio), - typeof(UserRootFolder), - typeof(UserView), - typeof(Video), - typeof(Year), - typeof(Channel), - 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", @@ -4946,37 +4875,35 @@ where AncestorIdText not null and ItemValues.Value not null and ItemValues.Type } } - private static Dictionary GetTypeMapDictionary() + private static Dictionary GetTypeMapDictionary() { - var dict = new Dictionary(StringComparer.OrdinalIgnoreCase); + var dict = new Dictionary(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 _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(); + Logger.LogWarning("Unknown item type: {ItemType}", value); + return null; } public void DeleteItem(Guid id) @@ -5279,64 +5206,87 @@ AND Type = @InternalPersonType)"); public List GetStudioNames() { - return GetItemValueNames(new[] { 3 }, new List(), new List()); + return GetItemValueNames(new[] { 3 }, Array.Empty(), Array.Empty()); } public List GetAllArtistNames() { - return GetItemValueNames(new[] { 0, 1 }, new List(), new List()); + return GetItemValueNames(new[] { 0, 1 }, Array.Empty(), Array.Empty()); } public List GetMusicGenreNames() { - return GetItemValueNames(new[] { 2 }, new List { "Audio", "MusicVideo", "MusicAlbum", "MusicArtist" }, new List()); + return GetItemValueNames( + new[] { 2 }, + new string[] + { + typeof(Audio).FullName, + typeof(MusicVideo).FullName, + typeof(MusicAlbum).FullName, + typeof(MusicArtist).FullName + }, + Array.Empty()); } public List GetGenreNames() { - return GetItemValueNames(new[] { 2 }, new List(), new List { "Audio", "MusicVideo", "MusicAlbum", "MusicArtist" }); + return GetItemValueNames( + new[] { 2 }, + Array.Empty(), + new string[] + { + typeof(Audio).FullName, + typeof(MusicVideo).FullName, + typeof(MusicAlbum).FullName, + typeof(MusicArtist).FullName + }); } - private List GetItemValueNames(int[] itemValueTypes, List withItemTypes, List excludeItemTypes) + private List GetItemValueNames(int[] itemValueTypes, IReadOnlyList withItemTypes, IReadOnlyList 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(); 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); } } } @@ -5362,18 +5312,19 @@ AND Type = @InternalPersonType)"); 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 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) { @@ -5389,20 +5340,22 @@ AND Type = @InternalPersonType)"); }; 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() - { - { "itemTypes", "(" + itemCountColumnQuery + ") as itemTypes" } - }; + itemCountColumns = stringBuilder.ToString(); + stringBuilder.Clear(); } List 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 @@ -5423,20 +5376,20 @@ AND Type = @InternalPersonType)"); 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) { @@ -5461,21 +5414,31 @@ AND Type = @InternalPersonType)"); }; 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(); - if (query.SimilarTo != null || !string.IsNullOrEmpty(query.SearchTerm)) + stringBuilder.Append("select ") + .AppendJoin(',', columns) + .Append(FromText) + .Append(GetJoinUserDataText(query)) + .Append(whereText) + .Append(" group by PresentationUniqueKey"); + + 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) @@ -5484,32 +5447,39 @@ AND Type = @InternalPersonType)"); 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 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 { "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)>(); @@ -5519,11 +5489,9 @@ AND Type = @InternalPersonType)"); 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)) @@ -5564,13 +5532,7 @@ AND Type = @InternalPersonType)"); 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)) @@ -5607,7 +5569,7 @@ AND Type = @InternalPersonType)"); return result; } - private ItemCounts GetItemCounts(IReadOnlyList reader, int countStartColumn, string[] typesToCount) + private static ItemCounts GetItemCounts(IReadOnlyList reader, int countStartColumn, string[] typesToCount) { var counts = new ItemCounts(); @@ -5616,51 +5578,43 @@ AND Type = @InternalPersonType)"); 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('|', 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; @@ -5809,7 +5763,10 @@ AND Type = @InternalPersonType)"); 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 @@ -5843,7 +5800,7 @@ AND Type = @InternalPersonType)"); } } - private PersonInfo GetPerson(IReadOnlyList reader) + private PersonInfo GetPerson(IReadOnlyList reader) { var item = new PersonInfo { @@ -5851,19 +5808,19 @@ AND Type = @InternalPersonType)"); 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; @@ -6050,7 +6007,7 @@ AND Type = @InternalPersonType)"); /// /// The reader. /// ChapterInfo. - private MediaStream GetMediaStream(IReadOnlyList reader) + private MediaStream GetMediaStream(IReadOnlyList reader) { var item = new MediaStream { @@ -6059,150 +6016,150 @@ AND Type = @InternalPersonType)"); item.Type = Enum.Parse(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) @@ -6261,7 +6218,7 @@ AND Type = @InternalPersonType)"); CheckDisposed(); if (id == Guid.Empty) { - throw new ArgumentException(nameof(id)); + throw new ArgumentException("Guid can't be empty.", nameof(id)); } if (attachments == null) @@ -6351,36 +6308,36 @@ AND Type = @InternalPersonType)"); /// /// The reader. /// MediaAttachment. - private MediaAttachment GetMediaAttachment(IReadOnlyList reader) + private MediaAttachment GetMediaAttachment(IReadOnlyList 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 6574db6078..107096b5f2 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; @@ -30,6 +32,9 @@ namespace Emby.Server.Implementations.Data /// /// Opens the connection to the database. /// + /// The user manager. + /// The lock to use for database IO. + /// The connection to use for database IO. public void Initialize(IUserManager userManager, SemaphoreSlim dbLock, SQLiteDatabaseConnection dbConnection) { WriteLock.Dispose(); @@ -47,8 +52,8 @@ 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)", "drop index if exists idx_userdata", @@ -127,19 +132,17 @@ namespace Emby.Server.Implementations.Data return list; } - /// - /// Saves the user data. - /// - public void SaveUserData(long internalUserId, string key, UserItemData userData, CancellationToken cancellationToken) + /// + public void SaveUserData(long userId, string key, UserItemData userData, CancellationToken cancellationToken) { if (userData == null) { throw new ArgumentNullException(nameof(userData)); } - if (internalUserId <= 0) + if (userId <= 0) { - throw new ArgumentNullException(nameof(internalUserId)); + throw new ArgumentNullException(nameof(userId)); } if (string.IsNullOrEmpty(key)) @@ -147,22 +150,23 @@ namespace Emby.Server.Implementations.Data throw new ArgumentNullException(nameof(key)); } - PersistUserData(internalUserId, key, userData, cancellationToken); + PersistUserData(userId, key, userData, cancellationToken); } - public void SaveAllUserData(long internalUserId, UserItemData[] userData, CancellationToken cancellationToken) + /// + public void SaveAllUserData(long userId, UserItemData[] userData, CancellationToken cancellationToken) { if (userData == null) { throw new ArgumentNullException(nameof(userData)); } - if (internalUserId <= 0) + if (userId <= 0) { - throw new ArgumentNullException(nameof(internalUserId)); + throw new ArgumentNullException(nameof(userId)); } - PersistAllUserData(internalUserId, userData, cancellationToken); + PersistAllUserData(userId, userData, cancellationToken); } /// @@ -172,7 +176,6 @@ namespace Emby.Server.Implementations.Data /// The key. /// The user data. /// The cancellation token. - /// Task. public void PersistUserData(long internalUserId, string key, UserItemData userData, CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); @@ -262,19 +265,19 @@ namespace Emby.Server.Implementations.Data /// /// Gets the user data. /// - /// The user id. + /// The user id. /// The key. /// Task{UserItemData}. /// /// userId /// or - /// key + /// key. /// - public UserItemData GetUserData(long internalUserId, string key) + public UserItemData GetUserData(long userId, string key) { - if (internalUserId <= 0) + if (userId <= 0) { - throw new ArgumentNullException(nameof(internalUserId)); + throw new ArgumentNullException(nameof(userId)); } if (string.IsNullOrEmpty(key)) @@ -286,7 +289,7 @@ namespace Emby.Server.Implementations.Data { using (var statement = connection.PrepareStatement("select key,userid,rating,played,playCount,isFavorite,playbackPositionTicks,lastPlayedDate,AudioStreamIndex,SubtitleStreamIndex from UserDatas where key =@Key and userId=@UserId")) { - statement.TryBind("@UserId", internalUserId); + statement.TryBind("@UserId", userId); statement.TryBind("@Key", key); foreach (var row in statement.ExecuteQuery()) @@ -299,7 +302,7 @@ namespace Emby.Server.Implementations.Data } } - public UserItemData GetUserData(long internalUserId, List keys) + public UserItemData GetUserData(long userId, List keys) { if (keys == null) { @@ -311,19 +314,19 @@ namespace Emby.Server.Implementations.Data return null; } - return GetUserData(internalUserId, keys[0]); + return GetUserData(userId, keys[0]); } /// /// Return all user-data associated with the given user. /// - /// - /// - public List GetAllUserData(long internalUserId) + /// The internal user id. + /// The list of user item data. + public List GetAllUserData(long userId) { - if (internalUserId <= 0) + if (userId <= 0) { - throw new ArgumentNullException(nameof(internalUserId)); + throw new ArgumentNullException(nameof(userId)); } var list = new List(); @@ -332,7 +335,7 @@ namespace Emby.Server.Implementations.Data { using (var statement = connection.PrepareStatement("select key,userid,rating,played,playCount,isFavorite,playbackPositionTicks,lastPlayedDate,AudioStreamIndex,SubtitleStreamIndex from UserDatas where userId=@UserId")) { - statement.TryBind("@UserId", internalUserId); + statement.TryBind("@UserId", userId); foreach (var row in statement.ExecuteQuery()) { @@ -347,17 +350,18 @@ namespace Emby.Server.Implementations.Data /// /// Read a row from the specified reader into the provided userData object. /// - /// - private UserItemData ReadRow(IReadOnlyList reader) + /// The list of result set values. + /// The user item data. + private UserItemData ReadRow(IReadOnlyList 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 +369,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/SynchronouseMode.cs b/Emby.Server.Implementations/Data/SynchronouseMode.cs new file mode 100644 index 0000000000..cde524e2e0 --- /dev/null +++ b/Emby.Server.Implementations/Data/SynchronouseMode.cs @@ -0,0 +1,30 @@ +namespace Emby.Server.Implementations.Data; + +/// +/// The disk synchronization mode, controls how aggressively SQLite will write data +/// all the way out to physical storage. +/// +public enum SynchronousMode +{ + /// + /// SQLite continues without syncing as soon as it has handed data off to the operating system. + /// + Off = 0, + + /// + /// SQLite database engine will still sync at the most critical moments. + /// + Normal = 1, + + /// + /// SQLite database engine will use the xSync method of the VFS + /// to ensure that all content is safely written to the disk surface prior to continuing. + /// + Full = 2, + + /// + /// EXTRA synchronous is like FULL with the addition that the directory containing a rollback journal + /// is synced after that journal is unlinked to commit a transaction in DELETE mode. + /// + Extra = 3 +} diff --git a/Emby.Server.Implementations/Data/TempStoreMode.cs b/Emby.Server.Implementations/Data/TempStoreMode.cs new file mode 100644 index 0000000000..d2427ce478 --- /dev/null +++ b/Emby.Server.Implementations/Data/TempStoreMode.cs @@ -0,0 +1,23 @@ +namespace Emby.Server.Implementations.Data; + +/// +/// Storage mode used by temporary database files. +/// +public enum TempStoreMode +{ + /// + /// The compile-time C preprocessor macro SQLITE_TEMP_STORE + /// is used to determine where temporary tables and indices are stored. + /// + Default = 0, + + /// + /// Temporary tables and indices are stored in a file. + /// + File = 1, + + /// + /// Temporary tables and indices are kept in as if they were pure in-memory databases memory. + /// + Memory = 2 +} 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. /// - private readonly ConcurrentDictionary _typeMap = new ConcurrentDictionary(); + private readonly ConcurrentDictionary _typeMap = new ConcurrentDictionary(); /// /// Gets the type. @@ -21,26 +21,16 @@ namespace Emby.Server.Implementations.Data /// Name of the type. /// Type. /// typeName is null. - public Type GetType(string typeName) + public Type? GetType(string typeName) { if (string.IsNullOrEmpty(typeName)) { throw new ArgumentNullException(nameof(typeName)); } - return _typeMap.GetOrAdd(typeName, LookupType); - } - - /// - /// Lookups the type. - /// - /// Name of the type. - /// Type. - 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..0cfced8beb 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; @@ -13,9 +15,18 @@ namespace Emby.Server.Implementations.Devices { private readonly IApplicationPaths _appPaths; private readonly ILogger _logger; - private readonly object _syncLock = new object(); + private string _id; + + public DeviceId(IApplicationPaths appPaths, ILoggerFactory loggerFactory) + { + _appPaths = appPaths; + _logger = loggerFactory.CreateLogger(); + } + + public string Value => _id ?? (_id = GetDeviceId()); + private string CachePath => Path.Combine(_appPaths.DataPath, "device.txt"); private string GetCachedId() @@ -84,15 +95,5 @@ namespace Emby.Server.Implementations.Devices return id; } - - private string _id; - - public DeviceId(IApplicationPaths appPaths, ILoggerFactory loggerFactory) - { - _appPaths = appPaths; - _logger = loggerFactory.CreateLogger(); - } - - public string Value => _id ?? (_id = GetDeviceId()); } } diff --git a/Emby.Server.Implementations/Devices/DeviceManager.cs b/Emby.Server.Implementations/Devices/DeviceManager.cs deleted file mode 100644 index da5047d244..0000000000 --- a/Emby.Server.Implementations/Devices/DeviceManager.cs +++ /dev/null @@ -1,144 +0,0 @@ -#pragma warning disable CS1591 - -using System; -using System.Collections.Concurrent; -using System.Collections.Generic; -using System.Linq; -using Jellyfin.Data.Entities; -using Jellyfin.Data.Enums; -using Jellyfin.Data.Events; -using MediaBrowser.Controller.Devices; -using MediaBrowser.Controller.Library; -using MediaBrowser.Controller.Security; -using MediaBrowser.Model.Devices; -using MediaBrowser.Model.Querying; -using MediaBrowser.Model.Session; - -namespace Emby.Server.Implementations.Devices -{ - public class DeviceManager : IDeviceManager - { - private readonly IUserManager _userManager; - private readonly IAuthenticationRepository _authRepo; - private readonly ConcurrentDictionary _capabilitiesMap = new (); - - public DeviceManager(IAuthenticationRepository authRepo, IUserManager userManager) - { - _userManager = userManager; - _authRepo = authRepo; - } - - public event EventHandler>> DeviceOptionsUpdated; - - public void SaveCapabilities(string deviceId, ClientCapabilities capabilities) - { - _capabilitiesMap[deviceId] = capabilities; - } - - public void UpdateDeviceOptions(string deviceId, DeviceOptions options) - { - _authRepo.UpdateDeviceOptions(deviceId, options); - - DeviceOptionsUpdated?.Invoke(this, new GenericEventArgs>(new Tuple(deviceId, options))); - } - - public DeviceOptions GetDeviceOptions(string deviceId) - { - return _authRepo.GetDeviceOptions(deviceId); - } - - public ClientCapabilities GetCapabilities(string id) - { - return _capabilitiesMap.TryGetValue(id, out ClientCapabilities result) - ? result - : new ClientCapabilities(); - } - - public DeviceInfo GetDevice(string id) - { - var session = _authRepo.Get(new AuthenticationInfoQuery - { - DeviceId = id - }).Items.FirstOrDefault(); - - var device = session == null ? null : ToDeviceInfo(session); - - return device; - } - - public QueryResult GetDevices(DeviceQuery query) - { - IEnumerable sessions = _authRepo.Get(new AuthenticationInfoQuery - { - // UserId = query.UserId - HasUser = true - }).Items; - - // TODO: DeviceQuery doesn't seem to be used from client. Not even Swagger. - if (query.SupportsSync.HasValue) - { - var val = query.SupportsSync.Value; - - sessions = sessions.Where(i => GetCapabilities(i.DeviceId).SupportsSync == val); - } - - if (!query.UserId.Equals(Guid.Empty)) - { - var user = _userManager.GetUserById(query.UserId); - - sessions = sessions.Where(i => CanAccessDevice(user, i.DeviceId)); - } - - var array = sessions.Select(ToDeviceInfo).ToArray(); - - return new QueryResult(array); - } - - private DeviceInfo ToDeviceInfo(AuthenticationInfo authInfo) - { - var caps = GetCapabilities(authInfo.DeviceId); - - return new DeviceInfo - { - AppName = authInfo.AppName, - AppVersion = authInfo.AppVersion, - Id = authInfo.DeviceId, - LastUserId = authInfo.UserId, - LastUserName = authInfo.UserName, - Name = authInfo.DeviceName, - DateLastActivity = authInfo.DateLastActivity, - IconUrl = caps?.IconUrl - }; - } - - public bool CanAccessDevice(User user, string deviceId) - { - if (user == null) - { - throw new ArgumentException("user not found"); - } - - if (string.IsNullOrEmpty(deviceId)) - { - throw new ArgumentNullException(nameof(deviceId)); - } - - if (user.HasPermission(PermissionKind.EnableAllDevices) || user.HasPermission(PermissionKind.IsAdministrator)) - { - return true; - } - - if (!user.GetPreference(PreferenceKind.EnabledDevices).Contains(deviceId, StringComparer.OrdinalIgnoreCase)) - { - var capabilities = GetCapabilities(deviceId); - - if (capabilities != null && capabilities.SupportsPersistentIdentifier) - { - return false; - } - } - - return true; - } - } -} diff --git a/Emby.Server.Implementations/Dto/DtoService.cs b/Emby.Server.Implementations/Dto/DtoService.cs index 54b18a8c80..b91ff64087 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; @@ -49,8 +51,6 @@ namespace Emby.Server.Implementations.Dto private readonly IMediaSourceManager _mediaSourceManager; private readonly Lazy _livetvManagerFactory; - private ILiveTvManager LivetvManager => _livetvManagerFactory.Value; - public DtoService( ILogger logger, ILibraryManager libraryManager, @@ -73,6 +73,8 @@ namespace Emby.Server.Implementations.Dto _livetvManagerFactory = livetvManagerFactory; } + private ILiveTvManager LivetvManager => _livetvManagerFactory.Value; + /// public IReadOnlyList GetBaseItemDtos(IReadOnlyList items, DtoOptions options, User user = null, BaseItem owner = null) { @@ -132,14 +134,11 @@ namespace Emby.Server.Implementations.Dto var dto = GetBaseItemDtoInternal(item, options, user, owner); if (item is LiveTvChannel tvChannel) { - var list = new List<(BaseItemDto, LiveTvChannel)>(1) { (dto, tvChannel) }; - LivetvManager.AddChannelInfo(list, options, user); + LivetvManager.AddChannelInfo(new[] { (dto, tvChannel) }, options, user); } else if (item is LiveTvProgram) { - var list = new List<(BaseItem, BaseItemDto)>(1) { (item, dto) }; - var task = LivetvManager.AddInfoToProgramDto(list, options.Fields, user); - Task.WaitAll(task); + LivetvManager.AddInfoToProgramDto(new[] { (item, dto) }, options.Fields, user).GetAwaiter().GetResult(); } if (item is IItemByName itemByName @@ -371,6 +370,12 @@ namespace Emby.Server.Implementations.Dto if (item is MusicAlbum || item is Season || item is Playlist) { dto.ChildCount = dto.RecursiveItemCount; + var folderChildCount = folder.LinkedChildren.Length; + // The default is an empty array, so we can't reliably use the count when it's empty + if (folderChildCount > 0) + { + dto.ChildCount ??= folderChildCount; + } } if (options.ContainsField(ItemFields.ChildCount)) @@ -418,7 +423,7 @@ namespace Emby.Server.Implementations.Dto // Just return something so that apps that are expecting a value won't think the folders are empty if (folder is ICollectionFolder || folder is UserView) { - return new Random().Next(1, 10); + return Random.Shared.Next(1, 10); } return folder.GetChildCount(user); @@ -495,7 +500,7 @@ namespace Emby.Server.Implementations.Dto } catch (Exception ex) { - _logger.LogError(ex, "Error getting {imageType} image info for {path}", image.Type, image.Path); + _logger.LogError(ex, "Error getting {ImageType} image info for {Path}", image.Type, image.Path); return null; } } @@ -505,7 +510,6 @@ namespace Emby.Server.Implementations.Dto /// /// The dto. /// The item. - /// Task. private void AttachPeople(BaseItemDto dto, BaseItem item) { // Ordering by person type to ensure actors and artists are at the front. @@ -614,7 +618,6 @@ namespace Emby.Server.Implementations.Dto /// /// The dto. /// The item. - /// Task. private void AttachStudios(BaseItemDto dto, BaseItem item) { dto.Studios = item.Studios @@ -665,10 +668,7 @@ namespace Emby.Server.Implementations.Dto var tag = GetImageCacheTag(item, image); if (!string.IsNullOrEmpty(image.BlurHash)) { - if (dto.ImageBlurHashes == null) - { - dto.ImageBlurHashes = new Dictionary>(); - } + dto.ImageBlurHashes ??= new Dictionary>(); if (!dto.ImageBlurHashes.ContainsKey(image.Type)) { @@ -702,10 +702,7 @@ namespace Emby.Server.Implementations.Dto if (hashes.Count > 0) { - if (dto.ImageBlurHashes == null) - { - dto.ImageBlurHashes = new Dictionary>(); - } + dto.ImageBlurHashes ??= new Dictionary>(); dto.ImageBlurHashes[imageType] = hashes; } @@ -761,15 +758,6 @@ namespace Emby.Server.Implementations.Dto dto.BackdropImageTags = GetTagsAndFillBlurhashes(dto, item, ImageType.Backdrop, backdropLimit); } - if (options.ContainsField(ItemFields.ScreenshotImageTags)) - { - var screenshotLimit = options.GetImageLimit(ImageType.Screenshot); - if (screenshotLimit > 0) - { - dto.ScreenshotImageTags = GetTagsAndFillBlurhashes(dto, item, ImageType.Screenshot, screenshotLimit); - } - } - if (options.ContainsField(ItemFields.Genres)) { dto.Genres = item.Genres; @@ -811,7 +799,7 @@ namespace Emby.Server.Implementations.Dto dto.MediaType = item.MediaType; - if (!(item is LiveTvProgram)) + if (item is not LiveTvProgram) { dto.LocationType = item.LocationType; } @@ -898,10 +886,7 @@ namespace Emby.Server.Implementations.Dto dto.Taglines = new string[] { item.Tagline }; } - if (dto.Taglines == null) - { - dto.Taglines = Array.Empty(); - } + dto.Taglines ??= Array.Empty(); } dto.Type = item.GetBaseItemKind(); @@ -935,9 +920,9 @@ namespace Emby.Server.Implementations.Dto } // if (options.ContainsField(ItemFields.MediaSourceCount)) - //{ + // { // Songs always have one - //} + // } } if (item is IHasArtist hasArtist) @@ -945,10 +930,10 @@ namespace Emby.Server.Implementations.Dto dto.Artists = hasArtist.Artists; // var artistItems = _libraryManager.GetArtists(new InternalItemsQuery - //{ + // { // EnableTotalRecordCount = false, // ItemIds = new[] { item.Id.ToString("N", CultureInfo.InvariantCulture) } - //}); + // }); // dto.ArtistItems = artistItems.Items // .Select(i => @@ -965,7 +950,7 @@ namespace Emby.Server.Implementations.Dto // Include artists that are not in the database yet, e.g., just added via metadata editor // var foundArtists = artistItems.Items.Select(i => i.Item1.Name).ToList(); dto.ArtistItems = hasArtist.Artists - //.Except(foundArtists, new DistinctNameComparer()) + // .Except(foundArtists, new DistinctNameComparer()) .Select(i => { // This should not be necessary but we're seeing some cases of it @@ -997,10 +982,10 @@ namespace Emby.Server.Implementations.Dto dto.AlbumArtist = hasAlbumArtist.AlbumArtists.FirstOrDefault(); // var artistItems = _libraryManager.GetAlbumArtists(new InternalItemsQuery - //{ + // { // EnableTotalRecordCount = false, // ItemIds = new[] { item.Id.ToString("N", CultureInfo.InvariantCulture) } - //}); + // }); // dto.AlbumArtists = artistItems.Items // .Select(i => @@ -1015,7 +1000,7 @@ namespace Emby.Server.Implementations.Dto // .ToList(); dto.AlbumArtists = hasAlbumArtist.AlbumArtists - //.Except(foundArtists, new DistinctNameComparer()) + // .Except(foundArtists, new DistinctNameComparer()) .Select(i => { // This should not be necessary but we're seeing some cases of it @@ -1042,8 +1027,7 @@ namespace Emby.Server.Implementations.Dto } // Add video info - var video = item as Video; - if (video != null) + if (item is Video video) { dto.VideoType = video.VideoType; dto.Video3DFormat = video.Video3DFormat; @@ -1082,9 +1066,7 @@ namespace Emby.Server.Implementations.Dto if (options.ContainsField(ItemFields.MediaStreams)) { // Add VideoInfo - var iHasMediaSources = item as IHasMediaSources; - - if (iHasMediaSources != null) + if (item is IHasMediaSources) { MediaStream[] mediaStreams; @@ -1153,7 +1135,7 @@ namespace Emby.Server.Implementations.Dto // TODO maybe remove the if statement entirely // if (options.ContainsField(ItemFields.SeriesPrimaryImage)) { - episodeSeries = episodeSeries ?? episode.Series; + episodeSeries ??= episode.Series; if (episodeSeries != null) { dto.SeriesPrimaryImageTag = GetTagAndFillBlurhash(dto, episodeSeries, ImageType.Primary); @@ -1166,7 +1148,7 @@ namespace Emby.Server.Implementations.Dto if (options.ContainsField(ItemFields.SeriesStudio)) { - episodeSeries = episodeSeries ?? episode.Series; + episodeSeries ??= episode.Series; if (episodeSeries != null) { dto.SeriesStudio = episodeSeries.Studios.FirstOrDefault(); @@ -1179,7 +1161,7 @@ namespace Emby.Server.Implementations.Dto { dto.AirDays = series.AirDays; dto.AirTime = series.AirTime; - dto.Status = series.Status.HasValue ? series.Status.Value.ToString() : null; + dto.Status = series.Status?.ToString(); } // Add SeasonInfo @@ -1192,7 +1174,7 @@ namespace Emby.Server.Implementations.Dto if (options.ContainsField(ItemFields.SeriesStudio)) { - series = series ?? season.Series; + series ??= season.Series; if (series != null) { dto.SeriesStudio = series.Studios.FirstOrDefault(); @@ -1203,7 +1185,7 @@ namespace Emby.Server.Implementations.Dto // TODO maybe remove the if statement entirely // if (options.ContainsField(ItemFields.SeriesPrimaryImage)) { - series = series ?? season.Series; + series ??= season.Series; if (series != null) { dto.SeriesPrimaryImageTag = GetTagAndFillBlurhash(dto, series, ImageType.Primary); @@ -1290,7 +1272,7 @@ namespace Emby.Server.Implementations.Dto var parent = currentItem.DisplayParent ?? currentItem.GetOwner() ?? currentItem.GetParent(); - if (parent == null && !(originalItem is UserRootFolder) && !(originalItem is UserView) && !(originalItem is AggregateFolder) && !(originalItem is ICollectionFolder) && !(originalItem is Channel)) + if (parent == null && originalItem is not UserRootFolder && originalItem is not UserView && originalItem is not AggregateFolder && originalItem is not ICollectionFolder && originalItem is not Channel) { parent = _libraryManager.GetCollectionFolders(originalItem).FirstOrDefault(); } @@ -1323,9 +1305,12 @@ namespace Emby.Server.Implementations.Dto var imageTags = dto.ImageTags; - while (((!(imageTags != null && imageTags.ContainsKey(ImageType.Logo)) && logoLimit > 0) || (!(imageTags != null && imageTags.ContainsKey(ImageType.Art)) && artLimit > 0) || (!(imageTags != null && imageTags.ContainsKey(ImageType.Thumb)) && thumbLimit > 0) || parent is Series) && - (parent = parent ?? (isFirst ? GetImageDisplayParent(item, item) ?? owner : parent)) != null) + while ((!(imageTags != null && imageTags.ContainsKey(ImageType.Logo)) && logoLimit > 0) + || (!(imageTags != null && imageTags.ContainsKey(ImageType.Art)) && artLimit > 0) + || (!(imageTags != null && imageTags.ContainsKey(ImageType.Thumb)) && thumbLimit > 0) + || parent is Series) { + parent ??= isFirst ? GetImageDisplayParent(item, item) ?? owner : parent; if (parent == null) { break; @@ -1355,7 +1340,7 @@ namespace Emby.Server.Implementations.Dto } } - if (thumbLimit > 0 && !(imageTags != null && imageTags.ContainsKey(ImageType.Thumb)) && (dto.ParentThumbItemId == null || parent is Series) && !(parent is ICollectionFolder) && !(parent is UserView)) + if (thumbLimit > 0 && !(imageTags != null && imageTags.ContainsKey(ImageType.Thumb)) && (dto.ParentThumbItemId == null || parent is Series) && parent is not ICollectionFolder && parent is not UserView) { var image = allImages.FirstOrDefault(i => i.Type == ImageType.Thumb); @@ -1405,7 +1390,6 @@ namespace Emby.Server.Implementations.Dto /// /// The dto. /// The item. - /// Task. public void AttachPrimaryImageAspectRatio(IItemDto dto, BaseItem item) { dto.PrimaryImageAspectRatio = GetPrimaryImageAspectRatio(item); diff --git a/Emby.Server.Implementations/Emby.Server.Implementations.csproj b/Emby.Server.Implementations/Emby.Server.Implementations.csproj index 9248053f54..95acd216d9 100644 --- a/Emby.Server.Implementations/Emby.Server.Implementations.csproj +++ b/Emby.Server.Implementations/Emby.Server.Implementations.csproj @@ -9,6 +9,7 @@ + @@ -22,17 +23,18 @@ + - - - - - - - - - - + + + + + + + + + + @@ -40,14 +42,11 @@ - net5.0 + net6.0 false true - true AD0001 - AllEnabledByDefault - ../jellyfin.ruleset diff --git a/Emby.Server.Implementations/EntryPoints/ExternalPortForwarding.cs b/Emby.Server.Implementations/EntryPoints/ExternalPortForwarding.cs index 14201ead29..d325fa14fc 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; @@ -7,7 +9,6 @@ using System.Net; 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; @@ -26,7 +27,6 @@ namespace Emby.Server.Implementations.EntryPoints private readonly IServerApplicationHost _appHost; private readonly ILogger _logger; private readonly IServerConfigurationManager _config; - private readonly IDeviceDiscovery _deviceDiscovery; private readonly ConcurrentDictionary _createdRules = new ConcurrentDictionary(); @@ -41,17 +41,14 @@ namespace Emby.Server.Implementations.EntryPoints /// The logger. /// The application host. /// The configuration manager. - /// The device discovery. public ExternalPortForwarding( ILogger logger, IServerApplicationHost appHost, - IServerConfigurationManager config, - IDeviceDiscovery deviceDiscovery) + IServerConfigurationManager config) { _logger = logger; _appHost = appHost; _config = config; - _deviceDiscovery = deviceDiscovery; } private string GetConfigIdentifier() @@ -106,8 +103,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() @@ -118,13 +113,6 @@ namespace Emby.Server.Implementations.EntryPoints NatUtility.DeviceFound -= OnNatUtilityDeviceFound; _timer?.Dispose(); - - _deviceDiscovery.DeviceDiscovered -= OnDeviceDiscoveryDeviceDiscovered; - } - - private void OnDeviceDiscoveryDeviceDiscovered(object sender, GenericEventArgs e) - { - NatUtility.Search(e.Argument.LocalIpAddress, NatProtocol.Upnp); } private async void OnNatUtilityDeviceFound(object sender, DeviceEventArgs e) diff --git a/Emby.Server.Implementations/EntryPoints/LibraryChangedNotifier.cs b/Emby.Server.Implementations/EntryPoints/LibraryChangedNotifier.cs index ae1b51b4c3..331de45c1e 100644 --- a/Emby.Server.Implementations/EntryPoints/LibraryChangedNotifier.cs +++ b/Emby.Server.Implementations/EntryPoints/LibraryChangedNotifier.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System; @@ -147,7 +149,7 @@ namespace Emby.Server.Implementations.EntryPoints private static bool EnableRefreshMessage(BaseItem item) { - if (!(item is Folder folder)) + if (item is not Folder folder) { return false; } @@ -401,7 +403,7 @@ namespace Emby.Server.Implementations.EntryPoints return false; } - if (item is IItemByName && !(item is MusicArtist)) + if (item is IItemByName && item is not MusicArtist) { return false; } @@ -434,7 +436,7 @@ namespace Emby.Server.Implementations.EntryPoints /// /// Translates the physical item to user library. /// - /// + /// The type of item. /// The item. /// The user. /// if set to true [include if not found]. 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 3624e079f5..feaccf9fa1 100644 --- a/Emby.Server.Implementations/EntryPoints/UdpServerEntryPoint.cs +++ b/Emby.Server.Implementations/EntryPoints/UdpServerEntryPoint.cs @@ -1,5 +1,3 @@ -#nullable enable - using System; using System.Net.Sockets; using System.Threading; @@ -39,6 +37,9 @@ namespace Emby.Server.Implementations.EntryPoints /// /// Initializes a new instance of the class. /// + /// Instance of the interface. + /// Instance of the interface. + /// Instance of the interface. public UdpServerEntryPoint( ILogger logger, IServerApplicationHost appHost, @@ -56,8 +57,8 @@ namespace Emby.Server.Implementations.EntryPoints 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) { 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> _changedItems = new Dictionary>(); 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 keys)) + if (!_changedItems.TryGetValue(e.UserId, out List? keys)) { keys = new List(); _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 9afabf5272..e7103ec958 100644 --- a/Emby.Server.Implementations/HttpServer/Security/AuthService.cs +++ b/Emby.Server.Implementations/HttpServer/Security/AuthService.cs @@ -1,5 +1,6 @@ #pragma warning disable CS1591 +using System.Threading.Tasks; using Jellyfin.Data.Enums; using MediaBrowser.Controller.Authentication; using MediaBrowser.Controller.Net; @@ -17,13 +18,13 @@ namespace Emby.Server.Implementations.HttpServer.Security _authorizationContext = authorizationContext; } - public AuthorizationInfo Authenticate(HttpRequest request) + public async Task Authenticate(HttpRequest request) { - var auth = _authorizationContext.GetAuthorizationInfo(request); + var auth = await _authorizationContext.GetAuthorizationInfo(request).ConfigureAwait(false); if (!auth.HasToken) { - throw new AuthenticationException("Request does not contain a token."); + return auth; } if (!auth.IsAuthenticated) diff --git a/Emby.Server.Implementations/HttpServer/Security/AuthorizationContext.cs b/Emby.Server.Implementations/HttpServer/Security/AuthorizationContext.cs deleted file mode 100644 index 024404ceb0..0000000000 --- a/Emby.Server.Implementations/HttpServer/Security/AuthorizationContext.cs +++ /dev/null @@ -1,291 +0,0 @@ -#pragma warning disable CS1591 - -using System; -using System.Collections.Generic; -using System.Linq; -using System.Net; -using MediaBrowser.Controller.Library; -using MediaBrowser.Controller.Net; -using MediaBrowser.Controller.Security; -using Microsoft.AspNetCore.Http; -using Microsoft.Net.Http.Headers; - -namespace Emby.Server.Implementations.HttpServer.Security -{ - public class AuthorizationContext : IAuthorizationContext - { - private readonly IAuthenticationRepository _authRepo; - private readonly IUserManager _userManager; - - public AuthorizationContext(IAuthenticationRepository authRepo, IUserManager userManager) - { - _authRepo = authRepo; - _userManager = userManager; - } - - public AuthorizationInfo GetAuthorizationInfo(HttpContext requestContext) - { - if (requestContext.Request.HttpContext.Items.TryGetValue("AuthorizationInfo", out var cached)) - { - return (AuthorizationInfo)cached; - } - - return GetAuthorization(requestContext); - } - - public AuthorizationInfo GetAuthorizationInfo(HttpRequest requestContext) - { - var auth = GetAuthorizationDictionary(requestContext); - var authInfo = GetAuthorizationInfoFromDictionary(auth, requestContext.Headers, requestContext.Query); - return authInfo; - } - - /// - /// Gets the authorization. - /// - /// The HTTP req. - /// Dictionary{System.StringSystem.String}. - private AuthorizationInfo GetAuthorization(HttpContext httpReq) - { - var auth = GetAuthorizationDictionary(httpReq); - var authInfo = GetAuthorizationInfoFromDictionary(auth, httpReq.Request.Headers, httpReq.Request.Query); - - httpReq.Request.HttpContext.Items["AuthorizationInfo"] = authInfo; - return authInfo; - } - - private AuthorizationInfo GetAuthorizationInfoFromDictionary( - in Dictionary auth, - in IHeaderDictionary headers, - in IQueryCollection queryString) - { - string deviceId = null; - string device = null; - string client = null; - string version = null; - string token = null; - - if (auth != null) - { - auth.TryGetValue("DeviceId", out deviceId); - auth.TryGetValue("Device", out device); - auth.TryGetValue("Client", out client); - auth.TryGetValue("Version", out version); - auth.TryGetValue("Token", out token); - } - - if (string.IsNullOrEmpty(token)) - { - token = headers["X-Emby-Token"]; - } - - if (string.IsNullOrEmpty(token)) - { - token = headers["X-MediaBrowser-Token"]; - } - - if (string.IsNullOrEmpty(token)) - { - token = queryString["ApiKey"]; - } - - // TODO deprecate this query parameter. - if (string.IsNullOrEmpty(token)) - { - token = queryString["api_key"]; - } - - var authInfo = new AuthorizationInfo - { - Client = client, - Device = device, - DeviceId = deviceId, - Version = version, - Token = token, - IsAuthenticated = false, - HasToken = false - }; - - if (string.IsNullOrWhiteSpace(token)) - { - // Request doesn't contain a token. - return authInfo; - } - - authInfo.HasToken = true; - var result = _authRepo.Get(new AuthenticationInfoQuery - { - AccessToken = token - }); - - if (result.Items.Count > 0) - { - authInfo.IsAuthenticated = true; - } - - var originalAuthenticationInfo = result.Items.Count > 0 ? result.Items[0] : null; - - if (originalAuthenticationInfo != null) - { - var updateToken = false; - - // TODO: Remove these checks for IsNullOrWhiteSpace - if (string.IsNullOrWhiteSpace(authInfo.Client)) - { - authInfo.Client = originalAuthenticationInfo.AppName; - } - - if (string.IsNullOrWhiteSpace(authInfo.DeviceId)) - { - authInfo.DeviceId = originalAuthenticationInfo.DeviceId; - } - - // 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; - - 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.Version)) - { - authInfo.Version = originalAuthenticationInfo.AppVersion; - } - else if (!string.Equals(authInfo.Version, originalAuthenticationInfo.AppVersion, StringComparison.OrdinalIgnoreCase)) - { - if (allowTokenInfoUpdate) - { - updateToken = true; - originalAuthenticationInfo.AppVersion = authInfo.Version; - } - } - - if ((DateTime.UtcNow - originalAuthenticationInfo.DateLastActivity).TotalMinutes > 3) - { - originalAuthenticationInfo.DateLastActivity = DateTime.UtcNow; - updateToken = true; - } - - if (!originalAuthenticationInfo.UserId.Equals(Guid.Empty)) - { - authInfo.User = _userManager.GetUserById(originalAuthenticationInfo.UserId); - - if (authInfo.User != null && !string.Equals(authInfo.User.Username, originalAuthenticationInfo.UserName, StringComparison.OrdinalIgnoreCase)) - { - originalAuthenticationInfo.UserName = authInfo.User.Username; - updateToken = true; - } - - authInfo.IsApiKey = false; - } - else - { - authInfo.IsApiKey = true; - } - - if (updateToken) - { - _authRepo.Update(originalAuthenticationInfo); - } - } - - return authInfo; - } - - /// - /// Gets the auth. - /// - /// The HTTP req. - /// Dictionary{System.StringSystem.String}. - private Dictionary GetAuthorizationDictionary(HttpContext httpReq) - { - var auth = httpReq.Request.Headers["X-Emby-Authorization"]; - - if (string.IsNullOrEmpty(auth)) - { - auth = httpReq.Request.Headers[HeaderNames.Authorization]; - } - - return GetAuthorization(auth); - } - - /// - /// Gets the auth. - /// - /// The HTTP req. - /// Dictionary{System.StringSystem.String}. - private Dictionary GetAuthorizationDictionary(HttpRequest httpReq) - { - var auth = httpReq.Headers["X-Emby-Authorization"]; - - if (string.IsNullOrEmpty(auth)) - { - auth = httpReq.Headers[HeaderNames.Authorization]; - } - - return GetAuthorization(auth); - } - - /// - /// Gets the authorization. - /// - /// The authorization header. - /// Dictionary{System.StringSystem.String}. - private Dictionary GetAuthorization(string authorizationHeader) - { - if (authorizationHeader == null) - { - return null; - } - - var parts = authorizationHeader.Split(' ', 2); - - // There should be at least to parts - if (parts.Length != 2) - { - return null; - } - - var acceptedNames = new[] { "MediaBrowser", "Emby" }; - - // It has to be a digest request - if (!acceptedNames.Contains(parts[0], StringComparer.OrdinalIgnoreCase)) - { - return null; - } - - // Remove uptil the first space - authorizationHeader = parts[1]; - parts = authorizationHeader.Split(','); - - var result = new Dictionary(StringComparer.OrdinalIgnoreCase); - - foreach (var item in parts) - { - var param = item.Trim().Split('=', 2); - - if (param.Length == 2) - { - var value = NormalizeValue(param[1].Trim('"')); - result[param[0]] = value; - } - } - - return result; - } - - private static string NormalizeValue(string value) - { - return string.IsNullOrEmpty(value) ? value : WebUtility.HtmlEncode(value); - } - } -} diff --git a/Emby.Server.Implementations/HttpServer/Security/SessionContext.cs b/Emby.Server.Implementations/HttpServer/Security/SessionContext.cs index dd77b45d89..a7647caf95 100644 --- a/Emby.Server.Implementations/HttpServer/Security/SessionContext.cs +++ b/Emby.Server.Implementations/HttpServer/Security/SessionContext.cs @@ -1,6 +1,7 @@ #pragma warning disable CS1591 using System; +using System.Threading.Tasks; using Jellyfin.Data.Entities; using MediaBrowser.Common.Extensions; using MediaBrowser.Controller.Library; @@ -23,27 +24,33 @@ namespace Emby.Server.Implementations.HttpServer.Security _sessionManager = sessionManager; } - public SessionInfo GetSession(HttpContext requestContext) + public async Task GetSession(HttpContext requestContext) { - var authorization = _authContext.GetAuthorizationInfo(requestContext); + var authorization = await _authContext.GetAuthorizationInfo(requestContext).ConfigureAwait(false); var user = authorization.User; - return _sessionManager.LogSessionActivity(authorization.Client, authorization.Version, authorization.DeviceId, authorization.Device, requestContext.GetNormalizedRemoteIp().ToString(), user); + return await _sessionManager.LogSessionActivity( + authorization.Client, + authorization.Version, + authorization.DeviceId, + authorization.Device, + requestContext.GetNormalizedRemoteIp().ToString(), + user).ConfigureAwait(false); } - public SessionInfo GetSession(object requestContext) + public Task GetSession(object requestContext) { return GetSession((HttpContext)requestContext); } - public User GetUser(HttpContext requestContext) + public async Task GetUser(HttpContext requestContext) { - var session = GetSession(requestContext); + var session = await GetSession(requestContext).ConfigureAwait(false); return session == null || session.UserId.Equals(Guid.Empty) ? null : _userManager.GetUserById(session.UserId); } - public User GetUser(object requestContext) + public Task GetUser(object requestContext) { return GetUser(((HttpRequest)requestContext).HttpContext); } diff --git a/Emby.Server.Implementations/HttpServer/WebSocketConnection.cs b/Emby.Server.Implementations/HttpServer/WebSocketConnection.cs index 06acb56061..5f25f69801 100644 --- a/Emby.Server.Implementations/HttpServer/WebSocketConnection.cs +++ b/Emby.Server.Implementations/HttpServer/WebSocketConnection.cs @@ -1,5 +1,3 @@ -#nullable enable - using System; using System.Buffers; using System.IO.Pipelines; @@ -9,7 +7,7 @@ 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; @@ -64,7 +62,7 @@ namespace Emby.Server.Implementations.HttpServer public event EventHandler? Closed; /// - /// Gets or sets the remote end point. + /// Gets the remote end point. /// public IPAddress? RemoteEndPoint { get; } @@ -84,7 +82,7 @@ namespace Emby.Server.Implementations.HttpServer public DateTime LastKeepAliveDate { get; set; } /// - /// Gets or sets the query string. + /// Gets the query string. /// /// The query string. public IQueryCollection QueryString { get; } @@ -98,7 +96,7 @@ namespace Emby.Server.Implementations.HttpServer /// /// Sends a message asynchronously. /// - /// + /// The type of the message. /// The message. /// The cancellation token. /// Task. @@ -152,8 +150,8 @@ namespace Emby.Server.Implementations.HttpServer { await ProcessInternal(pipe.Reader).ConfigureAwait(false); } - } while ( - (_socket.State == WebSocketState.Open || _socket.State == WebSocketState.Connecting) + } + while ((_socket.State == WebSocketState.Open || _socket.State == WebSocketState.Connecting) && receiveresult.MessageType != WebSocketMessageType.Close); Closed?.Invoke(this, EventArgs.Empty); diff --git a/Emby.Server.Implementations/HttpServer/WebSocketManager.cs b/Emby.Server.Implementations/HttpServer/WebSocketManager.cs index 1bee1ac310..e99876dce3 100644 --- a/Emby.Server.Implementations/HttpServer/WebSocketManager.cs +++ b/Emby.Server.Implementations/HttpServer/WebSocketManager.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System; @@ -33,7 +35,12 @@ namespace Emby.Server.Implementations.HttpServer /// public async Task WebSocketRequestHandler(HttpContext context) { - _ = _authService.Authenticate(context.Request); + var authorizationInfo = await _authService.Authenticate(context.Request).ConfigureAwait(false); + if (!authorizationInfo.IsAuthenticated) + { + throw new SecurityException("Token is required"); + } + try { _logger.LogInformation("WS {IP} request", context.Connection.RemoteIpAddress); diff --git a/Emby.Server.Implementations/IO/FileRefresher.cs b/Emby.Server.Implementations/IO/FileRefresher.cs index 7435e9d0bf..e62361c1e1 100644 --- a/Emby.Server.Implementations/IO/FileRefresher.cs +++ b/Emby.Server.Implementations/IO/FileRefresher.cs @@ -12,7 +12,7 @@ using Microsoft.Extensions.Logging; namespace Emby.Server.Implementations.IO { - public class FileRefresher : IDisposable + public sealed class FileRefresher : IDisposable { private readonly ILogger _logger; private readonly ILibraryManager _libraryManager; @@ -20,7 +20,7 @@ namespace Emby.Server.Implementations.IO private readonly List _affectedPaths = new List(); private readonly object _timerLock = new object(); - private Timer _timer; + private Timer? _timer; private bool _disposed; public FileRefresher(string path, IServerConfigurationManager configurationManager, ILibraryManager libraryManager, ILogger logger) @@ -34,7 +34,7 @@ namespace Emby.Server.Implementations.IO AddPath(path); } - public event EventHandler Completed; + public event EventHandler? Completed; public string Path { get; private set; } @@ -109,7 +109,7 @@ namespace Emby.Server.Implementations.IO RestartTimer(); } - private void OnTimerCallback(object state) + private void OnTimerCallback(object? state) { List paths; @@ -125,7 +125,7 @@ namespace Emby.Server.Implementations.IO try { - ProcessPathChanges(paths.ToList()); + ProcessPathChanges(paths); } catch (Exception ex) { @@ -135,12 +135,12 @@ namespace Emby.Server.Implementations.IO private void ProcessPathChanges(List paths) { - var itemsToRefresh = paths + IEnumerable itemsToRefresh = paths .Distinct(StringComparer.OrdinalIgnoreCase) .Select(GetAffectedBaseItem) .Where(item => item != null) - .GroupBy(x => x.Id) - .Select(x => x.First()); + .GroupBy(x => x!.Id) // Removed null values in the previous .Where() + .Select(x => x.First())!; foreach (var item in itemsToRefresh) { @@ -174,15 +174,15 @@ namespace Emby.Server.Implementations.IO /// /// The path. /// BaseItem. - private BaseItem GetAffectedBaseItem(string path) + private BaseItem? GetAffectedBaseItem(string path) { - BaseItem item = null; + BaseItem? item = null; while (item == null && !string.IsNullOrEmpty(path)) { item = _libraryManager.FindByPath(path, null); - path = System.IO.Path.GetDirectoryName(path); + path = System.IO.Path.GetDirectoryName(path) ?? string.Empty; } if (item != null) diff --git a/Emby.Server.Implementations/IO/LibraryMonitor.cs b/Emby.Server.Implementations/IO/LibraryMonitor.cs index 3353fae9d8..9fcc7fe59c 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; @@ -39,6 +41,25 @@ namespace Emby.Server.Implementations.IO private bool _disposed = false; + /// + /// Initializes a new instance of the class. + /// + /// The logger. + /// The library manager. + /// The configuration manager. + /// The filesystem. + public LibraryMonitor( + ILogger logger, + ILibraryManager libraryManager, + IServerConfigurationManager configurationManager, + IFileSystem fileSystem) + { + _libraryManager = libraryManager; + _logger = logger; + _configurationManager = configurationManager; + _fileSystem = fileSystem; + } + /// /// Add the path to our temporary ignore list. Use when writing to a path within our listening scope. /// @@ -93,21 +114,6 @@ namespace Emby.Server.Implementations.IO } } - /// - /// Initializes a new instance of the class. - /// - public LibraryMonitor( - ILogger logger, - ILibraryManager libraryManager, - IServerConfigurationManager configurationManager, - IFileSystem fileSystem) - { - _libraryManager = libraryManager; - _logger = logger; - _configurationManager = configurationManager; - _fileSystem = fileSystem; - } - private bool IsLibraryMonitorEnabled(BaseItem item) { if (item is BasePluginFolder) @@ -197,7 +203,7 @@ namespace Emby.Server.Implementations.IO /// The LST. /// The path. /// true if [contains parent folder] [the specified LST]; otherwise, false. - /// path + /// is null. private static bool ContainsParentFolder(IEnumerable lst, string path) { if (string.IsNullOrEmpty(path)) @@ -261,7 +267,7 @@ namespace Emby.Server.Implementations.IO if (_fileSystemWatchers.TryAdd(path, newWatcher)) { newWatcher.EnableRaisingEvents = true; - _logger.LogInformation("Watching directory " + path); + _logger.LogInformation("Watching directory {Path}", path); } else { @@ -270,7 +276,7 @@ namespace Emby.Server.Implementations.IO } catch (Exception ex) { - _logger.LogError(ex, "Error watching path: {path}", path); + _logger.LogError(ex, "Error watching path: {Path}", path); } }); } @@ -443,12 +449,12 @@ namespace Emby.Server.Implementations.IO } var newRefresher = new FileRefresher(path, _configurationManager, _libraryManager, _logger); - newRefresher.Completed += NewRefresher_Completed; + newRefresher.Completed += OnNewRefresherCompleted; _activeRefreshers.Add(newRefresher); } } - private void NewRefresher_Completed(object sender, EventArgs e) + private void OnNewRefresherCompleted(object sender, EventArgs e) { var refresher = (FileRefresher)sender; DisposeRefresher(refresher); @@ -475,6 +481,7 @@ namespace Emby.Server.Implementations.IO { lock (_activeRefreshers) { + refresher.Completed -= OnNewRefresherCompleted; refresher.Dispose(); _activeRefreshers.Remove(refresher); } @@ -486,6 +493,7 @@ namespace Emby.Server.Implementations.IO { foreach (var refresher in _activeRefreshers.ToList()) { + refresher.Completed -= OnNewRefresherCompleted; refresher.Dispose(); } diff --git a/Emby.Server.Implementations/IO/ManagedFileSystem.cs b/Emby.Server.Implementations/IO/ManagedFileSystem.cs index 3893a15771..777cd2cd46 100644 --- a/Emby.Server.Implementations/IO/ManagedFileSystem.cs +++ b/Emby.Server.Implementations/IO/ManagedFileSystem.cs @@ -1,17 +1,12 @@ -#pragma warning disable CS1591 - using System; using System.Collections.Generic; -using System.Diagnostics; using System.Globalization; using System.IO; using System.Linq; -using System.Text; +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 { @@ -20,22 +15,26 @@ namespace Emby.Server.Implementations.IO /// public class ManagedFileSystem : IFileSystem { - protected ILogger Logger; + private readonly ILogger _logger; private readonly List _shortcutHandlers = new List(); private readonly string _tempPath; - private readonly bool _isEnvironmentCaseInsensitive; + private static readonly bool _isEnvironmentCaseInsensitive = OperatingSystem.IsWindows(); + /// + /// Initializes a new instance of the class. + /// + /// The instance to use. + /// The instance to use. public ManagedFileSystem( ILogger logger, IApplicationPaths applicationPaths) { - Logger = logger; + _logger = logger; _tempPath = applicationPaths.TempDirectory; - - _isEnvironmentCaseInsensitive = OperatingSystem.Id == OperatingSystemId.Windows; } + /// public virtual void AddShortcutHandler(IShortcutHandler handler) { _shortcutHandlers.Add(handler); @@ -46,7 +45,7 @@ namespace Emby.Server.Implementations.IO /// /// The filename. /// true if the specified filename is shortcut; otherwise, false. - /// filename + /// is null. public virtual bool IsShortcut(string filename) { if (string.IsNullOrEmpty(filename)) @@ -55,7 +54,7 @@ namespace Emby.Server.Implementations.IO } var extension = Path.GetExtension(filename); - return _shortcutHandlers.Any(i => string.Equals(extension, i.Extension, _isEnvironmentCaseInsensitive ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal)); + return _shortcutHandlers.Any(i => string.Equals(extension, i.Extension, StringComparison.OrdinalIgnoreCase)); } /// @@ -63,8 +62,8 @@ namespace Emby.Server.Implementations.IO /// /// The filename. /// System.String. - /// filename - public virtual string ResolveShortcut(string filename) + /// is null. + public virtual string? ResolveShortcut(string filename) { if (string.IsNullOrEmpty(filename)) { @@ -72,11 +71,12 @@ 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); } + /// public virtual string MakeAbsolutePath(string folderPath, string filePath) { // path is actually a stream @@ -238,33 +238,31 @@ namespace Emby.Server.Implementations.IO result.IsDirectory = info is DirectoryInfo || (info.Attributes & FileAttributes.Directory) == FileAttributes.Directory; // if (!result.IsDirectory) - //{ + // { // result.IsHidden = (info.Attributes & FileAttributes.Hidden) == FileAttributes.Hidden; - //} + // } if (info is FileInfo fileInfo) { 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) { try { - using (Stream thisFileStream = File.OpenRead(fileInfo.FullName)) + using (var fileHandle = File.OpenHandle(fileInfo.FullName, FileMode.Open, FileAccess.Read, FileShare.ReadWrite)) { - result.Length = thisFileStream.Length; + result.Length = RandomAccess.GetLength(fileHandle); } } 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); + _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); @@ -303,16 +301,37 @@ namespace Emby.Server.Implementations.IO /// The filename. /// System.String. /// The filename is null. - 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] = ' '; + } + } + } + }); } /// @@ -329,7 +348,7 @@ namespace Emby.Server.Implementations.IO } catch (Exception ex) { - Logger.LogError(ex, "Error determining CreationTimeUtc for {FullName}", info.FullName); + _logger.LogError(ex, "Error determining CreationTimeUtc for {FullName}", info.FullName); return DateTime.MinValue; } } @@ -344,11 +363,13 @@ namespace Emby.Server.Implementations.IO return GetCreationTimeUtc(GetFileSystemInfo(path)); } + /// public virtual DateTime GetCreationTimeUtc(FileSystemMetadata info) { return info.CreationTimeUtc; } + /// public virtual DateTime GetLastWriteTimeUtc(FileSystemMetadata info) { return info.LastWriteTimeUtc; @@ -368,7 +389,7 @@ namespace Emby.Server.Implementations.IO } catch (Exception ex) { - Logger.LogError(ex, "Error determining LastAccessTimeUtc for {FullName}", info.FullName); + _logger.LogError(ex, "Error determining LastAccessTimeUtc for {FullName}", info.FullName); return DateTime.MinValue; } } @@ -383,9 +404,10 @@ namespace Emby.Server.Implementations.IO return GetLastWriteTimeUtc(GetFileSystemInfo(path)); } + /// public virtual void SetHidden(string path, bool isHidden) { - if (OperatingSystem.Id != OperatingSystemId.Windows) + if (!OperatingSystem.IsWindows()) { return; } @@ -407,9 +429,10 @@ namespace Emby.Server.Implementations.IO } } - public virtual void SetAttributes(string path, bool isHidden, bool isReadOnly) + /// + public virtual void SetAttributes(string path, bool isHidden, bool readOnly) { - if (OperatingSystem.Id != OperatingSystemId.Windows) + if (!OperatingSystem.IsWindows()) { return; } @@ -421,16 +444,16 @@ namespace Emby.Server.Implementations.IO return; } - if (info.IsReadOnly == isReadOnly && info.IsHidden == isHidden) + if (info.IsReadOnly == readOnly && info.IsHidden == isHidden) { return; } var attributes = File.GetAttributes(path); - if (isReadOnly) + if (readOnly) { - attributes = attributes | FileAttributes.ReadOnly; + attributes |= FileAttributes.ReadOnly; } else { @@ -439,7 +462,7 @@ namespace Emby.Server.Implementations.IO if (isHidden) { - attributes = attributes | FileAttributes.Hidden; + attributes |= FileAttributes.Hidden; } else { @@ -484,6 +507,7 @@ namespace Emby.Server.Implementations.IO File.Copy(temp1, file2, true); } + /// public virtual bool ContainsSubPath(string parentPath, string path) { if (string.IsNullOrEmpty(parentPath)) @@ -501,6 +525,7 @@ namespace Emby.Server.Implementations.IO _isEnvironmentCaseInsensitive ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal); } + /// public virtual string NormalizePath(string path) { if (string.IsNullOrEmpty(path)) @@ -516,6 +541,7 @@ namespace Emby.Server.Implementations.IO return Path.TrimEndingDirectorySeparator(path); } + /// public virtual bool AreEqual(string path1, string path2) { if (path1 == null && path2 == null) @@ -534,6 +560,7 @@ namespace Emby.Server.Implementations.IO _isEnvironmentCaseInsensitive ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal); } + /// public virtual string GetFileNameWithoutExtension(FileSystemMetadata info) { if (info.IsDirectory) @@ -544,11 +571,11 @@ namespace Emby.Server.Implementations.IO return Path.GetFileNameWithoutExtension(info.FullName); } + /// public virtual bool IsPathFile(string path) { - // Cannot use Path.IsPathRooted because it returns false under mono when using windows-based paths, e.g. C:\\ - if (path.IndexOf("://", StringComparison.OrdinalIgnoreCase) != -1 && - !path.StartsWith("file://", StringComparison.OrdinalIgnoreCase)) + if (path.Contains("://", StringComparison.OrdinalIgnoreCase) + && !path.StartsWith("file://", StringComparison.OrdinalIgnoreCase)) { return false; } @@ -556,17 +583,23 @@ namespace Emby.Server.Implementations.IO return true; } + /// public virtual void DeleteFile(string path) { SetAttributes(path, false, false); File.Delete(path); } + /// public virtual List GetDrives() { // check for ready state to avoid waiting for drives to timeout // some drives on linux have no actual size or are used for other purposes - return DriveInfo.GetDrives().Where(d => d.IsReady && d.TotalSize != 0 && d.DriveType != DriveType.Ram) + return DriveInfo.GetDrives() + .Where( + d => (d.DriveType == DriveType.Fixed || d.DriveType == DriveType.Network || d.DriveType == DriveType.Removable) + && d.IsReady + && d.TotalSize != 0) .Select(d => new FileSystemMetadata { Name = d.Name, @@ -575,17 +608,20 @@ namespace Emby.Server.Implementations.IO }).ToList(); } + /// public virtual IEnumerable GetDirectories(string path, bool recursive = false) { return ToMetadata(new DirectoryInfo(path).EnumerateDirectories("*", GetEnumerationOptions(recursive))); } + /// public virtual IEnumerable GetFiles(string path, bool recursive = false) { return GetFiles(path, null, false, recursive); } - public virtual IEnumerable GetFiles(string path, IReadOnlyList extensions, bool enableCaseSensitiveExtensions, bool recursive = false) + /// + public virtual IEnumerable GetFiles(string path, IReadOnlyList? extensions, bool enableCaseSensitiveExtensions, bool recursive = false) { var enumerationOptions = GetEnumerationOptions(recursive); @@ -602,26 +638,26 @@ namespace Emby.Server.Implementations.IO { 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); }); } return ToMetadata(files); } + /// public virtual IEnumerable GetFileSystemEntries(string path, bool recursive = false) { var directoryInfo = new DirectoryInfo(path); var enumerationOptions = GetEnumerationOptions(recursive); - return ToMetadata(directoryInfo.EnumerateDirectories("*", enumerationOptions)) - .Concat(ToMetadata(directoryInfo.EnumerateFiles("*", enumerationOptions))); + return ToMetadata(directoryInfo.EnumerateFileSystemInfos("*", enumerationOptions)); } private IEnumerable ToMetadata(IEnumerable infos) @@ -629,17 +665,20 @@ namespace Emby.Server.Implementations.IO return infos.Select(GetFileSystemMetadata); } + /// public virtual IEnumerable GetDirectoryPaths(string path, bool recursive = false) { return Directory.EnumerateDirectories(path, "*", GetEnumerationOptions(recursive)); } + /// public virtual IEnumerable GetFilePaths(string path, bool recursive = false) { return GetFilePaths(path, null, false, recursive); } - public virtual IEnumerable GetFilePaths(string path, string[] extensions, bool enableCaseSensitiveExtensions, bool recursive = false) + /// + public virtual IEnumerable GetFilePaths(string path, string[]? extensions, bool enableCaseSensitiveExtensions, bool recursive = false) { var enumerationOptions = GetEnumerationOptions(recursive); @@ -656,19 +695,20 @@ namespace Emby.Server.Implementations.IO { 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); }); } return files; } + /// public virtual IEnumerable GetFileSystemEntryPaths(string path, bool recursive = false) { return Directory.EnumerateFileSystemEntries(path, "*", GetEnumerationOptions(recursive)); @@ -684,20 +724,5 @@ namespace Emby.Server.Implementations.IO AttributesToSkip = 0 }; } - - private static void RunProcess(string path, string args, string workingDirectory) - { - using (var process = Process.Start(new ProcessStartInfo - { - Arguments = args, - FileName = path, - CreateNoWindow = true, - WorkingDirectory = workingDirectory, - WindowStyle = ProcessWindowStyle.Normal - })) - { - process.WaitForExit(); - } - } } } 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.Shared.Rent(bufferSize); try diff --git a/Emby.Server.Implementations/IStartupOptions.cs b/Emby.Server.Implementations/IStartupOptions.cs index f719dc5f89..3769ae4dd8 100644 --- a/Emby.Server.Implementations/IStartupOptions.cs +++ b/Emby.Server.Implementations/IStartupOptions.cs @@ -1,8 +1,8 @@ -#pragma warning disable CS1591 -#nullable enable - namespace Emby.Server.Implementations { + /// + /// Specifies the contract for server startup options. + /// public interface IStartupOptions { /// @@ -11,7 +11,7 @@ namespace Emby.Server.Implementations string? FFmpegPath { get; } /// - /// Gets the value of the --service command line option. + /// Gets a value indicating whether to run as service by the --service command line option. /// bool IsService { get; } diff --git a/Emby.Server.Implementations/Images/BaseDynamicImageProvider.cs b/Emby.Server.Implementations/Images/BaseDynamicImageProvider.cs index 5f7e51858a..7589869457 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; @@ -49,7 +51,7 @@ namespace Emby.Server.Implementations.Images public int Order => 0; - protected virtual bool Supports(BaseItem _) => true; + protected virtual bool Supports(BaseItem item) => true; public async Task FetchAsync(T item, MetadataRefreshOptions options, CancellationToken cancellationToken) { @@ -63,13 +65,13 @@ namespace Emby.Server.Implementations.Images if (SupportedImages.Contains(ImageType.Primary)) { var primaryResult = await FetchAsync(item, ImageType.Primary, options, cancellationToken).ConfigureAwait(false); - updateType = updateType | primaryResult; + updateType |= primaryResult; } if (SupportedImages.Contains(ImageType.Thumb)) { var thumbResult = await FetchAsync(item, ImageType.Thumb, options, cancellationToken).ConfigureAwait(false); - updateType = updateType | thumbResult; + updateType |= thumbResult; } return updateType; @@ -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/BaseFolderImageProvider.cs b/Emby.Server.Implementations/Images/BaseFolderImageProvider.cs new file mode 100644 index 0000000000..1c69056d20 --- /dev/null +++ b/Emby.Server.Implementations/Images/BaseFolderImageProvider.cs @@ -0,0 +1,67 @@ +#nullable disable + +#pragma warning disable CS1591 + +using System.Collections.Generic; +using Jellyfin.Data.Enums; +using MediaBrowser.Common.Configuration; +using MediaBrowser.Controller.Drawing; +using MediaBrowser.Controller.Dto; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.Audio; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.IO; +using MediaBrowser.Model.Querying; + +namespace Emby.Server.Implementations.Images +{ + public abstract class BaseFolderImageProvider : BaseDynamicImageProvider + where T : Folder, new() + { + private readonly ILibraryManager _libraryManager; + + public BaseFolderImageProvider(IFileSystem fileSystem, IProviderManager providerManager, IApplicationPaths applicationPaths, IImageProcessor imageProcessor, ILibraryManager libraryManager) + : base(fileSystem, providerManager, applicationPaths, imageProcessor) + { + _libraryManager = libraryManager; + } + + protected override IReadOnlyList GetItemsWithImages(BaseItem item) + { + return _libraryManager.GetItemList(new InternalItemsQuery + { + Parent = item, + DtoOptions = new DtoOptions(true), + ImageTypes = new ImageType[] { ImageType.Primary }, + OrderBy = new (string, SortOrder)[] + { + (ItemSortBy.IsFolder, SortOrder.Ascending), + (ItemSortBy.SortName, SortOrder.Ascending) + }, + Limit = 1 + }); + } + + protected override string CreateImage(BaseItem item, IReadOnlyCollection itemsWithImages, string outputPathWithoutExtension, ImageType imageType, int imageIndex) + { + return CreateSingleImage(itemsWithImages, outputPathWithoutExtension, ImageType.Primary); + } + + protected override bool Supports(BaseItem item) + { + return item is T; + } + + protected override bool HasChangedByDate(BaseItem item, ItemImageInfo image) + { + if (item is MusicAlbum) + { + return false; + } + + return base.HasChangedByDate(item, image); + } + } +} diff --git a/Emby.Server.Implementations/Images/CollectionFolderImageProvider.cs b/Emby.Server.Implementations/Images/CollectionFolderImageProvider.cs index 161b4c4528..0229fbae79 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; @@ -28,27 +30,27 @@ namespace Emby.Server.Implementations.Images string[] includeItemTypes; - if (string.Equals(viewType, CollectionType.Movies)) + if (string.Equals(viewType, CollectionType.Movies, StringComparison.Ordinal)) { includeItemTypes = new string[] { "Movie" }; } - else if (string.Equals(viewType, CollectionType.TvShows)) + else if (string.Equals(viewType, CollectionType.TvShows, StringComparison.Ordinal)) { includeItemTypes = new string[] { "Series" }; } - else if (string.Equals(viewType, CollectionType.Music)) + else if (string.Equals(viewType, CollectionType.Music, StringComparison.Ordinal)) { includeItemTypes = new string[] { "MusicAlbum" }; } - else if (string.Equals(viewType, CollectionType.Books)) + else if (string.Equals(viewType, CollectionType.Books, StringComparison.Ordinal)) { includeItemTypes = new string[] { "Book", "AudioBook" }; } - else if (string.Equals(viewType, CollectionType.BoxSets)) + else if (string.Equals(viewType, CollectionType.BoxSets, StringComparison.Ordinal)) { includeItemTypes = new string[] { "BoxSet" }; } - else if (string.Equals(viewType, CollectionType.HomeVideos) || string.Equals(viewType, CollectionType.Photos)) + else if (string.Equals(viewType, CollectionType.HomeVideos, StringComparison.Ordinal) || string.Equals(viewType, CollectionType.Photos, StringComparison.Ordinal)) { includeItemTypes = new string[] { "Video", "Photo" }; } diff --git a/Emby.Server.Implementations/Images/DynamicImageProvider.cs b/Emby.Server.Implementations/Images/DynamicImageProvider.cs index 50c5314820..900b3fd9c6 100644 --- a/Emby.Server.Implementations/Images/DynamicImageProvider.cs +++ b/Emby.Server.Implementations/Images/DynamicImageProvider.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System; diff --git a/Emby.Server.Implementations/Images/FolderImageProvider.cs b/Emby.Server.Implementations/Images/FolderImageProvider.cs index 0224ab32a0..4376bd356c 100644 --- a/Emby.Server.Implementations/Images/FolderImageProvider.cs +++ b/Emby.Server.Implementations/Images/FolderImageProvider.cs @@ -1,68 +1,17 @@ +#nullable disable + #pragma warning disable CS1591 -using System.Collections.Generic; -using Jellyfin.Data.Enums; using MediaBrowser.Common.Configuration; using MediaBrowser.Controller.Drawing; -using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.Audio; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Providers; -using MediaBrowser.Model.Entities; using MediaBrowser.Model.IO; -using MediaBrowser.Model.Querying; namespace Emby.Server.Implementations.Images { - public abstract class BaseFolderImageProvider : BaseDynamicImageProvider - where T : Folder, new() - { - protected ILibraryManager _libraryManager; - - public BaseFolderImageProvider(IFileSystem fileSystem, IProviderManager providerManager, IApplicationPaths applicationPaths, IImageProcessor imageProcessor, ILibraryManager libraryManager) - : base(fileSystem, providerManager, applicationPaths, imageProcessor) - { - _libraryManager = libraryManager; - } - - protected override IReadOnlyList GetItemsWithImages(BaseItem item) - { - return _libraryManager.GetItemList(new InternalItemsQuery - { - Parent = item, - DtoOptions = new DtoOptions(true), - ImageTypes = new ImageType[] { ImageType.Primary }, - OrderBy = new System.ValueTuple[] - { - new System.ValueTuple(ItemSortBy.IsFolder, SortOrder.Ascending), - new System.ValueTuple(ItemSortBy.SortName, SortOrder.Ascending) - }, - Limit = 1 - }); - } - - protected override string CreateImage(BaseItem item, IReadOnlyCollection itemsWithImages, string outputPathWithoutExtension, ImageType imageType, int imageIndex) - { - return CreateSingleImage(itemsWithImages, outputPathWithoutExtension, ImageType.Primary); - } - - protected override bool Supports(BaseItem item) - { - return item is T; - } - - protected override bool HasChangedByDate(BaseItem item, ItemImageInfo image) - { - if (item is MusicAlbum) - { - return false; - } - - return base.HasChangedByDate(item, image); - } - } - public class FolderImageProvider : BaseFolderImageProvider { public FolderImageProvider(IFileSystem fileSystem, IProviderManager providerManager, IApplicationPaths applicationPaths, IImageProcessor imageProcessor, ILibraryManager libraryManager) @@ -85,20 +34,4 @@ namespace Emby.Server.Implementations.Images return true; } } - - public class MusicAlbumImageProvider : BaseFolderImageProvider - { - public MusicAlbumImageProvider(IFileSystem fileSystem, IProviderManager providerManager, IApplicationPaths applicationPaths, IImageProcessor imageProcessor, ILibraryManager libraryManager) - : base(fileSystem, providerManager, applicationPaths, imageProcessor, libraryManager) - { - } - } - - public class PhotoAlbumImageProvider : BaseFolderImageProvider - { - public PhotoAlbumImageProvider(IFileSystem fileSystem, IProviderManager providerManager, IApplicationPaths applicationPaths, IImageProcessor imageProcessor, ILibraryManager libraryManager) - : base(fileSystem, providerManager, applicationPaths, imageProcessor, libraryManager) - { - } - } } diff --git a/Emby.Server.Implementations/Images/GenreImageProvider.cs b/Emby.Server.Implementations/Images/GenreImageProvider.cs index 3817882312..1f5090f7f5 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; @@ -6,7 +8,6 @@ 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.Library; @@ -17,46 +18,6 @@ using MediaBrowser.Model.Querying; namespace Emby.Server.Implementations.Images { - /// - /// Class MusicGenreImageProvider. - /// - public class MusicGenreImageProvider : BaseDynamicImageProvider - { - /// - /// The library manager. - /// - private readonly ILibraryManager _libraryManager; - - public MusicGenreImageProvider(IFileSystem fileSystem, IProviderManager providerManager, IApplicationPaths applicationPaths, IImageProcessor imageProcessor, ILibraryManager libraryManager) : base(fileSystem, providerManager, applicationPaths, imageProcessor) - { - _libraryManager = libraryManager; - } - - /// - /// Get children objects used to create an music genre image. - /// - /// The music genre used to create the image. - /// Any relevant children objects. - protected override IReadOnlyList GetItemsWithImages(BaseItem item) - { - return _libraryManager.GetItemList(new InternalItemsQuery - { - Genres = new[] { item.Name }, - IncludeItemTypes = new[] - { - nameof(MusicAlbum), - nameof(MusicVideo), - nameof(Audio) - }, - OrderBy = new[] { (ItemSortBy.Random, SortOrder.Ascending) }, - Limit = 4, - Recursive = true, - ImageTypes = new[] { ImageType.Primary }, - DtoOptions = new DtoOptions(false) - }); - } - } - /// /// Class GenreImageProvider. /// diff --git a/Emby.Server.Implementations/Images/MusicAlbumImageProvider.cs b/Emby.Server.Implementations/Images/MusicAlbumImageProvider.cs new file mode 100644 index 0000000000..ce83673638 --- /dev/null +++ b/Emby.Server.Implementations/Images/MusicAlbumImageProvider.cs @@ -0,0 +1,19 @@ +#pragma warning disable CS1591 + +using MediaBrowser.Common.Configuration; +using MediaBrowser.Controller.Drawing; +using MediaBrowser.Controller.Entities.Audio; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.IO; + +namespace Emby.Server.Implementations.Images +{ + public class MusicAlbumImageProvider : BaseFolderImageProvider + { + public MusicAlbumImageProvider(IFileSystem fileSystem, IProviderManager providerManager, IApplicationPaths applicationPaths, IImageProcessor imageProcessor, ILibraryManager libraryManager) + : base(fileSystem, providerManager, applicationPaths, imageProcessor, libraryManager) + { + } + } +} diff --git a/Emby.Server.Implementations/Images/MusicGenreImageProvider.cs b/Emby.Server.Implementations/Images/MusicGenreImageProvider.cs new file mode 100644 index 0000000000..baf1c90517 --- /dev/null +++ b/Emby.Server.Implementations/Images/MusicGenreImageProvider.cs @@ -0,0 +1,59 @@ +#nullable disable + +#pragma warning disable CS1591 + +using System.Collections.Generic; +using Jellyfin.Data.Enums; +using MediaBrowser.Common.Configuration; +using MediaBrowser.Controller.Drawing; +using MediaBrowser.Controller.Dto; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.Audio; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.IO; +using MediaBrowser.Model.Querying; + +namespace Emby.Server.Implementations.Images +{ + /// + /// Class MusicGenreImageProvider. + /// + public class MusicGenreImageProvider : BaseDynamicImageProvider + { + /// + /// The library manager. + /// + private readonly ILibraryManager _libraryManager; + + public MusicGenreImageProvider(IFileSystem fileSystem, IProviderManager providerManager, IApplicationPaths applicationPaths, IImageProcessor imageProcessor, ILibraryManager libraryManager) : base(fileSystem, providerManager, applicationPaths, imageProcessor) + { + _libraryManager = libraryManager; + } + + /// + /// Get children objects used to create an music genre image. + /// + /// The music genre used to create the image. + /// Any relevant children objects. + protected override IReadOnlyList GetItemsWithImages(BaseItem item) + { + return _libraryManager.GetItemList(new InternalItemsQuery + { + Genres = new[] { item.Name }, + IncludeItemTypes = new[] + { + nameof(MusicAlbum), + nameof(MusicVideo), + nameof(Audio) + }, + OrderBy = new[] { (ItemSortBy.Random, SortOrder.Ascending) }, + Limit = 4, + Recursive = true, + ImageTypes = new[] { ImageType.Primary }, + DtoOptions = new DtoOptions(false) + }); + } + } +} diff --git a/Emby.Server.Implementations/Images/PhotoAlbumImageProvider.cs b/Emby.Server.Implementations/Images/PhotoAlbumImageProvider.cs new file mode 100644 index 0000000000..1ddb4c7579 --- /dev/null +++ b/Emby.Server.Implementations/Images/PhotoAlbumImageProvider.cs @@ -0,0 +1,19 @@ +#pragma warning disable CS1591 + +using MediaBrowser.Common.Configuration; +using MediaBrowser.Controller.Drawing; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.IO; + +namespace Emby.Server.Implementations.Images +{ + public class PhotoAlbumImageProvider : BaseFolderImageProvider + { + public PhotoAlbumImageProvider(IFileSystem fileSystem, IProviderManager providerManager, IApplicationPaths applicationPaths, IImageProcessor imageProcessor, ILibraryManager libraryManager) + : base(fileSystem, providerManager, applicationPaths, imageProcessor, libraryManager) + { + } + } +} 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..29758a0788 100644 --- a/Emby.Server.Implementations/Library/CoreResolutionIgnoreRule.cs +++ b/Emby.Server.Implementations/Library/CoreResolutionIgnoreRule.cs @@ -1,8 +1,9 @@ using System; using System.IO; +using Emby.Naming.Audio; +using Emby.Naming.Common; using MediaBrowser.Controller; using MediaBrowser.Controller.Entities; -using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Resolvers; using MediaBrowser.Model.IO; @@ -13,17 +14,17 @@ namespace Emby.Server.Implementations.Library /// public class CoreResolutionIgnoreRule : IResolverIgnoreRule { - private readonly ILibraryManager _libraryManager; + private readonly NamingOptions _namingOptions; private readonly IServerApplicationPaths _serverApplicationPaths; /// /// Initializes a new instance of the class. /// - /// The library manager. + /// The naming options. /// The server application paths. - public CoreResolutionIgnoreRule(ILibraryManager libraryManager, IServerApplicationPaths serverApplicationPaths) + public CoreResolutionIgnoreRule(NamingOptions namingOptions, IServerApplicationPaths serverApplicationPaths) { - _libraryManager = libraryManager; + _namingOptions = namingOptions; _serverApplicationPaths = serverApplicationPaths; } @@ -54,7 +55,7 @@ namespace Emby.Server.Implementations.Library if (parent != null) { // Ignore trailer folders but allow it at the collection level - if (string.Equals(filename, BaseItem.TrailerFolderName, StringComparison.OrdinalIgnoreCase) + if (string.Equals(filename, BaseItem.TrailersFolderName, StringComparison.OrdinalIgnoreCase) && !(parent is AggregateFolder) && !(parent is UserRootFolder)) { @@ -77,8 +78,8 @@ 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) - && _libraryManager.IsAudioFile(filename)) + if (Path.GetFileNameWithoutExtension(filename.AsSpan()).Equals(BaseItem.ThemeSongFileName, StringComparison.Ordinal) + && AudioFileParser.IsAudioFile(filename, _namingOptions)) { return true; } diff --git a/Emby.Server.Implementations/Library/ExclusiveLiveStream.cs b/Emby.Server.Implementations/Library/ExclusiveLiveStream.cs index 236453e805..868071a992 100644 --- a/Emby.Server.Implementations/Library/ExclusiveLiveStream.cs +++ b/Emby.Server.Implementations/Library/ExclusiveLiveStream.cs @@ -1,7 +1,10 @@ +#nullable disable + #pragma warning disable CS1591 using System; using System.Globalization; +using System.IO; using System.Threading; using System.Threading.Tasks; using MediaBrowser.Controller.Library; @@ -39,6 +42,11 @@ namespace Emby.Server.Implementations.Library return _closeFn(); } + public Stream GetStream() + { + throw new NotSupportedException(); + } + public Task Open(CancellationToken openCancellationToken) { return Task.CompletedTask; 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 6a9f4174dc..778b6225e1 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; @@ -19,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; @@ -48,6 +51,7 @@ 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; @@ -75,6 +79,7 @@ namespace Emby.Server.Implementations.Library private readonly IFileSystem _fileSystem; private readonly IItemRepository _itemRepository; private readonly IImageProcessor _imageProcessor; + private readonly NamingOptions _namingOptions; /// /// The _root folder sync lock. @@ -84,9 +89,6 @@ namespace Emby.Server.Implementations.Library private readonly TimeSpan _viewRefreshInterval = TimeSpan.FromHours(24); - private NamingOptions _namingOptions; - private string[] _videoFileExtensions; - /// /// The _root folder. /// @@ -112,6 +114,7 @@ namespace Emby.Server.Implementations.Library /// The item repository. /// The image processor. /// The memory cache. + /// The naming options. public LibraryManager( IServerApplicationHost appHost, ILogger logger, @@ -126,7 +129,8 @@ namespace Emby.Server.Implementations.Library IMediaEncoder mediaEncoder, IItemRepository itemRepository, IImageProcessor imageProcessor, - IMemoryCache memoryCache) + IMemoryCache memoryCache, + NamingOptions namingOptions) { _appHost = appHost; _logger = logger; @@ -142,6 +146,7 @@ namespace Emby.Server.Implementations.Library _itemRepository = itemRepository; _imageProcessor = imageProcessor; _memoryCache = memoryCache; + _namingOptions = namingOptions; _configurationManager.ConfigurationUpdated += ConfigurationUpdated; @@ -175,10 +180,7 @@ namespace Emby.Server.Implementations.Library { lock (_rootFolderSyncLock) { - if (_rootFolder == null) - { - _rootFolder = CreateRootFolder(); - } + _rootFolder ??= CreateRootFolder(); } } @@ -286,14 +288,14 @@ namespace Emby.Server.Implementations.Library if (item is IItemByName) { - if (!(item is MusicArtist)) + if (item is not MusicArtist) { return; } } else if (!item.IsFolder) { - if (!(item is Video) && !(item is LiveTvChannel)) + if (item is not Video && item is not LiveTvChannel) { return; } @@ -332,8 +334,7 @@ namespace Emby.Server.Implementations.Library { try { - var task = BaseItem.ChannelManager.DeleteItem(item); - Task.WaitAll(task); + BaseItem.ChannelManager.DeleteItem(item).GetAwaiter().GetResult(); } catch (ArgumentException) { @@ -491,7 +492,7 @@ namespace Emby.Server.Implementations.Library } catch (Exception ex) { - _logger.LogError(ex, "Error in {resolver} resolving {path}", resolver.GetType().Name, args.Path); + _logger.LogError(ex, "Error in {Resolver} resolving {Path}", resolver.GetType().Name, args.Path); return null; } } @@ -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 @@ -647,7 +647,7 @@ namespace Emby.Server.Implementations.Library /// Determines whether a path should be ignored based on its contents - called after the contents have been read. /// /// The args. - /// true if XXXX, false otherwise + /// true if XXXX, false otherwise. private static bool ShouldResolvePathContents(ItemResolveArgs args) { // Ignore any folders containing a file called .ignore @@ -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 ResolveFileList( - IEnumerable fileList, + IReadOnlyList 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; + } + } } /// @@ -792,7 +799,7 @@ namespace Emby.Server.Implementations.Library { var userRootPath = _configurationManager.ApplicationPaths.DefaultUserViewsPath; - _logger.LogDebug("Creating userRootPath at {path}", userRootPath); + _logger.LogDebug("Creating userRootPath at {Path}", userRootPath); Directory.CreateDirectory(userRootPath); var newItemId = GetNewItemId(userRootPath, typeof(UserRootFolder)); @@ -803,7 +810,7 @@ namespace Emby.Server.Implementations.Library } catch (Exception ex) { - _logger.LogError(ex, "Error creating UserRootFolder {path}", newItemId); + _logger.LogError(ex, "Error creating UserRootFolder {Path}", newItemId); } if (tmpItem == null) @@ -820,7 +827,7 @@ namespace Emby.Server.Implementations.Library } _userRootFolder = tmpItem; - _logger.LogDebug("Setting userRootFolder: {folder}", _userRootFolder); + _logger.LogDebug("Setting userRootFolder: {Folder}", _userRootFolder); } } } @@ -859,7 +866,7 @@ namespace Emby.Server.Implementations.Library { var path = Person.GetPath(name); var id = GetItemByNameId(path); - if (!(GetItemById(id) is Person item)) + if (GetItemById(id) is not Person item) { item = new Person { @@ -1066,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(), - 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(), - 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()) @@ -1096,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); @@ -1163,7 +1170,7 @@ namespace Emby.Server.Implementations.Library progress.Report(percent * 100); } - _itemRepository.UpdateInheritedValues(cancellationToken); + _itemRepository.UpdateInheritedValues(); progress.Report(100); } @@ -1206,7 +1213,7 @@ namespace Emby.Server.Implementations.Library } catch (Exception ex) { - _logger.LogError(ex, "Error resolving shortcut file {file}", i); + _logger.LogError(ex, "Error resolving shortcut file {File}", i); return null; } }) @@ -1243,10 +1250,8 @@ namespace Emby.Server.Implementations.Library private CollectionTypeOptions? GetCollectionType(string path) { var files = _fileSystem.GetFilePaths(path, new[] { ".collection" }, true, false); - foreach (var file in files) + foreach (ReadOnlySpan file in files) { - // TODO: @bond use a ReadOnlySpan here when Enum.TryParse supports it - // https://github.com/dotnet/runtime/issues/20008 if (Enum.TryParse(Path.GetFileNameWithoutExtension(file), true, out var res)) { return res; @@ -1261,7 +1266,7 @@ namespace Emby.Server.Implementations.Library /// /// The id. /// BaseItem. - /// id + /// is null. public BaseItem GetItemById(Guid id) { if (id == Guid.Empty) @@ -1693,7 +1698,7 @@ namespace Emby.Server.Implementations.Library if (video == null) { - _logger.LogError("Intro resolver returned null for {path}.", info.Path); + _logger.LogError("Intro resolver returned null for {Path}.", info.Path); } else { @@ -1712,7 +1717,7 @@ namespace Emby.Server.Implementations.Library } catch (Exception ex) { - _logger.LogError(ex, "Error resolving path {path}.", info.Path); + _logger.LogError(ex, "Error resolving path {Path}.", info.Path); } } else @@ -1754,22 +1759,20 @@ namespace Emby.Server.Implementations.Library return orderedItems ?? items; } - public IEnumerable Sort(IEnumerable items, User user, IEnumerable> orderByList) + public IEnumerable Sort(IEnumerable items, User user, IEnumerable> orderBy) { var isFirst = true; IOrderedEnumerable orderedItems = null; - foreach (var orderBy in orderByList) + foreach (var (name, sortOrder) in orderBy) { - var comparer = GetComparer(orderBy.Item1, user); + var comparer = GetComparer(name, user); if (comparer == null) { continue; } - var sortOrder = orderBy.Item2; - if (isFirst) { orderedItems = sortOrder == SortOrder.Descending ? items.OrderByDescending(i => i, comparer) : items.OrderBy(i => i, comparer); @@ -2077,7 +2080,7 @@ namespace Emby.Server.Implementations.Library return new List(); } - return GetCollectionFoldersInternal(item, GetUserRootFolder().Children.OfType().ToList()); + return GetCollectionFoldersInternal(item, GetUserRootFolder().Children.OfType()); } public List GetCollectionFolders(BaseItem item, List allUserRootChildren) @@ -2102,20 +2105,20 @@ namespace Emby.Server.Implementations.Library return GetCollectionFoldersInternal(item, allUserRootChildren); } - private static List GetCollectionFoldersInternal(BaseItem item, List allUserRootChildren) + private static List GetCollectionFoldersInternal(BaseItem item, IEnumerable 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(); } public LibraryOptions GetLibraryOptions(BaseItem item) { - if (!(item is CollectionFolder collectionFolder)) + if (item is not CollectionFolder collectionFolder) { + // List.Find is more performant than FirstOrDefault due to enumerator allocation collectionFolder = GetCollectionFolders(item) - .OfType() - .FirstOrDefault(); + .Find(folder => folder is CollectionFolder) as CollectionFolder; } return collectionFolder == null ? new LibraryOptions() : collectionFolder.GetLibraryOptions(); @@ -2498,17 +2501,6 @@ namespace Emby.Server.Implementations.Library return RootFolder; } - /// - public bool IsVideoFile(string path) - { - var resolver = new VideoResolver(GetNamingOptions()); - return resolver.IsVideoFile(path); - } - - /// - public bool IsAudioFile(string path) - => AudioFileParser.IsAudioFile(path, GetNamingOptions()); - /// public int? GetSeasonNumberFromPath(string path) => SeasonPathParser.Parse(path, true, true).SeasonNumber; @@ -2517,21 +2509,36 @@ 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 isAbsoluteNaming = null; } - var resolver = new EpisodeResolver(GetNamingOptions()); + var resolver = new EpisodeResolver(_namingOptions); var isFolder = episode.VideoType == VideoType.BluRay || episode.VideoType == VideoType.Dvd; // TODO nullable - what are we trying to do there with empty episodeInfo? - var episodeInfo = episode.IsFileProtocol - ? resolver.Resolve(episode.Path, isFolder, null, null, isAbsoluteNaming) ?? new Naming.TV.EpisodeInfo(episode.Path) - : new Naming.TV.EpisodeInfo(episode.Path); + 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 { @@ -2666,42 +2673,28 @@ namespace Emby.Server.Implementations.Library return changed; } - public NamingOptions GetNamingOptions() - { - if (_namingOptions == null) - { - _namingOptions = new NamingOptions(); - _videoFileExtensions = _namingOptions.VideoFileExtensions; - } - - return _namingOptions; - } - public ItemLookupInfo ParseName(string name) { - var resolver = new VideoResolver(GetNamingOptions()); - - var result = resolver.CleanDateTime(name); + var namingOptions = _namingOptions; + 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 }; } public IEnumerable /// The item. /// The parent. - /// The file system. /// The library manager. /// The directory service. - /// Item must have a path - public static void SetInitialItemValues(BaseItem item, Folder parent, IFileSystem fileSystem, ILibraryManager libraryManager, IDirectoryService directoryService) + /// Item must have a path. + 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); } /// @@ -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 /// /// Ensures the name. /// - 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); } } - /// - /// Gets the display name. - /// - /// The path. - /// if set to true [is directory]. - /// System.String. - private static string GetDisplayName(string path, bool isDirectory) - { - return isDirectory ? Path.GetFileName(path) : Path.GetFileNameWithoutExtension(path); - } - /// /// Ensures DateCreated and DateModified have values. /// @@ -114,21 +105,6 @@ namespace Emby.Server.Implementations.Library /// The args. 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 4ad84579d8..7a6aea9c1f 100644 --- a/Emby.Server.Implementations/Library/Resolvers/Audio/AudioResolver.cs +++ b/Emby.Server.Implementations/Library/Resolvers/Audio/AudioResolver.cs @@ -1,10 +1,15 @@ +#nullable disable + #pragma warning disable CS1591 using System; using System.Collections.Generic; using System.IO; using System.Linq; +using Emby.Naming.Audio; using Emby.Naming.AudioBook; +using Emby.Naming.Common; +using Emby.Naming.Video; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Providers; @@ -19,11 +24,11 @@ namespace Emby.Server.Implementations.Library.Resolvers.Audio /// public class AudioResolver : ItemResolver, IMultiItemResolver { - private readonly ILibraryManager LibraryManager; + private readonly NamingOptions _namingOptions; - public AudioResolver(ILibraryManager libraryManager) + public AudioResolver(NamingOptions namingOptions) { - LibraryManager = libraryManager; + _namingOptions = namingOptions; } /// @@ -38,7 +43,7 @@ namespace Emby.Server.Implementations.Library.Resolvers.Audio string collectionType, IDirectoryService directoryService) { - var result = ResolveMultipleInternal(parent, files, collectionType, directoryService); + var result = ResolveMultipleInternal(parent, files, collectionType); if (result != null) { @@ -54,12 +59,11 @@ namespace Emby.Server.Implementations.Library.Resolvers.Audio private MultiItemResolverResult ResolveMultipleInternal( Folder parent, List files, - string collectionType, - IDirectoryService directoryService) + string collectionType) { if (string.Equals(collectionType, CollectionType.Books, StringComparison.OrdinalIgnoreCase)) { - return ResolveMultipleAudio(parent, files, directoryService, false, collectionType, true); + return ResolveMultipleAudio(parent, files, true); } return null; @@ -85,14 +89,10 @@ namespace Emby.Server.Implementations.Library.Resolvers.Audio return null; } - var files = args.FileSystemChildren - .Where(i => !LibraryManager.IgnoreFile(i, args.Parent)) - .ToList(); - - return FindAudio(args, args.Path, args.Parent, files, args.DirectoryService, collectionType, false); + return FindAudioBook(args, false); } - if (LibraryManager.IsAudioFile(args.Path)) + if (AudioFileParser.IsAudioFile(args.Path, _namingOptions)) { var extension = Path.GetExtension(args.Path); @@ -105,7 +105,7 @@ namespace Emby.Server.Implementations.Library.Resolvers.Audio var isMixedCollectionType = string.IsNullOrEmpty(collectionType); // For conflicting extensions, give priority to videos - if (isMixedCollectionType && LibraryManager.IsVideoFile(args.Path)) + if (isMixedCollectionType && VideoResolver.IsVideoFile(args.Path, _namingOptions)) { return null; } @@ -139,29 +139,23 @@ namespace Emby.Server.Implementations.Library.Resolvers.Audio return null; } - private T FindAudio(ItemResolveArgs args, string path, Folder parent, List fileSystemEntries, IDirectoryService directoryService, string collectionType, bool parseName) - where T : MediaBrowser.Controller.Entities.Audio.Audio, new() + private AudioBook FindAudioBook(ItemResolveArgs args, bool parseName) { // TODO: Allow GetMultiDiscMovie in here - const bool supportsMultiVersion = false; - - var result = ResolveMultipleAudio(parent, fileSystemEntries, directoryService, supportsMultiVersion, collectionType, parseName) ?? - new MultiItemResolverResult(); + var result = ResolveMultipleAudio(args.Parent, args.GetActualFileSystemChildren(), parseName); - if (result.Items.Count == 1) + if (result == null || result.Items.Count != 1 || result.Items[0] is not AudioBook item) { - // If we were supporting this we'd be checking filesFromOtherItems - var item = (T)result.Items[0]; - item.IsInMixedFolder = false; - item.Name = Path.GetFileName(item.ContainingFolderPath); - return item; + return null; } - return null; + // If we were supporting this we'd be checking filesFromOtherItems + item.IsInMixedFolder = false; + item.Name = Path.GetFileName(item.ContainingFolderPath); + return item; } - private MultiItemResolverResult ResolveMultipleAudio(Folder parent, IEnumerable fileSystemEntries, IDirectoryService directoryService, bool suppportMultiEditions, string collectionType, bool parseName) - where T : MediaBrowser.Controller.Entities.Audio.Audio, new() + private MultiItemResolverResult ResolveMultipleAudio(Folder parent, IEnumerable fileSystemEntries, bool parseName) { var files = new List(); var items = new List(); @@ -174,15 +168,13 @@ namespace Emby.Server.Implementations.Library.Resolvers.Audio { leftOver.Add(child); } - else if (!IsIgnored(child.Name)) + else { files.Add(child); } } - var namingOptions = ((LibraryManager)LibraryManager).GetNamingOptions(); - - var resolver = new AudioBookListResolver(namingOptions); + var resolver = new AudioBookListResolver(_namingOptions); var resolverResult = resolver.Resolve(files).ToList(); var result = new MultiItemResolverResult @@ -208,7 +200,7 @@ namespace Emby.Server.Implementations.Library.Resolvers.Audio var firstMedia = resolvedItem.Files[0]; - var libraryItem = new T + var libraryItem = new AudioBook { Path = firstMedia.Path, IsInMixedFolder = isInMixedFolder, @@ -228,12 +220,12 @@ namespace Emby.Server.Implementations.Library.Resolvers.Audio return result; } - private bool ContainsFile(List result, FileSystemMetadata file) + private static bool ContainsFile(IEnumerable result, FileSystemMetadata file) { return result.Any(i => ContainsFile(i, file)); } - private bool ContainsFile(AudioBookInfo result, FileSystemMetadata file) + private static bool ContainsFile(AudioBookInfo result, FileSystemMetadata file) { return result.Files.Any(i => ContainsFile(i, file)) || result.AlternateVersions.Any(i => ContainsFile(i, file)) || @@ -244,10 +236,5 @@ namespace Emby.Server.Implementations.Library.Resolvers.Audio { return string.Equals(result.Path, file.FullName, StringComparison.OrdinalIgnoreCase); } - - private static bool IsIgnored(string filename) - { - return false; - } } } diff --git a/Emby.Server.Implementations/Library/Resolvers/Audio/MusicAlbumResolver.cs b/Emby.Server.Implementations/Library/Resolvers/Audio/MusicAlbumResolver.cs index bf32381ebf..a9819a3645 100644 --- a/Emby.Server.Implementations/Library/Resolvers/Audio/MusicAlbumResolver.cs +++ b/Emby.Server.Implementations/Library/Resolvers/Audio/MusicAlbumResolver.cs @@ -1,9 +1,12 @@ +#nullable disable + using System; using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; using Emby.Naming.Audio; +using Emby.Naming.Common; using MediaBrowser.Controller.Entities.Audio; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Providers; @@ -20,20 +23,17 @@ namespace Emby.Server.Implementations.Library.Resolvers.Audio public class MusicAlbumResolver : ItemResolver { private readonly ILogger _logger; - private readonly IFileSystem _fileSystem; - private readonly ILibraryManager _libraryManager; + private readonly NamingOptions _namingOptions; /// /// Initializes a new instance of the class. /// /// The logger. - /// The file system. - /// The library manager. - public MusicAlbumResolver(ILogger logger, IFileSystem fileSystem, ILibraryManager libraryManager) + /// The naming options. + public MusicAlbumResolver(ILogger logger, NamingOptions namingOptions) { _logger = logger; - _fileSystem = fileSystem; - _libraryManager = libraryManager; + _namingOptions = namingOptions; } /// @@ -80,9 +80,12 @@ namespace Emby.Server.Implementations.Library.Resolvers.Audio /// /// Determine if the supplied file data points to a music album. /// + /// The path to check. + /// The directory service. + /// true if the provided path points to a music album, false otherwise. public bool IsMusicAlbum(string path, IDirectoryService directoryService) { - return ContainsMusic(directoryService.GetFileSystemEntries(path), true, directoryService, _logger, _fileSystem, _libraryManager); + return ContainsMusic(directoryService.GetFileSystemEntries(path), true, directoryService); } /// @@ -96,7 +99,7 @@ namespace Emby.Server.Implementations.Library.Resolvers.Audio if (args.IsDirectory) { // if (args.Parent is MusicArtist) return true; // saves us from testing children twice - if (ContainsMusic(args.FileSystemChildren, true, args.DirectoryService, _logger, _fileSystem, _libraryManager)) + if (ContainsMusic(args.FileSystemChildren, true, args.DirectoryService)) { return true; } @@ -111,13 +114,10 @@ namespace Emby.Server.Implementations.Library.Resolvers.Audio private bool ContainsMusic( IEnumerable list, bool allowSubfolders, - IDirectoryService directoryService, - ILogger logger, - IFileSystem fileSystem, - ILibraryManager libraryManager) + IDirectoryService directoryService) { // check for audio files before digging down into directories - var foundAudioFile = list.Any(fileSystemInfo => !fileSystemInfo.IsDirectory && libraryManager.IsAudioFile(fileSystemInfo.FullName)); + var foundAudioFile = list.Any(fileSystemInfo => !fileSystemInfo.IsDirectory && AudioFileParser.IsAudioFile(fileSystemInfo.FullName, _namingOptions)); if (foundAudioFile) { // at least one audio file exists @@ -132,21 +132,20 @@ namespace Emby.Server.Implementations.Library.Resolvers.Audio var discSubfolderCount = 0; - var namingOptions = ((LibraryManager)_libraryManager).GetNamingOptions(); - var parser = new AlbumParser(namingOptions); + var parser = new AlbumParser(_namingOptions); var directories = list.Where(fileSystemInfo => fileSystemInfo.IsDirectory); var result = Parallel.ForEach(directories, (fileSystemInfo, state) => { var path = fileSystemInfo.FullName; - var hasMusic = ContainsMusic(directoryService.GetFileSystemEntries(path), false, directoryService, logger, fileSystem, libraryManager); + var hasMusic = ContainsMusic(directoryService.GetFileSystemEntries(path), false, directoryService); if (hasMusic) { if (parser.IsMultiPart(path)) { - logger.LogDebug("Found multi-disc folder: " + path); + _logger.LogDebug("Found multi-disc folder: {Path}", path); Interlocked.Increment(ref discSubfolderCount); } else diff --git a/Emby.Server.Implementations/Library/Resolvers/Audio/MusicArtistResolver.cs b/Emby.Server.Implementations/Library/Resolvers/Audio/MusicArtistResolver.cs index 60f82806fb..27e18be42c 100644 --- a/Emby.Server.Implementations/Library/Resolvers/Audio/MusicArtistResolver.cs +++ b/Emby.Server.Implementations/Library/Resolvers/Audio/MusicArtistResolver.cs @@ -1,6 +1,9 @@ +#nullable disable + using System; using System.Linq; using System.Threading.Tasks; +using Emby.Naming.Common; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Entities.Audio; using MediaBrowser.Controller.Library; @@ -17,27 +20,19 @@ namespace Emby.Server.Implementations.Library.Resolvers.Audio public class MusicArtistResolver : ItemResolver { private readonly ILogger _logger; - private readonly IFileSystem _fileSystem; - private readonly ILibraryManager _libraryManager; - private readonly IServerConfigurationManager _config; + private NamingOptions _namingOptions; /// /// Initializes a new instance of the class. /// /// The logger for the created instances. - /// The file system. - /// The library manager. - /// The configuration manager. + /// The naming options. public MusicArtistResolver( ILogger logger, - IFileSystem fileSystem, - ILibraryManager libraryManager, - IServerConfigurationManager config) + NamingOptions namingOptions) { _logger = logger; - _fileSystem = fileSystem; - _libraryManager = libraryManager; - _config = config; + _namingOptions = namingOptions; } /// @@ -87,7 +82,7 @@ namespace Emby.Server.Implementations.Library.Resolvers.Audio var directoryService = args.DirectoryService; - var albumResolver = new MusicAlbumResolver(_logger, _fileSystem, _libraryManager); + var albumResolver = new MusicAlbumResolver(_logger, _namingOptions); // If we contain an album assume we are an artist folder var directories = args.FileSystemChildren.Where(i => i.IsDirectory); diff --git a/Emby.Server.Implementations/Library/Resolvers/BaseVideoResolver.cs b/Emby.Server.Implementations/Library/Resolvers/BaseVideoResolver.cs index 6e688693be..0ebf0e5302 100644 --- a/Emby.Server.Implementations/Library/Resolvers/BaseVideoResolver.cs +++ b/Emby.Server.Implementations/Library/Resolvers/BaseVideoResolver.cs @@ -1,8 +1,12 @@ +#nullable disable + #pragma warning disable CS1591 using System; using System.IO; using System.Linq; +using DiscUtils.Udf; +using Emby.Naming.Common; using Emby.Naming.Video; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; @@ -14,17 +18,17 @@ namespace Emby.Server.Implementations.Library.Resolvers /// /// Resolves a Path into a Video or Video subclass. /// - /// + /// The type of item to resolve. public abstract class BaseVideoResolver : MediaBrowser.Controller.Resolvers.ItemResolver where T : Video, new() { - protected readonly ILibraryManager LibraryManager; - - protected BaseVideoResolver(ILibraryManager libraryManager) + protected BaseVideoResolver(NamingOptions namingOptions) { - LibraryManager = libraryManager; + NamingOptions = namingOptions; } + protected NamingOptions NamingOptions { get; } + /// /// Resolves the specified args. /// @@ -45,11 +49,9 @@ namespace Emby.Server.Implementations.Library.Resolvers protected virtual TVideoType ResolveVideo(ItemResolveArgs args, bool parseName) where TVideoType : Video, new() { - var namingOptions = ((LibraryManager)LibraryManager).GetNamingOptions(); + var namingOptions = NamingOptions; // If the path is a file check for a matching extensions - var parser = new VideoResolver(namingOptions); - if (args.IsDirectory) { TVideoType video = null; @@ -64,7 +66,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) { @@ -80,9 +82,9 @@ namespace Emby.Server.Implementations.Library.Resolvers break; } - if (IsBluRayDirectory(child.FullName, filename, args.DirectoryService)) + if (IsBluRayDirectory(filename)) { - videoInfo = parser.ResolveDirectory(args.Path); + videoInfo = VideoResolver.ResolveDirectory(args.Path, namingOptions); if (videoInfo == null) { @@ -100,7 +102,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,14 +132,14 @@ 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) { return null; } - if (LibraryManager.IsVideoFile(args.Path) || videoInfo.IsStub) + if (VideoResolver.IsVideoFile(args.Path, NamingOptions) || videoInfo.IsStub) { var path = args.Path; @@ -165,13 +167,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,14 +195,30 @@ 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; } + else + { + // use disc-utils, both DVDs and BDs use UDF filesystem + using (var videoFileStream = File.Open(video.Path, FileMode.Open, FileAccess.Read)) + { + UdfReader udfReader = new UdfReader(videoFileStream); + if (udfReader.DirectoryExists("VIDEO_TS")) + { + video.IsoType = IsoType.Dvd; + } + else if (udfReader.DirectoryExists("BDMV")) + { + video.IsoType = IsoType.BluRay; + } + } + } } } @@ -250,10 +268,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, NamingOptions); Set3DFormat(video, result.Is3D, result.Format3D); } @@ -261,6 +276,10 @@ namespace Emby.Server.Implementations.Library.Resolvers /// /// Determines whether [is DVD directory] [the specified directory name]. /// + /// The full path of the directory. + /// The name of the directory. + /// The directory service. + /// true if the provided directory is a DVD directory, false otherwise. protected bool IsDvdDirectory(string fullPath, string directoryName, IDirectoryService directoryService) { if (!string.Equals(directoryName, "video_ts", StringComparison.OrdinalIgnoreCase)) @@ -282,25 +301,13 @@ namespace Emby.Server.Implementations.Library.Resolvers } /// - /// Determines whether [is blu ray directory] [the specified directory name]. + /// Determines whether [is bluray directory] [the specified directory name]. /// - protected bool IsBluRayDirectory(string fullPath, string directoryName, IDirectoryService directoryService) + /// The directory name. + /// Whether the directory is a bluray directory. + protected bool IsBluRayDirectory(string directoryName) { - if (!string.Equals(directoryName, "bdmv", StringComparison.OrdinalIgnoreCase)) - { - return false; - } - - return true; - // var blurayExtensions = new[] - //{ - // ".mts", - // ".m2ts", - // ".bdmv", - // ".mpls" - //}; - - // return directoryService.GetFiles(fullPath).Any(i => blurayExtensions.Contains(i.Extension ?? string.Empty, StringComparer.OrdinalIgnoreCase)); + return string.Equals(directoryName, "bdmv", StringComparison.OrdinalIgnoreCase); } } } diff --git a/Emby.Server.Implementations/Library/Resolvers/Books/BookResolver.cs b/Emby.Server.Implementations/Library/Resolvers/Books/BookResolver.cs index 0525c7e307..e685c87f1a 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; @@ -47,13 +49,12 @@ namespace Emby.Server.Implementations.Library.Resolvers.Books { var bookFiles = args.FileSystemChildren.Where(f => { - var fileExtension = Path.GetExtension(f.FullName) ?? - string.Empty; + var fileExtension = Path.GetExtension(f.FullName) + ?? string.Empty; return _validExtensions.Contains( fileExtension, - StringComparer - .OrdinalIgnoreCase); + StringComparer.OrdinalIgnoreCase); }).ToList(); // Don't return a Book if there is more (or less) than one document in the directory diff --git a/Emby.Server.Implementations/Library/Resolvers/FolderResolver.cs b/Emby.Server.Implementations/Library/Resolvers/FolderResolver.cs index 7dbce7a6ef..db7703cd60 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; @@ -7,7 +9,7 @@ namespace Emby.Server.Implementations.Library.Resolvers /// /// Class FolderResolver. /// - public class FolderResolver : FolderResolver + public class FolderResolver : GenericFolderResolver { /// /// Gets the priority. @@ -30,24 +32,4 @@ namespace Emby.Server.Implementations.Library.Resolvers return null; } } - - /// - /// Class FolderResolver. - /// - /// The type of the T item type. - public abstract class FolderResolver : ItemResolver - where TItemType : Folder, new() - { - /// - /// Sets the initial item values. - /// - /// The item. - /// The args. - protected override void SetInitialItemValues(TItemType item, ItemResolveArgs args) - { - base.SetInitialItemValues(item, args); - - item.IsRoot = args.Parent == null; - } - } } diff --git a/Emby.Server.Implementations/Library/Resolvers/GenericFolderResolver.cs b/Emby.Server.Implementations/Library/Resolvers/GenericFolderResolver.cs new file mode 100644 index 0000000000..f109a5e9a2 --- /dev/null +++ b/Emby.Server.Implementations/Library/Resolvers/GenericFolderResolver.cs @@ -0,0 +1,27 @@ +#nullable disable + +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Library; + +namespace Emby.Server.Implementations.Library.Resolvers +{ + /// + /// Class FolderResolver. + /// + /// The type of the T item type. + public abstract class GenericFolderResolver : ItemResolver + where TItemType : Folder, new() + { + /// + /// Sets the initial item values. + /// + /// The item. + /// The args. + protected override void SetInitialItemValues(TItemType item, ItemResolveArgs args) + { + base.SetInitialItemValues(item, args); + + item.IsRoot = args.Parent == null; + } + } +} diff --git a/Emby.Server.Implementations/Library/Resolvers/VideoResolver.cs b/Emby.Server.Implementations/Library/Resolvers/GenericVideoResolver.cs similarity index 64% rename from Emby.Server.Implementations/Library/Resolvers/VideoResolver.cs rename to Emby.Server.Implementations/Library/Resolvers/GenericVideoResolver.cs index 62268fce90..72341d9dbd 100644 --- a/Emby.Server.Implementations/Library/Resolvers/VideoResolver.cs +++ b/Emby.Server.Implementations/Library/Resolvers/GenericVideoResolver.cs @@ -1,15 +1,17 @@ +#nullable disable + #pragma warning disable CS1591 +using Emby.Naming.Common; using MediaBrowser.Controller.Entities; -using MediaBrowser.Controller.Library; namespace Emby.Server.Implementations.Library.Resolvers { public class GenericVideoResolver : BaseVideoResolver where T : Video, new() { - public GenericVideoResolver(ILibraryManager libraryManager) - : base(libraryManager) + public GenericVideoResolver(NamingOptions namingOptions) + : base(namingOptions) { } } diff --git a/Emby.Server.Implementations/Library/Resolvers/ItemResolver.cs b/Emby.Server.Implementations/Library/Resolvers/ItemResolver.cs index 9ca76095b2..3f29ab191f 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; @@ -7,10 +9,16 @@ namespace Emby.Server.Implementations.Library.Resolvers /// /// Class ItemResolver. /// - /// + /// The type of BaseItem. public abstract class ItemResolver : IItemResolver where T : BaseItem, new() { + /// + /// Gets the priority. + /// + /// The priority. + public virtual ResolverPriority Priority => ResolverPriority.First; + /// /// Resolves the specified args. /// @@ -21,12 +29,6 @@ namespace Emby.Server.Implementations.Library.Resolvers return null; } - /// - /// Gets the priority. - /// - /// The priority. - public virtual ResolverPriority Priority => ResolverPriority.First; - /// /// Sets initial values on the newly resolved item. /// diff --git a/Emby.Server.Implementations/Library/Resolvers/Movies/BoxSetResolver.cs b/Emby.Server.Implementations/Library/Resolvers/Movies/BoxSetResolver.cs index 295e9e120b..e7abe1e6da 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; @@ -10,7 +12,7 @@ namespace Emby.Server.Implementations.Library.Resolvers.Movies /// /// Class BoxSetResolver. /// - public class BoxSetResolver : FolderResolver + public class BoxSetResolver : GenericFolderResolver { /// /// Resolves the specified args. diff --git a/Emby.Server.Implementations/Library/Resolvers/Movies/MovieResolver.cs b/Emby.Server.Implementations/Library/Resolvers/Movies/MovieResolver.cs index 714bc3a84d..732be0fe5c 100644 --- a/Emby.Server.Implementations/Library/Resolvers/Movies/MovieResolver.cs +++ b/Emby.Server.Implementations/Library/Resolvers/Movies/MovieResolver.cs @@ -1,9 +1,13 @@ +#nullable disable + using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Text.RegularExpressions; +using Emby.Naming.Common; using Emby.Naming.Video; +using Jellyfin.Extensions; using MediaBrowser.Controller.Drawing; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.Movies; @@ -21,6 +25,9 @@ namespace Emby.Server.Implementations.Library.Resolvers.Movies /// public class MovieResolver : BaseVideoResolver public class EpisodeNfoSaver : BaseNfoSaver { - private readonly CultureInfo _usCulture = new CultureInfo("en-US"); - /// /// Initializes a new instance of the class. /// @@ -60,17 +58,17 @@ namespace MediaBrowser.XbmcMetadata.Savers if (episode.IndexNumber.HasValue) { - writer.WriteElementString("episode", episode.IndexNumber.Value.ToString(_usCulture)); + writer.WriteElementString("episode", episode.IndexNumber.Value.ToString(CultureInfo.InvariantCulture)); } if (episode.IndexNumberEnd.HasValue) { - writer.WriteElementString("episodenumberend", episode.IndexNumberEnd.Value.ToString(_usCulture)); + writer.WriteElementString("episodenumberend", episode.IndexNumberEnd.Value.ToString(CultureInfo.InvariantCulture)); } if (episode.ParentIndexNumber.HasValue) { - writer.WriteElementString("season", episode.ParentIndexNumber.Value.ToString(_usCulture)); + writer.WriteElementString("season", episode.ParentIndexNumber.Value.ToString(CultureInfo.InvariantCulture)); } if (episode.PremiereDate.HasValue) @@ -84,28 +82,28 @@ namespace MediaBrowser.XbmcMetadata.Savers { if (episode.AirsAfterSeasonNumber.HasValue && episode.AirsAfterSeasonNumber.Value != -1) { - writer.WriteElementString("airsafter_season", episode.AirsAfterSeasonNumber.Value.ToString(_usCulture)); + writer.WriteElementString("airsafter_season", episode.AirsAfterSeasonNumber.Value.ToString(CultureInfo.InvariantCulture)); } if (episode.AirsBeforeEpisodeNumber.HasValue && episode.AirsBeforeEpisodeNumber.Value != -1) { - writer.WriteElementString("airsbefore_episode", episode.AirsBeforeEpisodeNumber.Value.ToString(_usCulture)); + writer.WriteElementString("airsbefore_episode", episode.AirsBeforeEpisodeNumber.Value.ToString(CultureInfo.InvariantCulture)); } if (episode.AirsBeforeSeasonNumber.HasValue && episode.AirsBeforeSeasonNumber.Value != -1) { - writer.WriteElementString("airsbefore_season", episode.AirsBeforeSeasonNumber.Value.ToString(_usCulture)); + writer.WriteElementString("airsbefore_season", episode.AirsBeforeSeasonNumber.Value.ToString(CultureInfo.InvariantCulture)); } if (episode.AirsBeforeEpisodeNumber.HasValue && episode.AirsBeforeEpisodeNumber.Value != -1) { - writer.WriteElementString("displayepisode", episode.AirsBeforeEpisodeNumber.Value.ToString(_usCulture)); + writer.WriteElementString("displayepisode", episode.AirsBeforeEpisodeNumber.Value.ToString(CultureInfo.InvariantCulture)); } var specialSeason = episode.AiredSeasonNumber; if (specialSeason.HasValue && specialSeason.Value != -1) { - writer.WriteElementString("displayseason", specialSeason.Value.ToString(_usCulture)); + writer.WriteElementString("displayseason", specialSeason.Value.ToString(CultureInfo.InvariantCulture)); } } } diff --git a/MediaBrowser.XbmcMetadata/Savers/MovieNfoSaver.cs b/MediaBrowser.XbmcMetadata/Savers/MovieNfoSaver.cs index 412e8031b2..21e7e2335b 100644 --- a/MediaBrowser.XbmcMetadata/Savers/MovieNfoSaver.cs +++ b/MediaBrowser.XbmcMetadata/Savers/MovieNfoSaver.cs @@ -82,7 +82,7 @@ namespace MediaBrowser.XbmcMetadata.Savers } // Check parent for null to avoid running this against things like video backdrops - if (item is Video video && !(item is Episode) && !video.ExtraType.HasValue) + if (item is Video video && item is not Episode && !video.ExtraType.HasValue) { return updateType >= MinimumUpdateType; } diff --git a/MediaBrowser.XbmcMetadata/Savers/SeasonNfoSaver.cs b/MediaBrowser.XbmcMetadata/Savers/SeasonNfoSaver.cs index b9d73ba822..e975506308 100644 --- a/MediaBrowser.XbmcMetadata/Savers/SeasonNfoSaver.cs +++ b/MediaBrowser.XbmcMetadata/Savers/SeasonNfoSaver.cs @@ -52,7 +52,7 @@ namespace MediaBrowser.XbmcMetadata.Savers return false; } - if (!(item is Season)) + if (item is not Season) { return false; } diff --git a/README.md b/README.md index 6859a8a76f..6653bff116 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,9 @@ Master Commits RSS Feed + +Total LGTM alerts +

--- @@ -68,6 +71,8 @@ Check out our Weblate instance to h Detailed Translation Status +--- + ## 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. @@ -78,7 +83,7 @@ These instructions will help you get set up with a local development environment ### Prerequisites -Before the project can be built, you must first install the [.NET 5.0 SDK](https://dotnet.microsoft.com/download) on your system. +Before the project can be built, you must first install the [.NET 6.0 SDK](https://dotnet.microsoft.com/download/dotnet) on your system. Instructions to run this project from the command line are included here, but you will also need to install an IDE if you want to debug the server while it is running. Any IDE that supports .NET 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). @@ -162,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. + +--- +

+This project is supported by: +
+
+DigitalOcean +   +JetBrains logo +

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 ///
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 7d6a471f95..5d7da4124e 100644 --- a/RSSDP/DisposableManagedObjectBase.cs +++ b/RSSDP/DisposableManagedObjectBase.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Globalization; using System.Text; namespace Rssdp.Infrastructure @@ -45,11 +46,11 @@ namespace Rssdp.Infrastructure const string ArgFormat = "{0}: {1}\r\n"; - builder.AppendFormat("{0}\r\n", header); + builder.AppendFormat(CultureInfo.InvariantCulture, "{0}\r\n", header); foreach (var pair in values) { - builder.AppendFormat(ArgFormat, pair.Key, pair.Value); + builder.AppendFormat(CultureInfo.InvariantCulture, ArgFormat, pair.Key, pair.Value); } builder.Append("\r\n"); diff --git a/RSSDP/HttpParserBase.cs b/RSSDP/HttpParserBase.cs index c56249523d..6b6c13d996 100644 --- a/RSSDP/HttpParserBase.cs +++ b/RSSDP/HttpParserBase.cs @@ -82,7 +82,7 @@ namespace Rssdp.Infrastructure throw new ArgumentNullException(nameof(versionData)); } - var versionSeparatorIndex = versionData.IndexOf('/'); + var versionSeparatorIndex = versionData.IndexOf('/', StringComparison.Ordinal); if (versionSeparatorIndex <= 0 || versionSeparatorIndex == versionData.Length) { throw new ArgumentException("request header line is invalid. Http Version not supplied or incorrect format.", nameof(versionData)); @@ -101,7 +101,7 @@ namespace Rssdp.Infrastructure { // Header format is // name: value - var headerKeySeparatorIndex = line.IndexOf(":", StringComparison.OrdinalIgnoreCase); + var headerKeySeparatorIndex = line.IndexOf(':', StringComparison.Ordinal); var headerName = line.Substring(0, headerKeySeparatorIndex).Trim(); var headerValue = line.Substring(headerKeySeparatorIndex + 1).Trim(); @@ -172,7 +172,7 @@ namespace Rssdp.Infrastructure else { var segments = headerValue.Split(SeparatorCharacters); - if (headerValue.Contains('"')) + if (headerValue.Contains('"', StringComparison.Ordinal)) { for (int segmentIndex = 0; segmentIndex < segments.Length; segmentIndex++) { diff --git a/RSSDP/RSSDP.csproj b/RSSDP/RSSDP.csproj index c64ee9389d..77130983b5 100644 --- a/RSSDP/RSSDP.csproj +++ b/RSSDP/RSSDP.csproj @@ -11,9 +11,11 @@ - net5.0 + net6.0 false - true + AllDisabledByDefault + disable + CA2016 diff --git a/RSSDP/SsdpCommunicationsServer.cs b/RSSDP/SsdpCommunicationsServer.cs index 8f1f0fa613..58dabc628b 100644 --- a/RSSDP/SsdpCommunicationsServer.cs +++ b/RSSDP/SsdpCommunicationsServer.cs @@ -42,7 +42,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 +68,7 @@ namespace Rssdp.Infrastructure INetworkManager networkManager, ILogger logger, bool enableMultiSocketBinding) : this(socketFactory, 0, SsdpConstants.SsdpDefaultMulticastTimeToLive, networkManager, logger, enableMultiSocketBinding) { - + } /// @@ -358,7 +358,7 @@ namespace Rssdp.Infrastructure { // Not support IPv6 right now continue; - } + } try { @@ -395,7 +395,7 @@ namespace Rssdp.Infrastructure // Strange cannot convert compiler error here if I don't explicitly // assign or cast to Action first. Assignment is easier to read, // so went with that. - ProcessMessage(System.Text.UTF8Encoding.UTF8.GetString(result.Buffer, 0, result.ReceivedBytes), result.RemoteEndPoint, result.LocalIPAddress); + ProcessMessage(UTF8Encoding.UTF8.GetString(result.Buffer, 0, result.ReceivedBytes), result.RemoteEndPoint, result.LocalIPAddress); } } catch (ObjectDisposedException) @@ -415,10 +415,7 @@ namespace Rssdp.Infrastructure { lock (_SendSocketSynchroniser) { - if (_sendSockets == null) - { - _sendSockets = CreateSocketAndListenForResponsesAsync(); - } + _sendSockets ??= CreateSocketAndListenForResponsesAsync(); } } } diff --git a/RSSDP/SsdpDevice.cs b/RSSDP/SsdpDevice.cs index 4005d836d9..c826830f1d 100644 --- a/RSSDP/SsdpDevice.cs +++ b/RSSDP/SsdpDevice.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Collections.ObjectModel; +using System.Globalization; using Rssdp.Infrastructure; namespace Rssdp @@ -134,11 +135,13 @@ namespace Rssdp { get { - return String.Format("urn:{0}:{3}:{1}:{2}", - this.DeviceTypeNamespace ?? String.Empty, - this.DeviceType ?? String.Empty, - this.DeviceVersion, - this.DeviceClass ?? "device"); + return String.Format( + CultureInfo.InvariantCulture, + "urn:{0}:{3}:{1}:{2}", + this.DeviceTypeNamespace ?? String.Empty, + this.DeviceType ?? String.Empty, + this.DeviceVersion, + this.DeviceClass ?? "device"); } } diff --git a/RSSDP/SsdpDeviceLocator.cs b/RSSDP/SsdpDeviceLocator.cs index 0cdc5ce3db..3a52b2a3e6 100644 --- a/RSSDP/SsdpDeviceLocator.cs +++ b/RSSDP/SsdpDeviceLocator.cs @@ -211,7 +211,7 @@ namespace Rssdp.Infrastructure /// Raises the event. /// /// - protected virtual void OnDeviceAvailable(DiscoveredSsdpDevice device, bool isNewDevice, IPAddress localIpAddress) + protected virtual void OnDeviceAvailable(DiscoveredSsdpDevice device, bool isNewDevice, IPAddress IpAddress) { if (this.IsDisposed) { @@ -223,7 +223,7 @@ namespace Rssdp.Infrastructure { handlers(this, new DeviceAvailableEventArgs(device, isNewDevice) { - LocalIpAddress = localIpAddress + RemoteIpAddress = IpAddress }); } } @@ -289,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) @@ -307,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) @@ -350,7 +350,7 @@ 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) { @@ -370,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) { @@ -384,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) { @@ -392,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) @@ -407,7 +407,7 @@ namespace Rssdp.Infrastructure ResponseHeaders = message.Headers }; - AddOrUpdateDiscoveredDevice(device, localIpAddress); + AddOrUpdateDiscoveredDevice(device, IpAddress); } } @@ -513,7 +513,7 @@ namespace Rssdp.Infrastructure return TimeSpan.Zero; } - return (TimeSpan)(headerValue.MaxAge ?? headerValue.SharedMaxAge ?? TimeSpan.Zero); + return headerValue.MaxAge ?? headerValue.SharedMaxAge ?? TimeSpan.Zero; } private void RemoveExpiredDevicesFromCache() @@ -628,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 c9e795d565..a7767b3c04 100644 --- a/RSSDP/SsdpDevicePublisher.cs +++ b/RSSDP/SsdpDevicePublisher.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Collections.ObjectModel; +using System.Globalization; using System.Linq; using System.Net; using System.Threading; @@ -14,8 +15,6 @@ namespace Rssdp.Infrastructure ///
public class SsdpDevicePublisher : DisposableManagedObjectBase, ISsdpDevicePublisher { - private readonly INetworkManager _networkManager; - private ISsdpCommunicationsServer _CommsServer; private string _OSName; private string _OSVersion; @@ -37,19 +36,17 @@ namespace Rssdp.Infrastructure /// /// Default constructor. /// - public SsdpDevicePublisher(ISsdpCommunicationsServer communicationsServer, INetworkManager networkManager, - string osName, string osVersion, bool sendOnlyMatchedHost) + public SsdpDevicePublisher( + ISsdpCommunicationsServer communicationsServer, + string osName, + string osVersion, + bool sendOnlyMatchedHost) { if (communicationsServer == null) { throw new ArgumentNullException(nameof(communicationsServer)); } - if (networkManager == null) - { - throw new ArgumentNullException(nameof(networkManager)); - } - if (osName == null) { throw new ArgumentNullException(nameof(osName)); @@ -76,7 +73,6 @@ namespace Rssdp.Infrastructure _RecentSearchRequests = new Dictionary(StringComparer.OrdinalIgnoreCase); _Random = new Random(); - _networkManager = networkManager; _CommsServer = communicationsServer; _CommsServer.RequestReceived += CommsServer_RequestReceived; _OSName = osName; @@ -233,7 +229,7 @@ namespace Rssdp.Infrastructure { if (String.IsNullOrEmpty(searchTarget)) { - WriteTrace(String.Format("Invalid search request received From {0}, Target is null/empty.", remoteEndPoint.ToString())); + WriteTrace(String.Format(CultureInfo.InvariantCulture, "Invalid search request received From {0}, Target is null/empty.", remoteEndPoint.ToString())); return; } @@ -340,7 +336,7 @@ namespace Rssdp.Infrastructure private string GetUsn(string udn, string fullDeviceType) { - return String.Format("{0}::{1}", udn, fullDeviceType); + return String.Format(CultureInfo.InvariantCulture, "{0}::{1}", udn, fullDeviceType); } private async void SendSearchResponse( @@ -363,7 +359,7 @@ namespace Rssdp.Infrastructure values["DATE"] = DateTime.UtcNow.ToString("r"); values["CACHE-CONTROL"] = "max-age = " + rootDevice.CacheLifetime.TotalSeconds; values["ST"] = searchTarget; - values["SERVER"] = string.Format("{0}/{1} UPnP/1.0 RSSDP/{2}", _OSName, _OSVersion, ServerVersion); + values["SERVER"] = string.Format(CultureInfo.InvariantCulture, "{0}/{1} UPnP/1.0 RSSDP/{2}", _OSName, _OSVersion, ServerVersion); values["USN"] = uniqueServiceName; values["LOCATION"] = rootDevice.Location.ToString(); @@ -497,7 +493,7 @@ namespace Rssdp.Infrastructure values["DATE"] = DateTime.UtcNow.ToString("r"); values["CACHE-CONTROL"] = "max-age = " + rootDevice.CacheLifetime.TotalSeconds; values["LOCATION"] = rootDevice.Location.ToString(); - values["SERVER"] = string.Format("{0}/{1} UPnP/1.0 RSSDP/{2}", _OSName, _OSVersion, ServerVersion); + values["SERVER"] = string.Format(CultureInfo.InvariantCulture, "{0}/{1} UPnP/1.0 RSSDP/{2}", _OSName, _OSVersion, ServerVersion); values["NTS"] = "ssdp:alive"; values["NT"] = notificationType; values["USN"] = uniqueServiceName; @@ -522,7 +518,7 @@ namespace Rssdp.Infrastructure } tasks.Add(SendByeByeNotification(device, device.Udn, device.Udn, cancellationToken)); - tasks.Add(SendByeByeNotification(device, String.Format("urn:{0}", device.FullDeviceType), GetUsn(device.Udn, device.FullDeviceType), cancellationToken)); + tasks.Add(SendByeByeNotification(device, String.Format(CultureInfo.InvariantCulture, "urn:{0}", device.FullDeviceType), GetUsn(device.Udn, device.FullDeviceType), cancellationToken)); foreach (var childDevice in device.Devices) { @@ -542,7 +538,7 @@ namespace Rssdp.Infrastructure // If needed later for non-server devices, these headers will need to be dynamic values["HOST"] = "239.255.255.250:1900"; values["DATE"] = DateTime.UtcNow.ToString("r"); - values["SERVER"] = string.Format("{0}/{1} UPnP/1.0 RSSDP/{2}", _OSName, _OSVersion, ServerVersion); + values["SERVER"] = string.Format(CultureInfo.InvariantCulture, "{0}/{1} UPnP/1.0 RSSDP/{2}", _OSName, _OSVersion, ServerVersion); values["NTS"] = "ssdp:byebye"; values["NT"] = notificationType; values["USN"] = uniqueServiceName; @@ -550,7 +546,7 @@ namespace Rssdp.Infrastructure var message = BuildMessage(header, values); var sendCount = IsDisposed ? 1 : 3; - WriteTrace(String.Format("Sent byebye notification"), device); + WriteTrace(String.Format(CultureInfo.InvariantCulture, "Sent byebye notification"), device); return _CommsServer.SendMulticastMessage(message, sendCount, _sendOnlyMatchedHost ? device.ToRootDevice().Address : null, cancellationToken); } 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 9599f85dbd..0000000000 --- a/apiclient/templates/typescript/axios/generate.sh +++ /dev/null @@ -1,11 +0,0 @@ -#!/bin/bash - -artifactsDirectory="${1}" - -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" 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/bump_version b/bump_version index 8012ec07f1..f615606e2d 100755 --- a/bump_version +++ b/bump_version @@ -21,7 +21,14 @@ fi shared_version_file="./SharedVersion.cs" build_file="./build.yaml" # csproj files for nuget packages -jellyfin_subprojects=( MediaBrowser.Common/MediaBrowser.Common.csproj Jellyfin.Data/Jellyfin.Data.csproj MediaBrowser.Controller/MediaBrowser.Controller.csproj MediaBrowser.Model/MediaBrowser.Model.csproj Emby.Naming/Emby.Naming.csproj ) +jellyfin_subprojects=( + MediaBrowser.Common/MediaBrowser.Common.csproj + Jellyfin.Data/Jellyfin.Data.csproj + MediaBrowser.Controller/MediaBrowser.Controller.csproj + MediaBrowser.Model/MediaBrowser.Model.csproj + Emby.Naming/Emby.Naming.csproj + src/Jellyfin.Extensions/Jellyfin.Extensions.csproj +) new_version="$1" diff --git a/debian/bin/restart.sh b/debian/bin/restart.sh index 34fce06708..4847b918be 100755 --- a/debian/bin/restart.sh +++ b/debian/bin/restart.sh @@ -11,23 +11,43 @@ # # This script is used by the Debian/Ubuntu/Fedora/CentOS packages. -get_service_command() { - for command in systemctl service; do - if which $command &>/dev/null; then - echo $command && return +# This is the Right Way(tm) to check if we are booted with +# systemd, according to sd_booted(3) +if [ -d /run/systemd/system ]; then + cmd=systemctl +else + # Everything else is really hard to figure out, so we just use + # service(8) if it's available - that works with most init + # systems/distributions I know of, including FreeBSD + if type service >/dev/null 2>&1; then + cmd=service + else + # If even service(8) isn't available, we just try /etc/init.d + # and hope for the best + if [ -d /etc/init.d ]; then + cmd=sysv + else + echo "Unable to detect a way to restart Jellyfin; bailing out" 1>&2 + echo "Please report this bug to https://github.com/jellyfin/jellyfin/issues" 1>&2 + exit 1 fi - done - echo "sysv" -} + fi +fi + +if type sudo >/dev/null 2>&1; then + sudo_command=sudo +else + sudo_command= +fi -cmd="$( get_service_command )" echo "Detected service control platform '$cmd'; using it to restart Jellyfin..." case $cmd in 'systemctl') - echo "sleep 0.5; /usr/bin/sudo $( which systemctl ) start jellyfin" | at now + # Without systemd-run here, `jellyfin.service`'s shutdown terminates this process too + $sudo_command systemd-run systemctl restart jellyfin ;; 'service') - echo "sleep 0.5; /usr/bin/sudo $( which service ) jellyfin start" | at now + echo "sleep 0.5; $sudo_command service jellyfin start" | at now ;; 'sysv') echo "sleep 0.5; /usr/bin/sudo /etc/init.d/jellyfin start" | at now diff --git a/debian/conf/jellyfin b/debian/conf/jellyfin index 9ebaf2bd88..ab8d5d1d4d 100644 --- a/debian/conf/jellyfin +++ b/debian/conf/jellyfin @@ -33,6 +33,9 @@ JELLYFIN_FFMPEG_OPT="--ffmpeg=/usr/lib/jellyfin-ffmpeg/ffmpeg" # [OPTIONAL] run Jellyfin without the web app #JELLYFIN_NOWEBAPP_OPT="--nowebclient" +# Space to add additional command line options to jellyfin (for help see ~$ jellyfin --help) +JELLYFIN_ADDITIONAL_OPTS="" + # [OPTIONAL] run Jellyfin with ASP.NET Server Garbage Collection (uses more RAM and less CPU than Workstation GC) # 0 = Workstation # 1 = Server @@ -45,4 +48,4 @@ JELLYFIN_FFMPEG_OPT="--ffmpeg=/usr/lib/jellyfin-ffmpeg/ffmpeg" # Application username JELLYFIN_USER="jellyfin" # Full application command -JELLYFIN_ARGS="$JELLYFIN_WEB_OPT $JELLYFIN_RESTART_OPT $JELLYFIN_FFMPEG_OPT $JELLYFIN_SERVICE_OPT $JELLYFIN_NOWEBAPP_OPT" +JELLYFIN_ARGS="$JELLYFIN_WEB_OPT $JELLYFIN_RESTART_OPT $JELLYFIN_FFMPEG_OPT $JELLYFIN_SERVICE_OPT $JELLYFIN_NOWEBAPP_OPT $JELLFIN_ADDITIONAL_OPTS" diff --git a/debian/conf/jellyfin-sudoers b/debian/conf/jellyfin-sudoers index b481ba4ad4..f84e7454ff 100644 --- a/debian/conf/jellyfin-sudoers +++ b/debian/conf/jellyfin-sudoers @@ -2,9 +2,9 @@ Cmnd_Alias RESTARTSERVER_SYSV = /sbin/service jellyfin restart, /usr/sbin/service jellyfin restart Cmnd_Alias STARTSERVER_SYSV = /sbin/service jellyfin start, /usr/sbin/service jellyfin start Cmnd_Alias STOPSERVER_SYSV = /sbin/service jellyfin stop, /usr/sbin/service jellyfin stop -Cmnd_Alias RESTARTSERVER_SYSTEMD = /usr/bin/systemctl restart jellyfin, /bin/systemctl restart jellyfin -Cmnd_Alias STARTSERVER_SYSTEMD = /usr/bin/systemctl start jellyfin, /bin/systemctl start jellyfin -Cmnd_Alias STOPSERVER_SYSTEMD = /usr/bin/systemctl stop jellyfin, /bin/systemctl stop jellyfin +Cmnd_Alias RESTARTSERVER_SYSTEMD = /usr/bin/systemd-run systemctl restart jellyfin +Cmnd_Alias STARTSERVER_SYSTEMD = /usr/bin/systemd-run systemctl start jellyfin +Cmnd_Alias STOPSERVER_SYSTEMD = /usr/bin/systemd-run systemctl stop jellyfin Cmnd_Alias RESTARTSERVER_INITD = /etc/init.d/jellyfin restart Cmnd_Alias STARTSERVER_INITD = /etc/init.d/jellyfin start Cmnd_Alias STOPSERVER_INITD = /etc/init.d/jellyfin stop diff --git a/debian/control b/debian/control index 9675d36ca6..da9aa94d4d 100644 --- a/debian/control +++ b/debian/control @@ -3,7 +3,7 @@ Section: misc Priority: optional Maintainer: Jellyfin Team Build-Depends: debhelper (>= 9), - dotnet-sdk-5.0, + dotnet-sdk-6.0, libc6-dev, libcurl4-openssl-dev, libfontconfig1-dev, @@ -23,6 +23,6 @@ Depends: at, libfontconfig1, libfreetype6, libssl1.1 -Recommends: jellyfin-web +Recommends: jellyfin-web, sudo Description: Jellyfin is the Free Software Media System. This package provides the Jellyfin server backend and API. diff --git a/debian/jellyfin.service b/debian/jellyfin.service index f1a8f4652c..e215a85362 100644 --- a/debian/jellyfin.service +++ b/debian/jellyfin.service @@ -1,14 +1,36 @@ [Unit] Description = Jellyfin Media Server -After = network.target +After = network-online.target [Service] Type = simple EnvironmentFile = /etc/default/jellyfin User = jellyfin -ExecStart = /usr/bin/jellyfin ${JELLYFIN_WEB_OPT} ${JELLYFIN_RESTART_OPT} ${JELLYFIN_FFMPEG_OPT} ${JELLYFIN_SERVICE_OPT} ${JELLYFIN_NOWEBAPP_OPT} +ExecStart = /usr/bin/jellyfin ${JELLYFIN_WEB_OPT} ${JELLYFIN_RESTART_OPT} ${JELLYFIN_FFMPEG_OPT} ${JELLYFIN_SERVICE_OPT} ${JELLYFIN_NOWEBAPP_OPT} ${JELLYFIN_ADDITIONAL_OPTS} Restart = on-failure TimeoutSec = 15 +NoNewPrivileges=true +SystemCallArchitectures=native +RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 AF_NETLINK +ProtectKernelModules=True +SystemCallFilter=~@clock +SystemCallFilter=~@aio +SystemCallFilter=~@chown +SystemCallFilter=~@cpu-emulation +SystemCallFilter=~@debug +SystemCallFilter=~@keyring +SystemCallFilter=~@memlock +SystemCallFilter=~@module +SystemCallFilter=~@mount +SystemCallFilter=~@obsolete +SystemCallFilter=~@privileged +SystemCallFilter=~@raw-io +SystemCallFilter=~@reboot +SystemCallFilter=~@setuid +SystemCallFilter=~@swap +SystemCallErrorNumber=EPERM + + [Install] WantedBy = multi-user.target 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 01fc1aaac2..3967a165d2 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=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 wget # 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} +RUN wget -q https://download.visualstudio.microsoft.com/download/pr/17b6759f-1af0-41bc-ab12-209ba0377779/e8d02195dbf1434b940e0f05ae086453/dotnet-sdk-6.0.100-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 # 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 ec0321f477..daba0eb7d1 100644 --- a/deployment/Dockerfile.debian.amd64 +++ b/deployment/Dockerfile.debian.amd64 @@ -1,8 +1,7 @@ -FROM debian:10 +FROM mcr.microsoft.com/dotnet/sdk:6.0-bullseye-slim # Docker build arguments ARG SOURCE_DIR=/jellyfin ARG ARTIFACT_DIR=/dist -ARG SDK_VERSION=5.0 # 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/5f0f07ab-cd9a-4498-a9f7-67d90d582180/2a3db6698751e6cbb93ec244cb81cc5f/dotnet-sdk-5.0.202-linux-x64.tar.gz -O dotnet-sdk.tar.gz \ - && mkdir -p dotnet-sdk \ - && tar -xzf dotnet-sdk.tar.gz -C dotnet-sdk \ - && ln -s $( pwd )/dotnet-sdk/dotnet /usr/bin/dotnet +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 8fd5ddb932..db4e7f8178 100644 --- a/deployment/Dockerfile.debian.arm64 +++ b/deployment/Dockerfile.debian.arm64 @@ -1,8 +1,7 @@ -FROM debian:10 +FROM mcr.microsoft.com/dotnet/sdk:6.0-bullseye-slim # Docker build arguments ARG SOURCE_DIR=/jellyfin ARG ARTIFACT_DIR=/dist -ARG SDK_VERSION=5.0 # 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/5f0f07ab-cd9a-4498-a9f7-67d90d582180/2a3db6698751e6cbb93ec244cb81cc5f/dotnet-sdk-5.0.202-linux-x64.tar.gz -O dotnet-sdk.tar.gz \ - && mkdir -p dotnet-sdk \ - && tar -xzf dotnet-sdk.tar.gz -C dotnet-sdk \ - && ln -s $( pwd )/dotnet-sdk/dotnet /usr/bin/dotnet +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 9 \ + && cd cross-gcc-packages-amd64/cross-gcc-9-arm64 \ + && apt-get install -yqq --no-install-recommends \ + gcc-9-source libstdc++-9-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++-9-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 14615d19fb..9b008e7fb0 100644 --- a/deployment/Dockerfile.debian.armhf +++ b/deployment/Dockerfile.debian.armhf @@ -1,8 +1,7 @@ -FROM debian:10 +FROM mcr.microsoft.com/dotnet/sdk:6.0-bullseye-slim # Docker build arguments ARG SOURCE_DIR=/jellyfin ARG ARTIFACT_DIR=/dist -ARG SDK_VERSION=5.0 # 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/5f0f07ab-cd9a-4498-a9f7-67d90d582180/2a3db6698751e6cbb93ec244cb81cc5f/dotnet-sdk-5.0.202-linux-x64.tar.gz -O dotnet-sdk.tar.gz \ - && mkdir -p dotnet-sdk \ - && tar -xzf dotnet-sdk.tar.gz -C dotnet-sdk \ - && ln -s $( pwd )/dotnet-sdk/dotnet /usr/bin/dotnet +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 9 \ + && cd cross-gcc-packages-amd64/cross-gcc-9-armhf \ + && apt-get install -yqq --no-install-recommends\ + gcc-9-source libstdc++-9-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++-9-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 0b1a57014f..b2bd40713d 100644 --- a/deployment/Dockerfile.docker.amd64 +++ b/deployment/Dockerfile.docker.amd64 @@ -1,6 +1,4 @@ -ARG DOTNET_VERSION=5.0 - -FROM mcr.microsoft.com/dotnet/sdk:${DOTNET_VERSION}-buster-slim +FROM mcr.microsoft.com/dotnet/sdk:6.0-bullseye-slim ARG SOURCE_DIR=/src ARG ARTIFACT_DIR=/jellyfin diff --git a/deployment/Dockerfile.docker.arm64 b/deployment/Dockerfile.docker.arm64 index 583f53ca09..fc60f16246 100644 --- a/deployment/Dockerfile.docker.arm64 +++ b/deployment/Dockerfile.docker.arm64 @@ -1,6 +1,4 @@ -ARG DOTNET_VERSION=5.0 - -FROM mcr.microsoft.com/dotnet/sdk:${DOTNET_VERSION}-buster-slim +FROM mcr.microsoft.com/dotnet/sdk:6.0-bullseye-slim ARG SOURCE_DIR=/src ARG ARTIFACT_DIR=/jellyfin diff --git a/deployment/Dockerfile.docker.armhf b/deployment/Dockerfile.docker.armhf index 177c117134..f5cc47d83e 100644 --- a/deployment/Dockerfile.docker.armhf +++ b/deployment/Dockerfile.docker.armhf @@ -1,6 +1,4 @@ -ARG DOTNET_VERSION=5.0 - -FROM mcr.microsoft.com/dotnet/sdk:${DOTNET_VERSION}-buster-slim +FROM mcr.microsoft.com/dotnet/sdk:6.0-bullseye-slim ARG SOURCE_DIR=/src ARG ARTIFACT_DIR=/jellyfin diff --git a/deployment/Dockerfile.fedora.amd64 b/deployment/Dockerfile.fedora.amd64 index 137e56ecf2..bc40a8059e 100644 --- a/deployment/Dockerfile.fedora.amd64 +++ b/deployment/Dockerfile.fedora.amd64 @@ -2,25 +2,28 @@ FROM fedora:33 # Docker build arguments ARG SOURCE_DIR=/jellyfin ARG ARTIFACT_DIR=/dist -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 systemd +RUN dnf update -yq \ + && dnf install -yq @buildsys-build rpmdevtools git dnf-plugins-core libcurl-devel fontconfig-devel freetype-devel openssl-devel glibc-devel libicu-devel systemd wget # Install DotNET SDK -RUN dnf install -y dotnet-sdk-${SDK_VERSION} dotnet-runtime-${SDK_VERSION} +RUN wget -q https://download.visualstudio.microsoft.com/download/pr/17b6759f-1af0-41bc-ab12-209ba0377779/e8d02195dbf1434b940e0f05ae086453/dotnet-sdk-6.0.100-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 + # 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 1f6ca15586..2c7e41cace 100644 --- a/deployment/Dockerfile.linux.amd64 +++ b/deployment/Dockerfile.linux.amd64 @@ -1,8 +1,7 @@ -FROM debian:10 +FROM mcr.microsoft.com/dotnet/sdk:6.0-bullseye-slim # Docker build arguments ARG SOURCE_DIR=/jellyfin ARG ARTIFACT_DIR=/dist -ARG SDK_VERSION=5.0 # 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/5f0f07ab-cd9a-4498-a9f7-67d90d582180/2a3db6698751e6cbb93ec244cb81cc5f/dotnet-sdk-5.0.202-linux-x64.tar.gz -O dotnet-sdk.tar.gz \ - && mkdir -p dotnet-sdk \ - && tar -xzf dotnet-sdk.tar.gz -C dotnet-sdk \ - && ln -s $( pwd )/dotnet-sdk/dotnet /usr/bin/dotnet +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 index 6af5d8baf5..e903cf1d32 100644 --- a/deployment/Dockerfile.linux.amd64-musl +++ b/deployment/Dockerfile.linux.amd64-musl @@ -1,8 +1,7 @@ -FROM debian:10 +FROM mcr.microsoft.com/dotnet/sdk:6.0-bullseye-slim # Docker build arguments ARG SOURCE_DIR=/jellyfin ARG ARTIFACT_DIR=/dist -ARG SDK_VERSION=5.0 # 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/5f0f07ab-cd9a-4498-a9f7-67d90d582180/2a3db6698751e6cbb93ec244cb81cc5f/dotnet-sdk-5.0.202-linux-x64.tar.gz -O dotnet-sdk.tar.gz \ - && mkdir -p dotnet-sdk \ - && tar -xzf dotnet-sdk.tar.gz -C dotnet-sdk \ - && ln -s $( pwd )/dotnet-sdk/dotnet /usr/bin/dotnet +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 diff --git a/deployment/Dockerfile.linux.arm64 b/deployment/Dockerfile.linux.arm64 index 15b59e29d1..0dd3c5e4e8 100644 --- a/deployment/Dockerfile.linux.arm64 +++ b/deployment/Dockerfile.linux.arm64 @@ -1,8 +1,7 @@ -FROM debian:10 +FROM mcr.microsoft.com/dotnet/sdk:6.0-bullseye-slim # Docker build arguments ARG SOURCE_DIR=/jellyfin ARG ARTIFACT_DIR=/dist -ARG SDK_VERSION=5.0 # Docker run environment ENV SOURCE_DIR=/jellyfin ENV ARTIFACT_DIR=/dist @@ -11,15 +10,11 @@ ENV ARCH=arm64 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/5f0f07ab-cd9a-4498-a9f7-67d90d582180/2a3db6698751e6cbb93ec244cb81cc5f/dotnet-sdk-5.0.202-linux-x64.tar.gz -O dotnet-sdk.tar.gz \ - && mkdir -p dotnet-sdk \ - && tar -xzf dotnet-sdk.tar.gz -C dotnet-sdk \ - && ln -s $( pwd )/dotnet-sdk/dotnet /usr/bin/dotnet +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 diff --git a/deployment/Dockerfile.linux.armhf b/deployment/Dockerfile.linux.armhf index 71a0fda215..16a8218e18 100644 --- a/deployment/Dockerfile.linux.armhf +++ b/deployment/Dockerfile.linux.armhf @@ -1,8 +1,7 @@ -FROM debian:10 +FROM mcr.microsoft.com/dotnet/sdk:6.0-bullseye-slim # Docker build arguments ARG SOURCE_DIR=/jellyfin ARG ARTIFACT_DIR=/dist -ARG SDK_VERSION=5.0 # Docker run environment ENV SOURCE_DIR=/jellyfin ENV ARTIFACT_DIR=/dist @@ -11,15 +10,11 @@ ENV ARCH=armhf 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/5f0f07ab-cd9a-4498-a9f7-67d90d582180/2a3db6698751e6cbb93ec244cb81cc5f/dotnet-sdk-5.0.202-linux-x64.tar.gz -O dotnet-sdk.tar.gz \ - && mkdir -p dotnet-sdk \ - && tar -xzf dotnet-sdk.tar.gz -C dotnet-sdk \ - && ln -s $( pwd )/dotnet-sdk/dotnet /usr/bin/dotnet +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 diff --git a/deployment/Dockerfile.macos b/deployment/Dockerfile.macos index 9291bcbb94..699ab2d408 100644 --- a/deployment/Dockerfile.macos +++ b/deployment/Dockerfile.macos @@ -1,8 +1,7 @@ -FROM debian:10 +FROM mcr.microsoft.com/dotnet/sdk:6.0-bullseye-slim # Docker build arguments ARG SOURCE_DIR=/jellyfin ARG ARTIFACT_DIR=/dist -ARG SDK_VERSION=5.0 # 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/5f0f07ab-cd9a-4498-a9f7-67d90d582180/2a3db6698751e6cbb93ec244cb81cc5f/dotnet-sdk-5.0.202-linux-x64.tar.gz -O dotnet-sdk.tar.gz \ - && mkdir -p dotnet-sdk \ - && tar -xzf dotnet-sdk.tar.gz -C dotnet-sdk \ - && ln -s $( pwd )/dotnet-sdk/dotnet /usr/bin/dotnet +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 e98ba74f87..b567d7bcea 100644 --- a/deployment/Dockerfile.portable +++ b/deployment/Dockerfile.portable @@ -1,8 +1,7 @@ -FROM debian:10 +FROM mcr.microsoft.com/dotnet/sdk:6.0-bullseye-slim # Docker build arguments ARG SOURCE_DIR=/jellyfin ARG ARTIFACT_DIR=/dist -ARG SDK_VERSION=5.0 # 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/5f0f07ab-cd9a-4498-a9f7-67d90d582180/2a3db6698751e6cbb93ec244cb81cc5f/dotnet-sdk-5.0.202-linux-x64.tar.gz -O dotnet-sdk.tar.gz \ - && mkdir -p dotnet-sdk \ - && tar -xzf dotnet-sdk.tar.gz -C dotnet-sdk \ - && ln -s $( pwd )/dotnet-sdk/dotnet /usr/bin/dotnet +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 d1fd8818e6..c1b541c596 100644 --- a/deployment/Dockerfile.ubuntu.amd64 +++ b/deployment/Dockerfile.ubuntu.amd64 @@ -2,7 +2,6 @@ FROM ubuntu:bionic # Docker build arguments ARG SOURCE_DIR=/jellyfin ARG ARTIFACT_DIR=/dist -ARG SDK_VERSION=5.0 # Docker run environment ENV SOURCE_DIR=/jellyfin ENV ARTIFACT_DIR=/dist @@ -11,15 +10,17 @@ 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/5f0f07ab-cd9a-4498-a9f7-67d90d582180/2a3db6698751e6cbb93ec244cb81cc5f/dotnet-sdk-5.0.202-linux-x64.tar.gz -O dotnet-sdk.tar.gz \ - && mkdir -p dotnet-sdk \ - && tar -xzf dotnet-sdk.tar.gz -C dotnet-sdk \ - && ln -s $( pwd )/dotnet-sdk/dotnet /usr/bin/dotnet +RUN wget -q https://download.visualstudio.microsoft.com/download/pr/17b6759f-1af0-41bc-ab12-209ba0377779/e8d02195dbf1434b940e0f05ae086453/dotnet-sdk-6.0.100-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 8e79d417cf..6aa98a2890 100644 --- a/deployment/Dockerfile.ubuntu.arm64 +++ b/deployment/Dockerfile.ubuntu.arm64 @@ -2,7 +2,6 @@ FROM ubuntu:bionic # Docker build arguments ARG SOURCE_DIR=/jellyfin ARG ARTIFACT_DIR=/dist -ARG SDK_VERSION=5.0 # Docker run environment ENV SOURCE_DIR=/jellyfin ENV ARTIFACT_DIR=/dist @@ -11,34 +10,40 @@ 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/5f0f07ab-cd9a-4498-a9f7-67d90d582180/2a3db6698751e6cbb93ec244cb81cc5f/dotnet-sdk-5.0.202-linux-x64.tar.gz -O dotnet-sdk.tar.gz \ - && mkdir -p dotnet-sdk \ - && tar -xzf dotnet-sdk.tar.gz -C dotnet-sdk \ - && ln -s $( pwd )/dotnet-sdk/dotnet /usr/bin/dotnet +RUN wget -q https://download.visualstudio.microsoft.com/download/pr/17b6759f-1af0-41bc-ab12-209ba0377779/e8d02195dbf1434b940e0f05ae086453/dotnet-sdk-6.0.100-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 627caa95a8..cc9d8dc797 100644 --- a/deployment/Dockerfile.ubuntu.armhf +++ b/deployment/Dockerfile.ubuntu.armhf @@ -2,7 +2,6 @@ FROM ubuntu:bionic # Docker build arguments ARG SOURCE_DIR=/jellyfin ARG ARTIFACT_DIR=/dist -ARG SDK_VERSION=5.0 # Docker run environment ENV SOURCE_DIR=/jellyfin ENV ARTIFACT_DIR=/dist @@ -11,34 +10,40 @@ 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/5f0f07ab-cd9a-4498-a9f7-67d90d582180/2a3db6698751e6cbb93ec244cb81cc5f/dotnet-sdk-5.0.202-linux-x64.tar.gz -O dotnet-sdk.tar.gz \ - && mkdir -p dotnet-sdk \ - && tar -xzf dotnet-sdk.tar.gz -C dotnet-sdk \ - && ln -s $( pwd )/dotnet-sdk/dotnet /usr/bin/dotnet +RUN wget -q https://download.visualstudio.microsoft.com/download/pr/17b6759f-1af0-41bc-ab12-209ba0377779/e8d02195dbf1434b940e0f05ae086453/dotnet-sdk-6.0.100-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 5723abcae6..b9543a7c91 100644 --- a/deployment/Dockerfile.windows.amd64 +++ b/deployment/Dockerfile.windows.amd64 @@ -1,8 +1,7 @@ -FROM debian:10 +FROM mcr.microsoft.com/dotnet/sdk:6.0-bullseye-slim # Docker build arguments ARG SOURCE_DIR=/jellyfin ARG ARTIFACT_DIR=/dist -ARG SDK_VERSION=5.0 # 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/5f0f07ab-cd9a-4498-a9f7-67d90d582180/2a3db6698751e6cbb93ec244cb81cc5f/dotnet-sdk-5.0.202-linux-x64.tar.gz -O dotnet-sdk.tar.gz \ - && mkdir -p dotnet-sdk \ - && tar -xzf dotnet-sdk.tar.gz -C dotnet-sdk \ - && ln -s $( pwd )/dotnet-sdk/dotnet /usr/bin/dotnet +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.centos.amd64 b/deployment/build.centos.amd64 index 69f0cadcfe..bfdc6e591e 100755 --- a/deployment/build.centos.amd64 +++ b/deployment/build.centos.amd64 @@ -8,6 +8,16 @@ set -o xtrace # Move to source directory pushd ${SOURCE_DIR} +if [[ ${IS_DOCKER} == YES ]]; then + # Remove BuildRequires for dotnet-sdk-6.0, since it's installed manually + pushd fedora + + cp -a jellyfin.spec /tmp/spec.orig + sed -i 's/BuildRequires: dotnet/# BuildRequires: dotnet/' jellyfin.spec + + popd +fi + # Modify changelog to unstable configuration if IS_UNSTABLE if [[ ${IS_UNSTABLE} == 'yes' ]]; then pushd fedora @@ -37,4 +47,13 @@ fi rm -f fedora/jellyfin*.tar.gz +if [[ ${IS_DOCKER} == YES ]]; then + pushd fedora + + cp -a /tmp/spec.orig jellyfin.spec + chown -Rc $(stat -c %u:%g ${ARTIFACT_DIR}) ${ARTIFACT_DIR} + + popd +fi + popd diff --git a/deployment/build.debian.amd64 b/deployment/build.debian.amd64 index 145e28d871..b2bbf9c29e 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-5.0, since it's installed manually + # Remove build-dep for dotnet-sdk-6.0, since it's installed manually cp -a debian/control /tmp/control.orig - sed -i '/dotnet-sdk-5.0,/d' debian/control + sed -i '/dotnet-sdk-6.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 5699133a01..02f84471e6 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-5.0, since it's installed manually + # Remove build-dep for dotnet-sdk-6.0, since it's installed manually cp -a debian/control /tmp/control.orig - sed -i '/dotnet-sdk-5.0,/d' debian/control + sed -i '/dotnet-sdk-6.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 20af2ddfbe..92779cb594 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-5.0, since it's installed manually + # Remove build-dep for dotnet-sdk-6.0, since it's installed manually cp -a debian/control /tmp/control.orig - sed -i '/dotnet-sdk-5.0,/d' debian/control + sed -i '/dotnet-sdk-6.0,/d' debian/control fi # Modify changelog to unstable configuration if IS_UNSTABLE diff --git a/deployment/build.fedora.amd64 b/deployment/build.fedora.amd64 index 2c7bff5068..23c5ed86a2 100755 --- a/deployment/build.fedora.amd64 +++ b/deployment/build.fedora.amd64 @@ -8,6 +8,16 @@ set -o xtrace # Move to source directory pushd ${SOURCE_DIR} +if [[ ${IS_DOCKER} == YES ]]; then + # Remove BuildRequires for dotnet-sdk-6.0, since it's installed manually + pushd fedora + + cp -a jellyfin.spec /tmp/spec.orig + sed -i 's/BuildRequires: dotnet/# BuildRequires: dotnet/' jellyfin.spec + + popd +fi + # Modify changelog to unstable configuration if IS_UNSTABLE if [[ ${IS_UNSTABLE} == 'yes' ]]; then pushd fedora @@ -37,4 +47,13 @@ fi rm -f fedora/jellyfin*.tar.gz +if [[ ${IS_DOCKER} == YES ]]; then + pushd fedora + + cp -a /tmp/spec.orig jellyfin.spec + chown -Rc $(stat -c %u:%g ${ARTIFACT_DIR}) ${ARTIFACT_DIR} + + popd +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 0c29286c02..c36978c9e3 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-5.0, since it's installed manually + # Remove build-dep for dotnet-sdk-6.0, since it's installed manually cp -a debian/control /tmp/control.orig - sed -i '/dotnet-sdk-5.0,/d' debian/control + sed -i '/dotnet-sdk-6.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 65d67f80f7..76d51e321f 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-5.0, since it's installed manually + # Remove build-dep for dotnet-sdk-6.0, since it's installed manually cp -a debian/control /tmp/control.orig - sed -i '/dotnet-sdk-5.0,/d' debian/control + sed -i '/dotnet-sdk-6.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 370370abc1..0ff5ab0662 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-5.0, since it's installed manually + # Remove build-dep for dotnet-sdk-6.0, since it's installed manually cp -a debian/control /tmp/control.orig - sed -i '/dotnet-sdk-5.0,/d' debian/control + sed -i '/dotnet-sdk-6.0,/d' debian/control fi # Modify changelog to unstable configuration if IS_UNSTABLE diff --git a/deployment/unraid/docker-templates/README.md b/deployment/unraid/docker-templates/README.md index 2c268e8b3e..8e401e0095 100644 --- a/deployment/unraid/docker-templates/README.md +++ b/deployment/unraid/docker-templates/README.md @@ -8,7 +8,7 @@ Click on the Docker tab Add the following line under "Template Repositories" -https://github.com/jellyfin/jellyfin/blob/master/deployment/unraid/docker-templates +https://github.com/jellyfin/jellyfin/tree/master/deployment/unraid/docker-templates Click save than click on Add Container and select jellyfin. diff --git a/fedora/jellyfin.service b/fedora/jellyfin.service index bde124a0fa..b91e1a566e 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 928fe590f0..47dee7c13f 100644 --- a/fedora/jellyfin.spec +++ b/fedora/jellyfin.spec @@ -27,7 +27,7 @@ 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-5.0, dotnet-sdk-5.0 +BuildRequires: dotnet-runtime-6.0, dotnet-sdk-6.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, at +Requires: libcurl, fontconfig, freetype, openssl, glibc, libicu, at, sudo %description server The Jellyfin media server backend. diff --git a/fedora/jellyfin.sudoers b/fedora/jellyfin.sudoers index dd245af4b8..57a9e7b671 100644 --- a/fedora/jellyfin.sudoers +++ b/fedora/jellyfin.sudoers @@ -1,8 +1,7 @@ # Allow jellyfin group to start, stop and restart itself -Cmnd_Alias RESTARTSERVER_SYSTEMD = /usr/bin/systemctl restart jellyfin, /bin/systemctl restart jellyfin -Cmnd_Alias STARTSERVER_SYSTEMD = /usr/bin/systemctl start jellyfin, /bin/systemctl start jellyfin -Cmnd_Alias STOPSERVER_SYSTEMD = /usr/bin/systemctl stop jellyfin, /bin/systemctl stop jellyfin - +Cmnd_Alias RESTARTSERVER_SYSTEMD = /usr/bin/systemd-run systemctl restart jellyfin +Cmnd_Alias STARTSERVER_SYSTEMD = /usr/bin/systemd-run systemctl start jellyfin +Cmnd_Alias STOPSERVER_SYSTEMD = /usr/bin/systemd-run systemctl stop jellyfin jellyfin ALL=(ALL) NOPASSWD: RESTARTSERVER_SYSTEMD jellyfin ALL=(ALL) NOPASSWD: STARTSERVER_SYSTEMD diff --git a/fedora/restart.sh b/fedora/restart.sh index 34fce06708..4847b918be 100755 --- a/fedora/restart.sh +++ b/fedora/restart.sh @@ -11,23 +11,43 @@ # # This script is used by the Debian/Ubuntu/Fedora/CentOS packages. -get_service_command() { - for command in systemctl service; do - if which $command &>/dev/null; then - echo $command && return +# This is the Right Way(tm) to check if we are booted with +# systemd, according to sd_booted(3) +if [ -d /run/systemd/system ]; then + cmd=systemctl +else + # Everything else is really hard to figure out, so we just use + # service(8) if it's available - that works with most init + # systems/distributions I know of, including FreeBSD + if type service >/dev/null 2>&1; then + cmd=service + else + # If even service(8) isn't available, we just try /etc/init.d + # and hope for the best + if [ -d /etc/init.d ]; then + cmd=sysv + else + echo "Unable to detect a way to restart Jellyfin; bailing out" 1>&2 + echo "Please report this bug to https://github.com/jellyfin/jellyfin/issues" 1>&2 + exit 1 fi - done - echo "sysv" -} + fi +fi + +if type sudo >/dev/null 2>&1; then + sudo_command=sudo +else + sudo_command= +fi -cmd="$( get_service_command )" echo "Detected service control platform '$cmd'; using it to restart Jellyfin..." case $cmd in 'systemctl') - echo "sleep 0.5; /usr/bin/sudo $( which systemctl ) start jellyfin" | at now + # Without systemd-run here, `jellyfin.service`'s shutdown terminates this process too + $sudo_command systemd-run systemctl restart jellyfin ;; 'service') - echo "sleep 0.5; /usr/bin/sudo $( which service ) jellyfin start" | at now + echo "sleep 0.5; $sudo_command service jellyfin start" | at now ;; 'sysv') echo "sleep 0.5; /usr/bin/sudo /etc/init.d/jellyfin start" | at now 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..6abdb77342 --- /dev/null +++ b/fuzz/Emby.Server.Implementations.Fuzz/Emby.Server.Implementations.Fuzz.csproj @@ -0,0 +1,25 @@ + + + + Exe + net5.0 + + + + + Emby.Server.Implementations.dll + + + + + + + + + + + + + + + diff --git a/fuzz/Emby.Server.Implementations.Fuzz/Program.cs b/fuzz/Emby.Server.Implementations.Fuzz/Program.cs new file mode 100644 index 0000000000..03b2964948 --- /dev/null +++ b/fuzz/Emby.Server.Implementations.Fuzz/Program.cs @@ -0,0 +1,62 @@ +using System; +using AutoFixture; +using AutoFixture.AutoMoq; +using Emby.Server.Implementations.Data; +using Emby.Server.Implementations.Library; +using MediaBrowser.Controller; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Model.Entities; +using Moq; +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; + case "SqliteItemRepository.ItemImageInfoFromValueString": Run(SqliteItemRepository_ItemImageInfoFromValueString); return; + default: throw new ArgumentException($"Unknown fuzzing function: {args[0]}"); + } + } + + private static void Run(Action 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 _); + } + + private static void SqliteItemRepository_ItemImageInfoFromValueString(string data) + { + var sqliteItemRepository = MockSqliteItemRepository(); + sqliteItemRepository.ItemImageInfoFromValueString(data); + } + + private static SqliteItemRepository MockSqliteItemRepository() + { + const string VirtualMetaDataPath = "%MetadataPath%"; + const string MetaDataPath = "/meta/data/path"; + + var appHost = new Mock(); + appHost.Setup(x => x.ExpandVirtualPath(It.IsAny())) + .Returns((string x) => x.Replace(VirtualMetaDataPath, MetaDataPath, StringComparison.Ordinal)); + appHost.Setup(x => x.ReverseVirtualPath(It.IsAny())) + .Returns((string x) => x.Replace(MetaDataPath, VirtualMetaDataPath, StringComparison.Ordinal)); + + IFixture fixture = new Fixture().Customize(new AutoMoqCustomization { ConfigureMembers = true }); + fixture.Inject(appHost); + return fixture.Create(); + } + } +} 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/Testcases/SqliteItemRepository.ItemImageInfoFromValueString/test1.txt b/fuzz/Emby.Server.Implementations.Fuzz/Testcases/SqliteItemRepository.ItemImageInfoFromValueString/test1.txt new file mode 100644 index 0000000000..1b0115882f --- /dev/null +++ b/fuzz/Emby.Server.Implementations.Fuzz/Testcases/SqliteItemRepository.ItemImageInfoFromValueString/test1.txt @@ -0,0 +1 @@ +/mnt/series/Family Guy/Season 1/Family Guy - S01E01-thumb.jpg*637452096478512963*Primary*1920*1080*WjQbtJtSO8nhNZ%L_Io#R/oaS6o}-;adXAoIn7j[%hW9s:WGw[nN 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 @@ + + + + Exe + net5.0 + + + + + jellyfin.dll + + + + + + + + + + + + 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 action) => Fuzzer.OutOfProcess.Run(action); + + private static void UrlDecodeQueryFeature(string data) + { + var dict = new Dictionary + { + { 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/jellyfin.ruleset b/jellyfin.ruleset index 19c0a08b23..7adc35087f 100644 --- a/jellyfin.ruleset +++ b/jellyfin.ruleset @@ -1,13 +1,6 @@ - - - - - - - @@ -16,12 +9,18 @@ + + + + + + @@ -39,20 +38,51 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - @@ -78,7 +108,11 @@ + + + + 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 + /// + /// Alphanumeric . + /// + public class AlphanumericComparator : IComparer { + /// + /// Compares two objects and returns a value indicating whether one is less than, equal to, or greater than the other. + /// + /// The first object to compare. + /// The second object to compare. + /// A signed integer that indicates the relative values of x and y. 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 { /// /// Provides CopyTo extensions methods for . diff --git a/src/Jellyfin.Extensions/DictionaryExtensions.cs b/src/Jellyfin.Extensions/DictionaryExtensions.cs new file mode 100644 index 0000000000..5bb828d016 --- /dev/null +++ b/src/Jellyfin.Extensions/DictionaryExtensions.cs @@ -0,0 +1,64 @@ +using System.Collections.Generic; + +namespace Jellyfin.Extensions +{ + /// + /// Static extensions for the interface. + /// + public static class DictionaryExtensions + { + /// + /// 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. + /// + /// The dictionary. + /// The first checked key. + /// System.String. + public static string? GetFirstNotNullNorWhiteSpaceValue(this IReadOnlyDictionary dictionary, string key1) + { + return dictionary.GetFirstNotNullNorWhiteSpaceValue(key1, string.Empty, string.Empty); + } + + /// + /// 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. + /// + /// The dictionary. + /// The first checked key. + /// The second checked key. + /// System.String. + public static string? GetFirstNotNullNorWhiteSpaceValue(this IReadOnlyDictionary dictionary, string key1, string key2) + { + return dictionary.GetFirstNotNullNorWhiteSpaceValue(key1, key2, string.Empty); + } + + /// + /// 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. + /// + /// The dictionary. + /// The first checked key. + /// The second checked key. + /// The third checked key. + /// System.String. + public static string? GetFirstNotNullNorWhiteSpaceValue(this IReadOnlyDictionary 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 +{ + /// + /// Static extensions for the interface. + /// + public static class EnumerableExtensions + { + /// + /// Determines whether the value is contained in the source collection. + /// + /// An instance of the interface. + /// The value to look for in the collection. + /// The string comparison. + /// A value indicating whether the value is contained in the collection. + /// The source is null. + public static bool Contains(this IEnumerable source, ReadOnlySpan value, StringComparison stringComparison) + { + if (source == null) + { + throw new ArgumentNullException(nameof(source)); + } + + if (source is IList 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..3d9538d1bb --- /dev/null +++ b/src/Jellyfin.Extensions/Jellyfin.Extensions.csproj @@ -0,0 +1,37 @@ + + + + net6.0 + false + true + true + true + true + snupkg + + + + Jellyfin Contributors + Jellyfin.Extensions + 10.8.0 + https://github.com/jellyfin/jellyfin + GPL-3.0-only + + + + + $(AllowedOutputExtensionsInPackageBuildOutputFolder);.pdb + + + + + + + + + + + + + + diff --git a/MediaBrowser.Common/Json/Converters/JsonBoolNumberConverter.cs b/src/Jellyfin.Extensions/Json/Converters/JsonBoolNumberConverter.cs similarity index 94% rename from MediaBrowser.Common/Json/Converters/JsonBoolNumberConverter.cs rename to src/Jellyfin.Extensions/Json/Converters/JsonBoolNumberConverter.cs index b29e6a71a6..c2543cf7ce 100644 --- a/MediaBrowser.Common/Json/Converters/JsonBoolNumberConverter.cs +++ b/src/Jellyfin.Extensions/Json/Converters/JsonBoolNumberConverter.cs @@ -2,7 +2,7 @@ using System.Text.Json; using System.Text.Json.Serialization; -namespace MediaBrowser.Common.Json.Converters +namespace Jellyfin.Extensions.Json.Converters { /// /// Converts a number to a boolean. @@ -27,4 +27,4 @@ namespace MediaBrowser.Common.Json.Converters writer.WriteBooleanValue(value); } } -} \ No newline at end of file +} diff --git a/src/Jellyfin.Extensions/Json/Converters/JsonCommaDelimitedArrayConverter.cs b/src/Jellyfin.Extensions/Json/Converters/JsonCommaDelimitedArrayConverter.cs new file mode 100644 index 0000000000..0d0cc2d063 --- /dev/null +++ b/src/Jellyfin.Extensions/Json/Converters/JsonCommaDelimitedArrayConverter.cs @@ -0,0 +1,19 @@ +namespace Jellyfin.Extensions.Json.Converters +{ + /// + /// Convert comma delimited string to array of type. + /// + /// Type to convert to. + public sealed class JsonCommaDelimitedArrayConverter : JsonDelimitedArrayConverter + { + /// + /// Initializes a new instance of the class. + /// + public JsonCommaDelimitedArrayConverter() : base() + { + } + + /// + protected override char Delimiter => ','; + } +} diff --git a/MediaBrowser.Common/Json/Converters/JsonCommaDelimitedArrayConverterFactory.cs b/src/Jellyfin.Extensions/Json/Converters/JsonCommaDelimitedArrayConverterFactory.cs similarity index 70% rename from MediaBrowser.Common/Json/Converters/JsonCommaDelimitedArrayConverterFactory.cs rename to src/Jellyfin.Extensions/Json/Converters/JsonCommaDelimitedArrayConverterFactory.cs index 24ed3ea19e..cc9311a24c 100644 --- a/MediaBrowser.Common/Json/Converters/JsonCommaDelimitedArrayConverterFactory.cs +++ b/src/Jellyfin.Extensions/Json/Converters/JsonCommaDelimitedArrayConverterFactory.cs @@ -2,7 +2,7 @@ using System.Text.Json; using System.Text.Json.Serialization; -namespace MediaBrowser.Common.Json.Converters +namespace Jellyfin.Extensions.Json.Converters { /// /// Json comma delimited array converter factory. @@ -19,10 +19,10 @@ namespace MediaBrowser.Common.Json.Converters } /// - public override JsonConverter CreateConverter(Type typeToConvert, JsonSerializerOptions options) + public override JsonConverter? CreateConverter(Type typeToConvert, JsonSerializerOptions options) { var structType = typeToConvert.GetElementType() ?? typeToConvert.GenericTypeArguments[0]; - return (JsonConverter)Activator.CreateInstance(typeof(JsonCommaDelimitedArrayConverter<>).MakeGenericType(structType)); + return (JsonConverter?)Activator.CreateInstance(typeof(JsonCommaDelimitedArrayConverter<>).MakeGenericType(structType)); } } } diff --git a/MediaBrowser.Common/Json/Converters/JsonDateTimeConverter.cs b/src/Jellyfin.Extensions/Json/Converters/JsonDateTimeConverter.cs similarity index 95% rename from MediaBrowser.Common/Json/Converters/JsonDateTimeConverter.cs rename to src/Jellyfin.Extensions/Json/Converters/JsonDateTimeConverter.cs index 73e3a0493d..8ae080b15a 100644 --- a/MediaBrowser.Common/Json/Converters/JsonDateTimeConverter.cs +++ b/src/Jellyfin.Extensions/Json/Converters/JsonDateTimeConverter.cs @@ -3,7 +3,7 @@ using System.Globalization; using System.Text.Json; using System.Text.Json.Serialization; -namespace MediaBrowser.Common.Json.Converters +namespace Jellyfin.Extensions.Json.Converters { /// /// Legacy DateTime converter. @@ -31,4 +31,4 @@ namespace MediaBrowser.Common.Json.Converters } } } -} \ No newline at end of file +} diff --git a/MediaBrowser.Common/Json/Converters/JsonPipeDelimitedArrayConverter.cs b/src/Jellyfin.Extensions/Json/Converters/JsonDelimitedArrayConverter.cs similarity index 67% rename from MediaBrowser.Common/Json/Converters/JsonPipeDelimitedArrayConverter.cs rename to src/Jellyfin.Extensions/Json/Converters/JsonDelimitedArrayConverter.cs index c408a3be13..51b955145f 100644 --- a/MediaBrowser.Common/Json/Converters/JsonPipeDelimitedArrayConverter.cs +++ b/src/Jellyfin.Extensions/Json/Converters/JsonDelimitedArrayConverter.cs @@ -3,30 +3,36 @@ using System.ComponentModel; using System.Text.Json; using System.Text.Json.Serialization; -namespace MediaBrowser.Common.Json.Converters +namespace Jellyfin.Extensions.Json.Converters { /// - /// Convert Pipe delimited string to array of type. + /// Convert delimited string to array of type. /// /// Type to convert to. - public class JsonPipeDelimitedArrayConverter : JsonConverter + public abstract class JsonDelimitedArrayConverter : JsonConverter { private readonly TypeConverter _typeConverter; /// - /// Initializes a new instance of the class. + /// Initializes a new instance of the class. /// - public JsonPipeDelimitedArrayConverter() + protected JsonDelimitedArrayConverter() { _typeConverter = TypeDescriptor.GetConverter(typeof(T)); } + /// + /// Gets the array delimiter. + /// + protected virtual char Delimiter { get; } + /// - public override T[] Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + public override T[]? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { if (reader.TokenType == JsonTokenType.String) { - var stringEntries = reader.GetString()?.Split('|', StringSplitOptions.RemoveEmptyEntries); + // 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(); @@ -38,7 +44,7 @@ namespace MediaBrowser.Common.Json.Converters { try { - parsedValues[i] = _typeConverter.ConvertFrom(stringEntries[i].Trim()); + parsedValues[i] = _typeConverter.ConvertFromInvariantString(stringEntries[i].Trim()) ?? throw new FormatException(); convertedCount++; } catch (FormatException) @@ -67,7 +73,7 @@ namespace MediaBrowser.Common.Json.Converters } /// - public override void Write(Utf8JsonWriter writer, T[] value, JsonSerializerOptions options) + 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 94% rename from MediaBrowser.Common/Json/Converters/JsonGuidConverter.cs rename to src/Jellyfin.Extensions/Json/Converters/JsonGuidConverter.cs index bd96001105..be94dd5192 100644 --- a/MediaBrowser.Common/Json/Converters/JsonGuidConverter.cs +++ b/src/Jellyfin.Extensions/Json/Converters/JsonGuidConverter.cs @@ -3,7 +3,7 @@ using System.Globalization; using System.Text.Json; using System.Text.Json.Serialization; -namespace MediaBrowser.Common.Json.Converters +namespace Jellyfin.Extensions.Json.Converters { /// /// Converts a GUID object or value to/from JSON. diff --git a/MediaBrowser.Model/Entities/JsonLowerCaseConverter.cs b/src/Jellyfin.Extensions/Json/Converters/JsonLowerCaseConverter.cs similarity index 65% rename from MediaBrowser.Model/Entities/JsonLowerCaseConverter.cs rename to src/Jellyfin.Extensions/Json/Converters/JsonLowerCaseConverter.cs index 7c627f0e39..cd582ced64 100644 --- a/MediaBrowser.Model/Entities/JsonLowerCaseConverter.cs +++ b/src/Jellyfin.Extensions/Json/Converters/JsonLowerCaseConverter.cs @@ -1,12 +1,8 @@ -#nullable disable -// THIS IS A HACK -// TODO: @bond Move to separate project - using System; using System.Text.Json; using System.Text.Json.Serialization; -namespace MediaBrowser.Model.Entities +namespace Jellyfin.Extensions.Json.Converters { /// /// Converts an object to a lowercase string. @@ -15,7 +11,7 @@ namespace MediaBrowser.Model.Entities public class JsonLowerCaseConverter : JsonConverter { /// - public override T Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + public override T? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { return JsonSerializer.Deserialize(ref reader, options); } @@ -23,7 +19,7 @@ namespace MediaBrowser.Model.Entities /// public override void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions options) { - writer.WriteStringValue(value?.ToString().ToLowerInvariant()); + writer.WriteStringValue(value?.ToString()?.ToLowerInvariant()); } } } diff --git a/MediaBrowser.Common/Json/Converters/JsonNullableGuidConverter.cs b/src/Jellyfin.Extensions/Json/Converters/JsonNullableGuidConverter.cs similarity index 95% rename from MediaBrowser.Common/Json/Converters/JsonNullableGuidConverter.cs rename to src/Jellyfin.Extensions/Json/Converters/JsonNullableGuidConverter.cs index 6d96d5496f..6192d1598d 100644 --- a/MediaBrowser.Common/Json/Converters/JsonNullableGuidConverter.cs +++ b/src/Jellyfin.Extensions/Json/Converters/JsonNullableGuidConverter.cs @@ -3,7 +3,7 @@ using System.Globalization; using System.Text.Json; using System.Text.Json.Serialization; -namespace MediaBrowser.Common.Json.Converters +namespace Jellyfin.Extensions.Json.Converters { /// /// Converts a GUID object or value to/from JSON. 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 { /// /// 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 { /// /// Json nullable struct converter factory. @@ -18,10 +18,10 @@ namespace MediaBrowser.Common.Json.Converters } /// - 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..6e59fe4641 --- /dev/null +++ b/src/Jellyfin.Extensions/Json/Converters/JsonPipeDelimitedArrayConverter.cs @@ -0,0 +1,19 @@ +namespace Jellyfin.Extensions.Json.Converters +{ + /// + /// Convert Pipe delimited string to array of type. + /// + /// Type to convert to. + public sealed class JsonPipeDelimitedArrayConverter : JsonDelimitedArrayConverter + { + /// + /// Initializes a new instance of the class. + /// + public JsonPipeDelimitedArrayConverter() : base() + { + } + + /// + protected override char Delimiter => '|'; + } +} diff --git a/MediaBrowser.Common/Json/Converters/JsonPipeDelimitedArrayConverterFactory.cs b/src/Jellyfin.Extensions/Json/Converters/JsonPipeDelimitedArrayConverterFactory.cs similarity index 70% rename from MediaBrowser.Common/Json/Converters/JsonPipeDelimitedArrayConverterFactory.cs rename to src/Jellyfin.Extensions/Json/Converters/JsonPipeDelimitedArrayConverterFactory.cs index 5e77223ef2..579674f18c 100644 --- a/MediaBrowser.Common/Json/Converters/JsonPipeDelimitedArrayConverterFactory.cs +++ b/src/Jellyfin.Extensions/Json/Converters/JsonPipeDelimitedArrayConverterFactory.cs @@ -2,7 +2,7 @@ using System.Text.Json; using System.Text.Json.Serialization; -namespace MediaBrowser.Common.Json.Converters +namespace Jellyfin.Extensions.Json.Converters { /// /// Json Pipe delimited array converter factory. @@ -19,10 +19,10 @@ namespace MediaBrowser.Common.Json.Converters } /// - public override JsonConverter CreateConverter(Type typeToConvert, JsonSerializerOptions options) + public override JsonConverter? CreateConverter(Type typeToConvert, JsonSerializerOptions options) { var structType = typeToConvert.GetElementType() ?? typeToConvert.GenericTypeArguments[0]; - return (JsonConverter)Activator.CreateInstance(typeof(JsonPipeDelimitedArrayConverter<>).MakeGenericType(structType)); + return (JsonConverter?)Activator.CreateInstance(typeof(JsonPipeDelimitedArrayConverter<>).MakeGenericType(structType)); } } } diff --git a/MediaBrowser.Common/Json/Converters/JsonStringConverter.cs b/src/Jellyfin.Extensions/Json/Converters/JsonStringConverter.cs similarity index 77% rename from MediaBrowser.Common/Json/Converters/JsonStringConverter.cs rename to src/Jellyfin.Extensions/Json/Converters/JsonStringConverter.cs index 669b3cd077..1a7a8c4f55 100644 --- a/MediaBrowser.Common/Json/Converters/JsonStringConverter.cs +++ b/src/Jellyfin.Extensions/Json/Converters/JsonStringConverter.cs @@ -4,15 +4,15 @@ using System.Text; using System.Text.Json; using System.Text.Json.Serialization; -namespace MediaBrowser.Common.Json.Converters +namespace Jellyfin.Extensions.Json.Converters { /// /// Converter to allow the serializer to read strings. /// - public class JsonStringConverter : JsonConverter + public class JsonStringConverter : JsonConverter { /// - public override string Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + public override string? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { return reader.TokenType switch { @@ -23,7 +23,7 @@ namespace MediaBrowser.Common.Json.Converters } /// - public override void Write(Utf8JsonWriter writer, string value, JsonSerializerOptions options) + public override void Write(Utf8JsonWriter writer, string? value, JsonSerializerOptions options) { writer.WriteStringValue(value); } @@ -36,4 +36,4 @@ namespace MediaBrowser.Common.Json.Converters return Encoding.UTF8.GetString(utf8Bytes); } } -} \ No newline at end of file +} diff --git a/MediaBrowser.Common/Json/Converters/JsonVersionConverter.cs b/src/Jellyfin.Extensions/Json/Converters/JsonVersionConverter.cs similarity index 83% rename from MediaBrowser.Common/Json/Converters/JsonVersionConverter.cs rename to src/Jellyfin.Extensions/Json/Converters/JsonVersionConverter.cs index f69e868cc4..51ffec1cbf 100644 --- a/MediaBrowser.Common/Json/Converters/JsonVersionConverter.cs +++ b/src/Jellyfin.Extensions/Json/Converters/JsonVersionConverter.cs @@ -2,7 +2,7 @@ using System.Text.Json; using System.Text.Json.Serialization; -namespace MediaBrowser.Common.Json.Converters +namespace Jellyfin.Extensions.Json.Converters { /// /// Converts a Version object or value to/from JSON. @@ -14,7 +14,7 @@ namespace MediaBrowser.Common.Json.Converters { /// public override Version Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) - => new Version(reader.GetString()); + => new Version(reader.GetString()!); // Will throw ArgumentNullException on null /// public override void Write(Utf8JsonWriter writer, Version value, JsonSerializerOptions options) diff --git a/MediaBrowser.Common/Json/JsonDefaults.cs b/src/Jellyfin.Extensions/Json/JsonDefaults.cs similarity index 97% rename from MediaBrowser.Common/Json/JsonDefaults.cs rename to src/Jellyfin.Extensions/Json/JsonDefaults.cs index 405d6125f4..f4ec911231 100644 --- a/MediaBrowser.Common/Json/JsonDefaults.cs +++ b/src/Jellyfin.Extensions/Json/JsonDefaults.cs @@ -1,8 +1,8 @@ using System.Text.Json; using System.Text.Json.Serialization; -using MediaBrowser.Common.Json.Converters; +using Jellyfin.Extensions.Json.Converters; -namespace MediaBrowser.Common.Json +namespace Jellyfin.Extensions.Json { /// /// Helper class for having compatible JSON throughout the codebase. diff --git a/src/Jellyfin.Extensions/ReadOnlyListExtension.cs b/src/Jellyfin.Extensions/ReadOnlyListExtension.cs new file mode 100644 index 0000000000..7785cfb495 --- /dev/null +++ b/src/Jellyfin.Extensions/ReadOnlyListExtension.cs @@ -0,0 +1,61 @@ +using System; +using System.Collections.Generic; + +namespace Jellyfin.Extensions +{ + /// + /// Static extensions for the interface. + /// + public static class ReadOnlyListExtension + { + /// + /// Finds the index of the desired item. + /// + /// The source list. + /// The value to fine. + /// The type of item to find. + /// Index if found, else -1. + public static int IndexOf(this IReadOnlyList source, T value) + { + if (source is IList list) + { + return list.IndexOf(value); + } + + for (int i = 0; i < source.Count; i++) + { + if (Equals(value, source[i])) + { + return i; + } + } + + return -1; + } + + /// + /// Finds the index of the predicate. + /// + /// The source list. + /// The value to find. + /// The type of item to find. + /// Index if found, else -1. + public static int FindIndex(this IReadOnlyList source, Predicate match) + { + if (source is List list) + { + return list.FindIndex(match); + } + + for (int i = 0; i < source.Count; i++) + { + if (match(source[i])) + { + return i; + } + } + + return -1; + } + } +} diff --git a/MediaBrowser.Common/Extensions/ShuffleExtensions.cs b/src/Jellyfin.Extensions/ShuffleExtensions.cs similarity index 88% rename from MediaBrowser.Common/Extensions/ShuffleExtensions.cs rename to src/Jellyfin.Extensions/ShuffleExtensions.cs index 6f0ea9bd58..33c492053a 100644 --- a/MediaBrowser.Common/Extensions/ShuffleExtensions.cs +++ b/src/Jellyfin.Extensions/ShuffleExtensions.cs @@ -1,17 +1,13 @@ -#nullable enable - using System; using System.Collections.Generic; -namespace MediaBrowser.Common.Extensions +namespace Jellyfin.Extensions { /// /// Provides Shuffle extensions methods for . /// public static class ShuffleExtensions { - private static readonly Random _rng = new Random(); - /// /// Shuffles the items in a list. /// @@ -19,7 +15,7 @@ namespace MediaBrowser.Common.Extensions /// The type. public static void Shuffle(this IList list) { - list.Shuffle(_rng); + list.Shuffle(Random.Shared); } /// 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 +{ + /// + /// Extension class for splitting lines without unnecessary allocations. + /// + public static class SplitStringExtensions + { + /// + /// Creates a new string split enumerator. + /// + /// The string to split. + /// The separator to split on. + /// The enumerator struct. + [Pure] + public static Enumerator SpanSplit(this string str, char separator) => new (str.AsSpan(), separator); + + /// + /// Creates a new span split enumerator. + /// + /// The span to split. + /// The separator to split on. + /// The enumerator struct. + [Pure] + public static Enumerator Split(this ReadOnlySpan str, char separator) => new (str, separator); + + /// + /// Provides an enumerator for the substrings seperated by the separator. + /// + [StructLayout(LayoutKind.Auto)] + public ref struct Enumerator + { + private readonly char _separator; + private ReadOnlySpan _str; + + /// + /// Initializes a new instance of the struct. + /// + /// The span to split. + /// The separator to split on. + public Enumerator(ReadOnlySpan str, char separator) + { + _str = str; + _separator = separator; + Current = default; + } + + /// + /// Gets a reference to the item at the current position of the enumerator. + /// + public ReadOnlySpan Current { get; private set; } + + /// + /// Returns this. + /// + /// this. + public readonly Enumerator GetEnumerator() => this; + + /// + /// Advances the enumerator to the next item. + /// + /// true if there is a next element; otherwise false. + public bool MoveNext() + { + if (_str.Length == 0) + { + return false; + } + + var span = _str; + var index = span.IndexOf(_separator); + if (index == -1) + { + _str = ReadOnlySpan.Empty; + Current = span; + return true; + } + + Current = span.Slice(0, index); + _str = span[(index + 1)..]; + return true; + } + } + } +} diff --git a/MediaBrowser.Common/Extensions/StreamExtensions.cs b/src/Jellyfin.Extensions/StreamExtensions.cs similarity index 63% rename from MediaBrowser.Common/Extensions/StreamExtensions.cs rename to src/Jellyfin.Extensions/StreamExtensions.cs index cd77be7b2b..9751d9d42a 100644 --- a/MediaBrowser.Common/Extensions/StreamExtensions.cs +++ b/src/Jellyfin.Extensions/StreamExtensions.cs @@ -1,11 +1,9 @@ -#nullable enable - using System.Collections.Generic; using System.IO; using System.Linq; using System.Text; -namespace MediaBrowser.Common.Extensions +namespace Jellyfin.Extensions { /// /// Class BaseExtensions. @@ -35,11 +33,11 @@ namespace MediaBrowser.Common.Extensions } /// - /// Reads all lines in the . + /// Reads all lines in the . /// - /// The to read from. + /// The to read from. /// All lines in the stream. - public static IEnumerable ReadAllLines(this StreamReader reader) + public static IEnumerable ReadAllLines(this TextReader reader) { string? line; while ((line = reader.ReadLine()) != null) @@ -47,5 +45,19 @@ namespace MediaBrowser.Common.Extensions yield return line; } } + + /// + /// Reads all lines in the . + /// + /// The to read from. + /// All lines in the stream. + public static async IAsyncEnumerable 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 +{ + /// + /// Extension methods for the class. + /// + public static class StringBuilderExtensions + { + /// + /// Concatenates and appends the members of a collection in single quotes using the specified delimiter. + /// + /// The string builder. + /// The character delimiter. + /// The collection of strings to concatenate. + /// The updated string builder. + public static StringBuilder AppendJoinInSingleQuotes(this StringBuilder builder, char delimiter, IReadOnlyList 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..3a77072539 --- /dev/null +++ b/src/Jellyfin.Extensions/StringExtensions.cs @@ -0,0 +1,65 @@ +using System; + +namespace Jellyfin.Extensions +{ + /// + /// Provides extensions methods for . + /// + public static class StringExtensions + { + /// + /// Counts the number of occurrences of [needle] in the string. + /// + /// The haystack to search in. + /// The character to search for. + /// The number of occurrences of the [needle] character. + public static int Count(this ReadOnlySpan value, char needle) + { + var count = 0; + var length = value.Length; + for (var i = 0; i < length; i++) + { + if (value[i] == needle) + { + count++; + } + } + + return count; + } + + /// + /// Returns the part on the left of the needle. + /// + /// The string to seek. + /// The needle to find. + /// The part left of the . + public static ReadOnlySpan LeftPart(this ReadOnlySpan haystack, char needle) + { + var pos = haystack.IndexOf(needle); + return pos == -1 ? haystack : haystack[..pos]; + } + + /// + /// Returns the part on the right of the needle. + /// + /// The string to seek. + /// The needle to find. + /// The part right of the . + public static ReadOnlySpan RightPart(this ReadOnlySpan haystack, char needle) + { + var pos = haystack.LastIndexOf(needle); + if (pos == -1) + { + return haystack; + } + + if (pos == haystack.Length - 1) + { + return ReadOnlySpan.Empty; + } + + return haystack[(pos + 1)..]; + } + } +} diff --git a/tests/Jellyfin.Api.Tests/Auth/CustomAuthenticationHandlerTests.cs b/tests/Jellyfin.Api.Tests/Auth/CustomAuthenticationHandlerTests.cs index de03aa5f5b..6f5c0ed0c8 100644 --- a/tests/Jellyfin.Api.Tests/Auth/CustomAuthenticationHandlerTests.cs +++ b/tests/Jellyfin.Api.Tests/Auth/CustomAuthenticationHandlerTests.cs @@ -132,11 +132,13 @@ namespace Jellyfin.Api.Tests.Auth authorizationInfo.User.AddDefaultPreferences(); authorizationInfo.User.SetPermission(PermissionKind.IsAdministrator, isAdmin); authorizationInfo.IsApiKey = false; + authorizationInfo.HasToken = true; + authorizationInfo.Token = "fake-token"; _jellyfinAuthServiceMock.Setup( a => a.Authenticate( It.IsAny())) - .Returns(authorizationInfo); + .Returns(Task.FromResult(authorizationInfo)); return authorizationInfo; } diff --git a/tests/Jellyfin.Api.Tests/Auth/DefaultAuthorizationPolicy/DefaultAuthorizationHandlerTests.cs b/tests/Jellyfin.Api.Tests/Auth/DefaultAuthorizationPolicy/DefaultAuthorizationHandlerTests.cs index a62fd8d5ae..23c51999fa 100644 --- a/tests/Jellyfin.Api.Tests/Auth/DefaultAuthorizationPolicy/DefaultAuthorizationHandlerTests.cs +++ b/tests/Jellyfin.Api.Tests/Auth/DefaultAuthorizationPolicy/DefaultAuthorizationHandlerTests.cs @@ -4,6 +4,7 @@ using AutoFixture; using AutoFixture.AutoMoq; using Jellyfin.Api.Auth.DefaultAuthorizationPolicy; using Jellyfin.Api.Constants; +using Jellyfin.Server.Implementations.Security; using MediaBrowser.Common.Configuration; using MediaBrowser.Controller.Library; using Microsoft.AspNetCore.Authorization; @@ -49,5 +50,61 @@ namespace Jellyfin.Api.Tests.Auth.DefaultAuthorizationPolicy await _sut.HandleAsync(context); Assert.True(context.HasSucceeded); } + + [Theory] + [MemberData(nameof(GetParts_ValidAuthHeader_Success_Data))] + public void GetParts_ValidAuthHeader_Success(string input, Dictionary parts) + { + var dict = AuthorizationContext.GetParts(input); + foreach (var (key, value) in parts) + { + Assert.Equal(dict[key], value); + } + } + + private static TheoryData> GetParts_ValidAuthHeader_Success_Data() + { + var data = new TheoryData>(); + + data.Add( + "x=\"123,123\",y=\"123\"", + new Dictionary + { + { "x", "123,123" }, + { "y", "123" } + }); + + data.Add( + "x=\"123,123\", y=\"123\",z=\"'hi'\"", + new Dictionary + { + { "x", "123,123" }, + { "y", "123" }, + { "z", "'hi'" } + }); + + data.Add( + "x=\"ab\"", + new Dictionary + { + { "x", "ab" } + }); + + data.Add( + "param=Hörbücher", + new Dictionary + { + { "param", "Hörbücher" } + }); + + data.Add( + "param=%22%Hörbücher", + new Dictionary + { + { "param", "\"%Hörbücher" } + }); + + return data; + } } } diff --git a/tests/Jellyfin.Api.Tests/Controllers/DynamicHlsControllerTests.cs b/tests/Jellyfin.Api.Tests/Controllers/DynamicHlsControllerTests.cs new file mode 100644 index 0000000000..1f06e8fde6 --- /dev/null +++ b/tests/Jellyfin.Api.Tests/Controllers/DynamicHlsControllerTests.cs @@ -0,0 +1,45 @@ +using System; +using Jellyfin.Api.Controllers; +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 TheoryData GetSegmentLengths_Success_TestData() + { + var data = new TheoryData(); + data.Add(0, 6, Array.Empty()); + data.Add( + TimeSpan.FromSeconds(3).Ticks, + 6, + new double[] { 3 }); + data.Add( + TimeSpan.FromSeconds(6).Ticks, + 6, + new double[] { 6 }); + data.Add( + TimeSpan.FromSeconds(3.3333333).Ticks, + 6, + new double[] { 3.3333333 }); + data.Add( + TimeSpan.FromSeconds(9.3333333).Ticks, + 6, + new double[] { 6, 3.3333333 }); + + return data; + } + } +} diff --git a/tests/Jellyfin.Api.Tests/Helpers/RequestHelpersTests.cs b/tests/Jellyfin.Api.Tests/Helpers/RequestHelpersTests.cs index 97e441b1dc..4ba7e1d2ff 100644 --- a/tests/Jellyfin.Api.Tests/Helpers/RequestHelpersTests.cs +++ b/tests/Jellyfin.Api.Tests/Helpers/RequestHelpersTests.cs @@ -15,16 +15,16 @@ namespace Jellyfin.Api.Tests.Helpers Assert.Equal(expected, RequestHelpers.GetOrderBy(sortBy, requestedSortOrder)); } - public static IEnumerable GetOrderBy_Success_TestData() + public static TheoryData, IReadOnlyList, (string, SortOrder)[]> GetOrderBy_Success_TestData() { - yield return new object[] - { + var data = new TheoryData, IReadOnlyList, (string, SortOrder)[]>(); + + data.Add( Array.Empty(), Array.Empty(), - Array.Empty<(string, SortOrder)>() - }; - yield return new object[] - { + Array.Empty<(string, SortOrder)>()); + + data.Add( new string[] { "IsFavoriteOrLiked", @@ -35,10 +35,9 @@ namespace Jellyfin.Api.Tests.Helpers { ("IsFavoriteOrLiked", SortOrder.Ascending), ("Random", SortOrder.Ascending), - } - }; - yield return new object[] - { + }); + + data.Add( new string[] { "SortName", @@ -52,8 +51,9 @@ namespace Jellyfin.Api.Tests.Helpers { ("SortName", SortOrder.Descending), ("ProductionYear", SortOrder.Descending), - } - }; + }); + + return data; } [Fact] diff --git a/tests/Jellyfin.Api.Tests/Jellyfin.Api.Tests.csproj b/tests/Jellyfin.Api.Tests/Jellyfin.Api.Tests.csproj index 050d4c040c..2aced06691 100644 --- a/tests/Jellyfin.Api.Tests/Jellyfin.Api.Tests.csproj +++ b/tests/Jellyfin.Api.Tests/Jellyfin.Api.Tests.csproj @@ -6,24 +6,21 @@ - net5.0 + net6.0 false - true - enable - AllEnabledByDefault ../jellyfin-tests.ruleset - - - - - - + + + + + + - + diff --git a/tests/Jellyfin.Common.Tests/Extensions/CopyToExtensionsTests.cs b/tests/Jellyfin.Common.Tests/Extensions/CopyToExtensionsTests.cs deleted file mode 100644 index 9903409fa6..0000000000 --- a/tests/Jellyfin.Common.Tests/Extensions/CopyToExtensionsTests.cs +++ /dev/null @@ -1,40 +0,0 @@ -using System; -using System.Collections.Generic; -using MediaBrowser.Common.Extensions; -using Xunit; - -namespace Jellyfin.Common.Tests.Extensions -{ - public static class CopyToExtensionsTests - { - public static IEnumerable 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(IReadOnlyList source, IList destination, int index, IList expected) - { - source.CopyTo(destination, index); - Assert.Equal(expected, destination); - } - - public static IEnumerable 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(), 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(IReadOnlyList source, IList destination, int index) - { - Assert.Throws(() => source.CopyTo(destination, index)); - } - } -} diff --git a/tests/Jellyfin.Common.Tests/Jellyfin.Common.Tests.csproj b/tests/Jellyfin.Common.Tests/Jellyfin.Common.Tests.csproj index 8018b29663..ce607b2ec9 100644 --- a/tests/Jellyfin.Common.Tests/Jellyfin.Common.Tests.csproj +++ b/tests/Jellyfin.Common.Tests/Jellyfin.Common.Tests.csproj @@ -6,19 +6,17 @@ - net5.0 + net6.0 false - true - enable - AllEnabledByDefault ../jellyfin-tests.ruleset - + - + + diff --git a/tests/Jellyfin.Common.Tests/Json/JsonBoolNumberTests.cs b/tests/Jellyfin.Common.Tests/Json/JsonBoolNumberTests.cs deleted file mode 100644 index 9ded01f2b1..0000000000 --- a/tests/Jellyfin.Common.Tests/Json/JsonBoolNumberTests.cs +++ /dev/null @@ -1,34 +0,0 @@ -using System.Text.Json; -using MediaBrowser.Common.Json.Converters; -using Xunit; - -namespace Jellyfin.Common.Tests.Json -{ - public static class JsonBoolNumberTests - { - [Theory] - [InlineData("1", true)] - [InlineData("0", false)] - [InlineData("2", true)] - [InlineData("true", true)] - [InlineData("false", false)] - public static void Deserialize_Number_Valid_Success(string input, bool? output) - { - var options = new JsonSerializerOptions(); - options.Converters.Add(new JsonBoolNumberConverter()); - var value = JsonSerializer.Deserialize(input, options); - Assert.Equal(value, output); - } - - [Theory] - [InlineData(true, "true")] - [InlineData(false, "false")] - public static void Serialize_Bool_Success(bool input, string output) - { - var options = new JsonSerializerOptions(); - options.Converters.Add(new JsonBoolNumberConverter()); - var value = JsonSerializer.Serialize(input, options); - Assert.Equal(value, output); - } - } -} \ No newline at end of file diff --git a/tests/Jellyfin.Controller.Tests/BaseItemManagerTests.cs b/tests/Jellyfin.Controller.Tests/BaseItemManagerTests.cs new file mode 100644 index 0000000000..edceef4a7c --- /dev/null +++ b/tests/Jellyfin.Controller.Tests/BaseItemManagerTests.cs @@ -0,0 +1,89 @@ +using System; +using MediaBrowser.Controller.BaseItemManager; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.Audio; +using MediaBrowser.Controller.Entities.Movies; +using MediaBrowser.Model.Configuration; +using Moq; +using Xunit; + +namespace Jellyfin.Controller.Tests +{ + public class BaseItemManagerTests + { + [Theory] + [InlineData(typeof(Book), "LibraryEnabled", true)] + [InlineData(typeof(Book), "LibraryDisabled", false)] + [InlineData(typeof(MusicArtist), "Enabled", true)] + [InlineData(typeof(MusicArtist), "ServerDisabled", false)] + public void IsMetadataFetcherEnabled_ChecksOptions_ReturnsExpected(Type itemType, string fetcherName, bool expected) + { + BaseItem item = (BaseItem)Activator.CreateInstance(itemType)!; + + var libraryOptions = new LibraryOptions + { + TypeOptions = new[] + { + new TypeOptions + { + Type = "Book", + MetadataFetchers = new[] { "LibraryEnabled" } + } + } + }; + + var serverConfiguration = new ServerConfiguration(); + foreach (var typeConfig in serverConfiguration.MetadataOptions) + { + typeConfig.DisabledMetadataFetchers = new[] { "ServerDisabled" }; + } + + var serverConfigurationManager = new Mock(); + serverConfigurationManager.Setup(scm => scm.Configuration) + .Returns(serverConfiguration); + + var baseItemManager = new BaseItemManager(serverConfigurationManager.Object); + var actual = baseItemManager.IsMetadataFetcherEnabled(item, libraryOptions, fetcherName); + + Assert.Equal(expected, actual); + } + + [Theory] + [InlineData(typeof(Book), "LibraryEnabled", true)] + [InlineData(typeof(Book), "LibraryDisabled", false)] + [InlineData(typeof(MusicArtist), "Enabled", true)] + [InlineData(typeof(MusicArtist), "ServerDisabled", false)] + public void IsImageFetcherEnabled_ChecksOptions_ReturnsExpected(Type itemType, string fetcherName, bool expected) + { + BaseItem item = (BaseItem)Activator.CreateInstance(itemType)!; + + var libraryOptions = new LibraryOptions + { + TypeOptions = new[] + { + new TypeOptions + { + Type = "Book", + ImageFetchers = new[] { "LibraryEnabled" } + } + } + }; + + var serverConfiguration = new ServerConfiguration(); + foreach (var typeConfig in serverConfiguration.MetadataOptions) + { + typeConfig.DisabledImageFetchers = new[] { "ServerDisabled" }; + } + + var serverConfigurationManager = new Mock(); + serverConfigurationManager.Setup(scm => scm.Configuration) + .Returns(serverConfiguration); + + var baseItemManager = new BaseItemManager(serverConfigurationManager.Object); + var actual = baseItemManager.IsImageFetcherEnabled(item, libraryOptions, fetcherName); + + Assert.Equal(expected, actual); + } + } +} diff --git a/tests/Jellyfin.Controller.Tests/Jellyfin.Controller.Tests.csproj b/tests/Jellyfin.Controller.Tests/Jellyfin.Controller.Tests.csproj index ad16276989..0ffc19833a 100644 --- a/tests/Jellyfin.Controller.Tests/Jellyfin.Controller.Tests.csproj +++ b/tests/Jellyfin.Controller.Tests/Jellyfin.Controller.Tests.csproj @@ -6,20 +6,17 @@ - net5.0 + net6.0 false - true - enable - AllEnabledByDefault ../jellyfin-tests.ruleset - + - + 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(); + var fileSystem = new Mock(); + var appPaths = new Mock(); + var loggerFactory = new Mock(); + var appHost = new Mock(); + + 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/Jellyfin.Dlna.Tests.csproj b/tests/Jellyfin.Dlna.Tests/Jellyfin.Dlna.Tests.csproj index c2c0dca1bb..0981660012 100644 --- a/tests/Jellyfin.Dlna.Tests/Jellyfin.Dlna.Tests.csproj +++ b/tests/Jellyfin.Dlna.Tests/Jellyfin.Dlna.Tests.csproj @@ -1,19 +1,17 @@ - net5.0 + net6.0 false - true - enable - AllEnabledByDefault ../jellyfin-tests.ruleset - + + - + diff --git a/tests/Jellyfin.Controller.Tests/AlphanumComparatorTests.cs b/tests/Jellyfin.Extensions.Tests/AlphanumericComparatorTests.cs similarity index 83% rename from tests/Jellyfin.Controller.Tests/AlphanumComparatorTests.cs rename to tests/Jellyfin.Extensions.Tests/AlphanumericComparatorTests.cs index 0adf098c33..7730841a1c 100644 --- a/tests/Jellyfin.Controller.Tests/AlphanumComparatorTests.cs +++ b/tests/Jellyfin.Extensions.Tests/AlphanumericComparatorTests.cs @@ -1,11 +1,10 @@ using System; using System.Linq; -using MediaBrowser.Controller.Sorting; using Xunit; -namespace Jellyfin.Controller.Tests +namespace Jellyfin.Extensions.Tests { - public class AlphanumComparatorTests + public class AlphanumericComparatorTests { // InlineData is pre-sorted [Theory] @@ -20,10 +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 = strings.Reverse().ToArray(); - Array.Sort(copy, new AlphanumComparator()); + 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..d46beedd99 --- /dev/null +++ b/tests/Jellyfin.Extensions.Tests/CopyToExtensionsTests.cs @@ -0,0 +1,59 @@ +using System; +using System.Collections.Generic; +using Xunit; + +namespace Jellyfin.Extensions.Tests +{ + public static class CopyToExtensionsTests + { + public static TheoryData, IList, int, IList> CopyTo_Valid_Correct_TestData() + { + var data = new TheoryData, IList, int, IList>(); + + data.Add( + new[] { 0, 1, 2, 3, 4, 5 }, new[] { 0, 0, 0, 0, 0, 0 }, 0, new[] { 0, 1, 2, 3, 4, 5 }); + + data.Add( + new[] { 0, 1, 2 }, new[] { 5, 4, 3, 2, 1, 0 }, 2, new[] { 5, 4, 0, 1, 2, 0 } ); + + return data; + } + + [Theory] + [MemberData(nameof(CopyTo_Valid_Correct_TestData))] + public static void CopyTo_Valid_Correct(IReadOnlyList source, IList destination, int index, IList expected) + { + source.CopyTo(destination, index); + Assert.Equal(expected, destination); + } + + public static TheoryData, IList, int> CopyTo_Invalid_ThrowsArgumentOutOfRangeException_TestData() + { + var data = new TheoryData, IList, int>(); + + data.Add( + new[] { 0, 1, 2, 3, 4, 5 }, new[] { 0, 0, 0, 0, 0, 0 }, -1 ); + + data.Add( + new[] { 0, 1, 2 }, new[] { 5, 4, 3, 2, 1, 0 }, 6 ); + + data.Add( + new[] { 0, 1, 2 }, Array.Empty(), 0 ); + + data.Add( + new[] { 0, 1, 2, 3, 4, 5 }, new[] { 0 }, 0 ); + + data.Add( + new[] { 0, 1, 2, 3, 4, 5 }, new[] { 0, 0, 0, 0, 0, 0 }, 1 ); + + return data; + } + + [Theory] + [MemberData(nameof(CopyTo_Invalid_ThrowsArgumentOutOfRangeException_TestData))] + public static void CopyTo_Invalid_ThrowsArgumentOutOfRangeException(IReadOnlyList source, IList destination, int index) + { + Assert.Throws(() => 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..ee3af7559e --- /dev/null +++ b/tests/Jellyfin.Extensions.Tests/Jellyfin.Extensions.Tests.csproj @@ -0,0 +1,35 @@ + + + + net6.0 + false + ../jellyfin-tests.ruleset + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + + + + + + + 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..125229ff92 --- /dev/null +++ b/tests/Jellyfin.Extensions.Tests/Json/Converters/JsonBoolNumberTests.cs @@ -0,0 +1,44 @@ +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(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(input.ToString(), _jsonOptions).ToProperty(); + } +} diff --git a/tests/Jellyfin.Common.Tests/Json/JsonCommaDelimitedArrayTests.cs b/tests/Jellyfin.Extensions.Tests/Json/Converters/JsonCommaDelimitedArrayTests.cs similarity index 98% rename from tests/Jellyfin.Common.Tests/Json/JsonCommaDelimitedArrayTests.cs rename to tests/Jellyfin.Extensions.Tests/Json/Converters/JsonCommaDelimitedArrayTests.cs index ca300401da..f2ca2ff081 100644 --- a/tests/Jellyfin.Common.Tests/Json/JsonCommaDelimitedArrayTests.cs +++ b/tests/Jellyfin.Extensions.Tests/Json/Converters/JsonCommaDelimitedArrayTests.cs @@ -1,11 +1,11 @@ -using System; +using System; using System.Text.Json; using System.Text.Json.Serialization; -using Jellyfin.Common.Tests.Models; +using Jellyfin.Extensions.Tests.Json.Models; using MediaBrowser.Model.Session; using Xunit; -namespace Jellyfin.Common.Tests.Json +namespace Jellyfin.Extensions.Tests.Json.Converters { public static class JsonCommaDelimitedArrayTests { diff --git a/tests/Jellyfin.Common.Tests/Json/JsonCommaDelimitedIReadOnlyListTests.cs b/tests/Jellyfin.Extensions.Tests/Json/Converters/JsonCommaDelimitedIReadOnlyListTests.cs similarity index 96% rename from tests/Jellyfin.Common.Tests/Json/JsonCommaDelimitedIReadOnlyListTests.cs rename to tests/Jellyfin.Extensions.Tests/Json/Converters/JsonCommaDelimitedIReadOnlyListTests.cs index 34ad9bac79..92886dcd28 100644 --- a/tests/Jellyfin.Common.Tests/Json/JsonCommaDelimitedIReadOnlyListTests.cs +++ b/tests/Jellyfin.Extensions.Tests/Json/Converters/JsonCommaDelimitedIReadOnlyListTests.cs @@ -1,10 +1,10 @@ -using System.Text.Json; +using System.Text.Json; using System.Text.Json.Serialization; -using Jellyfin.Common.Tests.Models; +using Jellyfin.Extensions.Tests.Json.Models; using MediaBrowser.Model.Session; using Xunit; -namespace Jellyfin.Common.Tests.Json +namespace Jellyfin.Extensions.Tests.Json.Converters { public static class JsonCommaDelimitedIReadOnlyListTests { diff --git a/tests/Jellyfin.Common.Tests/Json/JsonGuidConverterTests.cs b/tests/Jellyfin.Extensions.Tests/Json/Converters/JsonGuidConverterTests.cs similarity index 95% rename from tests/Jellyfin.Common.Tests/Json/JsonGuidConverterTests.cs rename to tests/Jellyfin.Extensions.Tests/Json/Converters/JsonGuidConverterTests.cs index dbfad3c2ff..8465d465ab 100644 --- a/tests/Jellyfin.Common.Tests/Json/JsonGuidConverterTests.cs +++ b/tests/Jellyfin.Extensions.Tests/Json/Converters/JsonGuidConverterTests.cs @@ -1,9 +1,9 @@ using System; using System.Text.Json; -using MediaBrowser.Common.Json.Converters; +using Jellyfin.Extensions.Json.Converters; using Xunit; -namespace Jellyfin.Common.Tests.Json +namespace Jellyfin.Extensions.Tests.Json.Converters { public class JsonGuidConverterTests { diff --git a/tests/Jellyfin.Model.Tests/Entities/JsonLowerCaseConverterTests.cs b/tests/Jellyfin.Extensions.Tests/Json/Converters/JsonLowerCaseConverterTests.cs similarity index 96% rename from tests/Jellyfin.Model.Tests/Entities/JsonLowerCaseConverterTests.cs rename to tests/Jellyfin.Extensions.Tests/Json/Converters/JsonLowerCaseConverterTests.cs index 955d296cc8..af9227de27 100644 --- a/tests/Jellyfin.Model.Tests/Entities/JsonLowerCaseConverterTests.cs +++ b/tests/Jellyfin.Extensions.Tests/Json/Converters/JsonLowerCaseConverterTests.cs @@ -1,9 +1,10 @@ using System.Text.Json; using System.Text.Json.Serialization; +using Jellyfin.Extensions.Json.Converters; using MediaBrowser.Model.Entities; using Xunit; -namespace Jellyfin.Model.Tests.Entities +namespace Jellyfin.Extensions.Tests.Json.Converters { public class JsonLowerCaseConverterTests { diff --git a/tests/Jellyfin.Common.Tests/Json/JsonNullableGuidConverterTests.cs b/tests/Jellyfin.Extensions.Tests/Json/Converters/JsonNullableGuidConverterTests.cs similarity index 96% rename from tests/Jellyfin.Common.Tests/Json/JsonNullableGuidConverterTests.cs rename to tests/Jellyfin.Extensions.Tests/Json/Converters/JsonNullableGuidConverterTests.cs index cb3b66c4c5..b0dbc09e4f 100644 --- a/tests/Jellyfin.Common.Tests/Json/JsonNullableGuidConverterTests.cs +++ b/tests/Jellyfin.Extensions.Tests/Json/Converters/JsonNullableGuidConverterTests.cs @@ -1,9 +1,9 @@ using System; using System.Text.Json; -using MediaBrowser.Common.Json.Converters; +using Jellyfin.Extensions.Json.Converters; using Xunit; -namespace Jellyfin.Common.Tests.Json +namespace Jellyfin.Extensions.Tests.Json.Converters { public class JsonNullableGuidConverterTests { diff --git a/tests/Jellyfin.Common.Tests/Json/JsonStringConverterTests.cs b/tests/Jellyfin.Extensions.Tests/Json/Converters/JsonStringConverterTests.cs similarity index 77% rename from tests/Jellyfin.Common.Tests/Json/JsonStringConverterTests.cs rename to tests/Jellyfin.Extensions.Tests/Json/Converters/JsonStringConverterTests.cs index fd77694b30..655e070742 100644 --- a/tests/Jellyfin.Common.Tests/Json/JsonStringConverterTests.cs +++ b/tests/Jellyfin.Extensions.Tests/Json/Converters/JsonStringConverterTests.cs @@ -1,19 +1,18 @@ -using System.Text.Json; -using MediaBrowser.Common.Json.Converters; +using System.Text.Json; +using Jellyfin.Extensions.Json.Converters; using Xunit; -namespace Jellyfin.Common.Tests.Json +namespace Jellyfin.Extensions.Tests.Json.Converters { public class JsonStringConverterTests { - private readonly JsonSerializerOptions _jsonSerializerOptions - = new () + private readonly JsonSerializerOptions _jsonSerializerOptions = new () + { + Converters = { - Converters = - { - new JsonStringConverter() - } - }; + new JsonStringConverter() + } + }; [Theory] [InlineData("\"test\"", "test")] @@ -36,4 +35,4 @@ namespace Jellyfin.Common.Tests.Json Assert.Equal(deserialized, output); } } -} \ No newline at end of file +} diff --git a/tests/Jellyfin.Common.Tests/Json/JsonVersionConverterTests.cs b/tests/Jellyfin.Extensions.Tests/Json/Converters/JsonVersionConverterTests.cs similarity index 89% rename from tests/Jellyfin.Common.Tests/Json/JsonVersionConverterTests.cs rename to tests/Jellyfin.Extensions.Tests/Json/Converters/JsonVersionConverterTests.cs index f2cefdbf8e..5fbac7eab0 100644 --- a/tests/Jellyfin.Common.Tests/Json/JsonVersionConverterTests.cs +++ b/tests/Jellyfin.Extensions.Tests/Json/Converters/JsonVersionConverterTests.cs @@ -1,9 +1,9 @@ -using System; +using System; using System.Text.Json; -using MediaBrowser.Common.Json.Converters; +using Jellyfin.Extensions.Json.Converters; using Xunit; -namespace Jellyfin.Common.Tests.Json +namespace Jellyfin.Extensions.Tests.Json.Converters { public class JsonVersionConverterTests { diff --git a/tests/Jellyfin.Common.Tests/Models/GenericBodyArrayModel.cs b/tests/Jellyfin.Extensions.Tests/Json/Models/GenericBodyArrayModel.cs similarity index 81% rename from tests/Jellyfin.Common.Tests/Models/GenericBodyArrayModel.cs rename to tests/Jellyfin.Extensions.Tests/Json/Models/GenericBodyArrayModel.cs index 276e1bfbe2..ef135278fd 100644 --- a/tests/Jellyfin.Common.Tests/Models/GenericBodyArrayModel.cs +++ b/tests/Jellyfin.Extensions.Tests/Json/Models/GenericBodyArrayModel.cs @@ -1,8 +1,8 @@ -using System.Diagnostics.CodeAnalysis; +using System.Diagnostics.CodeAnalysis; using System.Text.Json.Serialization; -using MediaBrowser.Common.Json.Converters; +using Jellyfin.Extensions.Json.Converters; -namespace Jellyfin.Common.Tests.Models +namespace Jellyfin.Extensions.Tests.Json.Models { /// /// The generic body model. diff --git a/tests/Jellyfin.Common.Tests/Models/GenericBodyIReadOnlyListModel.cs b/tests/Jellyfin.Extensions.Tests/Json/Models/GenericBodyIReadOnlyListModel.cs similarity index 78% rename from tests/Jellyfin.Common.Tests/Models/GenericBodyIReadOnlyListModel.cs rename to tests/Jellyfin.Extensions.Tests/Json/Models/GenericBodyIReadOnlyListModel.cs index 627454b25f..8e7b5a35b4 100644 --- a/tests/Jellyfin.Common.Tests/Models/GenericBodyIReadOnlyListModel.cs +++ b/tests/Jellyfin.Extensions.Tests/Json/Models/GenericBodyIReadOnlyListModel.cs @@ -1,8 +1,8 @@ -using System.Collections.Generic; +using System.Collections.Generic; using System.Text.Json.Serialization; -using MediaBrowser.Common.Json.Converters; +using Jellyfin.Extensions.Json.Converters; -namespace Jellyfin.Common.Tests.Models +namespace Jellyfin.Extensions.Tests.Json.Models { /// /// The generic body IReadOnlyList model. diff --git a/tests/Jellyfin.Common.Tests/Extensions/ShuffleExtensionsTests.cs b/tests/Jellyfin.Extensions.Tests/ShuffleExtensionsTests.cs similarity index 66% rename from tests/Jellyfin.Common.Tests/Extensions/ShuffleExtensionsTests.cs rename to tests/Jellyfin.Extensions.Tests/ShuffleExtensionsTests.cs index cbdbcf112c..a73cfb0781 100644 --- a/tests/Jellyfin.Common.Tests/Extensions/ShuffleExtensionsTests.cs +++ b/tests/Jellyfin.Extensions.Tests/ShuffleExtensionsTests.cs @@ -1,18 +1,15 @@ using System; -using MediaBrowser.Common.Extensions; using Xunit; -namespace Jellyfin.Common.Tests.Extensions +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); + Random.Shared.NextBytes(original); byte[] shuffled = (byte[])original.Clone(); shuffled.Shuffle(); diff --git a/tests/Jellyfin.Extensions.Tests/StringExtensionsTests.cs b/tests/Jellyfin.Extensions.Tests/StringExtensionsTests.cs new file mode 100644 index 0000000000..7186cc0236 --- /dev/null +++ b/tests/Jellyfin.Extensions.Tests/StringExtensionsTests.cs @@ -0,0 +1,41 @@ +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)); + } + + [Theory] + [InlineData("", 'q', "")] + [InlineData("Banana split", ' ', "Banana")] + [InlineData("Banana split", 'q', "Banana split")] + [InlineData("Banana split 2", ' ', "Banana")] + public void LeftPart_ValidArgsCharNeedle_Correct(string str, char needle, string expectedResult) + { + var result = str.AsSpan().LeftPart(needle).ToString(); + Assert.Equal(expectedResult, result); + } + + [Theory] + [InlineData("", 'q', "")] + [InlineData("Banana split", ' ', "split")] + [InlineData("Banana split", 'q', "Banana split")] + [InlineData("Banana split.", '.', "")] + [InlineData("Banana split 2", ' ', "2")] + public void RightPart_ValidArgsCharNeedle_Correct(string str, char needle, string expectedResult) + { + var result = str.AsSpan().RightPart(needle).ToString(); + Assert.Equal(expectedResult, result); + } + } +} diff --git a/tests/Jellyfin.MediaEncoding.Tests/EncoderValidatorTests.cs b/tests/Jellyfin.MediaEncoding.Tests/EncoderValidatorTests.cs index 39fd8afda1..c0c363d3da 100644 --- a/tests/Jellyfin.MediaEncoding.Tests/EncoderValidatorTests.cs +++ b/tests/Jellyfin.MediaEncoding.Tests/EncoderValidatorTests.cs @@ -1,6 +1,4 @@ using System; -using System.Collections; -using System.Collections.Generic; using MediaBrowser.MediaEncoding.Encoder; using Microsoft.Extensions.Logging.Abstractions; using Xunit; @@ -9,15 +7,18 @@ namespace Jellyfin.MediaEncoding.Tests { public class EncoderValidatorTests { + private readonly EncoderValidator _encoderValidator = new EncoderValidator(new NullLogger(), "ffmpeg"); + [Theory] [ClassData(typeof(GetFFmpegVersionTestData))] public void GetFFmpegVersionTest(string versionOutput, Version? version) { - var val = new EncoderValidator(new NullLogger()); - Assert.Equal(version, val.GetFFmpegVersion(versionOutput)); + Assert.Equal(version, _encoderValidator.GetFFmpegVersionInternal(versionOutput)); } [Theory] + [InlineData(EncoderValidatorTestsData.FFmpegV44Output, true)] + [InlineData(EncoderValidatorTestsData.FFmpegV432Output, true)] [InlineData(EncoderValidatorTestsData.FFmpegV431Output, true)] [InlineData(EncoderValidatorTestsData.FFmpegV43Output, true)] [InlineData(EncoderValidatorTestsData.FFmpegV421Output, true)] @@ -28,25 +29,24 @@ namespace Jellyfin.MediaEncoding.Tests [InlineData(EncoderValidatorTestsData.FFmpegGitUnknownOutput, false)] public void ValidateVersionInternalTest(string versionOutput, bool valid) { - var val = new EncoderValidator(new NullLogger()); - Assert.Equal(valid, val.ValidateVersionInternal(versionOutput)); + Assert.Equal(valid, _encoderValidator.ValidateVersionInternal(versionOutput)); } - private class GetFFmpegVersionTestData : IEnumerable + private class GetFFmpegVersionTestData : TheoryData { - public IEnumerator GetEnumerator() + public GetFFmpegVersionTestData() { - yield return new object?[] { EncoderValidatorTestsData.FFmpegV431Output, new Version(4, 3, 1) }; - yield return new object?[] { EncoderValidatorTestsData.FFmpegV43Output, new Version(4, 3) }; - yield return new object?[] { EncoderValidatorTestsData.FFmpegV421Output, new Version(4, 2, 1) }; - yield return new object?[] { EncoderValidatorTestsData.FFmpegV42Output, new Version(4, 2) }; - yield return new object?[] { EncoderValidatorTestsData.FFmpegV414Output, new Version(4, 1, 4) }; - yield return new object?[] { EncoderValidatorTestsData.FFmpegV404Output, new Version(4, 0, 4) }; - yield return new object?[] { EncoderValidatorTestsData.FFmpegGitUnknownOutput2, new Version(4, 0) }; - yield return new object?[] { EncoderValidatorTestsData.FFmpegGitUnknownOutput, null }; + Add(EncoderValidatorTestsData.FFmpegV44Output, new Version(4, 4)); + Add(EncoderValidatorTestsData.FFmpegV432Output, new Version(4, 3, 2)); + Add(EncoderValidatorTestsData.FFmpegV431Output, new Version(4, 3, 1)); + Add(EncoderValidatorTestsData.FFmpegV43Output, new Version(4, 3)); + Add(EncoderValidatorTestsData.FFmpegV421Output, new Version(4, 2, 1)); + Add(EncoderValidatorTestsData.FFmpegV42Output, new Version(4, 2)); + Add(EncoderValidatorTestsData.FFmpegV414Output, new Version(4, 1, 4)); + Add(EncoderValidatorTestsData.FFmpegV404Output, new Version(4, 0, 4)); + Add(EncoderValidatorTestsData.FFmpegGitUnknownOutput2, new Version(4, 0)); + Add(EncoderValidatorTestsData.FFmpegGitUnknownOutput, null); } - - IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); } } } diff --git a/tests/Jellyfin.MediaEncoding.Tests/EncoderValidatorTestsData.cs b/tests/Jellyfin.MediaEncoding.Tests/EncoderValidatorTestsData.cs index 9f5bef9a88..02bf046ed1 100644 --- a/tests/Jellyfin.MediaEncoding.Tests/EncoderValidatorTestsData.cs +++ b/tests/Jellyfin.MediaEncoding.Tests/EncoderValidatorTestsData.cs @@ -2,6 +2,30 @@ namespace Jellyfin.MediaEncoding.Tests { internal static class EncoderValidatorTestsData { + public const string FFmpegV44Output = @"ffmpeg version 4.4-Jellyfin Copyright (c) 2000-2021 the FFmpeg developers +built with gcc 10.3.0 (Rev5, Built by MSYS2 project) +configuration: --disable-static --enable-shared --extra-version=Jellyfin --disable-ffplay --disable-debug --enable-gpl --enable-version3 --enable-bzlib --enable-iconv --enable-lzma --enable-zlib --enable-sdl2 --enable-fontconfig --enable-gmp --enable-libass --enable-libzimg --enable-libbluray --enable-libfreetype --enable-libmp3lame --enable-libopus --enable-libtheora --enable-libvorbis --enable-libwebp --enable-libvpx --enable-libx264 --enable-libx265 --enable-libdav1d --enable-opencl --enable-dxva2 --enable-d3d11va --enable-amf --enable-libmfx --enable-cuda --enable-cuda-llvm --enable-cuvid --enable-nvenc --enable-nvdec --enable-ffnvcodec --enable-gnutls +libavutil 56. 70.100 / 56. 70.100 +libavcodec 58.134.100 / 58.134.100 +libavformat 58. 76.100 / 58. 76.100 +libavdevice 58. 13.100 / 58. 13.100 +libavfilter 7.110.100 / 7.110.100 +libswscale 5. 9.100 / 5. 9.100 +libswresample 3. 9.100 / 3. 9.100 +libpostproc 55. 9.100 / 55. 9.100"; + + public const string FFmpegV432Output = @"ffmpeg version n4.3.2-Jellyfin Copyright (c) 2000-2021 the FFmpeg developers +built with gcc 10.2.0 (Rev9, Built by MSYS2 project) +configuration: --disable-static --enable-shared --cc='ccache gcc' --cxx='ccache g++' --extra-version=Jellyfin --disable-ffplay --disable-debug --enable-lto --enable-gpl --enable-version3 --enable-bzlib --enable-iconv --enable-lzma --enable-zlib --enable-sdl2 --enable-fontconfig --enable-gmp --enable-libass --enable-libzimg --enable-libbluray --enable-libfreetype --enable-libmp3lame --enable-libopus --enable-libtheora --enable-libvorbis --enable-libwebp --enable-libvpx --enable-libx264 --enable-libx265 --enable-libdav1d --enable-opencl --enable-dxva2 --enable-d3d11va --enable-amf --enable-libmfx --enable-cuda --enable-cuda-llvm --enable-cuvid --enable-nvenc --enable-nvdec --enable-ffnvcodec --enable-gnutls +libavutil 56. 51.100 / 56. 51.100 +libavcodec 58. 91.100 / 58. 91.100 +libavformat 58. 45.100 / 58. 45.100 +libavdevice 58. 10.100 / 58. 10.100 +libavfilter 7. 85.100 / 7. 85.100 +libswscale 5. 7.100 / 5. 7.100 +libswresample 3. 7.100 / 3. 7.100 +libpostproc 55. 7.100 / 55. 7.100"; + public const string FFmpegV431Output = @"ffmpeg version n4.3.1 Copyright (c) 2000-2020 the FFmpeg developers built with gcc 10.1.0 (GCC) configuration: --prefix=/usr --disable-debug --disable-static --disable-stripping --enable-avisynth --enable-fontconfig --enable-gmp --enable-gnutls --enable-gpl --enable-ladspa --enable-libaom --enable-libass --enable-libbluray --enable-libdav1d --enable-libdrm --enable-libfreetype --enable-libfribidi --enable-libgsm --enable-libiec61883 --enable-libjack --enable-libmfx --enable-libmodplug --enable-libmp3lame --enable-libopencore_amrnb --enable-libopencore_amrwb --enable-libopenjpeg --enable-libopus --enable-libpulse --enable-librav1e --enable-libsoxr --enable-libspeex --enable-libsrt --enable-libssh --enable-libtheora --enable-libv4l2 --enable-libvidstab --enable-libvmaf --enable-libvorbis --enable-libvpx --enable-libwebp --enable-libx264 --enable-libx265 --enable-libxcb --enable-libxml2 --enable-libxvid --enable-nvdec --enable-nvenc --enable-omx --enable-shared --enable-version3 diff --git a/tests/Jellyfin.MediaEncoding.Tests/FFprobeParserTests.cs b/tests/Jellyfin.MediaEncoding.Tests/FFprobeParserTests.cs index 415682e855..97dbb3be03 100644 --- a/tests/Jellyfin.MediaEncoding.Tests/FFprobeParserTests.cs +++ b/tests/Jellyfin.MediaEncoding.Tests/FFprobeParserTests.cs @@ -1,8 +1,9 @@ using System.IO; using System.Text.Json; using System.Threading.Tasks; -using MediaBrowser.Common.Json; +using Jellyfin.Extensions.Json; using MediaBrowser.MediaEncoding.Probing; +using MediaBrowser.Model.IO; using Xunit; namespace Jellyfin.MediaEncoding.Tests @@ -14,9 +15,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 = AsyncFile.OpenRead(path)) { - await JsonSerializer.DeserializeAsync(stream, JsonDefaults.Options).ConfigureAwait(false); + var res = await JsonSerializer.DeserializeAsync(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 8321d02552..dc4a42c19f 100644 --- a/tests/Jellyfin.MediaEncoding.Tests/Jellyfin.MediaEncoding.Tests.csproj +++ b/tests/Jellyfin.MediaEncoding.Tests/Jellyfin.MediaEncoding.Tests.csproj @@ -6,11 +6,8 @@ - net5.0 + net6.0 false - true - enable - AllEnabledByDefault ../jellyfin-tests.ruleset @@ -21,10 +18,14 @@ - + + + + + + - diff --git a/tests/Jellyfin.MediaEncoding.Tests/Probing/ProbeResultNormalizerTests.cs b/tests/Jellyfin.MediaEncoding.Tests/Probing/ProbeResultNormalizerTests.cs index 69e2aa4372..4504924cbf 100644 --- a/tests/Jellyfin.MediaEncoding.Tests/Probing/ProbeResultNormalizerTests.cs +++ b/tests/Jellyfin.MediaEncoding.Tests/Probing/ProbeResultNormalizerTests.cs @@ -1,10 +1,14 @@ +using System; +using System.Globalization; using System.IO; using System.Text.Json; -using MediaBrowser.Common.Json; +using Jellyfin.Extensions.Json; using MediaBrowser.MediaEncoding.Probing; using MediaBrowser.Model.Entities; +using MediaBrowser.Model.Globalization; using MediaBrowser.Model.MediaInfo; using Microsoft.Extensions.Logging.Abstractions; +using Moq; using Xunit; namespace Jellyfin.MediaEncoding.Tests.Probing @@ -17,9 +21,9 @@ namespace Jellyfin.MediaEncoding.Tests.Probing [Fact] public void GetMediaInfo_MetaData_Success() { - var bytes = File.ReadAllBytes("Test Data/Probing/some_matadata.json"); + var bytes = File.ReadAllBytes("Test Data/Probing/video_metadata.json"); var internalMediaInfoResult = JsonSerializer.Deserialize(bytes, _jsonOptions); - MediaInfo res = _probeResultNormalizer.GetMediaInfo(internalMediaInfoResult, VideoType.VideoFile, false, "Test Data/Probing/some_matadata.mkv", MediaProtocol.File); + MediaInfo res = _probeResultNormalizer.GetMediaInfo(internalMediaInfoResult, VideoType.VideoFile, false, "Test Data/Probing/video_metadata.mkv", MediaProtocol.File); Assert.Single(res.MediaStreams); @@ -52,5 +56,141 @@ namespace Jellyfin.MediaEncoding.Tests.Probing Assert.Empty(res.Chapters); Assert.Equal("Just color bars", res.Overview); } + + [Fact] + public void GetMediaInfo_Mp4MetaData_Success() + { + var bytes = File.ReadAllBytes("Test Data/Probing/video_mp4_metadata.json"); + var internalMediaInfoResult = JsonSerializer.Deserialize(bytes, _jsonOptions); + + // subtitle handling requires a localization object, set a mock to return the input string + var mockLocalization = new Mock(); + mockLocalization.Setup(x => x.GetLocalizedString(It.IsAny())).Returns(x => x); + ProbeResultNormalizer localizedProbeResultNormalizer = new ProbeResultNormalizer(new NullLogger(), mockLocalization.Object); + + MediaInfo res = localizedProbeResultNormalizer.GetMediaInfo(internalMediaInfoResult, VideoType.VideoFile, false, "Test Data/Probing/video_mp4_metadata.mkv", MediaProtocol.File); + + // [Video, Audio (Main), Audio (Commentary), Subtitle (Main, Spanish), Subtitle (Main, English), Subtitle (Commentary) + Assert.Equal(6, res.MediaStreams.Count); + + Assert.NotNull(res.VideoStream); + Assert.Equal(res.MediaStreams[0], res.VideoStream); + Assert.Equal(0, res.VideoStream.Index); + Assert.Equal("h264", res.VideoStream.Codec); + Assert.Equal("High", res.VideoStream.Profile); + Assert.Equal(MediaStreamType.Video, res.VideoStream.Type); + Assert.Equal(358, res.VideoStream.Height); + Assert.Equal(720, res.VideoStream.Width); + Assert.Equal("2.40:1", res.VideoStream.AspectRatio); + Assert.Equal("yuv420p", res.VideoStream.PixelFormat); + Assert.Equal(31d, res.VideoStream.Level); + Assert.Equal(1, res.VideoStream.RefFrames); + Assert.True(res.VideoStream.IsAVC); + Assert.Equal(120f, res.VideoStream.RealFrameRate); + Assert.Equal("1/90000", res.VideoStream.TimeBase); + Assert.Equal(1147365, res.VideoStream.BitRate); + Assert.Equal(8, res.VideoStream.BitDepth); + Assert.True(res.VideoStream.IsDefault); + Assert.Equal("und", res.VideoStream.Language); + + Assert.Equal(MediaStreamType.Audio, res.MediaStreams[1].Type); + Assert.Equal("aac", res.MediaStreams[1].Codec); + Assert.Equal(7, res.MediaStreams[1].Channels); + Assert.True(res.MediaStreams[1].IsDefault); + Assert.Equal("eng", res.MediaStreams[1].Language); + Assert.Equal("Surround 6.1", res.MediaStreams[1].Title); + + Assert.Equal(MediaStreamType.Audio, res.MediaStreams[2].Type); + Assert.Equal("aac", res.MediaStreams[2].Codec); + Assert.Equal(2, res.MediaStreams[2].Channels); + Assert.False(res.MediaStreams[2].IsDefault); + Assert.Equal("eng", res.MediaStreams[2].Language); + Assert.Equal("Commentary", res.MediaStreams[2].Title); + + Assert.Equal("spa", res.MediaStreams[3].Language); + Assert.Equal(MediaStreamType.Subtitle, res.MediaStreams[3].Type); + Assert.Equal("DVDSUB", res.MediaStreams[3].Codec); + Assert.Null(res.MediaStreams[3].Title); + + Assert.Equal("eng", res.MediaStreams[4].Language); + Assert.Equal(MediaStreamType.Subtitle, res.MediaStreams[4].Type); + Assert.Equal("mov_text", res.MediaStreams[4].Codec); + Assert.Null(res.MediaStreams[4].Title); + + Assert.Equal("eng", res.MediaStreams[5].Language); + Assert.Equal(MediaStreamType.Subtitle, res.MediaStreams[5].Type); + Assert.Equal("mov_text", res.MediaStreams[5].Codec); + Assert.Equal("Commentary", res.MediaStreams[5].Title); + } + + [Fact] + public void GetMediaInfo_MusicVideo_Success() + { + var bytes = File.ReadAllBytes("Test Data/Probing/music_video_metadata.json"); + var internalMediaInfoResult = JsonSerializer.Deserialize(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, DateTimeStyles.AdjustToUniversal), res.PremiereDate); + } + + [Fact] + public void GetMediaInfo_GivenOriginalDateContainsOnlyYear_Success() + { + var bytes = File.ReadAllBytes("Test Data/Probing/music_year_only_metadata.json"); + var internalMediaInfoResult = JsonSerializer.Deserialize(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, DateTimeStyles.AdjustToUniversal), res.PremiereDate); + Assert.Contains("Electronic", res.Genres); + Assert.Contains("Ambient", res.Genres); + Assert.Contains("Pop", res.Genres); + Assert.Contains("Jazz", res.Genres); + } + + [Fact] + public void GetMediaInfo_Music_Success() + { + var bytes = File.ReadAllBytes("Test Data/Probing/music_metadata.json"); + var internalMediaInfoResult = JsonSerializer.Deserialize(bytes, _jsonOptions); + MediaInfo res = _probeResultNormalizer.GetMediaInfo(internalMediaInfoResult, null, true, "Test Data/Probing/music.flac", MediaProtocol.File); + + Assert.Equal("UP NO MORE", res.Name); + Assert.Single(res.Artists); + Assert.Equal("TWICE", res.Artists[0]); + Assert.Equal("Eyes wide open", res.Album); + Assert.Equal(2020, res.ProductionYear); + Assert.True(res.PremiereDate.HasValue); + Assert.Equal(DateTime.Parse("2020-10-26T00:00Z", DateTimeFormatInfo.CurrentInfo, DateTimeStyles.AdjustToUniversal), res.PremiereDate); + Assert.Equal(22, res.People.Length); + Assert.Equal("Krysta Youngs", res.People[0].Name); + Assert.Equal(PersonType.Composer, res.People[0].Type); + Assert.Equal("Julia Ross", res.People[1].Name); + Assert.Equal(PersonType.Composer, res.People[1].Type); + Assert.Equal("Yiwoomin", res.People[2].Name); + Assert.Equal(PersonType.Composer, res.People[2].Type); + Assert.Equal("Ji-hyo Park", res.People[3].Name); + Assert.Equal(PersonType.Lyricist, res.People[3].Type); + Assert.Equal("Yiwoomin", res.People[4].Name); + Assert.Equal(PersonType.Actor, res.People[4].Type); + Assert.Equal("Electric Piano", res.People[4].Role); + Assert.Equal(4, res.Genres.Length); + Assert.Contains("Electronic", res.Genres); + Assert.Contains("Trance", res.Genres); + Assert.Contains("Dance", res.Genres); + Assert.Contains("Jazz", res.Genres); + } } } diff --git a/tests/Jellyfin.MediaEncoding.Tests/Subtitles/SrtParserTests.cs b/tests/Jellyfin.MediaEncoding.Tests/Subtitles/SrtParserTests.cs index 537a944b03..c07c9ea7db 100644 --- a/tests/Jellyfin.MediaEncoding.Tests/Subtitles/SrtParserTests.cs +++ b/tests/Jellyfin.MediaEncoding.Tests/Subtitles/SrtParserTests.cs @@ -31,5 +31,27 @@ namespace Jellyfin.MediaEncoding.Subtitles.Tests Assert.Equal("Very good, Lieutenant.", trackEvent2.Text); } } + + [Fact] + public void Parse_EmptyNewlineBetweenText_Success() + { + using (var stream = File.OpenRead("Test Data/example2.srt")) + { + var parsed = new SrtParser(new NullLogger()).Parse(stream, CancellationToken.None); + Assert.Equal(2, parsed.TrackEvents.Count); + + var trackEvent1 = parsed.TrackEvents[0]; + Assert.Equal("311", trackEvent1.Id); + Assert.Equal(TimeSpan.Parse("00:16:46.465", CultureInfo.InvariantCulture).Ticks, trackEvent1.StartPositionTicks); + Assert.Equal(TimeSpan.Parse("00:16:49.009", CultureInfo.InvariantCulture).Ticks, trackEvent1.EndPositionTicks); + Assert.Equal("Una vez que la gente se entere" + Environment.NewLine + Environment.NewLine + "de que ustedes están aquí,", trackEvent1.Text); + + var trackEvent2 = parsed.TrackEvents[1]; + Assert.Equal("312", trackEvent2.Id); + Assert.Equal(TimeSpan.Parse("00:16:49.092", CultureInfo.InvariantCulture).Ticks, trackEvent2.StartPositionTicks); + Assert.Equal(TimeSpan.Parse("00:16:51.470", CultureInfo.InvariantCulture).Ticks, trackEvent2.EndPositionTicks); + Assert.Equal("este lugar se convertirá" + Environment.NewLine + Environment.NewLine + "en un maldito zoológico.", trackEvent2.Text); + } + } } } diff --git a/tests/Jellyfin.MediaEncoding.Tests/Subtitles/SsaParserTests.cs b/tests/Jellyfin.MediaEncoding.Tests/Subtitles/SsaParserTests.cs index 5db80c3001..56649db8f8 100644 --- a/tests/Jellyfin.MediaEncoding.Tests/Subtitles/SsaParserTests.cs +++ b/tests/Jellyfin.MediaEncoding.Tests/Subtitles/SsaParserTests.cs @@ -38,10 +38,11 @@ namespace Jellyfin.MediaEncoding.Subtitles.Tests } } - public static IEnumerable Parse_MultipleDialogues_TestData() + public static TheoryData> Parse_MultipleDialogues_TestData() { - yield return new object[] - { + var data = new TheoryData>(); + + data.Add( @"[Events] Format: Layer, Start, End, Text Dialogue: ,0:00:01.18,0:00:01.85,dialogue1 @@ -65,8 +66,9 @@ namespace Jellyfin.MediaEncoding.Subtitles.Tests StartPositionTicks = 31800000, EndPositionTicks = 38500000 } - } - }; + }); + + return data; } [Fact] diff --git a/tests/Jellyfin.MediaEncoding.Tests/Subtitles/SubtitleEncoderTests.cs b/tests/Jellyfin.MediaEncoding.Tests/Subtitles/SubtitleEncoderTests.cs new file mode 100644 index 0000000000..639c364df2 --- /dev/null +++ b/tests/Jellyfin.MediaEncoding.Tests/Subtitles/SubtitleEncoderTests.cs @@ -0,0 +1,83 @@ +using System.Threading; +using System.Threading.Tasks; +using AutoFixture; +using AutoFixture.AutoMoq; +using MediaBrowser.MediaEncoding.Subtitles; +using MediaBrowser.Model.Dto; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.MediaInfo; +using Xunit; + +namespace Jellyfin.MediaEncoding.Subtitles.Tests +{ + public class SubtitleEncoderTests + { + internal static TheoryData GetReadableFile_Valid_TestData() + { + var data = new TheoryData(); + + data.Add( + new MediaSourceInfo() + { + Protocol = MediaProtocol.File + }, + new MediaStream() + { + Path = "/media/sub.ass", + IsExternal = true + }, + new SubtitleEncoder.SubtitleInfo("/media/sub.ass", MediaProtocol.File, "ass", true)); + + data.Add( + new MediaSourceInfo() + { + Protocol = MediaProtocol.File + }, + new MediaStream() + { + Path = "/media/sub.ssa", + IsExternal = true + }, + new SubtitleEncoder.SubtitleInfo("/media/sub.ssa", MediaProtocol.File, "ssa", true)); + + data.Add( + new MediaSourceInfo() + { + Protocol = MediaProtocol.File + }, + new MediaStream() + { + Path = "/media/sub.srt", + IsExternal = true + }, + new SubtitleEncoder.SubtitleInfo("/media/sub.srt", MediaProtocol.File, "srt", true)); + + data.Add( + new MediaSourceInfo() + { + Protocol = MediaProtocol.Http + }, + new MediaStream() + { + Path = "/media/sub.ass", + IsExternal = true + }, + new SubtitleEncoder.SubtitleInfo("/media/sub.ass", MediaProtocol.File, "ass", true)); + + return data; + } + + [Theory] + [MemberData(nameof(GetReadableFile_Valid_TestData))] + internal async Task GetReadableFile_Valid_Success(MediaSourceInfo mediaSource, MediaStream subtitleStream, SubtitleEncoder.SubtitleInfo subtitleInfo) + { + var fixture = new Fixture().Customize(new AutoMoqCustomization { ConfigureMembers = true }); + var subtitleEncoder = fixture.Create(); + var result = await subtitleEncoder.GetReadableFile(mediaSource, subtitleStream, CancellationToken.None).ConfigureAwait(false); + Assert.Equal(subtitleInfo.Path, result.Path); + Assert.Equal(subtitleInfo.Protocol, result.Protocol); + Assert.Equal(subtitleInfo.Format, result.Format); + Assert.Equal(subtitleInfo.IsExternal, result.IsExternal); + } + } +} diff --git a/tests/Jellyfin.MediaEncoding.Tests/Test Data/Probing/music_metadata.json b/tests/Jellyfin.MediaEncoding.Tests/Test Data/Probing/music_metadata.json new file mode 100644 index 0000000000..6530629fe8 --- /dev/null +++ b/tests/Jellyfin.MediaEncoding.Tests/Test Data/Probing/music_metadata.json @@ -0,0 +1,144 @@ +{ + "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": 9447984, + "duration": "214.240000", + "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": 500, + "coded_width": 500, + "coded_height": 500, + "closed_captions": 0, + "has_b_frames": 0, + "sample_aspect_ratio": "1:1", + "display_aspect_ratio": "1:1", + "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": 19281600, + "duration": "214.240000", + "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": "03 UP NO MORE.flac", + "nb_streams": 2, + "nb_programs": 0, + "format_name": "flac", + "format_long_name": "raw FLAC", + "start_time": "0.000000", + "duration": "214.240000", + "size": "28714641", + "bit_rate": "1072242", + "probe_score": 100, + "tags": { + "MUSICBRAINZ_RELEASEGROUPID": "aa05ff10-8589-4c9c-a0d4-6b024f4e4556", + "ORIGINALDATE": "2020-10-26", + "ORIGINALYEAR": "2020", + "RELEASETYPE": "album", + "MUSICBRAINZ_ALBUMID": "222e6610-75c9-400e-8dc3-bb61f9fc5ca7", + "SCRIPT": "Latn", + "ALBUM": "Eyes wide open", + "RELEASECOUNTRY": "JP", + "BARCODE": "190295105280", + "LABEL": "JYP Entertainment", + "RELEASESTATUS": "official", + "DATE": "2020-10-26", + "MUSICBRAINZ_ALBUMARTISTID": "8da127cc-c432-418f-b356-ef36210d82ac", + "album_artist": "TWICE", + "ALBUMARTISTSORT": "TWICE", + "TOTALDISCS": "1", + "TOTALTRACKS": "13", + "MEDIA": "Digital Media", + "disc": "1", + "MUSICBRAINZ_TRACKID": "7d1a1044-b564-480d-9df3-22f9656fdb97", + "TITLE": "UP NO MORE", + "ISRC": "US5TA2000136", + "PERFORMER": "Yiwoomin (electric piano);Yiwoomin (synthesizer);Yiwoomin (bass);Yiwoomin (guitar);TWICE;Tzu-yu Chou (vocals);Momo Hirai (vocals);Na-yeon Im (vocals);Da-hyun Kim (vocals);Sana Minatozaki (vocals);Mina Myoui (vocals);Ji-hyo Park (vocals);Chae-young Son (vocals);Jeong-yeon Yoo (vocals);Perrie (background vocals)", + "MIXER": "Bong Won Shin", + "ARRANGER": "Krysta Youngs;Julia Ross;Yiwoomin", + "MUSICBRAINZ_WORKID": "02b37083-0337-4721-9f17-bf31971043e8", + "LANGUAGE": "kor;eng", + "WORK": "Up No More", + "COMPOSER": "Krysta Youngs;Julia Ross;Yiwoomin", + "COMPOSERSORT": "Krysta Youngs;Ross, Julia;Yiwoomin", + "LYRICIST": "Ji-hyo Park", + "MUSICBRAINZ_ARTISTID": "8da127cc-c432-418f-b356-ef36210d82ac", + "ARTIST": "TWICE", + "ARTISTSORT": "TWICE", + "ARTISTS": "TWICE", + "MUSICBRAINZ_RELEASETRACKID": "ad49b840-da9e-4e7c-924b-29fdee187052", + "track": "3", + "GENRE": "Electronic;Trance;Dance;Jazz", + "WEBSITE": "http://twice.jype.com/;http://www.twicejapan.com/", + "ACOUSTID_ID": "aae2e972-108c-4d0c-8e31-9d078283e3dc", + "MOOD": "Not acoustic;Not aggressive;Electronic;Happy;Party;Not relaxed;Not sad", + "TRACKTOTAL": "13", + "DISCTOTAL": "1" + } + } +} 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/some_matadata.json b/tests/Jellyfin.MediaEncoding.Tests/Test Data/Probing/video_metadata.json similarity index 100% rename from tests/Jellyfin.MediaEncoding.Tests/Test Data/Probing/some_matadata.json rename to tests/Jellyfin.MediaEncoding.Tests/Test Data/Probing/video_metadata.json diff --git a/tests/Jellyfin.MediaEncoding.Tests/Test Data/Probing/video_mp4_metadata.json b/tests/Jellyfin.MediaEncoding.Tests/Test Data/Probing/video_mp4_metadata.json new file mode 100644 index 0000000000..77e3def76d --- /dev/null +++ b/tests/Jellyfin.MediaEncoding.Tests/Test Data/Probing/video_mp4_metadata.json @@ -0,0 +1,260 @@ +{ + "streams": [ + { + "index": 0, + "codec_name": "h264", + "codec_long_name": "H.264 / AVC / MPEG-4 AVC / MPEG-4 part 10", + "profile": "High", + "codec_type": "video", + "codec_tag_string": "avc1", + "codec_tag": "0x31637661", + "width": 720, + "height": 358, + "coded_width": 720, + "coded_height": 358, + "closed_captions": 0, + "has_b_frames": 2, + "sample_aspect_ratio": "32:27", + "display_aspect_ratio": "1280:537", + "pix_fmt": "yuv420p", + "level": 31, + "color_range": "tv", + "color_space": "smpte170m", + "color_transfer": "bt709", + "color_primaries": "smpte170m", + "chroma_location": "left", + "refs": 1, + "is_avc": "true", + "nal_length_size": "4", + "r_frame_rate": "120/1", + "avg_frame_rate": "1704753000/71073479", + "time_base": "1/90000", + "start_pts": 0, + "start_time": "0.000000", + "duration_ts": 1421469580, + "duration": "15794.106444", + "bit_rate": "1147365", + "bits_per_raw_sample": "8", + "nb_frames": "378834", + "disposition": { + "default": 1, + "dub": 0, + "original": 0, + "comment": 0, + "lyrics": 0, + "karaoke": 0, + "forced": 0, + "hearing_impaired": 0, + "visual_impaired": 0, + "clean_effects": 0, + "attached_pic": 0, + "timed_thumbnails": 0 + }, + "tags": { + "creation_time": "2021-09-13T22:42:42.000000Z", + "language": "und", + "handler_name": "VideoHandler", + "vendor_id": "[0][0][0][0]" + } + }, + { + "index": 1, + "codec_name": "aac", + "codec_long_name": "AAC (Advanced Audio Coding)", + "profile": "LC", + "codec_type": "audio", + "codec_tag_string": "mp4a", + "codec_tag": "0x6134706d", + "sample_fmt": "fltp", + "sample_rate": "48000", + "channels": 7, + "bits_per_sample": 0, + "r_frame_rate": "0/0", + "avg_frame_rate": "0/0", + "time_base": "1/48000", + "start_pts": 0, + "start_time": "0.000000", + "duration_ts": 758115312, + "duration": "15794.069000", + "bit_rate": "224197", + "nb_frames": "740348", + "disposition": { + "default": 1, + "dub": 0, + "original": 0, + "comment": 0, + "lyrics": 0, + "karaoke": 0, + "forced": 0, + "hearing_impaired": 0, + "visual_impaired": 0, + "clean_effects": 0, + "attached_pic": 0, + "timed_thumbnails": 0 + }, + "tags": { + "creation_time": "2021-09-13T22:42:42.000000Z", + "language": "eng", + "handler_name": "Surround 6.1", + "vendor_id": "[0][0][0][0]" + } + }, + { + "index": 2, + "codec_name": "aac", + "codec_long_name": "AAC (Advanced Audio Coding)", + "profile": "LC", + "codec_type": "audio", + "codec_tag_string": "mp4a", + "codec_tag": "0x6134706d", + "sample_fmt": "fltp", + "sample_rate": "48000", + "channels": 2, + "channel_layout": "stereo", + "bits_per_sample": 0, + "r_frame_rate": "0/0", + "avg_frame_rate": "0/0", + "time_base": "1/48000", + "start_pts": 0, + "start_time": "0.000000", + "duration_ts": 758114304, + "duration": "15794.048000", + "bit_rate": "160519", + "nb_frames": "740347", + "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 + }, + "tags": { + "creation_time": "2021-09-13T22:42:42.000000Z", + "language": "eng", + "handler_name": "Commentary", + "vendor_id": "[0][0][0][0]" + } + }, + { + "index": 3, + "codec_name": "dvd_subtitle", + "codec_long_name": "DVD subtitles", + "codec_type": "subtitle", + "codec_tag_string": "mp4s", + "codec_tag": "0x7334706d", + "width": 720, + "height": 480, + "r_frame_rate": "0/0", + "avg_frame_rate": "0/0", + "time_base": "1/90000", + "start_pts": 0, + "start_time": "0.000000", + "duration_ts": 1300301588, + "duration": "14447.795422", + "bit_rate": "2653", + "nb_frames": "3545", + "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 + }, + "tags": { + "creation_time": "2021-09-13T22:42:42.000000Z", + "language": "spa", + "handler_name": "SubtitleHandler" + } + }, + { + "index": 4, + "codec_name": "mov_text", + "codec_long_name": "MOV text", + "codec_type": "subtitle", + "codec_tag_string": "tx3g", + "codec_tag": "0x67337874", + "width": 853, + "height": 51, + "r_frame_rate": "0/0", + "avg_frame_rate": "0/0", + "time_base": "1/90000", + "start_pts": 0, + "start_time": "0.000000", + "duration_ts": 1401339330, + "duration": "15570.437000", + "bit_rate": "88", + "nb_frames": "5079", + "disposition": { + "default": 1, + "dub": 0, + "original": 0, + "comment": 0, + "lyrics": 0, + "karaoke": 0, + "forced": 0, + "hearing_impaired": 0, + "visual_impaired": 0, + "clean_effects": 0, + "attached_pic": 0, + "timed_thumbnails": 0 + }, + "tags": { + "creation_time": "2021-09-13T22:42:42.000000Z", + "language": "eng", + "handler_name": "SubtitleHandler" + } + }, + { + "index": 5, + "codec_name": "mov_text", + "codec_long_name": "MOV text", + "codec_type": "subtitle", + "codec_tag_string": "tx3g", + "codec_tag": "0x67337874", + "width": 853, + "height": 51, + "r_frame_rate": "0/0", + "avg_frame_rate": "0/0", + "time_base": "1/90000", + "start_pts": 0, + "start_time": "0.000000", + "duration_ts": 1370580300, + "duration": "15228.670000", + "bit_rate": "18", + "nb_frames": "1563", + "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 + }, + "tags": { + "creation_time": "2021-09-13T22:42:42.000000Z", + "language": "eng", + "handler_name": "Commentary" + } + } + ] +} diff --git a/tests/Jellyfin.MediaEncoding.Tests/Test Data/example2.srt b/tests/Jellyfin.MediaEncoding.Tests/Test Data/example2.srt new file mode 100644 index 0000000000..b14aa8ea34 --- /dev/null +++ b/tests/Jellyfin.MediaEncoding.Tests/Test Data/example2.srt @@ -0,0 +1,11 @@ +311 +00:16:46,465 --> 00:16:49,009 +Una vez que la gente se entere + +de que ustedes están aquí, + +312 +00:16:49,092 --> 00:16:51,470 +este lugar se convertirá + +en un maldito zoológico. diff --git a/tests/Jellyfin.Common.Tests/Cryptography/PasswordHashTests.cs b/tests/Jellyfin.Model.Tests/Cryptography/PasswordHashTests.cs similarity index 80% rename from tests/Jellyfin.Common.Tests/Cryptography/PasswordHashTests.cs rename to tests/Jellyfin.Model.Tests/Cryptography/PasswordHashTests.cs index e6c325bac0..6948280a3d 100644 --- a/tests/Jellyfin.Common.Tests/Cryptography/PasswordHashTests.cs +++ b/tests/Jellyfin.Model.Tests/Cryptography/PasswordHashTests.cs @@ -1,9 +1,9 @@ using System; using System.Collections.Generic; -using MediaBrowser.Common.Cryptography; +using MediaBrowser.Model.Cryptography; using Xunit; -namespace Jellyfin.Common.Tests.Cryptography +namespace Jellyfin.Model.Tests.Cryptography { public static class PasswordHashTests { @@ -19,18 +19,16 @@ namespace Jellyfin.Common.Tests.Cryptography Assert.Throws(() => new PasswordHash(string.Empty, Array.Empty())); } - public static IEnumerable Parse_Valid_TestData() + public static TheoryData Parse_Valid_TestData() { + var data = new TheoryData(); // Id - yield return new object[] - { + data.Add( "$PBKDF2", - new PasswordHash("PBKDF2", Array.Empty()) - }; + new PasswordHash("PBKDF2", Array.Empty())); // Id + parameter - yield return new object[] - { + data.Add( "$PBKDF2$iterations=1000", new PasswordHash( "PBKDF2", @@ -39,12 +37,10 @@ namespace Jellyfin.Common.Tests.Cryptography new Dictionary() { { "iterations", "1000" }, - }) - }; + })); // Id + parameters - yield return new object[] - { + data.Add( "$PBKDF2$iterations=1000,m=120", new PasswordHash( "PBKDF2", @@ -54,34 +50,28 @@ namespace Jellyfin.Common.Tests.Cryptography { { "iterations", "1000" }, { "m", "120" } - }) - }; + })); // Id + hash - yield return new object[] - { + data.Add( "$PBKDF2$62FBA410AFCA5B4475F35137AB2E8596B127E4D927BA23F6CC05C067E897042D", new PasswordHash( "PBKDF2", Convert.FromHexString("62FBA410AFCA5B4475F35137AB2E8596B127E4D927BA23F6CC05C067E897042D"), Array.Empty(), - new Dictionary()) - }; + new Dictionary())); // Id + salt + hash - yield return new object[] - { + data.Add( "$PBKDF2$69F420$62FBA410AFCA5B4475F35137AB2E8596B127E4D927BA23F6CC05C067E897042D", new PasswordHash( "PBKDF2", Convert.FromHexString("62FBA410AFCA5B4475F35137AB2E8596B127E4D927BA23F6CC05C067E897042D"), Convert.FromHexString("69F420"), - new Dictionary()) - }; + new Dictionary())); // Id + parameter + hash - yield return new object[] - { + data.Add( "$PBKDF2$iterations=1000$62FBA410AFCA5B4475F35137AB2E8596B127E4D927BA23F6CC05C067E897042D", new PasswordHash( "PBKDF2", @@ -90,12 +80,9 @@ namespace Jellyfin.Common.Tests.Cryptography new Dictionary() { { "iterations", "1000" } - }) - }; - + })); // Id + parameters + hash - yield return new object[] - { + data.Add( "$PBKDF2$iterations=1000,m=120$62FBA410AFCA5B4475F35137AB2E8596B127E4D927BA23F6CC05C067E897042D", new PasswordHash( "PBKDF2", @@ -105,12 +92,9 @@ namespace Jellyfin.Common.Tests.Cryptography { { "iterations", "1000" }, { "m", "120" } - }) - }; - + })); // Id + parameters + salt + hash - yield return new object[] - { + data.Add( "$PBKDF2$iterations=1000,m=120$69F420$62FBA410AFCA5B4475F35137AB2E8596B127E4D927BA23F6CC05C067E897042D", new PasswordHash( "PBKDF2", @@ -120,8 +104,8 @@ namespace Jellyfin.Common.Tests.Cryptography { { "iterations", "1000" }, { "m", "120" } - }) - }; + })); + return data; } [Theory] @@ -171,11 +155,11 @@ namespace Jellyfin.Common.Tests.Cryptography [InlineData("$PBKDF2$=$62FBA410AFCA5B4475F35137AB2E8596B127E4D927BA23F6CC05C067E897042D")] // Invalid parmeter [InlineData("$PBKDF2$=1000$62FBA410AFCA5B4475F35137AB2E8596B127E4D927BA23F6CC05C067E897042D")] // Invalid parmeter [InlineData("$PBKDF2$iterations=$62FBA410AFCA5B4475F35137AB2E8596B127E4D927BA23F6CC05C067E897042D")] // Invalid parmeter - [InlineData("$PBKDF2$iterations=$62FBA410AFCA5B4475F35137AB2E8596B127E4D927BA23F6CC05C067E897042D$")] // Ends on $ - [InlineData("$PBKDF2$iterations=$69F420$62FBA410AFCA5B4475F35137AB2E8596B127E4D927BA23F6CC05C067E897042D$")] // Extra segment - [InlineData("$PBKDF2$iterations=$69F420$62FBA410AFCA5B4475F35137AB2E8596B127E4D927BA23F6CC05C067E897042D$anotherone")] // Extra segment - [InlineData("$PBKDF2$iterations=$invalidstalt$62FBA410AFCA5B4475F35137AB2E8596B127E4D927BA23F6CC05C067E897042D")] // Invalid salt - [InlineData("$PBKDF2$iterations=$69F420$invalid hash")] // Invalid hash + [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) { 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..0c97a90b43 --- /dev/null +++ b/tests/Jellyfin.Model.Tests/Entities/MediaStreamTests.cs @@ -0,0 +1,150 @@ +using MediaBrowser.Model.Entities; +using Xunit; + +namespace Jellyfin.Model.Tests.Entities +{ + public class MediaStreamTests + { + public static TheoryData Get_DisplayTitle_TestData() + { + var data = new TheoryData(); + + data.Add( + new MediaStream + { + Type = MediaStreamType.Subtitle, + Title = "English", + Language = string.Empty, + IsForced = false, + IsDefault = false, + Codec = "ASS" + }, + "English - Und - ASS"); + + data.Add( + new MediaStream + { + Type = MediaStreamType.Subtitle, + Title = "English", + Language = string.Empty, + IsForced = false, + IsDefault = false, + Codec = string.Empty + }, + "English - Und"); + + data.Add( + new MediaStream + { + Type = MediaStreamType.Subtitle, + Title = "English", + Language = "EN", + IsForced = false, + IsDefault = false, + Codec = string.Empty + }, + "English"); + + data.Add( + new MediaStream + { + Type = MediaStreamType.Subtitle, + Title = "English", + Language = "EN", + IsForced = true, + IsDefault = true, + Codec = "SRT" + }, + "English - Default - Forced - SRT"); + + data.Add( + new MediaStream + { + Type = MediaStreamType.Subtitle, + Title = null, + Language = null, + IsForced = false, + IsDefault = false, + Codec = null + }, + "Und"); + + return data; + } + + [Theory] + [MemberData(nameof(Get_DisplayTitle_TestData))] + public void Get_DisplayTitle_should_return_valid_title(MediaStream mediaStream, string expected) + { + Assert.Equal(expected, mediaStream.DisplayTitle); + } + + [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/Extensions/StringHelperTests.cs b/tests/Jellyfin.Model.Tests/Extensions/StringHelperTests.cs index 5864a05094..0a4e060df6 100644 --- a/tests/Jellyfin.Model.Tests/Extensions/StringHelperTests.cs +++ b/tests/Jellyfin.Model.Tests/Extensions/StringHelperTests.cs @@ -1,3 +1,6 @@ +using System; +using FsCheck; +using FsCheck.Xunit; using MediaBrowser.Model.Extensions; using Xunit; @@ -10,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 c5b51ef766..7e8397d9f7 100644 --- a/tests/Jellyfin.Model.Tests/Jellyfin.Model.Tests.csproj +++ b/tests/Jellyfin.Model.Tests/Jellyfin.Model.Tests.csproj @@ -1,19 +1,17 @@ - net5.0 + net6.0 false - true - enable - AllEnabledByDefault ../jellyfin-tests.ruleset - + - + + diff --git a/tests/Jellyfin.Naming.Tests/AudioBook/AudioBookResolverTests.cs b/tests/Jellyfin.Naming.Tests/AudioBook/AudioBookResolverTests.cs index 53b35c2d6c..c72a3315e7 100644 --- a/tests/Jellyfin.Naming.Tests/AudioBook/AudioBookResolverTests.cs +++ b/tests/Jellyfin.Naming.Tests/AudioBook/AudioBookResolverTests.cs @@ -1,4 +1,3 @@ -using System.Collections.Generic; using Emby.Naming.AudioBook; using Emby.Naming.Common; using Xunit; @@ -9,29 +8,29 @@ namespace Jellyfin.Naming.Tests.AudioBook { private readonly NamingOptions _namingOptions = new NamingOptions(); - public static IEnumerable Resolve_ValidFileNameTestData() + public static TheoryData Resolve_ValidFileNameTestData() { - yield return new object[] - { + var data = new TheoryData(); + + data.Add( new AudioBookFileInfo( @"/server/AudioBooks/Larry Potter/Larry Potter.mp3", - "mp3") - }; - yield return new object[] - { + "mp3")); + + data.Add( new AudioBookFileInfo( @"/server/AudioBooks/Berry Potter/Chapter 1 .ogg", "ogg", - chapterNumber: 1) - }; - yield return new object[] - { + chapterNumber: 1)); + + data.Add( new AudioBookFileInfo( @"/server/AudioBooks/Nerry Potter/Part 3 - Chapter 2.mp3", "mp3", chapterNumber: 2, - partNumber: 3) - }; + partNumber: 3)); + + return data; } [Theory] diff --git a/tests/Jellyfin.Naming.Tests/Jellyfin.Naming.Tests.csproj b/tests/Jellyfin.Naming.Tests/Jellyfin.Naming.Tests.csproj index ebb134fc3c..4096873a37 100644 --- a/tests/Jellyfin.Naming.Tests/Jellyfin.Naming.Tests.csproj +++ b/tests/Jellyfin.Naming.Tests/Jellyfin.Naming.Tests.csproj @@ -6,19 +6,16 @@ - net5.0 + net6.0 false - true - enable - AllEnabledByDefault ../jellyfin-tests.ruleset - + - + diff --git a/tests/Jellyfin.Naming.Tests/TV/EpisodeNumberTests.cs b/tests/Jellyfin.Naming.Tests/TV/EpisodeNumberTests.cs index 921c2b1f57..1e7fedb36f 100644 --- a/tests/Jellyfin.Naming.Tests/TV/EpisodeNumberTests.cs +++ b/tests/Jellyfin.Naming.Tests/TV/EpisodeNumberTests.cs @@ -70,9 +70,10 @@ namespace Jellyfin.Naming.Tests.TV [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 - // TODO: [InlineData("Case Closed (1996-2007)/Case Closed - 317.mkv", 317)] // triple digit episode number + [InlineData("Season 2/Episode 21 - 94 Meetings.mp4", 21)] // Title starts with a number + [InlineData("/The.Legend.of.Condor.Heroes.2017.V2.web-dl.1080p.h264.aac-hdctv/The.Legend.of.Condor.Heroes.2017.E07.V2.web-dl.1080p.h264.aac-hdctv.mkv", 7)] + // [InlineData("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)] diff --git a/tests/Jellyfin.Naming.Tests/TV/SeriesPathParserTest.cs b/tests/Jellyfin.Naming.Tests/TV/SeriesPathParserTest.cs new file mode 100644 index 0000000000..ceb5f8b736 --- /dev/null +++ b/tests/Jellyfin.Naming.Tests/TV/SeriesPathParserTest.cs @@ -0,0 +1,28 @@ +using Emby.Naming.Common; +using Emby.Naming.TV; +using Xunit; + +namespace Jellyfin.Naming.Tests.TV +{ + public class SeriesPathParserTest + { + [Theory] + [InlineData("The.Show.S01", "The.Show")] + [InlineData("/The.Show.S01", "The.Show")] + [InlineData("/some/place/The.Show.S01", "The.Show")] + [InlineData("/something/The.Show.S01", "The.Show")] + [InlineData("The Show Season 10", "The Show")] + [InlineData("The Show S01E01", "The Show")] + [InlineData("The Show S01E01 Episode", "The Show")] + [InlineData("/something/The Show/Season 1", "The Show")] + [InlineData("/something/The Show/S01", "The Show")] + public void SeriesPathParserParseTest(string path, string name) + { + NamingOptions o = new NamingOptions(); + var res = SeriesPathParser.Parse(o, path); + + Assert.Equal(name, res.SeriesName); + Assert.True(res.Success); + } + } +} diff --git a/tests/Jellyfin.Naming.Tests/TV/SeriesResolverTests.cs b/tests/Jellyfin.Naming.Tests/TV/SeriesResolverTests.cs new file mode 100644 index 0000000000..97f4b40582 --- /dev/null +++ b/tests/Jellyfin.Naming.Tests/TV/SeriesResolverTests.cs @@ -0,0 +1,28 @@ +using Emby.Naming.Common; +using Emby.Naming.TV; +using Xunit; + +namespace Jellyfin.Naming.Tests.TV +{ + public class SeriesResolverTests + { + [Theory] + [InlineData("The.Show.S01", "The Show")] + [InlineData("The.Show.S01.COMPLETE", "The Show")] + [InlineData("S.H.O.W.S01", "S.H.O.W")] + [InlineData("The.Show.P.I.S01", "The Show P.I")] + [InlineData("The_Show_Season_1", "The Show")] + [InlineData("/something/The_Show/Season 10", "The Show")] + [InlineData("The Show", "The Show")] + [InlineData("/some/path/The Show", "The Show")] + [InlineData("/some/path/The Show s02e10 720p hdtv", "The Show")] + [InlineData("/some/path/The Show s02e10 the episode 720p hdtv", "The Show")] + public void SeriesResolverResolveTest(string path, string name) + { + NamingOptions o = new NamingOptions(); + var res = SeriesResolver.Resolve(o, path); + + Assert.Equal(name, res.Name); + } + } +} diff --git a/tests/Jellyfin.Naming.Tests/TV/SimpleEpisodeTests.cs b/tests/Jellyfin.Naming.Tests/TV/SimpleEpisodeTests.cs index 89579c0376..6d49ac832d 100644 --- a/tests/Jellyfin.Naming.Tests/TV/SimpleEpisodeTests.cs +++ b/tests/Jellyfin.Naming.Tests/TV/SimpleEpisodeTests.cs @@ -21,7 +21,8 @@ namespace Jellyfin.Naming.Tests.TV [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 TestSimple(string path, string seriesName, int? seasonNumber, int? episodeNumber) diff --git a/tests/Jellyfin.Naming.Tests/Video/CleanDateTimeTests.cs b/tests/Jellyfin.Naming.Tests/Video/CleanDateTimeTests.cs index 950899d7e7..b1141df479 100644 --- a/tests/Jellyfin.Naming.Tests/Video/CleanDateTimeTests.cs +++ b/tests/Jellyfin.Naming.Tests/Video/CleanDateTimeTests.cs @@ -58,7 +58,7 @@ namespace Jellyfin.Naming.Tests.Video { 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 a720bdadeb..1574bce581 100644 --- a/tests/Jellyfin.Naming.Tests/Video/CleanStringTests.cs +++ b/tests/Jellyfin.Naming.Tests/Video/CleanStringTests.cs @@ -1,4 +1,3 @@ -using System; using Emby.Naming.Common; using Emby.Naming.Video; using Xunit; @@ -7,7 +6,7 @@ namespace Jellyfin.Naming.Tests.Video { public sealed class CleanStringTests { - private readonly VideoResolver _videoResolver = new VideoResolver(new NamingOptions()); + private readonly NamingOptions _namingOptions = new NamingOptions(); [Theory] [InlineData("Super movie 480p.mp4", "Super movie")] @@ -23,12 +22,17 @@ namespace Jellyfin.Naming.Tests.Video [InlineData("Crouching.Tiger.Hidden.Dragon.BDrip.mkv", "Crouching.Tiger.Hidden.Dragon")] [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")] + [InlineData("[HorribleSubs] Made in Abyss - 13 [720p].mkv", "Made in Abyss")] + [InlineData("[Tsundere] Kore wa Zombie Desu ka of the Dead [BDRip h264 1920x1080 FLAC]", "Kore wa Zombie Desu ka of the Dead")] + [InlineData("[Erai-raws] Jujutsu Kaisen - 03 [720p][Multiple Subtitle].mkv", "Jujutsu Kaisen")] + [InlineData("[OCN] 애타는 로맨스 720p-NEXT", "애타는 로맨스")] + [InlineData("[tvN] 혼술남녀.E01-E16.720p-NEXT", "혼술남녀")] + [InlineData("[tvN] 연애말고 결혼 E01~E16 END HDTV.H264.720p-WITH", "연애말고 결혼")] // FIXME: [InlineData("After The Sunset - [0004].mkv", "After The Sunset")] public void CleanStringTest_NeedsCleaning_Success(string input, string expectedName) { - Assert.True(_videoResolver.TryCleanString(input, out ReadOnlySpan newName)); - // TODO: compare spans when XUnit supports it - Assert.Equal(expectedName, newName.ToString()); + Assert.True(VideoResolver.TryCleanString(input, _namingOptions, out var newName)); + Assert.Equal(expectedName, newName); } [Theory] @@ -41,8 +45,8 @@ namespace Jellyfin.Naming.Tests.Video [InlineData("Run lola run (lola rennt) (2009).mp4")] public void CleanStringTest_DoesntNeedCleaning_False(string? input) { - Assert.False(_videoResolver.TryCleanString(input, out ReadOnlySpan newName)); - Assert.True(newName.IsEmpty); + Assert.False(VideoResolver.TryCleanString(input, _namingOptions, out var newName)); + Assert.True(string.IsNullOrEmpty(newName)); } } } diff --git a/tests/Jellyfin.Naming.Tests/Video/ExtraTests.cs b/tests/Jellyfin.Naming.Tests/Video/ExtraTests.cs index 2f173b0ce0..d13e89cee8 100644 --- a/tests/Jellyfin.Naming.Tests/Video/ExtraTests.cs +++ b/tests/Jellyfin.Naming.Tests/Video/ExtraTests.cs @@ -18,30 +18,31 @@ namespace Jellyfin.Naming.Tests.Video [Fact] public void TestKodiExtras() { - Test("trailer.mp4", ExtraType.Trailer, _videoOptions); - Test("300-trailer.mp4", ExtraType.Trailer, _videoOptions); + Test("trailer.mp4", ExtraType.Trailer); + Test("300-trailer.mp4", ExtraType.Trailer); - Test("theme.mp3", ExtraType.ThemeSong, _videoOptions); + Test("theme.mp3", ExtraType.ThemeSong); } [Fact] public void TestExpandedExtras() { - Test("trailer.mp4", ExtraType.Trailer, _videoOptions); - Test("trailer.mp3", null, _videoOptions); - Test("300-trailer.mp4", ExtraType.Trailer, _videoOptions); - - Test("theme.mp3", ExtraType.ThemeSong, _videoOptions); - Test("theme.mkv", null, _videoOptions); - - Test("300-scene.mp4", ExtraType.Scene, _videoOptions); - Test("300-scene2.mp4", ExtraType.Scene, _videoOptions); - Test("300-clip.mp4", ExtraType.Clip, _videoOptions); - - Test("300-deleted.mp4", ExtraType.DeletedScene, _videoOptions); - Test("300-deletedscene.mp4", ExtraType.DeletedScene, _videoOptions); - Test("300-interview.mp4", ExtraType.Interview, _videoOptions); - Test("300-behindthescenes.mp4", ExtraType.BehindTheScenes, _videoOptions); + Test("trailer.mp4", ExtraType.Trailer); + Test("trailer.mp3", null); + Test("300-trailer.mp4", ExtraType.Trailer); + Test("stuff trailerthings.mkv", null); + + Test("theme.mp3", ExtraType.ThemeSong); + Test("theme.mkv", null); + + Test("300-scene.mp4", ExtraType.Scene); + Test("300-scene2.mp4", ExtraType.Scene); + Test("300-clip.mp4", ExtraType.Clip); + + Test("300-deleted.mp4", ExtraType.DeletedScene); + Test("300-deletedscene.mp4", ExtraType.DeletedScene); + Test("300-interview.mp4", ExtraType.Interview); + Test("300-behindthescenes.mp4", ExtraType.BehindTheScenes); } [Theory] @@ -55,9 +56,9 @@ namespace Jellyfin.Naming.Tests.Video [InlineData(ExtraType.Unknown, "extras")] public void TestDirectories(ExtraType type, string dirName) { - Test(dirName + "/300.mp4", type, _videoOptions); - Test("300/" + dirName + "/something.mkv", type, _videoOptions); - Test("/data/something/Movies/300/" + dirName + "/whoknows.mp4", type, _videoOptions); + Test(dirName + "/300.mp4", type); + Test("300/" + dirName + "/something.mkv", type); + Test("/data/something/Movies/300/" + dirName + "/whoknows.mp4", type); } [Theory] @@ -66,32 +67,25 @@ namespace Jellyfin.Naming.Tests.Video [InlineData("The Big Short")] public void TestNonExtraDirectories(string dirName) { - Test(dirName + "/300.mp4", null, _videoOptions); - Test("300/" + dirName + "/something.mkv", null, _videoOptions); - Test("/data/something/Movies/300/" + dirName + "/whoknows.mp4", null, _videoOptions); - Test("/data/something/Movies/" + dirName + "/" + dirName + ".mp4", null, _videoOptions); + Test(dirName + "/300.mp4", null); + Test("300/" + dirName + "/something.mkv", null); + Test("/data/something/Movies/300/" + dirName + "/whoknows.mp4", null); + Test("/data/something/Movies/" + dirName + "/" + dirName + ".mp4", null); } [Fact] public void TestSample() { - Test("300-sample.mp4", ExtraType.Sample, _videoOptions); + Test("300-sample.mp4", ExtraType.Sample); } - private void Test(string input, ExtraType? expectedType, NamingOptions videoOptions) + private void Test(string input, ExtraType? expectedType) { - var parser = GetExtraTypeParser(videoOptions); + var parser = GetExtraTypeParser(_videoOptions); var extraType = parser.GetExtraInfo(input).ExtraType; - if (expectedType == null) - { - Assert.Null(extraType); - } - else - { - Assert.Equal(expectedType, extraType); - } + Assert.Equal(expectedType, extraType); } [Fact] @@ -104,13 +98,6 @@ namespace Jellyfin.Naming.Tests.Video Assert.Equal(rule, res.Rule); } - [Fact] - public void TestFlagsParser() - { - var flags = new FlagParser(_videoOptions).GetFlags(string.Empty); - Assert.Empty(flags); - } - 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 6e803593e8..d02f8ae924 100644 --- a/tests/Jellyfin.Naming.Tests/Video/MultiVersionTests.cs +++ b/tests/Jellyfin.Naming.Tests/Video/MultiVersionTests.cs @@ -9,7 +9,7 @@ namespace Jellyfin.Naming.Tests.Video { public class MultiVersionTests { - private readonly VideoListResolver _videoListResolver = new VideoListResolver(new NamingOptions()); + private readonly NamingOptions _namingOptions = new NamingOptions(); [Fact] public void TestMultiEdition1() @@ -22,11 +22,13 @@ namespace Jellyfin.Naming.Tests.Video @"/movies/X-Men Days of Future Past/X-Men Days of Future Past [hsbs].mkv" }; - var result = _videoListResolver.Resolve(files.Select(i => 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); @@ -43,11 +45,13 @@ namespace Jellyfin.Naming.Tests.Video @"/movies/X-Men Days of Future Past/X-Men Days of Future Past [banana].mp4" }; - var result = _videoListResolver.Resolve(files.Select(i => 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); @@ -63,11 +67,13 @@ namespace Jellyfin.Naming.Tests.Video @"/movies/The Phantom of the Opera (1925)/The Phantom of the Opera (1925) - 1929 version.mkv" }; - var result = _videoListResolver.Resolve(files.Select(i => 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); @@ -87,11 +93,13 @@ namespace Jellyfin.Naming.Tests.Video @"/movies/M/Movie 7.mkv" }; - var result = _videoListResolver.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); @@ -113,11 +121,13 @@ namespace Jellyfin.Naming.Tests.Video @"/movies/Movie/Movie-8.mkv" }; - var result = _videoListResolver.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); @@ -140,11 +150,13 @@ namespace Jellyfin.Naming.Tests.Video @"/movies/Mo/Movie 9.mkv" }; - var result = _videoListResolver.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); @@ -163,11 +175,13 @@ namespace Jellyfin.Naming.Tests.Video @"/movies/Movie/Movie 5.mkv" }; - var result = _videoListResolver.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); @@ -188,11 +202,13 @@ namespace Jellyfin.Naming.Tests.Video @"/movies/Iron Man/Iron Man (2011).mkv" }; - var result = _videoListResolver.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); @@ -214,11 +230,13 @@ namespace Jellyfin.Naming.Tests.Video @"/movies/Iron Man/Iron Man[test].mkv", }; - var result = _videoListResolver.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); @@ -243,11 +261,13 @@ namespace Jellyfin.Naming.Tests.Video @"/movies/Iron Man/Iron Man [test].mkv" }; - var result = _videoListResolver.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); @@ -266,11 +286,13 @@ namespace Jellyfin.Naming.Tests.Video @"/movies/Iron Man/Iron Man - C (2007).mkv" }; - var result = _videoListResolver.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); } @@ -289,11 +311,13 @@ namespace Jellyfin.Naming.Tests.Video @"/movies/Iron Man/Iron Man_3d.hsbs.mkv" }; - var result = _videoListResolver.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); @@ -314,11 +338,13 @@ namespace Jellyfin.Naming.Tests.Video @"/movies/Iron Man/Iron Man (2011).mkv" }; - var result = _videoListResolver.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); @@ -334,11 +360,13 @@ namespace Jellyfin.Naming.Tests.Video @"/movies/Blade Runner (1982)/Blade Runner (1982) [EE by ADM] [480p HEVC AAC,AAC,AAC].mkv" }; - var result = _videoListResolver.Resolve(files.Select(i => 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); @@ -354,11 +382,13 @@ namespace Jellyfin.Naming.Tests.Video @"/movies/X-Men Apocalypse (2016)/X-Men Apocalypse (2016) [2160p] Blu-ray.x265.AAC.mkv" }; - var result = _videoListResolver.Resolve(files.Select(i => 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); @@ -374,11 +404,13 @@ namespace Jellyfin.Naming.Tests.Video @"/movies/John Wick - Kapitel 3 (2019) [imdbid=tt6146586]/John Wick - Kapitel 3 (2019) [imdbid=tt6146586] - Version 2.mkv" }; - var result = _videoListResolver.Resolve(files.Select(i => 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); @@ -394,11 +426,13 @@ namespace Jellyfin.Naming.Tests.Video @"/movies/John Wick - Chapter 3 (2019)/John Wick - Chapter 3 (2019) [Version 2.mkv" }; - var result = _videoListResolver.Resolve(files.Select(i => 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); } @@ -406,7 +440,7 @@ namespace Jellyfin.Naming.Tests.Video [Fact] public void TestEmptyList() { - var result = _videoListResolver.Resolve(new List()).ToList(); + var result = VideoListResolver.Resolve(new List(), _namingOptions).ToList(); Assert.Empty(result); } diff --git a/tests/Jellyfin.Naming.Tests/Video/StubTests.cs b/tests/Jellyfin.Naming.Tests/Video/StubTests.cs index 6e759c6d6b..1d50df7a60 100644 --- a/tests/Jellyfin.Naming.Tests/Video/StubTests.cs +++ b/tests/Jellyfin.Naming.Tests/Video/StubTests.cs @@ -29,8 +29,7 @@ namespace Jellyfin.Naming.Tests.Video [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 08af766699..9e0776c3cd 100644 --- a/tests/Jellyfin.Naming.Tests/Video/VideoListResolverTests.cs +++ b/tests/Jellyfin.Naming.Tests/Video/VideoListResolverTests.cs @@ -9,7 +9,7 @@ namespace Jellyfin.Naming.Tests.Video { public class VideoListResolverTests { - private readonly VideoListResolver _videoListResolver = new VideoListResolver(new NamingOptions()); + private readonly NamingOptions _namingOptions = new NamingOptions(); [Fact] public void TestStackAndExtras() @@ -40,11 +40,13 @@ namespace Jellyfin.Naming.Tests.Video "WillyWonka-trailer.mkv" }; - var result = _videoListResolver.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); var batman = result.FirstOrDefault(x => string.Equals(x.Name, "Batman", StringComparison.Ordinal)); @@ -67,11 +69,13 @@ namespace Jellyfin.Naming.Tests.Video "300.nfo" }; - var result = _videoListResolver.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); } @@ -85,11 +89,13 @@ namespace Jellyfin.Naming.Tests.Video "300 trailer.mkv" }; - var result = _videoListResolver.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); } @@ -103,11 +109,13 @@ namespace Jellyfin.Naming.Tests.Video "X-Men Days of Future Past-trailer.mp4" }; - var result = _videoListResolver.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); } @@ -122,11 +130,13 @@ namespace Jellyfin.Naming.Tests.Video "X-Men Days of Future Past-trailer2.mp4" }; - var result = _videoListResolver.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); } @@ -140,11 +150,13 @@ namespace Jellyfin.Naming.Tests.Video "Looper.2012.bluray.720p.x264.mkv" }; - var result = _videoListResolver.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); } @@ -162,11 +174,13 @@ namespace Jellyfin.Naming.Tests.Video "My video 5.mkv" }; - var result = _videoListResolver.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); } @@ -180,11 +194,13 @@ namespace Jellyfin.Naming.Tests.Video @"M:/Movies (DVD)/Movies (Musical)/Sound of Music (1965)/Sound of Music Disc 2" }; - var result = _videoListResolver.Resolve(files.Select(i => 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); } @@ -199,11 +215,13 @@ namespace Jellyfin.Naming.Tests.Video @"My movie #2.mp4" }; - var result = _videoListResolver.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); } @@ -218,11 +236,13 @@ namespace Jellyfin.Naming.Tests.Video @"No (2012) part1-trailer.mp4" }; - var result = _videoListResolver.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); } @@ -237,11 +257,13 @@ namespace Jellyfin.Naming.Tests.Video @"No (2012)-trailer.mp4" }; - var result = _videoListResolver.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); } @@ -257,11 +279,13 @@ namespace Jellyfin.Naming.Tests.Video @"trailer.mp4" }; - var result = _videoListResolver.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); } @@ -277,11 +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 result = _videoListResolver.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); } @@ -294,11 +320,13 @@ namespace Jellyfin.Naming.Tests.Video @"/nas-markrobbo78/Videos/INDEX HTPC/Movies/Watched/3 - ACTION/Argo (2012)/movie.mkv" }; - var result = _videoListResolver.Resolve(files.Select(i => 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); } @@ -311,11 +339,13 @@ namespace Jellyfin.Naming.Tests.Video @"The Colony.mkv" }; - var result = _videoListResolver.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); } @@ -329,11 +359,13 @@ namespace Jellyfin.Naming.Tests.Video @"Four Sisters and a Wedding - B.avi" }; - var result = _videoListResolver.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); } @@ -347,11 +379,13 @@ namespace Jellyfin.Naming.Tests.Video @"Four Rooms - A.mp4" }; - var result = _videoListResolver.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); } @@ -365,11 +399,13 @@ namespace Jellyfin.Naming.Tests.Video @"/Server/Despicable Me/movie-trailer.mkv" }; - var result = _videoListResolver.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); } @@ -385,11 +421,13 @@ namespace Jellyfin.Naming.Tests.Video @"/Server/Despicable Me/Baywatch (2017) - Trailer.mkv" }; - var result = _videoListResolver.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); } @@ -403,11 +441,13 @@ namespace Jellyfin.Naming.Tests.Video @"/Movies/Despicable Me/trailers/trailer.mkv" }; - var result = _videoListResolver.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); } diff --git a/tests/Jellyfin.Naming.Tests/Video/VideoResolverTests.cs b/tests/Jellyfin.Naming.Tests/Video/VideoResolverTests.cs index 9bbbe29709..33a99e107f 100644 --- a/tests/Jellyfin.Naming.Tests/Video/VideoResolverTests.cs +++ b/tests/Jellyfin.Naming.Tests/Video/VideoResolverTests.cs @@ -1,4 +1,3 @@ -using System.Collections.Generic; using System.Linq; using Emby.Naming.Common; using Emby.Naming.Video; @@ -9,177 +8,163 @@ namespace Jellyfin.Naming.Tests.Video { public class VideoResolverTests { - private readonly VideoResolver _videoResolver = new VideoResolver(new NamingOptions()); + private static NamingOptions _namingOptions = new NamingOptions(); - public static IEnumerable ResolveFile_ValidFileNameTestData() + public static TheoryData ResolveFile_ValidFileNameTestData() { - yield return new object[] - { + var data = new TheoryData(); + data.Add( new VideoFileInfo( path: @"/server/Movies/7 Psychos.mkv/7 Psychos.mkv", container: "mkv", - name: "7 Psychos") - }; - yield return new object[] - { + name: "7 Psychos")); + + data.Add( 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[] - { + year: 2005)); + + data.Add( new VideoFileInfo( path: @"/server/Movies/American Psycho/American.Psycho.mkv", container: "mkv", - name: "American.Psycho") - }; - yield return new object[] - { + name: "American.Psycho")); + + data.Add( 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[] - { + format3D: "sbs")); + + data.Add( new VideoFileInfo( path: @"/server/Movies/300 (2007)/300 (2006).3d1.sbas.mkv", container: "mkv", name: "300", - year: 2006) - }; - yield return new object[] - { + year: 2006)); + + data.Add( 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[] - { + format3D: "sbs")); + + data.Add( 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[] - { + stubType: "bluray")); + + data.Add( 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[] - { + stubType: "bluray")); + + data.Add( 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[] - { + stubType: "bluray")); + + data.Add( 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[] - { + stubType: "bluray")); + + data.Add( new VideoFileInfo( path: @"/server/Movies/300 (2007)/300 (2006)-trailer.mkv", container: "mkv", name: "300", year: 2006, - extraType: ExtraType.Trailer) - }; - yield return new object[] - { + extraType: ExtraType.Trailer)); + + data.Add( new VideoFileInfo( path: @"/server/Movies/Brave (2007)/Brave (2006)-trailer.mkv", container: "mkv", name: "Brave", year: 2006, - extraType: ExtraType.Trailer) - }; - yield return new object[] - { + extraType: ExtraType.Trailer)); + + data.Add( new VideoFileInfo( path: @"/server/Movies/300 (2007)/300 (2006).mkv", container: "mkv", name: "300", - year: 2006) - }; - yield return new object[] - { + year: 2006)); + + data.Add( new VideoFileInfo( path: @"/server/Movies/Bad Boys (1995)/Bad Boys (1995).mkv", container: "mkv", name: "Bad Boys", - year: 1995) - }; - yield return new object[] - { + year: 1995)); + + data.Add( new VideoFileInfo( path: @"/server/Movies/Brave (2007)/Brave (2006).mkv", container: "mkv", name: "Brave", - year: 2006) - }; - yield return new object[] - { + year: 2006)); + + data.Add( new VideoFileInfo( - path: @"/server/Movies/Rain Man 1988 REMASTERED 1080p BluRay x264 AAC - Ozlem/Rain Man 1988 REMASTERED 1080p BluRay x264 AAC - Ozlem.mp4", + 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) - }; + year: 1988)); + + return data; } [Theory] [MemberData(nameof(ResolveFile_ValidFileNameTestData))] public void ResolveFile_ValidFileName_Success(VideoFileInfo expectedResult) { - var result = _videoResolver.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?.ToString(), expectedResult.ToString()); + 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); + var result = VideoResolver.ResolveFile(string.Empty, _namingOptions); Assert.Null(result); } @@ -194,12 +179,16 @@ namespace Jellyfin.Naming.Tests.Video string.Empty }; - var results = paths.Select(path => _videoResolver.ResolveDirectory(path)).ToList(); + 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 + { + /// + /// Checks IP address formats. + /// + /// IP Address. + [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(); + + /// + /// All should be invalid address strings. + /// + /// Invalid address strings. + [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 + { + /// + /// Checks IP address formats. + /// + /// IP Address. + [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(); + + /// + /// All should be invalid address strings. + /// + /// Invalid address strings. + [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 index d5268facc9..78556ee675 100644 --- a/tests/Jellyfin.Networking.Tests/Jellyfin.Networking.Tests.csproj +++ b/tests/Jellyfin.Networking.Tests/Jellyfin.Networking.Tests.csproj @@ -6,19 +6,17 @@ - net5.0 + net6.0 false - true - enable - AllEnabledByDefault ../jellyfin-tests.ruleset - + - + + diff --git a/tests/Jellyfin.Networking.Tests/NetworkManagerTests.cs b/tests/Jellyfin.Networking.Tests/NetworkManagerTests.cs index 1cad625b73..61f9132528 100644 --- a/tests/Jellyfin.Networking.Tests/NetworkManagerTests.cs +++ b/tests/Jellyfin.Networking.Tests/NetworkManagerTests.cs @@ -34,7 +34,7 @@ namespace Jellyfin.Networking.Tests } /// - /// Checks that thge given IP address is not in the network provided. + /// Checks that the given IP address is not in the network provided. /// /// Network address(es). /// The IP to check. diff --git a/tests/Jellyfin.Networking.Tests/NetworkParseTests.cs b/tests/Jellyfin.Networking.Tests/NetworkParseTests.cs index 9b0da2b3c4..6b93974373 100644 --- a/tests/Jellyfin.Networking.Tests/NetworkParseTests.cs +++ b/tests/Jellyfin.Networking.Tests/NetworkParseTests.cs @@ -20,7 +20,7 @@ namespace Jellyfin.Networking.Tests CallBase = true }; configManager.Setup(x => x.GetConfiguration(It.IsAny())).Returns(conf); - return (IConfigurationManager)configManager.Object; + return configManager.Object; } /// @@ -35,9 +35,9 @@ namespace Jellyfin.Networking.Tests // 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]")] + [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", "[]")] // 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]")] + [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]")] // 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) @@ -56,66 +56,6 @@ namespace Jellyfin.Networking.Tests Assert.Equal(nm.GetInternalBindAddresses().AsString(), value); } - /// - /// Checks IP address formats. - /// - /// IP Address. - [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 void ValidHostStrings(string address) - { - Assert.True(IPHost.TryParse(address, out _)); - } - - /// - /// Checks IP address formats. - /// - /// IP Address. - [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 void ValidIPStrings(string address) - { - Assert.True(IPNetAddress.TryParse(address, out _)); - } - - /// - /// All should be invalid address strings. - /// - /// Invalid address strings. - [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 void InvalidAddressString(string address) - { - Assert.False(IPNetAddress.TryParse(address, out _)); - Assert.False(IPHost.TryParse(address, out _)); - } - /// /// Test collection parsing. /// @@ -384,6 +324,9 @@ namespace Jellyfin.Networking.Tests [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) @@ -416,10 +359,13 @@ namespace Jellyfin.Networking.Tests _ = nm.TryParseInterface(result, out Collection? resultObj); - if (resultObj != null) + // 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 int? _); + var intf = nm.GetBindInterface(source, out _); Assert.Equal(intf, result); } @@ -530,5 +476,51 @@ namespace Jellyfin.Networking.Tests Assert.NotEqual(nm.HasRemoteAccess(IPAddress.Parse(remoteIp)), denied); } + + [Theory] + [InlineData("192.168.1.209/24,-16,eth16", "192.168.1.0/24", "", "192.168.1.209")] // Only 1 address so use it. + [InlineData("192.168.1.208/24,-16,eth16|10.0.0.1/24,10,eth7", "192.168.1.0/24", "", "192.168.1.208")] // LAN address is specified by default. + [InlineData("192.168.1.208/24,-16,eth16|10.0.0.1/24,10,eth7", "192.168.1.0/24", "10.0.0.1", "10.0.0.1")] // return bind address + + public void GetBindInterface_NoSourceGiven_Success(string interfaces, string lan, string bind, string result) + { + var conf = new NetworkConfiguration + { + EnableIPV4 = true, + LocalNetworkSubnets = lan.Split(','), + LocalNetworkAddresses = bind.Split(',') + }; + + NetworkManager.MockNetworkSettings = interfaces; + using var nm = new NetworkManager(GetMockConfig(conf), new NullLogger()); + + var interfaceToUse = nm.GetBindInterface(string.Empty, out _); + + Assert.Equal(result, interfaceToUse); + } + + [Theory] + [InlineData("192.168.1.209/24,-16,eth16", "192.168.1.0/24", "", "192.168.1.210", "192.168.1.209")] // Source on LAN + [InlineData("192.168.1.208/24,-16,eth16|10.0.0.1/24,10,eth7", "192.168.1.0/24", "", "192.168.1.209", "192.168.1.208")] // Source on LAN + [InlineData("192.168.1.208/24,-16,eth16|10.0.0.1/24,10,eth7", "192.168.1.0/24", "", "8.8.8.8", "10.0.0.1")] // Source external. + [InlineData("192.168.1.208/24,-16,eth16|10.0.0.1/24,10,eth7", "192.168.1.0/24", "10.0.0.1", "192.168.1.209", "10.0.0.1")] // LAN not bound, so return external. + [InlineData("192.168.1.208/24,-16,eth16|10.0.0.1/24,10,eth7", "192.168.1.0/24", "192.168.1.208,10.0.0.1", "8.8.8.8", "10.0.0.1")] // return external bind address + [InlineData("192.168.1.208/24,-16,eth16|10.0.0.1/24,10,eth7", "192.168.1.0/24", "192.168.1.208,10.0.0.1", "192.168.1.210", "192.168.1.208")] // return LAN bind address + public void GetBindInterface_ValidSourceGiven_Success(string interfaces, string lan, string bind, string source, string result) + { + var conf = new NetworkConfiguration + { + EnableIPV4 = true, + LocalNetworkSubnets = lan.Split(','), + LocalNetworkAddresses = bind.Split(',') + }; + + NetworkManager.MockNetworkSettings = interfaces; + using var nm = new NetworkManager(GetMockConfig(conf), new NullLogger()); + + var interfaceToUse = nm.GetBindInterface(source, out _); + + Assert.Equal(result, interfaceToUse); + } } } 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..10767ae238 --- /dev/null +++ b/tests/Jellyfin.Providers.Tests/Jellyfin.Providers.Tests.csproj @@ -0,0 +1,40 @@ + + + + net6.0 + false + ../jellyfin-tests.ruleset + + + + + PreserveNewest + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + + + + + diff --git a/tests/Jellyfin.Providers.Tests/Manager/ItemImageProviderTests.cs b/tests/Jellyfin.Providers.Tests/Manager/ItemImageProviderTests.cs new file mode 100644 index 0000000000..2ba5c47d7f --- /dev/null +++ b/tests/Jellyfin.Providers.Tests/Manager/ItemImageProviderTests.cs @@ -0,0 +1,597 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Text; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.Configuration; +using MediaBrowser.Model.Drawing; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.IO; +using MediaBrowser.Model.MediaInfo; +using MediaBrowser.Model.Providers; +using MediaBrowser.Providers.Manager; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Xunit; + +namespace Jellyfin.Providers.Tests.Manager +{ + public class ItemImageProviderTests + { + private const string TestDataImagePath = "Test Data/Images/blank{0}.jpg"; + + [Fact] + public void ValidateImages_PhotoEmptyProviders_NoChange() + { + var itemImageProvider = GetItemImageProvider(null, null); + var changed = itemImageProvider.ValidateImages(new Photo(), Enumerable.Empty(), null); + + Assert.False(changed); + } + + [Fact] + public void ValidateImages_EmptyItemEmptyProviders_NoChange() + { + var itemImageProvider = GetItemImageProvider(null, null); + var changed = itemImageProvider.ValidateImages(new Video(), Enumerable.Empty(), null); + + Assert.False(changed); + } + + private static TheoryData GetImageTypesWithCount() + { + var theoryTypes = new TheoryData + { + // minimal test cases that hit different handling + { ImageType.Primary, 1 }, + { ImageType.Backdrop, 1 }, + { ImageType.Backdrop, 2 } + }; + + return theoryTypes; + } + + [Theory] + [MemberData(nameof(GetImageTypesWithCount))] + public void ValidateImages_EmptyItemAndPopulatedProviders_AddsImages(ImageType imageType, int imageCount) + { + // Has to exist for querying DateModified time on file, results stored but not checked so not populating + BaseItem.FileSystem = Mock.Of(); + + var item = new Video(); + var imageProvider = GetImageProvider(imageType, imageCount, true); + + var itemImageProvider = GetItemImageProvider(null, null); + var changed = itemImageProvider.ValidateImages(item, new[] { imageProvider }, null); + + Assert.True(changed); + Assert.Equal(imageCount, item.GetImages(imageType).Count()); + } + + [Theory] + [MemberData(nameof(GetImageTypesWithCount))] + public void ValidateImages_PopulatedItemWithGoodPathsAndEmptyProviders_NoChange(ImageType imageType, int imageCount) + { + var item = GetItemWithImages(imageType, imageCount, true); + + var itemImageProvider = GetItemImageProvider(null, null); + var changed = itemImageProvider.ValidateImages(item, Enumerable.Empty(), null); + + Assert.False(changed); + Assert.Equal(imageCount, item.GetImages(imageType).Count()); + } + + [Theory] + [MemberData(nameof(GetImageTypesWithCount))] + public void ValidateImages_PopulatedItemWithBadPathsAndEmptyProviders_RemovesImage(ImageType imageType, int imageCount) + { + var item = GetItemWithImages(imageType, imageCount, false); + + var itemImageProvider = GetItemImageProvider(null, null); + var changed = itemImageProvider.ValidateImages(item, Enumerable.Empty(), null); + + Assert.True(changed); + Assert.Empty(item.GetImages(imageType)); + } + + [Fact] + public void MergeImages_EmptyItemNewImagesEmpty_NoChange() + { + var itemImageProvider = GetItemImageProvider(null, null); + var changed = itemImageProvider.MergeImages(new Video(), Array.Empty()); + + Assert.False(changed); + } + + [Theory] + [MemberData(nameof(GetImageTypesWithCount))] + public void MergeImages_PopulatedItemWithGoodPathsAndPopulatedNewImages_AddsUpdatesImages(ImageType imageType, int imageCount) + { + // valid and not valid paths - should replace the valid paths with the invalid ones + var item = GetItemWithImages(imageType, imageCount, true); + var images = GetImages(imageType, imageCount, false); + + var itemImageProvider = GetItemImageProvider(null, null); + var changed = itemImageProvider.MergeImages(item, images); + + Assert.True(changed); + // adds for types that allow multiple, replaces singular type images + if (item.AllowsMultipleImages(imageType)) + { + Assert.Equal(imageCount * 2, item.GetImages(imageType).Count()); + } + else + { + Assert.Single(item.GetImages(imageType)); + Assert.Same(images[0].FileInfo.FullName, item.GetImages(imageType).First().Path); + } + } + + [Theory] + [MemberData(nameof(GetImageTypesWithCount))] + public void MergeImages_PopulatedItemWithGoodPathsAndSameNewImages_NoChange(ImageType imageType, int imageCount) + { + var oldTime = new DateTime(1970, 1, 1); + + // match update time with time added to item images (unix epoch) + var fileSystem = new Mock(); + fileSystem.Setup(fs => fs.GetLastWriteTimeUtc(It.IsAny())) + .Returns(oldTime); + BaseItem.FileSystem = fileSystem.Object; + + // all valid paths - matching for strictly updating + var item = GetItemWithImages(imageType, imageCount, true); + // set size to non-zero to allow for updates to occur + foreach (var image in item.GetImages(imageType)) + { + image.DateModified = oldTime; + image.Height = 1; + image.Width = 1; + } + + var images = GetImages(imageType, imageCount, true); + + var itemImageProvider = GetItemImageProvider(null, fileSystem); + var changed = itemImageProvider.MergeImages(item, images); + + Assert.False(changed); + } + + [Theory] + [MemberData(nameof(GetImageTypesWithCount))] + public void MergeImages_PopulatedItemWithGoodPathsAndSameNewImagesWithNewTimestamps_ResetsImageSizes(ImageType imageType, int imageCount) + { + var oldTime = new DateTime(1970, 1, 1); + var updatedTime = new DateTime(2021, 1, 1); + + var fileSystem = new Mock(); + fileSystem.Setup(fs => fs.GetLastWriteTimeUtc(It.IsAny())) + .Returns(updatedTime); + BaseItem.FileSystem = fileSystem.Object; + + // all valid paths - matching for strictly updating + var item = GetItemWithImages(imageType, imageCount, true); + // set size to non-zero to allow for image size reset to occur + foreach (var image in item.GetImages(imageType)) + { + image.DateModified = oldTime; + image.Height = 1; + image.Width = 1; + } + + var images = GetImages(imageType, imageCount, true); + + var itemImageProvider = GetItemImageProvider(null, fileSystem); + var changed = itemImageProvider.MergeImages(item, images); + + Assert.True(changed); + // before and after paths are the same, verify updated by size reset to 0 + Assert.Equal(imageCount, item.GetImages(imageType).Count()); + foreach (var image in item.GetImages(imageType)) + { + Assert.Equal(updatedTime, image.DateModified); + Assert.Equal(0, image.Height); + Assert.Equal(0, image.Width); + } + } + + [Theory] + [InlineData(ImageType.Primary, 1, false)] + [InlineData(ImageType.Backdrop, 2, false)] + [InlineData(ImageType.Primary, 1, true)] + [InlineData(ImageType.Backdrop, 2, true)] + public async void RefreshImages_PopulatedItemPopulatedProviderDynamic_UpdatesImagesIfForced(ImageType imageType, int imageCount, bool forceRefresh) + { + var item = GetItemWithImages(imageType, imageCount, false); + + var libraryOptions = GetLibraryOptions(item, imageType, imageCount); + + var imageResponse = new DynamicImageResponse + { + HasImage = true, + Format = ImageFormat.Jpg, + Path = "url path", + Protocol = MediaProtocol.Http + }; + + var dynamicProvider = new Mock(MockBehavior.Strict); + dynamicProvider.Setup(rp => rp.Name).Returns("MockDynamicProvider"); + dynamicProvider.Setup(rp => rp.GetSupportedImages(item)) + .Returns(new[] { imageType }); + dynamicProvider.Setup(rp => rp.GetImage(item, imageType, It.IsAny())) + .ReturnsAsync(imageResponse); + + var refreshOptions = forceRefresh + ? new ImageRefreshOptions(Mock.Of()) + { + ImageRefreshMode = MetadataRefreshMode.FullRefresh, + ReplaceAllImages = true + } + : new ImageRefreshOptions(Mock.Of()); + + var itemImageProvider = GetItemImageProvider(null, new Mock()); + var result = await itemImageProvider.RefreshImages(item, libraryOptions, new List { dynamicProvider.Object }, refreshOptions, CancellationToken.None); + + Assert.Equal(forceRefresh, result.UpdateType.HasFlag(ItemUpdateType.ImageUpdate)); + if (forceRefresh) + { + // replaces multi-types + Assert.Single(item.GetImages(imageType)); + } + else + { + // adds to multi-types if room + Assert.Equal(imageCount, item.GetImages(imageType).Count()); + } + } + + [Theory] + [InlineData(ImageType.Primary, 1, true, MediaProtocol.Http)] + [InlineData(ImageType.Backdrop, 2, true, MediaProtocol.Http)] + [InlineData(ImageType.Primary, 1, true, MediaProtocol.File)] + [InlineData(ImageType.Backdrop, 2, true, MediaProtocol.File)] + [InlineData(ImageType.Primary, 1, false, MediaProtocol.File)] + [InlineData(ImageType.Backdrop, 2, false, MediaProtocol.File)] + public async void RefreshImages_EmptyItemPopulatedProviderDynamic_AddsImages(ImageType imageType, int imageCount, bool responseHasPath, MediaProtocol protocol) + { + // Has to exist for querying DateModified time on file, results stored but not checked so not populating + BaseItem.FileSystem = Mock.Of(); + + var item = new Video(); + + var libraryOptions = GetLibraryOptions(item, imageType, imageCount); + + // Path must exist if set: is read in as a stream by AsyncFile.OpenRead + var imageResponse = new DynamicImageResponse + { + HasImage = true, + Format = ImageFormat.Jpg, + Path = responseHasPath ? string.Format(CultureInfo.InvariantCulture, TestDataImagePath, 0) : null, + Protocol = protocol + }; + + var dynamicProvider = new Mock(MockBehavior.Strict); + dynamicProvider.Setup(rp => rp.Name).Returns("MockDynamicProvider"); + dynamicProvider.Setup(rp => rp.GetSupportedImages(item)) + .Returns(new[] { imageType }); + dynamicProvider.Setup(rp => rp.GetImage(item, imageType, It.IsAny())) + .ReturnsAsync(imageResponse); + + var refreshOptions = new ImageRefreshOptions(Mock.Of()); + + var providerManager = new Mock(MockBehavior.Strict); + providerManager.Setup(pm => pm.SaveImage(item, It.IsAny(), It.IsAny(), imageType, null, It.IsAny())) + .Callback((callbackItem, _, _, callbackType, _, _) => callbackItem.SetImagePath(callbackType, 0, new FileSystemMetadata())) + .Returns(Task.CompletedTask); + var itemImageProvider = GetItemImageProvider(providerManager.Object, null); + var result = await itemImageProvider.RefreshImages(item, libraryOptions, new List { dynamicProvider.Object }, refreshOptions, CancellationToken.None); + + Assert.True(result.UpdateType.HasFlag(ItemUpdateType.ImageUpdate)); + // dynamic provider unable to return multiple images + Assert.Single(item.GetImages(imageType)); + if (protocol == MediaProtocol.Http) + { + Assert.Equal(imageResponse.Path, item.GetImagePath(imageType, 0)); + } + } + + [Theory] + [InlineData(ImageType.Primary, 1, false)] + [InlineData(ImageType.Backdrop, 1, false)] + [InlineData(ImageType.Backdrop, 2, false)] + [InlineData(ImageType.Primary, 1, true)] + [InlineData(ImageType.Backdrop, 1, true)] + [InlineData(ImageType.Backdrop, 2, true)] + public async void RefreshImages_PopulatedItemPopulatedProviderRemote_UpdatesImagesIfForced(ImageType imageType, int imageCount, bool forceRefresh) + { + var item = GetItemWithImages(imageType, imageCount, false); + + var libraryOptions = GetLibraryOptions(item, imageType, imageCount); + + var remoteProvider = new Mock(MockBehavior.Strict); + remoteProvider.Setup(rp => rp.Name).Returns("MockRemoteProvider"); + remoteProvider.Setup(rp => rp.GetSupportedImages(item)) + .Returns(new[] { imageType }); + + var refreshOptions = forceRefresh + ? new ImageRefreshOptions(Mock.Of()) + { + ImageRefreshMode = MetadataRefreshMode.FullRefresh, + ReplaceAllImages = true + } + : new ImageRefreshOptions(Mock.Of()); + + var remoteInfo = new RemoteImageInfo[imageCount]; + for (int i = 0; i < imageCount; i++) + { + remoteInfo[i] = new RemoteImageInfo + { + Type = imageType, + Url = "image url " + i, + Width = 1 // min width is set to 0, this will always pass + }; + } + + var providerManager = new Mock(MockBehavior.Strict); + providerManager.Setup(pm => pm.GetAvailableRemoteImages(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(remoteInfo); + var itemImageProvider = GetItemImageProvider(providerManager.Object, new Mock()); + var result = await itemImageProvider.RefreshImages(item, libraryOptions, new List { remoteProvider.Object }, refreshOptions, CancellationToken.None); + + Assert.Equal(forceRefresh, result.UpdateType.HasFlag(ItemUpdateType.ImageUpdate)); + Assert.Equal(imageCount, item.GetImages(imageType).Count()); + foreach (var image in item.GetImages(imageType)) + { + if (forceRefresh) + { + Assert.Matches(@"image url [0-9]", image.Path); + } + else + { + Assert.DoesNotMatch(@"image url [0-9]", image.Path); + } + } + } + + [Theory] + [InlineData(ImageType.Primary, 0, false)] // singular type only fetches if type is missing from item, no caching + [InlineData(ImageType.Backdrop, 0, false)] // empty item, no cache to check + [InlineData(ImageType.Backdrop, 1, false)] // populated item, cached so no download + [InlineData(ImageType.Backdrop, 1, true)] // populated item, forced to download + public async void RefreshImages_NonStubItemPopulatedProviderRemote_DownloadsIfNecessary(ImageType imageType, int initialImageCount, bool fullRefresh) + { + var targetImageCount = 1; + + // Set path and media source manager so images will be downloaded (EnableImageStub will return false) + var item = GetItemWithImages(imageType, initialImageCount, false); + item.Path = "non-empty path"; + BaseItem.MediaSourceManager = Mock.Of(); + + // seek 2 so it won't short-circuit out of downloading when populated + var libraryOptions = GetLibraryOptions(item, imageType, 2); + + const string Content = "Content"; + var remoteProvider = new Mock(MockBehavior.Strict); + remoteProvider.Setup(rp => rp.Name).Returns("MockRemoteProvider"); + remoteProvider.Setup(rp => rp.GetSupportedImages(item)) + .Returns(new[] { imageType }); + remoteProvider.Setup(rp => rp.GetImageResponse(It.IsAny(), It.IsAny())) + .ReturnsAsync((string url, CancellationToken _) => new HttpResponseMessage + { + ReasonPhrase = url, + StatusCode = HttpStatusCode.OK, + Content = new StringContent(Content, Encoding.UTF8, "image/jpeg") + }); + + var refreshOptions = fullRefresh + ? new ImageRefreshOptions(Mock.Of()) + { + ImageRefreshMode = MetadataRefreshMode.FullRefresh, + ReplaceAllImages = true + } + : new ImageRefreshOptions(Mock.Of()); + + var remoteInfo = new RemoteImageInfo[targetImageCount]; + for (int i = 0; i < targetImageCount; i++) + { + remoteInfo[i] = new RemoteImageInfo() + { + Type = imageType, + Url = "image url " + i, + Width = 1 // min width is set to 0, this will always pass + }; + } + + var providerManager = new Mock(MockBehavior.Strict); + providerManager.Setup(pm => pm.GetAvailableRemoteImages(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(remoteInfo); + providerManager.Setup(pm => pm.SaveImage(item, It.IsAny(), It.IsAny(), imageType, null, It.IsAny())) + .Callback((callbackItem, _, _, callbackType, _, _) => + callbackItem.SetImagePath(callbackType, callbackItem.AllowsMultipleImages(callbackType) ? callbackItem.GetImages(callbackType).Count() : 0, new FileSystemMetadata())) + .Returns(Task.CompletedTask); + var fileSystem = new Mock(); + // match reported file size to image content length - condition for skipping already downloaded multi-images + fileSystem.Setup(fs => fs.GetFileInfo(It.IsAny())) + .Returns(new FileSystemMetadata { Length = Content.Length }); + var itemImageProvider = GetItemImageProvider(providerManager.Object, fileSystem); + var result = await itemImageProvider.RefreshImages(item, libraryOptions, new List { remoteProvider.Object }, refreshOptions, CancellationToken.None); + + Assert.Equal(initialImageCount == 0 || fullRefresh, result.UpdateType.HasFlag(ItemUpdateType.ImageUpdate)); + Assert.Equal(targetImageCount, item.GetImages(imageType).Count()); + } + + [Theory] + [MemberData(nameof(GetImageTypesWithCount))] + public async void RefreshImages_EmptyItemPopulatedProviderRemoteExtras_LimitsImages(ImageType imageType, int imageCount) + { + var item = new Video(); + + var libraryOptions = GetLibraryOptions(item, imageType, imageCount); + + var remoteProvider = new Mock(MockBehavior.Strict); + remoteProvider.Setup(rp => rp.Name).Returns("MockRemoteProvider"); + remoteProvider.Setup(rp => rp.GetSupportedImages(item)) + .Returns(new[] { imageType }); + + var refreshOptions = new ImageRefreshOptions(Mock.Of()); + + // populate remote with double the required images to verify count is trimmed to the library option count + var remoteInfoCount = imageCount * 2; + var remoteInfo = new RemoteImageInfo[remoteInfoCount]; + for (int i = 0; i < remoteInfoCount; i++) + { + remoteInfo[i] = new RemoteImageInfo() + { + Type = imageType, + Url = "image url " + i, + Width = 1 // min width is set to 0, this will always pass + }; + } + + var providerManager = new Mock(MockBehavior.Strict); + providerManager.Setup(pm => pm.GetAvailableRemoteImages(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(remoteInfo); + var itemImageProvider = GetItemImageProvider(providerManager.Object, null); + var result = await itemImageProvider.RefreshImages(item, libraryOptions, new List { remoteProvider.Object }, refreshOptions, CancellationToken.None); + + Assert.True(result.UpdateType.HasFlag(ItemUpdateType.ImageUpdate)); + var actualImages = item.GetImages(imageType).ToList(); + Assert.Equal(imageCount, actualImages.Count); + // images from the provider manager are sorted by preference (earlier images are higher priority) so we can verify that low url numbers are chosen + foreach (var image in actualImages) + { + var index = int.Parse(Regex.Match(image.Path, @"[0-9]+").Value, NumberStyles.Integer, CultureInfo.InvariantCulture); + Assert.True(index < imageCount); + } + } + + [Theory] + [MemberData(nameof(GetImageTypesWithCount))] + public async void RefreshImages_PopulatedItemEmptyProviderRemoteFullRefresh_DoesntClearImages(ImageType imageType, int imageCount) + { + var item = GetItemWithImages(imageType, imageCount, false); + + var libraryOptions = GetLibraryOptions(item, imageType, imageCount); + + var remoteProvider = new Mock(MockBehavior.Strict); + remoteProvider.Setup(rp => rp.Name).Returns("MockRemoteProvider"); + remoteProvider.Setup(rp => rp.GetSupportedImages(item)) + .Returns(new[] { imageType }); + + var refreshOptions = new ImageRefreshOptions(Mock.Of()) + { + ImageRefreshMode = MetadataRefreshMode.FullRefresh, + ReplaceAllImages = true + }; + + var itemImageProvider = GetItemImageProvider(Mock.Of(), null); + var result = await itemImageProvider.RefreshImages(item, libraryOptions, new List { remoteProvider.Object }, refreshOptions, CancellationToken.None); + + Assert.False(result.UpdateType.HasFlag(ItemUpdateType.ImageUpdate)); + Assert.Equal(imageCount, item.GetImages(imageType).Count()); + } + + private static ItemImageProvider GetItemImageProvider(IProviderManager? providerManager, Mock? mockFileSystem) + { + // strict to ensure this isn't accidentally used where a prepared mock is intended + providerManager ??= Mock.Of(MockBehavior.Strict); + + // BaseItem.ValidateImages depends on the directory service being able to list directory contents, give it the expected valid file paths + mockFileSystem ??= new Mock(MockBehavior.Strict); + mockFileSystem.Setup(fs => fs.GetFilePaths(It.IsAny(), It.IsAny())) + .Returns(new[] + { + string.Format(CultureInfo.InvariantCulture, TestDataImagePath, 0), + string.Format(CultureInfo.InvariantCulture, TestDataImagePath, 1) + }); + + return new ItemImageProvider(new NullLogger(), providerManager, mockFileSystem.Object); + } + + private static BaseItem GetItemWithImages(ImageType type, int count, bool validPaths) + { + // Has to exist for querying DateModified time on file, results stored but not checked so not populating + BaseItem.FileSystem ??= Mock.Of(); + + var item = new Video(); + + var path = validPaths ? TestDataImagePath : "invalid path {0}"; + for (int i = 0; i < count; i++) + { + item.SetImagePath(type, i, new FileSystemMetadata + { + FullName = string.Format(CultureInfo.InvariantCulture, path, i), + }); + } + + return item; + } + + private static ILocalImageProvider GetImageProvider(ImageType type, int count, bool validPaths) + { + var images = GetImages(type, count, validPaths); + + var imageProvider = new Mock(); + imageProvider.Setup(ip => ip.GetImages(It.IsAny(), It.IsAny())) + .Returns(images); + return imageProvider.Object; + } + + /// + /// Creates a list of references of the specified type and size, optionally pointing to files that exist. + /// + private static LocalImageInfo[] GetImages(ImageType type, int count, bool validPaths) + { + var path = validPaths ? TestDataImagePath : "invalid path {0}"; + var images = new LocalImageInfo[count]; + for (int i = 0; i < count; i++) + { + images[i] = new LocalImageInfo + { + Type = type, + FileInfo = new FileSystemMetadata + { + FullName = string.Format(CultureInfo.InvariantCulture, path, i) + } + }; + } + + return images; + } + + /// + /// Generates a object that will allow for the requested number of images for the target type. + /// + private static LibraryOptions GetLibraryOptions(BaseItem item, ImageType type, int count) + { + return new LibraryOptions + { + TypeOptions = new[] + { + new TypeOptions + { + Type = item.GetType().Name, + ImageOptions = new[] + { + new ImageOption + { + Type = type, + Limit = count, + MinWidth = 0 + } + } + } + } + }; + } + } +} diff --git a/tests/Jellyfin.Providers.Tests/MediaInfo/EmbeddedImageProviderTests.cs b/tests/Jellyfin.Providers.Tests/MediaInfo/EmbeddedImageProviderTests.cs new file mode 100644 index 0000000000..5583218101 --- /dev/null +++ b/tests/Jellyfin.Providers.Tests/MediaInfo/EmbeddedImageProviderTests.cs @@ -0,0 +1,156 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.Movies; +using MediaBrowser.Controller.Entities.TV; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.MediaEncoding; +using MediaBrowser.Controller.Persistence; +using MediaBrowser.Model.Drawing; +using MediaBrowser.Model.Dto; +using MediaBrowser.Model.Entities; +using MediaBrowser.Providers.MediaInfo; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Xunit; + +namespace Jellyfin.Providers.Tests.MediaInfo +{ + public class EmbeddedImageProviderTests + { + [Theory] + [InlineData(typeof(AudioBook))] + [InlineData(typeof(BoxSet))] + [InlineData(typeof(Series))] + [InlineData(typeof(Season))] + [InlineData(typeof(Episode), ImageType.Primary)] + [InlineData(typeof(Movie), ImageType.Logo, ImageType.Backdrop, ImageType.Primary)] + public void GetSupportedImages_AnyBaseItem_ReturnsExpected(Type type, params ImageType[] expected) + { + BaseItem item = (BaseItem)Activator.CreateInstance(type)!; + var embeddedImageProvider = new EmbeddedImageProvider(Mock.Of(), Mock.Of(), new NullLogger()); + var actual = embeddedImageProvider.GetSupportedImages(item); + Assert.Equal(expected.OrderBy(i => i.ToString()), actual.OrderBy(i => i.ToString())); + } + + [Fact] + public async void GetImage_NoStreams_ReturnsNoImage() + { + var input = new Movie(); + + var mediaSourceManager = GetMediaSourceManager(input, new List(), new List()); + var embeddedImageProvider = new EmbeddedImageProvider(mediaSourceManager, null, new NullLogger()); + + var actual = await embeddedImageProvider.GetImage(input, ImageType.Primary, CancellationToken.None); + Assert.NotNull(actual); + Assert.False(actual.HasImage); + } + + [Theory] + [InlineData("chapter", null, 1, ImageType.Chapter, null)] // unexpected type, nothing found + [InlineData("unmatched", null, 1, ImageType.Primary, null)] // doesn't default on no match + [InlineData("clearlogo.png", null, 1, ImageType.Logo, ImageFormat.Png)] // extract extension from name + [InlineData("backdrop", "image/bmp", 2, ImageType.Backdrop, ImageFormat.Bmp)] // extract extension from mimetype + [InlineData("poster", null, 3, ImageType.Primary, ImageFormat.Jpg)] // default extension to jpg + public async void GetImage_Attachment_ReturnsCorrectSelection(string filename, string mimetype, int targetIndex, ImageType type, ImageFormat? expectedFormat) + { + var attachments = new List(); + string pathPrefix = "path"; + for (int i = 1; i <= targetIndex; i++) + { + var name = i == targetIndex ? filename : "unmatched"; + attachments.Add(new () + { + FileName = name, + MimeType = mimetype, + Index = i + }); + } + + var input = new Movie(); + + var mediaEncoder = new Mock(MockBehavior.Strict); + mediaEncoder.Setup(encoder => encoder.ExtractVideoImage(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns((_, _, _, _, index, ext, _) => Task.FromResult(pathPrefix + index + "." + ext)); + var mediaSourceManager = GetMediaSourceManager(input, attachments, new List()); + var embeddedImageProvider = new EmbeddedImageProvider(mediaSourceManager, mediaEncoder.Object, new NullLogger()); + + var actual = await embeddedImageProvider.GetImage(input, type, CancellationToken.None); + Assert.NotNull(actual); + if (expectedFormat == null) + { + Assert.False(actual.HasImage); + } + else + { + Assert.True(actual.HasImage); + Assert.Equal(pathPrefix + targetIndex + "." + expectedFormat, actual.Path, StringComparer.OrdinalIgnoreCase); + Assert.Equal(expectedFormat, actual.Format); + } + } + + [Theory] + [InlineData("chapter", null, 1, ImageType.Chapter, null)] // unexpected type, nothing found + [InlineData(null, null, 1, ImageType.Backdrop, null)] // no label, can only find primary + [InlineData(null, null, 1, ImageType.Primary, ImageFormat.Jpg)] // no label, finds primary + [InlineData("backdrop", null, 2, ImageType.Backdrop, ImageFormat.Jpg)] // uses label to find index 2, not just pulling first stream + [InlineData("cover", null, 2, ImageType.Primary, ImageFormat.Jpg)] // uses label to find index 2, not just pulling first stream + [InlineData(null, "mjpeg", 1, ImageType.Primary, ImageFormat.Jpg)] + [InlineData(null, "png", 1, ImageType.Primary, ImageFormat.Png)] + [InlineData(null, "gif", 1, ImageType.Primary, ImageFormat.Gif)] + public async void GetImage_Embedded_ReturnsCorrectSelection(string label, string? codec, int targetIndex, ImageType type, ImageFormat? expectedFormat) + { + var streams = new List(); + for (int i = 1; i <= targetIndex; i++) + { + var comment = i == targetIndex ? label : "unmatched"; + streams.Add(new () + { + Type = MediaStreamType.EmbeddedImage, + Index = i, + Comment = comment, + Codec = codec + }); + } + + var input = new Movie(); + + var pathPrefix = "path"; + var mediaEncoder = new Mock(MockBehavior.Strict); + mediaEncoder.Setup(encoder => encoder.ExtractVideoImage(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns((_, _, _, stream, index, ext, _) => + { + Assert.Equal(streams[index - 1], stream); + return Task.FromResult(pathPrefix + index + "." + ext); + }); + var mediaSourceManager = GetMediaSourceManager(input, new List(), streams); + var embeddedImageProvider = new EmbeddedImageProvider(mediaSourceManager, mediaEncoder.Object, new NullLogger()); + + var actual = await embeddedImageProvider.GetImage(input, type, CancellationToken.None); + Assert.NotNull(actual); + if (expectedFormat == null) + { + Assert.False(actual.HasImage); + } + else + { + Assert.True(actual.HasImage); + Assert.Equal(pathPrefix + targetIndex + "." + expectedFormat, actual.Path, StringComparer.OrdinalIgnoreCase); + Assert.Equal(expectedFormat, actual.Format); + } + } + + private static IMediaSourceManager GetMediaSourceManager(BaseItem item, List mediaAttachments, List mediaStreams) + { + var mediaSourceManager = new Mock(MockBehavior.Strict); + mediaSourceManager.Setup(i => i.GetMediaAttachments(item.Id)) + .Returns(mediaAttachments); + mediaSourceManager.Setup(i => i.GetMediaStreams(It.Is(q => q.ItemId == item.Id && q.Type == MediaStreamType.EmbeddedImage))) + .Returns(mediaStreams); + return mediaSourceManager.Object; + } + } +} diff --git a/tests/Jellyfin.Providers.Tests/MediaInfo/SubtitleResolverTests.cs b/tests/Jellyfin.Providers.Tests/MediaInfo/SubtitleResolverTests.cs new file mode 100644 index 0000000000..33da277e35 --- /dev/null +++ b/tests/Jellyfin.Providers.Tests/MediaInfo/SubtitleResolverTests.cs @@ -0,0 +1,129 @@ +#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 TheoryData, string, int, string[], MediaStream[]> AddExternalSubtitleStreams_GivenMixedFilenames_ReturnsValidSubtitles_TestData() + { + var data = new TheoryData, string, int, string[], MediaStream[]>(); + + var index = 0; + data.Add( + new List(), + "/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), + }); + + return data; + } + + [Theory] + [MemberData(nameof(AddExternalSubtitleStreams_GivenMixedFilenames_ReturnsValidSubtitles_TestData))] + public void AddExternalSubtitleStreams_GivenMixedFilenames_ReturnsValidSubtitles(List streams, string videoPath, int startIndex, string[] files, MediaStream[] expectedResult) + { + new SubtitleResolver(Mock.Of()).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); + } + } + + [Theory] + [InlineData("/video/My Video.mkv", "/video/My Video.srt", "srt", null, false, false)] + [InlineData("/video/My.Video.mkv", "/video/My.Video.srt", "srt", null, false, false)] + [InlineData("/video/My.Video.mkv", "/video/My.Video.foreign.srt", "srt", null, true, false)] + [InlineData("/video/My Video.mkv", "/video/My Video.forced.srt", "srt", null, true, false)] + [InlineData("/video/My.Video.mkv", "/video/My.Video.default.srt", "srt", null, false, true)] + [InlineData("/video/My.Video.mkv", "/video/My.Video.forced.default.srt", "srt", null, true, true)] + [InlineData("/video/My.Video.mkv", "/video/My.Video.en.srt", "srt", "en", false, false)] + [InlineData("/video/My.Video.mkv", "/video/My.Video.default.en.srt", "srt", "en", false, true)] + [InlineData("/video/My.Video.mkv", "/video/My.Video.default.forced.en.srt", "srt", "en", true, true)] + [InlineData("/video/My.Video.mkv", "/video/My.Video.en.default.forced.srt", "srt", "en", true, true)] + public void AddExternalSubtitleStreams_GivenSingleFile_ReturnsExpectedSubtitle(string videoPath, string file, string codec, string? language, bool isForced, bool isDefault) + { + var streams = new List(); + var expected = CreateMediaStream(file, codec, language, 0, isForced, isDefault); + + new SubtitleResolver(Mock.Of()).AddExternalSubtitleStreams(streams, videoPath, 0, new[] { file }); + + Assert.Single(streams); + + var actual = streams[0]; + + 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/MediaInfo/VideoImageProviderTests.cs b/tests/Jellyfin.Providers.Tests/MediaInfo/VideoImageProviderTests.cs new file mode 100644 index 0000000000..839925dd10 --- /dev/null +++ b/tests/Jellyfin.Providers.Tests/MediaInfo/VideoImageProviderTests.cs @@ -0,0 +1,126 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.Movies; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.MediaEncoding; +using MediaBrowser.Controller.Persistence; +using MediaBrowser.Model.Drawing; +using MediaBrowser.Model.Dto; +using MediaBrowser.Model.Entities; +using MediaBrowser.Providers.MediaInfo; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Xunit; + +namespace Jellyfin.Providers.Tests.MediaInfo +{ + public class VideoImageProviderTests + { + private static TheoryData