diff --git a/.ci/azure-pipelines-abi.yml b/.ci/azure-pipelines-abi.yml index e58a2bdc7..cf74a4201 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.302 + default: 6.0.x jobs: - job: CompatibilityCheck diff --git a/.ci/azure-pipelines-main.yml b/.ci/azure-pipelines-main.yml index d2c087c14..b7112ba24 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.302 + 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 543fd7fc6..19d65ea0c 100644 --- a/.ci/azure-pipelines-package.yml +++ b/.ci/azure-pipelines-package.yml @@ -39,6 +39,10 @@ jobs: vmImage: 'ubuntu-latest' steps: + - script: echo "##vso[task.setvariable variable=JellyfinVersion]$( awk -F '/' '{ print $NF }' <<<'$(Build.SourceBranch)' | sed 's/^v//' )" + displayName: Set release version (stable) + condition: startsWith(variables['Build.SourceBranch'], 'refs/tags/v') + - script: 'docker build -f deployment/Dockerfile.$(BuildConfiguration) -t jellyfin-server-$(BuildConfiguration) deployment' displayName: 'Build Dockerfile' @@ -80,6 +84,10 @@ jobs: vmImage: 'ubuntu-latest' steps: + - script: echo "##vso[task.setvariable variable=JellyfinVersion]$( awk -F '/' '{ print $NF }' <<<'$(Build.SourceBranch)' | sed 's/^v//' )" + displayName: Set release version (stable) + condition: startsWith(variables['Build.SourceBranch'], 'refs/tags/v') + - task: DownloadPipelineArtifact@2 displayName: 'Download OpenAPI Spec' inputs: @@ -181,7 +189,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 +203,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 +219,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 +234,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 7ec4cdad1..cc94dc2c5 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.302 + 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 4e8b6557b..19c9caacb 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.302 pr: autoCancel: true @@ -57,6 +55,9 @@ 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')) }}: diff --git a/.copr b/.copr new file mode 120000 index 000000000..100fe0cd7 --- /dev/null +++ b/.copr @@ -0,0 +1 @@ +fedora \ No newline at end of file diff --git a/.copr/Makefile b/.copr/Makefile deleted file mode 120000 index ec3c90dfd..000000000 --- a/.copr/Makefile +++ /dev/null @@ -1 +0,0 @@ -../fedora/Makefile \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md deleted file mode 100644 index c1d49778e..000000000 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ /dev/null @@ -1,51 +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 91, Chrome 93, Safari 13] - - Jellyfin Version: [e.g. 10.7.6, unstable 20191231] - - 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] - -**To Reproduce** - -1. Go to '...' -2. Click on '....' -3. Scroll down to '....' -4. See error - -**Expected behavior** - - -**Server Logs** - - -**FFmpeg Logs** - - -**Browser Console 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 000000000..63e0f0e22 --- /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/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 3e456f909..ea1d30cdf 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/openapi.yml b/.github/workflows/openapi.yml new file mode 100644 index 000000000..3e9346840 --- /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/.vscode/launch.json b/.vscode/launch.json index e55ea2248..b82956a72 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 cb52cafed..d52e13324 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -149,6 +149,8 @@ - [skyfrk](https://github.com/skyfrk) - [ianjazz246](https://github.com/ianjazz246) - [peterspenler](https://github.com/peterspenler) + - [MBR-0001](https://github.com/MBR-0001) + - [jonas-resch](https://github.com/jonas-resch) # Emby Contributors diff --git a/Directory.Build.props b/Directory.Build.props index b899999ef..b27782918 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -3,10 +3,13 @@ enable - true $(MSBuildThisFileDirectory)/jellyfin.ruleset + + true + + AllEnabledByDefault diff --git a/Dockerfile b/Dockerfile index 791a6113e..e133c0819 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,7 +2,7 @@ ##################################### # 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:lts-alpine as web-builder ARG JELLYFIN_WEB_VERSION=master @@ -12,7 +12,7 @@ RUN apk add curl git zlib zlib-dev autoconf g++ make libpng-dev gifsicle alpine- && npm ci --no-audit --unsafe-perm \ && mv dist /dist -FROM debian:bullseye-slim as app +FROM debian:stable-slim as app # https://askubuntu.com/questions/972516/debian-frontend-environment-variable ARG DEBIAN_FRONTEND="noninteractive" @@ -29,8 +29,9 @@ 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 \ @@ -61,7 +62,7 @@ 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 @@ -76,6 +77,8 @@ RUN dotnet publish Jellyfin.Server --disable-parallel --configuration Release -- FROM app +ENV HEALTHCHECK_URL=http://localhost:8096/health + COPY --from=builder /jellyfin /jellyfin COPY --from=web-builder /dist /jellyfin/jellyfin-web @@ -85,3 +88,6 @@ 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 8d4b548bc..a46fa331d 100644 --- a/Dockerfile.arm +++ b/Dockerfile.arm @@ -2,7 +2,7 @@ ##################################### # 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:lts-alpine as web-builder @@ -14,7 +14,7 @@ RUN apk add curl git zlib zlib-dev autoconf g++ make libpng-dev gifsicle alpine- && mv dist /dist FROM multiarch/qemu-user-static:x86_64-arm as qemu -FROM arm32v7/debian:bullseye-slim as app +FROM arm32v7/debian:stable-slim as app # https://askubuntu.com/questions/972516/debian-frontend-environment-variable ARG DEBIAN_FRONTEND="noninteractive" @@ -24,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 - && \ @@ -42,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/* \ @@ -50,7 +52,7 @@ 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 @@ -66,6 +68,8 @@ RUN dotnet publish Jellyfin.Server --configuration Release --output="/jellyfin" FROM app +ENV HEALTHCHECK_URL=http://localhost:8096/health + COPY --from=builder /jellyfin /jellyfin COPY --from=web-builder /dist /jellyfin/jellyfin-web @@ -75,3 +79,6 @@ 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 835aa36a1..1279c47f8 100644 --- a/Dockerfile.arm64 +++ b/Dockerfile.arm64 @@ -2,7 +2,7 @@ ##################################### # 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:lts-alpine as web-builder @@ -14,7 +14,7 @@ RUN apk add curl git zlib zlib-dev autoconf g++ make libpng-dev gifsicle alpine- && mv dist /dist FROM multiarch/qemu-user-static:x86_64-aarch64 as qemu -FROM arm64v8/debian:bullseye-slim as app +FROM arm64v8/debian:stable-slim as app # https://askubuntu.com/questions/972516/debian-frontend-environment-variable ARG DEBIAN_FRONTEND="noninteractive" @@ -24,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 \ @@ -33,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/* \ @@ -40,7 +43,7 @@ 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 -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 @@ -56,6 +59,8 @@ RUN dotnet publish Jellyfin.Server --configuration Release --output="/jellyfin" FROM app +ENV HEALTHCHECK_URL=http://localhost:8096/health + COPY --from=builder /jellyfin /jellyfin COPY --from=web-builder /dist /jellyfin/jellyfin-web @@ -65,3 +70,6 @@ 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 b8301e2f2..755d29160 100644 --- a/DvdLib/DvdLib.csproj +++ b/DvdLib/DvdLib.csproj @@ -10,7 +10,7 @@ - net5.0 + net6.0 false true AllDisabledByDefault diff --git a/DvdLib/Ifo/Dvd.cs b/DvdLib/Ifo/Dvd.cs index b4a11ed5d..7f8ece47d 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/ContentDirectory/ControlHandler.cs b/Emby.Dlna/ContentDirectory/ControlHandler.cs index ac336e5dc..0cd1a0daf 100644 --- a/Emby.Dlna/ContentDirectory/ControlHandler.cs +++ b/Emby.Dlna/ContentDirectory/ControlHandler.cs @@ -18,23 +18,16 @@ 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.Library; -using MediaBrowser.Controller.LiveTv; 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; using Microsoft.Extensions.Logging; -using Book = MediaBrowser.Controller.Entities.Book; -using Episode = MediaBrowser.Controller.Entities.TV.Episode; using Genre = MediaBrowser.Controller.Entities.Genre; -using Movie = MediaBrowser.Controller.Entities.Movies.Movie; -using MusicAlbum = MediaBrowser.Controller.Entities.Audio.MusicAlbum; -using Series = MediaBrowser.Controller.Entities.TV.Series; namespace Emby.Dlna.ContentDirectory { @@ -50,7 +43,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; @@ -104,7 +96,6 @@ namespace Emby.Dlna.ContentDirectory _userViewManager = userViewManager; _tvSeriesManager = tvSeriesManager; _profile = profile; - _config = config; _didlBuilder = new DidlBuilder( profile, @@ -291,9 +282,9 @@ namespace Emby.Dlna.ContentDirectory return "" + "" + "" - + "" - + "" - + "" + + "" + + "" + + "" + "" + ""; } @@ -330,75 +321,73 @@ namespace Emby.Dlna.ContentDirectory int totalCount; - using (StringWriter builder = new StringWriterWithEncoding(Encoding.UTF8)) + var settings = new XmlWriterSettings { - 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)) + { + 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); + + DidlBuilder.WriteXmlRootAttributes(_profile, writer); + + var serverItem = GetItemFromObjectId(id); + var item = serverItem.Item; + + if (string.Equals(flag, "BrowseMetadata", StringComparison.Ordinal)) { - Encoding = Encoding.UTF8, - CloseOutput = false, - OmitXmlDeclaration = true, - ConformanceLevel = ConformanceLevel.Fragment - }; + totalCount = 1; - 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); - - DidlBuilder.WriteXmlRootAttributes(_profile, writer); - - var serverItem = GetItemFromObjectId(id); - var item = serverItem.Item; - - if (string.Equals(flag, "BrowseMetadata", StringComparison.Ordinal)) + if (item.IsDisplayedAsFolder || serverItem.StubType.HasValue) { - totalCount = 1; + var childrenResult = GetUserItems(item, serverItem.StubType, _user, sortCriteria, start, requestedCount); - 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); - } - - 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; - - 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; - - _didlBuilder.WriteFolderElement(writer, childItem, displayStubType, item, childCount, filter); - } - else - { - _didlBuilder.WriteItemElement(writer, childItem, _user, item, serverItem.StubType, deviceId, filter); - } - } + _didlBuilder.WriteItemElement(writer, item, _user, null, null, deviceId, filter); } - writer.WriteFullEndElement(); + provided++; + } + else + { + var childrenResult = GetUserItems(item, serverItem.StubType, _user, sortCriteria, start, requestedCount); + totalCount = childrenResult.TotalRecordCount; + + 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; + + _didlBuilder.WriteFolderElement(writer, childItem, displayStubType, item, childCount, filter); + } + else + { + _didlBuilder.WriteItemElement(writer, childItem, _user, item, serverItem.StubType, deviceId, filter); + } + } } + writer.WriteFullEndElement(); + writer.Flush(); xmlWriter.WriteElementString("Result", builder.ToString()); } @@ -449,53 +438,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() + 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); + + DidlBuilder.WriteXmlRootAttributes(_profile, writer); + + var serverItem = GetItemFromObjectId(sparams["ContainerID"]); + + var item = serverItem.Item; + + childrenResult = GetChildrenSorted(item, _user, searchCriteria, sortCriteria, start, requestedCount); + foreach (var i in childrenResult.Items) { - Encoding = Encoding.UTF8, - CloseOutput = false, - OmitXmlDeclaration = true, - ConformanceLevel = ConformanceLevel.Fragment - }; - - 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); - - DidlBuilder.WriteXmlRootAttributes(_profile, writer); - - 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) + 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()); } @@ -518,48 +500,38 @@ 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) + switch (search.SearchType) { - mediaTypes = new[] { MediaType.Audio }; - isFolder = false; - } - else if (search.SearchType == SearchType.Video) - { - mediaTypes = new[] { MediaType.Video }; - isFolder = false; - } - else if (search.SearchType == SearchType.Image) - { - 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, - ExcludeItemTypes = new[] { nameof(Book) }, + ExcludeItemTypes = new[] { BaseItemKind.Book }, IsFolder = isFolder, MediaTypes = mediaTypes, DtoOptions = GetDtoOptions() @@ -587,52 +559,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) + switch (item) { - return GetMusicGenreItems(item, Guid.Empty, user, sort, startIndex, limit); + 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 (item is MusicArtist) + if (stubType != StubType.Folder && item is IHasCollectionType collectionFolder) { - return GetMusicArtistItems(item, Guid.Empty, user, sort, startIndex, limit); - } - - if (item is Genre) - { - return GetGenreItems(item, Guid.Empty, user, sort, startIndex, limit); - } - - if ((!stubType.HasValue || stubType.Value != 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; @@ -642,13 +611,12 @@ namespace Emby.Dlna.ContentDirectory Limit = limit, StartIndex = startIndex, IsVirtualItem = false, - ExcludeItemTypes = new[] { nameof(Book) }, + ExcludeItemTypes = new[] { BaseItemKind.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); @@ -668,10 +636,9 @@ namespace Emby.Dlna.ContentDirectory { StartIndex = startIndex, Limit = limit, + IncludeItemTypes = new[] { BaseItemKind.LiveTvChannel }, + OrderBy = GetOrderBy(sort, false) }; - query.IncludeItemTypes = new[] { nameof(LiveTvChannel) }; - - SetSorting(query, sort, false); var result = _libraryManager.GetItemsResult(query); @@ -693,117 +660,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) + switch (stubType) { - return GetMusicLatest(item, user, query); + case StubType.Latest: + return GetLatest(item, query, BaseItemKind.Audio); + case StubType.Playlists: + return GetMusicPlaylists(query); + case StubType.Albums: + return GetChildrenOfItem(item, query, BaseItemKind.MusicAlbum); + case StubType.Artists: + return GetMusicArtists(item, query); + case StubType.AlbumArtists: + return GetMusicAlbumArtists(item, query); + case StubType.FavoriteAlbums: + return GetChildrenOfItem(item, query, BaseItemKind.MusicAlbum, true); + case StubType.FavoriteArtists: + return GetFavoriteArtists(item, query); + case StubType.FavoriteSongs: + return GetChildrenOfItem(item, query, BaseItemKind.Audio, true); + case StubType.Songs: + return GetChildrenOfItem(item, query, BaseItemKind.Audio); + case StubType.Genres: + return GetMusicGenres(item, query); } - if (stubType.HasValue && stubType.Value == StubType.Playlists) + var serverItems = new ServerItem[] { - return GetMusicPlaylists(user, 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); - } - - if (stubType.HasValue && stubType.Value == StubType.Genres) - { - return GetMusicGenres(item, user, query); - } - - 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 - } + 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 (limit < serverItems.Length) + { + serverItems = serverItems[..limit.Value]; + } + return new QueryResult { - Items = list, - TotalRecordCount = list.Count + Items = serverItems, + TotalRecordCount = serverItems.Length }; } @@ -822,68 +729,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) + switch (stubType) { - return GetMovieContinueWatching(item, user, query); + case StubType.ContinueWatching: + return GetMovieContinueWatching(item, query); + case StubType.Latest: + return GetLatest(item, query, BaseItemKind.Movie); + case StubType.Movies: + return GetChildrenOfItem(item, query, BaseItemKind.Movie); + case StubType.Collections: + return GetMovieCollections(query); + case StubType.Favorites: + return GetChildrenOfItem(item, query, BaseItemKind.Movie, true); + case StubType.Genres: + return GetGenres(item, query); } - if (stubType.HasValue && stubType.Value == StubType.Latest) + var array = new ServerItem[] { - return GetMovieLatest(item, user, query); - } - - if (stubType.HasValue && stubType.Value == StubType.Movies) - { - return GetMovieMovies(item, user, query); - } - - if (stubType.HasValue && stubType.Value == StubType.Collections) - { - return GetMovieCollections(user, query); - } - - if (stubType.HasValue && stubType.Value == StubType.Favorites) - { - return GetMovieFavorites(item, user, query); - } - - if (stubType.HasValue && stubType.Value == StubType.Genres) - { - return GetGenres(item, user, query); - } - - 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 - } + 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 (limit < array.Length) + { + array = array[..limit.Value]; + } + return new QueryResult { Items = array, @@ -900,22 +780,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 + }; } /// @@ -933,87 +812,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) + switch (stubType) { - return GetMovieContinueWatching(item, user, query); + case StubType.ContinueWatching: + return GetMovieContinueWatching(item, query); + case StubType.NextUp: + return GetNextUp(item, query); + case StubType.Latest: + return GetLatest(item, query, BaseItemKind.Episode); + case StubType.Series: + return GetChildrenOfItem(item, query, BaseItemKind.Series); + case StubType.FavoriteSeries: + return GetChildrenOfItem(item, query, BaseItemKind.Series, true); + case StubType.FavoriteEpisodes: + return GetChildrenOfItem(item, query, BaseItemKind.Episode, true); + case StubType.Genres: + return GetGenres(item, query); } - if (stubType.HasValue && stubType.Value == StubType.NextUp) + var serverItems = new ServerItem[] { - 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); - } - - if (stubType.HasValue && stubType.Value == StubType.FavoriteEpisodes) - { - return GetFavoriteEpisodes(item, user, query); - } - - if (stubType.HasValue && stubType.Value == StubType.Genres) - { - return GetGenres(item, user, query); - } - - 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 - } + 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 (limit < serverItems.Length) + { + serverItems = serverItems[..limit.Value]; + } + return new QueryResult { - Items = list, - TotalRecordCount = list.Count + Items = serverItems, + TotalRecordCount = serverItems.Length }; } @@ -1021,14 +861,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[] { @@ -1037,47 +875,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); @@ -1087,16 +885,12 @@ 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) }; + query.IncludeItemTypes = new[] { BaseItemKind.BoxSet }; var result = _libraryManager.GetItemsResult(query); @@ -1104,139 +898,19 @@ namespace Emby.Dlna.ContentDirectory } /// - /// Returns the Music albums 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 GetMusicAlbums(BaseItem parent, User user, InternalItemsQuery query) + private QueryResult GetChildrenOfItem(BaseItem parent, InternalItemsQuery query, BaseItemKind itemType, bool isFavorite = false) { 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. - /// - /// The . - /// The . - /// The . - /// The . - private QueryResult GetMusicSongs(BaseItem parent, User user, InternalItemsQuery query) - { - query.Recursive = true; - query.Parent = parent; - query.SetUser(user); - - query.IncludeItemTypes = new[] { 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); @@ -1248,139 +922,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 - }); + // Don't sort + query.OrderBy = Array.Empty<(string, SortOrder)>(); + query.AncestorIds = new[] { parent.Id }; + var genresResult = _libraryManager.GetGenres(query); - var result = new QueryResult - { - TotalRecordCount = genresResult.TotalRecordCount, - Items = genresResult.Items.Select(i => i.Item1).ToArray() - }; - - 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 - }); + // Don't sort + query.OrderBy = Array.Empty<(string, SortOrder)>(); + query.AncestorIds = new[] { parent.Id }; + var genresResult = _libraryManager.GetMusicGenres(query); - var result = new QueryResult - { - TotalRecordCount = genresResult.TotalRecordCount, - Items = genresResult.Items.Select(i => i.Item1).ToArray() - }; - - 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 - }); + // Don't sort + query.OrderBy = Array.Empty<(string, SortOrder)>(); + query.AncestorIds = new[] { parent.Id }; + var artists = _libraryManager.GetAlbumArtists(query); - var result = new QueryResult - { - TotalRecordCount = artists.TotalRecordCount, - Items = artists.Items.Select(i => i.Item1).ToArray() - }; - - 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.IncludeItemTypes = new[] { BaseItemKind.Playlist }; query.Recursive = true; var result = _libraryManager.GetItemsResult(query); @@ -1388,31 +1013,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. /// @@ -1428,7 +1028,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); @@ -1437,47 +1038,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, BaseItemKind 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 }, @@ -1490,27 +1067,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) }, + IncludeItemTypes = new[] { BaseItemKind.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); @@ -1520,31 +1094,28 @@ 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[] { - nameof(Movie), - nameof(Series) + BaseItemKind.Movie, + BaseItemKind.Series }, Limit = limit, StartIndex = startIndex, - DtoOptions = GetDtoOptions() + DtoOptions = GetDtoOptions(), + OrderBy = GetOrderBy(sort, false) }; - SetSorting(query, sort, false); - var result = _libraryManager.GetItemsResult(query); return ToResult(result); @@ -1554,46 +1125,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) }, + IncludeItemTypes = new[] { BaseItemKind.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 }; } @@ -1605,10 +1173,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 { @@ -1618,35 +1188,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 Item, ItemCounts 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].Item, 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 SortName, SortOrder 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) }; } /// @@ -1657,7 +1226,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); } @@ -1675,37 +1244,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 ff30e6e4a..df05fa966 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 not 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/Didl/DidlBuilder.cs b/Emby.Dlna/Didl/DidlBuilder.cs index c00078499..6803b3b87 100644 --- a/Emby.Dlna/Didl/DidlBuilder.cs +++ b/Emby.Dlna/Didl/DidlBuilder.cs @@ -41,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; @@ -317,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")) @@ -328,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)); } } } @@ -342,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")) @@ -361,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( @@ -552,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")) @@ -563,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)); } } } @@ -575,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( @@ -639,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); @@ -731,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); } } @@ -931,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); } } } @@ -991,7 +989,7 @@ namespace Emby.Dlna.Didl writer.WriteAttributeString("dlna", "profileID", NsDlna, _profile.AlbumArtPn); } - writer.WriteString(albumArtUrlInfo.url); + writer.WriteString(albumArtUrlInfo.Url); writer.WriteFullEndElement(); // TODO: Remove these default values @@ -1000,7 +998,7 @@ namespace Emby.Dlna.Didl _profile.MaxIconWidth ?? 48, _profile.MaxIconHeight ?? 48, "jpg"); - writer.WriteElementString("upnp", "icon", NsUpnp, iconUrlInfo.url); + writer.WriteElementString("upnp", "icon", NsUpnp, iconUrlInfo.Url); if (!_profile.EnableAlbumArtInDidl) { @@ -1047,8 +1045,8 @@ namespace Emby.Dlna.Didl // Images must have a reported size or many clients (Bubble upnp), will only use the first thumbnail // rather than using a larger one when available - var width = albumartUrlInfo.width ?? maxWidth; - var height = albumartUrlInfo.height ?? maxHeight; + var width = albumartUrlInfo.Width ?? maxWidth; + var height = albumartUrlInfo.Height ?? maxHeight; var contentFeatures = ContentFeatureBuilder.BuildImageHeader(_profile, format, width, height, imageInfo.IsDirectStream, org_Pn); @@ -1064,7 +1062,7 @@ namespace Emby.Dlna.Didl "resolution", string.Format(CultureInfo.InvariantCulture, "{0}x{1}", width, height)); - writer.WriteString(albumartUrlInfo.url); + writer.WriteString(albumartUrlInfo.Url); writer.WriteFullEndElement(); } @@ -1202,7 +1200,7 @@ namespace Emby.Dlna.Didl return id; } - private (string url, int? width, int? height) GetImageUrl(ImageDownloadInfo info, int maxWidth, int maxHeight, string format) + private (string Url, int? Width, int? Height) GetImageUrl(ImageDownloadInfo info, int maxWidth, int maxHeight, string format) { var url = string.Format( CultureInfo.InvariantCulture, diff --git a/Emby.Dlna/Didl/Filter.cs b/Emby.Dlna/Didl/Filter.cs index d703f043e..6db6f3ae3 100644 --- a/Emby.Dlna/Didl/Filter.cs +++ b/Emby.Dlna/Didl/Filter.cs @@ -17,8 +17,7 @@ namespace Emby.Dlna.Didl public Filter(string filter) { _all = string.Equals(filter, "*", StringComparison.OrdinalIgnoreCase); - - _fields = (filter ?? string.Empty).Split(',', StringSplitOptions.RemoveEmptyEntries); + _fields = filter.Split(',', StringSplitOptions.RemoveEmptyEntries); } public bool Contains(string field) diff --git a/Emby.Dlna/DlnaManager.cs b/Emby.Dlna/DlnaManager.cs index 68fc80c0a..f2a0548c2 100644 --- a/Emby.Dlna/DlnaManager.cs +++ b/Emby.Dlna/DlnaManager.cs @@ -5,7 +5,6 @@ using System.Globalization; using System.IO; using System.Linq; using System.Reflection; -using System.Text; using System.Text.Json; using System.Text.RegularExpressions; using System.Threading.Tasks; @@ -84,8 +83,7 @@ namespace Emby.Dlna { lock (_profiles) { - var list = _profiles.Values.ToList(); - return list + return _profiles.Values .OrderBy(i => i.Item1.Info.Type == DeviceProfileType.User ? 0 : 1) .ThenBy(i => i.Item1.Info.Name) .Select(i => i.Item2) @@ -112,7 +110,7 @@ namespace Emby.Dlna if (profile == null) { - LogUnmatchedProfile(deviceInfo); + _logger.LogInformation("No matching device profile found. The default will need to be used. \n{@Profile}", deviceInfo); } else { @@ -122,23 +120,6 @@ namespace Emby.Dlna return profile; } - private void LogUnmatchedProfile(DeviceIdentification profile) - { - 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()); - } - /// /// Attempts to match a device with a profile. /// Rules: @@ -244,11 +225,8 @@ namespace Emby.Dlna { try { - var xmlFies = _fileSystem.GetFilePaths(path) + return _fileSystem.GetFilePaths(path) .Where(i => string.Equals(Path.GetExtension(i), ".xml", StringComparison.OrdinalIgnoreCase)) - .ToList(); - - return xmlFies .Select(i => ParseProfileFile(i, type)) .Where(i => i != null) .ToList()!; // We just filtered out all the nulls @@ -270,11 +248,8 @@ namespace Emby.Dlna try { - DeviceProfile profile; - var tempProfile = (DeviceProfile)_xmlSerializer.DeserializeFromFile(typeof(DeviceProfile), path); - - profile = ReserializeProfile(tempProfile); + var profile = ReserializeProfile(tempProfile); profile.Id = path.ToLowerInvariant().GetMD5().ToString("N", CultureInfo.InvariantCulture); @@ -313,8 +288,7 @@ namespace Emby.Dlna { lock (_profiles) { - var list = _profiles.Values.ToList(); - return list + return _profiles.Values .Select(i => i.Item1) .OrderBy(i => i.Info.Type == DeviceProfileType.User ? 0 : 1) .ThenBy(i => i.Info.Name); @@ -359,14 +333,17 @@ namespace Emby.Dlna // 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, IODefaults.FileStreamBufferSize, AsyncFile.UseAsyncIO)) + 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,7 +390,7 @@ namespace Emby.Dlna } /// - public void UpdateProfile(DeviceProfile profile) + public void UpdateProfile(string profileId, DeviceProfile profile) { profile = ReserializeProfile(profile); @@ -427,7 +404,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); @@ -486,18 +463,22 @@ namespace Emby.Dlna } /// - 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(); - - return new ImageStream + var stream = _assembly.GetManifestResourceStream(resource); + if (stream == null) { - Format = format, - Stream = _assembly.GetManifestResourceStream(resource) + return null; + } + + return new ImageStream(stream) + { + Format = format }; } diff --git a/Emby.Dlna/Emby.Dlna.csproj b/Emby.Dlna/Emby.Dlna.csproj index 970c16d2e..fd95041fe 100644 --- a/Emby.Dlna/Emby.Dlna.csproj +++ b/Emby.Dlna/Emby.Dlna.csproj @@ -17,16 +17,19 @@ - net5.0 + net6.0 false true - AllDisabledByDefault + + + + false - + @@ -73,7 +76,7 @@ - + diff --git a/Emby.Dlna/Eventing/DlnaEventManager.cs b/Emby.Dlna/Eventing/DlnaEventManager.cs index 3c9136090..d17e23871 100644 --- a/Emby.Dlna/Eventing/DlnaEventManager.cs +++ b/Emby.Dlna/Eventing/DlnaEventManager.cs @@ -11,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; @@ -25,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; @@ -82,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; } @@ -107,7 +104,7 @@ namespace Emby.Dlna.Eventing 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; } @@ -164,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/Main/DlnaEntryPoint.cs b/Emby.Dlna/Main/DlnaEntryPoint.cs index 5d252d8dc..08f639d93 100644 --- a/Emby.Dlna/Main/DlnaEntryPoint.cs +++ b/Emby.Dlna/Main/DlnaEntryPoint.cs @@ -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(NetworkConfigurationStore.StoreKey); + _disabled = appHost.ListenWithHttps && netConfig.RequireHttps; if (_disabled && _config.GetDlnaConfiguration().EnableServer) { @@ -219,11 +218,6 @@ namespace Emby.Dlna.Main } } - private void LogMessage(string msg) - { - _logger.LogDebug(msg); - } - private void StartDeviceDiscovery(ISsdpCommunicationsServer communicationsServer) { try @@ -268,12 +262,11 @@ namespace Emby.Dlna.Main { _publisher = new SsdpDevicePublisher( _communicationsServer, - _networkManager, MediaBrowser.Common.System.OperatingSystem.Name, Environment.OSVersion.VersionString, _config.GetDlnaConfiguration().SendOnlyMatchedHost) { - LogFunction = LogMessage, + LogFunction = (msg) => _logger.LogDebug("{Msg}", msg), SupportPnpRootDevice = false }; @@ -318,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 { @@ -412,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 11fcd81cf..7815e9293 100644 --- a/Emby.Dlna/PlayTo/Device.cs +++ b/Emby.Dlna/PlayTo/Device.cs @@ -20,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; @@ -537,9 +535,9 @@ namespace Emby.Dlna.PlayTo { var tuple = await GetPositionInfo(avCommands, cancellationToken).ConfigureAwait(false); - var currentObject = tuple.Item2; + var currentObject = tuple.Track; - if (tuple.Item1 && currentObject == null) + if (tuple.Success && currentObject == null) { currentObject = await GetMediaInfo(avCommands, cancellationToken).ConfigureAwait(false); } @@ -640,7 +638,7 @@ namespace Emby.Dlna.PlayTo return; } - Volume = int.Parse(volumeValue, UsCulture); + Volume = int.Parse(volumeValue, CultureInfo.InvariantCulture); if (Volume > 0) { @@ -799,7 +797,7 @@ namespace Emby.Dlna.PlayTo return null; } - private async Task<(bool, UBaseObject)> GetPositionInfo(TransportCommands avCommands, CancellationToken cancellationToken) + private async Task<(bool Success, UBaseObject Track)> GetPositionInfo(TransportCommands avCommands, CancellationToken cancellationToken) { var command = avCommands.ServiceActions.FirstOrDefault(c => c.Name == "GetPositionInfo"); if (command == null) @@ -842,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 { @@ -854,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(); @@ -1181,6 +1179,7 @@ namespace Emby.Dlna.PlayTo return new Device(deviceProperties, httpClientFactory, logger); } +#nullable enable private static DeviceIcon CreateIcon(XElement element) { if (element == null) @@ -1188,69 +1187,61 @@ namespace Emby.Dlna.PlayTo throw new ArgumentNullException(nameof(element)); } - var mimeType = element.GetDescendantValue(UPnpNamespaces.Ud.GetName("mimetype")); var width = element.GetDescendantValue(UPnpNamespaces.Ud.GetName("width")); var height = element.GetDescendantValue(UPnpNamespaces.Ud.GetName("height")); - 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); + _ = int.TryParse(width, NumberStyles.Integer, CultureInfo.InvariantCulture, out var widthValue); + _ = int.TryParse(height, NumberStyles.Integer, CultureInfo.InvariantCulture, out var heightValue); return new DeviceIcon { - Depth = depth, + Depth = element.GetDescendantValue(UPnpNamespaces.Ud.GetName("depth")) ?? string.Empty, Height = heightValue, - MimeType = mimeType, - Url = url, + MimeType = element.GetDescendantValue(UPnpNamespaces.Ud.GetName("mimetype")) ?? string.Empty, + Url = element.GetDescendantValue(UPnpNamespaces.Ud.GetName("url")) ?? string.Empty, Width = widthValue }; } private static DeviceService Create(XElement element) - { - var type = element.GetDescendantValue(UPnpNamespaces.Ud.GetName("serviceType")); - var id = element.GetDescendantValue(UPnpNamespaces.Ud.GetName("serviceId")); - var scpdUrl = element.GetDescendantValue(UPnpNamespaces.Ud.GetName("SCPDURL")); - var controlURL = element.GetDescendantValue(UPnpNamespaces.Ud.GetName("controlURL")); - var eventSubURL = element.GetDescendantValue(UPnpNamespaces.Ud.GetName("eventSubURL")); - - return new DeviceService + => new DeviceService() { - ControlUrl = controlURL, - EventSubUrl = eventSubURL, - ScpdUrl = scpdUrl, - ServiceId = id, - ServiceType = type + ControlUrl = element.GetDescendantValue(UPnpNamespaces.Ud.GetName("controlURL")) ?? string.Empty, + EventSubUrl = element.GetDescendantValue(UPnpNamespaces.Ud.GetName("eventSubURL")) ?? string.Empty, + ScpdUrl = element.GetDescendantValue(UPnpNamespaces.Ud.GetName("SCPDURL")) ?? string.Empty, + ServiceId = element.GetDescendantValue(UPnpNamespaces.Ud.GetName("serviceId")) ?? string.Empty, + ServiceType = element.GetDescendantValue(UPnpNamespaces.Ud.GetName("serviceType")) ?? string.Empty }; - } - private void UpdateMediaInfo(UBaseObject mediaInfo, TransportState state) + private void UpdateMediaInfo(UBaseObject? mediaInfo, TransportState state) { TransportState = state; var previousMediaInfo = CurrentMediaInfo; CurrentMediaInfo = mediaInfo; - if (previousMediaInfo == null && mediaInfo != null) + if (mediaInfo == null) + { + if (previousMediaInfo != null) + { + OnPlaybackStop(previousMediaInfo); + } + } + else if (previousMediaInfo == null) { if (state != TransportState.Stopped) { OnPlaybackStart(mediaInfo); } } - else if (mediaInfo != null && previousMediaInfo != null && !mediaInfo.Equals(previousMediaInfo)) - { - OnMediaChanged(previousMediaInfo, mediaInfo); - } - else if (mediaInfo == null && previousMediaInfo != null) - { - OnPlaybackStop(previousMediaInfo); - } - else if (mediaInfo != null && mediaInfo.Equals(previousMediaInfo)) + else if (mediaInfo.Equals(previousMediaInfo)) { OnPlaybackProgress(mediaInfo); } + else + { + OnMediaChanged(previousMediaInfo, mediaInfo); + } } private void OnPlaybackStart(UBaseObject mediaInfo) diff --git a/Emby.Dlna/PlayTo/PlayToController.cs b/Emby.Dlna/PlayTo/PlayToController.cs index 0e49fd2c0..d0c9df68e 100644 --- a/Emby.Dlna/PlayTo/PlayToController.cs +++ b/Emby.Dlna/PlayTo/PlayToController.cs @@ -30,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; @@ -212,9 +210,9 @@ namespace Emby.Dlna.PlayTo var mediaSource = await streamInfo.GetMediaSource(CancellationToken.None).ConfigureAwait(false); - var duration = mediaSource == null ? - (_device.Duration == null ? (long?)null : _device.Duration.Value.Ticks) : - mediaSource.RunTimeTicks; + var duration = mediaSource == null + ? _device.Duration?.Ticks + : mediaSource.RunTimeTicks; var playedToCompletion = positionTicks.HasValue && positionTicks.Value == 0; @@ -716,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); } @@ -728,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); } @@ -740,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); } diff --git a/Emby.Dlna/PlayTo/PlayToManager.cs b/Emby.Dlna/PlayTo/PlayToManager.cs index 7927f5f8f..294bda5b6 100644 --- a/Emby.Dlna/PlayTo/PlayToManager.cs +++ b/Emby.Dlna/PlayTo/PlayToManager.cs @@ -11,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; @@ -35,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; @@ -47,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; @@ -58,7 +56,6 @@ namespace Emby.Dlna.PlayTo _imageProcessor = imageProcessor; _deviceDiscovery = deviceDiscovery; _httpClientFactory = httpClientFactory; - _config = config; _userDataManager = userDataManager; _localization = localization; _mediaSourceManager = mediaSourceManager; diff --git a/Emby.Dlna/PlayTo/SsdpHttpClient.cs b/Emby.Dlna/PlayTo/SsdpHttpClient.cs index f14f73bb6..cade7b4c2 100644 --- a/Emby.Dlna/PlayTo/SsdpHttpClient.cs +++ b/Emby.Dlna/PlayTo/SsdpHttpClient.cs @@ -20,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) @@ -45,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); } @@ -78,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) @@ -94,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 b58669355..d373b57f5 100644 --- a/Emby.Dlna/PlayTo/TransportCommands.cs +++ b/Emby.Dlna/PlayTo/TransportCommands.cs @@ -175,7 +175,7 @@ namespace Emby.Dlna.PlayTo var sendValue = state.AllowedValues.FirstOrDefault(a => string.Equals(a, commandParameter, StringComparison.OrdinalIgnoreCase)) ?? (state.AllowedValues.Count > 0 ? state.AllowedValues[0] : value); - return string.Format(CultureInfo.InvariantCulture, "<{0} xmlns:dt=\"urn:schemas-microsoft-com:datatypes\" dt:dt=\"{1}\">{2}", argument.Name, state.DataType ?? "string", sendValue); + return string.Format(CultureInfo.InvariantCulture, "<{0} xmlns:dt=\"urn:schemas-microsoft-com:datatypes\" dt:dt=\"{1}\">{2}", argument.Name, state.DataType, sendValue); } return string.Format(CultureInfo.InvariantCulture, "<{0}>{1}", argument.Name, value); diff --git a/Emby.Dlna/Profiles/DefaultProfile.cs b/Emby.Dlna/Profiles/DefaultProfile.cs index 8eaf12ba9..8f4f2bd38 100644 --- a/Emby.Dlna/Profiles/DefaultProfile.cs +++ b/Emby.Dlna/Profiles/DefaultProfile.cs @@ -167,8 +167,7 @@ namespace Emby.Dlna.Profiles public void AddXmlRootAttribute(string name, string value) { - var atts = XmlRootAttributes ?? System.Array.Empty(); - var list = atts.ToList(); + var list = XmlRootAttributes.ToList(); list.Add(new XmlAttribute { diff --git a/Emby.Dlna/Server/DescriptionXmlBuilder.cs b/Emby.Dlna/Server/DescriptionXmlBuilder.cs index 3f3dfccd3..8adaaea77 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; @@ -190,16 +189,16 @@ namespace Emby.Dlna.Server builder.Append(""); builder.Append("") - .Append(SecurityElement.Escape(icon.MimeType ?? string.Empty)) + .Append(SecurityElement.Escape(icon.MimeType)) .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)) + .Append(SecurityElement.Escape(icon.Depth)) .Append(""); builder.Append("") .Append(BuildUrl(icon.Url)) @@ -220,10 +219,10 @@ namespace Emby.Dlna.Server builder.Append(""); builder.Append("") - .Append(SecurityElement.Escape(service.ServiceType ?? string.Empty)) + .Append(SecurityElement.Escape(service.ServiceType)) .Append(""); builder.Append("") - .Append(SecurityElement.Escape(service.ServiceId ?? string.Empty)) + .Append(SecurityElement.Escape(service.ServiceId)) .Append(""); builder.Append("") .Append(BuildUrl(service.ScpdUrl)) @@ -250,8 +249,7 @@ namespace Emby.Dlna.Server url = _serverAddress.TrimEnd('/') + "/dlna/" + _serverUdn + "/" + url.TrimStart('/'); - // TODO: @bond remove null-coalescing operator when https://github.com/dotnet/runtime/pull/52442 is merged/released - return SecurityElement.Escape(url) ?? string.Empty; + return SecurityElement.Escape(url); } private IEnumerable GetIcons() diff --git a/Emby.Dlna/Service/BaseControlHandler.cs b/Emby.Dlna/Service/BaseControlHandler.cs index 581e4a286..7bec2eb72 100644 --- a/Emby.Dlna/Service/BaseControlHandler.cs +++ b/Emby.Dlna/Service/BaseControlHandler.cs @@ -47,7 +47,7 @@ namespace Emby.Dlna.Service private async Task ProcessControlRequestInternalAsync(ControlRequest request) { - ControlRequestInfo? requestInfo = null; + ControlRequestInfo requestInfo; using (var streamReader = new StreamReader(request.InputXml, Encoding.UTF8)) { @@ -64,8 +64,13 @@ 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); + return CreateControlResponse(requestInfo); + } + + private ControlResponse CreateControlResponse(ControlRequestInfo requestInfo) + { var settings = new XmlWriterSettings { Encoding = Encoding.UTF8, @@ -112,29 +117,19 @@ namespace Emby.Dlna.Service { if (reader.NodeType == XmlNodeType.Element) { - switch (reader.LocalName) + if (string.Equals(reader.LocalName, "Body", StringComparison.Ordinal)) { - case "Body": - { - if (!reader.IsEmptyElement) - { - using var subReader = reader.ReadSubtree(); - return await ParseBodyTagAsync(subReader).ConfigureAwait(false); - } - else - { - await reader.ReadAsync().ConfigureAwait(false); - } + if (reader.IsEmptyElement) + { + await reader.ReadAsync().ConfigureAwait(false); + continue; + } - break; - } - - default: - { - await reader.SkipAsync().ConfigureAwait(false); - break; - } + using var subReader = reader.ReadSubtree(); + return await ParseBodyTagAsync(subReader).ConfigureAwait(false); } + + await reader.SkipAsync().ConfigureAwait(false); } else { @@ -160,17 +155,17 @@ namespace Emby.Dlna.Service localName = reader.LocalName; namespaceURI = reader.NamespaceURI; - if (!reader.IsEmptyElement) + if (reader.IsEmptyElement) + { + await reader.ReadAsync().ConfigureAwait(false); + } + else { var result = new ControlRequestInfo(localName, namespaceURI); using var subReader = reader.ReadSubtree(); await ParseFirstBodyChildAsync(subReader, result.Headers).ConfigureAwait(false); return result; } - else - { - await reader.ReadAsync().ConfigureAwait(false); - } } else { diff --git a/Emby.Dlna/Service/BaseService.cs b/Emby.Dlna/Service/BaseService.cs index a97c4d63a..68fd98758 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/ServiceXmlBuilder.cs b/Emby.Dlna/Service/ServiceXmlBuilder.cs index 1e56d09b2..6e0bc6ad8 100644 --- a/Emby.Dlna/Service/ServiceXmlBuilder.cs +++ b/Emby.Dlna/Service/ServiceXmlBuilder.cs @@ -38,7 +38,7 @@ namespace Emby.Dlna.Service builder.Append(""); builder.Append("") - .Append(SecurityElement.Escape(item.Name ?? string.Empty)) + .Append(SecurityElement.Escape(item.Name)) .Append(""); builder.Append(""); @@ -48,13 +48,13 @@ namespace Emby.Dlna.Service builder.Append(""); builder.Append("") - .Append(SecurityElement.Escape(argument.Name ?? string.Empty)) + .Append(SecurityElement.Escape(argument.Name)) .Append(""); builder.Append("") - .Append(SecurityElement.Escape(argument.Direction ?? string.Empty)) + .Append(SecurityElement.Escape(argument.Direction)) .Append(""); builder.Append("") - .Append(SecurityElement.Escape(argument.RelatedStateVariable ?? string.Empty)) + .Append(SecurityElement.Escape(argument.RelatedStateVariable)) .Append(""); builder.Append(""); @@ -81,10 +81,10 @@ namespace Emby.Dlna.Service .Append("\">"); builder.Append("") - .Append(SecurityElement.Escape(item.Name ?? string.Empty)) + .Append(SecurityElement.Escape(item.Name)) .Append(""); builder.Append("") - .Append(SecurityElement.Escape(item.DataType ?? string.Empty)) + .Append(SecurityElement.Escape(item.DataType)) .Append(""); if (item.AllowedValues.Count > 0) diff --git a/Emby.Drawing/Emby.Drawing.csproj b/Emby.Drawing/Emby.Drawing.csproj index baf350c6f..b9a2c5d5d 100644 --- a/Emby.Drawing/Emby.Drawing.csproj +++ b/Emby.Drawing/Emby.Drawing.csproj @@ -6,10 +6,13 @@ - net5.0 + net6.0 false true - AllDisabledByDefault + + + + false @@ -25,7 +28,7 @@ - + diff --git a/Emby.Drawing/ImageProcessor.cs b/Emby.Drawing/ImageProcessor.cs index 0ad8bca31..18b413964 100644 --- a/Emby.Drawing/ImageProcessor.cs +++ b/Emby.Drawing/ImageProcessor.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Globalization; using System.IO; using System.Linq; +using System.Net.Mime; using System.Text; using System.Threading.Tasks; using Jellyfin.Data.Entities; @@ -26,7 +27,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" }; @@ -101,8 +102,7 @@ namespace Emby.Drawing public async Task ProcessImage(ImageProcessingOptions options, Stream toStream) { var file = await ProcessImage(options).ConfigureAwait(false); - - using (var fileStream = new FileStream(file.Item1, FileMode.Open, FileAccess.Read, FileShare.Read, IODefaults.FileStreamBufferSize, AsyncFile.UseAsyncIO)) + using (var fileStream = AsyncFile.OpenRead(file.Path)) { await fileStream.CopyToAsync(toStream).ConfigureAwait(false); } @@ -117,7 +117,7 @@ namespace Emby.Drawing => _transparentImageTypes.Contains(Path.GetExtension(path)); /// - public async Task<(string path, string? mimeType, DateTime dateModified)> ProcessImage(ImageProcessingOptions options) + public async Task<(string Path, string? MimeType, DateTime DateModified)> ProcessImage(ImageProcessingOptions options) { ItemImageInfo originalImage = options.Image; BaseItem item = options.Item; @@ -130,20 +130,22 @@ namespace Emby.Drawing originalImageSize = new ImageDimensions(originalImage.Width, originalImage.Height); } + var mimeType = MimeTypes.GetMimeType(originalImagePath); if (!_imageEncoder.SupportsImageEncoding) { - return (originalImagePath, MimeTypes.GetMimeType(originalImagePath), dateModified); + return (originalImagePath, mimeType, dateModified); } var supportedImageInfo = await GetSupportedImage(originalImagePath, dateModified).ConfigureAwait(false); - originalImagePath = supportedImageInfo.path; + originalImagePath = supportedImageInfo.Path; - if (!File.Exists(originalImagePath)) + // Original file doesn't exist, or original file is gif. + if (!File.Exists(originalImagePath) || string.Equals(mimeType, MediaTypeNames.Image.Gif, StringComparison.OrdinalIgnoreCase)) { - return (originalImagePath, MimeTypes.GetMimeType(originalImagePath), dateModified); + return (originalImagePath, mimeType, dateModified); } - dateModified = supportedImageInfo.dateModified; + dateModified = supportedImageInfo.DateModified; bool requiresTransparency = _transparentImageTypes.Contains(Path.GetExtension(originalImagePath)); bool autoOrient = false; @@ -243,7 +245,7 @@ namespace Emby.Drawing return ImageFormat.Jpg; } - private string? GetMimeType(ImageFormat format, string path) + private string GetMimeType(ImageFormat format, string path) => format switch { ImageFormat.Bmp => MimeTypes.GetMimeType("i.bmp"), @@ -437,7 +439,7 @@ namespace Emby.Drawing .ToString("N", CultureInfo.InvariantCulture); } - private async Task<(string path, DateTime dateModified)> GetSupportedImage(string originalImagePath, DateTime dateModified) + private async Task<(string Path, DateTime DateModified)> GetSupportedImage(string originalImagePath, DateTime dateModified) { var inputFormat = Path.GetExtension(originalImagePath) .TrimStart('.') diff --git a/Emby.Naming/AudioBook/AudioBookListResolver.cs b/Emby.Naming/AudioBook/AudioBookListResolver.cs index 1e4a8d2ed..2efe7d526 100644 --- a/Emby.Naming/AudioBook/AudioBookListResolver.cs +++ b/Emby.Naming/AudioBook/AudioBookListResolver.cs @@ -14,6 +14,7 @@ namespace Emby.Naming.AudioBook public class AudioBookListResolver { private readonly NamingOptions _options; + private readonly AudioBookResolver _audioBookResolver; /// /// Initializes a new instance of the class. @@ -22,6 +23,7 @@ namespace Emby.Naming.AudioBook public AudioBookListResolver(NamingOptions options) { _options = options; + _audioBookResolver = new AudioBookResolver(_options); } /// @@ -31,21 +33,18 @@ namespace Emby.Naming.AudioBook /// Returns IEnumerable of . public IEnumerable Resolve(IEnumerable files) { - var audioBookResolver = new AudioBookResolver(_options); - // File with empty fullname will be sorted out here. var audiobookFileInfos = files - .Select(i => audioBookResolver.Resolve(i.FullName)) + .Select(i => _audioBookResolver.Resolve(i.FullName)) .OfType() .ToList(); - var stackResult = new StackResolver(_options) - .ResolveAudioBooks(audiobookFileInfos); + var stackResult = StackResolver.ResolveAudioBooks(audiobookFileInfos); foreach (var stack in stackResult) { var stackFiles = stack.Files - .Select(i => audioBookResolver.Resolve(i)) + .Select(i => _audioBookResolver.Resolve(i)) .OfType() .ToList(); diff --git a/Emby.Naming/AudioBook/AudioBookResolver.cs b/Emby.Naming/AudioBook/AudioBookResolver.cs index f6ad3601d..183b6c3b1 100644 --- a/Emby.Naming/AudioBook/AudioBookResolver.cs +++ b/Emby.Naming/AudioBook/AudioBookResolver.cs @@ -1,7 +1,7 @@ using System; using System.IO; -using System.Linq; using Emby.Naming.Common; +using Jellyfin.Extensions; namespace Emby.Naming.AudioBook { @@ -37,7 +37,7 @@ namespace Emby.Naming.AudioBook var extension = Path.GetExtension(path); // Check supported extensions - if (!_options.AudioFileExtensions.Contains(extension, StringComparer.OrdinalIgnoreCase)) + if (!_options.AudioFileExtensions.Contains(extension, StringComparison.OrdinalIgnoreCase)) { return null; } diff --git a/Emby.Naming/Common/NamingOptions.cs b/Emby.Naming/Common/NamingOptions.cs index 915ce42cc..e8c855b5a 100644 --- a/Emby.Naming/Common/NamingOptions.cs +++ b/Emby.Naming/Common/NamingOptions.cs @@ -1,4 +1,7 @@ +#pragma warning disable CA1819 + using System; +using System.Collections.Generic; using System.Linq; using System.Text.RegularExpressions; using Emby.Naming.Video; @@ -122,11 +125,11 @@ namespace Emby.Naming.Common token: "DSR") }; - VideoFileStackingExpressions = new[] + VideoFileStackingRules = new[] { - "(?.*?)(?<volume>[ _.-]*(?:cd|dvd|p(?:ar)?t|dis[ck])[ _.-]*[0-9]+)(?<ignore>.*?)(?<extension>\\.[^.]+)$", - "(?<title>.*?)(?<volume>[ _.-]*(?:cd|dvd|p(?:ar)?t|dis[ck])[ _.-]*[a-d])(?<ignore>.*?)(?<extension>\\.[^.]+)$", - "(?<title>.*?)(?<volume>[ ._-]*[a-d])(?<ignore>.*?)(?<extension>\\.[^.]+)$" + new FileStackRule(@"^(?<filename>.*?)(?:(?<=[\]\)\}])|[ _.-]+)[\(\[]?(?<parttype>cd|dvd|part|pt|dis[ck])[ _.-]*(?<number>[0-9]+)[\)\]]?(?:\.[^.]+)?$", true), + new FileStackRule(@"^(?<filename>.*?)(?:(?<=[\]\)\}])|[ _.-]+)[\(\[]?(?<parttype>cd|dvd|part|pt|dis[ck])[ _.-]*(?<number>[a-d])[\)\]]?(?:\.[^.]+)?$", false), + new FileStackRule(@"^(?<filename>.*?)(?:(?<=[\]\)\}])|[ _.-]?)(?<number>[a-d])(?:\.[^.]+)?$", false) }; CleanDateTimes = new[] @@ -137,8 +140,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|h265|xvid|xvidvd|xxx|www.www|AAC|DTS|\[.*\])([ _\,\.\(\)\[\]\-]|$)", - @"(\[.*\])" + @"^\s*(?<cleaned>.+?)[ _\,\.\(\)\[\]\-](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|\[.*\])([ _\,\.\(\)\[\]\-]|$)", + @"^(?<cleaned>.+?)(\[.*\])", + @"^\s*(?<cleaned>.+?)\WE[0-9]+(-|~)E?[0-9]+(\W|$)", + @"^\s*\[[^\]]+\](?!\.\w+$)\s*(?<cleaned>.+)", + @"^\s*(?<cleaned>.+?)\s+-\s+[0-9]+\s*$" }; SubtitleFileExtensions = new[] @@ -250,6 +256,8 @@ namespace Emby.Naming.Common }, // <!-- foo.ep01, foo.EP_01 --> new EpisodeExpression(@"[\._ -]()[Ee][Pp]_?([0-9]+)([^\\/]*)$"), + // <!-- foo.E01., foo.e01. --> + new EpisodeExpression(@"[^\\/]*?()\.?[Ee]([0-9]+)\.([^\\/]*)$"), new EpisodeExpression("(?<year>[0-9]{4})[\\.-](?<month>[0-9]{2})[\\.-](?<day>[0-9]{2})", true) { DateTimeFormats = new[] @@ -368,6 +376,20 @@ namespace Emby.Naming.Common IsOptimistic = true, IsNamed = true }, + + // Series and season only expression + // "the show/season 1", "the show/s01" + new EpisodeExpression(@"(.*(\\|\/))*(?<seriesname>.+)\/[Ss](eason)?[\. _\-]*(?<seasonnumber>[0-9]+)") + { + IsNamed = true + }, + + // Series and season only expression + // "the show S01", "the show season 1" + new EpisodeExpression(@"(.*(\\|\/))*(?<seriesname>.+)[\. _\-]+[sS](eason)?[\. _\-]*(?<seasonnumber>[0-9]+)") + { + IsNamed = true + }, }; EpisodeWithoutSeasonExpressions = new[] @@ -382,6 +404,72 @@ namespace Emby.Naming.Common VideoExtraRules = new[] { + new ExtraRule( + ExtraType.Trailer, + ExtraRuleType.DirectoryName, + "trailers", + MediaType.Video), + + new ExtraRule( + ExtraType.ThemeVideo, + ExtraRuleType.DirectoryName, + "backdrops", + MediaType.Video), + + new ExtraRule( + ExtraType.ThemeSong, + ExtraRuleType.DirectoryName, + "theme-music", + MediaType.Audio), + + new ExtraRule( + ExtraType.BehindTheScenes, + ExtraRuleType.DirectoryName, + "behind the scenes", + MediaType.Video), + + new ExtraRule( + ExtraType.DeletedScene, + ExtraRuleType.DirectoryName, + "deleted scenes", + MediaType.Video), + + new ExtraRule( + ExtraType.Interview, + ExtraRuleType.DirectoryName, + "interviews", + MediaType.Video), + + new ExtraRule( + ExtraType.Scene, + ExtraRuleType.DirectoryName, + "scenes", + MediaType.Video), + + new ExtraRule( + ExtraType.Sample, + ExtraRuleType.DirectoryName, + "samples", + MediaType.Video), + + new ExtraRule( + ExtraType.Clip, + ExtraRuleType.DirectoryName, + "shorts", + MediaType.Video), + + new ExtraRule( + ExtraType.Clip, + ExtraRuleType.DirectoryName, + "featurettes", + MediaType.Video), + + new ExtraRule( + ExtraType.Unknown, + ExtraRuleType.DirectoryName, + "extras", + MediaType.Video), + new ExtraRule( ExtraType.Trailer, ExtraRuleType.Filename, @@ -478,6 +566,12 @@ namespace Emby.Naming.Common "-deleted", MediaType.Video), + new ExtraRule( + ExtraType.DeletedScene, + ExtraRuleType.Suffix, + "-deletedscene", + MediaType.Video), + new ExtraRule( ExtraType.Clip, ExtraRuleType.Suffix, @@ -490,53 +584,11 @@ namespace Emby.Naming.Common "-short", MediaType.Video), - new ExtraRule( - ExtraType.BehindTheScenes, - ExtraRuleType.DirectoryName, - "behind the scenes", - MediaType.Video), - - new ExtraRule( - ExtraType.DeletedScene, - ExtraRuleType.DirectoryName, - "deleted scenes", - MediaType.Video), - - new ExtraRule( - ExtraType.Interview, - ExtraRuleType.DirectoryName, - "interviews", - MediaType.Video), - - new ExtraRule( - ExtraType.Scene, - ExtraRuleType.DirectoryName, - "scenes", - MediaType.Video), - - new ExtraRule( - ExtraType.Sample, - ExtraRuleType.DirectoryName, - "samples", - MediaType.Video), - - new ExtraRule( - ExtraType.Clip, - ExtraRuleType.DirectoryName, - "shorts", - MediaType.Video), - - new ExtraRule( - ExtraType.Clip, - ExtraRuleType.DirectoryName, - "featurettes", - MediaType.Video), - new ExtraRule( ExtraType.Unknown, - ExtraRuleType.DirectoryName, - "extras", - MediaType.Video), + ExtraRuleType.Suffix, + "-extra", + MediaType.Video) }; Format3DRules = new[] @@ -648,9 +700,29 @@ namespace Emby.Naming.Common .Distinct(StringComparer.OrdinalIgnoreCase) .ToArray(); + AllExtrasTypesFolderNames = new Dictionary<string, ExtraType>(StringComparer.OrdinalIgnoreCase) + { + ["trailers"] = ExtraType.Trailer, + ["theme-music"] = ExtraType.ThemeSong, + ["backdrops"] = ExtraType.ThemeVideo, + ["extras"] = ExtraType.Unknown, + ["behind the scenes"] = ExtraType.BehindTheScenes, + ["deleted scenes"] = ExtraType.DeletedScene, + ["interviews"] = ExtraType.Interview, + ["scenes"] = ExtraType.Scene, + ["samples"] = ExtraType.Sample, + ["shorts"] = ExtraType.Clip, + ["featurettes"] = ExtraType.Clip + }; + Compile(); } + /// <summary> + /// Gets or sets the folder name to extra types mapping. + /// </summary> + public Dictionary<string, ExtraType> AllExtrasTypesFolderNames { get; set; } + /// <summary> /// Gets or sets list of audio file extensions. /// </summary> @@ -732,9 +804,9 @@ namespace Emby.Naming.Common public Format3DRule[] Format3DRules { get; set; } /// <summary> - /// Gets or sets list of raw video file-stacking expressions strings. + /// Gets the file stacking rules. /// </summary> - public string[] VideoFileStackingExpressions { get; set; } + public FileStackRule[] VideoFileStackingRules { get; } /// <summary> /// Gets or sets list of raw clean DateTimes regular expressions strings. @@ -756,11 +828,6 @@ namespace Emby.Naming.Common /// </summary> public ExtraRule[] VideoExtraRules { get; set; } - /// <summary> - /// Gets list of video file-stack regular expressions. - /// </summary> - public Regex[] VideoFileStackingRegexes { get; private set; } = Array.Empty<Regex>(); - /// <summary> /// Gets list of clean datetime regular expressions. /// </summary> @@ -786,7 +853,6 @@ namespace Emby.Naming.Common /// </summary> public void Compile() { - VideoFileStackingRegexes = VideoFileStackingExpressions.Select(Compile).ToArray(); CleanDateTimeRegexes = CleanDateTimes.Select(Compile).ToArray(); CleanStringRegexes = CleanStrings.Select(Compile).ToArray(); EpisodeWithoutSeasonRegexes = EpisodeWithoutSeasonExpressions.Select(Compile).ToArray(); diff --git a/Emby.Naming/Emby.Naming.csproj b/Emby.Naming/Emby.Naming.csproj index 07d879e96..433ad137b 100644 --- a/Emby.Naming/Emby.Naming.csproj +++ b/Emby.Naming/Emby.Naming.csproj @@ -6,14 +6,17 @@ </PropertyGroup> <PropertyGroup> - <TargetFramework>net5.0</TargetFramework> + <TargetFramework>net6.0</TargetFramework> <GenerateAssemblyInfo>false</GenerateAssemblyInfo> <GenerateDocumentationFile>true</GenerateDocumentationFile> <PublishRepositoryUrl>true</PublishRepositoryUrl> <EmbedUntrackedSources>true</EmbedUntrackedSources> <IncludeSymbols>true</IncludeSymbols> <SymbolPackageFormat>snupkg</SymbolPackageFormat> - <AnalysisMode>AllDisabledByDefault</AnalysisMode> + </PropertyGroup> + + <PropertyGroup Condition=" '$(Configuration)' == 'Debug' "> + <TreatWarningsAsErrors>false</TreatWarningsAsErrors> </PropertyGroup> <PropertyGroup Condition=" '$(Stability)'=='Unstable'"> @@ -39,13 +42,13 @@ </PropertyGroup> <ItemGroup> - <PackageReference Include="Microsoft.SourceLink.GitHub" Version="1.0.0" PrivateAssets="All" /> + <PackageReference Include="Microsoft.SourceLink.GitHub" Version="1.1.1" PrivateAssets="All" /> </ItemGroup> <!-- Code Analyzers--> <ItemGroup Condition=" '$(Configuration)' == 'Debug' "> <PackageReference Include="SerilogAnalyzer" Version="0.15.0" PrivateAssets="All" /> - <PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="All" /> + <PackageReference Include="StyleCop.Analyzers" Version="1.2.0-beta.376" PrivateAssets="All" /> <PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" PrivateAssets="All" /> </ItemGroup> diff --git a/Emby.Naming/Subtitles/SubtitleParser.cs b/Emby.Naming/Subtitles/SubtitleParser.cs index a19340ef6..5809c512a 100644 --- a/Emby.Naming/Subtitles/SubtitleParser.cs +++ b/Emby.Naming/Subtitles/SubtitleParser.cs @@ -2,6 +2,7 @@ using System; using System.IO; using System.Linq; using Emby.Naming.Common; +using Jellyfin.Extensions; namespace Emby.Naming.Subtitles { @@ -34,7 +35,7 @@ namespace Emby.Naming.Subtitles } var extension = Path.GetExtension(path); - if (!_options.SubtitleFileExtensions.Contains(extension, StringComparer.OrdinalIgnoreCase)) + if (!_options.SubtitleFileExtensions.Contains(extension, StringComparison.OrdinalIgnoreCase)) { return null; } @@ -42,11 +43,11 @@ namespace Emby.Naming.Subtitles var flags = GetFlags(path); var info = new SubtitleInfo( path, - _options.SubtitleDefaultFlags.Any(i => flags.Contains(i, StringComparer.OrdinalIgnoreCase)), - _options.SubtitleForcedFlags.Any(i => flags.Contains(i, StringComparer.OrdinalIgnoreCase))); + _options.SubtitleDefaultFlags.Any(i => flags.Contains(i, StringComparison.OrdinalIgnoreCase)), + _options.SubtitleForcedFlags.Any(i => flags.Contains(i, StringComparison.OrdinalIgnoreCase))); - var parts = flags.Where(i => !_options.SubtitleDefaultFlags.Contains(i, StringComparer.OrdinalIgnoreCase) - && !_options.SubtitleForcedFlags.Contains(i, StringComparer.OrdinalIgnoreCase)) + var parts = flags.Where(i => !_options.SubtitleDefaultFlags.Contains(i, StringComparison.OrdinalIgnoreCase) + && !_options.SubtitleForcedFlags.Contains(i, StringComparison.OrdinalIgnoreCase)) .ToList(); // Should have a name, language and file extension diff --git a/Emby.Naming/TV/EpisodeResolver.cs b/Emby.Naming/TV/EpisodeResolver.cs index 5e952e47b..6cebc40c2 100644 --- a/Emby.Naming/TV/EpisodeResolver.cs +++ b/Emby.Naming/TV/EpisodeResolver.cs @@ -1,8 +1,8 @@ using System; using System.IO; -using System.Linq; using Emby.Naming.Common; using Emby.Naming.Video; +using Jellyfin.Extensions; namespace Emby.Naming.TV { @@ -48,7 +48,7 @@ namespace Emby.Naming.TV { var extension = Path.GetExtension(path); // Check supported extensions - if (!_options.VideoFileExtensions.Contains(extension, StringComparer.OrdinalIgnoreCase)) + if (!_options.VideoFileExtensions.Contains(extension, StringComparison.OrdinalIgnoreCase)) { // It's not supported. Check stub extensions if (!StubResolver.TryResolveFile(path, _options, out stubType)) diff --git a/Emby.Naming/TV/SeasonPathParser.cs b/Emby.Naming/TV/SeasonPathParser.cs index 6236f86c4..fc9ee8e56 100644 --- a/Emby.Naming/TV/SeasonPathParser.cs +++ b/Emby.Naming/TV/SeasonPathParser.cs @@ -55,7 +55,7 @@ namespace Emby.Naming.TV /// <param name="supportSpecialAliases">if set to <c>true</c> [support special aliases].</param> /// <param name="supportNumericSeasonFolders">if set to <c>true</c> [support numeric season folders].</param> /// <returns>System.Nullable{System.Int32}.</returns> - private static (int? seasonNumber, bool isSeasonFolder) GetSeasonNumberFromPath( + private static (int? SeasonNumber, bool IsSeasonFolder) GetSeasonNumberFromPath( string path, bool supportSpecialAliases, bool supportNumericSeasonFolders) @@ -99,7 +99,7 @@ namespace Emby.Naming.TV if (filename.Contains(name, StringComparison.OrdinalIgnoreCase)) { var result = GetSeasonNumberFromPathSubstring(filename.Replace(name, " ", StringComparison.OrdinalIgnoreCase)); - if (result.seasonNumber.HasValue) + if (result.SeasonNumber.HasValue) { return result; } @@ -142,7 +142,7 @@ namespace Emby.Naming.TV /// </summary> /// <param name="path">The path.</param> /// <returns>System.Nullable{System.Int32}.</returns> - private static (int? seasonNumber, bool isSeasonFolder) GetSeasonNumberFromPathSubstring(ReadOnlySpan<char> path) + private static (int? SeasonNumber, bool IsSeasonFolder) GetSeasonNumberFromPathSubstring(ReadOnlySpan<char> path) { var numericStart = -1; var length = 0; diff --git a/Emby.Naming/TV/SeriesInfo.cs b/Emby.Naming/TV/SeriesInfo.cs new file mode 100644 index 000000000..5d6cb4bd3 --- /dev/null +++ b/Emby.Naming/TV/SeriesInfo.cs @@ -0,0 +1,29 @@ +namespace Emby.Naming.TV +{ + /// <summary> + /// Holder object for Series information. + /// </summary> + public class SeriesInfo + { + /// <summary> + /// Initializes a new instance of the <see cref="SeriesInfo"/> class. + /// </summary> + /// <param name="path">Path to the file.</param> + public SeriesInfo(string path) + { + Path = path; + } + + /// <summary> + /// Gets or sets the path. + /// </summary> + /// <value>The path.</value> + public string Path { get; set; } + + /// <summary> + /// Gets or sets the name of the series. + /// </summary> + /// <value>The name of the series.</value> + public string? Name { get; set; } + } +} diff --git a/Emby.Naming/TV/SeriesPathParser.cs b/Emby.Naming/TV/SeriesPathParser.cs new file mode 100644 index 000000000..23067e6a4 --- /dev/null +++ b/Emby.Naming/TV/SeriesPathParser.cs @@ -0,0 +1,60 @@ +using Emby.Naming.Common; + +namespace Emby.Naming.TV +{ + /// <summary> + /// 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. + /// </summary> + public static class SeriesPathParser + { + /// <summary> + /// Parses information about series from path. + /// </summary> + /// <param name="options"><see cref="NamingOptions"/> object containing EpisodeExpressions and MultipleEpisodeExpressions.</param> + /// <param name="path">Path.</param> + /// <returns>Returns <see cref="SeriesPathParserResult"/> object.</returns> + 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) && !match.Groups["seasonnumber"].ValueSpan.IsEmpty; + } + } + + return result; + } + } +} diff --git a/Emby.Naming/TV/SeriesPathParserResult.cs b/Emby.Naming/TV/SeriesPathParserResult.cs new file mode 100644 index 000000000..44cd2fdfa --- /dev/null +++ b/Emby.Naming/TV/SeriesPathParserResult.cs @@ -0,0 +1,19 @@ +namespace Emby.Naming.TV +{ + /// <summary> + /// Holder object for <see cref="SeriesPathParser"/> result. + /// </summary> + public class SeriesPathParserResult + { + /// <summary> + /// Gets or sets the name of the series. + /// </summary> + /// <value>The name of the series.</value> + public string? SeriesName { get; set; } + + /// <summary> + /// Gets or sets a value indicating whether parsing was successful. + /// </summary> + public bool Success { get; set; } + } +} diff --git a/Emby.Naming/TV/SeriesResolver.cs b/Emby.Naming/TV/SeriesResolver.cs new file mode 100644 index 000000000..156a03c9e --- /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 +{ + /// <summary> + /// Used to resolve information about series from path. + /// </summary> + public static class SeriesResolver + { + /// <summary> + /// 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". + /// </summary> + private static readonly Regex _seriesNameRegex = new Regex(@"((?<a>[^\._]{2,})[\._]*)|([\._](?<b>[^\._]{2,}))"); + + /// <summary> + /// Resolve information about series from path. + /// </summary> + /// <param name="options"><see cref="NamingOptions"/> object passed to <see cref="SeriesPathParser"/>.</param> + /// <param name="path">Path to series.</param> + /// <returns>SeriesInfo.</returns> + 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 4eef3ebc5..a336f8fbd 100644 --- a/Emby.Naming/Video/CleanStringParser.cs +++ b/Emby.Naming/Video/CleanStringParser.cs @@ -1,4 +1,3 @@ -using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Text.RegularExpressions; @@ -17,38 +16,39 @@ namespace Emby.Naming.Video /// <param name="expressions">List of regex to parse name and year from.</param> /// <param name="newName">Parsing result string.</param> /// <returns>True if parsing was successful.</returns> - public static bool TryClean([NotNullWhen(true)] string? name, IReadOnlyList<Regex> expressions, out ReadOnlySpan<char> newName) + public static bool TryClean([NotNullWhen(true)] string? name, IReadOnlyList<Regex> expressions, out string newName) { if (string.IsNullOrEmpty(name)) { - newName = ReadOnlySpan<char>.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<char>.Empty; - return false; + newName = cleaned ? name : string.Empty; + return cleaned; } - private static bool TryClean(string name, Regex expression, out ReadOnlySpan<char> 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<char>.Empty; + newName = string.Empty; return false; } } diff --git a/Emby.Naming/Video/ExtraResolver.cs b/Emby.Naming/Video/ExtraRuleResolver.cs similarity index 63% rename from Emby.Naming/Video/ExtraResolver.cs rename to Emby.Naming/Video/ExtraRuleResolver.cs index a32af002c..0970e509a 100644 --- a/Emby.Naming/Video/ExtraResolver.cs +++ b/Emby.Naming/Video/ExtraRuleResolver.cs @@ -9,44 +9,27 @@ namespace Emby.Naming.Video /// <summary> /// Resolve if file is extra for video. /// </summary> - public class ExtraResolver + public static class ExtraRuleResolver { - private readonly NamingOptions _options; - - /// <summary> - /// Initializes a new instance of the <see cref="ExtraResolver"/> class. - /// </summary> - /// <param name="options"><see cref="NamingOptions"/> object containing VideoExtraRules and passed to <see cref="AudioFileParser"/> and <see cref="VideoResolver"/>.</param> - public ExtraResolver(NamingOptions options) - { - _options = options; - } + private static readonly char[] _digits = { '0', '1', '2', '3', '4', '5', '6', '7', '8', '9' }; /// <summary> /// Attempts to resolve if file is extra. /// </summary> /// <param name="path">Path to file.</param> + /// <param name="namingOptions">The naming options.</param> /// <returns>Returns <see cref="ExtraResult"/> object.</returns> - public ExtraResult GetExtraInfo(string path) + public static ExtraResult GetExtraInfo(string path, NamingOptions namingOptions) { var result = new ExtraResult(); - for (var i = 0; i < _options.VideoExtraRules.Length; i++) + for (var i = 0; i < namingOptions.VideoExtraRules.Length; i++) { - var rule = _options.VideoExtraRules[i]; - if (rule.MediaType == MediaType.Audio) + var rule = namingOptions.VideoExtraRules[i]; + if ((rule.MediaType == MediaType.Audio && !AudioFileParser.IsAudioFile(path, namingOptions)) + || (rule.MediaType == MediaType.Video && !VideoResolver.IsVideoFile(path, namingOptions))) { - if (!AudioFileParser.IsAudioFile(path, _options)) - { - continue; - } - } - else if (rule.MediaType == MediaType.Video) - { - if (!VideoResolver.IsVideoFile(path, _options)) - { - continue; - } + continue; } var pathSpan = path.AsSpan(); @@ -62,9 +45,10 @@ namespace Emby.Naming.Video } else if (rule.RuleType == ExtraRuleType.Suffix) { - var filename = Path.GetFileNameWithoutExtension(pathSpan); + // 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.Contains(rule.Token, StringComparison.OrdinalIgnoreCase)) + if (filename.EndsWith(rule.Token, StringComparison.OrdinalIgnoreCase)) { result.ExtraType = rule.ExtraType; result.Rule = rule; @@ -74,9 +58,9 @@ namespace Emby.Naming.Video { var filename = Path.GetFileName(path); - var regex = new Regex(rule.Token, RegexOptions.IgnoreCase); + var isMatch = Regex.IsMatch(filename, rule.Token, RegexOptions.IgnoreCase | RegexOptions.Compiled); - if (regex.IsMatch(filename)) + if (isMatch) { result.ExtraType = rule.ExtraType; result.Rule = rule; diff --git a/Emby.Naming/Video/FileStack.cs b/Emby.Naming/Video/FileStack.cs index 6519db57c..4902e6728 100644 --- a/Emby.Naming/Video/FileStack.cs +++ b/Emby.Naming/Video/FileStack.cs @@ -1,6 +1,6 @@ using System; using System.Collections.Generic; -using System.Linq; +using Jellyfin.Extensions; namespace Emby.Naming.Video { @@ -12,25 +12,30 @@ namespace Emby.Naming.Video /// <summary> /// Initializes a new instance of the <see cref="FileStack"/> class. /// </summary> - public FileStack() + /// <param name="name">The stack name.</param> + /// <param name="isDirectory">Whether the stack files are directories.</param> + /// <param name="files">The stack files.</param> + public FileStack(string name, bool isDirectory, IReadOnlyList<string> files) { - Files = new List<string>(); + Name = name; + IsDirectoryStack = isDirectory; + Files = files; } /// <summary> - /// Gets or sets name of file stack. + /// Gets the name of file stack. /// </summary> - public string Name { get; set; } = string.Empty; + public string Name { get; } /// <summary> - /// Gets or sets list of paths in stack. + /// Gets the list of paths in stack. /// </summary> - public List<string> Files { get; set; } + public IReadOnlyList<string> Files { get; } /// <summary> - /// Gets or sets a value indicating whether stack is directory stack. + /// Gets a value indicating whether stack is directory stack. /// </summary> - public bool IsDirectoryStack { get; set; } + public bool IsDirectoryStack { get; } /// <summary> /// Helper function to determine if path is in the stack. @@ -40,12 +45,12 @@ namespace Emby.Naming.Video /// <returns>True if file is in the stack.</returns> public bool ContainsFile(string file, bool isDirectory) { - if (IsDirectoryStack == isDirectory) + if (string.IsNullOrEmpty(file)) { - return Files.Contains(file, StringComparer.OrdinalIgnoreCase); + return false; } - return false; + return IsDirectoryStack == isDirectory && Files.Contains(file, StringComparison.OrdinalIgnoreCase); } } } diff --git a/Emby.Naming/Video/FileStackRule.cs b/Emby.Naming/Video/FileStackRule.cs new file mode 100644 index 000000000..76b487f42 --- /dev/null +++ b/Emby.Naming/Video/FileStackRule.cs @@ -0,0 +1,48 @@ +using System.Diagnostics.CodeAnalysis; +using System.Text.RegularExpressions; + +namespace Emby.Naming.Video; + +/// <summary> +/// Regex based rule for file stacking (eg. disc1, disc2). +/// </summary> +public class FileStackRule +{ + private readonly Regex _tokenRegex; + + /// <summary> + /// Initializes a new instance of the <see cref="FileStackRule"/> class. + /// </summary> + /// <param name="token">Token.</param> + /// <param name="isNumerical">Whether the file stack rule uses numerical or alphabetical numbering.</param> + public FileStackRule(string token, bool isNumerical) + { + _tokenRegex = new Regex(token, RegexOptions.IgnoreCase); + IsNumerical = isNumerical; + } + + /// <summary> + /// Gets a value indicating whether the rule uses numerical or alphabetical numbering. + /// </summary> + public bool IsNumerical { get; } + + /// <summary> + /// Match the input against the rule regex. + /// </summary> + /// <param name="input">The input.</param> + /// <param name="result">The part type and number or <c>null</c>.</param> + /// <returns>A value indicating whether the input matched the rule.</returns> + public bool Match(string input, [NotNullWhen(true)] out (string StackName, string PartType, string PartNumber)? result) + { + result = null; + var match = _tokenRegex.Match(input); + if (!match.Success) + { + return false; + } + + var partType = match.Groups["parttype"].Success ? match.Groups["parttype"].Value : "unknown"; + result = (match.Groups["filename"].Value, partType, match.Groups["number"].Value); + return true; + } +} diff --git a/Emby.Naming/Video/Format3DParser.cs b/Emby.Naming/Video/Format3DParser.cs index 089089989..eb5e71d78 100644 --- a/Emby.Naming/Video/Format3DParser.cs +++ b/Emby.Naming/Video/Format3DParser.cs @@ -9,7 +9,7 @@ namespace Emby.Naming.Video public static class Format3DParser { // Static default result to save on allocation costs. - private static readonly Format3DResult _defaultResult = new (false, null); + private static readonly Format3DResult _defaultResult = new(false, null); /// <summary> /// Parse 3D format related flags. diff --git a/Emby.Naming/Video/StackResolver.cs b/Emby.Naming/Video/StackResolver.cs index 36f65a562..8119a0267 100644 --- a/Emby.Naming/Video/StackResolver.cs +++ b/Emby.Naming/Video/StackResolver.cs @@ -2,7 +2,6 @@ using System; using System.Collections.Generic; using System.IO; using System.Linq; -using System.Text.RegularExpressions; using Emby.Naming.AudioBook; using Emby.Naming.Common; using MediaBrowser.Model.IO; @@ -12,37 +11,28 @@ namespace Emby.Naming.Video /// <summary> /// Resolve <see cref="FileStack"/> from list of paths. /// </summary> - public class StackResolver + public static class StackResolver { - private readonly NamingOptions _options; - - /// <summary> - /// Initializes a new instance of the <see cref="StackResolver"/> class. - /// </summary> - /// <param name="options"><see cref="NamingOptions"/> object containing VideoFileStackingRegexes and passes options to <see cref="VideoResolver"/>.</param> - public StackResolver(NamingOptions options) - { - _options = options; - } - /// <summary> /// Resolves only directories from paths. /// </summary> /// <param name="files">List of paths.</param> + /// <param name="namingOptions">The naming options.</param> /// <returns>Enumerable <see cref="FileStack"/> of directories.</returns> - public IEnumerable<FileStack> ResolveDirectories(IEnumerable<string> files) + public static IEnumerable<FileStack> ResolveDirectories(IEnumerable<string> files, NamingOptions namingOptions) { - return Resolve(files.Select(i => new FileSystemMetadata { FullName = i, IsDirectory = true })); + return Resolve(files.Select(i => new FileSystemMetadata { FullName = i, IsDirectory = true }), namingOptions); } /// <summary> /// Resolves only files from paths. /// </summary> /// <param name="files">List of paths.</param> + /// <param name="namingOptions">The naming options.</param> /// <returns>Enumerable <see cref="FileStack"/> of files.</returns> - public IEnumerable<FileStack> ResolveFiles(IEnumerable<string> files) + public static IEnumerable<FileStack> ResolveFiles(IEnumerable<string> files, NamingOptions namingOptions) { - return Resolve(files.Select(i => new FileSystemMetadata { FullName = i, IsDirectory = false })); + return Resolve(files.Select(i => new FileSystemMetadata { FullName = i, IsDirectory = false }), namingOptions); } /// <summary> @@ -50,7 +40,7 @@ namespace Emby.Naming.Video /// </summary> /// <param name="files">List of paths.</param> /// <returns>Enumerable <see cref="FileStack"/> of directories.</returns> - public IEnumerable<FileStack> ResolveAudioBooks(IEnumerable<AudioBookFileInfo> files) + public static IEnumerable<FileStack> ResolveAudioBooks(IEnumerable<AudioBookFileInfo> files) { var groupedDirectoryFiles = files.GroupBy(file => Path.GetDirectoryName(file.Path)); @@ -60,19 +50,13 @@ namespace Emby.Naming.Video { foreach (var file in directory) { - var stack = new FileStack { Name = Path.GetFileNameWithoutExtension(file.Path), IsDirectoryStack = false }; - stack.Files.Add(file.Path); + var stack = new FileStack(Path.GetFileNameWithoutExtension(file.Path), false, new[] { file.Path }); yield return stack; } } else { - var stack = new FileStack { Name = Path.GetFileName(directory.Key), IsDirectoryStack = false }; - foreach (var file in directory) - { - stack.Files.Add(file.Path); - } - + var stack = new FileStack(Path.GetFileName(directory.Key), false, directory.Select(f => f.Path).ToArray()); yield return stack; } } @@ -82,158 +66,91 @@ namespace Emby.Naming.Video /// Resolves videos from paths. /// </summary> /// <param name="files">List of paths.</param> + /// <param name="namingOptions">The naming options.</param> /// <returns>Enumerable <see cref="FileStack"/> of videos.</returns> - public IEnumerable<FileStack> Resolve(IEnumerable<FileSystemMetadata> files) + public static IEnumerable<FileStack> Resolve(IEnumerable<FileSystemMetadata> files, NamingOptions namingOptions) { - var list = files - .Where(i => i.IsDirectory || VideoResolver.IsVideoFile(i.FullName, _options) || VideoResolver.IsStubFile(i.FullName, _options)) - .OrderBy(i => i.FullName) - .ToList(); + var potentialFiles = files + .Where(i => i.IsDirectory || VideoResolver.IsVideoFile(i.FullName, namingOptions) || VideoResolver.IsStubFile(i.FullName, namingOptions)) + .OrderBy(i => i.FullName); - var expressions = _options.VideoFileStackingRegexes; - - for (var i = 0; i < list.Count; i++) + var potentialStacks = new Dictionary<string, StackMetadata>(); + foreach (var file in potentialFiles) { - var offset = 0; - - var file1 = list[i]; - - var expressionIndex = 0; - while (expressionIndex < expressions.Length) + var name = file.Name; + if (string.IsNullOrEmpty(name)) { - var exp = expressions[expressionIndex]; - var stack = new FileStack(); + name = Path.GetFileName(file.FullName); + } - // (Title)(Volume)(Ignore)(Extension) - var match1 = FindMatch(file1, exp, offset); - - if (match1.Success) + for (var i = 0; i < namingOptions.VideoFileStackingRules.Length; i++) + { + var rule = namingOptions.VideoFileStackingRules[i]; + if (!rule.Match(name, out var stackParsingResult)) { - var title1 = match1.Groups["title"].Value; - var volume1 = match1.Groups["volume"].Value; - var ignore1 = match1.Groups["ignore"].Value; - var extension1 = match1.Groups["extension"].Value; + continue; + } - var j = i + 1; - while (j < list.Count) + var stackName = stackParsingResult.Value.StackName; + var partNumber = stackParsingResult.Value.PartNumber; + var partType = stackParsingResult.Value.PartType; + + if (!potentialStacks.TryGetValue(stackName, out var stackResult)) + { + stackResult = new StackMetadata(file.IsDirectory, rule.IsNumerical, partType); + potentialStacks[stackName] = stackResult; + } + + if (stackResult.Parts.Count > 0) + { + if (stackResult.IsDirectory != file.IsDirectory + || !string.Equals(partType, stackResult.PartType, StringComparison.OrdinalIgnoreCase) + || stackResult.ContainsPart(partNumber)) { - var file2 = list[j]; - - if (file1.IsDirectory != file2.IsDirectory) - { - j++; - continue; - } - - // (Title)(Volume)(Ignore)(Extension) - var match2 = FindMatch(file2, exp, offset); - - if (match2.Success) - { - var title2 = match2.Groups[1].Value; - var volume2 = match2.Groups[2].Value; - var ignore2 = match2.Groups[3].Value; - var extension2 = match2.Groups[4].Value; - - if (string.Equals(title1, title2, StringComparison.OrdinalIgnoreCase)) - { - if (!string.Equals(volume1, volume2, StringComparison.OrdinalIgnoreCase)) - { - if (string.Equals(ignore1, ignore2, StringComparison.OrdinalIgnoreCase) - && string.Equals(extension1, extension2, StringComparison.OrdinalIgnoreCase)) - { - if (stack.Files.Count == 0) - { - stack.Name = title1 + ignore1; - stack.IsDirectoryStack = file1.IsDirectory; - stack.Files.Add(file1.FullName); - } - - stack.Files.Add(file2.FullName); - } - else - { - // Sequel - offset = 0; - expressionIndex++; - break; - } - } - else if (!string.Equals(ignore1, ignore2, StringComparison.OrdinalIgnoreCase)) - { - // False positive, try again with offset - offset = match1.Groups[3].Index; - break; - } - else - { - // Extension mismatch - offset = 0; - expressionIndex++; - break; - } - } - else - { - // Title mismatch - offset = 0; - expressionIndex++; - break; - } - } - else - { - // No match 2, next expression - offset = 0; - expressionIndex++; - break; - } - - j++; + continue; } - if (j == list.Count) + if (rule.IsNumerical != stackResult.IsNumerical) { - expressionIndex = expressions.Length; + break; } } - else - { - // No match 1 - offset = 0; - expressionIndex++; - } - if (stack.Files.Count > 1) - { - yield return stack; - i += stack.Files.Count - 1; - break; - } + stackResult.Parts.Add(partNumber, file); + break; } } - } - private static string GetRegexInput(FileSystemMetadata file) - { - // For directories, dummy up an extension otherwise the expressions will fail - var input = !file.IsDirectory - ? file.FullName - : file.FullName + ".mkv"; - - return Path.GetFileName(input); - } - - private static Match FindMatch(FileSystemMetadata input, Regex regex, int offset) - { - var regexInput = GetRegexInput(input); - - if (offset < 0 || offset >= regexInput.Length) + foreach (var (fileName, stack) in potentialStacks) { - return Match.Empty; + if (stack.Parts.Count < 2) + { + continue; + } + + yield return new FileStack(fileName, stack.IsDirectory, stack.Parts.Select(kv => kv.Value.FullName).ToArray()); + } + } + + private class StackMetadata + { + public StackMetadata(bool isDirectory, bool isNumerical, string partType) + { + Parts = new Dictionary<string, FileSystemMetadata>(StringComparer.OrdinalIgnoreCase); + IsDirectory = isDirectory; + IsNumerical = isNumerical; + PartType = partType; } - return regex.Match(regexInput, offset); + public Dictionary<string, FileSystemMetadata> Parts { get; } + + public bool IsDirectory { get; } + + public bool IsNumerical { get; } + + public string PartType { get; } + + public bool ContainsPart(string partNumber) => Parts.ContainsKey(partNumber); } } } diff --git a/Emby.Naming/Video/StubResolver.cs b/Emby.Naming/Video/StubResolver.cs index 079987fe8..f7ba606e3 100644 --- a/Emby.Naming/Video/StubResolver.cs +++ b/Emby.Naming/Video/StubResolver.cs @@ -1,7 +1,7 @@ using System; using System.IO; -using System.Linq; using Emby.Naming.Common; +using Jellyfin.Extensions; namespace Emby.Naming.Video { @@ -28,7 +28,7 @@ namespace Emby.Naming.Video var extension = Path.GetExtension(path); - if (!options.StubFileExtensions.Contains(extension, StringComparer.OrdinalIgnoreCase)) + if (!options.StubFileExtensions.Contains(extension, StringComparison.OrdinalIgnoreCase)) { return false; } diff --git a/Emby.Naming/Video/VideoInfo.cs b/Emby.Naming/Video/VideoInfo.cs index 930fdb33f..8847ee9bc 100644 --- a/Emby.Naming/Video/VideoInfo.cs +++ b/Emby.Naming/Video/VideoInfo.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using MediaBrowser.Model.Entities; namespace Emby.Naming.Video { @@ -17,7 +18,6 @@ namespace Emby.Naming.Video Name = name; Files = Array.Empty<VideoFileInfo>(); - Extras = Array.Empty<VideoFileInfo>(); AlternateVersions = Array.Empty<VideoFileInfo>(); } @@ -39,16 +39,15 @@ namespace Emby.Naming.Video /// <value>The files.</value> public IReadOnlyList<VideoFileInfo> Files { get; set; } - /// <summary> - /// Gets or sets the extras. - /// </summary> - /// <value>The extras.</value> - public IReadOnlyList<VideoFileInfo> Extras { get; set; } - /// <summary> /// Gets or sets the alternate versions. /// </summary> /// <value>The alternate versions.</value> public IReadOnlyList<VideoFileInfo> AlternateVersions { get; set; } + + /// <summary> + /// Gets or sets the extra type. + /// </summary> + public ExtraType? ExtraType { get; set; } } } diff --git a/Emby.Naming/Video/VideoListResolver.cs b/Emby.Naming/Video/VideoListResolver.cs index ed7d511a3..11f82525f 100644 --- a/Emby.Naming/Video/VideoListResolver.cs +++ b/Emby.Naming/Video/VideoListResolver.cs @@ -4,7 +4,6 @@ using System.IO; using System.Linq; using System.Text.RegularExpressions; using Emby.Naming.Common; -using MediaBrowser.Model.Entities; using MediaBrowser.Model.IO; namespace Emby.Naming.Video @@ -17,29 +16,41 @@ namespace Emby.Naming.Video /// <summary> /// Resolves alternative versions and extras from list of video files. /// </summary> - /// <param name="files">List of related video files.</param> + /// <param name="videoInfos">List of related video files.</param> /// <param name="namingOptions">The naming options.</param> /// <param name="supportMultiVersion">Indication we should consider multi-versions of content.</param> + /// <param name="parseName">Whether to parse the name or use the filename.</param> /// <returns>Returns enumerable of <see cref="VideoInfo"/> which groups files together when related.</returns> - public static IEnumerable<VideoInfo> Resolve(IEnumerable<FileSystemMetadata> files, NamingOptions namingOptions, bool supportMultiVersion = true) + public static IReadOnlyList<VideoInfo> Resolve(IReadOnlyList<VideoFileInfo> videoInfos, NamingOptions namingOptions, bool supportMultiVersion = true, bool parseName = true) { - var videoInfos = files - .Select(i => VideoResolver.Resolve(i.FullName, i.IsDirectory, namingOptions)) - .OfType<VideoFileInfo>() - .ToList(); - // Filter out all extras, otherwise they could cause stacks to not be resolved // See the unit test TestStackedWithTrailer var nonExtras = videoInfos .Where(i => i.ExtraType == null) .Select(i => new FileSystemMetadata { FullName = i.Path, IsDirectory = i.IsDirectory }); - var stackResult = new StackResolver(namingOptions) - .Resolve(nonExtras).ToList(); + var stackResult = StackResolver.Resolve(nonExtras, namingOptions).ToList(); - var remainingFiles = videoInfos - .Where(i => !stackResult.Any(s => i.Path != null && s.ContainsFile(i.Path, i.IsDirectory))) - .ToList(); + var remainingFiles = new List<VideoFileInfo>(); + var standaloneMedia = new List<VideoFileInfo>(); + + for (var i = 0; i < videoInfos.Count; i++) + { + var current = videoInfos[i]; + if (stackResult.Any(s => s.ContainsFile(current.Path, current.IsDirectory))) + { + continue; + } + + if (current.ExtraType == null) + { + standaloneMedia.Add(current); + } + else + { + remainingFiles.Add(current); + } + } var list = new List<VideoInfo>(); @@ -47,38 +58,20 @@ namespace Emby.Naming.Video { var info = new VideoInfo(stack.Name) { - Files = stack.Files.Select(i => VideoResolver.Resolve(i, stack.IsDirectoryStack, namingOptions)) + Files = stack.Files.Select(i => VideoResolver.Resolve(i, stack.IsDirectoryStack, namingOptions, parseName)) .OfType<VideoFileInfo>() .ToList() }; info.Year = info.Files[0].Year; - - var extras = ExtractExtras(remainingFiles, stack.Name, Path.GetFileNameWithoutExtension(stack.Files[0].AsSpan()), namingOptions.VideoFlagDelimiters); - - if (extras.Count > 0) - { - info.Extras = extras; - } - list.Add(info); } - var standaloneMedia = remainingFiles - .Where(i => i.ExtraType == null) - .ToList(); - foreach (var media in standaloneMedia) { var info = new VideoInfo(media.Name) { Files = new[] { media } }; info.Year = info.Files[0].Year; - - remainingFiles.Remove(media); - var extras = ExtractExtras(remainingFiles, media.FileNameWithoutExtension, namingOptions.VideoFlagDelimiters); - - info.Extras = extras; - list.Add(info); } @@ -87,58 +80,12 @@ namespace Emby.Naming.Video list = GetVideosGroupedByVersion(list, namingOptions); } - // If there's only one resolved video, use the folder name as well to find extras - if (list.Count == 1) - { - var info = list[0]; - var videoPath = list[0].Files[0].Path; - var parentPath = Path.GetDirectoryName(videoPath.AsSpan()); - - if (!parentPath.IsEmpty) - { - var folderName = Path.GetFileName(parentPath); - if (!folderName.IsEmpty) - { - var extras = ExtractExtras(remainingFiles, folderName, namingOptions.VideoFlagDelimiters); - extras.AddRange(info.Extras); - info.Extras = extras; - } - } - - // Add the extras that are just based on file name as well - var extrasByFileName = remainingFiles - .Where(i => i.ExtraRule != null && i.ExtraRule.RuleType == ExtraRuleType.Filename) - .ToList(); - - remainingFiles = remainingFiles - .Except(extrasByFileName) - .ToList(); - - extrasByFileName.AddRange(info.Extras); - info.Extras = extrasByFileName; - } - - // If there's only one video, accept all trailers - // Be lenient because people use all kinds of mishmash conventions with trailers. - if (list.Count == 1) - { - var trailers = remainingFiles - .Where(i => i.ExtraType == ExtraType.Trailer) - .ToList(); - - trailers.AddRange(list[0].Extras); - list[0].Extras = trailers; - - remainingFiles = remainingFiles - .Except(trailers) - .ToList(); - } - // Whatever files are left, just add them list.AddRange(remainingFiles.Select(i => new VideoInfo(i.Name) { Files = new[] { i }, - Year = i.Year + Year = i.Year, + ExtraType = i.ExtraType })); return list; @@ -162,6 +109,11 @@ namespace Emby.Naming.Video for (var i = 0; i < videos.Count; i++) { var video = videos[i]; + if (video.ExtraType != null) + { + continue; + } + if (!IsEligibleForMultiVersion(folderName, video.Files[0].Path, namingOptions)) { return videos; @@ -178,17 +130,14 @@ namespace Emby.Naming.Video var alternateVersionsLen = videos.Count - 1; var alternateVersions = new VideoFileInfo[alternateVersionsLen]; - var extras = new List<VideoFileInfo>(list[0].Extras); for (int i = 0; i < alternateVersionsLen; i++) { var video = videos[i + 1]; alternateVersions[i] = video.Files[0]; - extras.AddRange(video.Extras); } list[0].AlternateVersions = alternateVersions; list[0].Name = folderName.ToString(); - list[0].Extras = extras; return list; } @@ -230,7 +179,7 @@ namespace Emby.Naming.Video var tmpTestFilename = testFilename.ToString(); if (CleanStringParser.TryClean(tmpTestFilename, namingOptions.CleanStringRegexes, out var cleanName)) { - tmpTestFilename = cleanName.Trim().ToString(); + tmpTestFilename = cleanName.Trim(); } // The CleanStringParser should have removed common keywords etc. @@ -238,67 +187,5 @@ namespace Emby.Naming.Video || testFilename[0] == '-' || Regex.IsMatch(tmpTestFilename, @"^\[([^]]*)\]", RegexOptions.Compiled); } - - private static ReadOnlySpan<char> TrimFilenameDelimiters(ReadOnlySpan<char> name, ReadOnlySpan<char> videoFlagDelimiters) - { - return name.IsEmpty ? name : name.TrimEnd().TrimEnd(videoFlagDelimiters).TrimEnd(); - } - - private static bool StartsWith(ReadOnlySpan<char> fileName, ReadOnlySpan<char> baseName, ReadOnlySpan<char> trimmedBaseName) - { - if (baseName.IsEmpty) - { - return false; - } - - return fileName.StartsWith(baseName, StringComparison.OrdinalIgnoreCase) - || (!trimmedBaseName.IsEmpty && fileName.StartsWith(trimmedBaseName, StringComparison.OrdinalIgnoreCase)); - } - - /// <summary> - /// Finds similar filenames to that of [baseName] and removes any matches from [remainingFiles]. - /// </summary> - /// <param name="remainingFiles">The list of remaining filenames.</param> - /// <param name="baseName">The base name to use for the comparison.</param> - /// <param name="videoFlagDelimiters">The video flag delimiters.</param> - /// <returns>A list of video extras for [baseName].</returns> - private static List<VideoFileInfo> ExtractExtras(IList<VideoFileInfo> remainingFiles, ReadOnlySpan<char> baseName, ReadOnlySpan<char> videoFlagDelimiters) - { - return ExtractExtras(remainingFiles, baseName, ReadOnlySpan<char>.Empty, videoFlagDelimiters); - } - - /// <summary> - /// Finds similar filenames to that of [firstBaseName] and [secondBaseName] and removes any matches from [remainingFiles]. - /// </summary> - /// <param name="remainingFiles">The list of remaining filenames.</param> - /// <param name="firstBaseName">The first base name to use for the comparison.</param> - /// <param name="secondBaseName">The second base name to use for the comparison.</param> - /// <param name="videoFlagDelimiters">The video flag delimiters.</param> - /// <returns>A list of video extras for [firstBaseName] and [secondBaseName].</returns> - private static List<VideoFileInfo> ExtractExtras(IList<VideoFileInfo> remainingFiles, ReadOnlySpan<char> firstBaseName, ReadOnlySpan<char> secondBaseName, ReadOnlySpan<char> videoFlagDelimiters) - { - var trimmedFirstBaseName = TrimFilenameDelimiters(firstBaseName, videoFlagDelimiters); - var trimmedSecondBaseName = TrimFilenameDelimiters(secondBaseName, videoFlagDelimiters); - - var result = new List<VideoFileInfo>(); - for (var pos = remainingFiles.Count - 1; pos >= 0; pos--) - { - var 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 3b1d906c6..de8e177d8 100644 --- a/Emby.Naming/Video/VideoResolver.cs +++ b/Emby.Naming/Video/VideoResolver.cs @@ -16,10 +16,11 @@ namespace Emby.Naming.Video /// </summary> /// <param name="path">The path.</param> /// <param name="namingOptions">The naming options.</param> + /// <param name="parseName">Whether to parse the name or use the filename.</param> /// <returns>VideoFileInfo.</returns> - public static VideoFileInfo? ResolveDirectory(string? path, NamingOptions namingOptions) + public static VideoFileInfo? ResolveDirectory(string? path, NamingOptions namingOptions, bool parseName = true) { - return Resolve(path, true, namingOptions); + return Resolve(path, true, namingOptions, parseName); } /// <summary> @@ -74,7 +75,7 @@ namespace Emby.Naming.Video var format3DResult = Format3DParser.Parse(path, namingOptions); - var extraResult = new ExtraResolver(namingOptions).GetExtraInfo(path); + var extraResult = ExtraRuleResolver.GetExtraInfo(path, namingOptions); var name = Path.GetFileNameWithoutExtension(path); @@ -87,9 +88,9 @@ namespace Emby.Naming.Video year = cleanDateTimeResult.Year; if (extraResult.ExtraType == null - && TryCleanString(name, namingOptions, out ReadOnlySpan<char> newName)) + && TryCleanString(name, namingOptions, out var newName)) { - name = newName.ToString(); + name = newName; } } @@ -138,7 +139,7 @@ namespace Emby.Naming.Video /// <param name="namingOptions">The naming options.</param> /// <param name="newName">Clean name.</param> /// <returns>True if cleaning of name was successful.</returns> - public static bool TryCleanString([NotNullWhen(true)] string? name, NamingOptions namingOptions, out ReadOnlySpan<char> newName) + public static bool TryCleanString([NotNullWhen(true)] string? name, NamingOptions namingOptions, out string newName) { return CleanStringParser.TryClean(name, namingOptions.CleanStringRegexes, out newName); } diff --git a/Emby.Notifications/CoreNotificationTypes.cs b/Emby.Notifications/CoreNotificationTypes.cs index ec3490e23..35aac3a11 100644 --- a/Emby.Notifications/CoreNotificationTypes.cs +++ b/Emby.Notifications/CoreNotificationTypes.cs @@ -24,63 +24,63 @@ namespace Emby.Notifications { new NotificationTypeInfo { - Type = NotificationType.ApplicationUpdateInstalled.ToString() + Type = nameof(NotificationType.ApplicationUpdateInstalled) }, new NotificationTypeInfo { - Type = NotificationType.InstallationFailed.ToString() + Type = nameof(NotificationType.InstallationFailed) }, new NotificationTypeInfo { - Type = NotificationType.PluginInstalled.ToString() + Type = nameof(NotificationType.PluginInstalled) }, new NotificationTypeInfo { - Type = NotificationType.PluginError.ToString() + Type = nameof(NotificationType.PluginError) }, new NotificationTypeInfo { - Type = NotificationType.PluginUninstalled.ToString() + Type = nameof(NotificationType.PluginUninstalled) }, new NotificationTypeInfo { - Type = NotificationType.PluginUpdateInstalled.ToString() + Type = nameof(NotificationType.PluginUpdateInstalled) }, new NotificationTypeInfo { - Type = NotificationType.ServerRestartRequired.ToString() + Type = nameof(NotificationType.ServerRestartRequired) }, new NotificationTypeInfo { - Type = NotificationType.TaskFailed.ToString() + Type = nameof(NotificationType.TaskFailed) }, new NotificationTypeInfo { - Type = NotificationType.NewLibraryContent.ToString() + Type = nameof(NotificationType.NewLibraryContent) }, new NotificationTypeInfo { - Type = NotificationType.AudioPlayback.ToString() + Type = nameof(NotificationType.AudioPlayback) }, new NotificationTypeInfo { - Type = NotificationType.VideoPlayback.ToString() + Type = nameof(NotificationType.VideoPlayback) }, new NotificationTypeInfo { - Type = NotificationType.AudioPlaybackStopped.ToString() + Type = nameof(NotificationType.AudioPlaybackStopped) }, new NotificationTypeInfo { - Type = NotificationType.VideoPlaybackStopped.ToString() + Type = nameof(NotificationType.VideoPlaybackStopped) }, new NotificationTypeInfo { - Type = NotificationType.UserLockedOut.ToString() + Type = nameof(NotificationType.UserLockedOut) }, new NotificationTypeInfo { - Type = NotificationType.ApplicationUpdateAvailable.ToString() + Type = nameof(NotificationType.ApplicationUpdateAvailable) } }; @@ -98,7 +98,7 @@ namespace Emby.Notifications private void Update(NotificationTypeInfo note) { - note.Name = _localization.GetLocalizedString("NotificationOption" + note.Type) ?? note.Type; + note.Name = _localization.GetLocalizedString("NotificationOption" + note.Type); note.IsBasedOnUserEvent = note.Type.IndexOf("Playback", StringComparison.OrdinalIgnoreCase) != -1; diff --git a/Emby.Notifications/Emby.Notifications.csproj b/Emby.Notifications/Emby.Notifications.csproj index 5edcf2f29..7fd2e9bb4 100644 --- a/Emby.Notifications/Emby.Notifications.csproj +++ b/Emby.Notifications/Emby.Notifications.csproj @@ -6,7 +6,7 @@ </PropertyGroup> <PropertyGroup> - <TargetFramework>net5.0</TargetFramework> + <TargetFramework>net6.0</TargetFramework> <GenerateAssemblyInfo>false</GenerateAssemblyInfo> <GenerateDocumentationFile>true</GenerateDocumentationFile> </PropertyGroup> @@ -24,7 +24,7 @@ <!-- Code analyzers--> <ItemGroup Condition=" '$(Configuration)' == 'Debug' "> <PackageReference Include="SerilogAnalyzer" Version="0.15.0" PrivateAssets="All" /> - <PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="All" /> + <PackageReference Include="StyleCop.Analyzers" Version="1.2.0-beta.376" PrivateAssets="All" /> <PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" PrivateAssets="All" /> </ItemGroup> diff --git a/Emby.Notifications/NotificationEntryPoint.cs b/Emby.Notifications/NotificationEntryPoint.cs index e8ae14ff2..a56df7031 100644 --- a/Emby.Notifications/NotificationEntryPoint.cs +++ b/Emby.Notifications/NotificationEntryPoint.cs @@ -5,6 +5,7 @@ using System.Linq; using System.Threading; using System.Threading.Tasks; using Jellyfin.Data.Events; +using Jellyfin.Extensions; using MediaBrowser.Common.Configuration; using MediaBrowser.Controller; using MediaBrowser.Controller.Entities; @@ -104,7 +105,7 @@ namespace Emby.Notifications var type = entry.Type; - if (string.IsNullOrEmpty(type) || !_coreNotificationTypes.Contains(type, StringComparer.OrdinalIgnoreCase)) + if (string.IsNullOrEmpty(type) || !_coreNotificationTypes.Contains(type, StringComparison.OrdinalIgnoreCase)) { return; } diff --git a/Emby.Photos/Emby.Photos.csproj b/Emby.Photos/Emby.Photos.csproj index 00b2f0f94..4964265c9 100644 --- a/Emby.Photos/Emby.Photos.csproj +++ b/Emby.Photos/Emby.Photos.csproj @@ -19,14 +19,14 @@ </ItemGroup> <PropertyGroup> - <TargetFramework>net5.0</TargetFramework> + <TargetFramework>net6.0</TargetFramework> <GenerateAssemblyInfo>false</GenerateAssemblyInfo> <GenerateDocumentationFile>true</GenerateDocumentationFile> </PropertyGroup> <!-- Code Analyzers--> <ItemGroup Condition=" '$(Configuration)' == 'Debug' "> - <PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="All" /> + <PackageReference Include="StyleCop.Analyzers" Version="1.2.0-beta.376" PrivateAssets="All" /> <PackageReference Include="SerilogAnalyzer" Version="0.15.0" PrivateAssets="All" /> <PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" PrivateAssets="All" /> </ItemGroup> diff --git a/Emby.Photos/PhotoProvider.cs b/Emby.Photos/PhotoProvider.cs index 4071e4e54..cef82b4d6 100644 --- a/Emby.Photos/PhotoProvider.cs +++ b/Emby.Photos/PhotoProvider.cs @@ -3,6 +3,7 @@ using System.IO; using System.Linq; using System.Threading; using System.Threading.Tasks; +using Jellyfin.Extensions; using MediaBrowser.Controller.Drawing; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; @@ -60,7 +61,7 @@ namespace Emby.Photos item.SetImagePath(ImageType.Primary, item.Path); // Examples: https://github.com/mono/taglib-sharp/blob/a5f6949a53d09ce63ee7495580d6802921a21f14/tests/fixtures/TagLib.Tests.Images/NullOrientationTest.cs - if (_includeExtensions.Contains(Path.GetExtension(item.Path), StringComparer.OrdinalIgnoreCase)) + if (_includeExtensions.Contains(Path.GetExtension(item.Path), StringComparison.OrdinalIgnoreCase)) { try { diff --git a/Emby.Server.Implementations/AppBase/BaseConfigurationManager.cs b/Emby.Server.Implementations/AppBase/BaseConfigurationManager.cs index d38535634..19fe0b108 100644 --- a/Emby.Server.Implementations/AppBase/BaseConfigurationManager.cs +++ b/Emby.Server.Implementations/AppBase/BaseConfigurationManager.cs @@ -301,7 +301,7 @@ namespace Emby.Server.Implementations.AppBase { return _configurations.GetOrAdd( key, - (k, configurationManager) => + static (k, configurationManager) => { var file = configurationManager.GetConfigurationFile(k); @@ -371,7 +371,7 @@ namespace Emby.Server.Implementations.AppBase NewConfiguration = configuration }); - _configurations.AddOrUpdate(key, configuration, (k, v) => configuration); + _configurations.AddOrUpdate(key, configuration, (_, _) => configuration); var path = GetConfigurationFile(key); Directory.CreateDirectory(Path.GetDirectoryName(path)); diff --git a/Emby.Server.Implementations/AppBase/ConfigurationHelper.cs b/Emby.Server.Implementations/AppBase/ConfigurationHelper.cs index 0308a68e4..f923e59ef 100644 --- a/Emby.Server.Implementations/AppBase/ConfigurationHelper.cs +++ b/Emby.Server.Implementations/AppBase/ConfigurationHelper.cs @@ -1,6 +1,5 @@ using System; using System.IO; -using System.Linq; using MediaBrowser.Model.Serialization; namespace Emby.Server.Implementations.AppBase @@ -41,20 +40,19 @@ namespace Emby.Server.Implementations.AppBase 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<byte> 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 3a504d2f4..814c10196 100644 --- a/Emby.Server.Implementations/ApplicationHost.cs +++ b/Emby.Server.Implementations/ApplicationHost.cs @@ -3,6 +3,7 @@ #pragma warning disable CS1591 using System; +using System.Collections.Concurrent; using System.Collections.Generic; using System.Diagnostics; using System.Globalization; @@ -18,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; @@ -56,6 +58,7 @@ 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.Dlna; @@ -117,7 +120,7 @@ namespace Emby.Server.Implementations /// <summary> /// The disposable parts. /// </summary> - private readonly List<IDisposable> _disposableParts = new List<IDisposable>(); + private readonly ConcurrentDictionary<IDisposable, byte> _disposableParts = new(); private readonly IFileSystem _fileSystemManager; private readonly IConfiguration _startupConfig; @@ -128,7 +131,6 @@ namespace Emby.Server.Implementations private List<Type> _creatingInstances; private IMediaEncoder _mediaEncoder; private ISessionManager _sessionManager; - private string[] _urlPrefixes; /// <summary> /// Gets or sets all concrete types. @@ -147,25 +149,20 @@ namespace Emby.Server.Implementations /// <param name="loggerFactory">Instance of the <see cref="ILoggerFactory"/> interface.</param> /// <param name="options">Instance of the <see cref="IStartupOptions"/> interface.</param> /// <param name="startupConfig">The <see cref="IConfiguration" /> interface.</param> - /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param> - /// <param name="serviceCollection">Instance of the <see cref="IServiceCollection"/> interface.</param> public ApplicationHost( IServerApplicationPaths applicationPaths, ILoggerFactory loggerFactory, IStartupOptions options, - IConfiguration startupConfig, - IFileSystem fileSystem, - IServiceCollection serviceCollection) + IConfiguration startupConfig) { ApplicationPaths = applicationPaths; LoggerFactory = loggerFactory; _startupOptions = options; _startupConfig = startupConfig; - _fileSystemManager = fileSystem; - ServiceCollection = serviceCollection; + _fileSystemManager = new ManagedFileSystem(LoggerFactory.CreateLogger<ManagedFileSystem>(), applicationPaths); Logger = LoggerFactory.CreateLogger<ApplicationHost>(); - fileSystem.AddShortcutHandler(new MbLinkShortcutHandler(fileSystem)); + _fileSystemManager.AddShortcutHandler(new MbLinkShortcutHandler(_fileSystemManager)); ApplicationVersion = typeof(ApplicationHost).Assembly.GetName().Version; ApplicationVersionString = ApplicationVersion.ToString(3); @@ -214,7 +211,7 @@ namespace Emby.Server.Implementations /// <summary> /// Gets the <see cref="INetworkManager"/> singleton instance. /// </summary> - public INetworkManager NetManager { get; internal set; } + public INetworkManager NetManager { get; private set; } /// <summary> /// Gets a value indicating whether this instance has changes that require the entire application to restart. @@ -230,24 +227,22 @@ namespace Emby.Server.Implementations /// </summary> protected ILogger<ApplicationHost> Logger { get; } - protected IServiceCollection ServiceCollection { get; } - /// <summary> /// Gets the logger factory. /// </summary> protected ILoggerFactory LoggerFactory { get; } /// <summary> - /// Gets or sets the application paths. + /// Gets the application paths. /// </summary> /// <value>The application paths.</value> - protected IServerApplicationPaths ApplicationPaths { get; set; } + protected IServerApplicationPaths ApplicationPaths { get; } /// <summary> - /// Gets or sets the configuration manager. + /// Gets the configuration manager. /// </summary> /// <value>The configuration manager.</value> - public ServerConfigurationManager ConfigurationManager { get; set; } + public ServerConfigurationManager ConfigurationManager { get; } /// <summary> /// Gets or sets the service provider. @@ -306,7 +301,7 @@ namespace Emby.Server.Implementations /// <inheritdoc/> public string Name => ApplicationProductName; - private CertificateInfo CertificateInfo { get; set; } + private string CertificatePath { get; set; } public X509Certificate2 Certificate { get; private set; } @@ -318,22 +313,6 @@ namespace Emby.Server.Implementations ? Environment.MachineName : ConfigurationManager.Configuration.ServerName; - /// <summary> - /// Temporary function to migration network settings out of system.xml and into network.xml. - /// TODO: remove at the point when a fixed migration path has been decided upon. - /// </summary> - private void MigrateNetworkConfiguration() - { - string path = Path.Combine(ConfigurationManager.CommonApplicationPaths.ConfigurationDirectoryPath, "network.xml"); - if (!File.Exists(path)) - { - var networkSettings = new NetworkConfiguration(); - ClassMigrationHelper.CopyProperties(ConfigurationManager.Configuration, networkSettings); - _xmlSerializer.SerializeToFile(networkSettings, path); - Logger.LogDebug("Successfully migrated network settings."); - } - } - public string ExpandVirtualPath(string path) { var appPaths = ApplicationPaths; @@ -350,22 +329,6 @@ namespace Emby.Server.Implementations .Replace(appPaths.InternalMetadataPath, appPaths.VirtualInternalMetadataPath, StringComparison.OrdinalIgnoreCase); } - /// <summary> - /// Creates an instance of type and resolves all constructor dependencies. - /// </summary> - /// <param name="type">The type.</param> - /// <returns>System.Object.</returns> - public object CreateInstance(Type type) - => ActivatorUtilities.CreateInstance(ServiceProvider, type); - - /// <summary> - /// Creates an instance of type and resolves all constructor dependencies. - /// </summary> - /// <typeparam name="T">The type.</typeparam> - /// <returns>T.</returns> - public T CreateInstance<T>() - => ActivatorUtilities.CreateInstance<T>(ServiceProvider); - /// <summary> /// Creates the instance safe. /// </summary> @@ -375,7 +338,7 @@ namespace Emby.Server.Implementations { _creatingInstances ??= new List<Type>(); - 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 +348,7 @@ namespace Emby.Server.Implementations _pluginManager.FailPlugin(type.Assembly); - throw new ExternalException("DI Loop detected."); + throw new TypeLoadException("DI Loop detected"); } try @@ -418,8 +381,15 @@ namespace Emby.Server.Implementations public IEnumerable<Type> GetExportTypes<T>() { 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; + } + } } /// <inheritdoc /> @@ -434,9 +404,9 @@ namespace Emby.Server.Implementations if (manageLifetime) { - lock (_disposableParts) + foreach (var part in parts.OfType<IDisposable>()) { - _disposableParts.AddRange(parts.OfType<IDisposable>()); + _disposableParts.TryAdd(part, byte.MinValue); } } @@ -455,9 +425,9 @@ namespace Emby.Server.Implementations if (manageLifetime) { - lock (_disposableParts) + foreach (var part in parts.OfType<IDisposable>()) { - _disposableParts.AddRange(parts.OfType<IDisposable>()); + _disposableParts.TryAdd(part, byte.MinValue); } } @@ -521,14 +491,12 @@ namespace Emby.Server.Implementations } /// <inheritdoc/> - public void Init() + public void Init(IServiceCollection serviceCollection) { DiscoverTypes(); ConfigurationManager.AddParts(GetExports<IConfigurationFactory>()); - // Have to migrate settings here as migration subsystem not yet initialised. - MigrateNetworkConfiguration(); NetManager = new NetworkManager(ConfigurationManager, LoggerFactory.CreateLogger<NetworkManager>()); // Initialize runtime stat collection @@ -548,135 +516,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); } /// <summary> /// Registers services/resources with the service collection that will be available via DI. /// </summary> - protected virtual void RegisterServices() + /// <param name="serviceCollection">Instance of the <see cref="IServiceCollection"/> interface.</param> + protected virtual void RegisterServices(IServiceCollection serviceCollection) { - ServiceCollection.AddSingleton(_startupOptions); + serviceCollection.AddSingleton(_startupOptions); - ServiceCollection.AddMemoryCache(); + serviceCollection.AddMemoryCache(); - ServiceCollection.AddSingleton<IServerConfigurationManager>(ConfigurationManager); - ServiceCollection.AddSingleton<IConfigurationManager>(ConfigurationManager); - ServiceCollection.AddSingleton<IApplicationHost>(this); - ServiceCollection.AddSingleton<IPluginManager>(_pluginManager); - ServiceCollection.AddSingleton<IApplicationPaths>(ApplicationPaths); + serviceCollection.AddSingleton<IServerConfigurationManager>(ConfigurationManager); + serviceCollection.AddSingleton<IConfigurationManager>(ConfigurationManager); + serviceCollection.AddSingleton<IApplicationHost>(this); + serviceCollection.AddSingleton(_pluginManager); + serviceCollection.AddSingleton<IApplicationPaths>(ApplicationPaths); - ServiceCollection.AddSingleton(_fileSystemManager); - ServiceCollection.AddSingleton<TmdbClientManager>(); + serviceCollection.AddSingleton(_fileSystemManager); + serviceCollection.AddSingleton<TmdbClientManager>(); - ServiceCollection.AddSingleton(NetManager); + serviceCollection.AddSingleton(NetManager); - ServiceCollection.AddSingleton<ITaskManager, TaskManager>(); + serviceCollection.AddSingleton<ITaskManager, TaskManager>(); - ServiceCollection.AddSingleton(_xmlSerializer); + serviceCollection.AddSingleton(_xmlSerializer); - ServiceCollection.AddSingleton<IStreamHelper, StreamHelper>(); + serviceCollection.AddSingleton<IStreamHelper, StreamHelper>(); - ServiceCollection.AddSingleton<ICryptoProvider, CryptographyProvider>(); + serviceCollection.AddSingleton<ICryptoProvider, CryptographyProvider>(); - ServiceCollection.AddSingleton<ISocketFactory, SocketFactory>(); + serviceCollection.AddSingleton<ISocketFactory, SocketFactory>(); - ServiceCollection.AddSingleton<IInstallationManager, InstallationManager>(); + serviceCollection.AddSingleton<IInstallationManager, InstallationManager>(); - ServiceCollection.AddSingleton<IZipClient, ZipClient>(); + serviceCollection.AddSingleton<IZipClient, ZipClient>(); - ServiceCollection.AddSingleton<IServerApplicationHost>(this); - ServiceCollection.AddSingleton<IServerApplicationPaths>(ApplicationPaths); + serviceCollection.AddSingleton<IServerApplicationHost>(this); + serviceCollection.AddSingleton(ApplicationPaths); - ServiceCollection.AddSingleton<ILocalizationManager, LocalizationManager>(); + serviceCollection.AddSingleton<ILocalizationManager, LocalizationManager>(); - ServiceCollection.AddSingleton<IBlurayExaminer, BdInfoExaminer>(); + serviceCollection.AddSingleton<IBlurayExaminer, BdInfoExaminer>(); - ServiceCollection.AddSingleton<IUserDataRepository, SqliteUserDataRepository>(); - ServiceCollection.AddSingleton<IUserDataManager, UserDataManager>(); + serviceCollection.AddSingleton<IUserDataRepository, SqliteUserDataRepository>(); + serviceCollection.AddSingleton<IUserDataManager, UserDataManager>(); - ServiceCollection.AddSingleton<IItemRepository, SqliteItemRepository>(); + serviceCollection.AddSingleton<IItemRepository, SqliteItemRepository>(); - ServiceCollection.AddSingleton<IMediaEncoder, MediaBrowser.MediaEncoding.Encoder.MediaEncoder>(); - ServiceCollection.AddSingleton<EncodingHelper>(); + serviceCollection.AddSingleton<IMediaEncoder, MediaBrowser.MediaEncoding.Encoder.MediaEncoder>(); + serviceCollection.AddSingleton<EncodingHelper>(); // TODO: Refactor to eliminate the circular dependencies here so that Lazy<T> isn't required - ServiceCollection.AddTransient(provider => new Lazy<ILibraryMonitor>(provider.GetRequiredService<ILibraryMonitor>)); - ServiceCollection.AddTransient(provider => new Lazy<IProviderManager>(provider.GetRequiredService<IProviderManager>)); - ServiceCollection.AddTransient(provider => new Lazy<IUserViewManager>(provider.GetRequiredService<IUserViewManager>)); - ServiceCollection.AddSingleton<ILibraryManager, LibraryManager>(); + serviceCollection.AddTransient(provider => new Lazy<ILibraryMonitor>(provider.GetRequiredService<ILibraryMonitor>)); + serviceCollection.AddTransient(provider => new Lazy<IProviderManager>(provider.GetRequiredService<IProviderManager>)); + serviceCollection.AddTransient(provider => new Lazy<IUserViewManager>(provider.GetRequiredService<IUserViewManager>)); + serviceCollection.AddSingleton<ILibraryManager, LibraryManager>(); + serviceCollection.AddSingleton<NamingOptions>(); - ServiceCollection.AddSingleton<IMusicManager, MusicManager>(); + serviceCollection.AddSingleton<IMusicManager, MusicManager>(); - ServiceCollection.AddSingleton<ILibraryMonitor, LibraryMonitor>(); + serviceCollection.AddSingleton<ILibraryMonitor, LibraryMonitor>(); - ServiceCollection.AddSingleton<ISearchEngine, SearchEngine>(); + serviceCollection.AddSingleton<ISearchEngine, SearchEngine>(); - ServiceCollection.AddSingleton<IWebSocketManager, WebSocketManager>(); + serviceCollection.AddSingleton<IWebSocketManager, WebSocketManager>(); - ServiceCollection.AddSingleton<IImageProcessor, ImageProcessor>(); + serviceCollection.AddSingleton<IImageProcessor, ImageProcessor>(); - ServiceCollection.AddSingleton<ITVSeriesManager, TVSeriesManager>(); + serviceCollection.AddSingleton<ITVSeriesManager, TVSeriesManager>(); - ServiceCollection.AddSingleton<IMediaSourceManager, MediaSourceManager>(); + serviceCollection.AddSingleton<IMediaSourceManager, MediaSourceManager>(); - ServiceCollection.AddSingleton<ISubtitleManager, SubtitleManager>(); + serviceCollection.AddSingleton<ISubtitleManager, SubtitleManager>(); - ServiceCollection.AddSingleton<IProviderManager, ProviderManager>(); + serviceCollection.AddSingleton<IProviderManager, ProviderManager>(); // TODO: Refactor to eliminate the circular dependency here so that Lazy<T> isn't required - ServiceCollection.AddTransient(provider => new Lazy<ILiveTvManager>(provider.GetRequiredService<ILiveTvManager>)); - ServiceCollection.AddSingleton<IDtoService, DtoService>(); + serviceCollection.AddTransient(provider => new Lazy<ILiveTvManager>(provider.GetRequiredService<ILiveTvManager>)); + serviceCollection.AddSingleton<IDtoService, DtoService>(); - ServiceCollection.AddSingleton<IChannelManager, ChannelManager>(); + serviceCollection.AddSingleton<IChannelManager, ChannelManager>(); - ServiceCollection.AddSingleton<ISessionManager, SessionManager>(); + serviceCollection.AddSingleton<ISessionManager, SessionManager>(); - ServiceCollection.AddSingleton<IDlnaManager, DlnaManager>(); + serviceCollection.AddSingleton<IDlnaManager, DlnaManager>(); - ServiceCollection.AddSingleton<ICollectionManager, CollectionManager>(); + serviceCollection.AddSingleton<ICollectionManager, CollectionManager>(); - ServiceCollection.AddSingleton<IPlaylistManager, PlaylistManager>(); + serviceCollection.AddSingleton<IPlaylistManager, PlaylistManager>(); - ServiceCollection.AddSingleton<ISyncPlayManager, SyncPlayManager>(); + serviceCollection.AddSingleton<ISyncPlayManager, SyncPlayManager>(); - ServiceCollection.AddSingleton<LiveTvDtoService>(); - ServiceCollection.AddSingleton<ILiveTvManager, LiveTvManager>(); + serviceCollection.AddSingleton<LiveTvDtoService>(); + serviceCollection.AddSingleton<ILiveTvManager, LiveTvManager>(); - ServiceCollection.AddSingleton<IUserViewManager, UserViewManager>(); + serviceCollection.AddSingleton<IUserViewManager, UserViewManager>(); - ServiceCollection.AddSingleton<INotificationManager, NotificationManager>(); + serviceCollection.AddSingleton<INotificationManager, NotificationManager>(); - ServiceCollection.AddSingleton<IDeviceDiscovery, DeviceDiscovery>(); + serviceCollection.AddSingleton<IDeviceDiscovery, DeviceDiscovery>(); - ServiceCollection.AddSingleton<IChapterManager, ChapterManager>(); + serviceCollection.AddSingleton<IChapterManager, ChapterManager>(); - ServiceCollection.AddSingleton<IEncodingManager, MediaEncoder.EncodingManager>(); + serviceCollection.AddSingleton<IEncodingManager, MediaEncoder.EncodingManager>(); - ServiceCollection.AddScoped<ISessionContext, SessionContext>(); + serviceCollection.AddScoped<ISessionContext, SessionContext>(); - ServiceCollection.AddSingleton<IAuthService, AuthService>(); - ServiceCollection.AddSingleton<IQuickConnect, QuickConnectManager>(); + serviceCollection.AddSingleton<IAuthService, AuthService>(); + serviceCollection.AddSingleton<IQuickConnect, QuickConnectManager>(); - ServiceCollection.AddSingleton<ISubtitleEncoder, MediaBrowser.MediaEncoding.Subtitles.SubtitleEncoder>(); + serviceCollection.AddSingleton<ISubtitleEncoder, MediaBrowser.MediaEncoding.Subtitles.SubtitleEncoder>(); - ServiceCollection.AddSingleton<IAttachmentExtractor, MediaBrowser.MediaEncoding.Attachments.AttachmentExtractor>(); + serviceCollection.AddSingleton<IAttachmentExtractor, MediaBrowser.MediaEncoding.Attachments.AttachmentExtractor>(); - ServiceCollection.AddSingleton<TranscodingJobHelper>(); - ServiceCollection.AddScoped<MediaInfoHelper>(); - ServiceCollection.AddScoped<AudioHelper>(); - ServiceCollection.AddScoped<DynamicHlsHelper>(); - - ServiceCollection.AddSingleton<IDirectoryService, DirectoryService>(); + serviceCollection.AddSingleton<TranscodingJobHelper>(); + serviceCollection.AddScoped<MediaInfoHelper>(); + serviceCollection.AddScoped<AudioHelper>(); + serviceCollection.AddScoped<DynamicHlsHelper>(); + serviceCollection.AddScoped<IClientEventLogger, ClientEventLogger>(); + serviceCollection.AddSingleton<IDirectoryService, DirectoryService>(); } /// <summary> @@ -729,30 +695,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; } @@ -760,7 +723,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; } } @@ -802,8 +765,6 @@ namespace Emby.Server.Implementations _pluginManager.CreatePlugins(); - _urlPrefixes = GetUrlPrefixes().ToArray(); - Resolve<ILibraryManager>().AddParts( GetExports<IResolverIgnoreRule>(), GetExports<IItemResolver>(), @@ -871,32 +832,12 @@ namespace Emby.Server.Implementations } } - private IEnumerable<string> GetUrlPrefixes() - { - var hosts = new[] { "+" }; - - return hosts.SelectMany(i => - { - var prefixes = new List<string> - { - "http://" + i + ":" + HttpPort + "/" - }; - - if (CertificateInfo != null) - { - prefixes.Add("https://" + i + ":" + HttpsPort + "/"); - } - - return prefixes; - }); - } - /// <summary> /// Called when [configuration updated]. /// </summary> /// <param name="sender">The sender.</param> /// <param name="e">The <see cref="EventArgs"/> instance containing the event data.</param> - protected void OnConfigurationUpdated(object sender, EventArgs e) + private void OnConfigurationUpdated(object sender, EventArgs e) { var requiresRestart = false; var networkConfiguration = ConfigurationManager.GetNetworkConfiguration(); @@ -905,8 +846,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) { @@ -918,11 +859,6 @@ namespace Emby.Server.Implementations } } - if (!_urlPrefixes.SequenceEqual(GetUrlPrefixes(), StringComparer.OrdinalIgnoreCase)) - { - requiresRestart = true; - } - if (ValidateSslCertificate(networkConfiguration)) { requiresRestart = true; @@ -946,7 +882,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)) { @@ -964,7 +900,7 @@ namespace Emby.Server.Implementations } /// <summary> - /// 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. /// </summary> public void NotifyPendingRestart() { @@ -1037,7 +973,7 @@ namespace Emby.Server.Implementations yield return typeof(IServerApplicationHost).Assembly; // Include composable parts in the Providers assembly - yield return typeof(ProviderUtils).Assembly; + yield return typeof(ProviderManager).Assembly; // Include composable parts in the Photos assembly yield return typeof(PhotoProvider).Assembly; @@ -1074,9 +1010,9 @@ namespace Emby.Server.Implementations /// <summary> /// Gets the system status. /// </summary> - /// <param name="source">Where this request originated.</param> + /// <param name="request">Where this request originated.</param> /// <returns>SystemInfo.</returns> - public SystemInfo GetSystemInfo(IPAddress source) + public SystemInfo GetSystemInfo(HttpRequest request) { return new SystemInfo { @@ -1098,19 +1034,14 @@ namespace Emby.Server.Implementations CanLaunchWebBrowser = CanLaunchWebBrowser, TranscodingTempPath = ConfigurationManager.GetTranscodePath(), ServerName = FriendlyName, - LocalAddress = GetSmartApiUrl(source), + LocalAddress = GetSmartApiUrl(request), SupportsLibraryMonitor = true, SystemArchitecture = RuntimeInformation.OSArchitecture, PackageName = _startupOptions.PackageName }; } - public IEnumerable<WakeOnLanInfo> GetWakeOnLanInfo() - => NetManager.GetMacAddresses() - .Select(i => new WakeOnLanInfo(i)) - .ToList(); - - public PublicSystemInfo GetPublicSystemInfo(IPAddress address) + public PublicSystemInfo GetPublicSystemInfo(HttpRequest request) { return new PublicSystemInfo { @@ -1119,13 +1050,13 @@ namespace Emby.Server.Implementations Id = SystemId, OperatingSystem = MediaBrowser.Common.System.OperatingSystem.Id.ToString(), ServerName = FriendlyName, - LocalAddress = GetSmartApiUrl(address), + LocalAddress = GetSmartApiUrl(request), StartupWizardCompleted = ConfigurationManager.CommonConfiguration.IsStartupWizardCompleted }; } /// <inheritdoc/> - public string GetSmartApiUrl(IPAddress remoteAddr, int? port = null) + public string GetSmartApiUrl(IPAddress remoteAddr) { // Published server ends with a / if (!string.IsNullOrEmpty(PublishedServerUrl)) @@ -1134,19 +1065,25 @@ namespace Emby.Server.Implementations return PublishedServerUrl.Trim('/'); } - string smart = NetManager.GetBindInterface(remoteAddr, out port); - // If the smartAPI doesn't start with http then treat it as a host or ip. - if (smart.StartsWith("http", StringComparison.OrdinalIgnoreCase)) - { - return smart.Trim('/'); - } - + string smart = NetManager.GetBindInterface(remoteAddr, out var port); return GetLocalApiUrl(smart.Trim('/'), null, port); } /// <inheritdoc/> - 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)) { @@ -1154,18 +1091,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); } /// <inheritdoc/> - public string GetSmartApiUrl(string hostname, int? port = null) + public string GetSmartApiUrl(string hostname) { // Published server ends with a / if (!string.IsNullOrEmpty(PublishedServerUrl)) @@ -1174,31 +1105,29 @@ 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); } /// <inheritdoc/> - 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); } /// <inheritdoc/> 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 @@ -1272,12 +1201,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 { @@ -1285,19 +1217,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 591ae547d..6a3b250d2 100644 --- a/Emby.Server.Implementations/Archiving/ZipClient.cs +++ b/Emby.Server.Implementations/Archiving/ZipClient.cs @@ -1,11 +1,8 @@ using System.IO; using MediaBrowser.Model.IO; -using SharpCompress.Archives.SevenZip; -using SharpCompress.Archives.Tar; using SharpCompress.Common; using SharpCompress.Readers; using SharpCompress.Readers.GZip; -using SharpCompress.Readers.Zip; namespace Emby.Server.Implementations.Archiving { @@ -14,53 +11,6 @@ namespace Emby.Server.Implementations.Archiving /// </summary> public class ZipClient : IZipClient { - /// <summary> - /// Extracts all. - /// </summary> - /// <param name="sourceFile">The source file.</param> - /// <param name="targetPath">The target path.</param> - /// <param name="overwriteExistingFiles">if set to <c>true</c> [overwrite existing files].</param> - public void ExtractAll(string sourceFile, string targetPath, bool overwriteExistingFiles) - { - using var fileStream = File.OpenRead(sourceFile); - ExtractAll(fileStream, targetPath, overwriteExistingFiles); - } - - /// <summary> - /// Extracts all. - /// </summary> - /// <param name="source">The source.</param> - /// <param name="targetPath">The target path.</param> - /// <param name="overwriteExistingFiles">if set to <c>true</c> [overwrite existing files].</param> - public void ExtractAll(Stream source, string targetPath, bool overwriteExistingFiles) - { - using var reader = ReaderFactory.Open(source); - var options = new ExtractionOptions - { - ExtractFullPath = true - }; - - if (overwriteExistingFiles) - { - options.Overwrite = true; - } - - reader.WriteAllToDirectory(targetPath, options); - } - - /// <inheritdoc /> - public void ExtractAllFromZip(Stream source, string targetPath, bool overwriteExistingFiles) - { - using var reader = ZipReader.Open(source); - var options = new ExtractionOptions - { - ExtractFullPath = true, - Overwrite = overwriteExistingFiles - }; - - reader.WriteAllToDirectory(targetPath, options); - } - /// <inheritdoc /> public void ExtractAllFromGz(Stream source, string targetPath, bool overwriteExistingFiles) { @@ -71,6 +21,7 @@ namespace Emby.Server.Implementations.Archiving Overwrite = overwriteExistingFiles }; + Directory.CreateDirectory(targetPath); reader.WriteAllToDirectory(targetPath, options); } @@ -91,67 +42,5 @@ namespace Emby.Server.Implementations.Archiving reader.WriteEntryToFile(Path.Combine(targetPath, filename)); } } - - /// <summary> - /// Extracts all from7z. - /// </summary> - /// <param name="sourceFile">The source file.</param> - /// <param name="targetPath">The target path.</param> - /// <param name="overwriteExistingFiles">if set to <c>true</c> [overwrite existing files].</param> - public void ExtractAllFrom7z(string sourceFile, string targetPath, bool overwriteExistingFiles) - { - using var fileStream = File.OpenRead(sourceFile); - ExtractAllFrom7z(fileStream, targetPath, overwriteExistingFiles); - } - - /// <summary> - /// Extracts all from7z. - /// </summary> - /// <param name="source">The source.</param> - /// <param name="targetPath">The target path.</param> - /// <param name="overwriteExistingFiles">if set to <c>true</c> [overwrite existing files].</param> - public void ExtractAllFrom7z(Stream source, string targetPath, bool overwriteExistingFiles) - { - using var archive = SevenZipArchive.Open(source); - using var reader = archive.ExtractAllEntries(); - var options = new ExtractionOptions - { - ExtractFullPath = true, - Overwrite = overwriteExistingFiles - }; - - reader.WriteAllToDirectory(targetPath, options); - } - - /// <summary> - /// Extracts all from tar. - /// </summary> - /// <param name="sourceFile">The source file.</param> - /// <param name="targetPath">The target path.</param> - /// <param name="overwriteExistingFiles">if set to <c>true</c> [overwrite existing files].</param> - public void ExtractAllFromTar(string sourceFile, string targetPath, bool overwriteExistingFiles) - { - using var fileStream = File.OpenRead(sourceFile); - ExtractAllFromTar(fileStream, targetPath, overwriteExistingFiles); - } - - /// <summary> - /// Extracts all from tar. - /// </summary> - /// <param name="source">The source.</param> - /// <param name="targetPath">The target path.</param> - /// <param name="overwriteExistingFiles">if set to <c>true</c> [overwrite existing files].</param> - public void ExtractAllFromTar(Stream source, string targetPath, bool overwriteExistingFiles) - { - using var archive = TarArchive.Open(source); - using var reader = archive.ExtractAllEntries(); - var options = new ExtractionOptions - { - ExtractFullPath = true, - Overwrite = overwriteExistingFiles - }; - - reader.WriteAllToDirectory(targetPath, options); - } } } diff --git a/Emby.Server.Implementations/Channels/ChannelManager.cs b/Emby.Server.Implementations/Channels/ChannelManager.cs index 6faa5d363..43c8a451b 100644 --- a/Emby.Server.Implementations/Channels/ChannelManager.cs +++ b/Emby.Server.Implementations/Channels/ChannelManager.cs @@ -10,8 +10,9 @@ using System.Threading; using System.Threading.Tasks; using Jellyfin.Data.Entities; using Jellyfin.Data.Enums; -using MediaBrowser.Common.Extensions; +using Jellyfin.Extensions; using Jellyfin.Extensions.Json; +using MediaBrowser.Common.Extensions; using MediaBrowser.Common.Progress; using MediaBrowser.Controller.Channels; using MediaBrowser.Controller.Configuration; @@ -129,16 +130,14 @@ namespace Emby.Server.Implementations.Channels var internalChannel = _libraryManager.GetItemById(item.ChannelId); if (internalChannel == null) { - throw new ArgumentException(); + throw new ArgumentException(nameof(item.ChannelId)); } var channel = Channels.FirstOrDefault(i => GetInternalChannelId(i.Name).Equals(internalChannel.Id)); - var supportsDelete = channel as ISupportsDelete; - - if (supportsDelete == null) + if (channel is not ISupportsDelete supportsDelete) { - throw new ArgumentException(); + throw new ArgumentException(nameof(channel)); } return supportsDelete.DeleteItem(item.ExternalId, CancellationToken.None); @@ -179,7 +178,7 @@ namespace Emby.Server.Implementations.Channels try { return (GetChannelProvider(i) is IHasFolderAttributes hasAttributes - && hasAttributes.Attributes.Contains("Recordings", StringComparer.OrdinalIgnoreCase)) == val; + && hasAttributes.Attributes.Contains("Recordings", StringComparison.OrdinalIgnoreCase)) == val; } catch { @@ -541,7 +540,7 @@ namespace Emby.Server.Implementations.Channels return _libraryManager.GetItemIds( new InternalItemsQuery { - IncludeItemTypes = new[] { nameof(Channel) }, + IncludeItemTypes = new[] { BaseItemKind.Channel }, OrderBy = new[] { (ItemSortBy.SortName, SortOrder.Ascending) } }).Select(i => GetChannelFeatures(i)).ToArray(); } @@ -586,7 +585,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, @@ -596,8 +595,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 }; @@ -1077,14 +1074,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; @@ -1145,7 +1134,7 @@ namespace Emby.Server.Implementations.Channels if (!info.IsLiveStream) { - if (item.Tags.Contains("livestream", StringComparer.OrdinalIgnoreCase)) + if (item.Tags.Contains("livestream", StringComparison.OrdinalIgnoreCase)) { item.Tags = item.Tags.Except(new[] { "livestream" }, StringComparer.OrdinalIgnoreCase).ToArray(); _logger.LogDebug("Forcing update due to Tags {0}", item.Name); @@ -1154,7 +1143,7 @@ namespace Emby.Server.Implementations.Channels } else { - if (!item.Tags.Contains("livestream", StringComparer.OrdinalIgnoreCase)) + if (!item.Tags.Contains("livestream", StringComparison.OrdinalIgnoreCase)) { item.Tags = item.Tags.Concat(new[] { "livestream" }).ToArray(); _logger.LogDebug("Forcing update due to Tags {0}", item.Name); diff --git a/Emby.Server.Implementations/Channels/ChannelPostScanTask.cs b/Emby.Server.Implementations/Channels/ChannelPostScanTask.cs index 2391eed42..b358ba4d5 100644 --- a/Emby.Server.Implementations/Channels/ChannelPostScanTask.cs +++ b/Emby.Server.Implementations/Channels/ChannelPostScanTask.cs @@ -2,6 +2,7 @@ using System; using System.Linq; using System.Threading; using System.Threading.Tasks; +using Jellyfin.Data.Enums; using MediaBrowser.Controller.Channels; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; @@ -51,7 +52,7 @@ namespace Emby.Server.Implementations.Channels var uninstalledChannels = _libraryManager.GetItemList(new InternalItemsQuery { - IncludeItemTypes = new[] { nameof(Channel) }, + IncludeItemTypes = new[] { BaseItemKind.Channel }, ExcludeItemIds = installedChannelIds.ToArray() }); diff --git a/Emby.Server.Implementations/Collections/CollectionManager.cs b/Emby.Server.Implementations/Collections/CollectionManager.cs index 79ef70fff..b5b8fea65 100644 --- a/Emby.Server.Implementations/Collections/CollectionManager.cs +++ b/Emby.Server.Implementations/Collections/CollectionManager.cs @@ -140,7 +140,7 @@ namespace Emby.Server.Implementations.Collections if (parentFolder == null) { - throw new ArgumentException(); + throw new ArgumentException(nameof(parentFolder)); } var path = Path.Combine(parentFolder.Path, folderName); diff --git a/Emby.Server.Implementations/Cryptography/CryptographyProvider.cs b/Emby.Server.Implementations/Cryptography/CryptographyProvider.cs index 4a9b28085..e9c005cea 100644 --- a/Emby.Server.Implementations/Cryptography/CryptographyProvider.cs +++ b/Emby.Server.Implementations/Cryptography/CryptographyProvider.cs @@ -1,17 +1,20 @@ 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 { /// <summary> /// Class providing abstractions over cryptographic functions. /// </summary> - public class CryptographyProvider : ICryptoProvider, IDisposable + public class CryptographyProvider : ICryptoProvider { + // TODO: remove when not needed for backwards compat private static readonly HashSet<string> _supportedHashMethods = new HashSet<string>() { "MD5", @@ -30,71 +33,71 @@ namespace Emby.Server.Implementations.Cryptography "System.Security.Cryptography.SHA512" }; - private RandomNumberGenerator _randomNumberGenerator; + /// <inheritdoc /> + public string DefaultHashMethod => "PBKDF2-SHA512"; - private bool _disposed; - - /// <summary> - /// Initializes a new instance of the <see cref="CryptographyProvider"/> class. - /// </summary> - public CryptographyProvider() + /// <inheritdoc /> + public PasswordHash CreatePasswordHash(ReadOnlySpan<char> 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<string, string> + { + { "iterations", DefaultIterations.ToString(CultureInfo.InvariantCulture) } + }); } /// <inheritdoc /> - public string DefaultHashMethod => "PBKDF2"; - - /// <inheritdoc /> - public IEnumerable<string> GetSupportedHashMethods() - => _supportedHashMethods; - - private byte[] PBKDF2(string method, byte[] bytes, byte[] salt, int iterations) + public bool Verify(PasswordHash hash, ReadOnlySpan<char> 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); - } - - /// <inheritdoc /> - 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)); } - /// <inheritdoc /> - public byte[] ComputeHashWithDefaultMethod(byte[] bytes, byte[] salt) - => PBKDF2(DefaultHashMethod, bytes, salt, DefaultIterations); - /// <inheritdoc /> public byte[] GenerateSalt() => GenerateSalt(DefaultSaltLength); @@ -102,35 +105,10 @@ namespace Emby.Server.Implementations.Cryptography /// <inheritdoc /> 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; } - - /// <inheritdoc /> - public void Dispose() - { - Dispose(true); - GC.SuppressFinalize(this); - } - - /// <summary> - /// Releases unmanaged and - optionally - managed resources. - /// </summary> - /// <param name="disposing"><c>true</c> to release both managed and unmanaged resources; <c>false</c> to release only unmanaged resources.</param> - protected virtual void Dispose(bool disposing) - { - if (_disposed) - { - return; - } - - if (disposing) - { - _randomNumberGenerator.Dispose(); - } - - _disposed = true; - } } } diff --git a/Emby.Server.Implementations/Data/BaseSqliteRepository.cs b/Emby.Server.Implementations/Data/BaseSqliteRepository.cs index 01c9fbca8..450688491 100644 --- a/Emby.Server.Implementations/Data/BaseSqliteRepository.cs +++ b/Emby.Server.Implementations/Data/BaseSqliteRepository.cs @@ -4,8 +4,8 @@ using System; using System.Collections.Generic; -using System.Linq; using System.Threading; +using Jellyfin.Extensions; using Microsoft.Extensions.Logging; using SQLitePCL.pretty; @@ -98,7 +98,7 @@ namespace Emby.Server.Implementations.Data /// <value>The write connection.</value> protected SQLiteDatabaseConnection WriteConnection { get; set; } - protected ManagedConnection GetConnection(bool _ = false) + protected ManagedConnection GetConnection(bool readOnly = false) { WriteLock.Wait(); if (WriteConnection != null) @@ -160,21 +160,22 @@ namespace Emby.Server.Implementations.Data protected bool TableExists(ManagedConnection connection, string name) { return connection.RunInTransaction( - db => - { - using (var statement = PrepareStatement(db, "select DISTINCT tbl_name from sqlite_master")) + db => { - foreach (var row in statement.ExecuteQuery()) + using (var statement = PrepareStatement(db, "select DISTINCT tbl_name from sqlite_master")) { - if (string.Equals(name, row.GetString(0), StringComparison.OrdinalIgnoreCase)) + foreach (var row in statement.ExecuteQuery()) { - return true; + if (string.Equals(name, row.GetString(0), StringComparison.OrdinalIgnoreCase)) + { + return true; + } } } - } - return false; - }, ReadTransactionMode); + return false; + }, + ReadTransactionMode); } protected List<string> GetColumnNames(IDatabaseConnection connection, string table) @@ -194,7 +195,7 @@ namespace Emby.Server.Implementations.Data protected void AddColumn(IDatabaseConnection connection, string table, string columnName, string type, List<string> existingColumnNames) { - if (existingColumnNames.Contains(columnName, StringComparer.OrdinalIgnoreCase)) + if (existingColumnNames.Contains(columnName, StringComparison.OrdinalIgnoreCase)) { return; } @@ -249,55 +250,4 @@ namespace Emby.Server.Implementations.Data _disposed = true; } } - - /// <summary> - /// The disk synchronization mode, controls how aggressively SQLite will write data - /// all the way out to physical storage. - /// </summary> - public enum SynchronousMode - { - /// <summary> - /// SQLite continues without syncing as soon as it has handed data off to the operating system. - /// </summary> - Off = 0, - - /// <summary> - /// SQLite database engine will still sync at the most critical moments. - /// </summary> - Normal = 1, - - /// <summary> - /// 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. - /// </summary> - Full = 2, - - /// <summary> - /// 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. - /// </summary> - Extra = 3 - } - - /// <summary> - /// Storage mode used by temporary database files. - /// </summary> - public enum TempStoreMode - { - /// <summary> - /// The compile-time C preprocessor macro SQLITE_TEMP_STORE - /// is used to determine where temporary tables and indices are stored. - /// </summary> - Default = 0, - - /// <summary> - /// Temporary tables and indices are stored in a file. - /// </summary> - File = 1, - - /// <summary> - /// Temporary tables and indices are kept in as if they were pure in-memory databases memory. - /// </summary> - Memory = 2 - } } diff --git a/Emby.Server.Implementations/Data/ManagedConnection.cs b/Emby.Server.Implementations/Data/ManagedConnection.cs index afc8966f9..11e33278d 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) diff --git a/Emby.Server.Implementations/Data/SqliteExtensions.cs b/Emby.Server.Implementations/Data/SqliteExtensions.cs index 3289e7609..381eb92a8 100644 --- a/Emby.Server.Implementations/Data/SqliteExtensions.cs +++ b/Emby.Server.Implementations/Data/SqliteExtensions.cs @@ -94,7 +94,7 @@ namespace Emby.Server.Implementations.Data dateText, _datetimeFormats, DateTimeFormatInfo.InvariantInfo, - DateTimeStyles.None).ToUniversalTime(); + DateTimeStyles.AdjustToUniversal); } public static bool TryReadDateTime(this IReadOnlyList<ResultSetValue> reader, int index, out DateTime result) @@ -108,9 +108,9 @@ namespace Emby.Server.Implementations.Data var dateText = item.ToString(); - if (DateTime.TryParseExact(dateText, _datetimeFormats, DateTimeFormatInfo.InvariantInfo, DateTimeStyles.None, out var dateTimeResult)) + if (DateTime.TryParseExact(dateText, _datetimeFormats, DateTimeFormatInfo.InvariantInfo, DateTimeStyles.AdjustToUniversal, out var dateTimeResult)) { - result = dateTimeResult.ToUniversalTime(); + result = dateTimeResult; return true; } diff --git a/Emby.Server.Implementations/Data/SqliteItemRepository.cs b/Emby.Server.Implementations/Data/SqliteItemRepository.cs index 88fc5018d..5ab9e02fe 100644 --- a/Emby.Server.Implementations/Data/SqliteItemRepository.cs +++ b/Emby.Server.Implementations/Data/SqliteItemRepository.cs @@ -46,6 +46,11 @@ namespace Emby.Server.Implementations.Data 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; @@ -55,6 +60,231 @@ namespace Emby.Server.Implementations.Data private readonly TypeMapper _typeMapper; private readonly JsonSerializerOptions _jsonOptions; + private readonly ItemFields[] _allItemFields = Enum.GetValues<ItemFields>(); + + private static readonly string[] _retrieveItemColumns = + { + "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 _retrieveItemColumnsSelectQuery = $"select {string.Join(',', _retrieveItemColumns)} 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 BaseItemKind[] _programTypes = new[] + { + BaseItemKind.Program, + BaseItemKind.TvChannel, + BaseItemKind.LiveTvProgram, + BaseItemKind.LiveTvChannel + }; + + private static readonly BaseItemKind[] _programExcludeParentTypes = new[] + { + BaseItemKind.Series, + BaseItemKind.Season, + BaseItemKind.MusicAlbum, + BaseItemKind.MusicArtist, + BaseItemKind.PhotoAlbum + }; + + private static readonly BaseItemKind[] _serviceTypes = new[] + { + BaseItemKind.TvChannel, + BaseItemKind.LiveTvChannel + }; + + private static readonly BaseItemKind[] _startDateTypes = new[] + { + BaseItemKind.Program, + BaseItemKind.LiveTvProgram + }; + + private static readonly BaseItemKind[] _seriesTypes = new[] + { + BaseItemKind.Book, + BaseItemKind.AudioBook, + BaseItemKind.Episode, + BaseItemKind.Season + }; + + private static readonly BaseItemKind[] _artistExcludeParentTypes = new[] + { + BaseItemKind.Series, + BaseItemKind.Season, + BaseItemKind.PhotoAlbum + }; + + private static readonly BaseItemKind[] _artistsTypes = new[] + { + BaseItemKind.Audio, + BaseItemKind.MusicAlbum, + BaseItemKind.MusicVideo, + BaseItemKind.AudioBook + }; + + private static readonly Dictionary<BaseItemKind, string> _baseItemKindNames = new() + { + { BaseItemKind.AggregateFolder, typeof(AggregateFolder).FullName }, + { BaseItemKind.Audio, typeof(Audio).FullName }, + { BaseItemKind.AudioBook, typeof(AudioBook).FullName }, + { BaseItemKind.BasePluginFolder, typeof(BasePluginFolder).FullName }, + { BaseItemKind.Book, typeof(Book).FullName }, + { BaseItemKind.BoxSet, typeof(BoxSet).FullName }, + { BaseItemKind.Channel, typeof(Channel).FullName }, + { BaseItemKind.CollectionFolder, typeof(CollectionFolder).FullName }, + { BaseItemKind.Episode, typeof(Episode).FullName }, + { BaseItemKind.Folder, typeof(Folder).FullName }, + { BaseItemKind.Genre, typeof(Genre).FullName }, + { BaseItemKind.Movie, typeof(Movie).FullName }, + { BaseItemKind.LiveTvChannel, typeof(LiveTvChannel).FullName }, + { BaseItemKind.LiveTvProgram, typeof(LiveTvProgram).FullName }, + { BaseItemKind.MusicAlbum, typeof(MusicAlbum).FullName }, + { BaseItemKind.MusicArtist, typeof(MusicArtist).FullName }, + { BaseItemKind.MusicGenre, typeof(MusicGenre).FullName }, + { BaseItemKind.MusicVideo, typeof(MusicVideo).FullName }, + { BaseItemKind.Person, typeof(Person).FullName }, + { BaseItemKind.Photo, typeof(Photo).FullName }, + { BaseItemKind.PhotoAlbum, typeof(PhotoAlbum).FullName }, + { BaseItemKind.Playlist, typeof(Playlist).FullName }, + { BaseItemKind.PlaylistsFolder, typeof(PlaylistsFolder).FullName }, + { BaseItemKind.Season, typeof(Season).FullName }, + { BaseItemKind.Series, typeof(Series).FullName }, + { BaseItemKind.Studio, typeof(Studio).FullName }, + { BaseItemKind.Trailer, typeof(Trailer).FullName }, + { BaseItemKind.TvChannel, typeof(LiveTvChannel).FullName }, + { BaseItemKind.TvProgram, typeof(LiveTvProgram).FullName }, + { BaseItemKind.UserRootFolder, typeof(UserRootFolder).FullName }, + { BaseItemKind.UserView, typeof(UserView).FullName }, + { BaseItemKind.Video, typeof(Video).FullName }, + { BaseItemKind.Year, typeof(Year).FullName } + }; + static SqliteItemRepository() { var queryPrefixText = new StringBuilder(); @@ -115,6 +345,8 @@ namespace Emby.Server.Implementations.Data /// <summary> /// Opens the connection to the database. /// </summary> + /// <param name="userDataRepo">The user data repository.</param> + /// <param name="userManager">The user manager.</param> public void Initialize(SqliteUserDataRepository userDataRepo, IUserManager userManager) { const string CreateMediaStreamsTableCommand @@ -154,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", @@ -230,109 +462,110 @@ namespace Emby.Server.Implementations.Data connection.RunQueries(queries); connection.RunInTransaction( - db => - { - var existingColumnNames = GetColumnNames(db, "AncestorIds"); - AddColumn(db, "AncestorIds", "AncestorIdText", "Text", existingColumnNames); + db => + { + var existingColumnNames = GetColumnNames(db, "AncestorIds"); + AddColumn(db, "AncestorIds", "AncestorIdText", "Text", existingColumnNames); - existingColumnNames = GetColumnNames(db, "TypedBaseItems"); + existingColumnNames = GetColumnNames(db, "TypedBaseItems"); - AddColumn(db, "TypedBaseItems", "Path", "Text", existingColumnNames); - AddColumn(db, "TypedBaseItems", "StartDate", "DATETIME", existingColumnNames); - AddColumn(db, "TypedBaseItems", "EndDate", "DATETIME", existingColumnNames); - AddColumn(db, "TypedBaseItems", "ChannelId", "Text", existingColumnNames); - AddColumn(db, "TypedBaseItems", "IsMovie", "BIT", existingColumnNames); - AddColumn(db, "TypedBaseItems", "CommunityRating", "Float", existingColumnNames); - AddColumn(db, "TypedBaseItems", "CustomRating", "Text", existingColumnNames); - AddColumn(db, "TypedBaseItems", "IndexNumber", "INT", existingColumnNames); - AddColumn(db, "TypedBaseItems", "IsLocked", "BIT", existingColumnNames); - AddColumn(db, "TypedBaseItems", "Name", "Text", existingColumnNames); - AddColumn(db, "TypedBaseItems", "OfficialRating", "Text", existingColumnNames); - AddColumn(db, "TypedBaseItems", "MediaType", "Text", existingColumnNames); - AddColumn(db, "TypedBaseItems", "Overview", "Text", existingColumnNames); - AddColumn(db, "TypedBaseItems", "ParentIndexNumber", "INT", existingColumnNames); - AddColumn(db, "TypedBaseItems", "PremiereDate", "DATETIME", existingColumnNames); - AddColumn(db, "TypedBaseItems", "ProductionYear", "INT", existingColumnNames); - AddColumn(db, "TypedBaseItems", "ParentId", "GUID", existingColumnNames); - AddColumn(db, "TypedBaseItems", "Genres", "Text", existingColumnNames); - AddColumn(db, "TypedBaseItems", "SortName", "Text", existingColumnNames); - AddColumn(db, "TypedBaseItems", "ForcedSortName", "Text", existingColumnNames); - AddColumn(db, "TypedBaseItems", "RunTimeTicks", "BIGINT", existingColumnNames); - AddColumn(db, "TypedBaseItems", "DateCreated", "DATETIME", existingColumnNames); - AddColumn(db, "TypedBaseItems", "DateModified", "DATETIME", existingColumnNames); - AddColumn(db, "TypedBaseItems", "IsSeries", "BIT", existingColumnNames); - AddColumn(db, "TypedBaseItems", "EpisodeTitle", "Text", existingColumnNames); - AddColumn(db, "TypedBaseItems", "IsRepeat", "BIT", existingColumnNames); - AddColumn(db, "TypedBaseItems", "PreferredMetadataLanguage", "Text", existingColumnNames); - AddColumn(db, "TypedBaseItems", "PreferredMetadataCountryCode", "Text", existingColumnNames); - AddColumn(db, "TypedBaseItems", "DateLastRefreshed", "DATETIME", existingColumnNames); - AddColumn(db, "TypedBaseItems", "DateLastSaved", "DATETIME", existingColumnNames); - AddColumn(db, "TypedBaseItems", "IsInMixedFolder", "BIT", existingColumnNames); - AddColumn(db, "TypedBaseItems", "LockedFields", "Text", existingColumnNames); - AddColumn(db, "TypedBaseItems", "Studios", "Text", existingColumnNames); - AddColumn(db, "TypedBaseItems", "Audio", "Text", existingColumnNames); - AddColumn(db, "TypedBaseItems", "ExternalServiceId", "Text", existingColumnNames); - AddColumn(db, "TypedBaseItems", "Tags", "Text", existingColumnNames); - AddColumn(db, "TypedBaseItems", "IsFolder", "BIT", existingColumnNames); - AddColumn(db, "TypedBaseItems", "InheritedParentalRatingValue", "INT", existingColumnNames); - AddColumn(db, "TypedBaseItems", "UnratedType", "Text", existingColumnNames); - AddColumn(db, "TypedBaseItems", "TopParentId", "Text", existingColumnNames); - AddColumn(db, "TypedBaseItems", "TrailerTypes", "Text", existingColumnNames); - AddColumn(db, "TypedBaseItems", "CriticRating", "Float", existingColumnNames); - AddColumn(db, "TypedBaseItems", "CleanName", "Text", existingColumnNames); - AddColumn(db, "TypedBaseItems", "PresentationUniqueKey", "Text", existingColumnNames); - AddColumn(db, "TypedBaseItems", "OriginalTitle", "Text", existingColumnNames); - AddColumn(db, "TypedBaseItems", "PrimaryVersionId", "Text", existingColumnNames); - AddColumn(db, "TypedBaseItems", "DateLastMediaAdded", "DATETIME", existingColumnNames); - AddColumn(db, "TypedBaseItems", "Album", "Text", existingColumnNames); - AddColumn(db, "TypedBaseItems", "IsVirtualItem", "BIT", existingColumnNames); - AddColumn(db, "TypedBaseItems", "SeriesName", "Text", existingColumnNames); - AddColumn(db, "TypedBaseItems", "UserDataKey", "Text", existingColumnNames); - AddColumn(db, "TypedBaseItems", "SeasonName", "Text", existingColumnNames); - AddColumn(db, "TypedBaseItems", "SeasonId", "GUID", existingColumnNames); - AddColumn(db, "TypedBaseItems", "SeriesId", "GUID", existingColumnNames); - AddColumn(db, "TypedBaseItems", "ExternalSeriesId", "Text", existingColumnNames); - AddColumn(db, "TypedBaseItems", "Tagline", "Text", existingColumnNames); - AddColumn(db, "TypedBaseItems", "ProviderIds", "Text", existingColumnNames); - AddColumn(db, "TypedBaseItems", "Images", "Text", existingColumnNames); - AddColumn(db, "TypedBaseItems", "ProductionLocations", "Text", existingColumnNames); - AddColumn(db, "TypedBaseItems", "ExtraIds", "Text", existingColumnNames); - AddColumn(db, "TypedBaseItems", "TotalBitrate", "INT", existingColumnNames); - AddColumn(db, "TypedBaseItems", "ExtraType", "Text", existingColumnNames); - AddColumn(db, "TypedBaseItems", "Artists", "Text", existingColumnNames); - AddColumn(db, "TypedBaseItems", "AlbumArtists", "Text", existingColumnNames); - AddColumn(db, "TypedBaseItems", "ExternalId", "Text", existingColumnNames); - AddColumn(db, "TypedBaseItems", "SeriesPresentationUniqueKey", "Text", existingColumnNames); - AddColumn(db, "TypedBaseItems", "ShowId", "Text", existingColumnNames); - AddColumn(db, "TypedBaseItems", "OwnerId", "Text", existingColumnNames); - AddColumn(db, "TypedBaseItems", "Width", "INT", existingColumnNames); - AddColumn(db, "TypedBaseItems", "Height", "INT", existingColumnNames); - AddColumn(db, "TypedBaseItems", "Size", "BIGINT", existingColumnNames); + AddColumn(db, "TypedBaseItems", "Path", "Text", existingColumnNames); + AddColumn(db, "TypedBaseItems", "StartDate", "DATETIME", existingColumnNames); + AddColumn(db, "TypedBaseItems", "EndDate", "DATETIME", existingColumnNames); + AddColumn(db, "TypedBaseItems", "ChannelId", "Text", existingColumnNames); + AddColumn(db, "TypedBaseItems", "IsMovie", "BIT", existingColumnNames); + AddColumn(db, "TypedBaseItems", "CommunityRating", "Float", existingColumnNames); + AddColumn(db, "TypedBaseItems", "CustomRating", "Text", existingColumnNames); + AddColumn(db, "TypedBaseItems", "IndexNumber", "INT", existingColumnNames); + AddColumn(db, "TypedBaseItems", "IsLocked", "BIT", existingColumnNames); + AddColumn(db, "TypedBaseItems", "Name", "Text", existingColumnNames); + AddColumn(db, "TypedBaseItems", "OfficialRating", "Text", existingColumnNames); + AddColumn(db, "TypedBaseItems", "MediaType", "Text", existingColumnNames); + AddColumn(db, "TypedBaseItems", "Overview", "Text", existingColumnNames); + AddColumn(db, "TypedBaseItems", "ParentIndexNumber", "INT", existingColumnNames); + AddColumn(db, "TypedBaseItems", "PremiereDate", "DATETIME", existingColumnNames); + AddColumn(db, "TypedBaseItems", "ProductionYear", "INT", existingColumnNames); + AddColumn(db, "TypedBaseItems", "ParentId", "GUID", existingColumnNames); + AddColumn(db, "TypedBaseItems", "Genres", "Text", existingColumnNames); + AddColumn(db, "TypedBaseItems", "SortName", "Text", existingColumnNames); + AddColumn(db, "TypedBaseItems", "ForcedSortName", "Text", existingColumnNames); + AddColumn(db, "TypedBaseItems", "RunTimeTicks", "BIGINT", existingColumnNames); + AddColumn(db, "TypedBaseItems", "DateCreated", "DATETIME", existingColumnNames); + AddColumn(db, "TypedBaseItems", "DateModified", "DATETIME", existingColumnNames); + AddColumn(db, "TypedBaseItems", "IsSeries", "BIT", existingColumnNames); + AddColumn(db, "TypedBaseItems", "EpisodeTitle", "Text", existingColumnNames); + AddColumn(db, "TypedBaseItems", "IsRepeat", "BIT", existingColumnNames); + AddColumn(db, "TypedBaseItems", "PreferredMetadataLanguage", "Text", existingColumnNames); + AddColumn(db, "TypedBaseItems", "PreferredMetadataCountryCode", "Text", existingColumnNames); + AddColumn(db, "TypedBaseItems", "DateLastRefreshed", "DATETIME", existingColumnNames); + AddColumn(db, "TypedBaseItems", "DateLastSaved", "DATETIME", existingColumnNames); + AddColumn(db, "TypedBaseItems", "IsInMixedFolder", "BIT", existingColumnNames); + AddColumn(db, "TypedBaseItems", "LockedFields", "Text", existingColumnNames); + AddColumn(db, "TypedBaseItems", "Studios", "Text", existingColumnNames); + AddColumn(db, "TypedBaseItems", "Audio", "Text", existingColumnNames); + AddColumn(db, "TypedBaseItems", "ExternalServiceId", "Text", existingColumnNames); + AddColumn(db, "TypedBaseItems", "Tags", "Text", existingColumnNames); + AddColumn(db, "TypedBaseItems", "IsFolder", "BIT", existingColumnNames); + AddColumn(db, "TypedBaseItems", "InheritedParentalRatingValue", "INT", existingColumnNames); + AddColumn(db, "TypedBaseItems", "UnratedType", "Text", existingColumnNames); + AddColumn(db, "TypedBaseItems", "TopParentId", "Text", existingColumnNames); + AddColumn(db, "TypedBaseItems", "TrailerTypes", "Text", existingColumnNames); + AddColumn(db, "TypedBaseItems", "CriticRating", "Float", existingColumnNames); + AddColumn(db, "TypedBaseItems", "CleanName", "Text", existingColumnNames); + AddColumn(db, "TypedBaseItems", "PresentationUniqueKey", "Text", existingColumnNames); + AddColumn(db, "TypedBaseItems", "OriginalTitle", "Text", existingColumnNames); + AddColumn(db, "TypedBaseItems", "PrimaryVersionId", "Text", existingColumnNames); + AddColumn(db, "TypedBaseItems", "DateLastMediaAdded", "DATETIME", existingColumnNames); + AddColumn(db, "TypedBaseItems", "Album", "Text", existingColumnNames); + AddColumn(db, "TypedBaseItems", "IsVirtualItem", "BIT", existingColumnNames); + AddColumn(db, "TypedBaseItems", "SeriesName", "Text", existingColumnNames); + AddColumn(db, "TypedBaseItems", "UserDataKey", "Text", existingColumnNames); + AddColumn(db, "TypedBaseItems", "SeasonName", "Text", existingColumnNames); + AddColumn(db, "TypedBaseItems", "SeasonId", "GUID", existingColumnNames); + AddColumn(db, "TypedBaseItems", "SeriesId", "GUID", existingColumnNames); + AddColumn(db, "TypedBaseItems", "ExternalSeriesId", "Text", existingColumnNames); + AddColumn(db, "TypedBaseItems", "Tagline", "Text", existingColumnNames); + AddColumn(db, "TypedBaseItems", "ProviderIds", "Text", existingColumnNames); + AddColumn(db, "TypedBaseItems", "Images", "Text", existingColumnNames); + AddColumn(db, "TypedBaseItems", "ProductionLocations", "Text", existingColumnNames); + AddColumn(db, "TypedBaseItems", "ExtraIds", "Text", existingColumnNames); + AddColumn(db, "TypedBaseItems", "TotalBitrate", "INT", existingColumnNames); + AddColumn(db, "TypedBaseItems", "ExtraType", "Text", existingColumnNames); + AddColumn(db, "TypedBaseItems", "Artists", "Text", existingColumnNames); + AddColumn(db, "TypedBaseItems", "AlbumArtists", "Text", existingColumnNames); + AddColumn(db, "TypedBaseItems", "ExternalId", "Text", existingColumnNames); + AddColumn(db, "TypedBaseItems", "SeriesPresentationUniqueKey", "Text", existingColumnNames); + AddColumn(db, "TypedBaseItems", "ShowId", "Text", existingColumnNames); + AddColumn(db, "TypedBaseItems", "OwnerId", "Text", existingColumnNames); + AddColumn(db, "TypedBaseItems", "Width", "INT", existingColumnNames); + 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, "ItemValues"); + AddColumn(db, "ItemValues", "CleanValue", "Text", existingColumnNames); - existingColumnNames = GetColumnNames(db, ChaptersTableName); - AddColumn(db, ChaptersTableName, "ImageDateModified", "DATETIME", 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); + 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); + AddColumn(db, "MediaStreams", "ColorPrimaries", "TEXT", existingColumnNames); + AddColumn(db, "MediaStreams", "ColorSpace", "TEXT", existingColumnNames); + AddColumn(db, "MediaStreams", "ColorTransfer", "TEXT", existingColumnNames); + }, + TransactionMode); connection.RunQueries(postQueries); } @@ -340,151 +573,12 @@ namespace Emby.Server.Implementations.Data 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 "; - - 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 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)"; - /// <summary> /// Save a standard item in the repo. /// </summary> /// <param name="item">The item.</param> /// <param name="cancellationToken">The cancellation token.</param> - /// <exception cref="ArgumentNullException">item</exception> + /// <exception cref="ArgumentNullException"><paramref name="item"/> is <c>null</c>.</exception> public void SaveItem(BaseItem item, CancellationToken cancellationToken) { if (item == null) @@ -507,16 +601,17 @@ namespace Emby.Server.Implementations.Data using (var connection = GetConnection()) { connection.RunInTransaction( - db => - { - using (var saveImagesStatement = base.PrepareStatement(db, "Update TypedBaseItems set Images=@Images where guid=@Id")) + db => { - saveImagesStatement.TryBind("@Id", item.Id.ToByteArray()); - saveImagesStatement.TryBind("@Images", SerializeImages(item.ImageInfos)); + using (var saveImagesStatement = PrepareStatement(db, "Update TypedBaseItems set Images=@Images where guid=@Id")) + { + saveImagesStatement.TryBind("@Id", item.Id.ToByteArray()); + saveImagesStatement.TryBind("@Images", SerializeImages(item.ImageInfos)); - saveImagesStatement.MoveNext(); - } - }, TransactionMode); + saveImagesStatement.MoveNext(); + } + }, + TransactionMode); } } @@ -526,9 +621,7 @@ namespace Emby.Server.Implementations.Data /// <param name="items">The items.</param> /// <param name="cancellationToken">The cancellation token.</param> /// <exception cref="ArgumentNullException"> - /// items - /// or - /// cancellationToken + /// <paramref name="items"/> or <paramref name="cancellationToken"/> is <c>null</c>. /// </exception> public void SaveItems(IEnumerable<BaseItem> items, CancellationToken cancellationToken) { @@ -559,14 +652,15 @@ namespace Emby.Server.Implementations.Data using (var connection = GetConnection()) { connection.RunInTransaction( - db => - { - SaveItemsInTranscation(db, tuples); - }, TransactionMode); + db => + { + SaveItemsInTransaction(db, tuples); + }, + TransactionMode); } } - private void SaveItemsInTranscation(IDatabaseConnection db, IEnumerable<(BaseItem, List<Guid>, BaseItem, string, List<string>)> tuples) + private void SaveItemsInTransaction(IDatabaseConnection db, IEnumerable<(BaseItem Item, List<Guid> AncestorIds, BaseItem TopParent, string UserDataKey, List<string> InheritedTags)> tuples) { var statements = PrepareAll(db, new string[] { @@ -585,17 +679,17 @@ namespace Emby.Server.Implementations.Data saveItemStatement.Reset(); } - var item = tuple.Item1; - var topParent = tuple.Item3; - var userDataKey = tuple.Item4; + var item = tuple.Item; + var topParent = tuple.TopParent; + var userDataKey = tuple.UserDataKey; SaveItem(item, topParent, userDataKey, saveItemStatement); - var inheritedTags = tuple.Item5; + var inheritedTags = tuple.InheritedTags; if (item.SupportsAncestors) { - UpdateAncestors(item.Id, tuple.Item2, db, deleteAncestorsStatement); + UpdateAncestors(item.Id, tuple.AncestorIds, db, deleteAncestorsStatement); } UpdateItemValues(item.Id, GetItemValuesToSave(item, inheritedTags), db); @@ -1150,7 +1244,7 @@ namespace Emby.Server.Implementations.Data return null; } - if (Enum.TryParse(imageType.ToString(), true, out ImageType type)) + if (Enum.TryParse(imageType, true, out ImageType type)) { image.Type = type; } @@ -1216,8 +1310,8 @@ namespace Emby.Server.Implementations.Data /// </summary> /// <param name="id">The id.</param> /// <returns>BaseItem.</returns> - /// <exception cref="ArgumentNullException">id</exception> - /// <exception cref="ArgumentException"></exception> + /// <exception cref="ArgumentNullException"><paramref name="id"/> is <c>null</c>.</exception> + /// <exception cref="ArgumentException"><paramr name="id"/> is <seealso cref="Guid.Empty"/>.</exception> public BaseItem RetrieveItem(Guid id) { if (id == Guid.Empty) @@ -1229,7 +1323,7 @@ namespace Emby.Server.Implementations.Data using (var connection = GetConnection(true)) { - using (var statement = PrepareStatement(connection, _retriveItemColumnsSelectQuery)) + using (var statement = PrepareStatement(connection, _retrieveItemColumnsSelectQuery)) { statement.TryBind("@guid", id); @@ -1571,7 +1665,6 @@ namespace Emby.Server.Implementations.Data if (reader.TryGetString(index++, out var audioString)) { - // TODO Span overload coming in the future https://github.com/dotnet/runtime/issues/1916 if (Enum.TryParse(audioString, true, out ProgramAudio audio)) { item.Audio = audio; @@ -1610,18 +1703,16 @@ namespace Emby.Server.Implementations.Data { if (reader.TryGetString(index++, out var lockedFields)) { - IEnumerable<MetadataField> GetLockedFields(string s) + List<MetadataField> 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<MetadataField>()).Add(parsedValue); } } - item.LockedFields = GetLockedFields(lockedFields).ToArray(); + item.LockedFields = fields?.ToArray() ?? Array.Empty<MetadataField>(); } } @@ -1647,18 +1738,16 @@ namespace Emby.Server.Implementations.Data { if (reader.TryGetString(index, out var trailerTypes)) { - IEnumerable<TrailerType> GetTrailerTypes(string s) + List<TrailerType> 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<TrailerType>()).Add(parsedValue); } } - trailer.TrailerTypes = GetTrailerTypes(trailerTypes).ToArray(); + trailer.TrailerTypes = types?.ToArray() ?? Array.Empty<TrailerType>(); } } @@ -1991,6 +2080,8 @@ namespace Emby.Server.Implementations.Data /// <summary> /// Saves the chapters. /// </summary> + /// <param name="id">The item id.</param> + /// <param name="chapters">The chapters.</param> public void SaveChapters(Guid id, IReadOnlyList<ChapterInfo> chapters) { CheckDisposed(); @@ -2010,13 +2101,14 @@ namespace Emby.Server.Implementations.Data using (var connection = GetConnection()) { connection.RunInTransaction( - db => - { - // First delete chapters - db.Execute("delete from " + ChaptersTableName + " where ItemId=@ItemId", idBlob); + db => + { + // First delete chapters + db.Execute("delete from " + ChaptersTableName + " where ItemId=@ItemId", idBlob); - InsertChapters(idBlob, chapters, db); - }, TransactionMode); + InsertChapters(idBlob, chapters, db); + }, + TransactionMode); } } @@ -2075,7 +2167,7 @@ namespace Emby.Server.Implementations.Data return false; } - var sortingFields = new HashSet<string>(query.OrderBy.Select(i => i.Item1), StringComparer.OrdinalIgnoreCase); + var sortingFields = new HashSet<string>(query.OrderBy.Select(i => i.OrderBy), StringComparer.OrdinalIgnoreCase); return sortingFields.Contains(ItemSortBy.IsFavoriteOrLiked) || sortingFields.Contains(ItemSortBy.IsPlayed) @@ -2090,8 +2182,6 @@ namespace Emby.Server.Implementations.Data || query.IsLiked.HasValue; } - private readonly ItemFields[] _allFields = Enum.GetValues<ItemFields>(); - private bool HasField(InternalItemsQuery query, ItemFields name) { switch (name) @@ -2124,26 +2214,9 @@ namespace Emby.Server.Implementations.Data } } - private static readonly HashSet<string> _programExcludeParentTypes = new HashSet<string>(StringComparer.OrdinalIgnoreCase) - { - "Series", - "Season", - "MusicAlbum", - "MusicArtist", - "PhotoAlbum" - }; - - private static readonly HashSet<string> _programTypes = new HashSet<string>(StringComparer.OrdinalIgnoreCase) - { - "Program", - "TvChannel", - "LiveTvProgram", - "LiveTvTvChannel" - }; - private bool HasProgramAttributes(InternalItemsQuery query) { - if (_programExcludeParentTypes.Contains(query.ParentType)) + if (query.ParentType != null && _programExcludeParentTypes.Contains(query.ParentType.Value)) { return false; } @@ -2156,15 +2229,9 @@ namespace Emby.Server.Implementations.Data return query.IncludeItemTypes.Any(x => _programTypes.Contains(x)); } - private static readonly HashSet<string> _serviceTypes = new HashSet<string>(StringComparer.OrdinalIgnoreCase) - { - "TvChannel", - "LiveTvTvChannel" - }; - private bool HasServiceName(InternalItemsQuery query) { - if (_programExcludeParentTypes.Contains(query.ParentType)) + if (query.ParentType != null && _programExcludeParentTypes.Contains(query.ParentType.Value)) { return false; } @@ -2177,15 +2244,9 @@ namespace Emby.Server.Implementations.Data return query.IncludeItemTypes.Any(x => _serviceTypes.Contains(x)); } - private static readonly HashSet<string> _startDateTypes = new HashSet<string>(StringComparer.OrdinalIgnoreCase) - { - "Program", - "LiveTvProgram" - }; - private bool HasStartDate(InternalItemsQuery query) { - if (_programExcludeParentTypes.Contains(query.ParentType)) + if (query.ParentType != null && _programExcludeParentTypes.Contains(query.ParentType.Value)) { return false; } @@ -2205,7 +2266,7 @@ namespace Emby.Server.Implementations.Data return true; } - return query.IncludeItemTypes.Contains("Episode", StringComparer.OrdinalIgnoreCase); + return query.IncludeItemTypes.Contains(BaseItemKind.Episode); } private bool HasTrailerTypes(InternalItemsQuery query) @@ -2215,28 +2276,12 @@ namespace Emby.Server.Implementations.Data return true; } - return query.IncludeItemTypes.Contains("Trailer", StringComparer.OrdinalIgnoreCase); + return query.IncludeItemTypes.Contains(BaseItemKind.Trailer); } - private static readonly HashSet<string> _artistExcludeParentTypes = new HashSet<string>(StringComparer.OrdinalIgnoreCase) - { - "Series", - "Season", - "PhotoAlbum" - }; - - private static readonly HashSet<string> _artistsTypes = new HashSet<string>(StringComparer.OrdinalIgnoreCase) - { - "Audio", - "MusicAlbum", - "MusicVideo", - "AudioBook", - "AudioPodcast" - }; - private bool HasArtistFields(InternalItemsQuery query) { - if (_artistExcludeParentTypes.Contains(query.ParentType)) + if (query.ParentType != null && _artistExcludeParentTypes.Contains(query.ParentType.Value)) { return false; } @@ -2249,17 +2294,9 @@ namespace Emby.Server.Implementations.Data return query.IncludeItemTypes.Any(x => _artistsTypes.Contains(x)); } - private static readonly HashSet<string> _seriesTypes = new HashSet<string>(StringComparer.OrdinalIgnoreCase) - { - "Book", - "AudioBook", - "Episode", - "Season" - }; - private bool HasSeriesFields(InternalItemsQuery query) { - if (string.Equals(query.ParentType, "PhotoAlbum", StringComparison.OrdinalIgnoreCase)) + if (query.ParentType == BaseItemKind.PhotoAlbum) { return false; } @@ -2274,7 +2311,7 @@ namespace Emby.Server.Implementations.Data private void SetFinalColumnsToSelect(InternalItemsQuery query, List<string> columns) { - foreach (var field in _allFields) + foreach (var field in _allItemFields) { if (!HasField(query, field)) { @@ -2597,7 +2634,7 @@ namespace Emby.Server.Implementations.Data query.Limit = query.Limit.Value + 4; } - var columns = _retriveItemColumns.ToList(); + var columns = _retrieveItemColumns.ToList(); SetFinalColumnsToSelect(query, columns); var commandTextBuilder = new StringBuilder("select ", 1024) .AppendJoin(',', columns) @@ -2788,7 +2825,7 @@ namespace Emby.Server.Implementations.Data query.Limit = query.Limit.Value + 4; } - var columns = _retriveItemColumns.ToList(); + var columns = _retrieveItemColumns.ToList(); SetFinalColumnsToSelect(query, columns); var commandTextBuilder = new StringBuilder("select ", 512) .AppendJoin(',', columns) @@ -2875,69 +2912,70 @@ namespace Emby.Server.Implementations.Data using (var connection = GetConnection(true)) { connection.RunInTransaction( - db => - { - var itemQueryStatement = PrepareStatement(db, itemQuery); - var totalRecordCountQueryStatement = PrepareStatement(db, totalRecordCountQuery); - - if (!isReturningZeroItems) + db => { - using (var statement = itemQueryStatement) + var itemQueryStatement = PrepareStatement(db, itemQuery); + var totalRecordCountQueryStatement = PrepareStatement(db, totalRecordCountQuery); + + if (!isReturningZeroItems) { - if (EnableJoinUserData(query)) + using (var statement = itemQueryStatement) { - statement.TryBind("@UserId", query.User.InternalId); - } - - BindSimilarParams(query, statement); - BindSearchParams(query, statement); - - // Running this again will bind the params - GetWhereClauses(query, statement); - - var hasEpisodeAttributes = HasEpisodeAttributes(query); - var hasServiceName = HasServiceName(query); - var hasProgramAttributes = HasProgramAttributes(query); - var hasStartDate = HasStartDate(query); - var hasTrailerTypes = HasTrailerTypes(query); - var hasArtistFields = HasArtistFields(query); - var hasSeriesFields = HasSeriesFields(query); - - foreach (var row in statement.ExecuteQuery()) - { - var item = GetItem(row, query, hasProgramAttributes, hasEpisodeAttributes, hasServiceName, hasStartDate, hasTrailerTypes, hasArtistFields, hasSeriesFields); - if (item != null) + if (EnableJoinUserData(query)) { - list.Add(item); + statement.TryBind("@UserId", query.User.InternalId); + } + + BindSimilarParams(query, statement); + BindSearchParams(query, statement); + + // Running this again will bind the params + GetWhereClauses(query, statement); + + var hasEpisodeAttributes = HasEpisodeAttributes(query); + var hasServiceName = HasServiceName(query); + var hasProgramAttributes = HasProgramAttributes(query); + var hasStartDate = HasStartDate(query); + var hasTrailerTypes = HasTrailerTypes(query); + var hasArtistFields = HasArtistFields(query); + var hasSeriesFields = HasSeriesFields(query); + + foreach (var row in statement.ExecuteQuery()) + { + var item = GetItem(row, query, hasProgramAttributes, hasEpisodeAttributes, hasServiceName, hasStartDate, hasTrailerTypes, hasArtistFields, hasSeriesFields); + if (item != null) + { + list.Add(item); + } } } + + LogQueryTime("GetItems.ItemQuery", itemQuery, now); } - LogQueryTime("GetItems.ItemQuery", itemQuery, now); - } - - now = DateTime.UtcNow; - if (query.EnableTotalRecordCount) - { - using (var statement = totalRecordCountQueryStatement) + now = DateTime.UtcNow; + if (query.EnableTotalRecordCount) { - if (EnableJoinUserData(query)) + using (var statement = totalRecordCountQueryStatement) { - statement.TryBind("@UserId", query.User.InternalId); + if (EnableJoinUserData(query)) + { + statement.TryBind("@UserId", query.User.InternalId); + } + + BindSimilarParams(query, statement); + BindSearchParams(query, statement); + + // Running this again will bind the params + GetWhereClauses(query, statement); + + result.TotalRecordCount = statement.ExecuteQuery().SelectScalarInt().First(); } - BindSimilarParams(query, statement); - BindSearchParams(query, statement); - - // Running this again will bind the params - GetWhereClauses(query, statement); - - result.TotalRecordCount = statement.ExecuteQuery().SelectScalarInt().First(); + LogQueryTime("GetItems.TotalRecordCount", totalRecordCountQuery, now); } - - LogQueryTime("GetItems.TotalRecordCount", totalRecordCountQuery, now); - } - }, ReadTransactionMode); + }, + ReadTransactionMode); } result.Items = list; @@ -2977,88 +3015,101 @@ namespace Emby.Server.Implementations.Data return " ORDER BY " + string.Join(',', orderBy.Select(i => { - var columnMap = MapOrderByField(i.Item1, query); - - var sortOrder = i.Item2 == SortOrder.Ascending ? "ASC" : "DESC"; - - return columnMap.Item1 + " " + sortOrder; + var sortBy = MapOrderByField(i.OrderBy, query); + var sortOrder = i.SortOrder == SortOrder.Ascending ? "ASC" : "DESC"; + return sortBy + " " + sortOrder; })); } - private (string, bool) MapOrderByField(string name, InternalItemsQuery query) + private string MapOrderByField(string name, InternalItemsQuery query) { if (string.Equals(name, ItemSortBy.AirTime, StringComparison.OrdinalIgnoreCase)) { // TODO - return ("SortName", false); + return "SortName"; } - else if (string.Equals(name, ItemSortBy.Runtime, StringComparison.OrdinalIgnoreCase)) + + if (string.Equals(name, ItemSortBy.Runtime, StringComparison.OrdinalIgnoreCase)) { - return ("RuntimeTicks", false); + return "RuntimeTicks"; } - else if (string.Equals(name, ItemSortBy.Random, StringComparison.OrdinalIgnoreCase)) + + if (string.Equals(name, ItemSortBy.Random, StringComparison.OrdinalIgnoreCase)) { - return ("RANDOM()", false); + return "RANDOM()"; } - else if (string.Equals(name, ItemSortBy.DatePlayed, StringComparison.OrdinalIgnoreCase)) + + if (string.Equals(name, ItemSortBy.DatePlayed, StringComparison.OrdinalIgnoreCase)) { if (query.GroupBySeriesPresentationUniqueKey) { - return ("MAX(LastPlayedDate)", false); + return "MAX(LastPlayedDate)"; } - return ("LastPlayedDate", false); - } - else if (string.Equals(name, ItemSortBy.PlayCount, StringComparison.OrdinalIgnoreCase)) - { - return ("PlayCount", false); - } - else if (string.Equals(name, ItemSortBy.IsFavoriteOrLiked, StringComparison.OrdinalIgnoreCase)) - { - return ("(Select Case When IsFavorite is null Then 0 Else IsFavorite End )", true); - } - else if (string.Equals(name, ItemSortBy.IsFolder, StringComparison.OrdinalIgnoreCase)) - { - return ("IsFolder", true); - } - else if (string.Equals(name, ItemSortBy.IsPlayed, StringComparison.OrdinalIgnoreCase)) - { - return ("played", true); - } - else if (string.Equals(name, ItemSortBy.IsUnplayed, StringComparison.OrdinalIgnoreCase)) - { - return ("played", false); - } - else if (string.Equals(name, ItemSortBy.DateLastContentAdded, StringComparison.OrdinalIgnoreCase)) - { - return ("DateLastMediaAdded", false); - } - else if (string.Equals(name, ItemSortBy.Artist, StringComparison.OrdinalIgnoreCase)) - { - return ("(select CleanValue from itemvalues where ItemId=Guid and Type=0 LIMIT 1)", false); - } - else if (string.Equals(name, ItemSortBy.AlbumArtist, StringComparison.OrdinalIgnoreCase)) - { - return ("(select CleanValue from itemvalues where ItemId=Guid and Type=1 LIMIT 1)", false); - } - else if (string.Equals(name, ItemSortBy.OfficialRating, StringComparison.OrdinalIgnoreCase)) - { - return ("InheritedParentalRatingValue", false); - } - else if (string.Equals(name, ItemSortBy.Studio, StringComparison.OrdinalIgnoreCase)) - { - return ("(select CleanValue from itemvalues where ItemId=Guid and Type=3 LIMIT 1)", false); - } - else if (string.Equals(name, ItemSortBy.SeriesDatePlayed, StringComparison.OrdinalIgnoreCase)) - { - return ("(Select MAX(LastPlayedDate) from TypedBaseItems B" + GetJoinUserDataText(query) + " where Played=1 and B.SeriesPresentationUniqueKey=A.PresentationUniqueKey)", false); - } - else if (string.Equals(name, ItemSortBy.SeriesSortName, StringComparison.OrdinalIgnoreCase)) - { - return ("SeriesName", false); + return "LastPlayedDate"; } - return (name, false); + if (string.Equals(name, ItemSortBy.PlayCount, StringComparison.OrdinalIgnoreCase)) + { + return "PlayCount"; + } + + if (string.Equals(name, ItemSortBy.IsFavoriteOrLiked, StringComparison.OrdinalIgnoreCase)) + { + return "(Select Case When IsFavorite is null Then 0 Else IsFavorite End )"; + } + + if (string.Equals(name, ItemSortBy.IsFolder, StringComparison.OrdinalIgnoreCase)) + { + return "IsFolder"; + } + + if (string.Equals(name, ItemSortBy.IsPlayed, StringComparison.OrdinalIgnoreCase)) + { + return "played"; + } + + if (string.Equals(name, ItemSortBy.IsUnplayed, StringComparison.OrdinalIgnoreCase)) + { + return "played"; + } + + if (string.Equals(name, ItemSortBy.DateLastContentAdded, StringComparison.OrdinalIgnoreCase)) + { + return "DateLastMediaAdded"; + } + + if (string.Equals(name, ItemSortBy.Artist, StringComparison.OrdinalIgnoreCase)) + { + return "(select CleanValue from itemvalues where ItemId=Guid and Type=0 LIMIT 1)"; + } + + if (string.Equals(name, ItemSortBy.AlbumArtist, StringComparison.OrdinalIgnoreCase)) + { + return "(select CleanValue from itemvalues where ItemId=Guid and Type=1 LIMIT 1)"; + } + + if (string.Equals(name, ItemSortBy.OfficialRating, StringComparison.OrdinalIgnoreCase)) + { + return "InheritedParentalRatingValue"; + } + + if (string.Equals(name, ItemSortBy.Studio, StringComparison.OrdinalIgnoreCase)) + { + return "(select CleanValue from itemvalues where ItemId=Guid and Type=3 LIMIT 1)"; + } + + if (string.Equals(name, ItemSortBy.SeriesDatePlayed, StringComparison.OrdinalIgnoreCase)) + { + return "(Select MAX(LastPlayedDate) from TypedBaseItems B" + GetJoinUserDataText(query) + " where Played=1 and B.SeriesPresentationUniqueKey=A.PresentationUniqueKey)"; + } + + if (string.Equals(name, ItemSortBy.SeriesSortName, StringComparison.OrdinalIgnoreCase)) + { + return "SeriesName"; + } + + return name; } public List<Guid> GetItemIdsList(InternalItemsQuery query) @@ -3294,51 +3345,52 @@ namespace Emby.Server.Implementations.Data using (var connection = GetConnection(true)) { connection.RunInTransaction( - db => - { - var statements = PrepareAll(db, statementTexts); - - if (!isReturningZeroItems) + db => { - using (var statement = statements[0]) + var statements = PrepareAll(db, statementTexts); + + if (!isReturningZeroItems) { - if (EnableJoinUserData(query)) + using (var statement = statements[0]) { - statement.TryBind("@UserId", query.User.InternalId); - } + if (EnableJoinUserData(query)) + { + statement.TryBind("@UserId", query.User.InternalId); + } - BindSimilarParams(query, statement); - BindSearchParams(query, statement); + BindSimilarParams(query, statement); + BindSearchParams(query, statement); - // Running this again will bind the params - GetWhereClauses(query, statement); + // Running this again will bind the params + GetWhereClauses(query, statement); - foreach (var row in statement.ExecuteQuery()) - { - list.Add(row[0].ReadGuidFromBlob()); + foreach (var row in statement.ExecuteQuery()) + { + list.Add(row[0].ReadGuidFromBlob()); + } } } - } - if (query.EnableTotalRecordCount) - { - using (var statement = statements[statements.Length - 1]) + if (query.EnableTotalRecordCount) { - if (EnableJoinUserData(query)) + using (var statement = statements[statements.Length - 1]) { - statement.TryBind("@UserId", query.User.InternalId); + if (EnableJoinUserData(query)) + { + statement.TryBind("@UserId", query.User.InternalId); + } + + BindSimilarParams(query, statement); + BindSearchParams(query, statement); + + // Running this again will bind the params + GetWhereClauses(query, statement); + + result.TotalRecordCount = statement.ExecuteQuery().SelectScalarInt().First(); } - - BindSimilarParams(query, statement); - BindSearchParams(query, statement); - - // Running this again will bind the params - GetWhereClauses(query, statement); - - result.TotalRecordCount = statement.ExecuteQuery().SelectScalarInt().First(); } - } - }, ReadTransactionMode); + }, + ReadTransactionMode); } LogQueryTime("GetItemIds", commandText, now); @@ -3365,11 +3417,6 @@ namespace Emby.Server.Implementations.Data return true; } - private bool IsValidType(string value) - { - return IsAlphaNumeric(value); - } - private bool IsValidMediaType(string value) { return IsAlphaNumeric(value); @@ -3454,8 +3501,8 @@ namespace Emby.Server.Implementations.Data if (query.IsMovie == true) { if (query.IncludeItemTypes.Length == 0 - || query.IncludeItemTypes.Contains(nameof(Movie)) - || query.IncludeItemTypes.Contains(nameof(Trailer))) + || query.IncludeItemTypes.Contains(BaseItemKind.Movie) + || query.IncludeItemTypes.Contains(BaseItemKind.Trailer)) { whereClauses.Add("(IsMovie is null OR IsMovie=@IsMovie)"); } @@ -3530,31 +3577,81 @@ namespace Emby.Server.Implementations.Data statement?.TryBind("@IsFolder", query.IsFolder); } - var includeTypes = query.IncludeItemTypes.Select(MapIncludeItemTypes).Where(x => x != null).ToArray(); + var includeTypes = query.IncludeItemTypes; // Only specify excluded types if no included types are specified - if (includeTypes.Length == 0) + if (query.IncludeItemTypes.Length == 0) { - var excludeTypes = query.ExcludeItemTypes.Select(MapIncludeItemTypes).Where(x => x != null).ToArray(); + var excludeTypes = query.ExcludeItemTypes; if (excludeTypes.Length == 1) { - whereClauses.Add("type<>@type"); - statement?.TryBind("@type", excludeTypes[0]); + if (_baseItemKindNames.TryGetValue(excludeTypes[0], out var excludeTypeName)) + { + whereClauses.Add("type<>@type"); + statement?.TryBind("@type", excludeTypeName); + } + else + { + Logger.LogWarning("Undefined BaseItemKind to Type mapping: {BaseItemKind}", excludeTypes[0]); + } } else if (excludeTypes.Length > 1) { - var inClause = string.Join(',', excludeTypes.Select(i => "'" + i + "'")); - whereClauses.Add($"type not in ({inClause})"); + var whereBuilder = new StringBuilder("type not in ("); + foreach (var excludeType in excludeTypes) + { + if (_baseItemKindNames.TryGetValue(excludeType, out var baseItemKindName)) + { + whereBuilder + .Append('\'') + .Append(baseItemKindName) + .Append("',"); + } + else + { + Logger.LogWarning("Undefined BaseItemKind to Type mapping: {BaseItemKind}", excludeType); + } + } + + // Remove trailing comma. + whereBuilder.Length--; + whereBuilder.Append(')'); + whereClauses.Add(whereBuilder.ToString()); } } else if (includeTypes.Length == 1) { - whereClauses.Add("type=@type"); - statement?.TryBind("@type", includeTypes[0]); + if (_baseItemKindNames.TryGetValue(includeTypes[0], out var includeTypeName)) + { + whereClauses.Add("type=@type"); + statement?.TryBind("@type", includeTypeName); + } + else + { + Logger.LogWarning("Undefined BaseItemKind to Type mapping: {BaseItemKind}", includeTypes[0]); + } } else if (includeTypes.Length > 1) { - var inClause = string.Join(',', includeTypes.Select(i => "'" + i + "'")); - whereClauses.Add($"type in ({inClause})"); + var whereBuilder = new StringBuilder("type in ("); + foreach (var includeType in includeTypes) + { + if (_baseItemKindNames.TryGetValue(includeType, out var baseItemKindName)) + { + whereBuilder + .Append('\'') + .Append(baseItemKindName) + .Append("',"); + } + else + { + Logger.LogWarning("Undefined BaseItemKind to Type mapping: {BaseItemKind}", includeType); + } + } + + // Remove trailing comma. + whereBuilder.Length--; + whereBuilder.Append(')'); + whereClauses.Add(whereBuilder.ToString()); } if (query.ChannelIds.Count == 1) @@ -3878,7 +3975,7 @@ namespace Emby.Server.Implementations.Data if (query.IsPlayed.HasValue) { // We should probably figure this out for all folders, but for right now, this is the only place where we need it - if (query.IncludeItemTypes.Length == 1 && string.Equals(query.IncludeItemTypes[0], nameof(Series), StringComparison.OrdinalIgnoreCase)) + if (query.IncludeItemTypes.Length == 1 && query.IncludeItemTypes[0] == BaseItemKind.Series) { if (query.IsPlayed.Value) { @@ -4586,7 +4683,7 @@ namespace Emby.Server.Implementations.Data if (statement == null) { int index = 0; - string excludedTags = string.Join(',', query.ExcludeInheritedTags.Select(t => paramName + index++)); + string excludedTags = string.Join(',', query.ExcludeInheritedTags.Select(_ => paramName + index++)); whereClauses.Add("((select CleanValue from itemvalues where ItemId=Guid and Type=6 and cleanvalue in (" + excludedTags + ")) is null)"); } else @@ -4728,27 +4825,27 @@ namespace Emby.Server.Implementations.Data { var list = new List<string>(); - if (IsTypeInQuery(nameof(Person), query)) + if (IsTypeInQuery(BaseItemKind.Person, query)) { list.Add(typeof(Person).FullName); } - if (IsTypeInQuery(nameof(Genre), query)) + if (IsTypeInQuery(BaseItemKind.Genre, query)) { list.Add(typeof(Genre).FullName); } - if (IsTypeInQuery(nameof(MusicGenre), query)) + if (IsTypeInQuery(BaseItemKind.MusicGenre, query)) { list.Add(typeof(MusicGenre).FullName); } - if (IsTypeInQuery(nameof(MusicArtist), query)) + if (IsTypeInQuery(BaseItemKind.MusicArtist, query)) { list.Add(typeof(MusicArtist).FullName); } - if (IsTypeInQuery(nameof(Studio), query)) + if (IsTypeInQuery(BaseItemKind.Studio, query)) { list.Add(typeof(Studio).FullName); } @@ -4756,14 +4853,14 @@ namespace Emby.Server.Implementations.Data return list; } - private bool IsTypeInQuery(string type, InternalItemsQuery query) + private bool IsTypeInQuery(BaseItemKind type, InternalItemsQuery query) { - if (query.ExcludeItemTypes.Contains(type, StringComparer.OrdinalIgnoreCase)) + if (query.ExcludeItemTypes.Contains(type)) { return false; } - return query.IncludeItemTypes.Length == 0 || query.IncludeItemTypes.Contains(type, StringComparer.OrdinalIgnoreCase); + return query.IncludeItemTypes.Length == 0 || query.IncludeItemTypes.Contains(type); } private string GetCleanValue(string value) @@ -4803,12 +4900,12 @@ namespace Emby.Server.Implementations.Data return true; } - 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)) + if (query.IncludeItemTypes.Contains(BaseItemKind.Episode) + || query.IncludeItemTypes.Contains(BaseItemKind.Video) + || query.IncludeItemTypes.Contains(BaseItemKind.Movie) + || query.IncludeItemTypes.Contains(BaseItemKind.MusicVideo) + || query.IncludeItemTypes.Contains(BaseItemKind.Series) + || query.IncludeItemTypes.Contains(BaseItemKind.Season)) { return true; } @@ -4816,40 +4913,6 @@ 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() { string sql = string.Join( @@ -4869,47 +4932,14 @@ where AncestorIdText not null and ItemValues.Value not null and ItemValues.Type using (var connection = GetConnection()) { connection.RunInTransaction( - db => - { - connection.ExecuteAll(sql); - }, TransactionMode); + db => + { + connection.ExecuteAll(sql); + }, + TransactionMode); } } - private static Dictionary<string, string> GetTypeMapDictionary() - { - var dict = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase); - - foreach (var t in _knownTypes) - { - dict[t.Name] = t.FullName; - } - - dict["Program"] = typeof(LiveTvProgram).FullName; - dict["TvChannel"] = typeof(LiveTvChannel).FullName; - - return dict; - } - - // Not crazy about having this all the way down here, but at least it's in one place - private readonly Dictionary<string, string> _types = GetTypeMapDictionary(); - - private string MapIncludeItemTypes(string value) - { - if (_types.TryGetValue(value, out string result)) - { - return result; - } - - if (IsValidType(value)) - { - return value; - } - - Logger.LogWarning("Unknown item type: {ItemType}", value); - return null; - } - public void DeleteItem(Guid id) { if (id == Guid.Empty) @@ -4922,28 +4952,29 @@ where AncestorIdText not null and ItemValues.Value not null and ItemValues.Type using (var connection = GetConnection()) { connection.RunInTransaction( - db => - { - var idBlob = id.ToByteArray(); + db => + { + var idBlob = id.ToByteArray(); - // Delete people - ExecuteWithSingleParam(db, "delete from People where ItemId=@Id", idBlob); + // Delete people + ExecuteWithSingleParam(db, "delete from People where ItemId=@Id", idBlob); - // Delete chapters - ExecuteWithSingleParam(db, "delete from " + ChaptersTableName + " where ItemId=@Id", idBlob); + // Delete chapters + ExecuteWithSingleParam(db, "delete from " + ChaptersTableName + " where ItemId=@Id", idBlob); - // Delete media streams - ExecuteWithSingleParam(db, "delete from mediastreams where ItemId=@Id", idBlob); + // Delete media streams + ExecuteWithSingleParam(db, "delete from mediastreams where ItemId=@Id", idBlob); - // Delete ancestors - ExecuteWithSingleParam(db, "delete from AncestorIds where ItemId=@Id", idBlob); + // Delete ancestors + ExecuteWithSingleParam(db, "delete from AncestorIds where ItemId=@Id", idBlob); - // Delete item values - ExecuteWithSingleParam(db, "delete from ItemValues where ItemId=@Id", idBlob); + // Delete item values + ExecuteWithSingleParam(db, "delete from ItemValues where ItemId=@Id", idBlob); - // Delete the item - ExecuteWithSingleParam(db, "delete from TypedBaseItems where guid=@Id", idBlob); - }, TransactionMode); + // Delete the item + ExecuteWithSingleParam(db, "delete from TypedBaseItems where guid=@Id", idBlob); + }, + TransactionMode); } } @@ -5178,32 +5209,32 @@ AND Type = @InternalPersonType)"); } } - public QueryResult<(BaseItem, ItemCounts)> GetAllArtists(InternalItemsQuery query) + public QueryResult<(BaseItem Item, ItemCounts ItemCounts)> GetAllArtists(InternalItemsQuery query) { return GetItemValues(query, new[] { 0, 1 }, typeof(MusicArtist).FullName); } - public QueryResult<(BaseItem, ItemCounts)> GetArtists(InternalItemsQuery query) + public QueryResult<(BaseItem Item, ItemCounts ItemCounts)> GetArtists(InternalItemsQuery query) { return GetItemValues(query, new[] { 0 }, typeof(MusicArtist).FullName); } - public QueryResult<(BaseItem, ItemCounts)> GetAlbumArtists(InternalItemsQuery query) + public QueryResult<(BaseItem Item, ItemCounts ItemCounts)> GetAlbumArtists(InternalItemsQuery query) { return GetItemValues(query, new[] { 1 }, typeof(MusicArtist).FullName); } - public QueryResult<(BaseItem, ItemCounts)> GetStudios(InternalItemsQuery query) + public QueryResult<(BaseItem Item, ItemCounts ItemCounts)> GetStudios(InternalItemsQuery query) { return GetItemValues(query, new[] { 3 }, typeof(Studio).FullName); } - public QueryResult<(BaseItem, ItemCounts)> GetGenres(InternalItemsQuery query) + public QueryResult<(BaseItem Item, ItemCounts ItemCounts)> GetGenres(InternalItemsQuery query) { return GetItemValues(query, new[] { 2 }, typeof(Genre).FullName); } - public QueryResult<(BaseItem, ItemCounts)> GetMusicGenres(InternalItemsQuery query) + public QueryResult<(BaseItem Item, ItemCounts ItemCounts)> GetMusicGenres(InternalItemsQuery query) { return GetItemValues(query, new[] { 2 }, typeof(MusicGenre).FullName); } @@ -5299,7 +5330,7 @@ AND Type = @InternalPersonType)"); return list; } - private QueryResult<(BaseItem, ItemCounts)> GetItemValues(InternalItemsQuery query, int[] itemValueTypes, string returnType) + private QueryResult<(BaseItem Item, ItemCounts ItemCounts)> GetItemValues(InternalItemsQuery query, int[] itemValueTypes, string returnType) { if (query == null) { @@ -5355,7 +5386,7 @@ AND Type = @InternalPersonType)"); stringBuilder.Clear(); } - List<string> columns = _retriveItemColumns.ToList(); + List<string> columns = _retrieveItemColumns.ToList(); // Unfortunately we need to add it to columns to ensure the order of the columns in the select if (!string.IsNullOrEmpty(itemCountColumns)) { @@ -5573,7 +5604,7 @@ AND Type = @InternalPersonType)"); return result; } - private static ItemCounts GetItemCounts(IReadOnlyList<ResultSetValue> reader, int countStartColumn, string[] typesToCount) + private static ItemCounts GetItemCounts(IReadOnlyList<ResultSetValue> reader, int countStartColumn, BaseItemKind[] typesToCount) { var counts = new ItemCounts(); @@ -5624,7 +5655,7 @@ AND Type = @InternalPersonType)"); return counts; } - private List<(int, string)> GetItemValuesToSave(BaseItem item, List<string> inheritedTags) + private List<(int MagicNumber, string Value)> GetItemValuesToSave(BaseItem item, List<string> inheritedTags) { var list = new List<(int, string)>(); @@ -5649,7 +5680,7 @@ AND Type = @InternalPersonType)"); return list; } - private void UpdateItemValues(Guid itemId, List<(int, string)> values, IDatabaseConnection db) + private void UpdateItemValues(Guid itemId, List<(int MagicNumber, string Value)> values, IDatabaseConnection db) { if (itemId.Equals(Guid.Empty)) { @@ -5671,7 +5702,7 @@ AND Type = @InternalPersonType)"); InsertItemValues(guidBlob, values, db); } - private void InsertItemValues(byte[] idBlob, List<(int, string)> values, IDatabaseConnection db) + private void InsertItemValues(byte[] idBlob, List<(int MagicNumber, string Value)> values, IDatabaseConnection db) { const int Limit = 100; var startIndex = 0; @@ -5703,7 +5734,7 @@ AND Type = @InternalPersonType)"); var currentValueInfo = values[i]; - var itemValue = currentValueInfo.Item2; + var itemValue = currentValueInfo.Value; // Don't save if invalid if (string.IsNullOrWhiteSpace(itemValue)) @@ -5711,7 +5742,7 @@ AND Type = @InternalPersonType)"); continue; } - statement.TryBind("@Type" + index, currentValueInfo.Item1); + statement.TryBind("@Type" + index, currentValueInfo.MagicNumber); statement.TryBind("@Value" + index, itemValue); statement.TryBind("@CleanValue" + index, GetCleanValue(itemValue)); } @@ -5742,15 +5773,16 @@ AND Type = @InternalPersonType)"); using (var connection = GetConnection()) { connection.RunInTransaction( - db => - { - var itemIdBlob = itemId.ToByteArray(); + db => + { + var itemIdBlob = itemId.ToByteArray(); - // First delete chapters - db.Execute("delete from People where ItemId=@ItemId", itemIdBlob); + // First delete chapters + db.Execute("delete from People where ItemId=@ItemId", itemIdBlob); - InsertPeople(itemIdBlob, people, db); - }, TransactionMode); + InsertPeople(itemIdBlob, people, db); + }, + TransactionMode); } } @@ -5908,7 +5940,8 @@ AND Type = @InternalPersonType)"); db.Execute("delete from mediastreams where ItemId=@ItemId", itemIdBlob); InsertMediaStreams(itemIdBlob, streams, db); - }, TransactionMode); + }, + TransactionMode); } } @@ -6242,7 +6275,8 @@ AND Type = @InternalPersonType)"); db.Execute("delete from mediaattachments where ItemId=@ItemId", itemIdBlob); InsertMediaAttachments(itemIdBlob, attachments, db, cancellationToken); - }, TransactionMode); + }, + TransactionMode); } } diff --git a/Emby.Server.Implementations/Data/SqliteUserDataRepository.cs b/Emby.Server.Implementations/Data/SqliteUserDataRepository.cs index 829f1de2f..80b8f9ebf 100644 --- a/Emby.Server.Implementations/Data/SqliteUserDataRepository.cs +++ b/Emby.Server.Implementations/Data/SqliteUserDataRepository.cs @@ -32,6 +32,9 @@ namespace Emby.Server.Implementations.Data /// <summary> /// Opens the connection to the database. /// </summary> + /// <param name="userManager">The user manager.</param> + /// <param name="dbLock">The lock to use for database IO.</param> + /// <param name="dbConnection">The connection to use for database IO.</param> public void Initialize(IUserManager userManager, SemaphoreSlim dbLock, SQLiteDatabaseConnection dbConnection) { WriteLock.Dispose(); @@ -47,41 +50,42 @@ namespace Emby.Server.Implementations.Data var users = userDatasTableExists ? null : userManager.Users; connection.RunInTransaction( - db => - { - 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", - "drop index if exists idx_userdata1", - "drop index if exists idx_userdata2", - "drop index if exists userdataindex1", - "drop index if exists userdataindex", - "drop index if exists userdataindex3", - "drop index if exists userdataindex4", - "create unique index if not exists UserDatasIndex1 on UserDatas (key, userId)", - "create index if not exists UserDatasIndex2 on UserDatas (key, userId, played)", - "create index if not exists UserDatasIndex3 on UserDatas (key, userId, playbackPositionTicks)", - "create index if not exists UserDatasIndex4 on UserDatas (key, userId, isFavorite)" - })); - - if (userDataTableExists) + db => { - var existingColumnNames = GetColumnNames(db, "userdata"); - - AddColumn(db, "userdata", "InternalUserId", "int", existingColumnNames); - AddColumn(db, "userdata", "AudioStreamIndex", "int", existingColumnNames); - AddColumn(db, "userdata", "SubtitleStreamIndex", "int", existingColumnNames); - - if (!userDatasTableExists) + db.ExecuteAll(string.Join(';', new[] { - ImportUserIds(db, users); + "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)", - db.ExecuteAll("INSERT INTO UserDatas (key, userId, rating, played, playCount, isFavorite, playbackPositionTicks, lastPlayedDate, AudioStreamIndex, SubtitleStreamIndex) SELECT key, InternalUserId, rating, played, playCount, isFavorite, playbackPositionTicks, lastPlayedDate, AudioStreamIndex, SubtitleStreamIndex from userdata where InternalUserId not null"); + "drop index if exists idx_userdata", + "drop index if exists idx_userdata1", + "drop index if exists idx_userdata2", + "drop index if exists userdataindex1", + "drop index if exists userdataindex", + "drop index if exists userdataindex3", + "drop index if exists userdataindex4", + "create unique index if not exists UserDatasIndex1 on UserDatas (key, userId)", + "create index if not exists UserDatasIndex2 on UserDatas (key, userId, played)", + "create index if not exists UserDatasIndex3 on UserDatas (key, userId, playbackPositionTicks)", + "create index if not exists UserDatasIndex4 on UserDatas (key, userId, isFavorite)" + })); + + if (userDataTableExists) + { + var existingColumnNames = GetColumnNames(db, "userdata"); + + AddColumn(db, "userdata", "InternalUserId", "int", existingColumnNames); + AddColumn(db, "userdata", "AudioStreamIndex", "int", existingColumnNames); + AddColumn(db, "userdata", "SubtitleStreamIndex", "int", existingColumnNames); + + if (!userDatasTableExists) + { + ImportUserIds(db, users); + + db.ExecuteAll("INSERT INTO UserDatas (key, userId, rating, played, playCount, isFavorite, playbackPositionTicks, lastPlayedDate, AudioStreamIndex, SubtitleStreamIndex) SELECT key, InternalUserId, rating, played, playCount, isFavorite, playbackPositionTicks, lastPlayedDate, AudioStreamIndex, SubtitleStreamIndex from userdata where InternalUserId not null"); + } } - } - }, TransactionMode); + }, + TransactionMode); } } @@ -180,10 +184,11 @@ namespace Emby.Server.Implementations.Data using (var connection = GetConnection()) { connection.RunInTransaction( - db => - { - SaveUserData(db, internalUserId, key, userData); - }, TransactionMode); + db => + { + SaveUserData(db, internalUserId, key, userData); + }, + TransactionMode); } } @@ -249,13 +254,14 @@ namespace Emby.Server.Implementations.Data using (var connection = GetConnection()) { connection.RunInTransaction( - db => - { - foreach (var userItemData in userDataList) + db => { - SaveUserData(db, internalUserId, userItemData.Key, userItemData); - } - }, TransactionMode); + foreach (var userItemData in userDataList) + { + SaveUserData(db, internalUserId, userItemData.Key, userItemData); + } + }, + TransactionMode); } } diff --git a/Emby.Server.Implementations/Data/SynchronouseMode.cs b/Emby.Server.Implementations/Data/SynchronouseMode.cs new file mode 100644 index 000000000..cde524e2e --- /dev/null +++ b/Emby.Server.Implementations/Data/SynchronouseMode.cs @@ -0,0 +1,30 @@ +namespace Emby.Server.Implementations.Data; + +/// <summary> +/// The disk synchronization mode, controls how aggressively SQLite will write data +/// all the way out to physical storage. +/// </summary> +public enum SynchronousMode +{ + /// <summary> + /// SQLite continues without syncing as soon as it has handed data off to the operating system. + /// </summary> + Off = 0, + + /// <summary> + /// SQLite database engine will still sync at the most critical moments. + /// </summary> + Normal = 1, + + /// <summary> + /// 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. + /// </summary> + Full = 2, + + /// <summary> + /// 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. + /// </summary> + Extra = 3 +} diff --git a/Emby.Server.Implementations/Data/TempStoreMode.cs b/Emby.Server.Implementations/Data/TempStoreMode.cs new file mode 100644 index 000000000..d2427ce47 --- /dev/null +++ b/Emby.Server.Implementations/Data/TempStoreMode.cs @@ -0,0 +1,23 @@ +namespace Emby.Server.Implementations.Data; + +/// <summary> +/// Storage mode used by temporary database files. +/// </summary> +public enum TempStoreMode +{ + /// <summary> + /// The compile-time C preprocessor macro SQLITE_TEMP_STORE + /// is used to determine where temporary tables and indices are stored. + /// </summary> + Default = 0, + + /// <summary> + /// Temporary tables and indices are stored in a file. + /// </summary> + File = 1, + + /// <summary> + /// Temporary tables and indices are kept in as if they were pure in-memory databases memory. + /// </summary> + Memory = 2 +} diff --git a/Emby.Server.Implementations/Devices/DeviceId.cs b/Emby.Server.Implementations/Devices/DeviceId.cs index 3d15b3e76..b3f5549bc 100644 --- a/Emby.Server.Implementations/Devices/DeviceId.cs +++ b/Emby.Server.Implementations/Devices/DeviceId.cs @@ -15,9 +15,18 @@ namespace Emby.Server.Implementations.Devices { private readonly IApplicationPaths _appPaths; private readonly ILogger<DeviceId> _logger; - private readonly object _syncLock = new object(); + private string _id; + + public DeviceId(IApplicationPaths appPaths, ILoggerFactory loggerFactory) + { + _appPaths = appPaths; + _logger = loggerFactory.CreateLogger<DeviceId>(); + } + + public string Value => _id ?? (_id = GetDeviceId()); + private string CachePath => Path.Combine(_appPaths.DataPath, "device.txt"); private string GetCachedId() @@ -28,7 +37,7 @@ namespace Emby.Server.Implementations.Devices { var value = File.ReadAllText(CachePath, Encoding.UTF8); - if (Guid.TryParse(value, out var guid)) + if (Guid.TryParse(value, out _)) { return value; } @@ -86,15 +95,5 @@ namespace Emby.Server.Implementations.Devices return id; } - - private string _id; - - public DeviceId(IApplicationPaths appPaths, ILoggerFactory loggerFactory) - { - _appPaths = appPaths; - _logger = loggerFactory.CreateLogger<DeviceId>(); - } - - public string Value => _id ?? (_id = GetDeviceId()); } } diff --git a/Emby.Server.Implementations/Dto/DtoService.cs b/Emby.Server.Implementations/Dto/DtoService.cs index 74400b512..7ba34e74a 100644 --- a/Emby.Server.Implementations/Dto/DtoService.cs +++ b/Emby.Server.Implementations/Dto/DtoService.cs @@ -7,9 +7,9 @@ using System.Collections.Generic; using System.Globalization; using System.IO; using System.Linq; -using System.Threading.Tasks; using Jellyfin.Data.Entities; using Jellyfin.Data.Enums; +using Jellyfin.Extensions; using MediaBrowser.Common; using MediaBrowser.Controller.Channels; using MediaBrowser.Controller.Drawing; @@ -109,7 +109,7 @@ namespace Emby.Server.Implementations.Dto } }); - SetItemByNameInfo(item, dto, libraryItems, user); + SetItemByNameInfo(item, dto, libraryItems); } } @@ -134,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 @@ -156,8 +153,7 @@ namespace Emby.Server.Implementations.Dto new DtoOptions(false) { EnableImages = false - }), - user); + })); } return dto; @@ -297,7 +293,7 @@ namespace Emby.Server.Implementations.Dto path = path.TrimStart('.'); } - if (!string.IsNullOrEmpty(path) && containers.Contains(path, StringComparer.OrdinalIgnoreCase)) + if (!string.IsNullOrEmpty(path) && containers.Contains(path, StringComparison.OrdinalIgnoreCase)) { fileExtensionContainer = path; } @@ -314,13 +310,13 @@ namespace Emby.Server.Implementations.Dto if (taggedItems != null && options.ContainsField(ItemFields.ItemCounts)) { - SetItemByNameInfo(item, dto, taggedItems, user); + SetItemByNameInfo(item, dto, taggedItems); } return dto; } - private static void SetItemByNameInfo(BaseItem item, BaseItemDto dto, IList<BaseItem> taggedItems, User user = null) + private static void SetItemByNameInfo(BaseItem item, BaseItemDto dto, IList<BaseItem> taggedItems) { if (item is MusicArtist) { @@ -373,6 +369,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)) @@ -420,7 +422,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); @@ -467,7 +469,7 @@ namespace Emby.Server.Implementations.Dto { var parentAlbumIds = _libraryManager.GetItemIds(new InternalItemsQuery { - IncludeItemTypes = new[] { nameof(MusicAlbum) }, + IncludeItemTypes = new[] { BaseItemKind.MusicAlbum }, Name = item.Album, Limit = 1 }); @@ -497,7 +499,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; } } @@ -755,15 +757,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; @@ -1410,44 +1403,27 @@ namespace Emby.Server.Implementations.Dto return null; } - ImageDimensions size; - - var defaultAspectRatio = item.GetDefaultPrimaryImageAspectRatio(); - - if (defaultAspectRatio > 0) - { - return defaultAspectRatio; - } - if (!imageInfo.IsLocalFile) { - return null; + return item.GetDefaultPrimaryImageAspectRatio(); } try { - size = _imageProcessor.GetImageDimensions(item, imageInfo); - - if (size.Width <= 0 || size.Height <= 0) + var size = _imageProcessor.GetImageDimensions(item, imageInfo); + var width = size.Width; + var height = size.Height; + if (width > 0 && height > 0) { - return null; + return (double)width / height; } } catch (Exception ex) { - _logger.LogError(ex, "Failed to determine primary image aspect ratio for {0}", imageInfo.Path); - return null; + _logger.LogError(ex, "Failed to determine primary image aspect ratio for {ImagePath}", imageInfo.Path); } - var width = size.Width; - var height = size.Height; - - if (width <= 0 || height <= 0) - { - return null; - } - - return (double)width / height; + return item.GetDefaultPrimaryImageAspectRatio(); } } } diff --git a/Emby.Server.Implementations/Emby.Server.Implementations.csproj b/Emby.Server.Implementations/Emby.Server.Implementations.csproj index ad4ad89d1..1e09a98cf 100644 --- a/Emby.Server.Implementations/Emby.Server.Implementations.csproj +++ b/Emby.Server.Implementations/Emby.Server.Implementations.csproj @@ -23,18 +23,18 @@ </ItemGroup> <ItemGroup> - <PackageReference Include="DiscUtils.Udf" Version="0.16.4" /> + <PackageReference Include="DiscUtils.Udf" Version="0.16.13" /> <PackageReference Include="Jellyfin.XmlTv" Version="10.6.2" /> - <PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="5.0.2" /> - <PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="5.0.0" /> - <PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="5.0.0" /> - <PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="5.0.0" /> - <PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="5.0.9" /> - <PackageReference Include="Mono.Nat" Version="3.0.1" /> - <PackageReference Include="prometheus-net.DotNetRuntime" Version="4.2.1" /> - <PackageReference Include="sharpcompress" Version="0.28.3" /> + <PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="6.0.0" /> + <PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="6.0.0" /> + <PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="6.0.0" /> + <PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="6.0.0" /> + <PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="6.0.1" /> + <PackageReference Include="Mono.Nat" Version="3.0.2" /> + <PackageReference Include="prometheus-net.DotNetRuntime" Version="4.2.2" /> + <PackageReference Include="sharpcompress" Version="0.30.1" /> <PackageReference Include="SQLitePCL.pretty.netstandard" Version="3.1.0" /> - <PackageReference Include="DotNet.Glob" Version="3.1.2" /> + <PackageReference Include="DotNet.Glob" Version="3.1.3" /> </ItemGroup> <ItemGroup> @@ -42,22 +42,21 @@ </ItemGroup> <PropertyGroup> - <TargetFramework>net5.0</TargetFramework> + <TargetFramework>net6.0</TargetFramework> <GenerateAssemblyInfo>false</GenerateAssemblyInfo> <GenerateDocumentationFile>true</GenerateDocumentationFile> <!-- https://github.com/microsoft/ApplicationInsights-dotnet/issues/2047 --> <NoWarn>AD0001</NoWarn> - <TreatWarningsAsErrors>false</TreatWarningsAsErrors> </PropertyGroup> - <PropertyGroup Condition=" '$(Configuration)' == 'Release'"> - <TreatWarningsAsErrors>true</TreatWarningsAsErrors> + <PropertyGroup Condition=" '$(Configuration)' == 'Debug' "> + <TreatWarningsAsErrors>false</TreatWarningsAsErrors> </PropertyGroup> <!-- Code Analyzers--> <ItemGroup Condition=" '$(Configuration)' == 'Debug' "> <PackageReference Include="SerilogAnalyzer" Version="0.15.0" PrivateAssets="All" /> - <PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="All" /> + <PackageReference Include="StyleCop.Analyzers" Version="1.2.0-beta.376" PrivateAssets="All" /> <PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" PrivateAssets="All" /> </ItemGroup> diff --git a/Emby.Server.Implementations/EntryPoints/ExternalPortForwarding.cs b/Emby.Server.Implementations/EntryPoints/ExternalPortForwarding.cs index 640754af4..06e57ad12 100644 --- a/Emby.Server.Implementations/EntryPoints/ExternalPortForwarding.cs +++ b/Emby.Server.Implementations/EntryPoints/ExternalPortForwarding.cs @@ -13,7 +13,6 @@ using Jellyfin.Networking.Configuration; using MediaBrowser.Controller; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Plugins; -using MediaBrowser.Model.Dlna; using Microsoft.Extensions.Logging; using Mono.Nat; @@ -27,7 +26,6 @@ namespace Emby.Server.Implementations.EntryPoints private readonly IServerApplicationHost _appHost; private readonly ILogger<ExternalPortForwarding> _logger; private readonly IServerConfigurationManager _config; - private readonly IDeviceDiscovery _deviceDiscovery; private readonly ConcurrentDictionary<IPEndPoint, byte> _createdRules = new ConcurrentDictionary<IPEndPoint, byte>(); @@ -42,17 +40,14 @@ namespace Emby.Server.Implementations.EntryPoints /// <param name="logger">The logger.</param> /// <param name="appHost">The application host.</param> /// <param name="config">The configuration manager.</param> - /// <param name="deviceDiscovery">The device discovery.</param> public ExternalPortForwarding( ILogger<ExternalPortForwarding> logger, IServerApplicationHost appHost, - IServerConfigurationManager config, - IDeviceDiscovery deviceDiscovery) + IServerConfigurationManager config) { _logger = logger; _appHost = appHost; _config = config; - _deviceDiscovery = deviceDiscovery; } private string GetConfigIdentifier() diff --git a/Emby.Server.Implementations/EntryPoints/LibraryChangedNotifier.cs b/Emby.Server.Implementations/EntryPoints/LibraryChangedNotifier.cs index df48346e3..d43996c69 100644 --- a/Emby.Server.Implementations/EntryPoints/LibraryChangedNotifier.cs +++ b/Emby.Server.Implementations/EntryPoints/LibraryChangedNotifier.cs @@ -101,7 +101,7 @@ namespace Emby.Server.Implementations.EntryPoints } } - _lastProgressMessageTimes.AddOrUpdate(item.Id, key => DateTime.UtcNow, (key, existing) => DateTime.UtcNow); + _lastProgressMessageTimes.AddOrUpdate(item.Id, _ => DateTime.UtcNow, (_, _) => DateTime.UtcNow); var dict = new Dictionary<string, string>(); dict["ItemId"] = item.Id.ToString("N", CultureInfo.InvariantCulture); @@ -144,7 +144,7 @@ namespace Emby.Server.Implementations.EntryPoints { OnProviderRefreshProgress(sender, new GenericEventArgs<Tuple<BaseItem, double>>(new Tuple<BaseItem, double>(e.Argument, 100))); - _lastProgressMessageTimes.TryRemove(e.Argument.Id, out DateTime removed); + _lastProgressMessageTimes.TryRemove(e.Argument.Id, out _); } private static bool EnableRefreshMessage(BaseItem item) @@ -423,7 +423,6 @@ namespace Emby.Server.Implementations.EntryPoints continue; } - var collectionFolders = _libraryManager.GetCollectionFolders(item, allUserRootChildren); foreach (var folder in allUserRootChildren) { list.Add(folder.Id.ToString("N", CultureInfo.InvariantCulture)); @@ -436,7 +435,7 @@ namespace Emby.Server.Implementations.EntryPoints /// <summary> /// Translates the physical item to user library. /// </summary> - /// <typeparam name="T"></typeparam> + /// <typeparam name="T">The type of item.</typeparam> /// <param name="item">The item.</param> /// <param name="user">The user.</param> /// <param name="includeIfNotFound">if set to <c>true</c> [include if not found].</param> @@ -465,6 +464,7 @@ namespace Emby.Server.Implementations.EntryPoints public void Dispose() { Dispose(true); + GC.SuppressFinalize(this); } /// <summary> diff --git a/Emby.Server.Implementations/EntryPoints/UserDataChangeNotifier.cs b/Emby.Server.Implementations/EntryPoints/UserDataChangeNotifier.cs index d3bcd5e13..82c8d3ab6 100644 --- a/Emby.Server.Implementations/EntryPoints/UserDataChangeNotifier.cs +++ b/Emby.Server.Implementations/EntryPoints/UserDataChangeNotifier.cs @@ -95,7 +95,7 @@ namespace Emby.Server.Implementations.EntryPoints var changes = _changedItems.ToList(); _changedItems.Clear(); - var task = SendNotifications(changes, CancellationToken.None); + SendNotifications(changes, CancellationToken.None).GetAwaiter().GetResult(); if (_updateTimer != null) { diff --git a/Emby.Server.Implementations/HttpServer/Security/AuthService.cs b/Emby.Server.Implementations/HttpServer/Security/AuthService.cs index e2ad07177..1d04f3da3 100644 --- a/Emby.Server.Implementations/HttpServer/Security/AuthService.cs +++ b/Emby.Server.Implementations/HttpServer/Security/AuthService.cs @@ -2,7 +2,6 @@ using System.Threading.Tasks; using Jellyfin.Data.Enums; -using MediaBrowser.Controller.Authentication; using MediaBrowser.Controller.Net; using Microsoft.AspNetCore.Http; @@ -24,7 +23,7 @@ namespace Emby.Server.Implementations.HttpServer.Security 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/SessionContext.cs b/Emby.Server.Implementations/HttpServer/Security/SessionContext.cs index a7647caf9..bb6041f28 100644 --- a/Emby.Server.Implementations/HttpServer/Security/SessionContext.cs +++ b/Emby.Server.Implementations/HttpServer/Security/SessionContext.cs @@ -47,7 +47,7 @@ namespace Emby.Server.Implementations.HttpServer.Security { var session = await GetSession(requestContext).ConfigureAwait(false); - return session == null || session.UserId.Equals(Guid.Empty) ? null : _userManager.GetUserById(session.UserId); + return session.UserId.Equals(Guid.Empty) ? null : _userManager.GetUserById(session.UserId); } public Task<User?> GetUser(object requestContext) diff --git a/Emby.Server.Implementations/HttpServer/WebSocketConnection.cs b/Emby.Server.Implementations/HttpServer/WebSocketConnection.cs index 7010a6fb0..b87f1bc22 100644 --- a/Emby.Server.Implementations/HttpServer/WebSocketConnection.cs +++ b/Emby.Server.Implementations/HttpServer/WebSocketConnection.cs @@ -42,17 +42,14 @@ namespace Emby.Server.Implementations.HttpServer /// <param name="logger">The logger.</param> /// <param name="socket">The socket.</param> /// <param name="remoteEndPoint">The remote end point.</param> - /// <param name="query">The query.</param> public WebSocketConnection( ILogger<WebSocketConnection> logger, WebSocket socket, - IPAddress? remoteEndPoint, - IQueryCollection query) + IPAddress? remoteEndPoint) { _logger = logger; _socket = socket; RemoteEndPoint = remoteEndPoint; - QueryString = query; _jsonOptions = JsonDefaults.Options; LastActivityDate = DateTime.Now; @@ -81,12 +78,6 @@ namespace Emby.Server.Implementations.HttpServer /// <inheritdoc /> public DateTime LastKeepAliveDate { get; set; } - /// <summary> - /// Gets the query string. - /// </summary> - /// <value>The query string.</value> - public IQueryCollection QueryString { get; } - /// <summary> /// Gets the state. /// </summary> @@ -96,7 +87,7 @@ namespace Emby.Server.Implementations.HttpServer /// <summary> /// Sends a message asynchronously. /// </summary> - /// <typeparam name="T"></typeparam> + /// <typeparam name="T">The type of the message.</typeparam> /// <param name="message">The message.</param> /// <param name="cancellationToken">The cancellation token.</param> /// <returns>Task.</returns> @@ -150,8 +141,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); @@ -180,7 +171,7 @@ namespace Emby.Server.Implementations.HttpServer } WebSocketMessage<object>? stub; - long bytesConsumed = 0; + long bytesConsumed; try { stub = DeserializeWebSocketMessage(buffer, out bytesConsumed); @@ -236,7 +227,8 @@ namespace Emby.Server.Implementations.HttpServer { MessageId = Guid.NewGuid(), MessageType = SessionMessageType.KeepAlive - }, CancellationToken.None); + }, + CancellationToken.None); } /// <inheritdoc /> diff --git a/Emby.Server.Implementations/HttpServer/WebSocketManager.cs b/Emby.Server.Implementations/HttpServer/WebSocketManager.cs index f86bfd755..4f7d1c40a 100644 --- a/Emby.Server.Implementations/HttpServer/WebSocketManager.cs +++ b/Emby.Server.Implementations/HttpServer/WebSocketManager.cs @@ -7,6 +7,7 @@ using System.Collections.Generic; using System.Linq; using System.Net.WebSockets; using System.Threading.Tasks; +using MediaBrowser.Common.Extensions; using MediaBrowser.Controller.Net; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; @@ -35,7 +36,12 @@ namespace Emby.Server.Implementations.HttpServer /// <inheritdoc /> public async Task WebSocketRequestHandler(HttpContext context) { - _ = await _authService.Authenticate(context.Request).ConfigureAwait(false); + 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); @@ -45,8 +51,7 @@ namespace Emby.Server.Implementations.HttpServer using var connection = new WebSocketConnection( _loggerFactory.CreateLogger<WebSocketConnection>(), webSocket, - context.Connection.RemoteIpAddress, - context.Request.Query) + context.GetNormalizedRemoteIp()) { OnReceive = ProcessWebSocketMessageReceived }; @@ -54,7 +59,7 @@ namespace Emby.Server.Implementations.HttpServer var tasks = new Task[_webSocketListeners.Length]; for (var i = 0; i < _webSocketListeners.Length; ++i) { - tasks[i] = _webSocketListeners[i].ProcessWebSocketConnectedAsync(connection); + tasks[i] = _webSocketListeners[i].ProcessWebSocketConnectedAsync(connection, context); } await Task.WhenAll(tasks).ConfigureAwait(false); diff --git a/Emby.Server.Implementations/IO/FileRefresher.cs b/Emby.Server.Implementations/IO/FileRefresher.cs index 47a83d77c..6326208f7 100644 --- a/Emby.Server.Implementations/IO/FileRefresher.cs +++ b/Emby.Server.Implementations/IO/FileRefresher.cs @@ -1,5 +1,3 @@ -#nullable disable - #pragma warning disable CS1591 using System; @@ -14,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; @@ -22,7 +20,7 @@ namespace Emby.Server.Implementations.IO private readonly List<string> _affectedPaths = new List<string>(); 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) @@ -36,7 +34,7 @@ namespace Emby.Server.Implementations.IO AddPath(path); } - public event EventHandler<EventArgs> Completed; + public event EventHandler<EventArgs>? Completed; public string Path { get; private set; } @@ -111,7 +109,7 @@ namespace Emby.Server.Implementations.IO RestartTimer(); } - private void OnTimerCallback(object state) + private void OnTimerCallback(object? state) { List<string> paths; @@ -127,7 +125,7 @@ namespace Emby.Server.Implementations.IO try { - ProcessPathChanges(paths.ToList()); + ProcessPathChanges(paths); } catch (Exception ex) { @@ -137,12 +135,12 @@ namespace Emby.Server.Implementations.IO private void ProcessPathChanges(List<string> paths) { - var itemsToRefresh = paths + IEnumerable<BaseItem> 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) { @@ -176,15 +174,15 @@ namespace Emby.Server.Implementations.IO /// </summary> /// <param name="path">The path.</param> /// <returns>BaseItem.</returns> - 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) @@ -219,8 +217,13 @@ namespace Emby.Server.Implementations.IO /// <inheritdoc /> public void Dispose() { - _disposed = true; + if (_disposed) + { + return; + } + DisposeTimer(); + _disposed = true; GC.SuppressFinalize(this); } } diff --git a/Emby.Server.Implementations/IO/LibraryMonitor.cs b/Emby.Server.Implementations/IO/LibraryMonitor.cs index aa80bccd7..657daac3f 100644 --- a/Emby.Server.Implementations/IO/LibraryMonitor.cs +++ b/Emby.Server.Implementations/IO/LibraryMonitor.cs @@ -41,6 +41,25 @@ namespace Emby.Server.Implementations.IO private bool _disposed = false; + /// <summary> + /// Initializes a new instance of the <see cref="LibraryMonitor" /> class. + /// </summary> + /// <param name="logger">The logger.</param> + /// <param name="libraryManager">The library manager.</param> + /// <param name="configurationManager">The configuration manager.</param> + /// <param name="fileSystem">The filesystem.</param> + public LibraryMonitor( + ILogger<LibraryMonitor> logger, + ILibraryManager libraryManager, + IServerConfigurationManager configurationManager, + IFileSystem fileSystem) + { + _libraryManager = libraryManager; + _logger = logger; + _configurationManager = configurationManager; + _fileSystem = fileSystem; + } + /// <summary> /// Add the path to our temporary ignore list. Use when writing to a path within our listening scope. /// </summary> @@ -80,7 +99,7 @@ namespace Emby.Server.Implementations.IO // But if we make this delay too high, we risk missing legitimate changes, such as user adding a new file, or hand-editing metadata await Task.Delay(45000).ConfigureAwait(false); - _tempIgnoredPaths.TryRemove(path, out var val); + _tempIgnoredPaths.TryRemove(path, out _); if (refreshPath) { @@ -95,21 +114,6 @@ namespace Emby.Server.Implementations.IO } } - /// <summary> - /// Initializes a new instance of the <see cref="LibraryMonitor" /> class. - /// </summary> - public LibraryMonitor( - ILogger<LibraryMonitor> logger, - ILibraryManager libraryManager, - IServerConfigurationManager configurationManager, - IFileSystem fileSystem) - { - _libraryManager = libraryManager; - _logger = logger; - _configurationManager = configurationManager; - _fileSystem = fileSystem; - } - private bool IsLibraryMonitorEnabled(BaseItem item) { if (item is BasePluginFolder) @@ -199,7 +203,7 @@ namespace Emby.Server.Implementations.IO /// <param name="lst">The LST.</param> /// <param name="path">The path.</param> /// <returns><c>true</c> if [contains parent folder] [the specified LST]; otherwise, <c>false</c>.</returns> - /// <exception cref="ArgumentNullException">path</exception> + /// <exception cref="ArgumentNullException"><paramref name="path"/> is <c>null</c>.</exception> private static bool ContainsParentFolder(IEnumerable<string> lst, string path) { if (string.IsNullOrEmpty(path)) @@ -263,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 { @@ -272,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); } }); } @@ -445,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); @@ -477,6 +481,7 @@ namespace Emby.Server.Implementations.IO { lock (_activeRefreshers) { + refresher.Completed -= OnNewRefresherCompleted; refresher.Dispose(); _activeRefreshers.Remove(refresher); } @@ -488,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 1bc229b0c..5c86dbbb7 100644 --- a/Emby.Server.Implementations/IO/ManagedFileSystem.cs +++ b/Emby.Server.Implementations/IO/ManagedFileSystem.cs @@ -1,5 +1,3 @@ -#pragma warning disable CS1591 - using System; using System.Collections.Generic; using System.Globalization; @@ -17,20 +15,26 @@ namespace Emby.Server.Implementations.IO /// </summary> public class ManagedFileSystem : IFileSystem { - protected ILogger<ManagedFileSystem> Logger; + private readonly ILogger<ManagedFileSystem> _logger; private readonly List<IShortcutHandler> _shortcutHandlers = new List<IShortcutHandler>(); private readonly string _tempPath; private static readonly bool _isEnvironmentCaseInsensitive = OperatingSystem.IsWindows(); + /// <summary> + /// Initializes a new instance of the <see cref="ManagedFileSystem"/> class. + /// </summary> + /// <param name="logger">The <see cref="ILogger"/> instance to use.</param> + /// <param name="applicationPaths">The <see cref="IApplicationPaths"/> instance to use.</param> public ManagedFileSystem( ILogger<ManagedFileSystem> logger, IApplicationPaths applicationPaths) { - Logger = logger; + _logger = logger; _tempPath = applicationPaths.TempDirectory; } + /// <inheritdoc /> public virtual void AddShortcutHandler(IShortcutHandler handler) { _shortcutHandlers.Add(handler); @@ -41,7 +45,7 @@ namespace Emby.Server.Implementations.IO /// </summary> /// <param name="filename">The filename.</param> /// <returns><c>true</c> if the specified filename is shortcut; otherwise, <c>false</c>.</returns> - /// <exception cref="ArgumentNullException">filename</exception> + /// <exception cref="ArgumentNullException"><paramref name="filename"/> is <c>null</c>.</exception> public virtual bool IsShortcut(string filename) { if (string.IsNullOrEmpty(filename)) @@ -58,7 +62,7 @@ namespace Emby.Server.Implementations.IO /// </summary> /// <param name="filename">The filename.</param> /// <returns>System.String.</returns> - /// <exception cref="ArgumentNullException">filename</exception> + /// <exception cref="ArgumentNullException"><paramref name="filename"/> is <c>null</c>.</exception> public virtual string? ResolveShortcut(string filename) { if (string.IsNullOrEmpty(filename)) @@ -72,6 +76,7 @@ namespace Emby.Server.Implementations.IO return handler?.Resolve(filename); } + /// <inheritdoc /> public virtual string MakeAbsolutePath(string folderPath, string filePath) { // path is actually a stream @@ -233,9 +238,9 @@ 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) { @@ -246,15 +251,15 @@ namespace Emby.Server.Implementations.IO { try { - using (Stream thisFileStream = new FileStream(fileInfo.FullName, FileMode.Open, FileAccess.Read, FileShare.Read, 1)) + 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; } } @@ -343,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; } } @@ -358,11 +363,13 @@ namespace Emby.Server.Implementations.IO return GetCreationTimeUtc(GetFileSystemInfo(path)); } + /// <inheritdoc /> public virtual DateTime GetCreationTimeUtc(FileSystemMetadata info) { return info.CreationTimeUtc; } + /// <inheritdoc /> public virtual DateTime GetLastWriteTimeUtc(FileSystemMetadata info) { return info.LastWriteTimeUtc; @@ -382,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; } } @@ -397,6 +404,7 @@ namespace Emby.Server.Implementations.IO return GetLastWriteTimeUtc(GetFileSystemInfo(path)); } + /// <inheritdoc /> public virtual void SetHidden(string path, bool isHidden) { if (!OperatingSystem.IsWindows()) @@ -421,6 +429,7 @@ namespace Emby.Server.Implementations.IO } } + /// <inheritdoc /> public virtual void SetAttributes(string path, bool isHidden, bool readOnly) { if (!OperatingSystem.IsWindows()) @@ -444,7 +453,7 @@ namespace Emby.Server.Implementations.IO if (readOnly) { - attributes = attributes | FileAttributes.ReadOnly; + attributes |= FileAttributes.ReadOnly; } else { @@ -453,7 +462,7 @@ namespace Emby.Server.Implementations.IO if (isHidden) { - attributes = attributes | FileAttributes.Hidden; + attributes |= FileAttributes.Hidden; } else { @@ -498,6 +507,7 @@ namespace Emby.Server.Implementations.IO File.Copy(temp1, file2, true); } + /// <inheritdoc /> public virtual bool ContainsSubPath(string parentPath, string path) { if (string.IsNullOrEmpty(parentPath)) @@ -515,6 +525,7 @@ namespace Emby.Server.Implementations.IO _isEnvironmentCaseInsensitive ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal); } + /// <inheritdoc /> public virtual string NormalizePath(string path) { if (string.IsNullOrEmpty(path)) @@ -530,24 +541,16 @@ namespace Emby.Server.Implementations.IO return Path.TrimEndingDirectorySeparator(path); } + /// <inheritdoc /> public virtual bool AreEqual(string path1, string path2) { - if (path1 == null && path2 == null) - { - return true; - } - - if (path1 == null || path2 == null) - { - return false; - } - return string.Equals( NormalizePath(path1), NormalizePath(path2), _isEnvironmentCaseInsensitive ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal); } + /// <inheritdoc /> public virtual string GetFileNameWithoutExtension(FileSystemMetadata info) { if (info.IsDirectory) @@ -558,11 +561,11 @@ namespace Emby.Server.Implementations.IO return Path.GetFileNameWithoutExtension(info.FullName); } + /// <inheritdoc /> 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; } @@ -570,17 +573,23 @@ namespace Emby.Server.Implementations.IO return true; } + /// <inheritdoc /> public virtual void DeleteFile(string path) { SetAttributes(path, false, false); File.Delete(path); } + /// <inheritdoc /> public virtual List<FileSystemMetadata> 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, @@ -589,16 +598,19 @@ namespace Emby.Server.Implementations.IO }).ToList(); } + /// <inheritdoc /> public virtual IEnumerable<FileSystemMetadata> GetDirectories(string path, bool recursive = false) { return ToMetadata(new DirectoryInfo(path).EnumerateDirectories("*", GetEnumerationOptions(recursive))); } + /// <inheritdoc /> public virtual IEnumerable<FileSystemMetadata> GetFiles(string path, bool recursive = false) { return GetFiles(path, null, false, recursive); } + /// <inheritdoc /> public virtual IEnumerable<FileSystemMetadata> GetFiles(string path, IReadOnlyList<string>? extensions, bool enableCaseSensitiveExtensions, bool recursive = false) { var enumerationOptions = GetEnumerationOptions(recursive); @@ -629,6 +641,7 @@ namespace Emby.Server.Implementations.IO return ToMetadata(files); } + /// <inheritdoc /> public virtual IEnumerable<FileSystemMetadata> GetFileSystemEntries(string path, bool recursive = false) { var directoryInfo = new DirectoryInfo(path); @@ -642,16 +655,19 @@ namespace Emby.Server.Implementations.IO return infos.Select(GetFileSystemMetadata); } + /// <inheritdoc /> public virtual IEnumerable<string> GetDirectoryPaths(string path, bool recursive = false) { return Directory.EnumerateDirectories(path, "*", GetEnumerationOptions(recursive)); } + /// <inheritdoc /> public virtual IEnumerable<string> GetFilePaths(string path, bool recursive = false) { return GetFilePaths(path, null, false, recursive); } + /// <inheritdoc /> public virtual IEnumerable<string> GetFilePaths(string path, string[]? extensions, bool enableCaseSensitiveExtensions, bool recursive = false) { var enumerationOptions = GetEnumerationOptions(recursive); @@ -682,6 +698,7 @@ namespace Emby.Server.Implementations.IO return files; } + /// <inheritdoc /> public virtual IEnumerable<string> GetFileSystemEntryPaths(string path, bool recursive = false) { return Directory.EnumerateFileSystemEntries(path, "*", GetEnumerationOptions(recursive)); diff --git a/Emby.Server.Implementations/IO/StreamHelper.cs b/Emby.Server.Implementations/IO/StreamHelper.cs index e4f5f4cf0..f55c16d6d 100644 --- a/Emby.Server.Implementations/IO/StreamHelper.cs +++ b/Emby.Server.Implementations/IO/StreamHelper.cs @@ -17,11 +17,11 @@ namespace Emby.Server.Implementations.IO try { int read; - while ((read = await source.ReadAsync(buffer, 0, buffer.Length, cancellationToken).ConfigureAwait(false)) != 0) + while ((read = await source.ReadAsync(buffer, cancellationToken).ConfigureAwait(false)) != 0) { cancellationToken.ThrowIfCancellationRequested(); - await destination.WriteAsync(buffer, 0, read, cancellationToken).ConfigureAwait(false); + await destination.WriteAsync(buffer.AsMemory(0, read), cancellationToken).ConfigureAwait(false); if (onStarted != null) { @@ -44,11 +44,11 @@ namespace Emby.Server.Implementations.IO if (emptyReadLimit <= 0) { int read; - while ((read = await source.ReadAsync(buffer, 0, buffer.Length, cancellationToken).ConfigureAwait(false)) != 0) + while ((read = await source.ReadAsync(buffer, cancellationToken).ConfigureAwait(false)) != 0) { cancellationToken.ThrowIfCancellationRequested(); - await destination.WriteAsync(buffer, 0, read, cancellationToken).ConfigureAwait(false); + await destination.WriteAsync(buffer.AsMemory(0, read), cancellationToken).ConfigureAwait(false); } return; @@ -60,7 +60,7 @@ namespace Emby.Server.Implementations.IO { cancellationToken.ThrowIfCancellationRequested(); - var bytesRead = await source.ReadAsync(buffer, 0, buffer.Length, cancellationToken).ConfigureAwait(false); + var bytesRead = await source.ReadAsync(buffer, cancellationToken).ConfigureAwait(false); if (bytesRead == 0) { @@ -71,7 +71,7 @@ namespace Emby.Server.Implementations.IO { eofCount = 0; - await destination.WriteAsync(buffer, 0, bytesRead, cancellationToken).ConfigureAwait(false); + await destination.WriteAsync(buffer.AsMemory(0, bytesRead), cancellationToken).ConfigureAwait(false); } } } @@ -88,13 +88,13 @@ namespace Emby.Server.Implementations.IO { int bytesRead; - while ((bytesRead = await source.ReadAsync(buffer, 0, buffer.Length, cancellationToken).ConfigureAwait(false)) != 0) + while ((bytesRead = await source.ReadAsync(buffer, cancellationToken).ConfigureAwait(false)) != 0) { var bytesToWrite = Math.Min(bytesRead, copyLength); if (bytesToWrite > 0) { - await destination.WriteAsync(buffer, 0, Convert.ToInt32(bytesToWrite), cancellationToken).ConfigureAwait(false); + await destination.WriteAsync(buffer.AsMemory(0, Convert.ToInt32(bytesToWrite)), cancellationToken).ConfigureAwait(false); } copyLength -= bytesToWrite; @@ -137,9 +137,9 @@ namespace Emby.Server.Implementations.IO int bytesRead; int totalBytesRead = 0; - while ((bytesRead = await source.ReadAsync(buffer, 0, buffer.Length, cancellationToken).ConfigureAwait(false)) != 0) + while ((bytesRead = await source.ReadAsync(buffer, cancellationToken).ConfigureAwait(false)) != 0) { - await destination.WriteAsync(buffer, 0, bytesRead, cancellationToken).ConfigureAwait(false); + await destination.WriteAsync(buffer.AsMemory(0, bytesRead), cancellationToken).ConfigureAwait(false); totalBytesRead += bytesRead; } diff --git a/Emby.Server.Implementations/IStartupOptions.cs b/Emby.Server.Implementations/IStartupOptions.cs index 1d97882db..3769ae4dd 100644 --- a/Emby.Server.Implementations/IStartupOptions.cs +++ b/Emby.Server.Implementations/IStartupOptions.cs @@ -1,7 +1,8 @@ -#pragma warning disable CS1591 - namespace Emby.Server.Implementations { + /// <summary> + /// Specifies the contract for server startup options. + /// </summary> public interface IStartupOptions { /// <summary> @@ -10,7 +11,7 @@ namespace Emby.Server.Implementations string? FFmpegPath { get; } /// <summary> - /// Gets a value value indicating whether to run as service by the --service command line option. + /// Gets a value indicating whether to run as service by the --service command line option. /// </summary> bool IsService { get; } diff --git a/Emby.Server.Implementations/Images/BaseDynamicImageProvider.cs b/Emby.Server.Implementations/Images/BaseDynamicImageProvider.cs index 4a026fd21..758986945 100644 --- a/Emby.Server.Implementations/Images/BaseDynamicImageProvider.cs +++ b/Emby.Server.Implementations/Images/BaseDynamicImageProvider.cs @@ -65,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; diff --git a/Emby.Server.Implementations/Images/BaseFolderImageProvider.cs b/Emby.Server.Implementations/Images/BaseFolderImageProvider.cs new file mode 100644 index 000000000..1c69056d2 --- /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<T> : BaseDynamicImageProvider<T> + 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<BaseItem> 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<BaseItem> 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 0229fbae7..7958eb8f5 100644 --- a/Emby.Server.Implementations/Images/CollectionFolderImageProvider.cs +++ b/Emby.Server.Implementations/Images/CollectionFolderImageProvider.cs @@ -28,35 +28,35 @@ namespace Emby.Server.Implementations.Images var view = (CollectionFolder)item; var viewType = view.CollectionType; - string[] includeItemTypes; + BaseItemKind[] includeItemTypes; if (string.Equals(viewType, CollectionType.Movies, StringComparison.Ordinal)) { - includeItemTypes = new string[] { "Movie" }; + includeItemTypes = new[] { BaseItemKind.Movie }; } else if (string.Equals(viewType, CollectionType.TvShows, StringComparison.Ordinal)) { - includeItemTypes = new string[] { "Series" }; + includeItemTypes = new[] { BaseItemKind.Series }; } else if (string.Equals(viewType, CollectionType.Music, StringComparison.Ordinal)) { - includeItemTypes = new string[] { "MusicAlbum" }; + includeItemTypes = new[] { BaseItemKind.MusicAlbum }; } else if (string.Equals(viewType, CollectionType.Books, StringComparison.Ordinal)) { - includeItemTypes = new string[] { "Book", "AudioBook" }; + includeItemTypes = new[] { BaseItemKind.Book, BaseItemKind.AudioBook }; } else if (string.Equals(viewType, CollectionType.BoxSets, StringComparison.Ordinal)) { - includeItemTypes = new string[] { "BoxSet" }; + includeItemTypes = new[] { BaseItemKind.BoxSet }; } else if (string.Equals(viewType, CollectionType.HomeVideos, StringComparison.Ordinal) || string.Equals(viewType, CollectionType.Photos, StringComparison.Ordinal)) { - includeItemTypes = new string[] { "Video", "Photo" }; + includeItemTypes = new[] { BaseItemKind.Video, BaseItemKind.Photo }; } else { - includeItemTypes = new string[] { "Video", "Audio", "Photo", "Movie", "Series" }; + includeItemTypes = new[] { BaseItemKind.Video, BaseItemKind.Audio, BaseItemKind.Photo, BaseItemKind.Movie, BaseItemKind.Series }; } var recursive = !string.Equals(CollectionType.Playlists, viewType, StringComparison.OrdinalIgnoreCase); @@ -68,9 +68,9 @@ namespace Emby.Server.Implementations.Images DtoOptions = new DtoOptions(false), ImageTypes = new ImageType[] { ImageType.Primary }, Limit = 8, - OrderBy = new ValueTuple<string, SortOrder>[] + OrderBy = new[] { - new ValueTuple<string, SortOrder>(ItemSortBy.Random, SortOrder.Ascending) + (ItemSortBy.Random, SortOrder.Ascending) }, IncludeItemTypes = includeItemTypes }); diff --git a/Emby.Server.Implementations/Images/DynamicImageProvider.cs b/Emby.Server.Implementations/Images/DynamicImageProvider.cs index 900b3fd9c..575680653 100644 --- a/Emby.Server.Implementations/Images/DynamicImageProvider.cs +++ b/Emby.Server.Implementations/Images/DynamicImageProvider.cs @@ -6,6 +6,8 @@ using System; using System.Collections.Generic; using System.IO; using System.Linq; +using Jellyfin.Data.Enums; +using Jellyfin.Extensions; using MediaBrowser.Common.Configuration; using MediaBrowser.Controller.Drawing; using MediaBrowser.Controller.Dto; @@ -34,14 +36,14 @@ namespace Emby.Server.Implementations.Images var view = (UserView)item; var isUsingCollectionStrip = IsUsingCollectionStrip(view); - var recursive = isUsingCollectionStrip && !new[] { CollectionType.BoxSets, CollectionType.Playlists }.Contains(view.ViewType ?? string.Empty, StringComparer.OrdinalIgnoreCase); + var recursive = isUsingCollectionStrip && !new[] { CollectionType.BoxSets, CollectionType.Playlists }.Contains(view.ViewType ?? string.Empty, StringComparison.OrdinalIgnoreCase); var result = view.GetItemList(new InternalItemsQuery { User = view.UserId.HasValue ? _userManager.GetUserById(view.UserId.Value) : null, CollapseBoxSetItems = false, Recursive = recursive, - ExcludeItemTypes = new[] { "UserView", "CollectionFolder", "Person" }, + ExcludeItemTypes = new[] { BaseItemKind.UserView, BaseItemKind.CollectionFolder, BaseItemKind.Person }, DtoOptions = new DtoOptions(false) }); diff --git a/Emby.Server.Implementations/Images/FolderImageProvider.cs b/Emby.Server.Implementations/Images/FolderImageProvider.cs index 859017f86..4376bd356 100644 --- a/Emby.Server.Implementations/Images/FolderImageProvider.cs +++ b/Emby.Server.Implementations/Images/FolderImageProvider.cs @@ -2,69 +2,16 @@ #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<T> : BaseDynamicImageProvider<T> - 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<BaseItem> GetItemsWithImages(BaseItem item) - { - return _libraryManager.GetItemList(new InternalItemsQuery - { - Parent = item, - DtoOptions = new DtoOptions(true), - ImageTypes = new ImageType[] { ImageType.Primary }, - OrderBy = new System.ValueTuple<string, SortOrder>[] - { - new System.ValueTuple<string, SortOrder>(ItemSortBy.IsFolder, SortOrder.Ascending), - new System.ValueTuple<string, SortOrder>(ItemSortBy.SortName, SortOrder.Ascending) - }, - Limit = 1 - }); - } - - protected override string CreateImage(BaseItem item, IReadOnlyCollection<BaseItem> 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<Folder> { public FolderImageProvider(IFileSystem fileSystem, IProviderManager providerManager, IApplicationPaths applicationPaths, IImageProcessor imageProcessor, ILibraryManager libraryManager) @@ -87,20 +34,4 @@ namespace Emby.Server.Implementations.Images return true; } } - - public class MusicAlbumImageProvider : BaseFolderImageProvider<MusicAlbum> - { - public MusicAlbumImageProvider(IFileSystem fileSystem, IProviderManager providerManager, IApplicationPaths applicationPaths, IImageProcessor imageProcessor, ILibraryManager libraryManager) - : base(fileSystem, providerManager, applicationPaths, imageProcessor, libraryManager) - { - } - } - - public class PhotoAlbumImageProvider : BaseFolderImageProvider<PhotoAlbum> - { - 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 6da431c68..968bf5fa3 100644 --- a/Emby.Server.Implementations/Images/GenreImageProvider.cs +++ b/Emby.Server.Implementations/Images/GenreImageProvider.cs @@ -8,9 +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; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Entities; @@ -19,46 +16,6 @@ using MediaBrowser.Model.Querying; namespace Emby.Server.Implementations.Images { - /// <summary> - /// Class MusicGenreImageProvider. - /// </summary> - public class MusicGenreImageProvider : BaseDynamicImageProvider<MusicGenre> - { - /// <summary> - /// The library manager. - /// </summary> - private readonly ILibraryManager _libraryManager; - - public MusicGenreImageProvider(IFileSystem fileSystem, IProviderManager providerManager, IApplicationPaths applicationPaths, IImageProcessor imageProcessor, ILibraryManager libraryManager) : base(fileSystem, providerManager, applicationPaths, imageProcessor) - { - _libraryManager = libraryManager; - } - - /// <summary> - /// Get children objects used to create an music genre image. - /// </summary> - /// <param name="item">The music genre used to create the image.</param> - /// <returns>Any relevant children objects.</returns> - protected override IReadOnlyList<BaseItem> 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) - }); - } - } - /// <summary> /// Class GenreImageProvider. /// </summary> @@ -84,7 +41,7 @@ namespace Emby.Server.Implementations.Images return _libraryManager.GetItemList(new InternalItemsQuery { Genres = new[] { item.Name }, - IncludeItemTypes = new[] { nameof(Series), nameof(Movie) }, + IncludeItemTypes = new[] { BaseItemKind.Series, BaseItemKind.Movie }, OrderBy = new[] { (ItemSortBy.Random, SortOrder.Ascending) }, Limit = 4, Recursive = true, diff --git a/Emby.Server.Implementations/Images/MusicAlbumImageProvider.cs b/Emby.Server.Implementations/Images/MusicAlbumImageProvider.cs new file mode 100644 index 000000000..ce8367363 --- /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<MusicAlbum> + { + 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 000000000..31f053f06 --- /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 +{ + /// <summary> + /// Class MusicGenreImageProvider. + /// </summary> + public class MusicGenreImageProvider : BaseDynamicImageProvider<MusicGenre> + { + /// <summary> + /// The library manager. + /// </summary> + private readonly ILibraryManager _libraryManager; + + public MusicGenreImageProvider(IFileSystem fileSystem, IProviderManager providerManager, IApplicationPaths applicationPaths, IImageProcessor imageProcessor, ILibraryManager libraryManager) : base(fileSystem, providerManager, applicationPaths, imageProcessor) + { + _libraryManager = libraryManager; + } + + /// <summary> + /// Get children objects used to create an music genre image. + /// </summary> + /// <param name="item">The music genre used to create the image.</param> + /// <returns>Any relevant children objects.</returns> + protected override IReadOnlyList<BaseItem> GetItemsWithImages(BaseItem item) + { + return _libraryManager.GetItemList(new InternalItemsQuery + { + Genres = new[] { item.Name }, + IncludeItemTypes = new[] + { + BaseItemKind.MusicAlbum, + BaseItemKind.MusicVideo, + BaseItemKind.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 000000000..1ddb4c757 --- /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<PhotoAlbum> + { + public PhotoAlbumImageProvider(IFileSystem fileSystem, IProviderManager providerManager, IApplicationPaths applicationPaths, IImageProcessor imageProcessor, ILibraryManager libraryManager) + : base(fileSystem, providerManager, applicationPaths, imageProcessor, libraryManager) + { + } + } +} diff --git a/Emby.Server.Implementations/Library/CoreResolutionIgnoreRule.cs b/Emby.Server.Implementations/Library/CoreResolutionIgnoreRule.cs index c7d113963..e558fbe27 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 /// </summary> public class CoreResolutionIgnoreRule : IResolverIgnoreRule { - private readonly ILibraryManager _libraryManager; + private readonly NamingOptions _namingOptions; private readonly IServerApplicationPaths _serverApplicationPaths; /// <summary> /// Initializes a new instance of the <see cref="CoreResolutionIgnoreRule"/> class. /// </summary> - /// <param name="libraryManager">The library manager.</param> + /// <param name="namingOptions">The naming options.</param> /// <param name="serverApplicationPaths">The server application paths.</param> - public CoreResolutionIgnoreRule(ILibraryManager libraryManager, IServerApplicationPaths serverApplicationPaths) + public CoreResolutionIgnoreRule(NamingOptions namingOptions, IServerApplicationPaths serverApplicationPaths) { - _libraryManager = libraryManager; + _namingOptions = namingOptions; _serverApplicationPaths = serverApplicationPaths; } @@ -53,20 +54,10 @@ 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) - && !(parent is AggregateFolder) - && !(parent is UserRootFolder)) - { - return true; - } - - if (string.Equals(filename, BaseItem.ThemeVideosFolderName, StringComparison.OrdinalIgnoreCase)) - { - return true; - } - - if (string.Equals(filename, BaseItem.ThemeSongsFolderName, StringComparison.OrdinalIgnoreCase)) + // Ignore extras folders but allow it at the collection level + if (_namingOptions.AllExtrasTypesFolderNames.ContainsKey(filename) + && parent is not AggregateFolder + && parent is not UserRootFolder) { return true; } @@ -77,8 +68,8 @@ namespace Emby.Server.Implementations.Library if (parent != null) { // Don't resolve these into audio files - if (Path.GetFileNameWithoutExtension(filename.AsSpan()).Equals(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 6c65b5899..868071a99 100644 --- a/Emby.Server.Implementations/Library/ExclusiveLiveStream.cs +++ b/Emby.Server.Implementations/Library/ExclusiveLiveStream.cs @@ -4,6 +4,7 @@ using System; using System.Globalization; +using System.IO; using System.Threading; using System.Threading.Tasks; using MediaBrowser.Controller.Library; @@ -41,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/LibraryManager.cs b/Emby.Server.Implementations/Library/LibraryManager.cs index 8054beae3..bd0c178fd 100644 --- a/Emby.Server.Implementations/Library/LibraryManager.cs +++ b/Emby.Server.Implementations/Library/LibraryManager.cs @@ -11,14 +11,12 @@ using System.Net; using System.Net.Http; using System.Threading; using System.Threading.Tasks; -using Emby.Naming.Audio; using Emby.Naming.Common; using Emby.Naming.TV; -using Emby.Naming.Video; using Emby.Server.Implementations.Library.Resolvers; using Emby.Server.Implementations.Library.Validators; using Emby.Server.Implementations.Playlists; -using Emby.Server.Implementations.ScheduledTasks; +using Emby.Server.Implementations.ScheduledTasks.Tasks; using Jellyfin.Data.Entities; using Jellyfin.Data.Enums; using Jellyfin.Extensions; @@ -79,6 +77,8 @@ namespace Emby.Server.Implementations.Library private readonly IFileSystem _fileSystem; private readonly IItemRepository _itemRepository; private readonly IImageProcessor _imageProcessor; + private readonly NamingOptions _namingOptions; + private readonly ExtraResolver _extraResolver; /// <summary> /// The _root folder sync lock. @@ -88,9 +88,6 @@ namespace Emby.Server.Implementations.Library private readonly TimeSpan _viewRefreshInterval = TimeSpan.FromHours(24); - private NamingOptions _namingOptions; - private string[] _videoFileExtensions; - /// <summary> /// The _root folder. /// </summary> @@ -116,6 +113,7 @@ namespace Emby.Server.Implementations.Library /// <param name="itemRepository">The item repository.</param> /// <param name="imageProcessor">The image processor.</param> /// <param name="memoryCache">The memory cache.</param> + /// <param name="namingOptions">The naming options.</param> public LibraryManager( IServerApplicationHost appHost, ILogger<LibraryManager> logger, @@ -130,7 +128,8 @@ namespace Emby.Server.Implementations.Library IMediaEncoder mediaEncoder, IItemRepository itemRepository, IImageProcessor imageProcessor, - IMemoryCache memoryCache) + IMemoryCache memoryCache, + NamingOptions namingOptions) { _appHost = appHost; _logger = logger; @@ -146,6 +145,9 @@ namespace Emby.Server.Implementations.Library _itemRepository = itemRepository; _imageProcessor = imageProcessor; _memoryCache = memoryCache; + _namingOptions = namingOptions; + + _extraResolver = new ExtraResolver(namingOptions); _configurationManager.ConfigurationUpdated += ConfigurationUpdated; @@ -333,8 +335,7 @@ namespace Emby.Server.Implementations.Library { try { - var task = BaseItem.ChannelManager.DeleteItem(item); - Task.WaitAll(task); + BaseItem.ChannelManager.DeleteItem(item).GetAwaiter().GetResult(); } catch (ArgumentException) { @@ -492,7 +493,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; } } @@ -533,8 +534,8 @@ namespace Emby.Server.Implementations.Library return key.GetMD5(); } - public BaseItem ResolvePath(FileSystemMetadata fileInfo, Folder parent = null) - => ResolvePath(fileInfo, new DirectoryService(_fileSystem), null, parent); + public BaseItem ResolvePath(FileSystemMetadata fileInfo, Folder parent = null, IDirectoryService directoryService = null) + => ResolvePath(fileInfo, directoryService ?? new DirectoryService(_fileSystem), null, parent); private BaseItem ResolvePath( FileSystemMetadata fileInfo, @@ -647,14 +648,14 @@ namespace Emby.Server.Implementations.Library /// Determines whether a path should be ignored based on its contents - called after the contents have been read. /// </summary> /// <param name="args">The args.</param> - /// <returns><c>true</c> if XXXX, <c>false</c> otherwise</returns> + /// <returns><c>true</c> if XXXX, <c>false</c> otherwise.</returns> private static bool ShouldResolvePathContents(ItemResolveArgs args) { // Ignore any folders containing a file called .ignore return !args.ContainsFileSystemEntryByName(".ignore"); } - public IEnumerable<BaseItem> ResolvePaths(IEnumerable<FileSystemMetadata> files, IDirectoryService directoryService, Folder parent, LibraryOptions libraryOptions, string collectionType) + public IEnumerable<BaseItem> ResolvePaths(IEnumerable<FileSystemMetadata> files, IDirectoryService directoryService, Folder parent, LibraryOptions libraryOptions, string collectionType = null) { return ResolvePaths(files, directoryService, parent, libraryOptions, collectionType, EntityResolvers); } @@ -677,7 +678,7 @@ namespace Emby.Server.Implementations.Library { var result = resolver.ResolveMultiple(parent, fileList, collectionType, directoryService); - if (result != null && result.Items.Count > 0) + if (result?.Items.Count > 0) { var items = new List<BaseItem>(); items.AddRange(result.Items); @@ -799,7 +800,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)); @@ -810,7 +811,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) @@ -827,7 +828,7 @@ namespace Emby.Server.Implementations.Library } _userRootFolder = tmpItem; - _logger.LogDebug("Setting userRootFolder: {folder}", _userRootFolder); + _logger.LogDebug("Setting userRootFolder: {Folder}", _userRootFolder); } } } @@ -965,7 +966,7 @@ namespace Emby.Server.Implementations.Library { var existing = GetItemList(new InternalItemsQuery { - IncludeItemTypes = new[] { nameof(MusicArtist) }, + IncludeItemTypes = new[] { BaseItemKind.MusicArtist }, Name = name, DtoOptions = options }).Cast<MusicArtist>() @@ -1213,7 +1214,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; } }) @@ -1250,10 +1251,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<char> file in files) { - // TODO: @bond use a ReadOnlySpan<char> here when Enum.TryParse supports it - // https://github.com/dotnet/runtime/issues/20008 if (Enum.TryParse<CollectionTypeOptions>(Path.GetFileNameWithoutExtension(file), true, out var res)) { return res; @@ -1268,7 +1267,7 @@ namespace Emby.Server.Implementations.Library /// </summary> /// <param name="id">The id.</param> /// <returns>BaseItem.</returns> - /// <exception cref="ArgumentNullException">id</exception> + /// <exception cref="ArgumentNullException"><paramref name="id"/> is <c>null</c>.</exception> public BaseItem GetItemById(Guid id) { if (id == Guid.Empty) @@ -1377,7 +1376,7 @@ namespace Emby.Server.Implementations.Library return _itemRepository.GetItemIdsList(query); } - public QueryResult<(BaseItem, ItemCounts)> GetStudios(InternalItemsQuery query) + public QueryResult<(BaseItem Item, ItemCounts ItemCounts)> GetStudios(InternalItemsQuery query) { if (query.User != null) { @@ -1388,7 +1387,7 @@ namespace Emby.Server.Implementations.Library return _itemRepository.GetStudios(query); } - public QueryResult<(BaseItem, ItemCounts)> GetGenres(InternalItemsQuery query) + public QueryResult<(BaseItem Item, ItemCounts ItemCounts)> GetGenres(InternalItemsQuery query) { if (query.User != null) { @@ -1399,7 +1398,7 @@ namespace Emby.Server.Implementations.Library return _itemRepository.GetGenres(query); } - public QueryResult<(BaseItem, ItemCounts)> GetMusicGenres(InternalItemsQuery query) + public QueryResult<(BaseItem Item, ItemCounts ItemCounts)> GetMusicGenres(InternalItemsQuery query) { if (query.User != null) { @@ -1410,7 +1409,7 @@ namespace Emby.Server.Implementations.Library return _itemRepository.GetMusicGenres(query); } - public QueryResult<(BaseItem, ItemCounts)> GetAllArtists(InternalItemsQuery query) + public QueryResult<(BaseItem Item, ItemCounts ItemCounts)> GetAllArtists(InternalItemsQuery query) { if (query.User != null) { @@ -1421,7 +1420,7 @@ namespace Emby.Server.Implementations.Library return _itemRepository.GetAllArtists(query); } - public QueryResult<(BaseItem, ItemCounts)> GetArtists(InternalItemsQuery query) + public QueryResult<(BaseItem Item, ItemCounts ItemCounts)> GetArtists(InternalItemsQuery query) { if (query.User != null) { @@ -1445,7 +1444,7 @@ namespace Emby.Server.Implementations.Library for (int i = 0; i < len; i++) { parents[i] = GetItemById(ancestorIds[i]); - if (!(parents[i] is ICollectionFolder || parents[i] is UserView)) + if (parents[i] is not (ICollectionFolder or UserView)) { return; } @@ -1462,7 +1461,7 @@ namespace Emby.Server.Implementations.Library } } - public QueryResult<(BaseItem, ItemCounts)> GetAlbumArtists(InternalItemsQuery query) + public QueryResult<(BaseItem Item, ItemCounts ItemCounts)> GetAlbumArtists(InternalItemsQuery query) { if (query.User != null) { @@ -1700,7 +1699,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 { @@ -1719,7 +1718,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 @@ -1761,7 +1760,7 @@ namespace Emby.Server.Implementations.Library return orderedItems ?? items; } - public IEnumerable<BaseItem> Sort(IEnumerable<BaseItem> items, User user, IEnumerable<ValueTuple<string, SortOrder>> orderBy) + public IEnumerable<BaseItem> Sort(IEnumerable<BaseItem> items, User user, IEnumerable<(string OrderBy, SortOrder SortOrder)> orderBy) { var isFirst = true; @@ -2503,16 +2502,6 @@ namespace Emby.Server.Implementations.Library return RootFolder; } - /// <inheritdoc /> - public bool IsVideoFile(string path) - { - return VideoResolver.IsVideoFile(path, GetNamingOptions()); - } - - /// <inheritdoc /> - public bool IsAudioFile(string path) - => AudioFileParser.IsAudioFile(path, GetNamingOptions()); - /// <inheritdoc /> public int? GetSeasonNumberFromPath(string path) => SeasonPathParser.Parse(path, true, true).SeasonNumber; @@ -2528,7 +2517,7 @@ namespace Emby.Server.Implementations.Library isAbsoluteNaming = null; } - var resolver = new EpisodeResolver(GetNamingOptions()); + var resolver = new EpisodeResolver(_namingOptions); var isFolder = episode.VideoType == VideoType.BluRay || episode.VideoType == VideoType.Dvd; @@ -2685,113 +2674,77 @@ namespace Emby.Server.Implementations.Library return changed; } - /// <inheritdoc /> - public NamingOptions GetNamingOptions() - { - if (_namingOptions == null) - { - _namingOptions = new NamingOptions(); - _videoFileExtensions = _namingOptions.VideoFileExtensions; - } - - return _namingOptions; - } - public ItemLookupInfo ParseName(string name) { - var namingOptions = GetNamingOptions(); + var namingOptions = _namingOptions; var result = VideoResolver.CleanDateTime(name, namingOptions); return new ItemLookupInfo { - Name = VideoResolver.TryCleanString(result.Name, namingOptions, out var newName) ? newName.ToString() : result.Name, + Name = VideoResolver.TryCleanString(result.Name, namingOptions, out var newName) ? newName : result.Name, Year = result.Year }; } - public IEnumerable<Video> FindTrailers(BaseItem owner, List<FileSystemMetadata> fileSystemChildren, IDirectoryService directoryService) + public IEnumerable<BaseItem> FindExtras(BaseItem owner, List<FileSystemMetadata> fileSystemChildren, IDirectoryService directoryService) { - var namingOptions = GetNamingOptions(); - - var files = owner.IsInMixedFolder ? new List<FileSystemMetadata>() : fileSystemChildren.Where(i => i.IsDirectory) - .Where(i => string.Equals(i.Name, BaseItem.TrailerFolderName, StringComparison.OrdinalIgnoreCase)) - .SelectMany(i => _fileSystem.GetFiles(i.FullName, _videoFileExtensions, false, false)) - .ToList(); - - var videos = VideoListResolver.Resolve(fileSystemChildren, namingOptions); - - var currentVideo = videos.FirstOrDefault(i => string.Equals(owner.Path, i.Files[0].Path, StringComparison.OrdinalIgnoreCase)); - - if (currentVideo != null) + var ownerVideoInfo = VideoResolver.Resolve(owner.Path, owner.IsFolder, _namingOptions); + if (ownerVideoInfo == null) { - files.AddRange(currentVideo.Extras.Where(i => i.ExtraType == ExtraType.Trailer).Select(i => _fileSystem.GetFileInfo(i.Path))); + yield break; } - var resolvers = new IItemResolver[] + var count = fileSystemChildren.Count; + for (var i = 0; i < count; i++) { - new GenericVideoResolver<Trailer>(this) - }; - - return ResolvePaths(files, directoryService, null, new LibraryOptions(), null, resolvers) - .OfType<Trailer>() - .Select(video => + var current = fileSystemChildren[i]; + if (current.IsDirectory && _namingOptions.AllExtrasTypesFolderNames.ContainsKey(current.Name)) { - // Try to retrieve it from the db. If we don't find it, use the resolved version - if (GetItemById(video.Id) is Trailer dbItem) + var filesInSubFolder = _fileSystem.GetFiles(current.FullName, null, false, false); + foreach (var file in filesInSubFolder) { - video = dbItem; + if (!_extraResolver.TryGetExtraTypeForOwner(file.FullName, ownerVideoInfo, out var extraType)) + { + continue; + } + + var extra = GetExtra(file, extraType.Value); + if (extra != null) + { + yield return extra; + } } - - video.ParentId = Guid.Empty; - video.OwnerId = owner.Id; - video.ExtraType = ExtraType.Trailer; - video.TrailerTypes = new[] { TrailerType.LocalTrailer }; - - return video; - - // Sort them so that the list can be easily compared for changes - }).OrderBy(i => i.Path); - } - - public IEnumerable<Video> FindExtras(BaseItem owner, List<FileSystemMetadata> fileSystemChildren, IDirectoryService directoryService) - { - var namingOptions = GetNamingOptions(); - - var files = owner.IsInMixedFolder ? new List<FileSystemMetadata>() : fileSystemChildren.Where(i => i.IsDirectory) - .Where(i => BaseItem.AllExtrasTypesFolderNames.Contains(i.Name ?? string.Empty, StringComparer.OrdinalIgnoreCase)) - .SelectMany(i => _fileSystem.GetFiles(i.FullName, _videoFileExtensions, false, false)) - .ToList(); - - var videos = VideoListResolver.Resolve(fileSystemChildren, namingOptions); - - var currentVideo = videos.FirstOrDefault(i => string.Equals(owner.Path, i.Files[0].Path, StringComparison.OrdinalIgnoreCase)); - - if (currentVideo != null) - { - files.AddRange(currentVideo.Extras.Where(i => i.ExtraType != ExtraType.Trailer).Select(i => _fileSystem.GetFileInfo(i.Path))); + } + else if (!current.IsDirectory && _extraResolver.TryGetExtraTypeForOwner(current.FullName, ownerVideoInfo, out var extraType)) + { + var extra = GetExtra(current, extraType.Value); + if (extra != null) + { + yield return extra; + } + } } - return ResolvePaths(files, directoryService, null, new LibraryOptions(), null) - .OfType<Video>() - .Select(video => + BaseItem GetExtra(FileSystemMetadata file, ExtraType extraType) + { + var extra = ResolvePath(_fileSystem.GetFileInfo(file.FullName), directoryService, _extraResolver.GetResolversForExtraType(extraType)); + if (extra is not Video && extra is not Audio) { - // Try to retrieve it from the db. If we don't find it, use the resolved version - var dbItem = GetItemById(video.Id) as Video; + return null; + } - if (dbItem != null) - { - video = dbItem; - } + // Try to retrieve it from the db. If we don't find it, use the resolved version + var itemById = GetItemById(extra.Id); + if (itemById != null) + { + extra = itemById; + } - video.ParentId = Guid.Empty; - video.OwnerId = owner.Id; - - SetExtraTypeFromFilename(video); - - return video; - - // Sort them so that the list can be easily compared for changes - }).OrderBy(i => i.Path); + extra.ExtraType = extraType; + extra.ParentId = Guid.Empty; + extra.OwnerId = owner.Id; + return extra; + } } public string GetPathAfterNetworkSubstitution(string path, BaseItem ownerItem) @@ -2841,15 +2794,6 @@ namespace Emby.Server.Implementations.Library return path; } - private void SetExtraTypeFromFilename(Video item) - { - var resolver = new ExtraResolver(GetNamingOptions()); - - var result = resolver.GetExtraInfo(item.Path); - - item.ExtraType = result.ExtraType; - } - public List<PersonInfo> GetPeople(InternalPeopleQuery query) { return _itemRepository.GetPeople(query); @@ -2956,11 +2900,12 @@ namespace Emby.Server.Implementations.Library var rootFolderPath = _configurationManager.ApplicationPaths.DefaultUserViewsPath; + var existingNameCount = 1; // first numbered name will be 2 var virtualFolderPath = Path.Combine(rootFolderPath, name); while (Directory.Exists(virtualFolderPath)) { - name += "1"; - virtualFolderPath = Path.Combine(rootFolderPath, name); + existingNameCount++; + virtualFolderPath = Path.Combine(rootFolderPath, name + " " + existingNameCount); } var mediaPathInfos = options.PathInfos; @@ -3062,7 +3007,10 @@ namespace Emby.Server.Implementations.Library } } - CreateItems(personsToSave, null, CancellationToken.None); + if (personsToSave.Count > 0) + { + CreateItems(personsToSave, null, CancellationToken.None); + } } private void StartScanInBackground() diff --git a/Emby.Server.Implementations/Library/LiveStreamHelper.cs b/Emby.Server.Implementations/Library/LiveStreamHelper.cs index 16b45161f..20624cc7a 100644 --- a/Emby.Server.Implementations/Library/LiveStreamHelper.cs +++ b/Emby.Server.Implementations/Library/LiveStreamHelper.cs @@ -10,9 +10,9 @@ using System.Linq; using System.Text.Json; using System.Threading; using System.Threading.Tasks; +using Jellyfin.Extensions.Json; using MediaBrowser.Common.Configuration; using MediaBrowser.Common.Extensions; -using Jellyfin.Extensions.Json; using MediaBrowser.Controller.MediaEncoding; using MediaBrowser.Model.Dlna; using MediaBrowser.Model.Dto; @@ -66,11 +66,8 @@ namespace Emby.Server.Implementations.Library { var delayMs = mediaSource.AnalyzeDurationMs ?? 0; delayMs = Math.Max(3000, delayMs); - if (delayMs > 0) - { - _logger.LogInformation("Waiting {0}ms before probing the live stream", delayMs); - await Task.Delay(delayMs, cancellationToken).ConfigureAwait(false); - } + _logger.LogInformation("Waiting {0}ms before probing the live stream", delayMs); + await Task.Delay(delayMs, cancellationToken).ConfigureAwait(false); } mediaSource.AnalyzeDurationMs = 3000; diff --git a/Emby.Server.Implementations/Library/MediaSourceManager.cs b/Emby.Server.Implementations/Library/MediaSourceManager.cs index 6f83973ba..a414e7e16 100644 --- a/Emby.Server.Implementations/Library/MediaSourceManager.cs +++ b/Emby.Server.Implementations/Library/MediaSourceManager.cs @@ -13,9 +13,9 @@ using System.Threading; using System.Threading.Tasks; using Jellyfin.Data.Entities; using Jellyfin.Data.Enums; +using Jellyfin.Extensions.Json; using MediaBrowser.Common.Configuration; using MediaBrowser.Common.Extensions; -using Jellyfin.Extensions.Json; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.MediaEncoding; @@ -45,6 +45,7 @@ namespace Emby.Server.Implementations.Library private readonly IMediaEncoder _mediaEncoder; private readonly ILocalizationManager _localizationManager; private readonly IApplicationPaths _appPaths; + private readonly IDirectoryService _directoryService; private readonly ConcurrentDictionary<string, ILiveStream> _openStreams = new ConcurrentDictionary<string, ILiveStream>(StringComparer.OrdinalIgnoreCase); private readonly SemaphoreSlim _liveStreamSemaphore = new SemaphoreSlim(1, 1); @@ -61,7 +62,8 @@ namespace Emby.Server.Implementations.Library ILogger<MediaSourceManager> logger, IFileSystem fileSystem, IUserDataManager userDataManager, - IMediaEncoder mediaEncoder) + IMediaEncoder mediaEncoder, + IDirectoryService directoryService) { _itemRepo = itemRepo; _userManager = userManager; @@ -72,6 +74,7 @@ namespace Emby.Server.Implementations.Library _mediaEncoder = mediaEncoder; _localizationManager = localizationManager; _appPaths = applicationPaths; + _directoryService = directoryService; } public void AddParts(IEnumerable<IMediaSourceProvider> providers) @@ -106,16 +109,6 @@ namespace Emby.Server.Implementations.Library return false; } - public List<MediaStream> GetMediaStreams(string mediaSourceId) - { - var list = GetMediaStreams(new MediaStreamQuery - { - ItemId = new Guid(mediaSourceId) - }); - - return GetMediaStreamsForItem(list); - } - public List<MediaStream> GetMediaStreams(Guid itemId) { var list = GetMediaStreams(new MediaStreamQuery @@ -161,7 +154,7 @@ namespace Emby.Server.Implementations.Library if (allowMediaProbe && mediaSources[0].Type != MediaSourceType.Placeholder && !mediaSources[0].MediaStreams.Any(i => i.Type == MediaStreamType.Audio || i.Type == MediaStreamType.Video)) { await item.RefreshMetadata( - new MetadataRefreshOptions(new DirectoryService(_fileSystem)) + new MetadataRefreshOptions(_directoryService) { EnableRemoteContentProbe = true, MetadataRefreshMode = MetadataRefreshMode.FullRefresh @@ -212,6 +205,7 @@ namespace Emby.Server.Implementations.Library return SortMediaSources(list); } + /// <inheritdoc />> public MediaProtocol GetPathProtocol(string path) { if (path.StartsWith("Rtsp", StringComparison.OrdinalIgnoreCase)) @@ -258,7 +252,7 @@ namespace Emby.Server.Implementations.Library { if (path != null) { - if (path.IndexOf(".m3u", StringComparison.OrdinalIgnoreCase) != -1) + if (path.Contains(".m3u", StringComparison.OrdinalIgnoreCase)) { return false; } @@ -297,7 +291,7 @@ namespace Emby.Server.Implementations.Library catch (Exception ex) { _logger.LogError(ex, "Error getting media sources"); - return new List<MediaSourceInfo>(); + return Enumerable.Empty<MediaSourceInfo>(); } } @@ -470,12 +464,11 @@ namespace Emby.Server.Implementations.Library try { - var tuple = GetProvider(request.OpenToken); - var provider = tuple.Item1; + var (provider, keyId) = GetProvider(request.OpenToken); var currentLiveStreams = _openStreams.Values.ToList(); - liveStream = await provider.OpenMediaSource(tuple.Item2, currentLiveStreams, cancellationToken).ConfigureAwait(false); + liveStream = await provider.OpenMediaSource(keyId, currentLiveStreams, cancellationToken).ConfigureAwait(false); mediaSource = liveStream.MediaSource; @@ -494,14 +487,11 @@ namespace Emby.Server.Implementations.Library _liveStreamSemaphore.Release(); } - // TODO: Don't hardcode this - const bool isAudio = false; - try { if (mediaSource.MediaStreams.Any(i => i.Index != -1) || !mediaSource.SupportsProbing) { - AddMediaInfo(mediaSource, isAudio); + AddMediaInfo(mediaSource); } else { @@ -509,14 +499,14 @@ namespace Emby.Server.Implementations.Library string cacheKey = request.OpenToken; await new LiveStreamHelper(_mediaEncoder, _logger, _appPaths) - .AddMediaInfoWithProbe(mediaSource, isAudio, cacheKey, true, cancellationToken) + .AddMediaInfoWithProbe(mediaSource, false, cacheKey, true, cancellationToken) .ConfigureAwait(false); } } catch (Exception ex) { _logger.LogError(ex, "Error probing live tv stream"); - AddMediaInfo(mediaSource, isAudio); + AddMediaInfo(mediaSource); } // TODO: @bond Fix @@ -536,7 +526,7 @@ namespace Emby.Server.Implementations.Library return new Tuple<LiveStreamResponse, IDirectStreamProvider>(new LiveStreamResponse(clone), liveStream as IDirectStreamProvider); } - private static void AddMediaInfo(MediaSourceInfo mediaSource, bool isAudio) + private static void AddMediaInfo(MediaSourceInfo mediaSource) { mediaSource.DefaultSubtitleStreamIndex = null; @@ -587,13 +577,6 @@ namespace Emby.Server.Implementations.Library mediaSource.InferTotalBitrate(); } - public Task<IDirectStreamProvider> GetDirectStreamProviderByUniqueId(string uniqueId, CancellationToken cancellationToken) - { - var info = _openStreams.FirstOrDefault(i => i.Value != null && string.Equals(i.Value.UniqueId, uniqueId, StringComparison.OrdinalIgnoreCase)); - - return Task.FromResult(info.Value as IDirectStreamProvider); - } - public async Task<LiveStreamResponse> OpenLiveStream(LiveStreamRequest request, CancellationToken cancellationToken) { var result = await OpenLiveStreamInternal(request, cancellationToken).ConfigureAwait(false); @@ -602,7 +585,8 @@ namespace Emby.Server.Implementations.Library public async Task<MediaSourceInfo> GetLiveStreamMediaInfo(string id, CancellationToken cancellationToken) { - var liveStreamInfo = await GetLiveStreamInfo(id, cancellationToken).ConfigureAwait(false); + // TODO probably shouldn't throw here but it is kept for "backwards compatibility" + var liveStreamInfo = GetLiveStreamInfo(id) ?? throw new ResourceNotFoundException(); var mediaSource = liveStreamInfo.MediaSource; @@ -771,18 +755,19 @@ namespace Emby.Server.Implementations.Library mediaSource.InferTotalBitrate(true); } - public async Task<Tuple<MediaSourceInfo, IDirectStreamProvider>> GetLiveStreamWithDirectStreamProvider(string id, CancellationToken cancellationToken) + public Task<Tuple<MediaSourceInfo, IDirectStreamProvider>> GetLiveStreamWithDirectStreamProvider(string id, CancellationToken cancellationToken) { if (string.IsNullOrEmpty(id)) { throw new ArgumentNullException(nameof(id)); } - var info = await GetLiveStreamInfo(id, cancellationToken).ConfigureAwait(false); - return new Tuple<MediaSourceInfo, IDirectStreamProvider>(info.MediaSource, info as IDirectStreamProvider); + // TODO probably shouldn't throw here but it is kept for "backwards compatibility" + var info = GetLiveStreamInfo(id) ?? throw new ResourceNotFoundException(); + return Task.FromResult(new Tuple<MediaSourceInfo, IDirectStreamProvider>(info.MediaSource, info as IDirectStreamProvider)); } - private Task<ILiveStream> GetLiveStreamInfo(string id, CancellationToken cancellationToken) + public ILiveStream GetLiveStreamInfo(string id) { if (string.IsNullOrEmpty(id)) { @@ -791,12 +776,16 @@ namespace Emby.Server.Implementations.Library if (_openStreams.TryGetValue(id, out ILiveStream info)) { - return Task.FromResult(info); - } - else - { - return Task.FromException<ILiveStream>(new ResourceNotFoundException()); + return info; } + + return null; + } + + /// <inheritdoc /> + public ILiveStream GetLiveStreamInfoByUniqueId(string uniqueId) + { + return _openStreams.Values.FirstOrDefault(stream => string.Equals(uniqueId, stream?.UniqueId, StringComparison.OrdinalIgnoreCase)); } public async Task<MediaSourceInfo> GetLiveStream(string id, CancellationToken cancellationToken) @@ -839,7 +828,7 @@ namespace Emby.Server.Implementations.Library } } - private (IMediaSourceProvider, string) GetProvider(string key) + private (IMediaSourceProvider MediaSourceProvider, string KeyId) GetProvider(string key) { if (string.IsNullOrEmpty(key)) { @@ -856,9 +845,7 @@ namespace Emby.Server.Implementations.Library return (provider, keyId); } - /// <summary> - /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources. - /// </summary> + /// <inheritdoc /> public void Dispose() { Dispose(true); diff --git a/Emby.Server.Implementations/Library/MediaStreamSelector.cs b/Emby.Server.Implementations/Library/MediaStreamSelector.cs index b833122ea..da0c89c13 100644 --- a/Emby.Server.Implementations/Library/MediaStreamSelector.cs +++ b/Emby.Server.Implementations/Library/MediaStreamSelector.cs @@ -6,6 +6,7 @@ using System; using System.Collections.Generic; using System.Linq; using Jellyfin.Data.Enums; +using Jellyfin.Extensions; using MediaBrowser.Model.Entities; namespace Emby.Server.Implementations.Library @@ -38,14 +39,11 @@ namespace Emby.Server.Implementations.Library } public static int? GetDefaultSubtitleStreamIndex( - List<MediaStream> streams, + IEnumerable<MediaStream> streams, string[] preferredLanguages, SubtitlePlaybackMode mode, string audioTrackLanguage) { - streams = GetSortedStreams(streams, MediaStreamType.Subtitle, preferredLanguages) - .ToList(); - MediaStream stream = null; if (mode == SubtitlePlaybackMode.None) @@ -53,52 +51,48 @@ namespace Emby.Server.Implementations.Library return null; } + var sortedStreams = streams + .Where(i => i.Type == MediaStreamType.Subtitle) + .OrderByDescending(x => x.IsExternal) + .ThenByDescending(x => x.IsForced && string.Equals(x.Language, audioTrackLanguage, StringComparison.OrdinalIgnoreCase)) + .ThenByDescending(x => x.IsForced) + .ThenByDescending(x => x.IsDefault) + .ToList(); + if (mode == SubtitlePlaybackMode.Default) { // Prefer embedded metadata over smart logic - - stream = streams.FirstOrDefault(s => s.IsForced && string.Equals(s.Language, audioTrackLanguage, StringComparison.OrdinalIgnoreCase)) ?? - streams.FirstOrDefault(s => s.IsForced) ?? - streams.FirstOrDefault(s => s.IsDefault); + stream = sortedStreams.FirstOrDefault(s => s.IsExternal || s.IsForced || s.IsDefault); // if the audio language is not understood by the user, load their preferred subs, if there are any - if (stream == null && !preferredLanguages.Contains(audioTrackLanguage, StringComparer.OrdinalIgnoreCase)) + if (stream == null && !preferredLanguages.Contains(audioTrackLanguage, StringComparison.OrdinalIgnoreCase)) { - stream = streams.Where(s => !s.IsForced).FirstOrDefault(s => preferredLanguages.Contains(s.Language, StringComparer.OrdinalIgnoreCase)); + stream = sortedStreams.FirstOrDefault(s => !s.IsForced && preferredLanguages.Contains(s.Language, StringComparison.OrdinalIgnoreCase)); } } else if (mode == SubtitlePlaybackMode.Smart) { - // Prefer smart logic over embedded metadata - // if the audio language is not understood by the user, load their preferred subs, if there are any - if (!preferredLanguages.Contains(audioTrackLanguage, StringComparer.OrdinalIgnoreCase)) + if (!preferredLanguages.Contains(audioTrackLanguage, StringComparison.OrdinalIgnoreCase)) { - stream = streams.Where(s => !s.IsForced).FirstOrDefault(s => preferredLanguages.Contains(s.Language, StringComparer.OrdinalIgnoreCase)) ?? - streams.FirstOrDefault(s => preferredLanguages.Contains(s.Language, StringComparer.OrdinalIgnoreCase)); + stream = streams.FirstOrDefault(s => !s.IsForced && preferredLanguages.Contains(s.Language, StringComparison.OrdinalIgnoreCase)) ?? + streams.FirstOrDefault(s => preferredLanguages.Contains(s.Language, StringComparison.OrdinalIgnoreCase)); } } else if (mode == SubtitlePlaybackMode.Always) { // always load the most suitable full subtitles - stream = streams.FirstOrDefault(s => !s.IsForced); + stream = sortedStreams.FirstOrDefault(s => !s.IsForced); } else if (mode == SubtitlePlaybackMode.OnlyForced) { // always load the most suitable full subtitles - stream = streams.FirstOrDefault(s => s.IsForced && string.Equals(s.Language, audioTrackLanguage, StringComparison.OrdinalIgnoreCase)) ?? - streams.FirstOrDefault(s => s.IsForced); + stream = sortedStreams.FirstOrDefault(x => x.IsForced); } // load forced subs if we have found no suitable full subtitles - stream ??= streams.FirstOrDefault(s => s.IsForced && string.Equals(s.Language, audioTrackLanguage, StringComparison.OrdinalIgnoreCase)); - - if (stream != null) - { - return stream.Index; - } - - return null; + stream ??= sortedStreams.FirstOrDefault(s => s.IsForced && string.Equals(s.Language, audioTrackLanguage, StringComparison.OrdinalIgnoreCase)); + return stream?.Index; } private static IEnumerable<MediaStream> GetSortedStreams(IEnumerable<MediaStream> streams, MediaStreamType type, string[] languagePreferences) @@ -143,9 +137,9 @@ namespace Emby.Server.Implementations.Library else if (mode == SubtitlePlaybackMode.Smart) { // Prefer smart logic over embedded metadata - if (!preferredLanguages.Contains(audioTrackLanguage, StringComparer.OrdinalIgnoreCase)) + if (!preferredLanguages.Contains(audioTrackLanguage, StringComparison.OrdinalIgnoreCase)) { - filteredStreams = streams.Where(s => !s.IsForced && preferredLanguages.Contains(s.Language, StringComparer.OrdinalIgnoreCase)) + filteredStreams = streams.Where(s => !s.IsForced && preferredLanguages.Contains(s.Language, StringComparison.OrdinalIgnoreCase)) .ToList(); } } diff --git a/Emby.Server.Implementations/Library/MusicManager.cs b/Emby.Server.Implementations/Library/MusicManager.cs index e2f1fb0ad..d35e74e7b 100644 --- a/Emby.Server.Implementations/Library/MusicManager.cs +++ b/Emby.Server.Implementations/Library/MusicManager.cs @@ -52,7 +52,7 @@ namespace Emby.Server.Implementations.Library var genres = item .GetRecursiveChildren(user, new InternalItemsQuery(user) { - IncludeItemTypes = new[] { nameof(Audio) }, + IncludeItemTypes = new[] { BaseItemKind.Audio }, DtoOptions = dtoOptions }) .Cast<Audio>() @@ -89,7 +89,7 @@ namespace Emby.Server.Implementations.Library { return _libraryManager.GetItemList(new InternalItemsQuery(user) { - IncludeItemTypes = new[] { nameof(Audio) }, + IncludeItemTypes = new[] { BaseItemKind.Audio }, GenreIds = genreIds.ToArray(), @@ -103,7 +103,7 @@ namespace Emby.Server.Implementations.Library public List<BaseItem> GetInstantMixFromItem(BaseItem item, User user, DtoOptions dtoOptions) { - if (item is MusicGenre genre) + if (item is MusicGenre) { return GetInstantMixFromGenreIds(new[] { item.Id }, user, dtoOptions); } diff --git a/Emby.Server.Implementations/Library/PathExtensions.cs b/Emby.Server.Implementations/Library/PathExtensions.cs index 86b8039fa..64e7d5446 100644 --- a/Emby.Server.Implementations/Library/PathExtensions.cs +++ b/Emby.Server.Implementations/Library/PathExtensions.cs @@ -16,7 +16,7 @@ namespace Emby.Server.Implementations.Library /// <param name="attribute">The attrib.</param> /// <returns>System.String.</returns> /// <exception cref="ArgumentException"><paramref name="str" /> or <paramref name="attribute" /> is empty.</exception> - public static string? GetAttributeValue(this string str, string attribute) + public static string? GetAttributeValue(this ReadOnlySpan<char> str, ReadOnlySpan<char> attribute) { if (str.Length == 0) { @@ -28,17 +28,31 @@ namespace Emby.Server.Implementations.Library throw new ArgumentException("String can't be empty.", nameof(attribute)); } - string srch = "[" + attribute + "="; - int start = str.IndexOf(srch, StringComparison.OrdinalIgnoreCase); - if (start != -1) + var attributeIndex = str.IndexOf(attribute, StringComparison.OrdinalIgnoreCase); + + // Must be at least 3 characters after the attribute =, ], any character. + var maxIndex = str.Length - attribute.Length - 3; + while (attributeIndex > -1 && attributeIndex < maxIndex) { - start += srch.Length; - int end = str.IndexOf(']', start); - return str.Substring(start, end - start); + var attributeEnd = attributeIndex + attribute.Length; + if (attributeIndex > 0 + && str[attributeIndex - 1] == '[' + && (str[attributeEnd] == '=' || str[attributeEnd] == '-')) + { + var closingIndex = str[attributeEnd..].IndexOf(']'); + // Must be at least 1 character before the closing bracket. + if (closingIndex > 1) + { + return str[(attributeEnd + 1)..(attributeEnd + closingIndex)].Trim().ToString(); + } + } + + str = str[attributeEnd..]; + attributeIndex = str.IndexOf(attribute, StringComparison.OrdinalIgnoreCase); } // for imdbid we also accept pattern matching - if (string.Equals(attribute, "imdbid", StringComparison.OrdinalIgnoreCase)) + if (attribute.Equals("imdbid", StringComparison.OrdinalIgnoreCase)) { var match = ProviderIdParsers.TryFindImdbId(str, out var imdbId); return match ? imdbId.ToString() : null; @@ -53,7 +67,7 @@ namespace Emby.Server.Implementations.Library /// <param name="path">The original path.</param> /// <param name="subPath">The original sub path.</param> /// <param name="newSubPath">The new sub path.</param> - /// <param name="newPath">The result of the sub path replacement</param> + /// <param name="newPath">The result of the sub path replacement.</param> /// <returns>The path after replacing the sub path.</returns> /// <exception cref="ArgumentNullException"><paramref name="path" />, <paramref name="newSubPath" /> or <paramref name="newSubPath" /> is empty.</exception> public static bool TryReplaceSubPath( diff --git a/Emby.Server.Implementations/Library/Resolvers/Audio/AudioResolver.cs b/Emby.Server.Implementations/Library/Resolvers/Audio/AudioResolver.cs index fd9747b4b..7a6aea9c1 100644 --- a/Emby.Server.Implementations/Library/Resolvers/Audio/AudioResolver.cs +++ b/Emby.Server.Implementations/Library/Resolvers/Audio/AudioResolver.cs @@ -6,7 +6,10 @@ 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; @@ -21,11 +24,11 @@ namespace Emby.Server.Implementations.Library.Resolvers.Audio /// </summary> public class AudioResolver : ItemResolver<MediaBrowser.Controller.Entities.Audio.Audio>, IMultiItemResolver { - private readonly ILibraryManager _libraryManager; + private readonly NamingOptions _namingOptions; - public AudioResolver(ILibraryManager libraryManager) + public AudioResolver(NamingOptions namingOptions) { - _libraryManager = libraryManager; + _namingOptions = namingOptions; } /// <summary> @@ -40,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) { @@ -56,12 +59,11 @@ namespace Emby.Server.Implementations.Library.Resolvers.Audio private MultiItemResolverResult ResolveMultipleInternal( Folder parent, List<FileSystemMetadata> files, - string collectionType, - IDirectoryService directoryService) + string collectionType) { if (string.Equals(collectionType, CollectionType.Books, StringComparison.OrdinalIgnoreCase)) { - return ResolveMultipleAudio<AudioBook>(parent, files, directoryService, false, collectionType, true); + return ResolveMultipleAudio(parent, files, true); } return null; @@ -87,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<AudioBook>(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); @@ -107,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; } @@ -141,29 +139,23 @@ namespace Emby.Server.Implementations.Library.Resolvers.Audio return null; } - private T FindAudio<T>(ItemResolveArgs args, string path, Folder parent, List<FileSystemMetadata> 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(args.Parent, args.GetActualFileSystemChildren(), parseName); - var result = ResolveMultipleAudio<T>(parent, fileSystemEntries, directoryService, supportsMultiVersion, collectionType, parseName) ?? - new MultiItemResolverResult(); - - 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<T>(Folder parent, IEnumerable<FileSystemMetadata> fileSystemEntries, IDirectoryService directoryService, bool suppportMultiEditions, string collectionType, bool parseName) - where T : MediaBrowser.Controller.Entities.Audio.Audio, new() + private MultiItemResolverResult ResolveMultipleAudio(Folder parent, IEnumerable<FileSystemMetadata> fileSystemEntries, bool parseName) { var files = new List<FileSystemMetadata>(); var items = new List<BaseItem>(); @@ -176,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 @@ -210,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, @@ -230,12 +220,12 @@ namespace Emby.Server.Implementations.Library.Resolvers.Audio return result; } - private bool ContainsFile(List<AudioBookInfo> result, FileSystemMetadata file) + private static bool ContainsFile(IEnumerable<AudioBookInfo> 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)) || @@ -246,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 8e1eccb10..da00b9cfa 100644 --- a/Emby.Server.Implementations/Library/Resolvers/Audio/MusicAlbumResolver.cs +++ b/Emby.Server.Implementations/Library/Resolvers/Audio/MusicAlbumResolver.cs @@ -6,6 +6,7 @@ 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; @@ -22,20 +23,17 @@ namespace Emby.Server.Implementations.Library.Resolvers.Audio public class MusicAlbumResolver : ItemResolver<MusicAlbum> { private readonly ILogger<MusicAlbumResolver> _logger; - private readonly IFileSystem _fileSystem; - private readonly ILibraryManager _libraryManager; + private readonly NamingOptions _namingOptions; /// <summary> /// Initializes a new instance of the <see cref="MusicAlbumResolver"/> class. /// </summary> /// <param name="logger">The logger.</param> - /// <param name="fileSystem">The file system.</param> - /// <param name="libraryManager">The library manager.</param> - public MusicAlbumResolver(ILogger<MusicAlbumResolver> logger, IFileSystem fileSystem, ILibraryManager libraryManager) + /// <param name="namingOptions">The naming options.</param> + public MusicAlbumResolver(ILogger<MusicAlbumResolver> logger, NamingOptions namingOptions) { _logger = logger; - _fileSystem = fileSystem; - _libraryManager = libraryManager; + _namingOptions = namingOptions; } /// <summary> @@ -82,9 +80,12 @@ namespace Emby.Server.Implementations.Library.Resolvers.Audio /// <summary> /// Determine if the supplied file data points to a music album. /// </summary> + /// <param name="path">The path to check.</param> + /// <param name="directoryService">The directory service.</param> + /// <returns><c>true</c> if the provided path points to a music album, <c>false</c> otherwise.</returns> public bool IsMusicAlbum(string path, IDirectoryService directoryService) { - return ContainsMusic(directoryService.GetFileSystemEntries(path), true, directoryService, _logger, _fileSystem, _libraryManager); + return ContainsMusic(directoryService.GetFileSystemEntries(path), true, directoryService); } /// <summary> @@ -98,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,15 +112,12 @@ namespace Emby.Server.Implementations.Library.Resolvers.Audio /// Determine if the supplied list contains what we should consider music. /// </summary> private bool ContainsMusic( - IEnumerable<FileSystemMetadata> list, + ICollection<FileSystemMetadata> list, bool allowSubfolders, - IDirectoryService directoryService, - ILogger<MusicAlbumResolver> 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 @@ -134,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 3d2ae95d2..210ed0953 100644 --- a/Emby.Server.Implementations/Library/Resolvers/Audio/MusicArtistResolver.cs +++ b/Emby.Server.Implementations/Library/Resolvers/Audio/MusicArtistResolver.cs @@ -3,12 +3,11 @@ using System; using System.Linq; using System.Threading.Tasks; -using MediaBrowser.Controller.Configuration; +using Emby.Naming.Common; using MediaBrowser.Controller.Entities.Audio; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Resolvers; using MediaBrowser.Model.Entities; -using MediaBrowser.Model.IO; using Microsoft.Extensions.Logging; namespace Emby.Server.Implementations.Library.Resolvers.Audio @@ -19,27 +18,19 @@ namespace Emby.Server.Implementations.Library.Resolvers.Audio public class MusicArtistResolver : ItemResolver<MusicArtist> { private readonly ILogger<MusicAlbumResolver> _logger; - private readonly IFileSystem _fileSystem; - private readonly ILibraryManager _libraryManager; - private readonly IServerConfigurationManager _config; + private NamingOptions _namingOptions; /// <summary> /// Initializes a new instance of the <see cref="MusicArtistResolver"/> class. /// </summary> /// <param name="logger">The logger for the created <see cref="MusicAlbumResolver"/> instances.</param> - /// <param name="fileSystem">The file system.</param> - /// <param name="libraryManager">The library manager.</param> - /// <param name="config">The configuration manager.</param> + /// <param name="namingOptions">The naming options.</param> public MusicArtistResolver( ILogger<MusicAlbumResolver> logger, - IFileSystem fileSystem, - ILibraryManager libraryManager, - IServerConfigurationManager config) + NamingOptions namingOptions) { _logger = logger; - _fileSystem = fileSystem; - _libraryManager = libraryManager; - _config = config; + _namingOptions = namingOptions; } /// <summary> @@ -89,7 +80,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 b102b86cf..9222a9479 100644 --- a/Emby.Server.Implementations/Library/Resolvers/BaseVideoResolver.cs +++ b/Emby.Server.Implementations/Library/Resolvers/BaseVideoResolver.cs @@ -6,6 +6,7 @@ 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; @@ -21,12 +22,12 @@ namespace Emby.Server.Implementations.Library.Resolvers public abstract class BaseVideoResolver<T> : MediaBrowser.Controller.Resolvers.ItemResolver<T> where T : Video, new() { - protected BaseVideoResolver(ILibraryManager libraryManager) + protected BaseVideoResolver(NamingOptions namingOptions) { - LibraryManager = libraryManager; + NamingOptions = namingOptions; } - protected ILibraryManager LibraryManager { get; } + protected NamingOptions NamingOptions { get; } /// <summary> /// Resolves the specified args. @@ -48,120 +49,71 @@ namespace Emby.Server.Implementations.Library.Resolvers protected virtual TVideoType ResolveVideo<TVideoType>(ItemResolveArgs args, bool parseName) where TVideoType : Video, new() { - var namingOptions = LibraryManager.GetNamingOptions(); + VideoFileInfo videoInfo = null; + VideoType? videoType = null; // If the path is a file check for a matching extensions if (args.IsDirectory) { - TVideoType video = null; - VideoFileInfo videoInfo = null; - // Loop through each child file/folder and see if we find a video foreach (var child in args.FileSystemChildren) { var filename = child.Name; - if (child.IsDirectory) { if (IsDvdDirectory(child.FullName, filename, args.DirectoryService)) { - videoInfo = VideoResolver.ResolveDirectory(args.Path, namingOptions); - - if (videoInfo == null) - { - return null; - } - - video = new TVideoType - { - Path = args.Path, - VideoType = VideoType.Dvd, - ProductionYear = videoInfo.Year - }; - break; + videoType = VideoType.Dvd; } - - if (IsBluRayDirectory(filename)) + else if (IsBluRayDirectory(filename)) { - videoInfo = VideoResolver.ResolveDirectory(args.Path, namingOptions); - - if (videoInfo == null) - { - return null; - } - - video = new TVideoType - { - Path = args.Path, - VideoType = VideoType.BluRay, - ProductionYear = videoInfo.Year - }; - break; + videoType = VideoType.BluRay; } } else if (IsDvdFile(filename)) { - videoInfo = VideoResolver.ResolveDirectory(args.Path, namingOptions); - - if (videoInfo == null) - { - return null; - } - - video = new TVideoType - { - Path = args.Path, - VideoType = VideoType.Dvd, - ProductionYear = videoInfo.Year - }; - break; + videoType = VideoType.Dvd; } + + if (videoType == null) + { + continue; + } + + videoInfo = VideoResolver.ResolveDirectory(args.Path, NamingOptions, parseName); + break; } - - if (video != null) - { - video.Name = parseName ? - videoInfo.Name : - Path.GetFileName(args.Path); - - Set3DFormat(video, videoInfo); - } - - return video; } else { - var videoInfo = VideoResolver.Resolve(args.Path, false, namingOptions, false); - - if (videoInfo == null) - { - return null; - } - - if (LibraryManager.IsVideoFile(args.Path) || videoInfo.IsStub) - { - var path = args.Path; - - var video = new TVideoType - { - Path = path, - IsInMixedFolder = true, - ProductionYear = videoInfo.Year - }; - - SetVideoType(video, videoInfo); - - video.Name = parseName ? - videoInfo.Name : - Path.GetFileNameWithoutExtension(args.Path); - - Set3DFormat(video, videoInfo); - - return video; - } + videoInfo = VideoResolver.Resolve(args.Path, false, NamingOptions, parseName); } - return null; + if (videoInfo == null || (!videoInfo.IsStub && !VideoResolver.IsVideoFile(args.Path, NamingOptions))) + { + return null; + } + + var video = new TVideoType + { + Name = videoInfo.Name, + Path = args.Path, + ProductionYear = videoInfo.Year, + ExtraType = videoInfo.ExtraType + }; + + if (videoType.HasValue) + { + video.VideoType = videoType.Value; + } + else + { + SetVideoType(video, videoInfo); + } + + Set3DFormat(video, videoInfo); + + return video; } protected void SetVideoType(Video video, VideoFileInfo videoInfo) @@ -206,8 +158,8 @@ namespace Emby.Server.Implementations.Library.Resolvers { // use disc-utils, both DVDs and BDs use UDF filesystem using (var videoFileStream = File.Open(video.Path, FileMode.Open, FileAccess.Read)) + using (UdfReader udfReader = new UdfReader(videoFileStream)) { - UdfReader udfReader = new UdfReader(videoFileStream); if (udfReader.DirectoryExists("VIDEO_TS")) { video.IsoType = IsoType.Dvd; @@ -267,7 +219,7 @@ namespace Emby.Server.Implementations.Library.Resolvers protected void Set3DFormat(Video video) { - var result = Format3DParser.Parse(video.Path, LibraryManager.GetNamingOptions()); + var result = Format3DParser.Parse(video.Path, NamingOptions); Set3DFormat(video, result.Is3D, result.Format3D); } @@ -275,6 +227,10 @@ namespace Emby.Server.Implementations.Library.Resolvers /// <summary> /// Determines whether [is DVD directory] [the specified directory name]. /// </summary> + /// <param name="fullPath">The full path of the directory.</param> + /// <param name="directoryName">The name of the directory.</param> + /// <param name="directoryService">The directory service.</param> + /// <returns><c>true</c> if the provided directory is a DVD directory, <c>false</c> otherwise.</returns> protected bool IsDvdDirectory(string fullPath, string directoryName, IDirectoryService directoryService) { if (!string.Equals(directoryName, "video_ts", StringComparison.OrdinalIgnoreCase)) diff --git a/Emby.Server.Implementations/Library/Resolvers/Books/BookResolver.cs b/Emby.Server.Implementations/Library/Resolvers/Books/BookResolver.cs index 68076730b..8f224f547 100644 --- a/Emby.Server.Implementations/Library/Resolvers/Books/BookResolver.cs +++ b/Emby.Server.Implementations/Library/Resolvers/Books/BookResolver.cs @@ -5,6 +5,7 @@ using System; using System.IO; using System.Linq; +using Jellyfin.Extensions; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; using MediaBrowser.Model.Entities; @@ -32,7 +33,7 @@ namespace Emby.Server.Implementations.Library.Resolvers.Books var extension = Path.GetExtension(args.Path); - if (extension != null && _validExtensions.Contains(extension, StringComparer.OrdinalIgnoreCase)) + if (extension != null && _validExtensions.Contains(extension, StringComparison.OrdinalIgnoreCase)) { // It's a book return new Book @@ -49,13 +50,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/ExtraResolver.cs b/Emby.Server.Implementations/Library/Resolvers/ExtraResolver.cs new file mode 100644 index 000000000..807913b5d --- /dev/null +++ b/Emby.Server.Implementations/Library/Resolvers/ExtraResolver.cs @@ -0,0 +1,93 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using System.IO; +using Emby.Naming.Common; +using Emby.Naming.Video; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Resolvers; +using MediaBrowser.Model.Entities; +using static Emby.Naming.Video.ExtraRuleResolver; + +namespace Emby.Server.Implementations.Library.Resolvers +{ + /// <summary> + /// Resolves a Path into a Video or Video subclass. + /// </summary> + internal class ExtraResolver + { + private readonly NamingOptions _namingOptions; + private readonly IItemResolver[] _trailerResolvers; + private readonly IItemResolver[] _videoResolvers; + + /// <summary> + /// Initializes a new instance of the <see cref="ExtraResolver"/> class. + /// </summary> + /// <param name="namingOptions">An instance of <see cref="NamingOptions"/>.</param> + public ExtraResolver(NamingOptions namingOptions) + { + _namingOptions = namingOptions; + _trailerResolvers = new IItemResolver[] { new GenericVideoResolver<Trailer>(namingOptions) }; + _videoResolvers = new IItemResolver[] { new GenericVideoResolver<Video>(namingOptions) }; + } + + /// <summary> + /// Gets the resolvers for the extra type. + /// </summary> + /// <param name="extraType">The extra type.</param> + /// <returns>The resolvers for the extra type.</returns> + public IItemResolver[]? GetResolversForExtraType(ExtraType extraType) => extraType switch + { + ExtraType.Trailer => _trailerResolvers, + // For audio we'll have to rely on the AudioResolver, which is a "built-in" + ExtraType.ThemeSong => null, + _ => _videoResolvers + }; + + public bool TryGetExtraTypeForOwner(string path, VideoFileInfo ownerVideoFileInfo, [NotNullWhen(true)] out ExtraType? extraType) + { + var extraResult = GetExtraInfo(path, _namingOptions); + if (extraResult.ExtraType == null) + { + extraType = null; + return false; + } + + var cleanDateTimeResult = CleanDateTimeParser.Clean(Path.GetFileNameWithoutExtension(path), _namingOptions.CleanDateTimeRegexes); + var name = cleanDateTimeResult.Name; + var year = cleanDateTimeResult.Year; + + var parentDir = ownerVideoFileInfo.IsDirectory ? ownerVideoFileInfo.Path : Path.GetDirectoryName(ownerVideoFileInfo.Path.AsSpan()); + + var trimmedFileNameWithoutExtension = TrimFilenameDelimiters(ownerVideoFileInfo.FileNameWithoutExtension, _namingOptions.VideoFlagDelimiters); + var trimmedVideoInfoName = TrimFilenameDelimiters(ownerVideoFileInfo.Name, _namingOptions.VideoFlagDelimiters); + var trimmedExtraFileName = TrimFilenameDelimiters(name, _namingOptions.VideoFlagDelimiters); + + // first check filenames + bool isValid = StartsWith(trimmedExtraFileName, trimmedFileNameWithoutExtension) + || (StartsWith(trimmedExtraFileName, trimmedVideoInfoName) && year == ownerVideoFileInfo.Year); + + if (!isValid) + { + // When the extra rule type is DirectoryName we must go one level higher to get the "real" dir name + var currentParentDir = extraResult.Rule?.RuleType == ExtraRuleType.DirectoryName + ? Path.GetDirectoryName(Path.GetDirectoryName(path.AsSpan())) + : Path.GetDirectoryName(path.AsSpan()); + + isValid = !currentParentDir.IsEmpty && !parentDir.IsEmpty && currentParentDir.Equals(parentDir, StringComparison.OrdinalIgnoreCase); + } + + extraType = extraResult.ExtraType; + return isValid; + } + + private static ReadOnlySpan<char> TrimFilenameDelimiters(ReadOnlySpan<char> name, ReadOnlySpan<char> videoFlagDelimiters) + { + return name.IsEmpty ? name : name.TrimEnd().TrimEnd(videoFlagDelimiters).TrimEnd(); + } + + private static bool StartsWith(ReadOnlySpan<char> fileName, ReadOnlySpan<char> baseName) + { + return !baseName.IsEmpty && fileName.StartsWith(baseName, StringComparison.OrdinalIgnoreCase); + } + } +} diff --git a/Emby.Server.Implementations/Library/Resolvers/FolderResolver.cs b/Emby.Server.Implementations/Library/Resolvers/FolderResolver.cs index 7aaee017d..db7703cd6 100644 --- a/Emby.Server.Implementations/Library/Resolvers/FolderResolver.cs +++ b/Emby.Server.Implementations/Library/Resolvers/FolderResolver.cs @@ -9,7 +9,7 @@ namespace Emby.Server.Implementations.Library.Resolvers /// <summary> /// Class FolderResolver. /// </summary> - public class FolderResolver : FolderResolver<Folder> + public class FolderResolver : GenericFolderResolver<Folder> { /// <summary> /// Gets the priority. @@ -32,24 +32,4 @@ namespace Emby.Server.Implementations.Library.Resolvers return null; } } - - /// <summary> - /// Class FolderResolver. - /// </summary> - /// <typeparam name="TItemType">The type of the T item type.</typeparam> - public abstract class FolderResolver<TItemType> : ItemResolver<TItemType> - where TItemType : Folder, new() - { - /// <summary> - /// Sets the initial item values. - /// </summary> - /// <param name="item">The item.</param> - /// <param name="args">The args.</param> - 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 000000000..f109a5e9a --- /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 +{ + /// <summary> + /// Class FolderResolver. + /// </summary> + /// <typeparam name="TItemType">The type of the T item type.</typeparam> + public abstract class GenericFolderResolver<TItemType> : ItemResolver<TItemType> + where TItemType : Folder, new() + { + /// <summary> + /// Sets the initial item values. + /// </summary> + /// <param name="item">The item.</param> + /// <param name="args">The args.</param> + 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/GenericVideoResolver.cs b/Emby.Server.Implementations/Library/Resolvers/GenericVideoResolver.cs index 9599faea4..b8554bd51 100644 --- a/Emby.Server.Implementations/Library/Resolvers/GenericVideoResolver.cs +++ b/Emby.Server.Implementations/Library/Resolvers/GenericVideoResolver.cs @@ -1,17 +1,23 @@ -#nullable disable - -#pragma warning disable CS1591 +#nullable disable +using Emby.Naming.Common; using MediaBrowser.Controller.Entities; -using MediaBrowser.Controller.Library; namespace Emby.Server.Implementations.Library.Resolvers { + /// <summary> + /// Resolves a Path into an instance of the <see cref="Video"/> class. + /// </summary> + /// <typeparam name="T">The type of item to resolve.</typeparam> public class GenericVideoResolver<T> : BaseVideoResolver<T> where T : Video, new() { - public GenericVideoResolver(ILibraryManager libraryManager) - : base(libraryManager) + /// <summary> + /// Initializes a new instance of the <see cref="GenericVideoResolver{T}"/> class. + /// </summary> + /// <param name="namingOptions">The naming options.</param> + public GenericVideoResolver(NamingOptions namingOptions) + : base(namingOptions) { } } diff --git a/Emby.Server.Implementations/Library/Resolvers/Movies/BoxSetResolver.cs b/Emby.Server.Implementations/Library/Resolvers/Movies/BoxSetResolver.cs index 69d71d0d9..6cc04ea81 100644 --- a/Emby.Server.Implementations/Library/Resolvers/Movies/BoxSetResolver.cs +++ b/Emby.Server.Implementations/Library/Resolvers/Movies/BoxSetResolver.cs @@ -12,7 +12,7 @@ namespace Emby.Server.Implementations.Library.Resolvers.Movies /// <summary> /// Class BoxSetResolver. /// </summary> - public class BoxSetResolver : FolderResolver<BoxSet> + public class BoxSetResolver : GenericFolderResolver<BoxSet> { /// <summary> /// Resolves the specified args. @@ -65,7 +65,7 @@ namespace Emby.Server.Implementations.Library.Resolvers.Movies private static void SetProviderIdFromPath(BaseItem item) { // we need to only look at the name of this actual item (not parents) - var justName = Path.GetFileName(item.Path); + var justName = Path.GetFileName(item.Path.AsSpan()); var id = justName.GetAttributeValue("tmdbid"); diff --git a/Emby.Server.Implementations/Library/Resolvers/Movies/MovieResolver.cs b/Emby.Server.Implementations/Library/Resolvers/Movies/MovieResolver.cs index 8b55a7744..1a9295dc8 100644 --- a/Emby.Server.Implementations/Library/Resolvers/Movies/MovieResolver.cs +++ b/Emby.Server.Implementations/Library/Resolvers/Movies/MovieResolver.cs @@ -5,6 +5,7 @@ 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; @@ -24,6 +25,8 @@ namespace Emby.Server.Implementations.Library.Resolvers.Movies /// </summary> public class MovieResolver : BaseVideoResolver<Video>, IMultiItemResolver { + private readonly IImageProcessor _imageProcessor; + private string[] _validCollectionTypes = new[] { CollectionType.Movies, @@ -33,15 +36,13 @@ namespace Emby.Server.Implementations.Library.Resolvers.Movies CollectionType.Photos }; - private readonly IImageProcessor _imageProcessor; - /// <summary> /// Initializes a new instance of the <see cref="MovieResolver"/> class. /// </summary> - /// <param name="libraryManager">The library manager.</param> /// <param name="imageProcessor">The image processor.</param> - public MovieResolver(ILibraryManager libraryManager, IImageProcessor imageProcessor) - : base(libraryManager) + /// <param name="namingOptions">The naming options.</param> + public MovieResolver(IImageProcessor imageProcessor, NamingOptions namingOptions) + : base(namingOptions) { _imageProcessor = imageProcessor; } @@ -59,7 +60,7 @@ namespace Emby.Server.Implementations.Library.Resolvers.Movies string collectionType, IDirectoryService directoryService) { - var result = ResolveMultipleInternal(parent, files, collectionType, directoryService); + var result = ResolveMultipleInternal(parent, files, collectionType); if (result != null) { @@ -89,26 +90,24 @@ namespace Emby.Server.Implementations.Library.Resolvers.Movies return null; } - var files = args.FileSystemChildren - .Where(i => !LibraryManager.IgnoreFile(i, args.Parent)) - .ToList(); + Video movie = null; + var files = args.GetActualFileSystemChildren().ToList(); if (string.Equals(collectionType, CollectionType.MusicVideos, StringComparison.OrdinalIgnoreCase)) { - return FindMovie<MusicVideo>(args, args.Path, args.Parent, files, args.DirectoryService, collectionType, false); + movie = FindMovie<MusicVideo>(args, args.Path, args.Parent, files, args.DirectoryService, collectionType, false); } if (string.Equals(collectionType, CollectionType.HomeVideos, StringComparison.OrdinalIgnoreCase)) { - return FindMovie<Video>(args, args.Path, args.Parent, files, args.DirectoryService, collectionType, false); + movie = FindMovie<Video>(args, args.Path, args.Parent, files, args.DirectoryService, collectionType, false); } if (string.IsNullOrEmpty(collectionType)) { - // Owned items will be caught by the plain video resolver + // Owned items will be caught by the video extra resolver if (args.Parent == null) { - // return FindMovie<Video>(args.Path, args.Parent, files, args.DirectoryService, collectionType); return null; } @@ -117,23 +116,22 @@ namespace Emby.Server.Implementations.Library.Resolvers.Movies return null; } - { - return FindMovie<Movie>(args, args.Path, args.Parent, files, args.DirectoryService, collectionType, true); - } + movie = FindMovie<Movie>(args, args.Path, args.Parent, files, args.DirectoryService, collectionType, true); } if (string.Equals(collectionType, CollectionType.Movies, StringComparison.OrdinalIgnoreCase)) { - return FindMovie<Movie>(args, args.Path, args.Parent, files, args.DirectoryService, collectionType, true); + movie = FindMovie<Movie>(args, args.Path, args.Parent, files, args.DirectoryService, collectionType, true); } - return null; + // ignore extras + return movie?.ExtraType == null ? movie : null; } - // Handle owned items + // Owned items will be caught by the video extra resolver if (args.Parent == null) { - return base.Resolve(args); + return null; } if (IsInvalid(args.Parent, collectionType)) @@ -168,6 +166,12 @@ namespace Emby.Server.Implementations.Library.Resolvers.Movies item = ResolveVideo<Video>(args, false); } + // Ignore extras + if (item?.ExtraType != null) + { + return null; + } + if (item != null) { item.IsInMixedFolder = true; @@ -179,8 +183,7 @@ namespace Emby.Server.Implementations.Library.Resolvers.Movies private MultiItemResolverResult ResolveMultipleInternal( Folder parent, List<FileSystemMetadata> files, - string collectionType, - IDirectoryService directoryService) + string collectionType) { if (IsInvalid(parent, collectionType)) { @@ -189,13 +192,13 @@ namespace Emby.Server.Implementations.Library.Resolvers.Movies if (string.Equals(collectionType, CollectionType.MusicVideos, StringComparison.OrdinalIgnoreCase)) { - return ResolveVideos<MusicVideo>(parent, files, directoryService, true, collectionType, false); + return ResolveVideos<MusicVideo>(parent, files, true, collectionType, false); } if (string.Equals(collectionType, CollectionType.HomeVideos, StringComparison.OrdinalIgnoreCase) || string.Equals(collectionType, CollectionType.Photos, StringComparison.OrdinalIgnoreCase)) { - return ResolveVideos<Video>(parent, files, directoryService, false, collectionType, false); + return ResolveVideos<Video>(parent, files, false, collectionType, false); } if (string.IsNullOrEmpty(collectionType)) @@ -203,7 +206,7 @@ namespace Emby.Server.Implementations.Library.Resolvers.Movies // Owned items should just use the plain video type if (parent == null) { - return ResolveVideos<Video>(parent, files, directoryService, false, collectionType, false); + return ResolveVideos<Video>(parent, files, false, collectionType, false); } if (parent is Series || parent.GetParents().OfType<Series>().Any()) @@ -211,12 +214,12 @@ namespace Emby.Server.Implementations.Library.Resolvers.Movies return null; } - return ResolveVideos<Movie>(parent, files, directoryService, false, collectionType, true); + return ResolveVideos<Movie>(parent, files, false, collectionType, true); } if (string.Equals(collectionType, CollectionType.Movies, StringComparison.OrdinalIgnoreCase)) { - return ResolveVideos<Movie>(parent, files, directoryService, true, collectionType, true); + return ResolveVideos<Movie>(parent, files, true, collectionType, true); } return null; @@ -225,21 +228,20 @@ namespace Emby.Server.Implementations.Library.Resolvers.Movies private MultiItemResolverResult ResolveVideos<T>( Folder parent, IEnumerable<FileSystemMetadata> fileSystemEntries, - IDirectoryService directoryService, - bool suppportMultiEditions, + bool supportMultiEditions, string collectionType, bool parseName) where T : Video, new() { var files = new List<FileSystemMetadata>(); - var videos = new List<BaseItem>(); var leftOver = new List<FileSystemMetadata>(); + var hasCollectionType = !string.IsNullOrEmpty(collectionType); // Loop through each child file/folder and see if we find a video foreach (var child in fileSystemEntries) { // This is a hack but currently no better way to resolve a sometimes ambiguous situation - if (string.IsNullOrEmpty(collectionType)) + if (!hasCollectionType) { if (string.Equals(child.Name, "tvshow.nfo", StringComparison.OrdinalIgnoreCase) || string.Equals(child.Name, "season.nfo", StringComparison.OrdinalIgnoreCase)) @@ -258,31 +260,39 @@ namespace Emby.Server.Implementations.Library.Resolvers.Movies } } - var namingOptions = LibraryManager.GetNamingOptions(); + var videoInfos = files + .Select(i => VideoResolver.Resolve(i.FullName, i.IsDirectory, NamingOptions, parseName)) + .Where(f => f != null) + .ToList(); - var resolverResult = VideoListResolver.Resolve(files, namingOptions, suppportMultiEditions).ToList(); + var resolverResult = VideoListResolver.Resolve(videoInfos, NamingOptions, supportMultiEditions, parseName); var result = new MultiItemResolverResult { - ExtraFiles = leftOver, - Items = videos + ExtraFiles = leftOver }; - var isInMixedFolder = resolverResult.Count > 1 || (parent != null && parent.IsTopParent); + var isInMixedFolder = resolverResult.Count > 1 || parent?.IsTopParent == true; foreach (var video in resolverResult) { var firstVideo = video.Files[0]; + var path = firstVideo.Path; + if (video.ExtraType != null) + { + result.ExtraFiles.Add(files.Find(f => string.Equals(f.FullName, path, StringComparison.OrdinalIgnoreCase))); + continue; + } + + var additionalParts = video.Files.Count > 1 ? video.Files.Skip(1).Select(i => i.Path).ToArray() : Array.Empty<string>(); var videoItem = new T { - Path = video.Files[0].Path, + Path = path, IsInMixedFolder = isInMixedFolder, ProductionYear = video.Year, - Name = parseName ? - video.Name : - Path.GetFileNameWithoutExtension(video.Files[0].Path), - AdditionalParts = video.Files.Skip(1).Select(i => i.Path).ToArray(), + Name = parseName ? video.Name : firstVideo.Name, + AdditionalParts = additionalParts, LocalAlternateVersions = video.AlternateVersions.Select(i => i.Path).ToArray() }; @@ -300,21 +310,34 @@ namespace Emby.Server.Implementations.Library.Resolvers.Movies private static bool IsIgnored(string filename) { // Ignore samples - Match m = Regex.Match(filename, @"\bsample\b", RegexOptions.IgnoreCase); + Match m = Regex.Match(filename, @"\bsample\b", RegexOptions.IgnoreCase | RegexOptions.Compiled); return m.Success; } - private bool ContainsFile(List<VideoInfo> result, FileSystemMetadata file) + private static bool ContainsFile(IReadOnlyList<VideoInfo> result, FileSystemMetadata file) { - return result.Any(i => ContainsFile(i, file)); - } + for (var i = 0; i < result.Count; i++) + { + var current = result[i]; + for (var j = 0; j < current.Files.Count; j++) + { + if (ContainsFile(current.Files[j], file)) + { + return true; + } + } - private bool ContainsFile(VideoInfo result, FileSystemMetadata file) - { - return result.Files.Any(i => ContainsFile(i, file)) || - result.AlternateVersions.Any(i => ContainsFile(i, file)) || - result.Extras.Any(i => ContainsFile(i, file)); + for (var j = 0; j < current.AlternateVersions.Count; j++) + { + if (ContainsFile(current.AlternateVersions[j], file)) + { + return true; + } + } + } + + return false; } private static bool ContainsFile(VideoFileInfo result, FileSystemMetadata file) @@ -343,9 +366,9 @@ namespace Emby.Server.Implementations.Library.Resolvers.Movies if (item is Movie || item is MusicVideo) { // We need to only look at the name of this actual item (not parents) - var justName = item.IsInMixedFolder ? Path.GetFileName(item.Path) : Path.GetFileName(item.ContainingFolderPath); + var justName = item.IsInMixedFolder ? Path.GetFileName(item.Path.AsSpan()) : Path.GetFileName(item.ContainingFolderPath.AsSpan()); - if (!string.IsNullOrEmpty(justName)) + if (!justName.IsEmpty) { // check for tmdb id var tmdbid = justName.GetAttributeValue("tmdbid"); @@ -359,7 +382,7 @@ namespace Emby.Server.Implementations.Library.Resolvers.Movies if (!string.IsNullOrEmpty(item.Path)) { // check for imdb id - we use full media path, as we can assume, that this will match in any use case (wither id in parent dir or in file name) - var imdbid = item.Path.GetAttributeValue("imdbid"); + var imdbid = item.Path.AsSpan().GetAttributeValue("imdbid"); if (!string.IsNullOrWhiteSpace(imdbid)) { @@ -432,13 +455,13 @@ namespace Emby.Server.Implementations.Library.Resolvers.Movies // TODO: Allow GetMultiDiscMovie in here const bool SupportsMultiVersion = true; - var result = ResolveVideos<T>(parent, fileSystemEntries, directoryService, SupportsMultiVersion, collectionType, parseName) ?? + var result = ResolveVideos<T>(parent, fileSystemEntries, SupportsMultiVersion, collectionType, parseName) ?? new MultiItemResolverResult(); if (result.Items.Count == 1) { var videoPath = result.Items[0].Path; - var hasPhotos = photos.Any(i => !PhotoResolver.IsOwnedByResolvedMedia(LibraryManager, videoPath, i.Name)); + var hasPhotos = photos.Any(i => !PhotoResolver.IsOwnedByResolvedMedia(videoPath, i.Name)); if (!hasPhotos) { @@ -511,9 +534,7 @@ namespace Emby.Server.Implementations.Library.Resolvers.Movies return null; } - var namingOptions = ((LibraryManager)LibraryManager).GetNamingOptions(); - - var result = new StackResolver(namingOptions).ResolveDirectories(folderPaths).ToList(); + var result = StackResolver.ResolveDirectories(folderPaths, NamingOptions).ToList(); if (result.Count != 1) { diff --git a/Emby.Server.Implementations/Library/Resolvers/PhotoAlbumResolver.cs b/Emby.Server.Implementations/Library/Resolvers/PhotoAlbumResolver.cs index 534bc80dd..7dd0ab185 100644 --- a/Emby.Server.Implementations/Library/Resolvers/PhotoAlbumResolver.cs +++ b/Emby.Server.Implementations/Library/Resolvers/PhotoAlbumResolver.cs @@ -1,6 +1,7 @@ #nullable disable using System; +using Emby.Naming.Common; using MediaBrowser.Controller.Drawing; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; @@ -12,20 +13,20 @@ namespace Emby.Server.Implementations.Library.Resolvers /// <summary> /// Class PhotoAlbumResolver. /// </summary> - public class PhotoAlbumResolver : FolderResolver<PhotoAlbum> + public class PhotoAlbumResolver : GenericFolderResolver<PhotoAlbum> { private readonly IImageProcessor _imageProcessor; - private readonly ILibraryManager _libraryManager; + private readonly NamingOptions _namingOptions; /// <summary> /// Initializes a new instance of the <see cref="PhotoAlbumResolver"/> class. /// </summary> /// <param name="imageProcessor">The image processor.</param> - /// <param name="libraryManager">The library manager.</param> - public PhotoAlbumResolver(IImageProcessor imageProcessor, ILibraryManager libraryManager) + /// <param name="namingOptions">The naming options.</param> + public PhotoAlbumResolver(IImageProcessor imageProcessor, NamingOptions namingOptions) { _imageProcessor = imageProcessor; - _libraryManager = libraryManager; + _namingOptions = namingOptions; } /// <inheritdoc /> @@ -73,7 +74,7 @@ namespace Emby.Server.Implementations.Library.Resolvers foreach (var siblingFile in files) { - if (PhotoResolver.IsOwnedByMedia(_libraryManager, siblingFile.FullName, filename)) + if (PhotoResolver.IsOwnedByMedia(_namingOptions, siblingFile.FullName, filename)) { ownedByMedia = true; break; diff --git a/Emby.Server.Implementations/Library/Resolvers/PhotoResolver.cs b/Emby.Server.Implementations/Library/Resolvers/PhotoResolver.cs index 57bf40e9e..bc2915db6 100644 --- a/Emby.Server.Implementations/Library/Resolvers/PhotoResolver.cs +++ b/Emby.Server.Implementations/Library/Resolvers/PhotoResolver.cs @@ -6,6 +6,9 @@ using System; using System.Collections.Generic; using System.IO; using System.Linq; +using Emby.Naming.Common; +using Emby.Naming.Video; +using Jellyfin.Extensions; using MediaBrowser.Controller.Drawing; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; @@ -16,7 +19,8 @@ namespace Emby.Server.Implementations.Library.Resolvers public class PhotoResolver : ItemResolver<Photo> { private readonly IImageProcessor _imageProcessor; - private readonly ILibraryManager _libraryManager; + private readonly NamingOptions _namingOptions; + private static readonly HashSet<string> _ignoreFiles = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "folder", @@ -30,10 +34,10 @@ namespace Emby.Server.Implementations.Library.Resolvers "default" }; - public PhotoResolver(IImageProcessor imageProcessor, ILibraryManager libraryManager) + public PhotoResolver(IImageProcessor imageProcessor, NamingOptions namingOptions) { _imageProcessor = imageProcessor; - _libraryManager = libraryManager; + _namingOptions = namingOptions; } /// <summary> @@ -60,7 +64,7 @@ namespace Emby.Server.Implementations.Library.Resolvers foreach (var file in files) { - if (IsOwnedByMedia(_libraryManager, file.FullName, filename)) + if (IsOwnedByMedia(_namingOptions, file.FullName, filename)) { return null; } @@ -77,17 +81,12 @@ namespace Emby.Server.Implementations.Library.Resolvers return null; } - internal static bool IsOwnedByMedia(ILibraryManager libraryManager, string file, string imageFilename) + internal static bool IsOwnedByMedia(NamingOptions namingOptions, string file, string imageFilename) { - if (libraryManager.IsVideoFile(file)) - { - return IsOwnedByResolvedMedia(libraryManager, file, imageFilename); - } - - return false; + return VideoResolver.IsVideoFile(file, namingOptions) && IsOwnedByResolvedMedia(file, imageFilename); } - internal static bool IsOwnedByResolvedMedia(ILibraryManager libraryManager, string file, string imageFilename) + internal static bool IsOwnedByResolvedMedia(string file, string imageFilename) => imageFilename.StartsWith(Path.GetFileNameWithoutExtension(file), StringComparison.OrdinalIgnoreCase); internal static bool IsImageFile(string path, IImageProcessor imageProcessor) @@ -110,7 +109,7 @@ namespace Emby.Server.Implementations.Library.Resolvers } string extension = Path.GetExtension(path).TrimStart('.'); - return imageProcessor.SupportedInputFormats.Contains(extension, StringComparer.OrdinalIgnoreCase); + return imageProcessor.SupportedInputFormats.Contains(extension, StringComparison.OrdinalIgnoreCase); } } } diff --git a/Emby.Server.Implementations/Library/Resolvers/PlaylistResolver.cs b/Emby.Server.Implementations/Library/Resolvers/PlaylistResolver.cs index 2c4ead719..6b0dfe986 100644 --- a/Emby.Server.Implementations/Library/Resolvers/PlaylistResolver.cs +++ b/Emby.Server.Implementations/Library/Resolvers/PlaylistResolver.cs @@ -5,6 +5,7 @@ using System; using System.IO; using System.Linq; +using Jellyfin.Extensions; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Playlists; using MediaBrowser.Controller.Resolvers; @@ -16,7 +17,7 @@ namespace Emby.Server.Implementations.Library.Resolvers /// <summary> /// <see cref="IItemResolver"/> for <see cref="Playlist"/> library items. /// </summary> - public class PlaylistResolver : FolderResolver<Playlist> + public class PlaylistResolver : GenericFolderResolver<Playlist> { private string[] _musicPlaylistCollectionTypes = { @@ -57,10 +58,10 @@ namespace Emby.Server.Implementations.Library.Resolvers // Check if this is a music playlist file // It should have the correct collection type and a supported file extension - else if (_musicPlaylistCollectionTypes.Contains(args.CollectionType ?? string.Empty, StringComparer.OrdinalIgnoreCase)) + else if (_musicPlaylistCollectionTypes.Contains(args.CollectionType ?? string.Empty, StringComparison.OrdinalIgnoreCase)) { var extension = Path.GetExtension(args.Path); - if (Playlist.SupportedExtensions.Contains(extension ?? string.Empty, StringComparer.OrdinalIgnoreCase)) + if (Playlist.SupportedExtensions.Contains(extension ?? string.Empty, StringComparison.OrdinalIgnoreCase)) { return new Playlist { diff --git a/Emby.Server.Implementations/Library/Resolvers/SpecialFolderResolver.cs b/Emby.Server.Implementations/Library/Resolvers/SpecialFolderResolver.cs index 7b4e14334..6bb999641 100644 --- a/Emby.Server.Implementations/Library/Resolvers/SpecialFolderResolver.cs +++ b/Emby.Server.Implementations/Library/Resolvers/SpecialFolderResolver.cs @@ -13,7 +13,7 @@ using MediaBrowser.Model.IO; namespace Emby.Server.Implementations.Library.Resolvers { - public class SpecialFolderResolver : FolderResolver<Folder> + public class SpecialFolderResolver : GenericFolderResolver<Folder> { private readonly IFileSystem _fileSystem; private readonly IServerApplicationPaths _appPaths; @@ -67,7 +67,6 @@ namespace Emby.Server.Implementations.Library.Resolvers return args.FileSystemChildren .Where(i => { - try { return !i.IsDirectory && diff --git a/Emby.Server.Implementations/Library/Resolvers/TV/EpisodeResolver.cs b/Emby.Server.Implementations/Library/Resolvers/TV/EpisodeResolver.cs index d6ae91056..be9905647 100644 --- a/Emby.Server.Implementations/Library/Resolvers/TV/EpisodeResolver.cs +++ b/Emby.Server.Implementations/Library/Resolvers/TV/EpisodeResolver.cs @@ -2,7 +2,7 @@ using System; using System.Linq; -using MediaBrowser.Controller.Entities; +using Emby.Naming.Common; using MediaBrowser.Controller.Entities.TV; using MediaBrowser.Controller.Library; using MediaBrowser.Model.Entities; @@ -17,9 +17,9 @@ namespace Emby.Server.Implementations.Library.Resolvers.TV /// <summary> /// Initializes a new instance of the <see cref="EpisodeResolver"/> class. /// </summary> - /// <param name="libraryManager">The library manager.</param> - public EpisodeResolver(ILibraryManager libraryManager) - : base(libraryManager) + /// <param name="namingOptions">The naming options.</param> + public EpisodeResolver(NamingOptions namingOptions) + : base(namingOptions) { } @@ -44,34 +44,36 @@ namespace Emby.Server.Implementations.Library.Resolvers.TV // If the parent is a Season or Series and the parent is not an extras folder, then this is an Episode if the VideoResolver returns something // Also handle flat tv folders - if ((season != null || - string.Equals(args.GetCollectionType(), CollectionType.TvShows, StringComparison.OrdinalIgnoreCase) || - args.HasParent<Series>()) - && (parent is Series || !BaseItem.AllExtrasTypesFolderNames.Contains(parent.Name, StringComparer.OrdinalIgnoreCase))) + if (season != null || + string.Equals(args.GetCollectionType(), CollectionType.TvShows, StringComparison.OrdinalIgnoreCase) || + args.HasParent<Series>()) { var episode = ResolveVideo<Episode>(args, false); - if (episode != null) + // Ignore extras + if (episode == null || episode.ExtraType != null) { - var series = parent as Series ?? parent.GetParents().OfType<Series>().FirstOrDefault(); + return null; + } - if (series != null) - { - episode.SeriesId = series.Id; - episode.SeriesName = series.Name; - } + var series = parent as Series ?? parent.GetParents().OfType<Series>().FirstOrDefault(); - if (season != null) - { - episode.SeasonId = season.Id; - episode.SeasonName = season.Name; - } + if (series != null) + { + episode.SeriesId = series.Id; + episode.SeriesName = series.Name; + } - // Assume season 1 if there's no season folder and a season number could not be determined - if (season == null && !episode.ParentIndexNumber.HasValue && (episode.IndexNumber.HasValue || episode.PremiereDate.HasValue)) - { - episode.ParentIndexNumber = 1; - } + if (season != null) + { + episode.SeasonId = season.Id; + episode.SeasonName = season.Name; + } + + // Assume season 1 if there's no season folder and a season number could not be determined + if (season == null && !episode.ParentIndexNumber.HasValue && (episode.IndexNumber.HasValue || episode.PremiereDate.HasValue)) + { + episode.ParentIndexNumber = 1; } return episode; diff --git a/Emby.Server.Implementations/Library/Resolvers/TV/SeasonResolver.cs b/Emby.Server.Implementations/Library/Resolvers/TV/SeasonResolver.cs index 7d707df18..ea4851458 100644 --- a/Emby.Server.Implementations/Library/Resolvers/TV/SeasonResolver.cs +++ b/Emby.Server.Implementations/Library/Resolvers/TV/SeasonResolver.cs @@ -1,6 +1,7 @@ #nullable disable using System.Globalization; +using Emby.Naming.Common; using Emby.Naming.TV; using MediaBrowser.Controller.Entities.TV; using MediaBrowser.Controller.Library; @@ -12,24 +13,24 @@ namespace Emby.Server.Implementations.Library.Resolvers.TV /// <summary> /// Class SeasonResolver. /// </summary> - public class SeasonResolver : FolderResolver<Season> + public class SeasonResolver : GenericFolderResolver<Season> { - private readonly ILibraryManager _libraryManager; private readonly ILocalizationManager _localization; private readonly ILogger<SeasonResolver> _logger; + private readonly NamingOptions _namingOptions; /// <summary> /// Initializes a new instance of the <see cref="SeasonResolver"/> class. /// </summary> - /// <param name="libraryManager">The library manager.</param> + /// <param name="namingOptions">The naming options.</param> /// <param name="localization">The localization.</param> /// <param name="logger">The logger.</param> public SeasonResolver( - ILibraryManager libraryManager, + NamingOptions namingOptions, ILocalizationManager localization, ILogger<SeasonResolver> logger) { - _libraryManager = libraryManager; + _namingOptions = namingOptions; _localization = localization; _logger = logger; } @@ -43,7 +44,7 @@ namespace Emby.Server.Implementations.Library.Resolvers.TV { if (args.Parent is Series series && args.IsDirectory) { - var namingOptions = ((LibraryManager)_libraryManager).GetNamingOptions(); + var namingOptions = _namingOptions; var path = args.Path; @@ -65,18 +66,15 @@ namespace Emby.Server.Implementations.Library.Resolvers.TV var episodeInfo = resolver.Resolve(testPath, true); - if (episodeInfo != null) + if (episodeInfo?.EpisodeNumber != null && episodeInfo.SeasonNumber.HasValue) { - if (episodeInfo.EpisodeNumber.HasValue && episodeInfo.SeasonNumber.HasValue) - { - _logger.LogDebug( - "Found folder underneath series with episode number: {0}. Season {1}. Episode {2}", - path, - episodeInfo.SeasonNumber.Value, - episodeInfo.EpisodeNumber.Value); + _logger.LogDebug( + "Found folder underneath series with episode number: {0}. Season {1}. Episode {2}", + path, + episodeInfo.SeasonNumber.Value, + episodeInfo.EpisodeNumber.Value); - return null; - } + return null; } } diff --git a/Emby.Server.Implementations/Library/Resolvers/TV/SeriesResolver.cs b/Emby.Server.Implementations/Library/Resolvers/TV/SeriesResolver.cs index 4d8a6494c..f5ac3c665 100644 --- a/Emby.Server.Implementations/Library/Resolvers/TV/SeriesResolver.cs +++ b/Emby.Server.Implementations/Library/Resolvers/TV/SeriesResolver.cs @@ -5,7 +5,9 @@ using System; using System.Collections.Generic; using System.IO; +using Emby.Naming.Common; using Emby.Naming.TV; +using Emby.Naming.Video; using MediaBrowser.Controller.Entities.TV; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Resolvers; @@ -18,20 +20,20 @@ namespace Emby.Server.Implementations.Library.Resolvers.TV /// <summary> /// Class SeriesResolver. /// </summary> - public class SeriesResolver : FolderResolver<Series> + public class SeriesResolver : GenericFolderResolver<Series> { private readonly ILogger<SeriesResolver> _logger; - private readonly ILibraryManager _libraryManager; + private readonly NamingOptions _namingOptions; /// <summary> /// Initializes a new instance of the <see cref="SeriesResolver"/> class. /// </summary> /// <param name="logger">The logger.</param> - /// <param name="libraryManager">The library manager.</param> - public SeriesResolver(ILogger<SeriesResolver> logger, ILibraryManager libraryManager) + /// <param name="namingOptions">The naming options.</param> + public SeriesResolver(ILogger<SeriesResolver> logger, NamingOptions namingOptions) { _logger = logger; - _libraryManager = libraryManager; + _namingOptions = namingOptions; } /// <summary> @@ -54,16 +56,19 @@ namespace Emby.Server.Implementations.Library.Resolvers.TV return null; } + var seriesInfo = Naming.TV.SeriesResolver.Resolve(_namingOptions, args.Path); + var collectionType = args.GetCollectionType(); if (string.Equals(collectionType, CollectionType.TvShows, StringComparison.OrdinalIgnoreCase)) { - var configuredContentType = _libraryManager.GetConfiguredContentType(args.Path); + // TODO refactor into separate class or something, this is copied from LibraryManager.GetConfiguredContentType + var configuredContentType = args.GetConfiguredContentType(); if (!string.Equals(configuredContentType, CollectionType.TvShows, StringComparison.OrdinalIgnoreCase)) { return new Series { Path = args.Path, - Name = Path.GetFileName(args.Path) + Name = seriesInfo.Name }; } } @@ -80,7 +85,7 @@ namespace Emby.Server.Implementations.Library.Resolvers.TV return new Series { Path = args.Path, - Name = Path.GetFileName(args.Path) + Name = seriesInfo.Name }; } @@ -89,12 +94,12 @@ namespace Emby.Server.Implementations.Library.Resolvers.TV return null; } - if (IsSeriesFolder(args.Path, args.FileSystemChildren, _logger, _libraryManager, false)) + if (IsSeriesFolder(args.Path, args.FileSystemChildren, false)) { return new Series { Path = args.Path, - Name = Path.GetFileName(args.Path) + Name = seriesInfo.Name }; } } @@ -103,11 +108,9 @@ namespace Emby.Server.Implementations.Library.Resolvers.TV return null; } - public static bool IsSeriesFolder( + private bool IsSeriesFolder( string path, IEnumerable<FileSystemMetadata> fileSystemChildren, - ILogger<SeriesResolver> logger, - ILibraryManager libraryManager, bool isTvContentType) { foreach (var child in fileSystemChildren) @@ -116,21 +119,21 @@ namespace Emby.Server.Implementations.Library.Resolvers.TV { if (IsSeasonFolder(child.FullName, isTvContentType)) { - logger.LogDebug("{Path} is a series because of season folder {Dir}.", path, child.FullName); + _logger.LogDebug("{Path} is a series because of season folder {Dir}.", path, child.FullName); return true; } } else { string fullName = child.FullName; - if (libraryManager.IsVideoFile(fullName)) + if (VideoResolver.IsVideoFile(path, _namingOptions)) { if (isTvContentType) { return true; } - var namingOptions = ((LibraryManager)libraryManager).GetNamingOptions(); + var namingOptions = _namingOptions; var episodeResolver = new Naming.TV.EpisodeResolver(namingOptions); @@ -143,7 +146,7 @@ namespace Emby.Server.Implementations.Library.Resolvers.TV } } - logger.LogDebug("{Path} is not a series folder.", path); + _logger.LogDebug("{Path} is not a series folder.", path); return false; } @@ -179,13 +182,42 @@ namespace Emby.Server.Implementations.Library.Resolvers.TV /// <param name="path">The path.</param> private static void SetProviderIdFromPath(Series item, string path) { - var justName = Path.GetFileName(path); + var justName = Path.GetFileName(path.AsSpan()); - var id = justName.GetAttributeValue("tvdbid"); - - if (!string.IsNullOrEmpty(id)) + var tvdbId = justName.GetAttributeValue("tvdbid"); + if (!string.IsNullOrEmpty(tvdbId)) { - item.SetProviderId(MetadataProvider.Tvdb, id); + item.SetProviderId(MetadataProvider.Tvdb, tvdbId); + } + + var tvmazeId = justName.GetAttributeValue("tvmazeid"); + if (!string.IsNullOrEmpty(tvmazeId)) + { + item.SetProviderId(MetadataProvider.TvMaze, tvmazeId); + } + + var tmdbId = justName.GetAttributeValue("tmdbid"); + if (!string.IsNullOrEmpty(tmdbId)) + { + item.SetProviderId(MetadataProvider.Tmdb, tmdbId); + } + + var anidbId = justName.GetAttributeValue("anidbid"); + if (!string.IsNullOrEmpty(anidbId)) + { + item.SetProviderId("AniDB", anidbId); + } + + var aniListId = justName.GetAttributeValue("anilistid"); + if (!string.IsNullOrEmpty(aniListId)) + { + item.SetProviderId("AniList", aniListId); + } + + var aniSearchId = justName.GetAttributeValue("anisearchid"); + if (!string.IsNullOrEmpty(aniSearchId)) + { + item.SetProviderId("AniSearch", aniSearchId); } } } diff --git a/Emby.Server.Implementations/Library/SearchEngine.cs b/Emby.Server.Implementations/Library/SearchEngine.cs index 9d0a24a88..55911933a 100644 --- a/Emby.Server.Implementations/Library/SearchEngine.cs +++ b/Emby.Server.Implementations/Library/SearchEngine.cs @@ -10,12 +10,9 @@ using Jellyfin.Data.Entities; using Jellyfin.Data.Enums; using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; -using MediaBrowser.Controller.Entities.Audio; using MediaBrowser.Controller.Library; using MediaBrowser.Model.Querying; using MediaBrowser.Model.Search; -using Genre = MediaBrowser.Controller.Entities.Genre; -using Person = MediaBrowser.Controller.Entities.Person; namespace Emby.Server.Implementations.Library { @@ -59,9 +56,9 @@ namespace Emby.Server.Implementations.Library }; } - private static void AddIfMissing(List<string> list, string value) + private static void AddIfMissing(List<BaseItemKind> list, BaseItemKind value) { - if (!list.Contains(value, StringComparer.OrdinalIgnoreCase)) + if (!list.Contains(value)) { list.Add(value); } @@ -73,7 +70,7 @@ namespace Emby.Server.Implementations.Library /// <param name="query">The query.</param> /// <param name="user">The user.</param> /// <returns>IEnumerable{SearchHintResult}.</returns> - /// <exception cref="ArgumentNullException">searchTerm</exception> + /// <exception cref="ArgumentException"><c>query.SearchTerm</c> is <c>null</c> or empty.</exception> private List<SearchHintInfo> GetSearchHints(SearchQuery query, User user) { var searchTerm = query.SearchTerm; @@ -86,63 +83,63 @@ namespace Emby.Server.Implementations.Library searchTerm = searchTerm.Trim().RemoveDiacritics(); var excludeItemTypes = query.ExcludeItemTypes.ToList(); - var includeItemTypes = (query.IncludeItemTypes ?? Array.Empty<string>()).ToList(); + var includeItemTypes = (query.IncludeItemTypes ?? Array.Empty<BaseItemKind>()).ToList(); - excludeItemTypes.Add(nameof(Year)); - excludeItemTypes.Add(nameof(Folder)); + excludeItemTypes.Add(BaseItemKind.Year); + excludeItemTypes.Add(BaseItemKind.Folder); - if (query.IncludeGenres && (includeItemTypes.Count == 0 || includeItemTypes.Contains("Genre", StringComparer.OrdinalIgnoreCase))) + if (query.IncludeGenres && (includeItemTypes.Count == 0 || includeItemTypes.Contains(BaseItemKind.Genre))) { if (!query.IncludeMedia) { - AddIfMissing(includeItemTypes, nameof(Genre)); - AddIfMissing(includeItemTypes, nameof(MusicGenre)); + AddIfMissing(includeItemTypes, BaseItemKind.Genre); + AddIfMissing(includeItemTypes, BaseItemKind.MusicGenre); } } else { - AddIfMissing(excludeItemTypes, nameof(Genre)); - AddIfMissing(excludeItemTypes, nameof(MusicGenre)); + AddIfMissing(excludeItemTypes, BaseItemKind.Genre); + AddIfMissing(excludeItemTypes, BaseItemKind.MusicGenre); } - if (query.IncludePeople && (includeItemTypes.Count == 0 || includeItemTypes.Contains("People", StringComparer.OrdinalIgnoreCase) || includeItemTypes.Contains("Person", StringComparer.OrdinalIgnoreCase))) + if (query.IncludePeople && (includeItemTypes.Count == 0 || includeItemTypes.Contains(BaseItemKind.Person))) { if (!query.IncludeMedia) { - AddIfMissing(includeItemTypes, nameof(Person)); + AddIfMissing(includeItemTypes, BaseItemKind.Person); } } else { - AddIfMissing(excludeItemTypes, nameof(Person)); + AddIfMissing(excludeItemTypes, BaseItemKind.Person); } - if (query.IncludeStudios && (includeItemTypes.Count == 0 || includeItemTypes.Contains("Studio", StringComparer.OrdinalIgnoreCase))) + if (query.IncludeStudios && (includeItemTypes.Count == 0 || includeItemTypes.Contains(BaseItemKind.Studio))) { if (!query.IncludeMedia) { - AddIfMissing(includeItemTypes, nameof(Studio)); + AddIfMissing(includeItemTypes, BaseItemKind.Studio); } } else { - AddIfMissing(excludeItemTypes, nameof(Studio)); + AddIfMissing(excludeItemTypes, BaseItemKind.Studio); } - if (query.IncludeArtists && (includeItemTypes.Count == 0 || includeItemTypes.Contains("MusicArtist", StringComparer.OrdinalIgnoreCase))) + if (query.IncludeArtists && (includeItemTypes.Count == 0 || includeItemTypes.Contains(BaseItemKind.MusicArtist))) { if (!query.IncludeMedia) { - AddIfMissing(includeItemTypes, nameof(MusicArtist)); + AddIfMissing(includeItemTypes, BaseItemKind.MusicArtist); } } else { - AddIfMissing(excludeItemTypes, nameof(MusicArtist)); + AddIfMissing(excludeItemTypes, BaseItemKind.MusicArtist); } - AddIfMissing(excludeItemTypes, nameof(CollectionFolder)); - AddIfMissing(excludeItemTypes, nameof(Folder)); + AddIfMissing(excludeItemTypes, BaseItemKind.CollectionFolder); + AddIfMissing(excludeItemTypes, BaseItemKind.Folder); var mediaTypes = query.MediaTypes.ToList(); if (includeItemTypes.Count > 0) @@ -183,7 +180,7 @@ namespace Emby.Server.Implementations.Library List<BaseItem> mediaItems; - if (searchQuery.IncludeItemTypes.Length == 1 && string.Equals(searchQuery.IncludeItemTypes[0], "MusicArtist", StringComparison.OrdinalIgnoreCase)) + if (searchQuery.IncludeItemTypes.Length == 1 && searchQuery.IncludeItemTypes[0] == BaseItemKind.MusicArtist) { if (!searchQuery.ParentId.Equals(Guid.Empty)) { @@ -192,8 +189,8 @@ namespace Emby.Server.Implementations.Library searchQuery.ParentId = Guid.Empty; searchQuery.IncludeItemsByName = true; - searchQuery.IncludeItemTypes = Array.Empty<string>(); - mediaItems = _libraryManager.GetAllArtists(searchQuery).Items.Select(i => i.Item1).ToList(); + searchQuery.IncludeItemTypes = Array.Empty<BaseItemKind>(); + mediaItems = _libraryManager.GetAllArtists(searchQuery).Items.Select(i => i.Item).ToList(); } else { diff --git a/Emby.Server.Implementations/Library/UserDataManager.cs b/Emby.Server.Implementations/Library/UserDataManager.cs index c4e230f21..3810a76c4 100644 --- a/Emby.Server.Implementations/Library/UserDataManager.cs +++ b/Emby.Server.Implementations/Library/UserDataManager.cs @@ -25,8 +25,6 @@ namespace Emby.Server.Implementations.Library /// </summary> public class UserDataManager : IUserDataManager { - public event EventHandler<UserDataSaveEventArgs> UserDataSaved; - private readonly ConcurrentDictionary<string, UserItemData> _userData = new ConcurrentDictionary<string, UserItemData>(StringComparer.OrdinalIgnoreCase); @@ -44,6 +42,8 @@ namespace Emby.Server.Implementations.Library _repository = repository; } + public event EventHandler<UserDataSaveEventArgs> UserDataSaved; + public void SaveUserData(Guid userId, BaseItem item, UserItemData userData, UserDataSaveReason reason, CancellationToken cancellationToken) { var user = _userManager.GetUserById(userId); @@ -75,7 +75,7 @@ namespace Emby.Server.Implementations.Library } var cacheKey = GetCacheKey(userId, item.Id); - _userData.AddOrUpdate(cacheKey, userData, (k, v) => userData); + _userData.AddOrUpdate(cacheKey, userData, (_, _) => userData); UserDataSaved?.Invoke(this, new UserDataSaveEventArgs { @@ -90,10 +90,9 @@ namespace Emby.Server.Implementations.Library /// <summary> /// Save the provided user data for the given user. Batch operation. Does not fire any events or update the cache. /// </summary> - /// <param name="userId"></param> - /// <param name="userData"></param> - /// <param name="cancellationToken"></param> - /// <returns></returns> + /// <param name="userId">The user id.</param> + /// <param name="userData">The user item data.</param> + /// <param name="cancellationToken">The cancellation token.</param> public void SaveAllUserData(Guid userId, UserItemData[] userData, CancellationToken cancellationToken) { var user = _userManager.GetUserById(userId); @@ -104,8 +103,8 @@ namespace Emby.Server.Implementations.Library /// <summary> /// Retrieve all user data for the given user. /// </summary> - /// <param name="userId"></param> - /// <returns></returns> + /// <param name="userId">The user id.</param> + /// <returns>A <see cref="List{UserItemData}"/> containing all of the user's item data.</returns> public List<UserItemData> GetAllUserData(Guid userId) { var user = _userManager.GetUserById(userId); @@ -126,7 +125,7 @@ namespace Emby.Server.Implementations.Library var cacheKey = GetCacheKey(userId, itemId); - return _userData.GetOrAdd(cacheKey, k => GetUserDataInternal(userId, keys)); + return _userData.GetOrAdd(cacheKey, _ => GetUserDataInternal(userId, keys)); } private UserItemData GetUserDataInternal(long internalUserId, List<string> keys) diff --git a/Emby.Server.Implementations/Library/UserViewManager.cs b/Emby.Server.Implementations/Library/UserViewManager.cs index e2da672a3..b00bc72e6 100644 --- a/Emby.Server.Implementations/Library/UserViewManager.cs +++ b/Emby.Server.Implementations/Library/UserViewManager.cs @@ -8,11 +8,11 @@ using System.Linq; using System.Threading; using Jellyfin.Data.Entities; using Jellyfin.Data.Enums; +using Jellyfin.Extensions; using MediaBrowser.Controller.Channels; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; -using MediaBrowser.Controller.Entities.Audio; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.LiveTv; using MediaBrowser.Model.Channels; @@ -20,8 +20,6 @@ using MediaBrowser.Model.Entities; using MediaBrowser.Model.Globalization; using MediaBrowser.Model.Library; using MediaBrowser.Model.Querying; -using Genre = MediaBrowser.Controller.Entities.Genre; -using Person = MediaBrowser.Controller.Entities.Person; namespace Emby.Server.Implementations.Library { @@ -80,7 +78,7 @@ namespace Emby.Server.Implementations.Library continue; } - if (query.PresetViews.Contains(folderViewType ?? string.Empty, StringComparer.OrdinalIgnoreCase)) + if (query.PresetViews.Contains(folderViewType ?? string.Empty, StringComparison.OrdinalIgnoreCase)) { list.Add(GetUserView(folder, folderViewType, string.Empty)); } @@ -175,12 +173,12 @@ namespace Emby.Server.Implementations.Library string viewType, string localizationKey, string sortName, - Jellyfin.Data.Entities.User user, + User user, string[] presetViews) { if (parents.Count == 1 && parents.All(i => string.Equals(i.CollectionType, viewType, StringComparison.OrdinalIgnoreCase))) { - if (!presetViews.Contains(viewType, StringComparer.OrdinalIgnoreCase)) + if (!presetViews.Contains(viewType, StringComparison.OrdinalIgnoreCase)) { return (Folder)parents[0]; } @@ -300,11 +298,11 @@ namespace Emby.Server.Implementations.Library { if (hasCollectionType.All(i => string.Equals(i.CollectionType, CollectionType.Movies, StringComparison.OrdinalIgnoreCase))) { - includeItemTypes = new string[] { "Movie" }; + includeItemTypes = new[] { BaseItemKind.Movie }; } else if (hasCollectionType.All(i => string.Equals(i.CollectionType, CollectionType.TvShows, StringComparison.OrdinalIgnoreCase))) { - includeItemTypes = new string[] { "Episode" }; + includeItemTypes = new[] { BaseItemKind.Episode }; } } } @@ -341,20 +339,27 @@ namespace Emby.Server.Implementations.Library mediaTypes = mediaTypes.Distinct().ToList(); } - var excludeItemTypes = includeItemTypes.Length == 0 && mediaTypes.Count == 0 ? new[] - { - nameof(Person), - nameof(Studio), - nameof(Year), - nameof(MusicGenre), - nameof(Genre) - } : Array.Empty<string>(); + var excludeItemTypes = includeItemTypes.Length == 0 && mediaTypes.Count == 0 + ? new[] + { + BaseItemKind.Person, + BaseItemKind.Studio, + BaseItemKind.Year, + BaseItemKind.MusicGenre, + BaseItemKind.Genre + } + : Array.Empty<BaseItemKind>(); var query = new InternalItemsQuery(user) { IncludeItemTypes = includeItemTypes, - OrderBy = new[] { (ItemSortBy.DateCreated, SortOrder.Descending) }, - IsFolder = includeItemTypes.Length == 0 ? false : (bool?)null, + OrderBy = new[] + { + (ItemSortBy.DateCreated, SortOrder.Descending), + (ItemSortBy.SortName, SortOrder.Descending), + (ItemSortBy.ProductionYear, SortOrder.Descending) + }, + IsFolder = includeItemTypes.Length == 0 ? false : null, ExcludeItemTypes = excludeItemTypes, IsVirtualItem = false, Limit = limit * 5, diff --git a/Emby.Server.Implementations/Library/Validators/ArtistsValidator.cs b/Emby.Server.Implementations/Library/Validators/ArtistsValidator.cs index f9a3e2c64..7591e8391 100644 --- a/Emby.Server.Implementations/Library/Validators/ArtistsValidator.cs +++ b/Emby.Server.Implementations/Library/Validators/ArtistsValidator.cs @@ -3,6 +3,7 @@ using System.Globalization; using System.Linq; using System.Threading; using System.Threading.Tasks; +using Jellyfin.Data.Enums; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.Audio; using MediaBrowser.Controller.Library; @@ -81,7 +82,7 @@ namespace Emby.Server.Implementations.Library.Validators var deadEntities = _libraryManager.GetItemList(new InternalItemsQuery { - IncludeItemTypes = new[] { nameof(MusicArtist) }, + IncludeItemTypes = new[] { BaseItemKind.MusicArtist }, IsDeadArtist = true, IsLocked = false }).Cast<MusicArtist>().ToList(); @@ -95,10 +96,13 @@ namespace Emby.Server.Implementations.Library.Validators _logger.LogInformation("Deleting dead {2} {0} {1}.", item.Id.ToString("N", CultureInfo.InvariantCulture), item.Name, item.GetType().Name); - _libraryManager.DeleteItem(item, new DeleteOptions - { - DeleteFileLocation = false - }, false); + _libraryManager.DeleteItem( + item, + new DeleteOptions + { + DeleteFileLocation = false + }, + false); } progress.Report(100); diff --git a/Emby.Server.Implementations/Library/Validators/CollectionPostScanTask.cs b/Emby.Server.Implementations/Library/Validators/CollectionPostScanTask.cs new file mode 100644 index 000000000..88b93a211 --- /dev/null +++ b/Emby.Server.Implementations/Library/Validators/CollectionPostScanTask.cs @@ -0,0 +1,156 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Data.Enums; +using MediaBrowser.Controller.Collections; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.Movies; +using MediaBrowser.Controller.Library; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.Querying; +using Microsoft.Extensions.Logging; + +namespace Emby.Server.Implementations.Library.Validators +{ + /// <summary> + /// Class CollectionPostScanTask. + /// </summary> + public class CollectionPostScanTask : ILibraryPostScanTask + { + private readonly ILibraryManager _libraryManager; + private readonly ICollectionManager _collectionManager; + private readonly ILogger<CollectionPostScanTask> _logger; + + /// <summary> + /// Initializes a new instance of the <see cref="CollectionPostScanTask" /> class. + /// </summary> + /// <param name="libraryManager">The library manager.</param> + /// <param name="collectionManager">The collection manager.</param> + /// <param name="logger">The logger.</param> + public CollectionPostScanTask( + ILibraryManager libraryManager, + ICollectionManager collectionManager, + ILogger<CollectionPostScanTask> logger) + { + _libraryManager = libraryManager; + _collectionManager = collectionManager; + _logger = logger; + } + + /// <summary> + /// Runs the specified progress. + /// </summary> + /// <param name="progress">The progress.</param> + /// <param name="cancellationToken">The cancellation token.</param> + /// <returns>Task.</returns> + public async Task Run(IProgress<double> progress, CancellationToken cancellationToken) + { + var collectionNameMoviesMap = new Dictionary<string, HashSet<Guid>>(); + + foreach (var library in _libraryManager.RootFolder.Children) + { + if (!_libraryManager.GetLibraryOptions(library).AutomaticallyAddToCollection) + { + continue; + } + + var startIndex = 0; + var pagesize = 1000; + + while (true) + { + var movies = _libraryManager.GetItemList(new InternalItemsQuery + { + MediaTypes = new string[] { MediaType.Video }, + IncludeItemTypes = new[] { BaseItemKind.Movie }, + IsVirtualItem = false, + OrderBy = new[] { (ItemSortBy.SortName, SortOrder.Ascending) }, + Parent = library, + StartIndex = startIndex, + Limit = pagesize, + Recursive = true + }); + + foreach (var m in movies) + { + if (m is Movie movie && !string.IsNullOrEmpty(movie.CollectionName)) + { + if (collectionNameMoviesMap.TryGetValue(movie.CollectionName, out var movieList)) + { + movieList.Add(movie.Id); + } + else + { + collectionNameMoviesMap[movie.CollectionName] = new HashSet<Guid> { movie.Id }; + } + } + } + + if (movies.Count < pagesize) + { + break; + } + + startIndex += pagesize; + } + } + + var numComplete = 0; + var count = collectionNameMoviesMap.Count; + + if (count == 0) + { + progress.Report(100); + return; + } + + var boxSets = _libraryManager.GetItemList(new InternalItemsQuery + { + IncludeItemTypes = new[] { BaseItemKind.BoxSet }, + CollapseBoxSetItems = false, + Recursive = true + }); + + foreach (var (collectionName, movieIds) in collectionNameMoviesMap) + { + try + { + var boxSet = boxSets.FirstOrDefault(b => b?.Name == collectionName) as BoxSet; + if (boxSet == null) + { + // won't automatically create collection if only one movie in it + if (movieIds.Count >= 2) + { + boxSet = await _collectionManager.CreateCollectionAsync(new CollectionCreationOptions + { + Name = collectionName, + IsLocked = true + }); + + await _collectionManager.AddToCollectionAsync(boxSet.Id, movieIds); + } + } + else + { + await _collectionManager.AddToCollectionAsync(boxSet.Id, movieIds); + } + + numComplete++; + double percent = numComplete; + percent /= count; + percent *= 100; + + progress.Report(percent); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error refreshing {CollectionName} with {@MovieIds}", collectionName, movieIds); + } + } + + progress.Report(100); + } + } +} diff --git a/Emby.Server.Implementations/Library/Validators/PeopleValidator.cs b/Emby.Server.Implementations/Library/Validators/PeopleValidator.cs index 8739a9e1b..601aab5b9 100644 --- a/Emby.Server.Implementations/Library/Validators/PeopleValidator.cs +++ b/Emby.Server.Implementations/Library/Validators/PeopleValidator.cs @@ -2,6 +2,7 @@ using System; using System.Globalization; using System.Threading; using System.Threading.Tasks; +using Jellyfin.Data.Enums; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Providers; @@ -78,7 +79,7 @@ namespace Emby.Server.Implementations.Library.Validators } catch (Exception ex) { - _logger.LogError(ex, "Error validating IBN entry {person}", person); + _logger.LogError(ex, "Error validating IBN entry {Person}", person); } // Update progress @@ -91,7 +92,7 @@ namespace Emby.Server.Implementations.Library.Validators var deadEntities = _libraryManager.GetItemList(new InternalItemsQuery { - IncludeItemTypes = new[] { nameof(Person) }, + IncludeItemTypes = new[] { BaseItemKind.Person }, IsDeadPerson = true, IsLocked = false }); diff --git a/Emby.Server.Implementations/Library/Validators/StudiosValidator.cs b/Emby.Server.Implementations/Library/Validators/StudiosValidator.cs index 8577d722e..26bc49c1f 100644 --- a/Emby.Server.Implementations/Library/Validators/StudiosValidator.cs +++ b/Emby.Server.Implementations/Library/Validators/StudiosValidator.cs @@ -2,6 +2,7 @@ using System; using System.Globalization; using System.Threading; using System.Threading.Tasks; +using Jellyfin.Data.Enums; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Persistence; @@ -80,7 +81,7 @@ namespace Emby.Server.Implementations.Library.Validators var deadEntities = _libraryManager.GetItemList(new InternalItemsQuery { - IncludeItemTypes = new[] { nameof(Studio) }, + IncludeItemTypes = new[] { BaseItemKind.Studio }, IsDeadStudio = true, IsLocked = false }); diff --git a/Emby.Server.Implementations/LiveTv/EmbyTV/DirectRecorder.cs b/Emby.Server.Implementations/LiveTv/EmbyTV/DirectRecorder.cs index c5a9a92ec..6937cc097 100644 --- a/Emby.Server.Implementations/LiveTv/EmbyTV/DirectRecorder.cs +++ b/Emby.Server.Implementations/LiveTv/EmbyTV/DirectRecorder.cs @@ -5,6 +5,7 @@ using System.IO; using System.Net.Http; using System.Threading; using System.Threading.Tasks; +using Jellyfin.Api.Helpers; using MediaBrowser.Common.Net; using MediaBrowser.Controller.Library; using MediaBrowser.Model.Dto; @@ -45,21 +46,27 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV { Directory.CreateDirectory(Path.GetDirectoryName(targetFile) ?? throw new ArgumentException("Path can't be a root directory.", nameof(targetFile))); - // use FileShare.None as this bypasses dotnet bug dotnet/runtime#42790 . - using (var output = new FileStream(targetFile, FileMode.Create, FileAccess.Write, FileShare.None, IODefaults.FileStreamBufferSize, AsyncFile.UseAsyncIO)) + using (var output = new FileStream(targetFile, FileMode.CreateNew, FileAccess.Write, FileShare.Read, IODefaults.FileStreamBufferSize, FileOptions.Asynchronous)) { onStarted(); - _logger.LogInformation("Copying recording stream to file {0}", targetFile); + _logger.LogInformation("Copying recording to file {FilePath}", targetFile); // The media source is infinite so we need to handle stopping ourselves using var durationToken = new CancellationTokenSource(duration); using var cancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, durationToken.Token); + var linkedCancellationToken = cancellationTokenSource.Token; - await directStreamProvider.CopyToAsync(output, cancellationTokenSource.Token).ConfigureAwait(false); + await using var fileStream = new ProgressiveFileStream(directStreamProvider.GetStream()); + await _streamHelper.CopyToAsync( + fileStream, + output, + IODefaults.CopyToBufferSize, + 1000, + linkedCancellationToken).ConfigureAwait(false); } - _logger.LogInformation("Recording completed to file {0}", targetFile); + _logger.LogInformation("Recording completed: {FilePath}", targetFile); } private async Task RecordFromMediaSource(MediaSourceInfo mediaSource, string targetFile, TimeSpan duration, Action onStarted, CancellationToken cancellationToken) @@ -71,8 +78,7 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV Directory.CreateDirectory(Path.GetDirectoryName(targetFile) ?? throw new ArgumentException("Path can't be a root directory.", nameof(targetFile))); - // use FileShare.None as this bypasses dotnet bug dotnet/runtime#42790 . - await using var output = new FileStream(targetFile, FileMode.Create, FileAccess.Write, FileShare.None, IODefaults.CopyToBufferSize, AsyncFile.UseAsyncIO); + await using var output = new FileStream(targetFile, FileMode.CreateNew, FileAccess.Write, FileShare.Read, IODefaults.CopyToBufferSize, FileOptions.Asynchronous); onStarted(); diff --git a/Emby.Server.Implementations/LiveTv/EmbyTV/EmbyTV.cs b/Emby.Server.Implementations/LiveTv/EmbyTV/EmbyTV.cs index 026b6bc0b..e7834ffd6 100644 --- a/Emby.Server.Implementations/LiveTv/EmbyTV/EmbyTV.cs +++ b/Emby.Server.Implementations/LiveTv/EmbyTV/EmbyTV.cs @@ -17,6 +17,7 @@ using System.Xml; using Emby.Server.Implementations.Library; using Jellyfin.Data.Enums; using Jellyfin.Data.Events; +using Jellyfin.Extensions; using MediaBrowser.Common.Configuration; using MediaBrowser.Common.Extensions; using MediaBrowser.Common.Progress; @@ -227,7 +228,7 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV foreach (var virtualFolder in virtualFolders) { - if (!virtualFolder.Locations.Contains(path, StringComparer.OrdinalIgnoreCase)) + if (!virtualFolder.Locations.Contains(path, StringComparison.OrdinalIgnoreCase)) { continue; } @@ -397,7 +398,7 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV } result = new EpgChannelData(channels); - _epgChannels.AddOrUpdate(info.Id, result, (k, v) => result); + _epgChannels.AddOrUpdate(info.Id, result, (_, _) => result); } return result; @@ -891,7 +892,7 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV throw new ArgumentNullException(nameof(tunerHostId)); } - return info.EnabledTuners.Contains(tunerHostId, StringComparer.OrdinalIgnoreCase); + return info.EnabledTuners.Contains(tunerHostId, StringComparison.OrdinalIgnoreCase); } public async Task<IEnumerable<ProgramInfo>> GetProgramsAsync(string channelId, DateTime startDateUtc, DateTime endDateUtc, CancellationToken cancellationToken) @@ -957,7 +958,7 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV public async Task<ILiveStream> GetChannelStreamWithDirectStreamProvider(string channelId, string streamId, List<ILiveStream> currentLiveStreams, CancellationToken cancellationToken) { - _logger.LogInformation("Streaming Channel " + channelId); + _logger.LogInformation("Streaming Channel {Id}", channelId); var result = string.IsNullOrEmpty(streamId) ? null : @@ -1027,7 +1028,7 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV { var stream = new MediaSourceInfo { - EncoderPath = _appHost.GetLoopbackHttpApiUrl() + "/LiveTv/LiveRecordings/" + info.Id + "/stream", + EncoderPath = _appHost.GetApiUrlForLocalAccess() + "/LiveTv/LiveRecordings/" + info.Id + "/stream", EncoderProtocol = MediaProtocol.Http, Path = info.Path, Protocol = MediaProtocol.File, @@ -1247,12 +1248,11 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV var remoteMetadata = await FetchInternetMetadata(timer, CancellationToken.None).ConfigureAwait(false); var recordPath = GetRecordingPath(timer, remoteMetadata, out string seriesPath); - var recordingStatus = RecordingStatus.New; - - string liveStreamId = null; var channelItem = _liveTvManager.GetLiveTvChannel(timer, this); + string liveStreamId = null; + RecordingStatus recordingStatus; try { var allMediaSources = await _mediaSourceManager.GetPlaybackMediaSources(channelItem, null, true, false, CancellationToken.None).ConfigureAwait(false); @@ -1308,16 +1308,16 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV await recorder.Record(directStreamProvider, mediaStreamInfo, recordPath, duration, onStarted, activeRecordingInfo.CancellationTokenSource.Token).ConfigureAwait(false); recordingStatus = RecordingStatus.Completed; - _logger.LogInformation("Recording completed: {recordPath}", recordPath); + _logger.LogInformation("Recording completed: {RecordPath}", recordPath); } catch (OperationCanceledException) { - _logger.LogInformation("Recording stopped: {recordPath}", recordPath); + _logger.LogInformation("Recording stopped: {RecordPath}", recordPath); recordingStatus = RecordingStatus.Completed; } catch (Exception ex) { - _logger.LogError(ex, "Error recording to {recordPath}", recordPath); + _logger.LogError(ex, "Error recording to {RecordPath}", recordPath); recordingStatus = RecordingStatus.Error; } @@ -1338,7 +1338,7 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV TriggerRefresh(recordPath); _libraryMonitor.ReportFileSystemChangeComplete(recordPath, false); - _activeRecordings.TryRemove(timer.Id, out var removed); + _activeRecordings.TryRemove(timer.Id, out _); if (recordingStatus != RecordingStatus.Completed && DateTime.UtcNow < timer.EndDate && timer.RetryCount < 10) { @@ -1404,7 +1404,7 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV } catch (Exception ex) { - _logger.LogError(ex, "Error deleting 0-byte failed recording file {path}", path); + _logger.LogError(ex, "Error deleting 0-byte failed recording file {Path}", path); } } } @@ -1778,7 +1778,7 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV { var program = string.IsNullOrWhiteSpace(timer.ProgramId) ? null : _libraryManager.GetItemList(new InternalItemsQuery { - IncludeItemTypes = new[] { nameof(LiveTvProgram) }, + IncludeItemTypes = new[] { BaseItemKind.LiveTvProgram }, Limit = 1, ExternalId = timer.ProgramId, DtoOptions = new DtoOptions(true) @@ -1848,14 +1848,12 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV return; } - // use FileShare.None as this bypasses dotnet bug dotnet/runtime#42790 . - using (var stream = new FileStream(nfoPath, FileMode.Create, FileAccess.Write, FileShare.None)) + using (var stream = new FileStream(nfoPath, FileMode.CreateNew, FileAccess.Write, FileShare.None)) { var settings = new XmlWriterSettings { Indent = true, - Encoding = Encoding.UTF8, - CloseOutput = false + Encoding = Encoding.UTF8 }; using (var writer = XmlWriter.Create(stream, settings)) @@ -1913,14 +1911,12 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV return; } - // use FileShare.None as this bypasses dotnet bug dotnet/runtime#42790 . - using (var stream = new FileStream(nfoPath, FileMode.Create, FileAccess.Write, FileShare.None)) + using (var stream = new FileStream(nfoPath, FileMode.CreateNew, FileAccess.Write, FileShare.None)) { var settings = new XmlWriterSettings { Indent = true, - Encoding = Encoding.UTF8, - CloseOutput = false + Encoding = Encoding.UTF8 }; var options = _config.GetNfoConfiguration(); @@ -1940,7 +1936,7 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV writer.WriteElementString("title", timer.EpisodeTitle); } - var premiereDate = item.PremiereDate ?? (!timer.IsRepeat ? DateTime.UtcNow : (DateTime?)null); + var premiereDate = item.PremiereDate ?? (!timer.IsRepeat ? DateTime.UtcNow : null); if (premiereDate.HasValue) { @@ -1990,7 +1986,7 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV writer.WriteElementString( "dateadded", - DateTime.UtcNow.ToLocalTime().ToString(DateAddedFormat, CultureInfo.InvariantCulture)); + DateTime.Now.ToString(DateAddedFormat, CultureInfo.InvariantCulture)); if (item.ProductionYear.HasValue) { @@ -2129,19 +2125,14 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV private LiveTvProgram GetProgramInfoFromCache(TimerInfo timer) { - return GetProgramInfoFromCache(timer.ProgramId, timer.ChannelId); - } - - private LiveTvProgram GetProgramInfoFromCache(string programId, string channelId) - { - return GetProgramInfoFromCache(programId); + return GetProgramInfoFromCache(timer.ProgramId); } private LiveTvProgram GetProgramInfoFromCache(string channelId, DateTime startDateUtc) { var query = new InternalItemsQuery { - IncludeItemTypes = new string[] { nameof(LiveTvProgram) }, + IncludeItemTypes = new[] { BaseItemKind.LiveTvProgram }, Limit = 1, DtoOptions = new DtoOptions(true) { @@ -2280,7 +2271,7 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV { // Only update if not currently active - test both new timer and existing in case Id's are different // Id's could be different if the timer was created manually prior to series timer creation - if (!_activeRecordings.TryGetValue(timer.Id, out var activeRecordingInfo) && !_activeRecordings.TryGetValue(existingTimer.Id, out activeRecordingInfo)) + if (!_activeRecordings.TryGetValue(timer.Id, out _) && !_activeRecordings.TryGetValue(existingTimer.Id, out _)) { UpdateExistingTimerWithNewMetadata(existingTimer, timer); @@ -2301,17 +2292,14 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV enabledTimersForSeries.Add(existingTimer); } - if (updateTimerSettings) - { - existingTimer.KeepUntil = seriesTimer.KeepUntil; - existingTimer.IsPostPaddingRequired = seriesTimer.IsPostPaddingRequired; - existingTimer.IsPrePaddingRequired = seriesTimer.IsPrePaddingRequired; - existingTimer.PostPaddingSeconds = seriesTimer.PostPaddingSeconds; - existingTimer.PrePaddingSeconds = seriesTimer.PrePaddingSeconds; - existingTimer.Priority = seriesTimer.Priority; - } - + existingTimer.KeepUntil = seriesTimer.KeepUntil; + existingTimer.IsPostPaddingRequired = seriesTimer.IsPostPaddingRequired; + existingTimer.IsPrePaddingRequired = seriesTimer.IsPrePaddingRequired; + existingTimer.PostPaddingSeconds = seriesTimer.PostPaddingSeconds; + existingTimer.PrePaddingSeconds = seriesTimer.PrePaddingSeconds; + existingTimer.Priority = seriesTimer.Priority; existingTimer.SeriesTimerId = seriesTimer.Id; + _timerProvider.Update(existingTimer); } } @@ -2336,7 +2324,7 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV var deletes = _timerProvider.GetAll() .Where(i => string.Equals(i.SeriesTimerId, seriesTimer.Id, StringComparison.OrdinalIgnoreCase)) - .Where(i => !allTimerIds.Contains(i.Id, StringComparer.OrdinalIgnoreCase) && i.StartDate > DateTime.UtcNow) + .Where(i => !allTimerIds.Contains(i.Id, StringComparison.OrdinalIgnoreCase) && i.StartDate > DateTime.UtcNow) .Where(i => deleteStatuses.Contains(i.Status)) .ToList(); @@ -2356,7 +2344,7 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV var query = new InternalItemsQuery { - IncludeItemTypes = new string[] { nameof(LiveTvProgram) }, + IncludeItemTypes = new[] { BaseItemKind.LiveTvProgram }, ExternalSeriesId = seriesTimer.SeriesId, DtoOptions = new DtoOptions(true) { @@ -2391,7 +2379,7 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV channel = _libraryManager.GetItemList( new InternalItemsQuery { - IncludeItemTypes = new string[] { nameof(LiveTvChannel) }, + IncludeItemTypes = new[] { BaseItemKind.LiveTvChannel }, ItemIds = new[] { parent.ChannelId }, DtoOptions = new DtoOptions() }).FirstOrDefault() as LiveTvChannel; @@ -2450,7 +2438,7 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV channel = _libraryManager.GetItemList( new InternalItemsQuery { - IncludeItemTypes = new string[] { nameof(LiveTvChannel) }, + IncludeItemTypes = new[] { BaseItemKind.LiveTvChannel }, ItemIds = new[] { programInfo.ChannelId }, DtoOptions = new DtoOptions() }).FirstOrDefault() as LiveTvChannel; @@ -2515,7 +2503,7 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV var seriesIds = _libraryManager.GetItemIds( new InternalItemsQuery { - IncludeItemTypes = new[] { nameof(Series) }, + IncludeItemTypes = new[] { BaseItemKind.Series }, Name = program.Name }).ToArray(); @@ -2528,7 +2516,7 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV { var result = _libraryManager.GetItemIds(new InternalItemsQuery { - IncludeItemTypes = new[] { nameof(Episode) }, + IncludeItemTypes = new[] { BaseItemKind.Episode }, ParentIndexNumber = program.SeasonNumber.Value, IndexNumber = program.EpisodeNumber.Value, AncestorIds = seriesIds, @@ -2625,7 +2613,7 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV if (newDevicesOnly) { - discoveredDevices = discoveredDevices.Where(d => !configuredDeviceIds.Contains(d.DeviceId, StringComparer.OrdinalIgnoreCase)) + discoveredDevices = discoveredDevices.Where(d => !configuredDeviceIds.Contains(d.DeviceId, StringComparison.OrdinalIgnoreCase)) .ToList(); } diff --git a/Emby.Server.Implementations/LiveTv/EmbyTV/EncodedRecorder.cs b/Emby.Server.Implementations/LiveTv/EmbyTV/EncodedRecorder.cs index d806a0295..7fa47e7db 100644 --- a/Emby.Server.Implementations/LiveTv/EmbyTV/EncodedRecorder.cs +++ b/Emby.Server.Implementations/LiveTv/EmbyTV/EncodedRecorder.cs @@ -62,12 +62,12 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV using var durationToken = new CancellationTokenSource(duration); using var cancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, durationToken.Token); - await RecordFromFile(mediaSource, mediaSource.Path, targetFile, duration, onStarted, cancellationTokenSource.Token).ConfigureAwait(false); + await RecordFromFile(mediaSource, mediaSource.Path, targetFile, onStarted, cancellationTokenSource.Token).ConfigureAwait(false); _logger.LogInformation("Recording completed to file {0}", targetFile); } - private async Task RecordFromFile(MediaSourceInfo mediaSource, string inputFile, string targetFile, TimeSpan duration, Action onStarted, CancellationToken cancellationToken) + private async Task RecordFromFile(MediaSourceInfo mediaSource, string inputFile, string targetFile, Action onStarted, CancellationToken cancellationToken) { _targetPath = targetFile; Directory.CreateDirectory(Path.GetDirectoryName(targetFile)); @@ -81,30 +81,29 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV RedirectStandardInput = true, FileName = _mediaEncoder.EncoderPath, - Arguments = GetCommandLineArgs(mediaSource, inputFile, targetFile, duration), + Arguments = GetCommandLineArgs(mediaSource, inputFile, targetFile), WindowStyle = ProcessWindowStyle.Hidden, ErrorDialog = false }; - var commandLineLogMessage = processStartInfo.FileName + " " + processStartInfo.Arguments; - _logger.LogInformation(commandLineLogMessage); + _logger.LogInformation("{Filename} {Arguments}", processStartInfo.FileName, processStartInfo.Arguments); var logFilePath = Path.Combine(_appPaths.LogDirectoryPath, "record-transcode-" + Guid.NewGuid() + ".txt"); Directory.CreateDirectory(Path.GetDirectoryName(logFilePath)); // FFMpeg writes debug/error info to stderr. This is useful when debugging so let's put it in the log directory. - _logFileStream = new FileStream(logFilePath, FileMode.Create, FileAccess.Write, FileShare.Read, IODefaults.FileStreamBufferSize, AsyncFile.UseAsyncIO); + _logFileStream = new FileStream(logFilePath, FileMode.CreateNew, FileAccess.Write, FileShare.Read, IODefaults.FileStreamBufferSize, FileOptions.Asynchronous); await JsonSerializer.SerializeAsync(_logFileStream, mediaSource, _jsonOptions, cancellationToken).ConfigureAwait(false); - await _logFileStream.WriteAsync(Encoding.UTF8.GetBytes(Environment.NewLine + Environment.NewLine + commandLineLogMessage + Environment.NewLine + Environment.NewLine), cancellationToken).ConfigureAwait(false); + await _logFileStream.WriteAsync(Encoding.UTF8.GetBytes(Environment.NewLine + Environment.NewLine + processStartInfo.FileName + " " + processStartInfo.Arguments + Environment.NewLine + Environment.NewLine), cancellationToken).ConfigureAwait(false); _process = new Process { StartInfo = processStartInfo, EnableRaisingEvents = true }; - _process.Exited += (sender, args) => OnFfMpegProcessExited(_process); + _process.Exited += (_, _) => OnFfMpegProcessExited(_process); _process.Start(); @@ -118,7 +117,7 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV _logger.LogInformation("ffmpeg recording process started for {0}", _targetPath); } - private string GetCommandLineArgs(MediaSourceInfo mediaSource, string inputTempFile, string targetFile, TimeSpan duration) + private string GetCommandLineArgs(MediaSourceInfo mediaSource, string inputTempFile, string targetFile) { string videoArgs; if (EncodeVideo(mediaSource)) @@ -188,7 +187,7 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV CultureInfo.InvariantCulture, "-i \"{0}\" {2} -map_metadata -1 -threads {6} {3}{4}{5} -y \"{1}\"", inputTempFile, - targetFile, + targetFile.Replace("\"", "\\\"", StringComparison.Ordinal), // Escape quotes in filename videoArgs, GetAudioArgs(mediaSource), subtitleArgs, @@ -205,9 +204,9 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV // var audioChannels = 2; // var audioStream = mediaStreams.FirstOrDefault(i => i.Type == MediaStreamType.Audio); // if (audioStream != null) - //{ + // { // audioChannels = audioStream.Channels ?? audioChannels; - //} + // } // return "-codec:a:0 aac -strict experimental -ab 320000"; } @@ -225,13 +224,13 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV { try { - _logger.LogInformation("Stopping ffmpeg recording process for {path}", _targetPath); + _logger.LogInformation("Stopping ffmpeg recording process for {Path}", _targetPath); _process.StandardInput.WriteLine("q"); } catch (Exception ex) { - _logger.LogError(ex, "Error stopping recording transcoding job for {path}", _targetPath); + _logger.LogError(ex, "Error stopping recording transcoding job for {Path}", _targetPath); } if (_hasExited) @@ -241,7 +240,7 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV try { - _logger.LogInformation("Calling recording process.WaitForExit for {path}", _targetPath); + _logger.LogInformation("Calling recording process.WaitForExit for {Path}", _targetPath); if (_process.WaitForExit(10000)) { @@ -250,7 +249,7 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV } catch (Exception ex) { - _logger.LogError(ex, "Error waiting for recording process to exit for {path}", _targetPath); + _logger.LogError(ex, "Error waiting for recording process to exit for {Path}", _targetPath); } if (_hasExited) @@ -260,13 +259,13 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV try { - _logger.LogInformation("Killing ffmpeg recording process for {path}", _targetPath); + _logger.LogInformation("Killing ffmpeg recording process for {Path}", _targetPath); _process.Kill(); } catch (Exception ex) { - _logger.LogError(ex, "Error killing recording transcoding job for {path}", _targetPath); + _logger.LogError(ex, "Error killing recording transcoding job for {Path}", _targetPath); } } } diff --git a/Emby.Server.Implementations/LiveTv/EmbyTV/IRecorder.cs b/Emby.Server.Implementations/LiveTv/EmbyTV/IRecorder.cs index dfe3517b2..7705132da 100644 --- a/Emby.Server.Implementations/LiveTv/EmbyTV/IRecorder.cs +++ b/Emby.Server.Implementations/LiveTv/EmbyTV/IRecorder.cs @@ -13,6 +13,13 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV /// <summary> /// Records the specified media source. /// </summary> + /// <param name="directStreamProvider">The direct stream provider, or <c>null</c>.</param> + /// <param name="mediaSource">The media source.</param> + /// <param name="targetFile">The target file.</param> + /// <param name="duration">The duration to record.</param> + /// <param name="onStarted">An action to perform when recording starts.</param> + /// <param name="cancellationToken">The cancellation token.</param> + /// <returns>A <see cref="Task"/> that represents the recording operation.</returns> Task Record(IDirectStreamProvider? directStreamProvider, MediaSourceInfo mediaSource, string targetFile, TimeSpan duration, Action onStarted, CancellationToken cancellationToken); string GetOutputPath(MediaSourceInfo mediaSource, string targetFile); diff --git a/Emby.Server.Implementations/LiveTv/EmbyTV/ItemDataProvider.cs b/Emby.Server.Implementations/LiveTv/EmbyTV/ItemDataProvider.cs index 4a031e475..46979bfc5 100644 --- a/Emby.Server.Implementations/LiveTv/EmbyTV/ItemDataProvider.cs +++ b/Emby.Server.Implementations/LiveTv/EmbyTV/ItemDataProvider.cs @@ -1,9 +1,8 @@ -#nullable disable - #pragma warning disable CS1591 using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.IO; using System.Linq; using System.Text.Json; @@ -18,7 +17,7 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV private readonly string _dataPath; private readonly object _fileDataLock = new object(); private readonly JsonSerializerOptions _jsonOptions = JsonDefaults.Options; - private T[] _items; + private T[]? _items; public ItemDataProvider( ILogger logger, @@ -34,6 +33,7 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV protected Func<T, T, bool> EqualityComparer { get; } + [MemberNotNull(nameof(_items))] private void EnsureLoaded() { if (_items != null) @@ -49,6 +49,12 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV { var bytes = File.ReadAllBytes(_dataPath); _items = JsonSerializer.Deserialize<T[]>(bytes, _jsonOptions); + if (_items == null) + { + Logger.LogError("Error deserializing {Path}, data was null", _dataPath); + _items = Array.Empty<T>(); + } + return; } catch (JsonException ex) @@ -62,7 +68,7 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV private void SaveList() { - Directory.CreateDirectory(Path.GetDirectoryName(_dataPath)); + Directory.CreateDirectory(Path.GetDirectoryName(_dataPath) ?? throw new ArgumentException("Path can't be a root directory.", nameof(_dataPath))); var jsonString = JsonSerializer.Serialize(_items, _jsonOptions); File.WriteAllText(_dataPath, jsonString); } diff --git a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirect.cs b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirect.cs index 8125ed57d..a8440102d 100644 --- a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirect.cs +++ b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirect.cs @@ -9,17 +9,18 @@ using System.Globalization; using System.Linq; using System.Net; using System.Net.Http; +using System.Net.Http.Json; using System.Net.Mime; +using System.Security.Cryptography; using System.Text; using System.Text.Json; using System.Threading; using System.Threading.Tasks; using Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos; +using Jellyfin.Extensions; using Jellyfin.Extensions.Json; -using MediaBrowser.Common; using MediaBrowser.Common.Net; using MediaBrowser.Controller.LiveTv; -using MediaBrowser.Model.Cryptography; using MediaBrowser.Model.Dto; using MediaBrowser.Model.Entities; using MediaBrowser.Model.LiveTv; @@ -34,8 +35,6 @@ namespace Emby.Server.Implementations.LiveTv.Listings private readonly ILogger<SchedulesDirect> _logger; private readonly IHttpClientFactory _httpClientFactory; private readonly SemaphoreSlim _tokenSemaphore = new SemaphoreSlim(1, 1); - private readonly IApplicationHost _appHost; - private readonly ICryptoProvider _cryptoProvider; private readonly ConcurrentDictionary<string, NameValuePair> _tokens = new ConcurrentDictionary<string, NameValuePair>(); private readonly JsonSerializerOptions _jsonOptions = JsonDefaults.Options; @@ -43,14 +42,10 @@ namespace Emby.Server.Implementations.LiveTv.Listings public SchedulesDirect( ILogger<SchedulesDirect> logger, - IHttpClientFactory httpClientFactory, - IApplicationHost appHost, - ICryptoProvider cryptoProvider) + IHttpClientFactory httpClientFactory) { _logger = logger; _httpClientFactory = httpClientFactory; - _appHost = appHost; - _cryptoProvider = cryptoProvider; } /// <inheritdoc /> @@ -106,26 +101,35 @@ namespace Emby.Server.Implementations.LiveTv.Listings } }; - var requestString = JsonSerializer.Serialize(requestList, _jsonOptions); - _logger.LogDebug("Request string for schedules is: {RequestString}", requestString); + _logger.LogDebug("Request string for schedules is: {@RequestString}", requestList); using var options = new HttpRequestMessage(HttpMethod.Post, ApiUrl + "/schedules"); - options.Content = new StringContent(requestString, Encoding.UTF8, MediaTypeNames.Application.Json); + options.Content = JsonContent.Create(requestList, options: _jsonOptions); options.Headers.TryAddWithoutValidation("token", token); using var response = await Send(options, true, info, cancellationToken).ConfigureAwait(false); await using var responseStream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); - var dailySchedules = await JsonSerializer.DeserializeAsync<List<DayDto>>(responseStream, _jsonOptions, cancellationToken).ConfigureAwait(false); + var dailySchedules = await JsonSerializer.DeserializeAsync<IReadOnlyList<DayDto>>(responseStream, _jsonOptions, cancellationToken).ConfigureAwait(false); + if (dailySchedules == null) + { + return Array.Empty<ProgramInfo>(); + } + _logger.LogDebug("Found {ScheduleCount} programs on {ChannelID} ScheduleDirect", dailySchedules.Count, channelId); using var programRequestOptions = new HttpRequestMessage(HttpMethod.Post, ApiUrl + "/programs"); programRequestOptions.Headers.TryAddWithoutValidation("token", token); - var programsID = dailySchedules.SelectMany(d => d.Programs.Select(s => s.ProgramId)).Distinct(); - programRequestOptions.Content = new StringContent("[\"" + string.Join("\", \"", programsID) + "\"]", Encoding.UTF8, MediaTypeNames.Application.Json); + var programIds = dailySchedules.SelectMany(d => d.Programs.Select(s => s.ProgramId)).Distinct(); + programRequestOptions.Content = JsonContent.Create(programIds, options: _jsonOptions); using var innerResponse = await Send(programRequestOptions, true, info, cancellationToken).ConfigureAwait(false); await using var innerResponseStream = await innerResponse.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); - var programDetails = await JsonSerializer.DeserializeAsync<List<ProgramDetailsDto>>(innerResponseStream, _jsonOptions, cancellationToken).ConfigureAwait(false); + var programDetails = await JsonSerializer.DeserializeAsync<IReadOnlyList<ProgramDetailsDto>>(innerResponseStream, _jsonOptions, cancellationToken).ConfigureAwait(false); + if (programDetails == null) + { + return Array.Empty<ProgramInfo>(); + } + var programDict = programDetails.ToDictionary(p => p.ProgramId, y => y); var programIdsWithImages = programDetails @@ -142,6 +146,11 @@ namespace Emby.Server.Implementations.LiveTv.Listings // schedule.ProgramId + " which says it has images? " + // programDict[schedule.ProgramId].hasImageArtwork); + if (string.IsNullOrEmpty(schedule.ProgramId)) + { + continue; + } + if (images != null) { var imageIndex = images.FindIndex(i => i.ProgramId == schedule.ProgramId[..10]); @@ -149,18 +158,18 @@ namespace Emby.Server.Implementations.LiveTv.Listings { var programEntry = programDict[schedule.ProgramId]; - var allImages = images[imageIndex].Data ?? new List<ImageDataDto>(); - var imagesWithText = allImages.Where(i => string.Equals(i.Text, "yes", StringComparison.OrdinalIgnoreCase)); - var imagesWithoutText = allImages.Where(i => string.Equals(i.Text, "no", StringComparison.OrdinalIgnoreCase)); + var allImages = images[imageIndex].Data; + var imagesWithText = allImages.Where(i => string.Equals(i.Text, "yes", StringComparison.OrdinalIgnoreCase)).ToList(); + var imagesWithoutText = allImages.Where(i => string.Equals(i.Text, "no", StringComparison.OrdinalIgnoreCase)).ToList(); const double DesiredAspect = 2.0 / 3; - programEntry.PrimaryImage = GetProgramImage(ApiUrl, imagesWithText, true, DesiredAspect) ?? - GetProgramImage(ApiUrl, allImages, true, DesiredAspect); + programEntry.PrimaryImage = GetProgramImage(ApiUrl, imagesWithText, DesiredAspect) ?? + GetProgramImage(ApiUrl, allImages, DesiredAspect); const double WideAspect = 16.0 / 9; - programEntry.ThumbImage = GetProgramImage(ApiUrl, imagesWithText, true, WideAspect); + programEntry.ThumbImage = GetProgramImage(ApiUrl, imagesWithText, WideAspect); // Don't supply the same image twice if (string.Equals(programEntry.PrimaryImage, programEntry.ThumbImage, StringComparison.Ordinal)) @@ -168,7 +177,7 @@ namespace Emby.Server.Implementations.LiveTv.Listings programEntry.ThumbImage = null; } - programEntry.BackdropImage = GetProgramImage(ApiUrl, imagesWithoutText, true, WideAspect); + programEntry.BackdropImage = GetProgramImage(ApiUrl, imagesWithoutText, WideAspect); // programEntry.bannerImage = GetProgramImage(ApiUrl, data, "Banner", false) ?? // GetProgramImage(ApiUrl, data, "Banner-L1", false) ?? @@ -217,7 +226,12 @@ namespace Emby.Server.Implementations.LiveTv.Listings private ProgramInfo GetProgram(string channelId, ProgramDto programInfo, ProgramDetailsDto details) { - var startAt = GetDate(programInfo.AirDateTime); + if (programInfo.AirDateTime == null) + { + return null; + } + + var startAt = programInfo.AirDateTime.Value; var endAt = startAt.AddSeconds(programInfo.Duration); var audioType = ProgramAudio.Stereo; @@ -225,21 +239,21 @@ namespace Emby.Server.Implementations.LiveTv.Listings string newID = programId + "T" + startAt.Ticks + "C" + channelId; - if (programInfo.AudioProperties != null) + if (programInfo.AudioProperties.Count != 0) { - if (programInfo.AudioProperties.Exists(item => string.Equals(item, "atmos", StringComparison.OrdinalIgnoreCase))) + if (programInfo.AudioProperties.Contains("atmos", StringComparison.OrdinalIgnoreCase)) { audioType = ProgramAudio.Atmos; } - else if (programInfo.AudioProperties.Exists(item => string.Equals(item, "dd 5.1", StringComparison.OrdinalIgnoreCase))) + else if (programInfo.AudioProperties.Contains("dd 5.1", StringComparison.OrdinalIgnoreCase)) { audioType = ProgramAudio.DolbyDigital; } - else if (programInfo.AudioProperties.Exists(item => string.Equals(item, "dd", StringComparison.OrdinalIgnoreCase))) + else if (programInfo.AudioProperties.Contains("dd", StringComparison.OrdinalIgnoreCase)) { audioType = ProgramAudio.DolbyDigital; } - else if (programInfo.AudioProperties.Exists(item => string.Equals(item, "stereo", StringComparison.OrdinalIgnoreCase))) + else if (programInfo.AudioProperties.Contains("stereo", StringComparison.OrdinalIgnoreCase)) { audioType = ProgramAudio.Stereo; } @@ -301,8 +315,8 @@ namespace Emby.Server.Implementations.LiveTv.Listings if (programInfo.VideoProperties != null) { - info.IsHD = programInfo.VideoProperties.Contains("hdtv", StringComparer.OrdinalIgnoreCase); - info.Is3D = programInfo.VideoProperties.Contains("3d", StringComparer.OrdinalIgnoreCase); + info.IsHD = programInfo.VideoProperties.Contains("hdtv", StringComparison.OrdinalIgnoreCase); + info.Is3D = programInfo.VideoProperties.Contains("3d", StringComparison.OrdinalIgnoreCase); } if (details.ContentRating != null && details.ContentRating.Count > 0) @@ -311,7 +325,7 @@ namespace Emby.Server.Implementations.LiveTv.Listings .Replace("--", "-", StringComparison.Ordinal); var invalid = new[] { "N/A", "Approved", "Not Rated", "Passed" }; - if (invalid.Contains(info.OfficialRating, StringComparer.OrdinalIgnoreCase)) + if (invalid.Contains(info.OfficialRating, StringComparison.OrdinalIgnoreCase)) { info.OfficialRating = null; } @@ -355,9 +369,9 @@ namespace Emby.Server.Implementations.LiveTv.Listings } } - if (!string.IsNullOrWhiteSpace(details.OriginalAirDate)) + if (details.OriginalAirDate != null) { - info.OriginalAirDate = DateTime.Parse(details.OriginalAirDate, CultureInfo.InvariantCulture); + info.OriginalAirDate = details.OriginalAirDate; info.ProductionYear = info.OriginalAirDate.Value.Year; } @@ -373,9 +387,9 @@ namespace Emby.Server.Implementations.LiveTv.Listings if (details.Genres != null) { info.Genres = details.Genres.Where(g => !string.IsNullOrWhiteSpace(g)).ToList(); - info.IsNews = details.Genres.Contains("news", StringComparer.OrdinalIgnoreCase); + info.IsNews = details.Genres.Contains("news", StringComparison.OrdinalIgnoreCase); - if (info.Genres.Contains("children", StringComparer.OrdinalIgnoreCase)) + if (info.Genres.Contains("children", StringComparison.OrdinalIgnoreCase)) { info.IsKids = true; } @@ -384,19 +398,7 @@ namespace Emby.Server.Implementations.LiveTv.Listings return info; } - private static DateTime GetDate(string value) - { - var date = DateTime.ParseExact(value, "yyyy'-'MM'-'dd'T'HH':'mm':'ss'Z'", CultureInfo.InvariantCulture); - - if (date.Kind != DateTimeKind.Utc) - { - date = DateTime.SpecifyKind(date, DateTimeKind.Utc); - } - - return date; - } - - private string GetProgramImage(string apiUrl, IEnumerable<ImageDataDto> images, bool returnDefaultImage, double desiredAspect) + private static string GetProgramImage(string apiUrl, IEnumerable<ImageDataDto> images, double desiredAspect) { var match = images .OrderBy(i => Math.Abs(desiredAspect - GetAspectRatio(i))) @@ -449,14 +451,14 @@ namespace Emby.Server.Implementations.LiveTv.Listings return result; } - private async Task<List<ShowImagesDto>> GetImageForPrograms( + private async Task<IReadOnlyList<ShowImagesDto>> GetImageForPrograms( ListingsProviderInfo info, IReadOnlyList<string> programIds, CancellationToken cancellationToken) { if (programIds.Count == 0) { - return new List<ShowImagesDto>(); + return Array.Empty<ShowImagesDto>(); } StringBuilder str = new StringBuilder("[", 1 + (programIds.Count * 13)); @@ -480,13 +482,13 @@ namespace Emby.Server.Implementations.LiveTv.Listings { using var innerResponse2 = await Send(message, true, info, cancellationToken).ConfigureAwait(false); await using var response = await innerResponse2.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); - return await JsonSerializer.DeserializeAsync<List<ShowImagesDto>>(response, _jsonOptions, cancellationToken).ConfigureAwait(false); + return await JsonSerializer.DeserializeAsync<IReadOnlyList<ShowImagesDto>>(response, _jsonOptions, cancellationToken).ConfigureAwait(false); } catch (Exception ex) { _logger.LogError(ex, "Error getting image info from schedules direct"); - return new List<ShowImagesDto>(); + return Array.Empty<ShowImagesDto>(); } } @@ -509,7 +511,7 @@ namespace Emby.Server.Implementations.LiveTv.Listings using var httpResponse = await Send(options, false, info, cancellationToken).ConfigureAwait(false); await using var response = await httpResponse.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); - var root = await JsonSerializer.DeserializeAsync<List<HeadendsDto>>(response, _jsonOptions, cancellationToken).ConfigureAwait(false); + var root = await JsonSerializer.DeserializeAsync<IReadOnlyList<HeadendsDto>>(response, _jsonOptions, cancellationToken).ConfigureAwait(false); if (root != null) { @@ -520,7 +522,7 @@ namespace Emby.Server.Implementations.LiveTv.Listings lineups.Add(new NameIdPair { Name = string.IsNullOrWhiteSpace(lineup.Name) ? lineup.Lineup : lineup.Name, - Id = lineup.Uri[18..] + Id = lineup.Uri?[18..] }); } } @@ -641,7 +643,9 @@ namespace Emby.Server.Implementations.LiveTv.Listings CancellationToken cancellationToken) { using var options = new HttpRequestMessage(HttpMethod.Post, ApiUrl + "/token"); - var hashedPasswordBytes = _cryptoProvider.ComputeHash("SHA1", Encoding.ASCII.GetBytes(password), Array.Empty<byte>()); +#pragma warning disable CA5350 // SchedulesDirect is always SHA1. + var hashedPasswordBytes = SHA1.HashData(Encoding.ASCII.GetBytes(password)); +#pragma warning restore CA5350 // TODO: remove ToLower when Convert.ToHexString supports lowercase // Schedules Direct requires the hex to be lowercase string hashedPassword = Convert.ToHexString(hashedPasswordBytes).ToLowerInvariant(); @@ -651,7 +655,7 @@ namespace Emby.Server.Implementations.LiveTv.Listings response.EnsureSuccessStatusCode(); await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); var root = await JsonSerializer.DeserializeAsync<TokenDto>(stream, _jsonOptions, cancellationToken).ConfigureAwait(false); - if (string.Equals(root.Message, "OK", StringComparison.Ordinal)) + if (string.Equals(root?.Message, "OK", StringComparison.Ordinal)) { _logger.LogInformation("Authenticated with Schedules Direct token: {Token}", root.Token); return root.Token; @@ -708,12 +712,12 @@ namespace Emby.Server.Implementations.LiveTv.Listings using var response = httpResponse.Content; var root = await JsonSerializer.DeserializeAsync<LineupsDto>(stream, _jsonOptions, cancellationToken).ConfigureAwait(false); - return root.Lineups.Any(i => string.Equals(info.ListingsId, i.Lineup, StringComparison.OrdinalIgnoreCase)); + return root?.Lineups.Any(i => string.Equals(info.ListingsId, i.Lineup, StringComparison.OrdinalIgnoreCase)) ?? false; } catch (HttpRequestException ex) { // SchedulesDirect returns 400 if no lineups are configured. - if (ex.StatusCode.HasValue && ex.StatusCode.Value == HttpStatusCode.BadRequest) + if (ex.StatusCode is HttpStatusCode.BadRequest) { return false; } @@ -779,10 +783,15 @@ namespace Emby.Server.Implementations.LiveTv.Listings using var httpResponse = await Send(options, true, info, cancellationToken).ConfigureAwait(false); await using var stream = await httpResponse.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); var root = await JsonSerializer.DeserializeAsync<ChannelDto>(stream, _jsonOptions, cancellationToken).ConfigureAwait(false); + if (root == null) + { + return new List<ChannelInfo>(); + } + _logger.LogInformation("Found {ChannelCount} channels on the lineup on ScheduleDirect", root.Map.Count); _logger.LogInformation("Mapping Stations to Channel"); - var allStations = root.Stations ?? new List<StationDto>(); + var allStations = root.Stations; var map = root.Map; var list = new List<ChannelInfo>(map.Count); @@ -790,11 +799,10 @@ namespace Emby.Server.Implementations.LiveTv.Listings { var channelNumber = GetChannelNumber(channel); - var station = allStations.Find(item => string.Equals(item.StationId, channel.StationId, StringComparison.OrdinalIgnoreCase)) - ?? new StationDto - { - StationId = channel.StationId - }; + var stationIndex = allStations.FindIndex(item => string.Equals(item.StationId, channel.StationId, StringComparison.OrdinalIgnoreCase)); + var station = stationIndex == -1 + ? new StationDto { StationId = channel.StationId } + : allStations[stationIndex]; var channelInfo = new ChannelInfo { @@ -814,10 +822,5 @@ namespace Emby.Server.Implementations.LiveTv.Listings return list; } - - private static string NormalizeName(string value) - { - return value.Replace(" ", string.Empty, StringComparison.Ordinal).Replace("-", string.Empty, StringComparison.Ordinal); - } } } diff --git a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/BroadcasterDto.cs b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/BroadcasterDto.cs index b881b307c..95ac996e0 100644 --- a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/BroadcasterDto.cs +++ b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/BroadcasterDto.cs @@ -1,5 +1,3 @@ -#nullable disable - using System.Text.Json.Serialization; namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos @@ -13,24 +11,24 @@ namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos /// Gets or sets the city. /// </summary> [JsonPropertyName("city")] - public string City { get; set; } + public string? City { get; set; } /// <summary> /// Gets or sets the state. /// </summary> [JsonPropertyName("state")] - public string State { get; set; } + public string? State { get; set; } /// <summary> /// Gets or sets the postal code. /// </summary> [JsonPropertyName("postalCode")] - public string Postalcode { get; set; } + public string? Postalcode { get; set; } /// <summary> /// Gets or sets the country. /// </summary> [JsonPropertyName("country")] - public string Country { get; set; } + public string? Country { get; set; } } } diff --git a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/CaptionDto.cs b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/CaptionDto.cs index 96b67d1eb..f6251b9ad 100644 --- a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/CaptionDto.cs +++ b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/CaptionDto.cs @@ -1,5 +1,3 @@ -#nullable disable - using System.Text.Json.Serialization; namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos @@ -13,12 +11,12 @@ namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos /// Gets or sets the content. /// </summary> [JsonPropertyName("content")] - public string Content { get; set; } + public string? Content { get; set; } /// <summary> /// Gets or sets the lang. /// </summary> [JsonPropertyName("lang")] - public string Lang { get; set; } + public string? Lang { get; set; } } } diff --git a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/CastDto.cs b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/CastDto.cs index dac6f5f3e..0b7a2c63a 100644 --- a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/CastDto.cs +++ b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/CastDto.cs @@ -1,5 +1,3 @@ -#nullable disable - using System.Text.Json.Serialization; namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos @@ -13,36 +11,36 @@ namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos /// Gets or sets the billing order. /// </summary> [JsonPropertyName("billingOrder")] - public string BillingOrder { get; set; } + public string? BillingOrder { get; set; } /// <summary> /// Gets or sets the role. /// </summary> [JsonPropertyName("role")] - public string Role { get; set; } + public string? Role { get; set; } /// <summary> /// Gets or sets the name id. /// </summary> [JsonPropertyName("nameId")] - public string NameId { get; set; } + public string? NameId { get; set; } /// <summary> /// Gets or sets the person id. /// </summary> [JsonPropertyName("personId")] - public string PersonId { get; set; } + public string? PersonId { get; set; } /// <summary> /// Gets or sets the name. /// </summary> [JsonPropertyName("name")] - public string Name { get; set; } + public string? Name { get; set; } /// <summary> /// Gets or sets the character name. /// </summary> [JsonPropertyName("characterName")] - public string CharacterName { get; set; } + public string? CharacterName { get; set; } } } diff --git a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/ChannelDto.cs b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/ChannelDto.cs index 8c9c2c1fc..87c327ed8 100644 --- a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/ChannelDto.cs +++ b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/ChannelDto.cs @@ -1,5 +1,4 @@ -#nullable disable - +using System; using System.Collections.Generic; using System.Text.Json.Serialization; @@ -14,18 +13,18 @@ namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos /// Gets or sets the list of maps. /// </summary> [JsonPropertyName("map")] - public List<MapDto> Map { get; set; } + public IReadOnlyList<MapDto> Map { get; set; } = Array.Empty<MapDto>(); /// <summary> /// Gets or sets the list of stations. /// </summary> [JsonPropertyName("stations")] - public List<StationDto> Stations { get; set; } + public IReadOnlyList<StationDto> Stations { get; set; } = Array.Empty<StationDto>(); /// <summary> /// Gets or sets the metadata. /// </summary> [JsonPropertyName("metadata")] - public MetadataDto Metadata { get; set; } + public MetadataDto? Metadata { get; set; } } } diff --git a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/ContentRatingDto.cs b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/ContentRatingDto.cs index 135b5bb08..c19cd2e48 100644 --- a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/ContentRatingDto.cs +++ b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/ContentRatingDto.cs @@ -1,5 +1,3 @@ -#nullable disable - using System.Text.Json.Serialization; namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos @@ -13,12 +11,12 @@ namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos /// Gets or sets the body. /// </summary> [JsonPropertyName("body")] - public string Body { get; set; } + public string? Body { get; set; } /// <summary> /// Gets or sets the code. /// </summary> [JsonPropertyName("code")] - public string Code { get; set; } + public string? Code { get; set; } } } diff --git a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/CrewDto.cs b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/CrewDto.cs index 82d1001c8..f00c9accd 100644 --- a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/CrewDto.cs +++ b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/CrewDto.cs @@ -1,5 +1,3 @@ -#nullable disable - using System.Text.Json.Serialization; namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos @@ -13,30 +11,30 @@ namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos /// Gets or sets the billing order. /// </summary> [JsonPropertyName("billingOrder")] - public string BillingOrder { get; set; } + public string? BillingOrder { get; set; } /// <summary> /// Gets or sets the role. /// </summary> [JsonPropertyName("role")] - public string Role { get; set; } + public string? Role { get; set; } /// <summary> /// Gets or sets the name id. /// </summary> [JsonPropertyName("nameId")] - public string NameId { get; set; } + public string? NameId { get; set; } /// <summary> /// Gets or sets the person id. /// </summary> [JsonPropertyName("personId")] - public string PersonId { get; set; } + public string? PersonId { get; set; } /// <summary> /// Gets or sets the name. /// </summary> [JsonPropertyName("name")] - public string Name { get; set; } + public string? Name { get; set; } } } diff --git a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/DayDto.cs b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/DayDto.cs index 68876b068..1a371965c 100644 --- a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/DayDto.cs +++ b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/DayDto.cs @@ -1,5 +1,4 @@ -#nullable disable - +using System; using System.Collections.Generic; using System.Text.Json.Serialization; @@ -10,30 +9,22 @@ namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos /// </summary> public class DayDto { - /// <summary> - /// Initializes a new instance of the <see cref="DayDto"/> class. - /// </summary> - public DayDto() - { - Programs = new List<ProgramDto>(); - } - /// <summary> /// Gets or sets the station id. /// </summary> [JsonPropertyName("stationID")] - public string StationId { get; set; } + public string? StationId { get; set; } /// <summary> /// Gets or sets the list of programs. /// </summary> [JsonPropertyName("programs")] - public List<ProgramDto> Programs { get; set; } + public IReadOnlyList<ProgramDto> Programs { get; set; } = Array.Empty<ProgramDto>(); /// <summary> /// Gets or sets the metadata schedule. /// </summary> [JsonPropertyName("metadata")] - public MetadataScheduleDto Metadata { get; set; } + public MetadataScheduleDto? Metadata { get; set; } } } diff --git a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/Description1000Dto.cs b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/Description1000Dto.cs index d3e6ff393..ca6ae7fb1 100644 --- a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/Description1000Dto.cs +++ b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/Description1000Dto.cs @@ -1,5 +1,3 @@ -#nullable disable - using System.Text.Json.Serialization; namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos @@ -13,12 +11,12 @@ namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos /// Gets or sets the description language. /// </summary> [JsonPropertyName("descriptionLanguage")] - public string DescriptionLanguage { get; set; } + public string? DescriptionLanguage { get; set; } /// <summary> /// Gets or sets the description. /// </summary> [JsonPropertyName("description")] - public string Description { get; set; } + public string? Description { get; set; } } } diff --git a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/Description100Dto.cs b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/Description100Dto.cs index 04360266c..1577219ed 100644 --- a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/Description100Dto.cs +++ b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/Description100Dto.cs @@ -1,5 +1,3 @@ -#nullable disable - using System.Text.Json.Serialization; namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos @@ -13,12 +11,12 @@ namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos /// Gets or sets the description language. /// </summary> [JsonPropertyName("descriptionLanguage")] - public string DescriptionLanguage { get; set; } + public string? DescriptionLanguage { get; set; } /// <summary> /// Gets or sets the description. /// </summary> [JsonPropertyName("description")] - public string Description { get; set; } + public string? Description { get; set; } } } diff --git a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/DescriptionsProgramDto.cs b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/DescriptionsProgramDto.cs index 3af36ae96..eaf4a340b 100644 --- a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/DescriptionsProgramDto.cs +++ b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/DescriptionsProgramDto.cs @@ -1,5 +1,4 @@ -#nullable disable - +using System; using System.Collections.Generic; using System.Text.Json.Serialization; @@ -14,12 +13,12 @@ namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos /// Gets or sets the list of description 100. /// </summary> [JsonPropertyName("description100")] - public List<Description100Dto> Description100 { get; set; } + public IReadOnlyList<Description100Dto> Description100 { get; set; } = Array.Empty<Description100Dto>(); /// <summary> /// Gets or sets the list of description1000. /// </summary> [JsonPropertyName("description1000")] - public List<Description1000Dto> Description1000 { get; set; } + public IReadOnlyList<Description1000Dto> Description1000 { get; set; } = Array.Empty<Description1000Dto>(); } } diff --git a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/EventDetailsDto.cs b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/EventDetailsDto.cs index c3b2bd9c1..fbdfb1f71 100644 --- a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/EventDetailsDto.cs +++ b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/EventDetailsDto.cs @@ -1,5 +1,3 @@ -#nullable disable - using System.Text.Json.Serialization; namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos @@ -13,6 +11,6 @@ namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos /// Gets or sets the sub type. /// </summary> [JsonPropertyName("subType")] - public string SubType { get; set; } + public string? SubType { get; set; } } } diff --git a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/GracenoteDto.cs b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/GracenoteDto.cs index 3d8bea362..6852d89d7 100644 --- a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/GracenoteDto.cs +++ b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/GracenoteDto.cs @@ -1,5 +1,3 @@ -#nullable disable - using System.Text.Json.Serialization; namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos diff --git a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/HeadendsDto.cs b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/HeadendsDto.cs index 1fb3decb2..b9844562f 100644 --- a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/HeadendsDto.cs +++ b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/HeadendsDto.cs @@ -1,5 +1,4 @@ -#nullable disable - +using System; using System.Collections.Generic; using System.Text.Json.Serialization; @@ -14,24 +13,24 @@ namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos /// Gets or sets the headend. /// </summary> [JsonPropertyName("headend")] - public string Headend { get; set; } + public string? Headend { get; set; } /// <summary> /// Gets or sets the transport. /// </summary> [JsonPropertyName("transport")] - public string Transport { get; set; } + public string? Transport { get; set; } /// <summary> /// Gets or sets the location. /// </summary> [JsonPropertyName("location")] - public string Location { get; set; } + public string? Location { get; set; } /// <summary> /// Gets or sets the list of lineups. /// </summary> [JsonPropertyName("lineups")] - public List<LineupDto> Lineups { get; set; } + public IReadOnlyList<LineupDto> Lineups { get; set; } = Array.Empty<LineupDto>(); } } diff --git a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/ImageDataDto.cs b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/ImageDataDto.cs index 912e680dd..a1ae3ca6d 100644 --- a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/ImageDataDto.cs +++ b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/ImageDataDto.cs @@ -1,5 +1,3 @@ -#nullable disable - using System.Text.Json.Serialization; namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos @@ -13,60 +11,60 @@ namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos /// Gets or sets the width. /// </summary> [JsonPropertyName("width")] - public string Width { get; set; } + public string? Width { get; set; } /// <summary> /// Gets or sets the height. /// </summary> [JsonPropertyName("height")] - public string Height { get; set; } + public string? Height { get; set; } /// <summary> /// Gets or sets the uri. /// </summary> [JsonPropertyName("uri")] - public string Uri { get; set; } + public string? Uri { get; set; } /// <summary> /// Gets or sets the size. /// </summary> [JsonPropertyName("size")] - public string Size { get; set; } + public string? Size { get; set; } /// <summary> /// Gets or sets the aspect. /// </summary> [JsonPropertyName("aspect")] - public string aspect { get; set; } + public string? Aspect { get; set; } /// <summary> /// Gets or sets the category. /// </summary> [JsonPropertyName("category")] - public string Category { get; set; } + public string? Category { get; set; } /// <summary> /// Gets or sets the text. /// </summary> [JsonPropertyName("text")] - public string Text { get; set; } + public string? Text { get; set; } /// <summary> /// Gets or sets the primary. /// </summary> [JsonPropertyName("primary")] - public string Primary { get; set; } + public string? Primary { get; set; } /// <summary> /// Gets or sets the tier. /// </summary> [JsonPropertyName("tier")] - public string Tier { get; set; } + public string? Tier { get; set; } /// <summary> /// Gets or sets the caption. /// </summary> [JsonPropertyName("caption")] - public CaptionDto Caption { get; set; } + public CaptionDto? Caption { get; set; } } } diff --git a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/LineupDto.cs b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/LineupDto.cs index 52e920aa6..3dc64e5d8 100644 --- a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/LineupDto.cs +++ b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/LineupDto.cs @@ -1,5 +1,3 @@ -#nullable disable - using System.Text.Json.Serialization; namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos @@ -13,30 +11,36 @@ namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos /// Gets or sets the linup. /// </summary> [JsonPropertyName("lineup")] - public string Lineup { get; set; } + public string? Lineup { get; set; } /// <summary> /// Gets or sets the lineup name. /// </summary> [JsonPropertyName("name")] - public string Name { get; set; } + public string? Name { get; set; } /// <summary> /// Gets or sets the transport. /// </summary> [JsonPropertyName("transport")] - public string Transport { get; set; } + public string? Transport { get; set; } /// <summary> /// Gets or sets the location. /// </summary> [JsonPropertyName("location")] - public string Location { get; set; } + public string? Location { get; set; } /// <summary> /// Gets or sets the uri. /// </summary> [JsonPropertyName("uri")] - public string Uri { get; set; } + public string? Uri { get; set; } + + /// <summary> + /// Gets or sets a value indicating whether this lineup was deleted. + /// </summary> + [JsonPropertyName("isDeleted")] + public bool? IsDeleted { get; set; } } } diff --git a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/LineupsDto.cs b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/LineupsDto.cs index 15139ba3b..f19081781 100644 --- a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/LineupsDto.cs +++ b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/LineupsDto.cs @@ -1,5 +1,4 @@ -#nullable disable - +using System; using System.Collections.Generic; using System.Text.Json.Serialization; @@ -20,18 +19,18 @@ namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos /// Gets or sets the server id. /// </summary> [JsonPropertyName("serverID")] - public string ServerId { get; set; } + public string? ServerId { get; set; } /// <summary> /// Gets or sets the datetime. /// </summary> [JsonPropertyName("datetime")] - public string Datetime { get; set; } + public DateTime? LineupTimestamp { get; set; } /// <summary> /// Gets or sets the list of lineups. /// </summary> [JsonPropertyName("lineups")] - public List<LineupDto> Lineups { get; set; } + public IReadOnlyList<LineupDto> Lineups { get; set; } = Array.Empty<LineupDto>(); } } diff --git a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/LogoDto.cs b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/LogoDto.cs index 7b235ed7f..fecc55e03 100644 --- a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/LogoDto.cs +++ b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/LogoDto.cs @@ -1,5 +1,3 @@ -#nullable disable - using System.Text.Json.Serialization; namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos @@ -13,7 +11,7 @@ namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos /// Gets or sets the url. /// </summary> [JsonPropertyName("URL")] - public string Url { get; set; } + public string? Url { get; set; } /// <summary> /// Gets or sets the height. @@ -31,6 +29,6 @@ namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos /// Gets or sets the md5. /// </summary> [JsonPropertyName("md5")] - public string Md5 { get; set; } + public string? Md5 { get; set; } } } diff --git a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/MapDto.cs b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/MapDto.cs index 5140277b2..ffd02d474 100644 --- a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/MapDto.cs +++ b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/MapDto.cs @@ -1,5 +1,3 @@ -#nullable disable - using System.Text.Json.Serialization; namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos @@ -13,19 +11,25 @@ namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos /// Gets or sets the station id. /// </summary> [JsonPropertyName("stationID")] - public string StationId { get; set; } + public string? StationId { get; set; } /// <summary> /// Gets or sets the channel. /// </summary> [JsonPropertyName("channel")] - public string Channel { get; set; } + public string? Channel { get; set; } + + /// <summary> + /// Gets or sets the provider callsign. + /// </summary> + [JsonPropertyName("providerCallsign")] + public string? ProvderCallsign { get; set; } /// <summary> /// Gets or sets the logical channel number. /// </summary> [JsonPropertyName("logicalChannelNumber")] - public string LogicalChannelNumber { get; set; } + public string? LogicalChannelNumber { get; set; } /// <summary> /// Gets or sets the uhfvhf. @@ -44,5 +48,11 @@ namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos /// </summary> [JsonPropertyName("atscMinor")] public int AtscMinor { get; set; } + + /// <summary> + /// Gets or sets the match type. + /// </summary> + [JsonPropertyName("matchType")] + public string? MatchType { get; set; } } } diff --git a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/MetadataDto.cs b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/MetadataDto.cs index 5a3893a35..40faa493c 100644 --- a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/MetadataDto.cs +++ b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/MetadataDto.cs @@ -1,5 +1,3 @@ -#nullable disable - using System.Text.Json.Serialization; namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos @@ -13,18 +11,18 @@ namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos /// Gets or sets the linup. /// </summary> [JsonPropertyName("lineup")] - public string Lineup { get; set; } + public string? Lineup { get; set; } /// <summary> /// Gets or sets the modified timestamp. /// </summary> [JsonPropertyName("modified")] - public string Modified { get; set; } + public string? Modified { get; set; } /// <summary> /// Gets or sets the transport. /// </summary> [JsonPropertyName("transport")] - public string Transport { get; set; } + public string? Transport { get; set; } } } diff --git a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/MetadataProgramsDto.cs b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/MetadataProgramsDto.cs index 4057e9802..43f290156 100644 --- a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/MetadataProgramsDto.cs +++ b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/MetadataProgramsDto.cs @@ -1,5 +1,3 @@ -#nullable disable - using System.Text.Json.Serialization; namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos @@ -12,7 +10,7 @@ namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos /// <summary> /// Gets or sets the gracenote object. /// </summary> - [JsonPropertyName("gracenote")] - public GracenoteDto Gracenote { get; set; } + [JsonPropertyName("Gracenote")] + public GracenoteDto? Gracenote { get; set; } } } diff --git a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/MetadataScheduleDto.cs b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/MetadataScheduleDto.cs index 4979296da..04560ab55 100644 --- a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/MetadataScheduleDto.cs +++ b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/MetadataScheduleDto.cs @@ -1,5 +1,4 @@ -#nullable disable - +using System; using System.Text.Json.Serialization; namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos @@ -13,25 +12,25 @@ namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos /// Gets or sets the modified timestamp. /// </summary> [JsonPropertyName("modified")] - public string Modified { get; set; } + public string? Modified { get; set; } /// <summary> /// Gets or sets the md5. /// </summary> [JsonPropertyName("md5")] - public string Md5 { get; set; } + public string? Md5 { get; set; } /// <summary> /// Gets or sets the start date. /// </summary> [JsonPropertyName("startDate")] - public string StartDate { get; set; } + public DateTime? StartDate { get; set; } /// <summary> /// Gets or sets the end date. /// </summary> [JsonPropertyName("endDate")] - public string EndDate { get; set; } + public DateTime? EndDate { get; set; } /// <summary> /// Gets or sets the days count. diff --git a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/MovieDto.cs b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/MovieDto.cs index 48d731d89..31bef423b 100644 --- a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/MovieDto.cs +++ b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/MovieDto.cs @@ -1,5 +1,4 @@ -#nullable disable - +using System; using System.Collections.Generic; using System.Text.Json.Serialization; @@ -14,7 +13,7 @@ namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos /// Gets or sets the year. /// </summary> [JsonPropertyName("year")] - public string Year { get; set; } + public string? Year { get; set; } /// <summary> /// Gets or sets the duration. @@ -26,6 +25,6 @@ namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos /// Gets or sets the list of quality rating. /// </summary> [JsonPropertyName("qualityRating")] - public List<QualityRatingDto> QualityRating { get; set; } + public IReadOnlyList<QualityRatingDto> QualityRating { get; set; } = Array.Empty<QualityRatingDto>(); } } diff --git a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/MultipartDto.cs b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/MultipartDto.cs index 42eddfff2..e8b15dc07 100644 --- a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/MultipartDto.cs +++ b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/MultipartDto.cs @@ -1,5 +1,3 @@ -#nullable disable - using System.Text.Json.Serialization; namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos diff --git a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/ProgramDetailsDto.cs b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/ProgramDetailsDto.cs index a84c47c12..84c48f67f 100644 --- a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/ProgramDetailsDto.cs +++ b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/ProgramDetailsDto.cs @@ -1,5 +1,4 @@ -#nullable disable - +using System; using System.Collections.Generic; using System.Text.Json.Serialization; @@ -14,85 +13,85 @@ namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos /// Gets or sets the audience. /// </summary> [JsonPropertyName("audience")] - public string Audience { get; set; } + public string? Audience { get; set; } /// <summary> /// Gets or sets the program id. /// </summary> [JsonPropertyName("programID")] - public string ProgramId { get; set; } + public string? ProgramId { get; set; } /// <summary> /// Gets or sets the list of titles. /// </summary> [JsonPropertyName("titles")] - public List<TitleDto> Titles { get; set; } + public IReadOnlyList<TitleDto> Titles { get; set; } = Array.Empty<TitleDto>(); /// <summary> /// Gets or sets the event details object. /// </summary> [JsonPropertyName("eventDetails")] - public EventDetailsDto EventDetails { get; set; } + public EventDetailsDto? EventDetails { get; set; } /// <summary> /// Gets or sets the descriptions. /// </summary> [JsonPropertyName("descriptions")] - public DescriptionsProgramDto Descriptions { get; set; } + public DescriptionsProgramDto? Descriptions { get; set; } /// <summary> /// Gets or sets the original air date. /// </summary> [JsonPropertyName("originalAirDate")] - public string OriginalAirDate { get; set; } + public DateTime? OriginalAirDate { get; set; } /// <summary> /// Gets or sets the list of genres. /// </summary> [JsonPropertyName("genres")] - public List<string> Genres { get; set; } + public IReadOnlyList<string> Genres { get; set; } = Array.Empty<string>(); /// <summary> /// Gets or sets the episode title. /// </summary> [JsonPropertyName("episodeTitle150")] - public string EpisodeTitle150 { get; set; } + public string? EpisodeTitle150 { get; set; } /// <summary> /// Gets or sets the list of metadata. /// </summary> [JsonPropertyName("metadata")] - public List<MetadataProgramsDto> Metadata { get; set; } + public IReadOnlyList<MetadataProgramsDto> Metadata { get; set; } = Array.Empty<MetadataProgramsDto>(); /// <summary> /// Gets or sets the list of content raitings. /// </summary> [JsonPropertyName("contentRating")] - public List<ContentRatingDto> ContentRating { get; set; } + public IReadOnlyList<ContentRatingDto> ContentRating { get; set; } = Array.Empty<ContentRatingDto>(); /// <summary> /// Gets or sets the list of cast. /// </summary> [JsonPropertyName("cast")] - public List<CastDto> Cast { get; set; } + public IReadOnlyList<CastDto> Cast { get; set; } = Array.Empty<CastDto>(); /// <summary> /// Gets or sets the list of crew. /// </summary> [JsonPropertyName("crew")] - public List<CrewDto> Crew { get; set; } + public IReadOnlyList<CrewDto> Crew { get; set; } = Array.Empty<CrewDto>(); /// <summary> /// Gets or sets the entity type. /// </summary> [JsonPropertyName("entityType")] - public string EntityType { get; set; } + public string? EntityType { get; set; } /// <summary> /// Gets or sets the show type. /// </summary> [JsonPropertyName("showType")] - public string ShowType { get; set; } + public string? ShowType { get; set; } /// <summary> /// Gets or sets a value indicating whether there is image artwork. @@ -104,54 +103,54 @@ namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos /// Gets or sets the primary image. /// </summary> [JsonPropertyName("primaryImage")] - public string PrimaryImage { get; set; } + public string? PrimaryImage { get; set; } /// <summary> /// Gets or sets the thumb image. /// </summary> [JsonPropertyName("thumbImage")] - public string ThumbImage { get; set; } + public string? ThumbImage { get; set; } /// <summary> /// Gets or sets the backdrop image. /// </summary> [JsonPropertyName("backdropImage")] - public string BackdropImage { get; set; } + public string? BackdropImage { get; set; } /// <summary> /// Gets or sets the banner image. /// </summary> [JsonPropertyName("bannerImage")] - public string BannerImage { get; set; } + public string? BannerImage { get; set; } /// <summary> /// Gets or sets the image id. /// </summary> [JsonPropertyName("imageID")] - public string ImageId { get; set; } + public string? ImageId { get; set; } /// <summary> /// Gets or sets the md5. /// </summary> [JsonPropertyName("md5")] - public string Md5 { get; set; } + public string? Md5 { get; set; } /// <summary> /// Gets or sets the list of content advisory. /// </summary> [JsonPropertyName("contentAdvisory")] - public List<string> ContentAdvisory { get; set; } + public IReadOnlyList<string> ContentAdvisory { get; set; } = Array.Empty<string>(); /// <summary> /// Gets or sets the movie object. /// </summary> [JsonPropertyName("movie")] - public MovieDto Movie { get; set; } + public MovieDto? Movie { get; set; } /// <summary> /// Gets or sets the list of recommendations. /// </summary> [JsonPropertyName("recommendations")] - public List<RecommendationDto> Recommendations { get; set; } + public IReadOnlyList<RecommendationDto> Recommendations { get; set; } = Array.Empty<RecommendationDto>(); } } diff --git a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/ProgramDto.cs b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/ProgramDto.cs index ad5389100..60389b45b 100644 --- a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/ProgramDto.cs +++ b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/ProgramDto.cs @@ -1,5 +1,4 @@ -#nullable disable - +using System; using System.Collections.Generic; using System.Text.Json.Serialization; @@ -14,13 +13,13 @@ namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos /// Gets or sets the program id. /// </summary> [JsonPropertyName("programID")] - public string ProgramId { get; set; } + public string? ProgramId { get; set; } /// <summary> /// Gets or sets the air date time. /// </summary> [JsonPropertyName("airDateTime")] - public string AirDateTime { get; set; } + public DateTime? AirDateTime { get; set; } /// <summary> /// Gets or sets the duration. @@ -32,25 +31,25 @@ namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos /// Gets or sets the md5. /// </summary> [JsonPropertyName("md5")] - public string Md5 { get; set; } + public string? Md5 { get; set; } /// <summary> /// Gets or sets the list of audio properties. /// </summary> [JsonPropertyName("audioProperties")] - public List<string> AudioProperties { get; set; } + public IReadOnlyList<string> AudioProperties { get; set; } = Array.Empty<string>(); /// <summary> /// Gets or sets the list of video properties. /// </summary> [JsonPropertyName("videoProperties")] - public List<string> VideoProperties { get; set; } + public IReadOnlyList<string> VideoProperties { get; set; } = Array.Empty<string>(); /// <summary> /// Gets or sets the list of ratings. /// </summary> [JsonPropertyName("ratings")] - public List<RatingDto> Ratings { get; set; } + public IReadOnlyList<RatingDto> Ratings { get; set; } = Array.Empty<RatingDto>(); /// <summary> /// Gets or sets a value indicating whether this program is new. @@ -62,13 +61,13 @@ namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos /// Gets or sets the multipart object. /// </summary> [JsonPropertyName("multipart")] - public MultipartDto Multipart { get; set; } + public MultipartDto? Multipart { get; set; } /// <summary> /// Gets or sets the live tape delay. /// </summary> [JsonPropertyName("liveTapeDelay")] - public string LiveTapeDelay { get; set; } + public string? LiveTapeDelay { get; set; } /// <summary> /// Gets or sets a value indicating whether this is the premiere. @@ -86,6 +85,6 @@ namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos /// Gets or sets the premiere or finale. /// </summary> [JsonPropertyName("isPremiereOrFinale")] - public string IsPremiereOrFinale { get; set; } + public string? IsPremiereOrFinale { get; set; } } } diff --git a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/QualityRatingDto.cs b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/QualityRatingDto.cs index 5cd0a7459..c5ddcf7c5 100644 --- a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/QualityRatingDto.cs +++ b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/QualityRatingDto.cs @@ -1,5 +1,3 @@ -#nullable disable - using System.Text.Json.Serialization; namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos @@ -13,30 +11,30 @@ namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos /// Gets or sets the ratings body. /// </summary> [JsonPropertyName("ratingsBody")] - public string RatingsBody { get; set; } + public string? RatingsBody { get; set; } /// <summary> /// Gets or sets the rating. /// </summary> [JsonPropertyName("rating")] - public string Rating { get; set; } + public string? Rating { get; set; } /// <summary> /// Gets or sets the min rating. /// </summary> [JsonPropertyName("minRating")] - public string MinRating { get; set; } + public string? MinRating { get; set; } /// <summary> /// Gets or sets the max rating. /// </summary> [JsonPropertyName("maxRating")] - public string MaxRating { get; set; } + public string? MaxRating { get; set; } /// <summary> /// Gets or sets the increment. /// </summary> [JsonPropertyName("increment")] - public string Increment { get; set; } + public string? Increment { get; set; } } } diff --git a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/RatingDto.cs b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/RatingDto.cs index 948b83144..e04b619a4 100644 --- a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/RatingDto.cs +++ b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/RatingDto.cs @@ -1,5 +1,3 @@ -#nullable disable - using System.Text.Json.Serialization; namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos @@ -13,12 +11,12 @@ namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos /// Gets or sets the body. /// </summary> [JsonPropertyName("body")] - public string Body { get; set; } + public string? Body { get; set; } /// <summary> /// Gets or sets the code. /// </summary> [JsonPropertyName("code")] - public string Code { get; set; } + public string? Code { get; set; } } } diff --git a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/RecommendationDto.cs b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/RecommendationDto.cs index 1308f45ce..c8f79fd1c 100644 --- a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/RecommendationDto.cs +++ b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/RecommendationDto.cs @@ -1,5 +1,3 @@ -#nullable disable - using System.Text.Json.Serialization; namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos @@ -13,12 +11,12 @@ namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos /// Gets or sets the program id. /// </summary> [JsonPropertyName("programID")] - public string ProgramId { get; set; } + public string? ProgramId { get; set; } /// <summary> /// Gets or sets the title. /// </summary> [JsonPropertyName("title120")] - public string Title120 { get; set; } + public string? Title120 { get; set; } } } diff --git a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/RequestScheduleForChannelDto.cs b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/RequestScheduleForChannelDto.cs index fb7a31ac8..0cd05709b 100644 --- a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/RequestScheduleForChannelDto.cs +++ b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/RequestScheduleForChannelDto.cs @@ -1,5 +1,4 @@ -#nullable disable - +using System; using System.Collections.Generic; using System.Text.Json.Serialization; @@ -14,12 +13,12 @@ namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos /// Gets or sets the station id. /// </summary> [JsonPropertyName("stationID")] - public string StationId { get; set; } + public string? StationId { get; set; } /// <summary> /// Gets or sets the list of dates. /// </summary> [JsonPropertyName("date")] - public List<string> Date { get; set; } + public IReadOnlyList<string> Date { get; set; } = Array.Empty<string>(); } } diff --git a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/ShowImagesDto.cs b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/ShowImagesDto.cs index 34302370d..84e224b71 100644 --- a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/ShowImagesDto.cs +++ b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/ShowImagesDto.cs @@ -1,5 +1,4 @@ -#nullable disable - +using System; using System.Collections.Generic; using System.Text.Json.Serialization; @@ -14,12 +13,12 @@ namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos /// Gets or sets the program id. /// </summary> [JsonPropertyName("programID")] - public string ProgramId { get; set; } + public string? ProgramId { get; set; } /// <summary> /// Gets or sets the list of data. /// </summary> [JsonPropertyName("data")] - public List<ImageDataDto> Data { get; set; } + public IReadOnlyList<ImageDataDto> Data { get; set; } = Array.Empty<ImageDataDto>(); } } diff --git a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/StationDto.cs b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/StationDto.cs index 12f3576c6..d797fd49b 100644 --- a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/StationDto.cs +++ b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/StationDto.cs @@ -1,67 +1,66 @@ -#nullable disable - +using System; using System.Collections.Generic; using System.Text.Json.Serialization; namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos { /// <summary> - /// Station dto. - /// </summary> - public class StationDto - { - /// <summary> - /// Gets or sets the station id. - /// </summary> - [JsonPropertyName("stationID")] - public string StationId { get; set; } + /// Station dto. + /// </summary> + public class StationDto + { + /// <summary> + /// Gets or sets the station id. + /// </summary> + [JsonPropertyName("stationID")] + public string? StationId { get; set; } - /// <summary> - /// Gets or sets the name. - /// </summary> - [JsonPropertyName("name")] - public string Name { get; set; } + /// <summary> + /// Gets or sets the name. + /// </summary> + [JsonPropertyName("name")] + public string? Name { get; set; } - /// <summary> - /// Gets or sets the callsign. - /// </summary> - [JsonPropertyName("callsign")] - public string Callsign { get; set; } + /// <summary> + /// Gets or sets the callsign. + /// </summary> + [JsonPropertyName("callsign")] + public string? Callsign { get; set; } - /// <summary> - /// Gets or sets the broadcast language. - /// </summary> - [JsonPropertyName("broadcastLanguage")] - public List<string> BroadcastLanguage { get; set; } + /// <summary> + /// Gets or sets the broadcast language. + /// </summary> + [JsonPropertyName("broadcastLanguage")] + public IReadOnlyList<string> BroadcastLanguage { get; set; } = Array.Empty<string>(); - /// <summary> - /// Gets or sets the description language. - /// </summary> - [JsonPropertyName("descriptionLanguage")] - public List<string> DescriptionLanguage { get; set; } + /// <summary> + /// Gets or sets the description language. + /// </summary> + [JsonPropertyName("descriptionLanguage")] + public IReadOnlyList<string> DescriptionLanguage { get; set; } = Array.Empty<string>(); - /// <summary> - /// Gets or sets the broadcaster. - /// </summary> - [JsonPropertyName("broadcaster")] - public BroadcasterDto Broadcaster { get; set; } + /// <summary> + /// Gets or sets the broadcaster. + /// </summary> + [JsonPropertyName("broadcaster")] + public BroadcasterDto? Broadcaster { get; set; } - /// <summary> - /// Gets or sets the affiliate. - /// </summary> - [JsonPropertyName("affiliate")] - public string Affiliate { get; set; } + /// <summary> + /// Gets or sets the affiliate. + /// </summary> + [JsonPropertyName("affiliate")] + public string? Affiliate { get; set; } - /// <summary> - /// Gets or sets the logo. - /// </summary> - [JsonPropertyName("logo")] - public LogoDto Logo { get; set; } + /// <summary> + /// Gets or sets the logo. + /// </summary> + [JsonPropertyName("logo")] + public LogoDto? Logo { get; set; } - /// <summary> - /// Gets or set a value indicating whether it is commercial free. - /// </summary> - [JsonPropertyName("isCommercialFree")] - public bool? IsCommercialFree { get; set; } - } + /// <summary> + /// Gets or sets a value indicating whether it is commercial free. + /// </summary> + [JsonPropertyName("isCommercialFree")] + public bool? IsCommercialFree { get; set; } + } } diff --git a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/TitleDto.cs b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/TitleDto.cs index 06c95524b..61cd4a9b0 100644 --- a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/TitleDto.cs +++ b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/TitleDto.cs @@ -1,5 +1,3 @@ -#nullable disable - using System.Text.Json.Serialization; namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos @@ -13,6 +11,6 @@ namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos /// Gets or sets the title. /// </summary> [JsonPropertyName("title120")] - public string Title120 { get; set; } + public string? Title120 { get; set; } } } diff --git a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/TokenDto.cs b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/TokenDto.cs index c3ec1c7d6..afb999486 100644 --- a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/TokenDto.cs +++ b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/TokenDto.cs @@ -1,5 +1,4 @@ -#nullable disable - +using System; using System.Text.Json.Serialization; namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos @@ -19,18 +18,30 @@ namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos /// Gets or sets the response message. /// </summary> [JsonPropertyName("message")] - public string Message { get; set; } + public string? Message { get; set; } /// <summary> /// Gets or sets the server id. /// </summary> [JsonPropertyName("serverID")] - public string ServerId { get; set; } + public string? ServerId { get; set; } /// <summary> /// Gets or sets the token. /// </summary> [JsonPropertyName("token")] - public string Token { get; set; } + public string? Token { get; set; } + + /// <summary> + /// Gets or sets the current datetime. + /// </summary> + [JsonPropertyName("datetime")] + public DateTime? TokenTimestamp { get; set; } + + /// <summary> + /// Gets or sets the response message. + /// </summary> + [JsonPropertyName("response")] + public string? Response { get; set; } } } diff --git a/Emby.Server.Implementations/LiveTv/Listings/XmlTvListingsProvider.cs b/Emby.Server.Implementations/LiveTv/Listings/XmlTvListingsProvider.cs index 8202fab86..3da9d02b8 100644 --- a/Emby.Server.Implementations/LiveTv/Listings/XmlTvListingsProvider.cs +++ b/Emby.Server.Implementations/LiveTv/Listings/XmlTvListingsProvider.cs @@ -10,6 +10,7 @@ using System.Linq; using System.Net.Http; using System.Threading; using System.Threading.Tasks; +using Jellyfin.Extensions; using Jellyfin.XmlTv; using Jellyfin.XmlTv.Entities; using MediaBrowser.Common.Extensions; @@ -59,41 +60,41 @@ namespace Emby.Server.Implementations.LiveTv.Listings return _config.Configuration.PreferredMetadataLanguage; } - private async Task<string> GetXml(string path, CancellationToken cancellationToken) + private async Task<string> GetXml(ListingsProviderInfo info, CancellationToken cancellationToken) { - _logger.LogInformation("xmltv path: {Path}", path); + _logger.LogInformation("xmltv path: {Path}", info.Path); - if (!path.StartsWith("http", StringComparison.OrdinalIgnoreCase)) + if (!info.Path.StartsWith("http", StringComparison.OrdinalIgnoreCase)) { - return UnzipIfNeeded(path, path); + return UnzipIfNeeded(info.Path, info.Path); } - string cacheFilename = DateTime.UtcNow.DayOfYear.ToString(CultureInfo.InvariantCulture) + "-" + DateTime.UtcNow.Hour.ToString(CultureInfo.InvariantCulture) + ".xml"; + string cacheFilename = DateTime.UtcNow.DayOfYear.ToString(CultureInfo.InvariantCulture) + "-" + DateTime.UtcNow.Hour.ToString(CultureInfo.InvariantCulture) + "-" + info.Id + ".xml"; string cacheFile = Path.Combine(_config.ApplicationPaths.CachePath, "xmltv", cacheFilename); if (File.Exists(cacheFile)) { - return UnzipIfNeeded(path, cacheFile); + return UnzipIfNeeded(info.Path, cacheFile); } - _logger.LogInformation("Downloading xmltv listings from {Path}", path); + _logger.LogInformation("Downloading xmltv listings from {Path}", info.Path); Directory.CreateDirectory(Path.GetDirectoryName(cacheFile)); - using var response = await _httpClientFactory.CreateClient(NamedClient.Default).GetAsync(path, cancellationToken).ConfigureAwait(false); + using var response = await _httpClientFactory.CreateClient(NamedClient.Default).GetAsync(info.Path, cancellationToken).ConfigureAwait(false); await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); - await using (var fileStream = new FileStream(cacheFile, FileMode.CreateNew, FileAccess.Write, FileShare.None, IODefaults.CopyToBufferSize, AsyncFile.UseAsyncIO)) + await using (var fileStream = new FileStream(cacheFile, FileMode.CreateNew, FileAccess.Write, FileShare.None, IODefaults.CopyToBufferSize, FileOptions.Asynchronous)) { await stream.CopyToAsync(fileStream, cancellationToken).ConfigureAwait(false); } - return UnzipIfNeeded(path, cacheFile); + return UnzipIfNeeded(info.Path, cacheFile); } - private string UnzipIfNeeded(string originalUrl, string file) + private string UnzipIfNeeded(ReadOnlySpan<char> originalUrl, string file) { - string ext = Path.GetExtension(originalUrl.Split('?')[0]); + ReadOnlySpan<char> ext = Path.GetExtension(originalUrl.LeftPart('?')); - if (string.Equals(ext, ".gz", StringComparison.OrdinalIgnoreCase)) + if (ext.Equals(".gz", StringComparison.OrdinalIgnoreCase)) { try { @@ -162,7 +163,7 @@ namespace Emby.Server.Implementations.LiveTv.Listings _logger.LogDebug("Getting xmltv programs for channel {Id}", channelId); - string path = await GetXml(info.Path, cancellationToken).ConfigureAwait(false); + string path = await GetXml(info, cancellationToken).ConfigureAwait(false); _logger.LogDebug("Opening XmlTvReader for {Path}", path); var reader = new XmlTvReader(path, GetLanguage(info)); @@ -189,10 +190,10 @@ namespace Emby.Server.Implementations.LiveTv.Listings IsSeries = program.Episode != null, IsRepeat = program.IsPreviouslyShown && !program.IsNew, IsPremiere = program.Premiere != null, - IsKids = program.Categories.Any(c => info.KidsCategories.Contains(c, StringComparer.OrdinalIgnoreCase)), - IsMovie = program.Categories.Any(c => info.MovieCategories.Contains(c, StringComparer.OrdinalIgnoreCase)), - IsNews = program.Categories.Any(c => info.NewsCategories.Contains(c, StringComparer.OrdinalIgnoreCase)), - IsSports = program.Categories.Any(c => info.SportsCategories.Contains(c, StringComparer.OrdinalIgnoreCase)), + IsKids = program.Categories.Any(c => info.KidsCategories.Contains(c, StringComparison.OrdinalIgnoreCase)), + IsMovie = program.Categories.Any(c => info.MovieCategories.Contains(c, StringComparison.OrdinalIgnoreCase)), + IsNews = program.Categories.Any(c => info.NewsCategories.Contains(c, StringComparison.OrdinalIgnoreCase)), + IsSports = program.Categories.Any(c => info.SportsCategories.Contains(c, StringComparison.OrdinalIgnoreCase)), ImageUrl = program.Icon != null && !string.IsNullOrEmpty(program.Icon.Source) ? program.Icon.Source : null, HasImage = program.Icon != null && !string.IsNullOrEmpty(program.Icon.Source), OfficialRating = program.Rating != null && !string.IsNullOrEmpty(program.Rating.Value) ? program.Rating.Value : null, @@ -256,7 +257,7 @@ namespace Emby.Server.Implementations.LiveTv.Listings public async Task<List<NameIdPair>> GetLineups(ListingsProviderInfo info, string country, string location) { // In theory this should never be called because there is always only one lineup - string path = await GetXml(info.Path, CancellationToken.None).ConfigureAwait(false); + string path = await GetXml(info, CancellationToken.None).ConfigureAwait(false); _logger.LogDebug("Opening XmlTvReader for {Path}", path); var reader = new XmlTvReader(path, GetLanguage(info)); IEnumerable<XmlTvChannel> results = reader.GetChannels(); @@ -268,7 +269,7 @@ namespace Emby.Server.Implementations.LiveTv.Listings public async Task<List<ChannelInfo>> GetChannels(ListingsProviderInfo info, CancellationToken cancellationToken) { // In theory this should never be called because there is always only one lineup - string path = await GetXml(info.Path, cancellationToken).ConfigureAwait(false); + string path = await GetXml(info, cancellationToken).ConfigureAwait(false); _logger.LogDebug("Opening XmlTvReader for {Path}", path); var reader = new XmlTvReader(path, GetLanguage(info)); var results = reader.GetChannels(); diff --git a/Emby.Server.Implementations/LiveTv/LiveTvDtoService.cs b/Emby.Server.Implementations/LiveTv/LiveTvDtoService.cs index 21e1409ac..323b96021 100644 --- a/Emby.Server.Implementations/LiveTv/LiveTvDtoService.cs +++ b/Emby.Server.Implementations/LiveTv/LiveTvDtoService.cs @@ -7,12 +7,12 @@ using System.Globalization; using System.Linq; using System.Threading; using System.Threading.Tasks; +using Jellyfin.Data.Enums; using MediaBrowser.Common; using MediaBrowser.Common.Extensions; using MediaBrowser.Controller.Drawing; using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; -using MediaBrowser.Controller.Entities.TV; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.LiveTv; using MediaBrowser.Model.Dto; @@ -161,7 +161,7 @@ namespace Emby.Server.Implementations.LiveTv { var librarySeries = _libraryManager.GetItemList(new InternalItemsQuery { - IncludeItemTypes = new string[] { nameof(Series) }, + IncludeItemTypes = new[] { BaseItemKind.Series }, Name = seriesName, Limit = 1, ImageTypes = new ImageType[] { ImageType.Thumb }, @@ -204,7 +204,7 @@ namespace Emby.Server.Implementations.LiveTv var program = _libraryManager.GetItemList(new InternalItemsQuery { - IncludeItemTypes = new string[] { nameof(LiveTvProgram) }, + IncludeItemTypes = new[] { BaseItemKind.LiveTvProgram }, ExternalSeriesId = programSeriesId, Limit = 1, ImageTypes = new ImageType[] { ImageType.Primary }, @@ -255,7 +255,7 @@ namespace Emby.Server.Implementations.LiveTv { var librarySeries = _libraryManager.GetItemList(new InternalItemsQuery { - IncludeItemTypes = new string[] { nameof(Series) }, + IncludeItemTypes = new[] { BaseItemKind.Series }, Name = seriesName, Limit = 1, ImageTypes = new ImageType[] { ImageType.Thumb }, @@ -298,7 +298,7 @@ namespace Emby.Server.Implementations.LiveTv var program = _libraryManager.GetItemList(new InternalItemsQuery { - IncludeItemTypes = new string[] { nameof(Series) }, + IncludeItemTypes = new[] { BaseItemKind.Series }, Name = seriesName, Limit = 1, ImageTypes = new ImageType[] { ImageType.Primary }, @@ -309,7 +309,7 @@ namespace Emby.Server.Implementations.LiveTv { program = _libraryManager.GetItemList(new InternalItemsQuery { - IncludeItemTypes = new string[] { nameof(LiveTvProgram) }, + IncludeItemTypes = new[] { BaseItemKind.LiveTvProgram }, ExternalSeriesId = programSeriesId, Limit = 1, ImageTypes = new ImageType[] { ImageType.Primary }, @@ -393,7 +393,7 @@ namespace Emby.Server.Implementations.LiveTv } catch (Exception ex) { - _logger.LogError(ex, "Error getting image info for {name}", info.Name); + _logger.LogError(ex, "Error getting image info for {Name}", info.Name); } return null; diff --git a/Emby.Server.Implementations/LiveTv/LiveTvManager.cs b/Emby.Server.Implementations/LiveTv/LiveTvManager.cs index ea1a28fe8..aa3598c8b 100644 --- a/Emby.Server.Implementations/LiveTv/LiveTvManager.cs +++ b/Emby.Server.Implementations/LiveTv/LiveTvManager.cs @@ -33,8 +33,6 @@ using MediaBrowser.Model.LiveTv; using MediaBrowser.Model.Querying; using MediaBrowser.Model.Tasks; using Microsoft.Extensions.Logging; -using Episode = MediaBrowser.Controller.Entities.TV.Episode; -using Movie = MediaBrowser.Controller.Entities.Movies.Movie; namespace Emby.Server.Implementations.LiveTv { @@ -191,7 +189,7 @@ namespace Emby.Server.Implementations.LiveTv IsKids = query.IsKids, IsSports = query.IsSports, IsSeries = query.IsSeries, - IncludeItemTypes = new[] { nameof(LiveTvChannel) }, + IncludeItemTypes = new[] { BaseItemKind.LiveTvChannel }, TopParentIds = new[] { topFolder.Id }, IsFavorite = query.IsFavorite, IsLiked = query.IsLiked, @@ -209,7 +207,7 @@ namespace Emby.Server.Implementations.LiveTv orderBy.Insert(0, (ItemSortBy.IsFavoriteOrLiked, SortOrder.Descending)); } - if (!internalQuery.OrderBy.Any(i => string.Equals(i.Item1, ItemSortBy.SortName, StringComparison.OrdinalIgnoreCase))) + if (!internalQuery.OrderBy.Any(i => string.Equals(i.OrderBy, ItemSortBy.SortName, StringComparison.OrdinalIgnoreCase))) { orderBy.Add((ItemSortBy.SortName, SortOrder.Ascending)); } @@ -522,7 +520,7 @@ namespace Emby.Server.Implementations.LiveTv return item; } - private (LiveTvProgram item, bool isNew, bool isUpdated) GetProgram(ProgramInfo info, Dictionary<Guid, LiveTvProgram> allExistingPrograms, LiveTvChannel channel) + private (LiveTvProgram Item, bool IsNew, bool IsUpdated) GetProgram(ProgramInfo info, Dictionary<Guid, LiveTvProgram> allExistingPrograms, LiveTvChannel channel) { var id = _tvDtoService.GetInternalProgramId(info.Id); @@ -781,9 +779,9 @@ namespace Emby.Server.Implementations.LiveTv var dto = _dtoService.GetBaseItemDto(program, new DtoOptions(), user); - var list = new List<Tuple<BaseItemDto, string, string>> + var list = new List<(BaseItemDto ItemDto, string ExternalId, string ExternalSeriesId)> { - new Tuple<BaseItemDto, string, string>(dto, program.ExternalId, program.ExternalSeriesId) + (dto, program.ExternalId, program.ExternalSeriesId) }; await AddRecordingInfo(list, cancellationToken).ConfigureAwait(false); @@ -810,7 +808,7 @@ namespace Emby.Server.Implementations.LiveTv var internalQuery = new InternalItemsQuery(user) { - IncludeItemTypes = new[] { nameof(LiveTvProgram) }, + IncludeItemTypes = new[] { BaseItemKind.LiveTvProgram }, MinEndDate = query.MinEndDate, MinStartDate = query.MinStartDate, MaxEndDate = query.MaxEndDate, @@ -874,7 +872,7 @@ namespace Emby.Server.Implementations.LiveTv var internalQuery = new InternalItemsQuery(user) { - IncludeItemTypes = new[] { nameof(LiveTvProgram) }, + IncludeItemTypes = new[] { BaseItemKind.LiveTvProgram }, IsAiring = query.IsAiring, HasAired = query.HasAired, IsNews = query.IsNews, @@ -978,16 +976,16 @@ namespace Emby.Server.Implementations.LiveTv return score; } - private async Task AddRecordingInfo(IEnumerable<Tuple<BaseItemDto, string, string>> programs, CancellationToken cancellationToken) + private async Task AddRecordingInfo(IEnumerable<(BaseItemDto ItemDto, string ExternalId, string ExternalSeriesId)> programs, CancellationToken cancellationToken) { IReadOnlyList<TimerInfo> timerList = null; IReadOnlyList<SeriesTimerInfo> seriesTimerList = null; foreach (var programTuple in programs) { - var program = programTuple.Item1; - var externalProgramId = programTuple.Item2; - string externalSeriesId = programTuple.Item3; + var program = programTuple.ItemDto; + var externalProgramId = programTuple.ExternalId; + string externalSeriesId = programTuple.ExternalSeriesId; timerList ??= (await GetTimersInternal(new TimerQuery(), cancellationToken).ConfigureAwait(false)).Items; @@ -1054,7 +1052,7 @@ namespace Emby.Server.Implementations.LiveTv { cancellationToken.ThrowIfCancellationRequested(); - _logger.LogDebug("Refreshing guide from {name}", service.Name); + _logger.LogDebug("Refreshing guide from {Name}", service.Name); try { @@ -1085,8 +1083,8 @@ namespace Emby.Server.Implementations.LiveTv if (cleanDatabase) { - CleanDatabaseInternal(newChannelIdList.ToArray(), new[] { nameof(LiveTvChannel) }, progress, cancellationToken); - CleanDatabaseInternal(newProgramIdList.ToArray(), new[] { nameof(LiveTvProgram) }, progress, cancellationToken); + CleanDatabaseInternal(newChannelIdList.ToArray(), new[] { BaseItemKind.LiveTvChannel }, progress, cancellationToken); + CleanDatabaseInternal(newProgramIdList.ToArray(), new[] { BaseItemKind.LiveTvProgram }, progress, cancellationToken); } var coreService = _services.OfType<EmbyTV.EmbyTV>().FirstOrDefault(); @@ -1135,7 +1133,7 @@ namespace Emby.Server.Implementations.LiveTv } catch (Exception ex) { - _logger.LogError(ex, "Error getting channel information for {name}", channelInfo.Item2.Name); + _logger.LogError(ex, "Error getting channel information for {Name}", channelInfo.Item2.Name); } numComplete++; @@ -1177,7 +1175,7 @@ namespace Emby.Server.Implementations.LiveTv var existingPrograms = _libraryManager.GetItemList(new InternalItemsQuery { - IncludeItemTypes = new string[] { nameof(LiveTvProgram) }, + IncludeItemTypes = new[] { BaseItemKind.LiveTvProgram }, ChannelIds = new Guid[] { currentChannel.Id }, DtoOptions = new DtoOptions(true) }).Cast<LiveTvProgram>().ToDictionary(i => i.Id); @@ -1188,13 +1186,13 @@ namespace Emby.Server.Implementations.LiveTv foreach (var program in channelPrograms) { var programTuple = GetProgram(program, existingPrograms, currentChannel); - var programItem = programTuple.item; + var programItem = programTuple.Item; - if (programTuple.isNew) + if (programTuple.IsNew) { newPrograms.Add(programItem); } - else if (programTuple.isUpdated) + else if (programTuple.IsUpdated) { updatedPrograms.Add(programItem); } @@ -1248,7 +1246,7 @@ namespace Emby.Server.Implementations.LiveTv } catch (Exception ex) { - _logger.LogError(ex, "Error getting programs for channel {name}", currentChannel.Name); + _logger.LogError(ex, "Error getting programs for channel {Name}", currentChannel.Name); } numComplete++; @@ -1261,7 +1259,7 @@ namespace Emby.Server.Implementations.LiveTv return new Tuple<List<Guid>, List<Guid>>(channels, programs); } - private void CleanDatabaseInternal(Guid[] currentIdList, string[] validTypes, IProgress<double> progress, CancellationToken cancellationToken) + private void CleanDatabaseInternal(Guid[] currentIdList, BaseItemKind[] validTypes, IProgress<double> progress, CancellationToken cancellationToken) { var list = _itemRepo.GetItemIdsList(new InternalItemsQuery { @@ -1328,25 +1326,25 @@ namespace Emby.Server.Implementations.LiveTv .Select(i => i.Id) .ToList(); - var excludeItemTypes = new List<string>(); + var excludeItemTypes = new List<BaseItemKind>(); if (folderIds.Count == 0) { return new QueryResult<BaseItem>(); } - var includeItemTypes = new List<string>(); + var includeItemTypes = new List<BaseItemKind>(); var genres = new List<string>(); if (query.IsMovie.HasValue) { if (query.IsMovie.Value) { - includeItemTypes.Add(nameof(Movie)); + includeItemTypes.Add(BaseItemKind.Movie); } else { - excludeItemTypes.Add(nameof(Movie)); + excludeItemTypes.Add(BaseItemKind.Movie); } } @@ -1354,11 +1352,11 @@ namespace Emby.Server.Implementations.LiveTv { if (query.IsSeries.Value) { - includeItemTypes.Add(nameof(Episode)); + includeItemTypes.Add(BaseItemKind.Episode); } else { - excludeItemTypes.Add(nameof(Episode)); + excludeItemTypes.Add(BaseItemKind.Episode); } } @@ -1425,9 +1423,9 @@ namespace Emby.Server.Implementations.LiveTv return result; } - public Task AddInfoToProgramDto(IReadOnlyCollection<(BaseItem, BaseItemDto)> programs, IReadOnlyList<ItemFields> fields, User user = null) + public Task AddInfoToProgramDto(IReadOnlyCollection<(BaseItem Item, BaseItemDto ItemDto)> programs, IReadOnlyList<ItemFields> fields, User user = null) { - var programTuples = new List<Tuple<BaseItemDto, string, string>>(); + var programTuples = new List<(BaseItemDto Dto, string ExternalId, string ExternalSeriesId)>(); var hasChannelImage = fields.Contains(ItemFields.ChannelImage); var hasChannelInfo = fields.Contains(ItemFields.ChannelInfo); @@ -1463,7 +1461,7 @@ namespace Emby.Server.Implementations.LiveTv } } - programTuples.Add(new Tuple<BaseItemDto, string, string>(dto, program.ExternalId, program.ExternalSeriesId)); + programTuples.Add((dto, program.ExternalId, program.ExternalSeriesId)); } return AddRecordingInfo(programTuples, CancellationToken.None); @@ -1870,15 +1868,15 @@ namespace Emby.Server.Implementations.LiveTv return _libraryManager.GetItemById(internalChannelId); } - public void AddChannelInfo(IReadOnlyCollection<(BaseItemDto, LiveTvChannel)> items, DtoOptions options, User user) + public void AddChannelInfo(IReadOnlyCollection<(BaseItemDto ItemDto, LiveTvChannel Channel)> items, DtoOptions options, User user) { var now = DateTime.UtcNow; - var channelIds = items.Select(i => i.Item2.Id).Distinct().ToArray(); + var channelIds = items.Select(i => i.Channel.Id).Distinct().ToArray(); var programs = options.AddCurrentProgram ? _libraryManager.GetItemList(new InternalItemsQuery(user) { - IncludeItemTypes = new[] { nameof(LiveTvProgram) }, + IncludeItemTypes = new[] { BaseItemKind.LiveTvProgram }, ChannelIds = channelIds, MaxStartDate = now, MinEndDate = now, @@ -1895,11 +1893,8 @@ namespace Emby.Server.Implementations.LiveTv var addCurrentProgram = options.AddCurrentProgram; - foreach (var tuple in items) + foreach (var (dto, channel) in items) { - var dto = tuple.Item1; - var channel = tuple.Item2; - dto.Number = channel.Number; dto.ChannelNumber = channel.Number; dto.ChannelType = channel.ChannelType; diff --git a/Emby.Server.Implementations/LiveTv/LiveTvMediaSourceProvider.cs b/Emby.Server.Implementations/LiveTv/LiveTvMediaSourceProvider.cs index ecd28097d..4b7584af3 100644 --- a/Emby.Server.Implementations/LiveTv/LiveTvMediaSourceProvider.cs +++ b/Emby.Server.Implementations/LiveTv/LiveTvMediaSourceProvider.cs @@ -104,7 +104,7 @@ namespace Emby.Server.Implementations.LiveTv // Dummy this up so that direct play checks can still run if (string.IsNullOrEmpty(source.Path) && source.Protocol == MediaProtocol.Http) { - source.Path = _appHost.GetSmartApiUrl(string.Empty); + source.Path = _appHost.GetApiUrlForLocalAccess(); } } diff --git a/Emby.Server.Implementations/LiveTv/TunerHosts/BaseTunerHost.cs b/Emby.Server.Implementations/LiveTv/TunerHosts/BaseTunerHost.cs index 096b7f045..2b82f2462 100644 --- a/Emby.Server.Implementations/LiveTv/TunerHosts/BaseTunerHost.cs +++ b/Emby.Server.Implementations/LiveTv/TunerHosts/BaseTunerHost.cs @@ -23,10 +23,6 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts { public abstract class BaseTunerHost { - protected readonly IServerConfigurationManager Config; - protected readonly ILogger<BaseTunerHost> Logger; - protected readonly IFileSystem FileSystem; - private readonly IMemoryCache _memoryCache; protected BaseTunerHost(IServerConfigurationManager config, ILogger<BaseTunerHost> logger, IFileSystem fileSystem, IMemoryCache memoryCache) @@ -37,12 +33,20 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts FileSystem = fileSystem; } + protected IServerConfigurationManager Config { get; } + + protected ILogger<BaseTunerHost> Logger { get; } + + protected IFileSystem FileSystem { get; } + public virtual bool IsSupported => true; - protected abstract Task<List<ChannelInfo>> GetChannelsInternal(TunerHostInfo tuner, CancellationToken cancellationToken); - public abstract string Type { get; } + protected virtual string ChannelIdPrefix => Type + "_"; + + protected abstract Task<List<ChannelInfo>> GetChannelsInternal(TunerHostInfo tuner, CancellationToken cancellationToken); + public async Task<List<ChannelInfo>> GetChannels(TunerHostInfo tuner, bool enableCache, CancellationToken cancellationToken) { var key = tuner.Id; @@ -217,8 +221,6 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts throw new LiveTvConflictException(); } - protected virtual string ChannelIdPrefix => Type + "_"; - protected virtual bool IsValidChannelId(string channelId) { if (string.IsNullOrEmpty(channelId)) diff --git a/Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/HdHomerunChannelCommands.cs b/Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/HdHomerunChannelCommands.cs new file mode 100644 index 000000000..aae33503f --- /dev/null +++ b/Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/HdHomerunChannelCommands.cs @@ -0,0 +1,35 @@ +#pragma warning disable CS1591 + +using System; +using System.Collections.Generic; + +namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun +{ + public class HdHomerunChannelCommands : IHdHomerunChannelCommands + { + private string? _channel; + private string? _profile; + + public HdHomerunChannelCommands(string? channel, string? profile) + { + _channel = channel; + _profile = profile; + } + + public IEnumerable<(string CommandName, string CommandValue)> GetCommands() + { + if (!string.IsNullOrEmpty(_channel)) + { + if (!string.IsNullOrEmpty(_profile) + && !string.Equals(_profile, "native", StringComparison.OrdinalIgnoreCase)) + { + yield return ("vchannel", $"{_channel} transcode={_profile}"); + } + else + { + yield return ("vchannel", _channel); + } + } + } + } +} diff --git a/Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/HdHomerunHost.cs b/Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/HdHomerunHost.cs index 2bd12a9c8..532790019 100644 --- a/Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/HdHomerunHost.cs +++ b/Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/HdHomerunHost.cs @@ -36,7 +36,6 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun private readonly IHttpClientFactory _httpClientFactory; private readonly IServerApplicationHost _appHost; private readonly ISocketFactory _socketFactory; - private readonly INetworkManager _networkManager; private readonly IStreamHelper _streamHelper; private readonly JsonSerializerOptions _jsonOptions; @@ -50,7 +49,6 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun IHttpClientFactory httpClientFactory, IServerApplicationHost appHost, ISocketFactory socketFactory, - INetworkManager networkManager, IStreamHelper streamHelper, IMemoryCache memoryCache) : base(config, logger, fileSystem, memoryCache) @@ -58,7 +56,6 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun _httpClientFactory = httpClientFactory; _appHost = appHost; _socketFactory = socketFactory; - _networkManager = networkManager; _streamHelper = streamHelper; _jsonOptions = JsonDefaults.Options; @@ -70,7 +67,7 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun protected override string ChannelIdPrefix => "hdhr_"; - private string GetChannelId(TunerHostInfo info, Channels i) + private string GetChannelId(Channels i) => ChannelIdPrefix + i.GuideNumber; internal async Task<List<Channels>> GetLineup(TunerHostInfo info, CancellationToken cancellationToken) @@ -90,11 +87,6 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun return lineup.Where(i => !i.DRM).ToList(); } - private class HdHomerunChannelInfo : ChannelInfo - { - public bool IsLegacyTuner { get; set; } - } - protected override async Task<List<ChannelInfo>> GetChannelsInternal(TunerHostInfo tuner, CancellationToken cancellationToken) { var lineup = await GetLineup(tuner, cancellationToken).ConfigureAwait(false); @@ -103,7 +95,7 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun { Name = i.GuideName, Number = i.GuideNumber, - Id = GetChannelId(tuner, i), + Id = GetChannelId(i), IsFavorite = i.Favorite, TunerHostId = tuner.Id, IsHD = i.HD, @@ -255,7 +247,7 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun { var model = await GetModelInfo(info, false, cancellationToken).ConfigureAwait(false); - var tuners = new List<LiveTvTunerInfo>(); + var tuners = new List<LiveTvTunerInfo>(model.TunerCount); var uri = new Uri(GetApiUrl(info)); @@ -264,10 +256,10 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun // Legacy HdHomeruns are IPv4 only var ipInfo = IPAddress.Parse(uri.Host); - for (int i = 0; i < model.TunerCount; ++i) + for (int i = 0; i < model.TunerCount; i++) { var name = string.Format(CultureInfo.InvariantCulture, "Tuner {0}", i + 1); - var currentChannel = "none"; // @todo Get current channel and map back to Station Id + var currentChannel = "none"; // TODO: Get current channel and map back to Station Id var isAvailable = await manager.CheckTunerAvailability(ipInfo, i, cancellationToken).ConfigureAwait(false); var status = isAvailable ? LiveTvTunerStatus.Available : LiveTvTunerStatus.LiveTv; tuners.Add(new LiveTvTunerInfo @@ -455,28 +447,28 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun Path = url, Protocol = MediaProtocol.Udp, MediaStreams = new List<MediaStream> - { - new MediaStream - { - Type = MediaStreamType.Video, - // Set the index to -1 because we don't know the exact index of the video stream within the container - Index = -1, - IsInterlaced = isInterlaced, - Codec = videoCodec, - Width = width, - Height = height, - BitRate = videoBitrate, - NalLengthSize = nal - }, - new MediaStream - { - Type = MediaStreamType.Audio, - // Set the index to -1 because we don't know the exact index of the audio stream within the container - Index = -1, - Codec = audioCodec, - BitRate = audioBitrate - } - }, + { + new MediaStream + { + Type = MediaStreamType.Video, + // Set the index to -1 because we don't know the exact index of the video stream within the container + Index = -1, + IsInterlaced = isInterlaced, + Codec = videoCodec, + Width = width, + Height = height, + BitRate = videoBitrate, + NalLengthSize = nal + }, + new MediaStream + { + Type = MediaStreamType.Audio, + // Set the index to -1 because we don't know the exact index of the audio stream within the container + Index = -1, + Codec = audioCodec, + BitRate = audioBitrate + } + }, RequiresOpening = true, RequiresClosing = true, BufferMs = 0, @@ -551,7 +543,7 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun } } - var profile = streamId.Split('_')[0]; + var profile = streamId.AsSpan().LeftPart('_').ToString(); Logger.LogInformation("GetChannelStream: channel id: {0}. stream id: {1} profile: {2}", channel.Id, streamId, profile); @@ -638,7 +630,7 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun } catch (HttpRequestException ex) { - if (ex.StatusCode.HasValue && ex.StatusCode.Value == System.Net.HttpStatusCode.NotFound) + if (ex.StatusCode.HasValue && ex.StatusCode.Value == HttpStatusCode.NotFound) { // HDHR4 doesn't have this api return; @@ -718,5 +710,10 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun return hostInfo; } + + private class HdHomerunChannelInfo : ChannelInfo + { + public bool IsLegacyTuner { get; set; } + } } } diff --git a/Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/HdHomerunManager.cs b/Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/HdHomerunManager.cs index b2e555c7d..48d9e316d 100644 --- a/Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/HdHomerunManager.cs +++ b/Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/HdHomerunManager.cs @@ -5,12 +5,10 @@ using System; using System.Buffers; using System.Buffers.Binary; -using System.Collections.Generic; using System.Globalization; using System.Net; using System.Net.Sockets; using System.Text; -using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; using MediaBrowser.Common; @@ -18,70 +16,6 @@ using MediaBrowser.Controller.LiveTv; namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun { - public interface IHdHomerunChannelCommands - { - IEnumerable<(string, string)> GetCommands(); - } - - public class LegacyHdHomerunChannelCommands : IHdHomerunChannelCommands - { - private string _channel; - private string _program; - - public LegacyHdHomerunChannelCommands(string url) - { - // parse url for channel and program - var regExp = new Regex(@"\/ch([0-9]+)-?([0-9]*)"); - var match = regExp.Match(url); - if (match.Success) - { - _channel = match.Groups[1].Value; - _program = match.Groups[2].Value; - } - } - - public IEnumerable<(string, string)> GetCommands() - { - if (!string.IsNullOrEmpty(_channel)) - { - yield return ("channel", _channel); - } - - if (!string.IsNullOrEmpty(_program)) - { - yield return ("program", _program); - } - } - } - - public class HdHomerunChannelCommands : IHdHomerunChannelCommands - { - private string _channel; - private string _profile; - - public HdHomerunChannelCommands(string channel, string profile) - { - _channel = channel; - _profile = profile; - } - - public IEnumerable<(string, string)> GetCommands() - { - if (!string.IsNullOrEmpty(_channel)) - { - if (!string.IsNullOrEmpty(_profile) - && !string.Equals(_profile, "native", StringComparison.OrdinalIgnoreCase)) - { - yield return ("vchannel", $"{_channel} transcode={_profile}"); - } - else - { - yield return ("vchannel", _channel); - } - } - } - } - public sealed class HdHomerunManager : IDisposable { public const int HdHomeRunPort = 65001; @@ -117,7 +51,7 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun public async Task<bool> CheckTunerAvailability(IPAddress remoteIp, int tuner, CancellationToken cancellationToken) { using var client = new TcpClient(); - client.Connect(remoteIp, HdHomeRunPort); + await client.ConnectAsync(remoteIp, HdHomeRunPort).ConfigureAwait(false); using var stream = client.GetStream(); return await CheckTunerAvailability(stream, tuner, cancellationToken).ConfigureAwait(false); @@ -146,12 +80,11 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun _remoteEndPoint = new IPEndPoint(remoteIp, HdHomeRunPort); _tcpClient = new TcpClient(); - _tcpClient.Connect(_remoteEndPoint); + await _tcpClient.ConnectAsync(_remoteEndPoint, cancellationToken).ConfigureAwait(false); if (!_lockkey.HasValue) { - var rand = new Random(); - _lockkey = (uint)rand.Next(); + _lockkey = (uint)Random.Shared.Next(); } var lockKeyValue = _lockkey.Value; @@ -181,7 +114,7 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun foreach (var command in commands.GetCommands()) { - var channelMsgLen = WriteSetMessage(buffer, i, command.Item1, command.Item2, lockKeyValue); + var channelMsgLen = WriteSetMessage(buffer, i, command.CommandName, command.CommandValue, lockKeyValue); await stream.WriteAsync(buffer.AsMemory(0, channelMsgLen), cancellationToken).ConfigureAwait(false); receivedBytes = await stream.ReadAsync(buffer, cancellationToken).ConfigureAwait(false); @@ -189,7 +122,6 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun if (!TryGetReturnValueOfGetSet(buffer.AsSpan(0, receivedBytes), out _)) { await ReleaseLockkey(_tcpClient, lockKeyValue).ConfigureAwait(false); - continue; } } @@ -226,7 +158,7 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun } using var tcpClient = new TcpClient(); - tcpClient.Connect(_remoteEndPoint); + await tcpClient.ConnectAsync(_remoteEndPoint, cancellationToken).ConfigureAwait(false); using var stream = tcpClient.GetStream(); var commandList = commands.GetCommands(); @@ -235,7 +167,7 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun { foreach (var command in commandList) { - var channelMsgLen = WriteSetMessage(buffer, _activeTuner, command.Item1, command.Item2, _lockkey); + var channelMsgLen = WriteSetMessage(buffer, _activeTuner, command.CommandName, command.CommandValue, _lockkey); await stream.WriteAsync(buffer.AsMemory(0, channelMsgLen), cancellationToken).ConfigureAwait(false); int receivedBytes = await stream.ReadAsync(buffer, cancellationToken).ConfigureAwait(false); diff --git a/Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/HdHomerunUdpStream.cs b/Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/HdHomerunUdpStream.cs index 58e0c7448..a5edd35cc 100644 --- a/Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/HdHomerunUdpStream.cs +++ b/Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/HdHomerunUdpStream.cs @@ -3,7 +3,6 @@ #pragma warning disable CS1591 using System; -using System.Collections.Generic; using System.IO; using System.Linq; using System.Net; @@ -82,10 +81,10 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun Directory.CreateDirectory(Path.GetDirectoryName(TempFilePath)); - Logger.LogInformation("Opening HDHR UDP Live stream from {host}", uri.Host); + Logger.LogInformation("Opening HDHR UDP Live stream from {Host}", uri.Host); var remoteAddress = IPAddress.Parse(uri.Host); - IPAddress localAddress = null; + IPAddress localAddress; using (var tcpClient = new TcpClient()) { try @@ -101,7 +100,8 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun } } - if (localAddress.IsIPv4MappedToIPv6) { + if (localAddress.IsIPv4MappedToIPv6) + { localAddress = localAddress.MapToIPv4(); } @@ -146,7 +146,7 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun // OpenedMediaSource.Path = tempFile; // OpenedMediaSource.ReadAtNativeFramerate = true; - MediaSource.Path = _appHost.GetLoopbackHttpApiUrl() + "/LiveTv/LiveStreamFiles/" + UniqueId + "/stream.ts"; + MediaSource.Path = _appHost.GetApiUrlForLocalAccess() + "/LiveTv/LiveStreamFiles/" + UniqueId + "/stream.ts"; MediaSource.Protocol = MediaProtocol.Http; // OpenedMediaSource.SupportsDirectPlay = false; // OpenedMediaSource.SupportsDirectStream = true; @@ -156,11 +156,6 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun await taskCompletionSource.Task.ConfigureAwait(false); } - public string GetFilePath() - { - return TempFilePath; - } - private async Task StartStreaming(UdpClient udpClient, HdHomerunManager hdHomerunManager, IPAddress remoteAddress, TaskCompletionSource<bool> openTaskCompletionSource, CancellationToken cancellationToken) { using (udpClient) @@ -170,7 +165,7 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun { await CopyTo(udpClient, TempFilePath, openTaskCompletionSource, cancellationToken).ConfigureAwait(false); } - catch (OperationCanceledException ex) + catch (Exception ex) when (ex is OperationCanceledException || ex is TimeoutException) { Logger.LogInformation("HDHR UDP stream cancelled or timed out from {0}", remoteAddress); openTaskCompletionSource.TrySetException(ex); @@ -184,7 +179,7 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun EnableStreamSharing = false; } - await DeleteTempFiles(new List<string> { TempFilePath }).ConfigureAwait(false); + await DeleteTempFiles(TempFilePath).ConfigureAwait(false); } private async Task CopyTo(UdpClient udpClient, string file, TaskCompletionSource<bool> openTaskCompletionSource, CancellationToken cancellationToken) @@ -196,36 +191,24 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun while (true) { cancellationToken.ThrowIfCancellationRequested(); - using (var timeOutSource = new CancellationTokenSource()) - using (var linkedSource = CancellationTokenSource.CreateLinkedTokenSource( - cancellationToken, - timeOutSource.Token)) + var res = await udpClient.ReceiveAsync(cancellationToken) + .AsTask() + .WaitAsync(TimeSpan.FromMilliseconds(30000), CancellationToken.None) + .ConfigureAwait(false); + var buffer = res.Buffer; + + var read = buffer.Length - RtpHeaderBytes; + + if (read > 0) { - var resTask = udpClient.ReceiveAsync(); - if (await Task.WhenAny(resTask, Task.Delay(30000, linkedSource.Token)).ConfigureAwait(false) != resTask) - { - resTask.Dispose(); - break; - } + await fileStream.WriteAsync(buffer.AsMemory(RtpHeaderBytes, read), cancellationToken).ConfigureAwait(false); + } - // We don't want all these delay tasks to keep running - timeOutSource.Cancel(); - var res = await resTask.ConfigureAwait(false); - var buffer = res.Buffer; - - var read = buffer.Length - RtpHeaderBytes; - - if (read > 0) - { - fileStream.Write(buffer, RtpHeaderBytes, read); - } - - if (!resolved) - { - resolved = true; - DateOpened = DateTime.UtcNow; - openTaskCompletionSource.TrySetResult(true); - } + if (!resolved) + { + resolved = true; + DateOpened = DateTime.UtcNow; + openTaskCompletionSource.TrySetResult(true); } } } diff --git a/Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/IHdHomerunChannelCommands.cs b/Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/IHdHomerunChannelCommands.cs new file mode 100644 index 000000000..11bd40ab1 --- /dev/null +++ b/Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/IHdHomerunChannelCommands.cs @@ -0,0 +1,11 @@ +#pragma warning disable CS1591 + +using System.Collections.Generic; + +namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun +{ + public interface IHdHomerunChannelCommands + { + IEnumerable<(string CommandName, string CommandValue)> GetCommands(); + } +} diff --git a/Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/LegacyHdHomerunChannelCommands.cs b/Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/LegacyHdHomerunChannelCommands.cs new file mode 100644 index 000000000..80d9d0724 --- /dev/null +++ b/Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/LegacyHdHomerunChannelCommands.cs @@ -0,0 +1,38 @@ +#pragma warning disable CS1591 + +using System.Collections.Generic; +using System.Text.RegularExpressions; + +namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun +{ + public class LegacyHdHomerunChannelCommands : IHdHomerunChannelCommands + { + private string? _channel; + private string? _program; + + public LegacyHdHomerunChannelCommands(string url) + { + // parse url for channel and program + var regExp = new Regex(@"\/ch([0-9]+)-?([0-9]*)"); + var match = regExp.Match(url); + if (match.Success) + { + _channel = match.Groups[1].Value; + _program = match.Groups[2].Value; + } + } + + public IEnumerable<(string CommandName, string CommandValue)> GetCommands() + { + if (!string.IsNullOrEmpty(_channel)) + { + yield return ("channel", _channel); + } + + if (!string.IsNullOrEmpty(_program)) + { + yield return ("program", _program); + } + } + } +} diff --git a/Emby.Server.Implementations/LiveTv/TunerHosts/LiveStream.cs b/Emby.Server.Implementations/LiveTv/TunerHosts/LiveStream.cs index 2c21a4a89..5581ba87c 100644 --- a/Emby.Server.Implementations/LiveTv/TunerHosts/LiveStream.cs +++ b/Emby.Server.Implementations/LiveTv/TunerHosts/LiveStream.cs @@ -3,10 +3,8 @@ #pragma warning disable CS1591 using System; -using System.Collections.Generic; using System.Globalization; using System.IO; -using System.Linq; using System.Threading; using System.Threading.Tasks; using MediaBrowser.Common.Configuration; @@ -22,14 +20,6 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts { private readonly IConfigurationManager _configurationManager; - protected readonly IFileSystem FileSystem; - - protected readonly IStreamHelper StreamHelper; - - protected string TempFilePath; - protected readonly ILogger Logger; - protected readonly CancellationTokenSource LiveStreamCancellationTokenSource = new CancellationTokenSource(); - public LiveStream( MediaSourceInfo mediaSource, TunerHostInfo tuner, @@ -57,7 +47,15 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts SetTempFilePath("ts"); } - protected virtual int EmptyReadLimit => 1000; + protected IFileSystem FileSystem { get; } + + protected IStreamHelper StreamHelper { get; } + + protected ILogger Logger { get; } + + protected CancellationTokenSource LiveStreamCancellationTokenSource { get; } = new CancellationTokenSource(); + + protected string TempFilePath { get; set; } public MediaSourceInfo OriginalMediaSource { get; set; } @@ -97,121 +95,50 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts return Task.CompletedTask; } - protected FileStream GetInputStream(string path, bool allowAsyncFileRead) + public Stream GetStream() + { + var stream = GetInputStream(TempFilePath); + bool seekFile = (DateTime.UtcNow - DateOpened).TotalSeconds > 10; + if (seekFile) + { + TrySeek(stream, -20000); + } + + return stream; + } + + protected FileStream GetInputStream(string path) => new FileStream( path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite, IODefaults.FileStreamBufferSize, - allowAsyncFileRead ? FileOptions.SequentialScan | FileOptions.Asynchronous : FileOptions.SequentialScan); + FileOptions.SequentialScan | FileOptions.Asynchronous); - public Task DeleteTempFiles() - { - return DeleteTempFiles(GetStreamFilePaths()); - } - - protected async Task DeleteTempFiles(IEnumerable<string> paths, int retryCount = 0) + protected async Task DeleteTempFiles(string path, int retryCount = 0) { if (retryCount == 0) { - Logger.LogInformation("Deleting temp files {0}", paths); + Logger.LogInformation("Deleting temp file {FilePath}", path); } - var failedFiles = new List<string>(); - - foreach (var path in paths) + try { - if (!File.Exists(path)) + FileSystem.DeleteFile(path); + } + catch (Exception ex) + { + Logger.LogError(ex, "Error deleting file {FilePath}", path); + if (retryCount <= 40) { - continue; - } - - try - { - FileSystem.DeleteFile(path); - } - catch (Exception ex) - { - Logger.LogError(ex, "Error deleting file {path}", path); - failedFiles.Add(path); + await Task.Delay(500).ConfigureAwait(false); + await DeleteTempFiles(path, retryCount + 1).ConfigureAwait(false); } } - - if (failedFiles.Count > 0 && retryCount <= 40) - { - await Task.Delay(500).ConfigureAwait(false); - await DeleteTempFiles(failedFiles, retryCount + 1).ConfigureAwait(false); - } } - protected virtual List<string> GetStreamFilePaths() - { - return new List<string> { TempFilePath }; - } - - public async Task CopyToAsync(Stream stream, CancellationToken cancellationToken) - { - using var linkedCancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, LiveStreamCancellationTokenSource.Token); - cancellationToken = linkedCancellationTokenSource.Token; - - bool seekFile = (DateTime.UtcNow - DateOpened).TotalSeconds > 10; - - var nextFileInfo = GetNextFile(null); - var nextFile = nextFileInfo.file; - var isLastFile = nextFileInfo.isLastFile; - - var allowAsync = AsyncFile.UseAsyncIO; - while (!string.IsNullOrEmpty(nextFile)) - { - var emptyReadLimit = isLastFile ? EmptyReadLimit : 1; - - await CopyFile(nextFile, seekFile, emptyReadLimit, allowAsync, stream, cancellationToken).ConfigureAwait(false); - - seekFile = false; - nextFileInfo = GetNextFile(nextFile); - nextFile = nextFileInfo.file; - isLastFile = nextFileInfo.isLastFile; - } - - Logger.LogInformation("Live Stream ended."); - } - - private (string file, bool isLastFile) GetNextFile(string currentFile) - { - var files = GetStreamFilePaths(); - - if (string.IsNullOrEmpty(currentFile)) - { - return (files[^1], true); - } - - var nextIndex = files.FindIndex(i => string.Equals(i, currentFile, StringComparison.OrdinalIgnoreCase)) + 1; - - var isLastFile = nextIndex == files.Count - 1; - - return (files.ElementAtOrDefault(nextIndex), isLastFile); - } - - private async Task CopyFile(string path, bool seekFile, int emptyReadLimit, bool allowAsync, Stream stream, CancellationToken cancellationToken) - { - using (var inputStream = GetInputStream(path, allowAsync)) - { - if (seekFile) - { - TrySeek(inputStream, -20000); - } - - await StreamHelper.CopyToAsync( - inputStream, - stream, - IODefaults.CopyToBufferSize, - emptyReadLimit, - cancellationToken).ConfigureAwait(false); - } - } - - private void TrySeek(FileStream stream, long offset) + private void TrySeek(Stream stream, long offset) { if (!stream.CanSeek) { diff --git a/Emby.Server.Implementations/LiveTv/TunerHosts/M3UTunerHost.cs b/Emby.Server.Implementations/LiveTv/TunerHosts/M3UTunerHost.cs index 08b9260b9..dd83f9a53 100644 --- a/Emby.Server.Implementations/LiveTv/TunerHosts/M3UTunerHost.cs +++ b/Emby.Server.Implementations/LiveTv/TunerHosts/M3UTunerHost.cs @@ -10,6 +10,7 @@ using System.Linq; using System.Net.Http; using System.Threading; using System.Threading.Tasks; +using Jellyfin.Extensions; using MediaBrowser.Common.Extensions; using MediaBrowser.Common.Net; using MediaBrowser.Controller; @@ -119,7 +120,7 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts { var extension = Path.GetExtension(mediaSource.Path) ?? string.Empty; - if (!_disallowedSharedStreamExtensions.Contains(extension, StringComparer.OrdinalIgnoreCase)) + if (!_disallowedSharedStreamExtensions.Contains(extension, StringComparison.OrdinalIgnoreCase)) { return new SharedHttpStream(mediaSource, tunerHost, streamId, FileSystem, _httpClientFactory, Logger, Config, _appHost, _streamHelper); } @@ -130,7 +131,7 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts public async Task Validate(TunerHostInfo info) { - using (var stream = await new M3uParser(Logger, _httpClientFactory).GetListingsStream(info, CancellationToken.None).ConfigureAwait(false)) + using (await new M3uParser(Logger, _httpClientFactory).GetListingsStream(info, CancellationToken.None).ConfigureAwait(false)) { } } diff --git a/Emby.Server.Implementations/LiveTv/TunerHosts/M3uParser.cs b/Emby.Server.Implementations/LiveTv/TunerHosts/M3uParser.cs index 23071a430..708ff52d7 100644 --- a/Emby.Server.Implementations/LiveTv/TunerHosts/M3uParser.cs +++ b/Emby.Server.Implementations/LiveTv/TunerHosts/M3uParser.cs @@ -238,7 +238,7 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts { try { - numberString = Path.GetFileNameWithoutExtension(mediaUrl.Split('/')[^1]); + numberString = Path.GetFileNameWithoutExtension(mediaUrl.AsSpan().RightPart('/')).ToString(); if (!IsValidChannelNumber(numberString)) { @@ -283,7 +283,7 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts // #EXTINF:0,84.0 - VOX Schweiz if (!string.IsNullOrWhiteSpace(nameInExtInf)) { - var numberIndex = nameInExtInf.IndexOf(' '); + var numberIndex = nameInExtInf.IndexOf(' ', StringComparison.Ordinal); if (numberIndex > 0) { var numberPart = nameInExtInf.Substring(0, numberIndex).Trim(new[] { ' ', '.' }); diff --git a/Emby.Server.Implementations/LiveTv/TunerHosts/SharedHttpStream.cs b/Emby.Server.Implementations/LiveTv/TunerHosts/SharedHttpStream.cs index 862993877..ab4beb15b 100644 --- a/Emby.Server.Implementations/LiveTv/TunerHosts/SharedHttpStream.cs +++ b/Emby.Server.Implementations/LiveTv/TunerHosts/SharedHttpStream.cs @@ -1,9 +1,6 @@ -#nullable disable - #pragma warning disable CS1591 using System; -using System.Collections.Generic; using System.Globalization; using System.IO; using System.Net.Http; @@ -52,42 +49,29 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts var url = mediaSource.Path; - Directory.CreateDirectory(Path.GetDirectoryName(TempFilePath)); + Directory.CreateDirectory(Path.GetDirectoryName(TempFilePath) ?? throw new InvalidOperationException("Path can't be a root directory.")); var typeName = GetType().Name; - Logger.LogInformation("Opening " + typeName + " Live stream from {0}", url); + Logger.LogInformation("Opening {StreamType} Live stream from {Url}", typeName, url); // Response stream is disposed manually. var response = await _httpClientFactory.CreateClient(NamedClient.Default) .GetAsync(url, HttpCompletionOption.ResponseHeadersRead, CancellationToken.None) .ConfigureAwait(false); - var extension = "ts"; - var requiresRemux = false; - var contentType = response.Content.Headers.ContentType?.ToString() ?? string.Empty; - if (contentType.IndexOf("matroska", StringComparison.OrdinalIgnoreCase) != -1) + if (contentType.Contains("matroska", StringComparison.OrdinalIgnoreCase) + || contentType.Contains("mp4", StringComparison.OrdinalIgnoreCase) + || contentType.Contains("dash", StringComparison.OrdinalIgnoreCase) + || contentType.Contains("mpegURL", StringComparison.OrdinalIgnoreCase) + || contentType.Contains("text/", StringComparison.OrdinalIgnoreCase)) { - requiresRemux = true; - } - else if (contentType.IndexOf("mp4", StringComparison.OrdinalIgnoreCase) != -1 || - contentType.IndexOf("dash", StringComparison.OrdinalIgnoreCase) != -1 || - contentType.IndexOf("mpegURL", StringComparison.OrdinalIgnoreCase) != -1 || - contentType.IndexOf("text/", StringComparison.OrdinalIgnoreCase) != -1) - { - requiresRemux = true; + // Close the stream without any sharing features + response.Dispose(); + return; } - // Close the stream without any sharing features - if (requiresRemux) - { - using (response) - { - return; - } - } - - SetTempFilePath(extension); + SetTempFilePath("ts"); var taskCompletionSource = new TaskCompletionSource<bool>(); @@ -97,7 +81,7 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts // OpenedMediaSource.Path = tempFile; // OpenedMediaSource.ReadAtNativeFramerate = true; - MediaSource.Path = _appHost.GetLoopbackHttpApiUrl() + "/LiveTv/LiveStreamFiles/" + UniqueId + "/stream.ts"; + MediaSource.Path = _appHost.GetApiUrlForLocalAccess() + "/LiveTv/LiveStreamFiles/" + UniqueId + "/stream.ts"; MediaSource.Protocol = MediaProtocol.Http; // OpenedMediaSource.Path = TempFilePath; @@ -108,25 +92,14 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts // OpenedMediaSource.SupportsDirectPlay = false; // OpenedMediaSource.SupportsDirectStream = true; // OpenedMediaSource.SupportsTranscoding = true; - await taskCompletionSource.Task.ConfigureAwait(false); - if (taskCompletionSource.Task.Exception != null) + var res = await taskCompletionSource.Task.ConfigureAwait(false); + if (!res) { - // Error happened while opening the stream so raise the exception again to inform the caller - throw taskCompletionSource.Task.Exception; - } - - if (!taskCompletionSource.Task.Result) - { - Logger.LogWarning("Zero bytes copied from stream {0} to {1} but no exception raised", GetType().Name, TempFilePath); + Logger.LogWarning("Zero bytes copied from stream {StreamType} to {FilePath} but no exception raised", GetType().Name, TempFilePath); throw new EndOfStreamException(string.Format(CultureInfo.InvariantCulture, "Zero bytes copied from stream {0}", GetType().Name)); } } - public string GetFilePath() - { - return TempFilePath; - } - private Task StartStreaming(HttpResponseMessage response, TaskCompletionSource<bool> openTaskCompletionSource, CancellationToken cancellationToken) { return Task.Run( @@ -134,10 +107,10 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts { try { - Logger.LogInformation("Beginning {0} stream to {1}", GetType().Name, TempFilePath); + Logger.LogInformation("Beginning {StreamType} stream to {FilePath}", GetType().Name, TempFilePath); using var message = response; await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); - await using var fileStream = new FileStream(TempFilePath, FileMode.Create, FileAccess.Write, FileShare.Read, IODefaults.FileStreamBufferSize, AsyncFile.UseAsyncIO); + await using var fileStream = new FileStream(TempFilePath, FileMode.Create, FileAccess.Write, FileShare.Read, IODefaults.FileStreamBufferSize, FileOptions.Asynchronous); await StreamHelper.CopyToAsync( stream, fileStream, @@ -147,19 +120,19 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts } catch (OperationCanceledException ex) { - Logger.LogInformation("Copying of {0} to {1} was canceled", GetType().Name, TempFilePath); + Logger.LogInformation("Copying of {StreamType} to {FilePath} was canceled", GetType().Name, TempFilePath); openTaskCompletionSource.TrySetException(ex); } catch (Exception ex) { - Logger.LogError(ex, "Error copying live stream {0} to {1}.", GetType().Name, TempFilePath); + Logger.LogError(ex, "Error copying live stream {StreamType} to {FilePath}", GetType().Name, TempFilePath); openTaskCompletionSource.TrySetException(ex); } openTaskCompletionSource.TrySetResult(false); EnableStreamSharing = false; - await DeleteTempFiles(new List<string> { TempFilePath }).ConfigureAwait(false); + await DeleteTempFiles(TempFilePath).ConfigureAwait(false); }, CancellationToken.None); } diff --git a/Emby.Server.Implementations/Localization/Core/ar.json b/Emby.Server.Implementations/Localization/Core/ar.json index be629c8a4..9d4d40e51 100644 --- a/Emby.Server.Implementations/Localization/Core/ar.json +++ b/Emby.Server.Implementations/Localization/Core/ar.json @@ -1,5 +1,5 @@ { - "Albums": "البومات", + "Albums": "ألبومات", "AppDeviceValues": "تطبيق: {0}, جهاز: {1}", "Application": "تطبيق", "Artists": "الفنانين", @@ -8,15 +8,15 @@ "CameraImageUploadedFrom": "صورة كاميرا جديدة تم رفعها من {0}", "Channels": "القنوات", "ChapterNameValue": "الفصل {0}", - "Collections": "مجموعات", + "Collections": "التجميعات", "DeviceOfflineWithName": "قُطِع الاتصال ب{0}", "DeviceOnlineWithName": "{0} متصل", - "FailedLoginAttemptWithUserName": "عملية تسجيل الدخول فشلت من {0}", - "Favorites": "المفضلة", + "FailedLoginAttemptWithUserName": "محاولة تسجيل الدخول فشلت من {0}", + "Favorites": "مفضلات", "Folders": "المجلدات", "Genres": "التضنيفات", - "HeaderAlbumArtists": "فناني الألبومات", - "HeaderContinueWatching": "استئناف", + "HeaderAlbumArtists": "فناني الألبوم", + "HeaderContinueWatching": "استمر بالمشاهدة", "HeaderFavoriteAlbums": "الألبومات المفضلة", "HeaderFavoriteArtists": "الفنانون المفضلون", "HeaderFavoriteEpisodes": "الحلقات المفضلة", @@ -25,7 +25,7 @@ "HeaderLiveTV": "التلفاز المباشر", "HeaderNextUp": "التالي", "HeaderRecordingGroups": "مجموعات التسجيل", - "HomeVideos": "الفيديوهات المنزلية", + "HomeVideos": "الفيديوهات الشخصية", "Inherit": "توريث", "ItemAddedWithName": "تم إضافة {0} للمكتبة", "ItemRemovedWithName": "تم إزالة {0} من المكتبة", @@ -33,7 +33,7 @@ "LabelRunningTimeValue": "المدة: {0}", "Latest": "الأحدث", "MessageApplicationUpdated": "لقد تم تحديث خادم Jellyfin", - "MessageApplicationUpdatedTo": "تم تحديث سيرفر Jellyfin الى {0}", + "MessageApplicationUpdatedTo": "تم تحديث خادم Jellyfin الى {0}", "MessageNamedServerConfigurationUpdatedWithValue": "تم تحديث إعدادات الخادم في قسم {0}", "MessageServerConfigurationUpdated": "تم تحديث إعدادات الخادم", "MixedContent": "محتوى مختلط", @@ -43,7 +43,7 @@ "NameInstallFailed": "فشل التثبيت {0}", "NameSeasonNumber": "الموسم {0}", "NameSeasonUnknown": "الموسم غير معروف", - "NewVersionIsAvailable": "نسخة جديدة من سيرفر Jellyfin متوفرة للتحميل.", + "NewVersionIsAvailable": "نسخة جديدة من خادم Jellyfin متوفرة للتحميل.", "NotificationOptionApplicationUpdateAvailable": "يوجد تحديث للتطبيق", "NotificationOptionApplicationUpdateInstalled": "تم تحديث التطبيق", "NotificationOptionAudioPlayback": "بدأ تشغيل المقطع الصوتي", @@ -55,7 +55,7 @@ "NotificationOptionPluginInstalled": "تم تثبيت الملحق", "NotificationOptionPluginUninstalled": "تمت إزالة الملحق", "NotificationOptionPluginUpdateInstalled": "تم تثبيت تحديثات الملحق", - "NotificationOptionServerRestartRequired": "يجب إعادة تشغيل السيرفر", + "NotificationOptionServerRestartRequired": "يجب إعادة تشغيل الخادم", "NotificationOptionTaskFailed": "فشل في المهمة المجدولة", "NotificationOptionUserLockedOut": "تم إقفال حساب المستخدم", "NotificationOptionVideoPlayback": "بدأ تشغيل الفيديو", @@ -72,7 +72,7 @@ "ServerNameNeedsToBeRestarted": "يحتاج لإعادة تشغيله {0}", "Shows": "الحلقات", "Songs": "الأغاني", - "StartupEmbyServerIsLoading": "سيرفر Jellyfin قيد التشغيل . الرجاء المحاولة بعد قليل.", + "StartupEmbyServerIsLoading": "خادم Jellyfin قيد التشغيل . الرجاء المحاولة بعد قليل.", "SubtitleDownloadFailureForItem": "عملية إنزال الترجمة فشلت لـ{0}", "SubtitleDownloadFailureFromForItem": "الترجمات فشلت في التحميل من {0} الى {1}", "Sync": "مزامنة", diff --git a/Emby.Server.Implementations/Localization/Core/as.json b/Emby.Server.Implementations/Localization/Core/as.json new file mode 100644 index 000000000..0967ef424 --- /dev/null +++ b/Emby.Server.Implementations/Localization/Core/as.json @@ -0,0 +1 @@ +{} diff --git a/Emby.Server.Implementations/Localization/Core/be.json b/Emby.Server.Implementations/Localization/Core/be.json new file mode 100644 index 000000000..56c4e7d39 --- /dev/null +++ b/Emby.Server.Implementations/Localization/Core/be.json @@ -0,0 +1,4 @@ +{ + "Sync": "Сінхранізацыя", + "Playlists": "Плэйліст" +} diff --git a/Emby.Server.Implementations/Localization/Core/ca.json b/Emby.Server.Implementations/Localization/Core/ca.json index 7715daa7c..9bab3b9a9 100644 --- a/Emby.Server.Implementations/Localization/Core/ca.json +++ b/Emby.Server.Implementations/Localization/Core/ca.json @@ -15,7 +15,7 @@ "Favorites": "Preferits", "Folders": "Carpetes", "Genres": "Gèneres", - "HeaderAlbumArtists": "Artistes del Àlbum", + "HeaderAlbumArtists": "Artistes de l'àlbum", "HeaderContinueWatching": "Continua Veient", "HeaderFavoriteAlbums": "Àlbums Preferits", "HeaderFavoriteArtists": "Artistes Predilectes", @@ -25,7 +25,7 @@ "HeaderLiveTV": "TV en Directe", "HeaderNextUp": "A continuació", "HeaderRecordingGroups": "Grups d'Enregistrament", - "HomeVideos": "Vídeos domèstics", + "HomeVideos": "Vídeos Domèstics", "Inherit": "Hereta", "ItemAddedWithName": "{0} ha estat afegit a la biblioteca", "ItemRemovedWithName": "{0} ha estat eliminat de la biblioteca", @@ -39,7 +39,7 @@ "MixedContent": "Contingut barrejat", "Movies": "Pel·lícules", "Music": "Música", - "MusicVideos": "Vídeos musicals", + "MusicVideos": "Vídeos Musicals", "NameInstallFailed": "Instalació de {0} fallida", "NameSeasonNumber": "Temporada {0}", "NameSeasonUnknown": "Temporada Desconeguda", diff --git a/Emby.Server.Implementations/Localization/Core/cs.json b/Emby.Server.Implementations/Localization/Core/cs.json index 4f1d231a4..25f51db16 100644 --- a/Emby.Server.Implementations/Localization/Core/cs.json +++ b/Emby.Server.Implementations/Localization/Core/cs.json @@ -15,7 +15,7 @@ "Favorites": "Oblíbené", "Folders": "Složky", "Genres": "Žánry", - "HeaderAlbumArtists": "Album umělce", + "HeaderAlbumArtists": "Umělci alba", "HeaderContinueWatching": "Pokračovat ve sledování", "HeaderFavoriteAlbums": "Oblíbená alba", "HeaderFavoriteArtists": "Oblíbení interpreti", @@ -25,7 +25,7 @@ "HeaderLiveTV": "Televize", "HeaderNextUp": "Nadcházející", "HeaderRecordingGroups": "Skupiny nahrávek", - "HomeVideos": "Domáci videa", + "HomeVideos": "Domácí videa", "Inherit": "Zdědit", "ItemAddedWithName": "{0} byl přidán do knihovny", "ItemRemovedWithName": "{0} byl odstraněn z knihovny", diff --git a/Emby.Server.Implementations/Localization/Core/cy.json b/Emby.Server.Implementations/Localization/Core/cy.json new file mode 100644 index 000000000..7fc27e18a --- /dev/null +++ b/Emby.Server.Implementations/Localization/Core/cy.json @@ -0,0 +1,58 @@ +{ + "DeviceOnlineWithName": "Mae {0} wedi'i gysylltu", + "DeviceOfflineWithName": "Mae {0} wedi datgysylltu", + "Default": "Diofyn", + "Collections": "Casgliadau", + "ChapterNameValue": "Pennod {0}", + "Channels": "Sianeli", + "CameraImageUploadedFrom": "Mae delwedd camera newydd wedi'i lanlwytho o {0}", + "Books": "Llyfrau", + "AuthenticationSucceededWithUserName": "{0} wedi’i ddilysu’n llwyddiannus", + "Artists": "Artistiaid", + "AppDeviceValues": "Ap: {0}, Dyfais: {1}", + "Albums": "Albwmau", + "Genres": "Genres", + "Folders": "Ffolderi", + "Favorites": "Ffefrynnau", + "LabelRunningTimeValue": "Amser rhedeg: {0}", + "TaskOptimizeDatabase": "Cronfa ddata Optimeiddio", + "TaskRefreshChannels": "Adnewyddu Sianeli", + "TaskRefreshPeople": "Adnewyddu Pobl", + "TasksChannelsCategory": "Sianeli Internet", + "VersionNumber": "Fersiwn {0}", + "ScheduledTaskStartedWithName": "{0} wedi dechrau", + "ScheduledTaskFailedWithName": "{0} wedi methu", + "ProviderValue": "Darparwr: {0}", + "NotificationOptionInstallationFailed": "Fethu Gosod", + "NameSeasonUnknown": "Tymor Anhysbys", + "NameSeasonNumber": "Tymor {0}", + "MusicVideos": "Fideos Cerddoriaeth", + "MixedContent": "Cynnwys amrywiol", + "HomeVideos": "Fideos Cartref", + "HeaderNextUp": "Nesaf i Fyny", + "HeaderFavoriteArtists": "Ffefryn Artistiaid", + "HeaderFavoriteAlbums": "Ffefryn Albwmau", + "HeaderContinueWatching": "Parhewch i Wylio", + "TasksApplicationCategory": "Rhaglen", + "TasksLibraryCategory": "Llyfrgell", + "TasksMaintenanceCategory": "Cynnal a Chadw", + "System": "System", + "Plugin": "Ategyn", + "Music": "Cerddoriaeth", + "Latest": "Diweddaraf", + "Inherit": "Etifeddu", + "Forced": "Orfodi", + "Application": "Rhaglen", + "HeaderAlbumArtists": "Artistiaid albwm", + "Sync": "Cysoni", + "Songs": "Caneuon", + "Shows": "Rhaglenni", + "Playlists": "Rhestri Chwarae", + "Photos": "Lluniau", + "ValueSpecialEpisodeName": "Arbennig - {0}", + "Movies": "Ffilmiau", + "Undefined": "Heb ddiffiniad", + "TvShows": "Rhaglenni teledu", + "HeaderLiveTV": "Teledu Byw", + "User": "Defnyddiwr" +} diff --git a/Emby.Server.Implementations/Localization/Core/da.json b/Emby.Server.Implementations/Localization/Core/da.json index b2c484a31..cfe365f57 100644 --- a/Emby.Server.Implementations/Localization/Core/da.json +++ b/Emby.Server.Implementations/Localization/Core/da.json @@ -15,7 +15,7 @@ "Favorites": "Favoritter", "Folders": "Mapper", "Genres": "Genrer", - "HeaderAlbumArtists": "Albumkunstnere", + "HeaderAlbumArtists": "Kunstnerens album", "HeaderContinueWatching": "Fortsæt Afspilning", "HeaderFavoriteAlbums": "Favoritalbummer", "HeaderFavoriteArtists": "Favoritkunstnere", diff --git a/Emby.Server.Implementations/Localization/Core/de.json b/Emby.Server.Implementations/Localization/Core/de.json index c924e5c15..115f36e7c 100644 --- a/Emby.Server.Implementations/Localization/Core/de.json +++ b/Emby.Server.Implementations/Localization/Core/de.json @@ -71,7 +71,7 @@ "ScheduledTaskStartedWithName": "{0} wurde gestartet", "ServerNameNeedsToBeRestarted": "{0} muss neu gestartet werden", "Shows": "Serien", - "Songs": "Songs", + "Songs": "Lieder", "StartupEmbyServerIsLoading": "Jellyfin-Server startet, bitte versuche es gleich noch einmal.", "SubtitleDownloadFailureForItem": "Download der Untertitel fehlgeschlagen für {0}", "SubtitleDownloadFailureFromForItem": "Untertitel von {0} für {1} konnten nicht heruntergeladen werden", @@ -92,25 +92,25 @@ "ValueHasBeenAddedToLibrary": "{0} wurde deiner Bibliothek hinzugefügt", "ValueSpecialEpisodeName": "Extra - {0}", "VersionNumber": "Version {0}", - "TaskDownloadMissingSubtitlesDescription": "Durchsucht das Internet nach fehlenden Untertiteln, basierend auf den Meta Einstellungen.", + "TaskDownloadMissingSubtitlesDescription": "Suche im Internet basierend auf den Metadaten-Einstellungen nach fehlenden Untertiteln.", "TaskDownloadMissingSubtitles": "Lade fehlende Untertitel herunter", - "TaskRefreshChannelsDescription": "Aktualisiere Internet Kanal Informationen.", + "TaskRefreshChannelsDescription": "Aktualisiere Internet-Kanal-Informationen.", "TaskRefreshChannels": "Aktualisiere Kanäle", - "TaskCleanTranscodeDescription": "Löscht Transkodierdateien, welche älter als einen Tag sind.", - "TaskCleanTranscode": "Lösche Transkodier-Pfad", + "TaskCleanTranscodeDescription": "Löscht Transkodierdateien, die älter als einen Tag sind.", + "TaskCleanTranscode": "Räume Transkodierungs-Verzeichnis auf", "TaskUpdatePluginsDescription": "Lädt Updates für Plugins herunter, welche für automatische Updates konfiguriert sind und installiert diese.", "TaskUpdatePlugins": "Aktualisiere Plugins", "TaskRefreshPeopleDescription": "Aktualisiert Metadaten für Schauspieler und Regisseure in deinen Bibliotheken.", - "TaskRefreshPeople": "Aktualisiere Schauspieler", - "TaskCleanLogsDescription": "Lösche Log Dateien, die älter als {0} Tage sind.", - "TaskCleanLogs": "Lösche Log-Verzeichnis", - "TaskRefreshLibraryDescription": "Scanne alle Bibliotheken nach neu hinzugefügten Dateien und aktualisiere Metadaten.", + "TaskRefreshPeople": "Aktualisiere Personen", + "TaskCleanLogsDescription": "Lösche Log-Dateien, die älter als {0} Tage sind.", + "TaskCleanLogs": "Räumt Log-Verzeichnis auf", + "TaskRefreshLibraryDescription": "Scannt alle Bibliotheken nach neu hinzugefügten Dateien und aktualisiere Metadaten.", "TaskRefreshLibrary": "Scanne Medien-Bibliothek", - "TaskRefreshChapterImagesDescription": "Erstellt Vorschaubilder für Videos, welche Kapitel besitzen.", - "TaskRefreshChapterImages": "Extrahiert Kapitel-Bilder", + "TaskRefreshChapterImagesDescription": "Erstellt Vorschaubilder für Videos, die Kapitel besitzen.", + "TaskRefreshChapterImages": "Extrahiere Kapitel-Bilder", "TaskCleanCacheDescription": "Löscht nicht mehr benötigte Zwischenspeicherdateien.", "TaskCleanCache": "Leere Zwischenspeicher", - "TasksChannelsCategory": "Internet Kanäle", + "TasksChannelsCategory": "Internet-Kanäle", "TasksApplicationCategory": "Anwendung", "TasksLibraryCategory": "Bibliothek", "TasksMaintenanceCategory": "Wartung", diff --git a/Emby.Server.Implementations/Localization/Core/el.json b/Emby.Server.Implementations/Localization/Core/el.json index 697063f26..9952c05ca 100644 --- a/Emby.Server.Implementations/Localization/Core/el.json +++ b/Emby.Server.Implementations/Localization/Core/el.json @@ -15,7 +15,7 @@ "Favorites": "Αγαπημένα", "Folders": "Φάκελοι", "Genres": "Είδη", - "HeaderAlbumArtists": "Άλμπουμ Καλλιτέχνη", + "HeaderAlbumArtists": "Καλλιτέχνες άλμπουμ", "HeaderContinueWatching": "Συνεχίστε την παρακολούθηση", "HeaderFavoriteAlbums": "Αγαπημένα Άλμπουμ", "HeaderFavoriteArtists": "Αγαπημένοι Καλλιτέχνες", diff --git a/Emby.Server.Implementations/Localization/Core/en-GB.json b/Emby.Server.Implementations/Localization/Core/en-GB.json index 8b2e8b6b1..add578376 100644 --- a/Emby.Server.Implementations/Localization/Core/en-GB.json +++ b/Emby.Server.Implementations/Localization/Core/en-GB.json @@ -15,7 +15,7 @@ "Favorites": "Favourites", "Folders": "Folders", "Genres": "Genres", - "HeaderAlbumArtists": "Album Artists", + "HeaderAlbumArtists": "Album artists", "HeaderContinueWatching": "Continue Watching", "HeaderFavoriteAlbums": "Favourite Albums", "HeaderFavoriteArtists": "Favourite Artists", @@ -25,7 +25,7 @@ "HeaderLiveTV": "Live TV", "HeaderNextUp": "Next Up", "HeaderRecordingGroups": "Recording Groups", - "HomeVideos": "Home videos", + "HomeVideos": "Home Videos", "Inherit": "Inherit", "ItemAddedWithName": "{0} was added to the library", "ItemRemovedWithName": "{0} was removed from the library", @@ -39,7 +39,7 @@ "MixedContent": "Mixed content", "Movies": "Movies", "Music": "Music", - "MusicVideos": "Music videos", + "MusicVideos": "Music Videos", "NameInstallFailed": "{0} installation failed", "NameSeasonNumber": "Season {0}", "NameSeasonUnknown": "Season Unknown", diff --git a/Emby.Server.Implementations/Localization/Core/en-US.json b/Emby.Server.Implementations/Localization/Core/en-US.json index ca127cdb8..568a8e447 100644 --- a/Emby.Server.Implementations/Localization/Core/en-US.json +++ b/Emby.Server.Implementations/Localization/Core/en-US.json @@ -12,12 +12,12 @@ "Default": "Default", "DeviceOfflineWithName": "{0} has disconnected", "DeviceOnlineWithName": "{0} is connected", - "FailedLoginAttemptWithUserName": "Failed login attempt from {0}", + "FailedLoginAttemptWithUserName": "Failed login try from {0}", "Favorites": "Favorites", "Folders": "Folders", "Forced": "Forced", "Genres": "Genres", - "HeaderAlbumArtists": "Artist's Album", + "HeaderAlbumArtists": "Album artists", "HeaderContinueWatching": "Continue Watching", "HeaderFavoriteAlbums": "Favorite Albums", "HeaderFavoriteArtists": "Favorite Artists", diff --git a/Emby.Server.Implementations/Localization/Core/eo.json b/Emby.Server.Implementations/Localization/Core/eo.json index ca615cc8c..8abf7fa66 100644 --- a/Emby.Server.Implementations/Localization/Core/eo.json +++ b/Emby.Server.Implementations/Localization/Core/eo.json @@ -1,17 +1,17 @@ { - "NotificationOptionInstallationFailed": "Instalada fiasko", - "NotificationOptionAudioPlaybackStopped": "Sono de ludado haltis", - "NotificationOptionAudioPlayback": "Ludado de sono startis", + "NotificationOptionInstallationFailed": "Instalada malsukceso", + "NotificationOptionAudioPlaybackStopped": "Ludado de sono haltis", + "NotificationOptionAudioPlayback": "Ludado de sono lanĉis", "NameSeasonUnknown": "Sezono Nekonata", "NameSeasonNumber": "Sezono {0}", "NameInstallFailed": "{0} instalado fiaskis", "Music": "Muziko", "Movies": "Filmoj", - "ItemRemovedWithName": "{0} forigis el la biblioteko", - "ItemAddedWithName": "{0} aldonis al la biblioteko", - "HeaderLiveTV": "Viva Televido", - "HeaderContinueWatching": "Daŭrigi Spektado", - "HeaderAlbumArtists": "Artistoj de Albumo", + "ItemRemovedWithName": "{0} forigis el la plurmediteko", + "ItemAddedWithName": "{0} aldonis al la plurmediteko", + "HeaderLiveTV": "TV-etero", + "HeaderContinueWatching": "Daŭrigi Spektadon", + "HeaderAlbumArtists": "Artistoj de albumo", "Folders": "Dosierujoj", "DeviceOnlineWithName": "{0} estas konektita", "Default": "Defaŭlte", @@ -23,14 +23,14 @@ "Application": "Aplikaĵo", "AppDeviceValues": "Aplikaĵo: {0}, Aparato: {1}", "Albums": "Albumoj", - "TasksLibraryCategory": "Libraro", + "TasksLibraryCategory": "Plurmediteko", "VersionNumber": "Versio {0}", "UserDownloadingItemWithValues": "{0} elŝutas {1}", "UserCreatedWithName": "Uzanto {0} kreiĝis", "User": "Uzanto", "System": "Sistemo", "Songs": "Kantoj", - "ScheduledTaskStartedWithName": "{0} komencis", + "ScheduledTaskStartedWithName": "{0} lanĉis", "ScheduledTaskFailedWithName": "{0} malsukcesis", "PluginUninstalledWithName": "{0} malinstaliĝis", "PluginInstalledWithName": "{0} instaliĝis", @@ -43,5 +43,81 @@ "MusicVideos": "Muzikvideoj", "LabelIpAddressValue": "IP-adreso: {0}", "Genres": "Ĝenroj", - "DeviceOfflineWithName": "{0} malkonektis" + "DeviceOfflineWithName": "{0} malkonektis", + "HeaderFavoriteArtists": "Favorataj Artistoj", + "Shows": "Serioj", + "HeaderFavoriteShows": "Favorataj Serioj", + "TvShows": "TV-serioj", + "Favorites": "Favorataj", + "TaskCleanLogs": "Purigi Ĵurnalan Katalogon", + "TaskRefreshLibrary": "Skani Plurmeditekon", + "ValueSpecialEpisodeName": "Speciala - {0}", + "TaskOptimizeDatabase": "Optimumigi datenbazon", + "TaskRefreshChannels": "Refreŝigi Kanalojn", + "TaskUpdatePlugins": "Ĝisdatigi Kromprogramojn", + "TaskRefreshPeople": "Refreŝigi Homojn", + "TasksChannelsCategory": "Interretaj Kanaloj", + "ProviderValue": "Provizanto: {0}", + "NotificationOptionPluginError": "Kromprogramo malsukcesis", + "MixedContent": "Miksita enhavo", + "TasksApplicationCategory": "Aplikaĵo", + "TasksMaintenanceCategory": "Prizorgado", + "Undefined": "Nedifinita", + "Sync": "Sinkronigo", + "Latest": "Plej novaj", + "Inherit": "Hereda", + "HomeVideos": "Hejmaj Videoj", + "HeaderNextUp": "Sekva Plue", + "HeaderFavoriteSongs": "Favorataj Kantoj", + "HeaderFavoriteEpisodes": "Favorataj Epizodoj", + "HeaderFavoriteAlbums": "Favorataj Albumoj", + "Forced": "Forcita", + "ServerNameNeedsToBeRestarted": "{0} devas esti relanĉita", + "NotificationOptionVideoPlayback": "La videoludado lanĉis", + "NotificationOptionServerRestartRequired": "Servila relanĉigo bezonata", + "TaskOptimizeDatabaseDescription": "Kompaktigas datenbazon kaj trunkas liberan lokon. Lanĉi ĉi tiun taskon post la plurmediteka skanado aŭ fari aliajn ŝanĝojn, kiuj implicas datenbazajn modifojn, povus plibonigi rendimenton.", + "TaskUpdatePluginsDescription": "Elŝutas kaj instalas ĝisdatigojn por kromprogramojn, kiuj estas agorditaj por ĝisdatigi aŭtomate.", + "TaskDownloadMissingSubtitlesDescription": "Serĉas en interreto mankantajn subtekstojn surbaze de metadatena agordaro.", + "TaskRefreshPeopleDescription": "Ĝisdatigas metadatenojn por aktoroj kaj reĵisoroj en via plurmediteko.", + "TaskCleanLogsDescription": "Forigas ĵurnalajn dosierojn aĝajn pli ol {0} tagojn.", + "TaskRefreshLibraryDescription": "Skanas vian plurmeditekon por novaj dosieroj kaj refreŝigas metadatenaron.", + "NewVersionIsAvailable": "Nova versio de Jellyfin Server estas elŝutebla.", + "TaskCleanCacheDescription": "Forigas stapla dosierojn ne plu necesajn de la sistemo.", + "TaskCleanActivityLogDescription": "Forigas aktivecan ĵurnalaĵojn pli malnovajn ol la agordita aĝo.", + "TaskCleanTranscodeDescription": "Forigas transkodajn dosierojn aĝajn pli ol unu tagon.", + "ValueHasBeenAddedToLibrary": "{0} estis aldonita al via plurmediteko", + "SubtitleDownloadFailureFromForItem": "Subtekstoj malsukcesis elŝuti de {0} por {1}", + "StartupEmbyServerIsLoading": "Jellyfin Server ŝarĝas. Provi denove baldaŭ.", + "TaskRefreshChapterImagesDescription": "Kreas bildetojn por videoj kiuj havas ĉapitrojn.", + "UserStoppedPlayingItemWithValues": "{0} finis ludi {1} ĉe {2}", + "UserPolicyUpdatedWithName": "Uzanta politiko estis ĝisdatigita por {0}", + "UserPasswordChangedWithName": "Pasvorto estis ŝanĝita por uzanto {0}", + "UserStartedPlayingItemWithValues": "{0} ludas {1} ĉe {2}", + "UserLockedOutWithName": "Uzanto {0} estas elŝlosita", + "UserOnlineFromDevice": "{0} estas enreta de {1}", + "UserOfflineFromDevice": "{0} malkonektis de {1}", + "UserDeletedWithName": "Uzanto {0} estis forigita", + "MessageServerConfigurationUpdated": "Servila agordaro estis ĝisdatigita", + "MessageNamedServerConfigurationUpdatedWithValue": "Servila agorda sekcio {0} estis ĝisdatigita", + "MessageApplicationUpdatedTo": "Jellyfin Server estis ĝisdatigita al {0}", + "MessageApplicationUpdated": "Jellyfin Server estis ĝisdatigita", + "TaskRefreshChannelsDescription": "Refreŝigas informon pri interretaj kanaloj.", + "TaskDownloadMissingSubtitles": "Elŝuti mankantajn subtekstojn", + "TaskCleanTranscode": "Malplenigi Transkodadan Katalogon", + "TaskRefreshChapterImages": "Eltiri Ĉapitrajn Bildojn", + "TaskCleanCache": "Malplenigi Staplan Katalogon", + "TaskCleanActivityLog": "Malplenigi Aktivecan Ĵurnalon", + "PluginUpdatedWithName": "{0} estis ĝisdatigita", + "NotificationOptionVideoPlaybackStopped": "La videoludado haltis", + "NotificationOptionUserLockedOut": "Uzanto ŝlosita", + "NotificationOptionTaskFailed": "Planita tasko malsukcesis", + "NotificationOptionPluginUpdateInstalled": "Ĝisdatigo de kromprogramo instalita", + "NotificationOptionCameraImageUploaded": "Kamera bildo alŝutita", + "NotificationOptionApplicationUpdateInstalled": "Aplikaĵa ĝisdatigo instalita", + "NotificationOptionApplicationUpdateAvailable": "Ĝisdatigo de aplikaĵo havebla", + "LabelRunningTimeValue": "Ludada tempo: {0}", + "HeaderRecordingGroups": "Rikordadaj Grupoj", + "FailedLoginAttemptWithUserName": "Malsukcesa ensaluta provo de {0}", + "CameraImageUploadedFrom": "Nova kamera bildo estis alŝutita de {0}", + "AuthenticationSucceededWithUserName": "{0} sukcese aŭtentikigis" } diff --git a/Emby.Server.Implementations/Localization/Core/es.json b/Emby.Server.Implementations/Localization/Core/es.json index d3d9d2703..f8c69712e 100644 --- a/Emby.Server.Implementations/Localization/Core/es.json +++ b/Emby.Server.Implementations/Localization/Core/es.json @@ -15,8 +15,8 @@ "Favorites": "Favoritos", "Folders": "Carpetas", "Genres": "Géneros", - "HeaderAlbumArtists": "Artista del álbum", - "HeaderContinueWatching": "Continuar viendo", + "HeaderAlbumArtists": "Artistas del álbum", + "HeaderContinueWatching": "Seguir viendo", "HeaderFavoriteAlbums": "Álbumes favoritos", "HeaderFavoriteArtists": "Artistas favoritos", "HeaderFavoriteEpisodes": "Episodios favoritos", diff --git a/Emby.Server.Implementations/Localization/Core/es_419.json b/Emby.Server.Implementations/Localization/Core/es_419.json index a968c6dab..2ca736ad9 100644 --- a/Emby.Server.Implementations/Localization/Core/es_419.json +++ b/Emby.Server.Implementations/Localization/Core/es_419.json @@ -15,7 +15,7 @@ "HeaderFavoriteEpisodes": "Episodios favoritos", "HeaderFavoriteShows": "Programas favoritos", "HeaderContinueWatching": "Continuar viendo", - "HeaderAlbumArtists": "Artistas del álbum", + "HeaderAlbumArtists": "Artistas de álbum", "Genres": "Géneros", "Folders": "Carpetas", "Favorites": "Favoritos", @@ -29,7 +29,7 @@ "TaskRefreshChannelsDescription": "Actualiza la información de canales de Internet.", "TaskRefreshChannels": "Actualizar canales", "TaskCleanTranscodeDescription": "Elimina archivos transcodificados que tengan más de un día.", - "TaskCleanTranscode": "Limpiar directorio de transcodificado", + "TaskCleanTranscode": "Limpiar el directorio de transcodificaciones", "TaskUpdatePluginsDescription": "Descarga e instala actualizaciones para complementos que están configurados para actualizarse automáticamente.", "TaskUpdatePlugins": "Actualizar complementos", "TaskRefreshPeopleDescription": "Actualiza metadatos de actores y directores en tu biblioteca de medios.", @@ -105,7 +105,7 @@ "Inherit": "Heredar", "HomeVideos": "Videos caseros", "HeaderRecordingGroups": "Grupos de grabación", - "FailedLoginAttemptWithUserName": "Intento fallido de inicio de sesión desde {0}", + "FailedLoginAttemptWithUserName": "Intento de inicio de sesión fallido desde {0}", "DeviceOnlineWithName": "{0} está conectado", "DeviceOfflineWithName": "{0} se ha desconectado", "ChapterNameValue": "Capítulo {0}", @@ -114,10 +114,10 @@ "Application": "Aplicación", "AppDeviceValues": "App: {0}, Dispositivo: {1}", "TaskCleanActivityLogDescription": "Elimina las entradas del registro de actividad anteriores al periodo configurado.", - "TaskCleanActivityLog": "Limpiar Registro de Actividades", + "TaskCleanActivityLog": "Limpiar registro de actividades", "Undefined": "Sin definir", "Forced": "Forzado", - "Default": "Por Defecto", - "TaskOptimizeDatabaseDescription": "Compacta la base de datos y restaura el espacio libre. Ejecutar esta tarea después de actualizar las librerías o realizar otros cambios que impliquen modificar las bases de datos puede mejorar la performance.", - "TaskOptimizeDatabase": "Optimización de base de datos" + "Default": "Por defecto", + "TaskOptimizeDatabaseDescription": "Compacta la base de datos y libera espacio. Ejecutar esta tarea después de escanear la biblioteca o hacer otros cambios que impliquen modificaciones en la base de datos puede mejorar el rendimiento.", + "TaskOptimizeDatabase": "Optimizar base de datos" } diff --git a/Emby.Server.Implementations/Localization/Core/et.json b/Emby.Server.Implementations/Localization/Core/et.json new file mode 100644 index 000000000..8db6a0b38 --- /dev/null +++ b/Emby.Server.Implementations/Localization/Core/et.json @@ -0,0 +1,123 @@ +{ + "TaskCleanActivityLogDescription": "Kustutab määratud ajast vanemad tegevuslogi kirjed.", + "UserDownloadingItemWithValues": "{0} laeb alla {1}", + "HeaderRecordingGroups": "Salvestusrühmad", + "TaskOptimizeDatabaseDescription": "Tihendab ja puhastab andmebaasi. Selle toimingu tegemine pärast meediakogu andmebaasiga seotud muudatuste skannimist võib jõudlust parandada.", + "TaskOptimizeDatabase": "Optimeeri andmebaasi", + "TaskDownloadMissingSubtitlesDescription": "Otsib veebist puuduvaid subtiitreid vastavalt määratud metaandmete seadetele.", + "TaskDownloadMissingSubtitles": "Laadi alla puuduvad subtiitrid", + "TaskRefreshChannelsDescription": "Värskendab veebikanalite teavet.", + "TaskRefreshChannels": "Värskenda kanaleid", + "TaskCleanTranscodeDescription": "Kustutab üle ühe päeva vanused transkodeerimisfailid.", + "TaskCleanTranscode": "Puhasta transkoodimise kataloog", + "TaskUpdatePluginsDescription": "Laadib alla ja paigaldab nende pluginate uuendused, mis on seadistatud automaatselt uuenduma.", + "TaskUpdatePlugins": "Uuenda pluginaid", + "TaskRefreshPeopleDescription": "Värskendab meediakogus näitlejate ja režissööride metaandmeid.", + "TaskRefreshPeople": "Värskenda inimesi", + "TaskCleanLogsDescription": "Kustutab logifailid, mis on vanemad kui {0} päeva.", + "TaskCleanLogs": "Puhasta logikataloog", + "TaskRefreshLibraryDescription": "Otsib meedikogust uusi faile ja värskendab metaandmeid.", + "Collections": "Kogumikud", + "TaskRefreshLibrary": "Skaneeri meediakogu", + "TaskRefreshChapterImagesDescription": "Loob peatükkidega videote jaoks pisipildid.", + "TaskRefreshChapterImages": "Eralda peatükipildid", + "TaskCleanCacheDescription": "Kustutab vahemälufailid, mida süsteem enam ei vaja.", + "TaskCleanCache": "Puhasta vahemälu kataloog", + "TaskCleanActivityLog": "Puhasta tegevuslogi", + "TasksChannelsCategory": "Veebikanalid", + "TasksApplicationCategory": "Rakendus", + "TasksLibraryCategory": "Meediakogu", + "TasksMaintenanceCategory": "Hooldus", + "VersionNumber": "Versioon {0}", + "ValueSpecialEpisodeName": "Eriepisood - {0}", + "ValueHasBeenAddedToLibrary": "{0} lisati meediakogusse", + "UserStartedPlayingItemWithValues": "{0} taasesitab {1} serveris {2}", + "UserPasswordChangedWithName": "Kasutaja {0} parool muudeti", + "UserLockedOutWithName": "Kasutaja {0} lukustati", + "UserDeletedWithName": "Kasutaja {0} kustutati", + "UserCreatedWithName": "Kasutaja {0} on loodud", + "ScheduledTaskStartedWithName": "{0} käivitati", + "ProviderValue": "Allikas: {0}", + "StartupEmbyServerIsLoading": "Jellyfin server laadib. Proovi varsti uuesti.", + "User": "Kasutaja", + "Undefined": "Määratlemata", + "TvShows": "Seriaalid", + "System": "Süsteem", + "Sync": "Sünkrooni", + "Songs": "Laulud", + "Shows": "Sarjad", + "ServerNameNeedsToBeRestarted": "{0} tuleb taaskäivitada", + "ScheduledTaskFailedWithName": "{0} nurjus", + "PluginUpdatedWithName": "{0} uuendati", + "PluginUninstalledWithName": "{0} eemaldati", + "PluginInstalledWithName": "{0} paigaldati", + "Plugin": "Plugin", + "Playlists": "Pleilistid", + "Photos": "Fotod", + "NotificationOptionVideoPlaybackStopped": "Video taasesitus lõppes", + "NotificationOptionVideoPlayback": "Video taasesitus algas", + "NotificationOptionUserLockedOut": "Kasutaja lukustati", + "NotificationOptionTaskFailed": "Ajastatud ülesanne nurjus", + "NotificationOptionServerRestartRequired": "Vajalik on serveri taaskäivitamine", + "NotificationOptionPluginUpdateInstalled": "Paigaldati plugina uuendus", + "NotificationOptionPluginUninstalled": "Plugin eemaldati", + "NotificationOptionPluginInstalled": "Plugin paigaldati", + "NotificationOptionPluginError": "Plugina tõrge", + "NotificationOptionNewLibraryContent": "Lisati uut sisu", + "NotificationOptionInstallationFailed": "Paigaldamine nurjus", + "NotificationOptionCameraImageUploaded": "Kaamera pilt on üles laaditud", + "NotificationOptionAudioPlaybackStopped": "Heli taasesitus lõppes", + "NotificationOptionAudioPlayback": "Heli taasesitus algas", + "NotificationOptionApplicationUpdateInstalled": "Rakenduse uuendus paigaldati", + "NotificationOptionApplicationUpdateAvailable": "Rakenduse uuendus on saadaval", + "NewVersionIsAvailable": "Jellyfin serveri uus versioon on allalaadimiseks saadaval.", + "NameSeasonUnknown": "Tundmatu hooaeg", + "NameSeasonNumber": "Hooaeg {0}", + "NameInstallFailed": "{0} paigaldamine nurjus", + "MusicVideos": "Muusikavideod", + "Music": "Muusika", + "Movies": "Filmid", + "MixedContent": "Segatud sisu", + "MessageServerConfigurationUpdated": "Serveri seadistust uuendati", + "MessageNamedServerConfigurationUpdatedWithValue": "Serveri seadistusosa {0} uuendati", + "MessageApplicationUpdatedTo": "Jellyfin server uuendati versioonile {0}", + "MessageApplicationUpdated": "Jellyfin server uuendati", + "Latest": "Uusimad", + "LabelRunningTimeValue": "Kestus: {0}", + "LabelIpAddressValue": "IP aadress: {0}", + "ItemRemovedWithName": "{0} eemaldati meediakogust", + "ItemAddedWithName": "{0} lisati meediakogusse", + "Inherit": "Päri", + "HomeVideos": "Koduvideod", + "HeaderNextUp": "Järgmisena", + "HeaderLiveTV": "Otse TV", + "HeaderFavoriteSongs": "Lemmiklood", + "HeaderFavoriteShows": "Lemmikseriaalid", + "HeaderFavoriteEpisodes": "Lemmikepisoodid", + "HeaderFavoriteArtists": "Lemmikesitajad", + "HeaderFavoriteAlbums": "Lemmikalbumid", + "HeaderContinueWatching": "Jätka vaatamist", + "HeaderAlbumArtists": "Albumi esitajad", + "Genres": "Žanrid", + "Forced": "Sunnitud", + "Folders": "Kaustad", + "Favorites": "Lemmikud", + "FailedLoginAttemptWithUserName": "{0} - sisselogimine nurjus", + "DeviceOnlineWithName": "{0} on ühendatud", + "DeviceOfflineWithName": "{0} katkestas ühenduse", + "Default": "Vaikimisi", + "ChapterNameValue": "Peatükk {0}", + "Channels": "Kanalid", + "CameraImageUploadedFrom": "Uus kaamera pilt laaditi üles allikalt {0}", + "Books": "Raamatud", + "AuthenticationSucceededWithUserName": "{0} autentimine õnnestus", + "Artists": "Esitajad", + "Application": "Rakendus", + "AppDeviceValues": "Rakendus: {0}, seade: {1}", + "Albums": "Albumid", + "UserOfflineFromDevice": "{0} katkestas ühenduse seadmega {1}", + "SubtitleDownloadFailureFromForItem": "Subtiitrite allalaadimine {0} > {1} nurjus", + "UserPolicyUpdatedWithName": "Kasutaja {0} õigusi värskendati", + "UserStoppedPlayingItemWithValues": "{0} lõpetas {1} taasesituse seadmes {2}", + "UserOnlineFromDevice": "{0} on ühendatud seadmest {1}" +} diff --git a/Emby.Server.Implementations/Localization/Core/fa.json b/Emby.Server.Implementations/Localization/Core/fa.json index 8ab657e5b..6960ff007 100644 --- a/Emby.Server.Implementations/Localization/Core/fa.json +++ b/Emby.Server.Implementations/Localization/Core/fa.json @@ -6,7 +6,7 @@ "AuthenticationSucceededWithUserName": "{0} با موفقیت تایید اعتبار شد", "Books": "کتاب‌ها", "CameraImageUploadedFrom": "یک عکس جدید از دوربین ارسال شده است {0}", - "Channels": "کانال‌ها", + "Channels": "کانالها", "ChapterNameValue": "قسمت {0}", "Collections": "مجموعه‌ها", "DeviceOfflineWithName": "ارتباط {0} قطع شد", @@ -37,7 +37,7 @@ "MessageNamedServerConfigurationUpdatedWithValue": "پکربندی بخش {0} سرور بروزرسانی شد", "MessageServerConfigurationUpdated": "پیکربندی سرور بروزرسانی شد", "MixedContent": "محتوای مخلوط", - "Movies": "فیلم‌ها", + "Movies": "فیلم ها", "Music": "موسیقی", "MusicVideos": "موزیک ویدیوها", "NameInstallFailed": "{0} نصب با مشکل مواجه شد", @@ -118,5 +118,6 @@ "Default": "پیشفرض", "TaskCleanActivityLogDescription": "ورودی‌های قدیمی‌تر از سن تنظیم شده در سیاهه فعالیت را حذف می‌کند.", "TaskCleanActivityLog": "پاکسازی سیاهه فعالیت", - "Undefined": "تعریف نشده" + "Undefined": "تعریف نشده", + "TaskOptimizeDatabase": "بهینه سازی پایگاه داده" } diff --git a/Emby.Server.Implementations/Localization/Core/fil.json b/Emby.Server.Implementations/Localization/Core/fil.json index f18a1c030..99839ae6e 100644 --- a/Emby.Server.Implementations/Localization/Core/fil.json +++ b/Emby.Server.Implementations/Localization/Core/fil.json @@ -117,5 +117,7 @@ "TaskCleanActivityLogDescription": "Tanggalin ang mga tala ng aktibidad na mas luma sa nakatakda na edad.", "Default": "Default", "Undefined": "Hindi tiyak", - "Forced": "Sapilitan" + "Forced": "Sapilitan", + "TaskOptimizeDatabaseDescription": "Iko-compact ang database at ita-truncate ang free space. Ang pagpapatakbo ng gawaing ito pagkatapos ng pag-scan sa library o paggawa ng iba pang mga pagbabago na nagpapahiwatig ng mga pagbabago sa database ay maaaring magpa-improve ng performance.", + "TaskOptimizeDatabase": "I-optimize ang database" } diff --git a/Emby.Server.Implementations/Localization/Core/fr-CA.json b/Emby.Server.Implementations/Localization/Core/fr-CA.json index 3c51d64e0..2a56d0745 100644 --- a/Emby.Server.Implementations/Localization/Core/fr-CA.json +++ b/Emby.Server.Implementations/Localization/Core/fr-CA.json @@ -118,5 +118,7 @@ "TaskCleanActivityLogDescription": "Éfface les entrées du journal plus anciennes que l'âge configuré.", "TaskCleanActivityLog": "Nettoyer le journal d'activité", "Undefined": "Indéfini", - "Forced": "Forcé" + "Forced": "Forcé", + "TaskOptimizeDatabaseDescription": "Compacte la base de données et tronque l'espace libre. Lancer cette tâche après avoir scanné la bibliothèque ou faire d'autres changements impliquant des modifications de la base peuvent ameliorer les performances.", + "TaskOptimizeDatabase": "Optimiser la base de données" } diff --git a/Emby.Server.Implementations/Localization/Core/fr.json b/Emby.Server.Implementations/Localization/Core/fr.json index 0e4c38425..bfafe7650 100644 --- a/Emby.Server.Implementations/Localization/Core/fr.json +++ b/Emby.Server.Implementations/Localization/Core/fr.json @@ -1,7 +1,7 @@ { "Albums": "Albums", "AppDeviceValues": "Application : {0}, Appareil : {1}", - "Application": "Application", + "Application": "Applications", "Artists": "Artistes", "AuthenticationSucceededWithUserName": "{0} authentifié avec succès", "Books": "Livres", @@ -15,8 +15,8 @@ "Favorites": "Favoris", "Folders": "Dossiers", "Genres": "Genres", - "HeaderAlbumArtists": "Artistes de l'album", - "HeaderContinueWatching": "Continuer à regarder", + "HeaderAlbumArtists": "Artistes d'album", + "HeaderContinueWatching": "Reprendre le visionnage", "HeaderFavoriteAlbums": "Albums favoris", "HeaderFavoriteArtists": "Artistes préférés", "HeaderFavoriteEpisodes": "Épisodes favoris", @@ -77,7 +77,7 @@ "SubtitleDownloadFailureFromForItem": "Échec du téléchargement des sous-titres depuis {0} pour {1}", "Sync": "Synchroniser", "System": "Système", - "TvShows": "Séries Télé", + "TvShows": "Séries TV", "User": "Utilisateur", "UserCreatedWithName": "L'utilisateur {0} a été créé", "UserDeletedWithName": "L'utilisateur {0} a été supprimé", @@ -105,8 +105,8 @@ "TaskRefreshPeople": "Rafraîchir les acteurs", "TaskCleanLogsDescription": "Supprime les journaux de plus de {0} jours.", "TaskCleanLogs": "Nettoyer le répertoire des journaux", - "TaskRefreshLibraryDescription": "Scanne toute les bibliothèques pour trouver les nouveaux fichiers et rafraîchit les métadonnées.", - "TaskRefreshLibrary": "Scanner toutes les Bibliothèques", + "TaskRefreshLibraryDescription": "Scanne votre médiathèque pour trouver les nouveaux fichiers et rafraîchit les métadonnées.", + "TaskRefreshLibrary": "Scanner la médiathèque", "TaskRefreshChapterImagesDescription": "Crée des vignettes pour les vidéos ayant des chapitres.", "TaskRefreshChapterImages": "Extraire les images de chapitre", "TaskCleanCacheDescription": "Supprime les fichiers de cache dont le système n'a plus besoin.", diff --git a/Emby.Server.Implementations/Localization/Core/gl.json b/Emby.Server.Implementations/Localization/Core/gl.json index afb22ab47..b433c6f68 100644 --- a/Emby.Server.Implementations/Localization/Core/gl.json +++ b/Emby.Server.Implementations/Localization/Core/gl.json @@ -48,7 +48,7 @@ "HeaderFavoriteArtists": "Artistas Favoritos", "HeaderFavoriteAlbums": "Álbunes Favoritos", "HeaderContinueWatching": "Seguir mirando", - "HeaderAlbumArtists": "Artistas de Album", + "HeaderAlbumArtists": "Artistas do Album", "Genres": "Xéneros", "Forced": "Forzado", "Folders": "Cartafoles", @@ -117,5 +117,7 @@ "UserPolicyUpdatedWithName": "A política de usuario foi actualizada para {0}", "UserPasswordChangedWithName": "Cambiouse o contrasinal para o usuario {0}", "UserOnlineFromDevice": "{0} está en liña desde {1}", - "UserOfflineFromDevice": "{0} desconectouse desde {1}" + "UserOfflineFromDevice": "{0} desconectouse desde {1}", + "TaskOptimizeDatabaseDescription": "Compacta e libera o espazo libre da base de datos. Executar esta tarefa logo de realizar mudanzas que impliquen modificacións da base de datos ou despois de escanear a biblioteca pode traer mellorías de desempeño.", + "TaskOptimizeDatabase": "Optimizar base de datos" } diff --git a/Emby.Server.Implementations/Localization/Core/gsw.json b/Emby.Server.Implementations/Localization/Core/gsw.json index 3364ee333..5bfe8c0b1 100644 --- a/Emby.Server.Implementations/Localization/Core/gsw.json +++ b/Emby.Server.Implementations/Localization/Core/gsw.json @@ -118,5 +118,6 @@ "TaskCleanActivityLog": "Aktivitätsprotokoll aufräumen", "Undefined": "Undefiniert", "Forced": "Erzwungen", - "Default": "Standard" + "Default": "Standard", + "TaskOptimizeDatabase": "Datenbank optimieren" } diff --git a/Emby.Server.Implementations/Localization/Core/he.json b/Emby.Server.Implementations/Localization/Core/he.json index 981e8a06e..e32ab4ca8 100644 --- a/Emby.Server.Implementations/Localization/Core/he.json +++ b/Emby.Server.Implementations/Localization/Core/he.json @@ -118,5 +118,7 @@ "TaskCleanActivityLog": "נקה רשומת פעילות", "Undefined": "לא מוגדר", "Forced": "כפוי", - "Default": "ברירת מחדל" + "Default": "ברירת מחדל", + "TaskOptimizeDatabase": "מיטוב מסד נתונים", + "TaskOptimizeDatabaseDescription": "דוחס את מסד הנתונים ומוריד את שטח האחסון שבשימוש. הרצה של פעולה זו לאחר סריקת הספרייה או שינויים אחרים שמשפיעים על מסד הנתונים יכולה לשפר ביצועים." } diff --git a/Emby.Server.Implementations/Localization/Core/hi.json b/Emby.Server.Implementations/Localization/Core/hi.json index 82dc601bc..85de5925e 100644 --- a/Emby.Server.Implementations/Localization/Core/hi.json +++ b/Emby.Server.Implementations/Localization/Core/hi.json @@ -9,12 +9,12 @@ "HeaderFavoriteArtists": "पसंदीदा कलाकारसमूह", "HeaderFavoriteAlbums": "पसंदीदा एलबम्स", "HeaderContinueWatching": "देखते रहिए", - "HeaderAlbumArtists": "एल्बम कलकरसमुह", + "HeaderAlbumArtists": "एल्बम कलाकार", "Genres": "शैली", "Forced": "बलपूर्वक", "Folders": "फोल्डेरें", "Favorites": "पसंदीदा", - "FailedLoginAttemptWithUserName": "{0} से लॉगिन असफल हुआ है", + "FailedLoginAttemptWithUserName": "लॉगिन असफल हुआ, पुनः {0} से प्रयास करें", "DeviceOnlineWithName": "{0} से संयोग हो गया है", "DeviceOfflineWithName": "{0} से संयोग विच्छिन्न हो गया है", "Default": "प्राथमिक", @@ -51,7 +51,7 @@ "Latest": "सबसे नया", "LabelIpAddressValue": "आई पी एड्रेस: {0}", "ItemRemovedWithName": "{0} लाइब्रेरी में से निकाल दिया है", - "HomeVideos": "होम वीडियोस", + "HomeVideos": "होम चलचित्र", "NotificationOptionVideoPlayback": "वीडियो प्लेबैक शुरू हुआ", "NotificationOptionUserLockedOut": "उपयोगकर्ता लॉक हो गया", "NotificationOptionTaskFailed": "निर्धारित कार्य विफलता", @@ -60,5 +60,6 @@ "NotificationOptionNewLibraryContent": "नई सामग्री जोड़ी गई", "LabelRunningTimeValue": "चलने का समय: {0}", "ItemAddedWithName": "{0} को लाइब्रेरी में जोड़ा गया", - "Inherit": "इनहेरिट" + "Inherit": "इनहेरिट", + "NotificationOptionVideoPlaybackStopped": "चलचित्र रुका हुआ" } diff --git a/Emby.Server.Implementations/Localization/Core/hr.json b/Emby.Server.Implementations/Localization/Core/hr.json index 9eb80b83b..4df0444e6 100644 --- a/Emby.Server.Implementations/Localization/Core/hr.json +++ b/Emby.Server.Implementations/Localization/Core/hr.json @@ -15,7 +15,7 @@ "Favorites": "Favoriti", "Folders": "Mape", "Genres": "Žanrovi", - "HeaderAlbumArtists": "Izvođači na albumu", + "HeaderAlbumArtists": "Izvođači albuma", "HeaderContinueWatching": "Nastavi gledati", "HeaderFavoriteAlbums": "Omiljeni albumi", "HeaderFavoriteArtists": "Omiljeni izvođači", diff --git a/Emby.Server.Implementations/Localization/Core/hu.json b/Emby.Server.Implementations/Localization/Core/hu.json index 85ab1511a..acde84aaf 100644 --- a/Emby.Server.Implementations/Localization/Core/hu.json +++ b/Emby.Server.Implementations/Localization/Core/hu.json @@ -11,11 +11,11 @@ "Collections": "Gyűjtemények", "DeviceOfflineWithName": "{0} kijelentkezett", "DeviceOnlineWithName": "{0} belépett", - "FailedLoginAttemptWithUserName": "Sikertelen bejelentkezési kísérlet tőle: {0}", + "FailedLoginAttemptWithUserName": "Sikertelen bejelentkezési kísérlet innen: {0}", "Favorites": "Kedvencek", "Folders": "Könyvtárak", "Genres": "Műfajok", - "HeaderAlbumArtists": "Előadó albumai", + "HeaderAlbumArtists": "Album előadó(k)", "HeaderContinueWatching": "Megtekintés folytatása", "HeaderFavoriteAlbums": "Kedvenc albumok", "HeaderFavoriteArtists": "Kedvenc előadók", diff --git a/Emby.Server.Implementations/Localization/Core/id.json b/Emby.Server.Implementations/Localization/Core/id.json index ba3513870..37d59abd9 100644 --- a/Emby.Server.Implementations/Localization/Core/id.json +++ b/Emby.Server.Implementations/Localization/Core/id.json @@ -7,10 +7,10 @@ "MessageApplicationUpdated": "Jellyfin Server sudah diperbarui", "Latest": "Terbaru", "LabelIpAddressValue": "Alamat IP: {0}", - "ItemRemovedWithName": "{0} sudah dikeluarkan dari pustaka", + "ItemRemovedWithName": "{0} sudah dihapus dari pustaka", "ItemAddedWithName": "{0} telah dimasukkan ke dalam pustaka", - "Inherit": "Warisan", - "HomeVideos": "Video Rumah", + "Inherit": "Warisi", + "HomeVideos": "Video Rumahan", "HeaderRecordingGroups": "Grup Rekaman", "HeaderNextUp": "Selanjutnya", "HeaderLiveTV": "TV Live", @@ -73,7 +73,7 @@ "NotificationOptionCameraImageUploaded": "Gambar kamera terunggah", "NotificationOptionApplicationUpdateInstalled": "Pembaruan aplikasi terpasang", "NotificationOptionApplicationUpdateAvailable": "Pembaruan aplikasi tersedia", - "NewVersionIsAvailable": "Versi baru dari Jellyfin Server tersedia untuk diunduh.", + "NewVersionIsAvailable": "Versi baru dari Jellyfin Server sudah tersedia untuk diunduh.", "NameSeasonUnknown": "Musim tak diketahui", "NameSeasonNumber": "Musim {0}", "NameInstallFailed": "{0} penginstalan gagal", @@ -117,5 +117,7 @@ "TaskCleanActivityLog": "Bersihkan Log Aktivitas", "Undefined": "Tidak terdefinisi", "Forced": "Dipaksa", - "Default": "Bawaan" + "Default": "Bawaan", + "TaskOptimizeDatabaseDescription": "Rapihkan basis data dan membersihkan ruang kosong. Menjalankan tugas ini setelah memindai pustaka atau melakukan perubahan lain yang menyiratkan modifikasi basis data dapat meningkatkan kinerja.", + "TaskOptimizeDatabase": "Optimalkan basis data" } diff --git a/Emby.Server.Implementations/Localization/Core/it.json b/Emby.Server.Implementations/Localization/Core/it.json index 5e28cf09f..4c4de4999 100644 --- a/Emby.Server.Implementations/Localization/Core/it.json +++ b/Emby.Server.Implementations/Localization/Core/it.json @@ -15,7 +15,7 @@ "Favorites": "Preferiti", "Folders": "Cartelle", "Genres": "Generi", - "HeaderAlbumArtists": "Artisti dell'Album", + "HeaderAlbumArtists": "Artisti dell'album", "HeaderContinueWatching": "Continua a guardare", "HeaderFavoriteAlbums": "Album Preferiti", "HeaderFavoriteArtists": "Artisti Preferiti", diff --git a/Emby.Server.Implementations/Localization/Core/ja.json b/Emby.Server.Implementations/Localization/Core/ja.json index c689bc58a..2588f1e8c 100644 --- a/Emby.Server.Implementations/Localization/Core/ja.json +++ b/Emby.Server.Implementations/Localization/Core/ja.json @@ -11,12 +11,12 @@ "Collections": "コレクション", "DeviceOfflineWithName": "{0} が切断されました", "DeviceOnlineWithName": "{0} が接続されました", - "FailedLoginAttemptWithUserName": "ログインを試行しましたが {0}によって失敗しました", + "FailedLoginAttemptWithUserName": "ログインを試行しましたが {0} によって失敗しました", "Favorites": "お気に入り", "Folders": "フォルダー", "Genres": "ジャンル", - "HeaderAlbumArtists": "アーティストのアルバム", - "HeaderContinueWatching": "視聴を続ける", + "HeaderAlbumArtists": "アルバムアーティスト", + "HeaderContinueWatching": "続きを見る", "HeaderFavoriteAlbums": "お気に入りのアルバム", "HeaderFavoriteArtists": "お気に入りのアーティスト", "HeaderFavoriteEpisodes": "お気に入りのエピソード", diff --git a/Emby.Server.Implementations/Localization/Core/kk.json b/Emby.Server.Implementations/Localization/Core/kk.json index d28564a7c..1b4a18deb 100644 --- a/Emby.Server.Implementations/Localization/Core/kk.json +++ b/Emby.Server.Implementations/Localization/Core/kk.json @@ -15,7 +15,7 @@ "Favorites": "Tañdaulylar", "Folders": "Qaltalar", "Genres": "Janrlar", - "HeaderAlbumArtists": "Oryndauşynyñ älbomy", + "HeaderAlbumArtists": "Älbom oryndauşylary", "HeaderContinueWatching": "Qaraudy jalğastyru", "HeaderFavoriteAlbums": "Tañdauly älbomdar", "HeaderFavoriteArtists": "Tañdauly oryndauşylar", diff --git a/Emby.Server.Implementations/Localization/Core/ko.json b/Emby.Server.Implementations/Localization/Core/ko.json index a37de0748..50d019f90 100644 --- a/Emby.Server.Implementations/Localization/Core/ko.json +++ b/Emby.Server.Implementations/Localization/Core/ko.json @@ -15,7 +15,7 @@ "Favorites": "즐겨찾기", "Folders": "폴더", "Genres": "장르", - "HeaderAlbumArtists": "아티스트의 앨범", + "HeaderAlbumArtists": "앨범 음악가", "HeaderContinueWatching": "계속 시청하기", "HeaderFavoriteAlbums": "즐겨찾는 앨범", "HeaderFavoriteArtists": "즐겨찾는 아티스트", diff --git a/Emby.Server.Implementations/Localization/Core/lt-LT.json b/Emby.Server.Implementations/Localization/Core/lt-LT.json index f3a131d40..881cd4a93 100644 --- a/Emby.Server.Implementations/Localization/Core/lt-LT.json +++ b/Emby.Server.Implementations/Localization/Core/lt-LT.json @@ -1,7 +1,7 @@ { "Albums": "Albumai", "AppDeviceValues": "Programa: {0}, Įrenginys: {1}", - "Application": "Programa", + "Application": "Programėlė", "Artists": "Atlikėjai", "AuthenticationSucceededWithUserName": "{0} sėkmingai autentifikuota", "Books": "Knygos", @@ -118,5 +118,6 @@ "Undefined": "Neapibrėžtas", "Forced": "Priverstas", "Default": "Numatytas", - "TaskCleanActivityLogDescription": "Ištrina veiklos žuranlo įrašus, kurie yra senesni nei nustatytas amžius." + "TaskCleanActivityLogDescription": "Ištrina veiklos žuranlo įrašus, kurie yra senesni nei nustatytas amžius.", + "TaskOptimizeDatabase": "Optimizuoti duomenų bazės" } diff --git a/Emby.Server.Implementations/Localization/Core/mk.json b/Emby.Server.Implementations/Localization/Core/mk.json index b780ef498..279734c5e 100644 --- a/Emby.Server.Implementations/Localization/Core/mk.json +++ b/Emby.Server.Implementations/Localization/Core/mk.json @@ -5,7 +5,7 @@ "PluginUninstalledWithName": "{0} беше успешно деинсталирано", "PluginInstalledWithName": "{0} беше успешно инсталирано", "Plugin": "Додатоци", - "Playlists": "Листи", + "Playlists": "Плејлисти", "Photos": "Слики", "NotificationOptionVideoPlaybackStopped": "Видео стопирано", "NotificationOptionVideoPlayback": "Видео пуштено", @@ -50,7 +50,7 @@ "HeaderFavoriteEpisodes": "Омилени Епизоди", "HeaderFavoriteArtists": "Омилени Изведувачи", "HeaderFavoriteAlbums": "Омилени Албуми", - "HeaderContinueWatching": "Продолжи со гледање", + "HeaderContinueWatching": "Продолжи со Гледање", "HeaderAlbumArtists": "Изведувачи од Албуми", "Genres": "Жанрови", "Folders": "Папки", @@ -97,5 +97,8 @@ "TasksChannelsCategory": "Интернет Канали", "TasksApplicationCategory": "Апликација", "TasksLibraryCategory": "Библиотека", - "TasksMaintenanceCategory": "Одржување" + "TasksMaintenanceCategory": "Одржување", + "Undefined": "Недефинирано", + "Forced": "Принудно", + "Default": "Зададено" } diff --git a/Emby.Server.Implementations/Localization/Core/ml.json b/Emby.Server.Implementations/Localization/Core/ml.json index 09ef34913..acc7746c1 100644 --- a/Emby.Server.Implementations/Localization/Core/ml.json +++ b/Emby.Server.Implementations/Localization/Core/ml.json @@ -6,7 +6,7 @@ "ChapterNameValue": "അധ്യായം {0}", "DeviceOfflineWithName": "{0} വിച്ഛേദിച്ചു", "DeviceOnlineWithName": "{0} ബന്ധിപ്പിച്ചു", - "FailedLoginAttemptWithUserName": "Log 0 from എന്നതിൽ നിന്നുള്ള പ്രവേശന ശ്രമം പരാജയപ്പെട്ടു", + "FailedLoginAttemptWithUserName": "{0} - എന്നതിൽ നിന്നുള്ള പ്രവേശന ശ്രമം പരാജയപ്പെട്ടു", "Forced": "നിർബന്ധിച്ചു", "HeaderFavoriteAlbums": "പ്രിയപ്പെട്ട ആൽബങ്ങൾ", "HeaderFavoriteArtists": "പ്രിയപ്പെട്ട കലാകാരന്മാർ", diff --git a/Emby.Server.Implementations/Localization/Core/mn.json b/Emby.Server.Implementations/Localization/Core/mn.json new file mode 100644 index 000000000..7421d42fb --- /dev/null +++ b/Emby.Server.Implementations/Localization/Core/mn.json @@ -0,0 +1,14 @@ +{ + "Books": "Номууд", + "HeaderNextUp": "Дараах", + "HeaderContinueWatching": "Үргэлжлүүлэн үзэх", + "Songs": "Дуунууд", + "Playlists": "Тоглуулах жагсаалт", + "Movies": "Кино", + "Latest": "Сүүлийн үеийн", + "Genres": "Төрөл зүйл", + "Favorites": "Дуртай", + "Collections": "Багц", + "Artists": "Зураачуд", + "Albums": "Цомгууд" +} diff --git a/Emby.Server.Implementations/Localization/Core/ms.json b/Emby.Server.Implementations/Localization/Core/ms.json index b2dcf270c..deb28970c 100644 --- a/Emby.Server.Implementations/Localization/Core/ms.json +++ b/Emby.Server.Implementations/Localization/Core/ms.json @@ -2,7 +2,7 @@ "Albums": "Album-album", "AppDeviceValues": "Apl: {0}, Peranti: {1}", "Application": "Aplikasi", - "Artists": "Artis", + "Artists": "Artis-artis", "AuthenticationSucceededWithUserName": "{0} berjaya disahkan", "Books": "Buku-buku", "CameraImageUploadedFrom": "Gambar baharu telah dimuat naik melalui {0}", @@ -37,9 +37,9 @@ "MessageNamedServerConfigurationUpdatedWithValue": "Konfigurasi pelayan di bahagian {0} telah dikemas kini", "MessageServerConfigurationUpdated": "Konfigurasi pelayan telah dikemas kini", "MixedContent": "Kandungan campuran", - "Movies": "Filem", + "Movies": "Filem-filem", "Music": "Muzik", - "MusicVideos": "Muzik video", + "MusicVideos": "Video muzik", "NameInstallFailed": "{0} pemasangan gagal", "NameSeasonNumber": "Musim {0}", "NameSeasonUnknown": "Musim Tidak Diketahui", @@ -53,43 +53,43 @@ "NotificationOptionNewLibraryContent": "Kandungan baru telah ditambah", "NotificationOptionPluginError": "Kegagalan plugin", "NotificationOptionPluginInstalled": "Plugin telah dipasang", - "NotificationOptionPluginUninstalled": "Plugin uninstalled", - "NotificationOptionPluginUpdateInstalled": "Plugin update installed", - "NotificationOptionServerRestartRequired": "Server restart required", - "NotificationOptionTaskFailed": "Scheduled task failure", - "NotificationOptionUserLockedOut": "User locked out", - "NotificationOptionVideoPlayback": "Video playback started", + "NotificationOptionPluginUninstalled": "Plugin telah dinyahpasang", + "NotificationOptionPluginUpdateInstalled": "Kemaskini plugin telah dipasang", + "NotificationOptionServerRestartRequired": "", + "NotificationOptionTaskFailed": "Kegagalan tugas berjadual", + "NotificationOptionUserLockedOut": "Pengguna telah dikunci", + "NotificationOptionVideoPlayback": "Ulangmain video bermula", "NotificationOptionVideoPlaybackStopped": "Ulangmain video dihentikan", "Photos": "Gambar-gambar", "Playlists": "Senarai main", "Plugin": "Plugin", - "PluginInstalledWithName": "{0} was installed", - "PluginUninstalledWithName": "{0} was uninstalled", - "PluginUpdatedWithName": "{0} was updated", - "ProviderValue": "Provider: {0}", + "PluginInstalledWithName": "{0} telah dipasang", + "PluginUninstalledWithName": "{0} telah dinyahpasang", + "PluginUpdatedWithName": "{0} telah dikemaskini", + "ProviderValue": "Pembekal: {0}", "ScheduledTaskFailedWithName": "{0} gagal", "ScheduledTaskStartedWithName": "{0} bermula", - "ServerNameNeedsToBeRestarted": "{0} needs to be restarted", - "Shows": "Series", + "ServerNameNeedsToBeRestarted": "{0} perlu di ulangmula", + "Shows": "Tayangan", "Songs": "Lagu-lagu", "StartupEmbyServerIsLoading": "Pelayan Jellyfin sedang dimuatkan. Sila cuba sebentar lagi.", "SubtitleDownloadFailureForItem": "Subtitles failed to download for {0}", "SubtitleDownloadFailureFromForItem": "Muat turun sarikata gagal dari {0} untuk {1}", - "Sync": "Sync", + "Sync": "Segerak", "System": "Sistem", - "TvShows": "TV Shows", - "User": "User", - "UserCreatedWithName": "User {0} has been created", - "UserDeletedWithName": "User {0} has been deleted", - "UserDownloadingItemWithValues": "{0} is downloading {1}", + "TvShows": "Tayangan TV", + "User": "Pengguna", + "UserCreatedWithName": "Pengguna {0} telah diwujudkan", + "UserDeletedWithName": "Pengguna {0} telah dipadamkan", + "UserDownloadingItemWithValues": "{0} sedang memuat turun {1}", "UserLockedOutWithName": "Pengguna {0} telah dikunci", "UserOfflineFromDevice": "{0} telah terputus dari {1}", "UserOnlineFromDevice": "{0} berada dalam talian dari {1}", "UserPasswordChangedWithName": "Kata laluan telah ditukar bagi pengguna {0}", "UserPolicyUpdatedWithName": "Dasar pengguna telah dikemas kini untuk {0}", - "UserStartedPlayingItemWithValues": "{0} is playing {1} on {2}", - "UserStoppedPlayingItemWithValues": "{0} has finished playing {1} on {2}", - "ValueHasBeenAddedToLibrary": "{0} has been added to your media library", + "UserStartedPlayingItemWithValues": "{0} sedang dimainkan {1} pada {2}", + "UserStoppedPlayingItemWithValues": "{0} telah tamat dimainkan {1} pada {2}", + "ValueHasBeenAddedToLibrary": "{0} telah ditambah ke media library anda", "ValueSpecialEpisodeName": "Khas - {0}", "VersionNumber": "Versi {0}", "TaskCleanActivityLog": "Log Aktiviti Bersih", diff --git a/Emby.Server.Implementations/Localization/Core/my.json b/Emby.Server.Implementations/Localization/Core/my.json new file mode 100644 index 000000000..418376c4e --- /dev/null +++ b/Emby.Server.Implementations/Localization/Core/my.json @@ -0,0 +1,123 @@ +{ + "Default": "ပုံသေ", + "Collections": "စုစည်းမှုများ", + "Channels": "ချန်နယ်များ", + "Books": "စာအုပ်များ", + "Artists": "အနုပညာရှင်များ", + "Albums": "အခွေများ", + "TaskOptimizeDatabaseDescription": "ဒေတာဘေ့စ်ကို ကျစ်လစ်စေပြီး နေရာလွတ်များကို ဖြတ်တောက်ပေးသည်။ စာကြည့်တိုက်ကို စကင်န်ဖတ်ပြီးနောက် ဤလုပ်ငန်းကို လုပ်ဆောင်ခြင်း သို့မဟုတ် ဒေတာဘေ့စ်မွမ်းမံမှုများ စွမ်းဆောင်ရည်ကို မြှင့်တင်ပေးနိုင်သည်ဟု ရည်ညွှန်းသော အခြားပြောင်းလဲမှုများကို လုပ်ဆောင်ခြင်း။.", + "TaskOptimizeDatabase": "ဒေတာဘေ့စ်ကို အကောင်းဆုံးဖြစ်အောင်လုပ်ပါ။", + "TaskDownloadMissingSubtitlesDescription": "မက်တာဒေတာ ဖွဲ့စည်းမှုပုံစံအပေါ် အခြေခံ၍ ပျောက်ဆုံးနေသော စာတန်းထိုးများအတွက် အင်တာနက်ကို ရှာဖွေသည်။", + "TaskDownloadMissingSubtitles": "ပျောက်ဆုံးနေသော စာတန်းထိုးများကို ဒေါင်းလုဒ်လုပ်ပါ။", + "TaskRefreshChannelsDescription": "အင်တာနက်ချန်နယ်အချက်အလက်ကို ပြန်လည်စတင်သည်။", + "TaskRefreshChannels": "ချန်နယ်များကို ပြန်လည်စတင်ပါ။", + "TaskCleanTranscodeDescription": "သက်တမ်း တစ်ရက်ထက်ပိုသော အသွင်ပြောင်းကုဒ်ဖိုင်များကို ဖျက်ပါ။", + "TaskCleanTranscode": "Transcode လမ်းညွှန်ကို သန့်ရှင်းပါ။", + "TaskUpdatePluginsDescription": "အလိုအလျောက် အပ်ဒိတ်လုပ်ရန် စီစဉ်ထားသော ပလပ်အင်များအတွက် အပ်ဒိတ်များကို ဒေါင်းလုဒ်လုပ်ပြီး ထည့်သွင်းပါ။", + "TaskUpdatePlugins": "ပလပ်အင်များကို အပ်ဒိတ်လုပ်ပါ။", + "TaskRefreshPeopleDescription": "သင့်မီဒီယာစာကြည့်တိုက်ရှိ သရုပ်ဆောင်များနှင့် ဒါရိုက်တာများအတွက် မက်တာဒေတာကို အပ်ဒိတ်လုပ်ပါ။", + "TaskRefreshPeople": "လူများကို ပြန်လည်ဆန်းသစ်ပါ။", + "TaskCleanLogsDescription": "{0} ရက်ထက်ပိုသော မှတ်တမ်းဖိုင်များကို ဖျက်သည်။", + "TaskCleanLogs": "မှတ်တမ်းလမ်းညွှန်ကို သန့်ရှင်းပါ။", + "TaskRefreshLibraryDescription": "ဖိုင်အသစ်များအတွက် သင့်မီဒီယာဒစ်ဂျစ်တိုက်ကို စကင်န်ဖတ်ပြီး မက်တာဒေတာကို ပြန်လည်စတင်ပါ။", + "TaskRefreshLibrary": "မီဒီယာစာကြည့်တိုက်ကို စကင်န်ဖတ်ပါ။", + "TaskRefreshChapterImagesDescription": "အခန်းများပါရှိသော ဗီဒီယိုများအတွက် ပုံသေးများကို ဖန်တီးပါ။", + "TaskRefreshChapterImages": "အခန်းပုံများကို ထုတ်ယူပါ။", + "TaskCleanCacheDescription": "စနစ်မှ မလိုအပ်တော့သော ကက်ရှ်ဖိုင်များကို ဖျက်ပါ။.", + "TaskCleanCache": "Cache Directory ကို ရှင်းပါ။", + "TaskCleanActivityLogDescription": "စီစဉ်သတ်မှတ်ထားသော အသက်ထက် ပိုကြီးသော လုပ်ဆောင်ချက်မှတ်တမ်းများကို ဖျက်ပါ။", + "TaskCleanActivityLog": "လုပ်ဆောင်ချက်မှတ်တမ်းကို ရှင်းလင်းပါ။", + "TasksChannelsCategory": "အင်တာနက်ချန်နယ်များ", + "TasksApplicationCategory": "အပလီကေးရှင်း", + "TasksLibraryCategory": "စာကြည့်တိုက်", + "TasksMaintenanceCategory": "ထိန်းသိမ်းခြင်း", + "VersionNumber": "ဗားရှင်း {0}", + "ValueSpecialEpisodeName": "အထူး- {0}", + "ValueHasBeenAddedToLibrary": "{0} ကို သင့်မီဒီယာဒစ်ဂျစ်တိုက်သို့ ပေါင်းထည့်လိုက်ပါပြီ။", + "UserStoppedPlayingItemWithValues": "{0} သည် {1} ကို {2} တွင် ဖွင့်ပြီးပါပြီ", + "UserStartedPlayingItemWithValues": "{0} သည် {1} ကို {2} တွင် ပြသနေသည်", + "UserPolicyUpdatedWithName": "{0} အတွက် အသုံးပြုသူမူဝါဒကို အပ်ဒိတ်လုပ်ပြီးပါပြီ", + "UserPasswordChangedWithName": "အသုံးပြုသူ {0} အတွက် စကားဝှက်ကို ပြောင်းထားသည်", + "UserOnlineFromDevice": "{0} သည် {1} မှ အွန်လိုင်းဖြစ်သည်", + "UserOfflineFromDevice": "{0} သည် {1} မှ ချိတ်ဆက်မှုပြတ်တောက်သွားသည်", + "UserLockedOutWithName": "အသုံးပြုသူ {0} အား လော့ခ်ချထားသည်။", + "UserDownloadingItemWithValues": "{0} သည် {1} ကို ဒေါင်းလုဒ်လုပ်နေသည်", + "UserDeletedWithName": "အသုံးပြုသူ {0} ကို ဖျက်လိုက်ပါပြီ။", + "UserCreatedWithName": "အသုံးပြုသူ {0} ကို ဖန်တီးပြီးပါပြီ။", + "User": "အသုံးပြုသူ", + "Undefined": "သတ်မှတ်မထားသော", + "TvShows": "တီဗီရှိုးများ", + "System": "စနစ်", + "Sync": "ထပ်တူကျသည်။", + "SubtitleDownloadFailureFromForItem": "စာတန်းထိုးများကို {1} အတွက် {0} မှ ဒေါင်းလုဒ်လုပ်၍ မရပါ", + "StartupEmbyServerIsLoading": "Jellyfin ဆာဗာကို ဖွင့်နေပါသည်။ ခဏနေ ထပ်စမ်းကြည့်ပါ။", + "Songs": "သီချင်းများ", + "Shows": "ရှိုးပွဲ", + "ServerNameNeedsToBeRestarted": "{0} ကို ပြန်လည်စတင်ရန် လိုအပ်သည်။", + "ScheduledTaskStartedWithName": "{0} စတင်ခဲ့သည်။", + "ScheduledTaskFailedWithName": "{0} မအောင်မြင်ပါ။", + "ProviderValue": "ဝန်ဆောင်မှုပေးသူ- {0}", + "PluginUpdatedWithName": "{0} ကို အပ်ဒိတ်လုပ်ထားသည်။", + "PluginUninstalledWithName": "{0} ကို ဖြုတ်လိုက်ပါပြီ။", + "PluginInstalledWithName": "{0} ကို ထည့်သွင်းခဲ့သည်။", + "Plugin": "ပလပ်အင်", + "Playlists": "အစီအစဉ်များ", + "Photos": "ဓာတ်ပုံများ", + "NotificationOptionVideoPlaybackStopped": "ဗီဒီယိုပြန်ဖွင့်ခြင်းကို ရပ်သွားသည်။", + "NotificationOptionVideoPlayback": "ဗီဒီယိုဖွင့်ခြင်း စတင်ပါပြီ။", + "NotificationOptionUserLockedOut": "အသုံးပြုသူ ထွက်သွားသည်။", + "NotificationOptionTaskFailed": "စီစဉ်ထားသော အလုပ်ပျက်ကွက်", + "NotificationOptionServerRestartRequired": "ဆာဗာ ပြန်လည်စတင်ရန် လိုအပ်သည်။", + "NotificationOptionPluginUpdateInstalled": "ပလပ်အင် အပ်ဒိတ် ထည့်သွင်းပြီးပါပြီ။", + "NotificationOptionPluginUninstalled": "ပလပ်အင်ကို ဖြုတ်လိုက်ပါပြီ။", + "NotificationOptionPluginInstalled": "ပလပ်အင် ထည့်သွင်းထားသည်။", + "NotificationOptionPluginError": "ပလပ်အင် ချို့ယွင်းခြင်း။", + "NotificationOptionNewLibraryContent": "အကြောင်းအရာအသစ် ထပ်ထည့်ထားပါတယ်။", + "NotificationOptionInstallationFailed": "တပ်ဆင်မှု မအောင်မြင်ပါ။", + "NotificationOptionCameraImageUploaded": "ကင်မရာပုံ အပ်လုဒ်လုပ်ထားသည်။", + "NotificationOptionAudioPlaybackStopped": "အသံပြန်ဖွင့်ခြင်းကို ရပ်သွားသည်။", + "NotificationOptionAudioPlayback": "အသံပြန်ဖွင့်ခြင်း စတင်ပါပြီ။", + "NotificationOptionApplicationUpdateInstalled": "အပလီကေးရှင်း အပ်ဒိတ်ကို ထည့်သွင်းထားသည်။", + "NotificationOptionApplicationUpdateAvailable": "အပလီကေးရှင်း အပ်ဒိတ် ရနိုင်ပါပြီ။", + "NewVersionIsAvailable": "Jellyfin Server ၏ ဗားရှင်းအသစ်ကို ဒေါင်းလုဒ်လုပ်နိုင်ပါသည်။", + "NameSeasonUnknown": "အမည််မသိ ဇာတ်လမ်းတွဲ", + "NameSeasonNumber": "ဇာတ်လမ်းတွဲ {0}", + "NameInstallFailed": "{0} ထည့်သွင်းမှု မအောင်မြင်ပါ။", + "MusicVideos": "ဂီတဗီဒီယိုများ", + "Music": "တေးဂီတ", + "Movies": "ရုပ်ရှင်များ", + "MixedContent": "ရောနှောပါဝင်မှု", + "MessageServerConfigurationUpdated": "ဆာဗာဖွဲ့စည်းပုံကို အပ်ဒိတ်လုပ်ပြီးပါပြီ။", + "MessageNamedServerConfigurationUpdatedWithValue": "ဆာဗာဖွဲ့စည်းပုံကဏ္ဍ {0} ကို အပ်ဒိတ်လုပ်ပြီးပါပြီ။", + "MessageApplicationUpdatedTo": "Jellyfin ဆာဗာကို {0} သို့ အပ်ဒိတ်လုပ်ထားသည်", + "MessageApplicationUpdated": "Jellyfin ဆာဗာကို အပ်ဒိတ်လုပ်ပြီးပါပြီ။", + "Latest": "နောက်ဆုံး", + "LabelRunningTimeValue": "လည်ပတ်ချိန်- {0}", + "LabelIpAddressValue": "IP လိပ်စာ- {0}", + "ItemRemovedWithName": "{0} ကို ဒစ်ဂျစ်တိုက်မှ ဖယ်ရှားခဲ့သည်။", + "ItemAddedWithName": "{0} ကို စာကြည့်တိုက်သို့ ထည့်ထားသည်။", + "Inherit": "ဆက်လက် လုပ်ဆောင်သည်။", + "HomeVideos": "ပင်မဗီဒီယိုများ", + "HeaderRecordingGroups": "အသံဖမ်းအဖွဲ့များ", + "HeaderNextUp": "နောက်ထပ်", + "HeaderLiveTV": "Live TV", + "HeaderFavoriteSongs": "အကြိုက်ဆုံးသီချင်းများ", + "HeaderFavoriteShows": "အကြိုက်ဆုံးရှိုးများ", + "HeaderFavoriteEpisodes": "အကြိုက်ဆုံးအပိုင်းများ", + "HeaderFavoriteArtists": "အကြိုက်ဆုံးအနုပညာရှင်များ", + "HeaderFavoriteAlbums": "အကြိုက်ဆုံး အယ်လ်ဘမ်များ", + "HeaderContinueWatching": "ဆက်လက်ကြည့်ရှုပါ။", + "HeaderAlbumArtists": "အယ်လ်ဘမ်အနုပညာရှင်များ", + "Genres": "အမျိုးအစားများ", + "Forced": "အတင်းအကြပ်", + "Folders": "ဖိုဒါများ", + "Favorites": "အကြိုက်ဆုံးများ", + "FailedLoginAttemptWithUserName": "{0} မှ အကောင့်ဝင်ရန် မအောင်မြင်ပါ", + "DeviceOnlineWithName": "{0} ကို ချိတ်ဆက်ထားသည်။", + "DeviceOfflineWithName": "{0} နှင့် အဆက်ပြတ်သွားပါပြီ။", + "ChapterNameValue": "အခန်း {0}", + "CameraImageUploadedFrom": "ကင်မရာပုံအသစ်ကို {0} မှ အပ်လုဒ်လုပ်ထားသည်", + "AuthenticationSucceededWithUserName": "{0} စစ်မှန်ကြောင်း အောင်မြင်စွာ အတည်ပြုပြီးပါပြီ။", + "Application": "အပလီကေးရှင်း", + "AppDeviceValues": "အက်ပ်- {0}၊ စက်- {1}" +} diff --git a/Emby.Server.Implementations/Localization/Core/nb.json b/Emby.Server.Implementations/Localization/Core/nb.json index 81c1eefe7..317bdcfcb 100644 --- a/Emby.Server.Implementations/Localization/Core/nb.json +++ b/Emby.Server.Implementations/Localization/Core/nb.json @@ -119,5 +119,6 @@ "Forced": "Tvunget", "Default": "Standard", "TaskCleanActivityLogDescription": "Sletter oppføringer i aktivitetsloggen som er eldre enn den konfigurerte alderen.", - "TaskOptimizeDatabase": "Optimiser database" + "TaskOptimizeDatabase": "Optimiser database", + "TaskOptimizeDatabaseDescription": "Komprimerer database og frigjør plass. Denne prosessen kan forbedre ytelsen etter skanning av bibliotek eller andre handlinger som fører til databaseendringer." } diff --git a/Emby.Server.Implementations/Localization/Core/ne.json b/Emby.Server.Implementations/Localization/Core/ne.json index 8e820d40c..8584fc065 100644 --- a/Emby.Server.Implementations/Localization/Core/ne.json +++ b/Emby.Server.Implementations/Localization/Core/ne.json @@ -69,7 +69,7 @@ "UserDeletedWithName": "प्रयोगकर्ता {0} हटाइएको छ", "UserCreatedWithName": "प्रयोगकर्ता {0} सिर्जना गरिएको छ", "User": "प्रयोगकर्ता", - "PluginInstalledWithName": "", + "PluginInstalledWithName": "{0} सभएको थियो", "StartupEmbyServerIsLoading": "Jellyfin सर्भर लोड हुँदैछ। कृपया छिट्टै फेरि प्रयास गर्नुहोस्।", "Songs": "गीतहरू", "Shows": "शोहरू", diff --git a/Emby.Server.Implementations/Localization/Core/nl.json b/Emby.Server.Implementations/Localization/Core/nl.json index f79840c78..9d512dea1 100644 --- a/Emby.Server.Implementations/Localization/Core/nl.json +++ b/Emby.Server.Implementations/Localization/Core/nl.json @@ -15,7 +15,7 @@ "Favorites": "Favorieten", "Folders": "Mappen", "Genres": "Genres", - "HeaderAlbumArtists": "Albumartiesten", + "HeaderAlbumArtists": "Album Artiesten", "HeaderContinueWatching": "Kijken hervatten", "HeaderFavoriteAlbums": "Favoriete albums", "HeaderFavoriteArtists": "Favoriete artiesten", diff --git a/Emby.Server.Implementations/Localization/Core/pa.json b/Emby.Server.Implementations/Localization/Core/pa.json index d1db09232..4ac57b630 100644 --- a/Emby.Server.Implementations/Localization/Core/pa.json +++ b/Emby.Server.Implementations/Localization/Core/pa.json @@ -24,7 +24,7 @@ "TasksLibraryCategory": "ਲਾਇਬ੍ਰੇਰੀ", "TasksMaintenanceCategory": "ਰੱਖ-ਰਖਾਅ", "VersionNumber": "ਵਰਜਨ {0}", - "ValueSpecialEpisodeName": "ਵਿਸ਼ੇਸ਼ - {0}", + "ValueSpecialEpisodeName": "ਖਾਸ - {0}", "ValueHasBeenAddedToLibrary": "{0} ਤੁਹਾਡੀ ਮੀਡੀਆ ਲਾਇਬ੍ਰੇਰੀ ਵਿੱਚ ਸ਼ਾਮਲ ਕੀਤਾ ਗਿਆ ਹੈ", "UserStoppedPlayingItemWithValues": "{0} ਨੇ {2} 'ਤੇ {1} ਖੇਡਣਾ ਪੂਰਾ ਕਰ ਲਿਆ ਹੈ", "UserStartedPlayingItemWithValues": "{0} {2} 'ਤੇ {1} ਖੇਡ ਰਿਹਾ ਹੈ", @@ -43,8 +43,8 @@ "Sync": "ਸਿੰਕ", "SubtitleDownloadFailureFromForItem": "ਉਪਸਿਰਲੇਖ {1} ਲਈ {0} ਤੋਂ ਡਾ toਨਲੋਡ ਕਰਨ ਵਿੱਚ ਅਸਫਲ ਰਹੇ", "StartupEmbyServerIsLoading": "ਜੈਲੀਫਿਨ ਸਰਵਰ ਲੋਡ ਹੋ ਰਿਹਾ ਹੈ. ਕਿਰਪਾ ਕਰਕੇ ਜਲਦੀ ਹੀ ਦੁਬਾਰਾ ਕੋਸ਼ਿਸ਼ ਕਰੋ.", - "Songs": "ਗਾਣੇ", - "Shows": "ਸ਼ੋਅਜ਼", + "Songs": "ਗਾਣੇਂ", + "Shows": "ਸ਼ੋਅ", "ServerNameNeedsToBeRestarted": "{0} ਮੁੜ ਚਾਲੂ ਕਰਨ ਦੀ ਲੋੜ ਹੈ", "ScheduledTaskStartedWithName": "{0} ਸ਼ੁਰੂ ਹੋਇਆ", "ScheduledTaskFailedWithName": "{0} ਅਸਫਲ", @@ -53,7 +53,7 @@ "PluginUninstalledWithName": "{0} ਅਣਇੰਸਟੌਲ ਕੀਤਾ ਗਿਆ ਸੀ", "PluginInstalledWithName": "{0} ਲਗਾਇਆ ਗਿਆ ਸੀ", "Plugin": "ਪਲੱਗਇਨ", - "Playlists": "ਪਲੇਲਿਸਟਸ", + "Playlists": "ਪਲੇਸੂਚੀਆਂ", "Photos": "ਫੋਟੋਆਂ", "NotificationOptionVideoPlaybackStopped": "ਵੀਡੀਓ ਪਲੇਬੈਕ ਰੋਕਿਆ ਗਿਆ", "NotificationOptionVideoPlayback": "ਵੀਡੀਓ ਪਲੇਬੈਕ ਸ਼ੁਰੂ ਹੋਇਆ", @@ -102,13 +102,13 @@ "HeaderAlbumArtists": "ਐਲਬਮ ਕਲਾਕਾਰ", "Genres": "ਸ਼ੈਲੀਆਂ", "Forced": "ਮਜਬੂਰ", - "Folders": "ਫੋਲਡਰ", + "Folders": "ਫੋਲਡਰਸ", "Favorites": "ਮਨਪਸੰਦ", "FailedLoginAttemptWithUserName": "ਤੋਂ ਲਾਗਇਨ ਕੋਸ਼ਿਸ਼ ਫੇਲ ਹੋਈ {0}", "DeviceOnlineWithName": "{0} ਜੁੜਿਆ ਹੋਇਆ ਹੈ", "DeviceOfflineWithName": "{0} ਡਿਸਕਨੈਕਟ ਹੋ ਗਿਆ ਹੈ", - "Default": "ਮੂਲ", - "Collections": "ਸੰਗ੍ਰਹਿ", + "Default": "ਡਿਫੌਲਟ", + "Collections": "ਸੰਗ੍ਰਹਿਣ", "ChapterNameValue": "ਅਧਿਆਇ {0}", "Channels": "ਚੈਨਲ", "CameraImageUploadedFrom": "ਤੋਂ ਇੱਕ ਨਵਾਂ ਕੈਮਰਾ ਚਿੱਤਰ ਅਪਲੋਡ ਕੀਤਾ ਗਿਆ ਹੈ {0}", diff --git a/Emby.Server.Implementations/Localization/Core/pl.json b/Emby.Server.Implementations/Localization/Core/pl.json index e8a32a13e..4fa8d2bb4 100644 --- a/Emby.Server.Implementations/Localization/Core/pl.json +++ b/Emby.Server.Implementations/Localization/Core/pl.json @@ -15,7 +15,7 @@ "Favorites": "Ulubione", "Folders": "Foldery", "Genres": "Gatunki", - "HeaderAlbumArtists": "Album artysty", + "HeaderAlbumArtists": "Wykonawcy albumów", "HeaderContinueWatching": "Kontynuuj odtwarzanie", "HeaderFavoriteAlbums": "Ulubione albumy", "HeaderFavoriteArtists": "Ulubieni wykonawcy", @@ -47,7 +47,7 @@ "NotificationOptionApplicationUpdateAvailable": "Dostępna aktualizacja aplikacji", "NotificationOptionApplicationUpdateInstalled": "Zaktualizowano aplikację", "NotificationOptionAudioPlayback": "Rozpoczęto odtwarzanie muzyki", - "NotificationOptionAudioPlaybackStopped": "Odtwarzane dźwięku zatrzymane", + "NotificationOptionAudioPlaybackStopped": "Odtwarzanie dźwięku zatrzymane", "NotificationOptionCameraImageUploaded": "Przekazano obraz z urządzenia przenośnego", "NotificationOptionInstallationFailed": "Nieudana instalacja", "NotificationOptionNewLibraryContent": "Dodano nową zawartość", @@ -98,7 +98,7 @@ "TaskRefreshChannels": "Odśwież kanały", "TaskCleanTranscodeDescription": "Usuwa transkodowane pliki starsze niż 1 dzień.", "TaskCleanTranscode": "Wyczyść folder transkodowania", - "TaskUpdatePluginsDescription": "Pobiera i instaluje aktualizacje dla pluginów które są skonfigurowane do automatycznej aktualizacji.", + "TaskUpdatePluginsDescription": "Pobiera i instaluje aktualizacje dla pluginów, które są skonfigurowane do automatycznej aktualizacji.", "TaskUpdatePlugins": "Aktualizuj pluginy", "TaskRefreshPeopleDescription": "Odświeża metadane o aktorów i reżyserów w Twojej bibliotece mediów.", "TaskRefreshPeople": "Odśwież obsadę", diff --git a/Emby.Server.Implementations/Localization/Core/pr.json b/Emby.Server.Implementations/Localization/Core/pr.json index 0967ef424..81aa996d9 100644 --- a/Emby.Server.Implementations/Localization/Core/pr.json +++ b/Emby.Server.Implementations/Localization/Core/pr.json @@ -1 +1,7 @@ -{} +{ + "Books": "Libros", + "AuthenticationSucceededWithUserName": "{0} autentificado correctamente", + "Artists": "Artistas", + "Songs": "Shantees", + "Albums": "Ships" +} diff --git a/Emby.Server.Implementations/Localization/Core/pt-PT.json b/Emby.Server.Implementations/Localization/Core/pt-PT.json index 8c41edf96..8870de168 100644 --- a/Emby.Server.Implementations/Localization/Core/pt-PT.json +++ b/Emby.Server.Implementations/Localization/Core/pt-PT.json @@ -15,7 +15,7 @@ "Favorites": "Favoritos", "Folders": "Pastas", "Genres": "Géneros", - "HeaderAlbumArtists": "Artistas do Álbum", + "HeaderAlbumArtists": "Álbum do Artista", "HeaderContinueWatching": "Continuar a Ver", "HeaderFavoriteAlbums": "Álbuns Favoritos", "HeaderFavoriteArtists": "Artistas Favoritos", @@ -39,7 +39,7 @@ "MixedContent": "Conteúdo Misto", "Movies": "Filmes", "Music": "Música", - "MusicVideos": "Videoclips", + "MusicVideos": "Videoclipes", "NameInstallFailed": "{0} falha na instalação", "NameSeasonNumber": "Temporada {0}", "NameSeasonUnknown": "Temporada Desconhecida", @@ -118,5 +118,7 @@ "TaskCleanActivityLog": "Limpar registo de atividade", "Undefined": "Indefinido", "Forced": "Forçado", - "Default": "Padrão" + "Default": "Padrão", + "TaskOptimizeDatabaseDescription": "Base de dados compacta e corta espaço livre. A execução desta tarefa depois de digitalizar a biblioteca ou de fazer outras alterações que impliquem modificações na base de dados pode melhorar o desempenho.", + "TaskOptimizeDatabase": "Otimizar base de dados" } diff --git a/Emby.Server.Implementations/Localization/Core/pt.json b/Emby.Server.Implementations/Localization/Core/pt.json index 474dacd7c..a9dbd53ea 100644 --- a/Emby.Server.Implementations/Localization/Core/pt.json +++ b/Emby.Server.Implementations/Localization/Core/pt.json @@ -1,6 +1,6 @@ { - "HeaderLiveTV": "TV em Directo", - "Collections": "Colecções", + "HeaderLiveTV": "TV Ao Vivo", + "Collections": "Coleções", "Books": "Livros", "Artists": "Artistas", "Albums": "Álbuns", @@ -10,29 +10,29 @@ "HeaderFavoriteAlbums": "Álbuns Favoritos", "HeaderFavoriteEpisodes": "Episódios Favoritos", "HeaderFavoriteShows": "Séries Favoritas", - "HeaderContinueWatching": "Continuar a Assistir", + "HeaderContinueWatching": "Continuar assistindo", "HeaderAlbumArtists": "Artistas do Álbum", - "Genres": "Géneros", - "Folders": "Directórios", + "Genres": "Gêneros", + "Folders": "Diretórios", "Favorites": "Favoritos", "Channels": "Canais", - "UserDownloadingItemWithValues": "{0} está a ser transferido {1}", + "UserDownloadingItemWithValues": "{0} está sendo baixado {1}", "VersionNumber": "Versão {0}", "ValueHasBeenAddedToLibrary": "{0} foi adicionado à sua biblioteca multimédia", "UserStoppedPlayingItemWithValues": "{0} terminou a reprodução de {1} em {2}", - "UserStartedPlayingItemWithValues": "{0} está a reproduzir {1} em {2}", - "UserPolicyUpdatedWithName": "A política do utilizador {0} foi alterada", - "UserPasswordChangedWithName": "A palavra-passe do utilizador {0} foi alterada", - "UserOnlineFromDevice": "{0} ligou-se a partir de {1}", + "UserStartedPlayingItemWithValues": "{0} está reproduzindo {1} em {2}", + "UserPolicyUpdatedWithName": "A política do usuário {0} foi alterada", + "UserPasswordChangedWithName": "A senha do usuário {0} foi alterada", + "UserOnlineFromDevice": "{0} está online a partir de {1}", "UserOfflineFromDevice": "{0} desconectou-se a partir de {1}", - "UserLockedOutWithName": "O utilizador {0} foi bloqueado", - "UserDeletedWithName": "O utilizador {0} foi removido", - "UserCreatedWithName": "O utilizador {0} foi criado", - "User": "Utilizador", + "UserLockedOutWithName": "O usuário {0} foi bloqueado", + "UserDeletedWithName": "O usuário {0} foi removido", + "UserCreatedWithName": "O usuário {0} foi criado", + "User": "Usuário", "TvShows": "Séries", "System": "Sistema", "SubtitleDownloadFailureFromForItem": "Falha na transferência de legendas de {0} para {1}", - "StartupEmbyServerIsLoading": "O servidor Jellyfin está a iniciar. Tente novamente dentro de momentos.", + "StartupEmbyServerIsLoading": "O servidor Jellyfin está iniciando. Tente novamente dentro de momentos.", "ServerNameNeedsToBeRestarted": "{0} necessita ser reiniciado", "ScheduledTaskStartedWithName": "{0} iniciou", "ScheduledTaskFailedWithName": "{0} falhou", @@ -43,38 +43,38 @@ "Plugin": "Plugin", "NotificationOptionVideoPlaybackStopped": "Reprodução de vídeo parada", "NotificationOptionVideoPlayback": "Reprodução de vídeo iniciada", - "NotificationOptionUserLockedOut": "Utilizador bloqueado", - "NotificationOptionTaskFailed": "Falha em tarefa agendada", + "NotificationOptionUserLockedOut": "Usuário bloqueado", + "NotificationOptionTaskFailed": "Falha na tarefa agendada", "NotificationOptionServerRestartRequired": "É necessário reiniciar o servidor", - "NotificationOptionPluginUpdateInstalled": "Plugin actualizado", + "NotificationOptionPluginUpdateInstalled": "Plugin atualizado", "NotificationOptionPluginUninstalled": "Plugin desinstalado", "NotificationOptionPluginInstalled": "Plugin instalado", "NotificationOptionPluginError": "Falha no plugin", "NotificationOptionNewLibraryContent": "Novo conteúdo adicionado", "NotificationOptionInstallationFailed": "Falha de instalação", - "NotificationOptionCameraImageUploaded": "Imagem de câmara enviada", + "NotificationOptionCameraImageUploaded": "Imagem de câmera enviada", "NotificationOptionAudioPlaybackStopped": "Reprodução Parada", "NotificationOptionAudioPlayback": "Reprodução Iniciada", - "NotificationOptionApplicationUpdateInstalled": "A actualização da aplicação foi instalada", - "NotificationOptionApplicationUpdateAvailable": "Uma actualização da aplicação está disponível", - "NewVersionIsAvailable": "Uma nova versão do servidor Jellyfin está disponível para transferência.", + "NotificationOptionApplicationUpdateInstalled": "A atualização do aplicativo foi instalada", + "NotificationOptionApplicationUpdateAvailable": "Uma atualização do aplicativo está disponível", + "NewVersionIsAvailable": "Uma nova versão do servidor Jellyfin está disponível para download.", "NameSeasonUnknown": "Temporada Desconhecida", "NameSeasonNumber": "Temporada {0}", "NameInstallFailed": "Falha na instalação de {0}", "MusicVideos": "Videoclipes", "Music": "Música", - "MixedContent": "Conteúdo Misto", - "MessageServerConfigurationUpdated": "A configuração do servidor foi actualizada", - "MessageNamedServerConfigurationUpdatedWithValue": "As configurações do servidor na secção {0} foram atualizadas", + "MixedContent": "Conteúdo diverso", + "MessageServerConfigurationUpdated": "A configuração do servidor foi atualizada", + "MessageNamedServerConfigurationUpdatedWithValue": "As configurações do servidor na seção {0} foram atualizadas", "MessageApplicationUpdatedTo": "O servidor Jellyfin foi atualizado para a versão {0}", - "MessageApplicationUpdated": "O servidor Jellyfin foi actualizado", + "MessageApplicationUpdated": "O servidor Jellyfin foi atualizado", "Latest": "Mais Recente", "LabelRunningTimeValue": "Duração: {0}", "LabelIpAddressValue": "Endereço de IP: {0}", "ItemRemovedWithName": "{0} foi removido da biblioteca", "ItemAddedWithName": "{0} foi adicionado à biblioteca", "Inherit": "Herdar", - "HomeVideos": "Vídeos Caseiros", + "HomeVideos": "Vídeos principais", "HeaderRecordingGroups": "Grupos de Gravação", "ValueSpecialEpisodeName": "Episódio Especial - {0}", "Sync": "Sincronização", @@ -83,22 +83,22 @@ "Playlists": "Listas de Reprodução", "Photos": "Fotografias", "Movies": "Filmes", - "FailedLoginAttemptWithUserName": "Tentativa de ligação falhada a partir de {0}", - "DeviceOnlineWithName": "{0} está connectado", + "FailedLoginAttemptWithUserName": "Tentativa falha de login a partir de {0}", + "DeviceOnlineWithName": "{0} está conectado", "DeviceOfflineWithName": "{0} desconectou-se", "ChapterNameValue": "Capítulo {0}", "CameraImageUploadedFrom": "Uma nova imagem da câmara foi enviada a partir de {0}", "AuthenticationSucceededWithUserName": "{0} autenticado com sucesso", - "Application": "Aplicação", - "AppDeviceValues": "Aplicação {0}, Dispositivo: {1}", + "Application": "Aplicativo", + "AppDeviceValues": "Aplicativo {0}, Dispositivo: {1}", "TaskCleanCache": "Limpar Diretório de Cache", - "TasksApplicationCategory": "Aplicação", + "TasksApplicationCategory": "Aplicativo", "TasksLibraryCategory": "Biblioteca", "TasksMaintenanceCategory": "Manutenção", "TaskRefreshChannels": "Atualizar Canais", "TaskUpdatePlugins": "Atualizar Plugins", "TaskCleanLogsDescription": "Deletar arquivos de log que existe a mais de {0} dias.", - "TaskCleanLogs": "Limpar diretório de log", + "TaskCleanLogs": "Limpar diretório de logs", "TaskRefreshLibrary": "Escanear biblioteca de mídias", "TaskRefreshChapterImagesDescription": "Cria miniaturas para vídeos que têm capítulos.", "TaskCleanCacheDescription": "Apaga ficheiros em cache que já não são usados pelo sistema.", @@ -109,14 +109,15 @@ "TaskRefreshChannelsDescription": "Atualiza as informações do canal da Internet.", "TaskCleanTranscodeDescription": "Apagar os ficheiros com mais de um dia, de Transcode.", "TaskCleanTranscode": "Limpar o diretório de Transcode", - "TaskUpdatePluginsDescription": "Download e instala as atualizações para plug-ins configurados para atualização automática.", + "TaskUpdatePluginsDescription": "Baixa e instala as atualizações para plug-ins configurados para atualização automática.", "TaskRefreshPeopleDescription": "Atualiza os metadados para atores e diretores na tua biblioteca de media.", "TaskRefreshPeople": "Atualizar pessoas", - "TaskRefreshLibraryDescription": "Pesquisa a tua biblioteca de media por novos ficheiros e atualiza os metadados.", - "TaskCleanActivityLog": "Limpar registo de atividade", + "TaskRefreshLibraryDescription": "Pesquisa sua biblioteca de media por novos arquivos e atualiza os metadados.", + "TaskCleanActivityLog": "Limpar registro de atividade", "Undefined": "Indefinido", "Forced": "Forçado", "Default": "Predefinição", "TaskCleanActivityLogDescription": "Apaga itens no registro com idade acima do que é configurado.", - "TaskOptimizeDatabase": "Otimizar base de dados" + "TaskOptimizeDatabase": "Otimizar base de dados", + "TaskOptimizeDatabaseDescription": "Base de dados compacta e corta espaço livre. A execução desta tarefa depois de digitalizar a biblioteca ou de fazer outras alterações que impliquem modificações na base de dados pode melhorar o desempenho." } diff --git a/Emby.Server.Implementations/Localization/Core/ro.json b/Emby.Server.Implementations/Localization/Core/ro.json index 510aac11c..f8fad7b63 100644 --- a/Emby.Server.Implementations/Localization/Core/ro.json +++ b/Emby.Server.Implementations/Localization/Core/ro.json @@ -74,7 +74,7 @@ "HeaderFavoriteArtists": "Artiști Favoriți", "HeaderFavoriteAlbums": "Albume Favorite", "HeaderContinueWatching": "Vizionează în continuare", - "HeaderAlbumArtists": "Album Artiști", + "HeaderAlbumArtists": "Albume Artiști", "Genres": "Genuri", "Folders": "Dosare", "Favorites": "Favorite", diff --git a/Emby.Server.Implementations/Localization/Core/ru.json b/Emby.Server.Implementations/Localization/Core/ru.json index cd016b51b..dc3793f1b 100644 --- a/Emby.Server.Implementations/Localization/Core/ru.json +++ b/Emby.Server.Implementations/Localization/Core/ru.json @@ -31,7 +31,7 @@ "ItemRemovedWithName": "{0} - изъято из медиатеки", "LabelIpAddressValue": "IP-адрес: {0}", "LabelRunningTimeValue": "Длительность: {0}", - "Latest": "Последнее", + "Latest": "Крайнее", "MessageApplicationUpdated": "Jellyfin Server был обновлён", "MessageApplicationUpdatedTo": "Jellyfin Server был обновлён до {0}", "MessageNamedServerConfigurationUpdatedWithValue": "Конфигурация сервера (раздел {0}) была обновлена", @@ -96,7 +96,7 @@ "TaskRefreshChannels": "Обновление каналов", "TaskCleanTranscode": "Очистка каталога перекодировки", "TaskUpdatePlugins": "Обновление плагинов", - "TaskRefreshPeople": "Подновить людей", + "TaskRefreshPeople": "Подновление людей", "TaskCleanLogs": "Очистка каталога журналов", "TaskRefreshLibrary": "Сканирование медиатеки", "TaskRefreshChapterImages": "Извлечение изображений сцен", @@ -115,10 +115,10 @@ "TaskRefreshChapterImagesDescription": "Создаются эскизы для видео, которые содержат сцены.", "TaskCleanCacheDescription": "Удаляются файлы кэша, которые больше не нужны системе.", "TaskCleanActivityLogDescription": "Удаляет записи журнала активности старше установленного возраста.", - "TaskCleanActivityLog": "Очистить журнал активности", + "TaskCleanActivityLog": "Очистка журнала активности", "Undefined": "Не определено", "Forced": "Форсир-ые", "Default": "По умолчанию", - "TaskOptimizeDatabaseDescription": "Сжимает базу данных и обрезает свободное место. Выполнение этой задачи после сканирования библиотеки или внесения других изменений, предполагающих модификации базы данных, может повысить производительность.", - "TaskOptimizeDatabase": "Оптимизировать базу данных" + "TaskOptimizeDatabaseDescription": "Сжимает базу данных и вырезает свободные места. Выполнение этой задачи после сканирования библиотеки или внесения других изменений, предполагающих модификации базы данных, может повысить производительность.", + "TaskOptimizeDatabase": "Оптимизация базы данных" } diff --git a/Emby.Server.Implementations/Localization/Core/sk.json b/Emby.Server.Implementations/Localization/Core/sk.json index ad90bd813..37da7d5ab 100644 --- a/Emby.Server.Implementations/Localization/Core/sk.json +++ b/Emby.Server.Implementations/Localization/Core/sk.json @@ -39,7 +39,7 @@ "MixedContent": "Zmiešaný obsah", "Movies": "Filmy", "Music": "Hudba", - "MusicVideos": "Hudobné videá", + "MusicVideos": "Hudobné videoklipy", "NameInstallFailed": "Inštalácia {0} zlyhala", "NameSeasonNumber": "Séria {0}", "NameSeasonUnknown": "Neznáma séria", diff --git a/Emby.Server.Implementations/Localization/Core/sq.json b/Emby.Server.Implementations/Localization/Core/sq.json index e36fdc43d..2766dab06 100644 --- a/Emby.Server.Implementations/Localization/Core/sq.json +++ b/Emby.Server.Implementations/Localization/Core/sq.json @@ -74,7 +74,7 @@ "NameSeasonUnknown": "Sezon i panjohur", "NameSeasonNumber": "Sezoni {0}", "NameInstallFailed": "Instalimi i {0} dështoi", - "MusicVideos": "Videot muzikore", + "MusicVideos": "Video Muzikore", "Music": "Muzikë", "Movies": "Filmat", "MixedContent": "Përmbajtje e përzier", diff --git a/Emby.Server.Implementations/Localization/Core/sr.json b/Emby.Server.Implementations/Localization/Core/sr.json index 15fb34186..72e125dfe 100644 --- a/Emby.Server.Implementations/Localization/Core/sr.json +++ b/Emby.Server.Implementations/Localization/Core/sr.json @@ -50,7 +50,7 @@ "NameSeasonUnknown": "Непозната сезона", "NameSeasonNumber": "Сезона {0}", "NameInstallFailed": "Инсталација {0} није успела", - "MusicVideos": "Музички спотови", + "MusicVideos": "Музички видео", "Music": "Музика", "Movies": "Филмови", "MixedContent": "Мешовит садржај", @@ -64,7 +64,7 @@ "ItemRemovedWithName": "{0} уклоњено из библиотеке", "ItemAddedWithName": "{0} додато у библиотеку", "Inherit": "Наследи", - "HomeVideos": "Кућни видео", + "HomeVideos": "Кућни Видео", "HeaderRecordingGroups": "Групе снимања", "HeaderNextUp": "Следи", "HeaderLiveTV": "ТВ уживо", @@ -117,5 +117,7 @@ "TaskCleanActivityLog": "Очисти историју активности", "Undefined": "Недефинисано", "Forced": "Принудно", - "Default": "Подразумевано" + "Default": "Подразумевано", + "TaskOptimizeDatabase": "Оптимизуј датабазу", + "TaskOptimizeDatabaseDescription": "Сажима базу података и скраћује слободан простор. Покретање овог задатка након скенирања библиотеке или других промена које подразумевају измене базе података које могу побољшати перформансе." } diff --git a/Emby.Server.Implementations/Localization/Core/sv.json b/Emby.Server.Implementations/Localization/Core/sv.json index 6c772c6a2..5d05361b0 100644 --- a/Emby.Server.Implementations/Localization/Core/sv.json +++ b/Emby.Server.Implementations/Localization/Core/sv.json @@ -15,8 +15,8 @@ "Favorites": "Favoriter", "Folders": "Mappar", "Genres": "Genrer", - "HeaderAlbumArtists": "Artistens album", - "HeaderContinueWatching": "Fortsätt kolla", + "HeaderAlbumArtists": "Albumsartister", + "HeaderContinueWatching": "Fortsätt kolla på", "HeaderFavoriteAlbums": "Favoritalbum", "HeaderFavoriteArtists": "Favoritartister", "HeaderFavoriteEpisodes": "Favoritavsnitt", @@ -96,8 +96,8 @@ "TaskDownloadMissingSubtitles": "Ladda ned saknade undertexter", "TaskRefreshChannelsDescription": "Uppdaterar information för internetkanaler.", "TaskRefreshChannels": "Uppdatera kanaler", - "TaskCleanTranscodeDescription": "Raderar transkodningsfiler som är mer än en dag gamla.", - "TaskCleanTranscode": "Töm transkodningskatalog", + "TaskCleanTranscodeDescription": "Raderar omkodningsfiler som är mer än en dag gamla.", + "TaskCleanTranscode": "Töm omkodningskatalog", "TaskUpdatePluginsDescription": "Laddar ned och installerar uppdateringar till insticksprogram som är konfigurerade att uppdateras automatiskt.", "TaskUpdatePlugins": "Uppdatera insticksprogram", "TaskRefreshPeopleDescription": "Uppdaterar metadata för skådespelare och regissörer i ditt mediabibliotek.", diff --git a/Emby.Server.Implementations/Localization/Core/ta.json b/Emby.Server.Implementations/Localization/Core/ta.json index d6e9aa8e5..98d763fcd 100644 --- a/Emby.Server.Implementations/Localization/Core/ta.json +++ b/Emby.Server.Implementations/Localization/Core/ta.json @@ -21,7 +21,7 @@ "Inherit": "மரபுரிமையாகப் பெறு", "HeaderRecordingGroups": "பதிவு குழுக்கள்", "Folders": "கோப்புறைகள்", - "FailedLoginAttemptWithUserName": "{0} இல் இருந்து உள்நுழைவு முயற்சி தோல்வியடைந்தது", + "FailedLoginAttemptWithUserName": "{0} இன் உள்நுழைவு முயற்சி தோல்வியடைந்தது", "DeviceOnlineWithName": "{0} இணைக்கப்பட்டது", "DeviceOfflineWithName": "{0} துண்டிக்கப்பட்டது", "Collections": "தொகுப்புகள்", @@ -85,7 +85,7 @@ "HeaderFavoriteArtists": "பிடித்த கலைஞர்கள்", "HeaderFavoriteAlbums": "பிடித்த ஆல்பங்கள்", "HeaderContinueWatching": "தொடர்ந்து பார்", - "HeaderAlbumArtists": "இசைக் கலைஞர்கள்", + "HeaderAlbumArtists": "கலைஞரின் ஆல்பம்", "Genres": "வகைகள்", "Favorites": "பிடித்தவை", "ChapterNameValue": "அத்தியாயம் {0}", diff --git a/Emby.Server.Implementations/Localization/Core/te.json b/Emby.Server.Implementations/Localization/Core/te.json new file mode 100644 index 000000000..a9a8ceae0 --- /dev/null +++ b/Emby.Server.Implementations/Localization/Core/te.json @@ -0,0 +1,23 @@ +{ + "ValueSpecialEpisodeName": "ప్రత్యేక - {0}", + "Sync": "సమకాలీకరించు", + "Songs": "పాటలు", + "Shows": "ప్రదర్శనలు", + "Playlists": "ప్లేజాబితాలు", + "Photos": "ఫోటోలు", + "MusicVideos": "మ్యూజిక్ వీడియోలు", + "Music": "సంగీతం", + "Movies": "సినిమాలు", + "HeaderContinueWatching": "చూడటం కొనసాగించండి", + "HeaderAlbumArtists": "ఆల్బమ్ కళాకారులు", + "Genres": "శైలులు", + "Forced": "బలవంతంగా", + "Folders": "ఫోల్డర్లు", + "Favorites": "ఇష్టమైనవి", + "Default": "డిఫాల్ట్", + "Collections": "సేకరణలు", + "Channels": "ఛానెల్‌లు", + "Books": "పుస్తకాలు", + "Artists": "కళాకారులు", + "Albums": "ఆల్బమ్‌లు" +} diff --git a/Emby.Server.Implementations/Localization/Core/th.json b/Emby.Server.Implementations/Localization/Core/th.json index e26010423..bed67fa4f 100644 --- a/Emby.Server.Implementations/Localization/Core/th.json +++ b/Emby.Server.Implementations/Localization/Core/th.json @@ -117,5 +117,7 @@ "TaskCleanActivityLogDescription": "ลบบันทึกกิจกรรมที่เก่ากว่าค่าที่กำหนดไว้", "TaskCleanActivityLog": "ล้างบันทึกกิจกรรม", "Undefined": "ไม่ได้กำหนด", - "Forced": "บังคับใช้" + "Forced": "บังคับใช้", + "TaskOptimizeDatabase": "ปรับปรุงประสิทธิภาพฐานข้อมูล", + "TaskOptimizeDatabaseDescription": "ลดขนาดการจัดเก็บฐานข้อมูล ใช้งานคำสั่งนี้หลังจากสแกนไลบรารีหรือหลังจากการเปลี่ยนแปลงฐานข้อมูล อาจจะทำให้ระบบทำงานเร็วขึ้น" } diff --git a/Emby.Server.Implementations/Localization/Core/tr.json b/Emby.Server.Implementations/Localization/Core/tr.json index e661299c4..8fadb88ac 100644 --- a/Emby.Server.Implementations/Localization/Core/tr.json +++ b/Emby.Server.Implementations/Localization/Core/tr.json @@ -8,7 +8,7 @@ "CameraImageUploadedFrom": "{0} 'den yeni bir kamera resmi yüklendi", "Channels": "Kanallar", "ChapterNameValue": "Bölüm {0}", - "Collections": "Koleksiyon", + "Collections": "Koleksiyonlar", "DeviceOfflineWithName": "{0} bağlantısı kesildi", "DeviceOnlineWithName": "{0} bağlı", "FailedLoginAttemptWithUserName": "{0} adresinden giriş başarısız oldu", diff --git a/Emby.Server.Implementations/Localization/Core/uk.json b/Emby.Server.Implementations/Localization/Core/uk.json index 5a2069df5..1c7d73615 100644 --- a/Emby.Server.Implementations/Localization/Core/uk.json +++ b/Emby.Server.Implementations/Localization/Core/uk.json @@ -1,5 +1,5 @@ { - "MusicVideos": "Музичні відеокліпи", + "MusicVideos": "Відеокліпи", "Music": "Музика", "Movies": "Фільми", "MessageApplicationUpdatedTo": "Jellyfin Server оновлено до версії {0}", @@ -38,7 +38,7 @@ "NotificationOptionPluginInstalled": "Плагін встановлено", "NotificationOptionPluginError": "Помилка плагіна", "NotificationOptionNewLibraryContent": "Додано новий контент", - "HomeVideos": "Домашнє відео", + "HomeVideos": "Мої відео", "FailedLoginAttemptWithUserName": "Невдала спроба входу від {0}", "LabelRunningTimeValue": "Тривалість: {0}", "TaskDownloadMissingSubtitlesDescription": "Шукає в Інтернеті відсутні субтитри на основі конфігурації метаданих.", @@ -117,5 +117,7 @@ "TaskCleanActivityLogDescription": "Видаляє старші за встановлений термін записи з журналу активності.", "TaskCleanActivityLog": "Очистити журнал активності", "Undefined": "Не визначено", - "Default": "За замовчуванням" + "Default": "За замовчуванням", + "TaskOptimizeDatabase": "Оптимізувати базу даних", + "TaskOptimizeDatabaseDescription": "Стиснення бази даних та збільшення вільного простору. Виконання цього завдання після сканування бібліотеки або внесення інших змін, які передбачають модифікацію бази даних, може покращити продуктивність." } diff --git a/Emby.Server.Implementations/Localization/Core/ur_PK.json b/Emby.Server.Implementations/Localization/Core/ur_PK.json index 5d6d0775c..e7f3e492c 100644 --- a/Emby.Server.Implementations/Localization/Core/ur_PK.json +++ b/Emby.Server.Implementations/Localization/Core/ur_PK.json @@ -90,7 +90,7 @@ "NameSeasonUnknown": "نامعلوم باب", "NameSeasonNumber": "باب {0}", "NameInstallFailed": "{0} تنصیب ناکام ہوگئی", - "MusicVideos": "موسیقی ویڈیو", + "MusicVideos": "میوزک ویڈیوز", "Music": "موسیقی", "MixedContent": "مخلوط مواد", "MessageServerConfigurationUpdated": "سرور کو اپ ڈیٹ کر دیا گیا ہے", @@ -99,18 +99,21 @@ "MessageApplicationUpdated": "جیلیفن سرور کو اپ ڈیٹ کر دیا گیا ہے", "Latest": "تازہ ترین", "LabelRunningTimeValue": "چلانے کی مدت", - "LabelIpAddressValue": "ای پی پتے {0}", + "LabelIpAddressValue": "آئ پی ایڈریس {0}", "ItemRemovedWithName": "لائبریری سے ہٹا دیا گیا ھے", "ItemAddedWithName": "[0} لائبریری میں شامل کیا گیا ھے", "Inherit": "وراثت میں", "HomeVideos": "ہوم ویڈیو", "HeaderRecordingGroups": "ریکارڈنگ گروپس", - "FailedLoginAttemptWithUserName": "لاگن کئ کوشش ناکام {0}", + "FailedLoginAttemptWithUserName": "{0} سے لاگ ان کی ناکام کوشش", "DeviceOnlineWithName": "{0} متصل ھو چکا ھے", "DeviceOfflineWithName": "{0} منقطع ھو چکا ھے", "ChapterNameValue": "باب", "AuthenticationSucceededWithUserName": "{0} کامیابی کے ساتھ تصدیق ھوچکی ھے", "CameraImageUploadedFrom": "ایک نئی کیمرہ تصویر اپ لوڈ کی گئی ہے {0}", "Application": "پروگرام", - "AppDeviceValues": "پروگرام:{0}, آلہ:{1}" + "AppDeviceValues": "پروگرام:{0}, ڈیوائس:{1}", + "Forced": "جَبری", + "Undefined": "غير وضاحتى", + "Default": "طے شدہ" } diff --git a/Emby.Server.Implementations/Localization/Core/vi.json b/Emby.Server.Implementations/Localization/Core/vi.json index 3d69e418b..d80f1760d 100644 --- a/Emby.Server.Implementations/Localization/Core/vi.json +++ b/Emby.Server.Implementations/Localization/Core/vi.json @@ -3,18 +3,18 @@ "Favorites": "Yêu Thích", "Folders": "Thư Mục", "Genres": "Thể Loại", - "HeaderAlbumArtists": "Album Nghệ sĩ", + "HeaderAlbumArtists": "Album nghệ sĩ", "HeaderContinueWatching": "Xem Tiếp", "HeaderLiveTV": "TV Trực Tiếp", "Movies": "Phim", "Photos": "Ảnh", "Playlists": "Danh sách phát", "Shows": "Chương Trình TV", - "Songs": "Các Bài Hát", + "Songs": "Bài Hát", "Sync": "Đồng Bộ", "ValueSpecialEpisodeName": "Đặc Biệt - {0}", - "Albums": "Tuyển Tập", - "Artists": "Các Nghệ Sĩ", + "Albums": "", + "Artists": "Ca Sĩ", "TaskDownloadMissingSubtitlesDescription": "Tìm kiếm phụ đề bị thiếu trên Internet dựa trên cấu hình dữ liệu mô tả.", "TaskDownloadMissingSubtitles": "Tải Xuống Phụ Đề Bị Thiếu", "TaskRefreshChannelsDescription": "Làm mới thông tin kênh internet.", @@ -32,7 +32,7 @@ "TaskRefreshChapterImagesDescription": "Tạo hình thu nhỏ cho video có các phân cảnh.", "TaskRefreshChapterImages": "Trích Xuất Ảnh Phân Cảnh", "TaskCleanCacheDescription": "Xóa các tệp cache không còn cần thiết của hệ thống.", - "TaskCleanCache": "Làm Sạch Thư Mục Cache", + "TaskCleanCache": "Làm Sạch Thư Mục Bộ Nhớ Đệm", "TasksChannelsCategory": "Kênh Internet", "TasksApplicationCategory": "Ứng Dụng", "TasksLibraryCategory": "Thư Viện", @@ -62,11 +62,11 @@ "PluginUninstalledWithName": "{0} đã được gỡ bỏ", "PluginInstalledWithName": "{0} đã được cài đặt", "Plugin": "Plugin", - "NotificationOptionVideoPlaybackStopped": "Phát lại video đã dừng", + "NotificationOptionVideoPlaybackStopped": "Đã dừng phát lại video", "NotificationOptionVideoPlayback": "Đã bắt đầu phát lại video", "NotificationOptionUserLockedOut": "Người dùng bị khóa", "NotificationOptionTaskFailed": "Lỗi tác vụ đã lên lịch", - "NotificationOptionServerRestartRequired": "Yêu cầu khởi động lại Server", + "NotificationOptionServerRestartRequired": "Yêu cầu khởi động lại máy chủ", "NotificationOptionPluginUpdateInstalled": "Cập nhật Plugin đã được cài đặt", "NotificationOptionPluginUninstalled": "Đã gỡ bỏ Plugin", "NotificationOptionPluginInstalled": "Đã cài đặt Plugin", @@ -75,7 +75,7 @@ "NotificationOptionInstallationFailed": "Cài đặt thất bại", "NotificationOptionCameraImageUploaded": "Đã tải lên hình ảnh máy ảnh", "NotificationOptionAudioPlaybackStopped": "Phát lại âm thanh đã dừng", - "NotificationOptionAudioPlayback": "Phát lại âm thanh đã bắt đầu", + "NotificationOptionAudioPlayback": "Đã bắt đầu phát lại âm thanh", "NotificationOptionApplicationUpdateInstalled": "Bản cập nhật ứng dụng đã được cài đặt", "NotificationOptionApplicationUpdateAvailable": "Bản cập nhật ứng dụng hiện sẵn có", "NewVersionIsAvailable": "Một phiên bản mới của Jellyfin Server sẵn có để tải.", @@ -95,7 +95,7 @@ "ItemRemovedWithName": "{0} đã xóa khỏi thư viện", "ItemAddedWithName": "{0} được thêm vào thư viện", "Inherit": "Thừa hưởng", - "HomeVideos": "Video nhà", + "HomeVideos": "Video Nhà", "HeaderRecordingGroups": "Nhóm Ghi Video", "HeaderNextUp": "Tiếp Theo", "HeaderFavoriteSongs": "Bài Hát Yêu Thích", @@ -103,7 +103,7 @@ "HeaderFavoriteEpisodes": "Tập Phim Yêu Thích", "HeaderFavoriteArtists": "Nghệ Sĩ Yêu Thích", "HeaderFavoriteAlbums": "Album Ưa Thích", - "FailedLoginAttemptWithUserName": "Nỗ lực đăng nhập thất bại từ {0}", + "FailedLoginAttemptWithUserName": "Đăng nhập không thành công thử từ {0}", "DeviceOnlineWithName": "{0} đã kết nối", "DeviceOfflineWithName": "{0} đã ngắt kết nối", "ChapterNameValue": "Phân Cảnh {0}", diff --git a/Emby.Server.Implementations/Localization/Core/zh-CN.json b/Emby.Server.Implementations/Localization/Core/zh-CN.json index f9df62724..ac4eb644b 100644 --- a/Emby.Server.Implementations/Localization/Core/zh-CN.json +++ b/Emby.Server.Implementations/Localization/Core/zh-CN.json @@ -11,7 +11,7 @@ "Collections": "合集", "DeviceOfflineWithName": "{0} 已断开", "DeviceOnlineWithName": "{0} 已连接", - "FailedLoginAttemptWithUserName": "来自 {0} 的失败登入", + "FailedLoginAttemptWithUserName": "从 {0} 尝试登录失败", "Favorites": "我的最爱", "Folders": "文件夹", "Genres": "风格", diff --git a/Emby.Server.Implementations/Localization/Core/zu.json b/Emby.Server.Implementations/Localization/Core/zu.json new file mode 100644 index 000000000..b5f4b920f --- /dev/null +++ b/Emby.Server.Implementations/Localization/Core/zu.json @@ -0,0 +1,29 @@ +{ + "TasksApplicationCategory": "Ukusetshenziswa", + "TasksLibraryCategory": "Umtapo", + "TasksMaintenanceCategory": "Ukunakekela", + "User": "Umsebenzisi", + "Undefined": "Akuchaziwe", + "System": "Isistimu", + "Sync": "Vumelanisa", + "Songs": "Amaculo", + "Shows": "Izinhlelo", + "Plugin": "Isijobelelo", + "Playlists": "Izinhla Zokudlalayo", + "Photos": "Izithombe", + "Music": "Umculo", + "Movies": "Amamuvi", + "Latest": "lwakamuva", + "Inherit": "Ngefa", + "Forced": "Kuphoqiwe", + "Application": "Ukusetshenziswa", + "Genres": "Izinhlobo", + "Folders": "Izikhwama", + "Favorites": "Izintandokazi", + "Default": "Okumisiwe", + "Collections": "Amaqoqo", + "Channels": "Amashaneli", + "Books": "Izincwadi", + "Artists": "Abadlali", + "Albums": "Ama-albhamu" +} diff --git a/Emby.Server.Implementations/Localization/LocalizationManager.cs b/Emby.Server.Implementations/Localization/LocalizationManager.cs index 03919197e..dbd70342a 100644 --- a/Emby.Server.Implementations/Localization/LocalizationManager.cs +++ b/Emby.Server.Implementations/Localization/LocalizationManager.cs @@ -310,7 +310,7 @@ namespace Emby.Server.Implementations.Localization return _dictionaries.GetOrAdd( culture, - (key, localizationManager) => localizationManager.GetDictionary(Prefix, key, DefaultCulture + ".json").GetAwaiter().GetResult(), + static (key, localizationManager) => localizationManager.GetDictionary(Prefix, key, DefaultCulture + ".json").GetAwaiter().GetResult(), this); } @@ -372,43 +372,76 @@ namespace Emby.Server.Implementations.Localization /// <inheritdoc /> public IEnumerable<LocalizationOption> GetLocalizationOptions() { - yield return new LocalizationOption("Arabic", "ar"); - yield return new LocalizationOption("Bulgarian (Bulgaria)", "bg-BG"); - yield return new LocalizationOption("Catalan", "ca"); - yield return new LocalizationOption("Chinese Simplified", "zh-CN"); - yield return new LocalizationOption("Chinese Traditional", "zh-TW"); - yield return new LocalizationOption("Croatian", "hr"); - yield return new LocalizationOption("Czech", "cs"); - yield return new LocalizationOption("Danish", "da"); - yield return new LocalizationOption("Dutch", "nl"); + yield return new LocalizationOption("Afrikaans", "af"); + yield return new LocalizationOption("العربية", "ar"); + yield return new LocalizationOption("Беларуская", "be"); + yield return new LocalizationOption("Български", "bg-BG"); + yield return new LocalizationOption("বাংলা (বাংলাদেশ)", "bn"); + yield return new LocalizationOption("Català", "ca"); + yield return new LocalizationOption("Čeština", "cs"); + yield return new LocalizationOption("Cymraeg", "cy"); + yield return new LocalizationOption("Dansk", "da"); + yield return new LocalizationOption("Deutsch", "de"); yield return new LocalizationOption("English (United Kingdom)", "en-GB"); - yield return new LocalizationOption("English (United States)", "en-US"); - yield return new LocalizationOption("French", "fr"); - yield return new LocalizationOption("French (Canada)", "fr-CA"); - yield return new LocalizationOption("German", "de"); - yield return new LocalizationOption("Greek", "el"); - yield return new LocalizationOption("Hebrew", "he"); - yield return new LocalizationOption("Hungarian", "hu"); - yield return new LocalizationOption("Italian", "it"); - yield return new LocalizationOption("Kazakh", "kk"); - yield return new LocalizationOption("Korean", "ko"); - yield return new LocalizationOption("Lithuanian", "lt-LT"); - yield return new LocalizationOption("Malay", "ms"); - yield return new LocalizationOption("Norwegian Bokmål", "nb"); - yield return new LocalizationOption("Persian", "fa"); - yield return new LocalizationOption("Polish", "pl"); - yield return new LocalizationOption("Portuguese (Brazil)", "pt-BR"); - yield return new LocalizationOption("Portuguese (Portugal)", "pt-PT"); - yield return new LocalizationOption("Russian", "ru"); - yield return new LocalizationOption("Slovak", "sk"); - yield return new LocalizationOption("Slovenian (Slovenia)", "sl-SI"); - yield return new LocalizationOption("Spanish", "es"); - yield return new LocalizationOption("Spanish (Argentina)", "es-AR"); - yield return new LocalizationOption("Spanish (Mexico)", "es-MX"); - yield return new LocalizationOption("Swedish", "sv"); - yield return new LocalizationOption("Swiss German", "gsw"); - yield return new LocalizationOption("Turkish", "tr"); + yield return new LocalizationOption("English", "en-US"); + yield return new LocalizationOption("Ελληνικά", "el"); + yield return new LocalizationOption("Esperanto", "eo"); + yield return new LocalizationOption("Español", "es"); + yield return new LocalizationOption("Español americano", "es_419"); + yield return new LocalizationOption("Español (Argentina)", "es-AR"); + yield return new LocalizationOption("Español (Dominicana)", "es_DO"); + yield return new LocalizationOption("Español (México)", "es-MX"); + yield return new LocalizationOption("Eesti", "et"); + yield return new LocalizationOption("فارسی", "fa"); + yield return new LocalizationOption("Suomi", "fi"); + yield return new LocalizationOption("Filipino", "fil"); + yield return new LocalizationOption("Français", "fr"); + yield return new LocalizationOption("Français (Canada)", "fr-CA"); + yield return new LocalizationOption("Galego", "gl"); + yield return new LocalizationOption("Schwiizerdütsch", "gsw"); + yield return new LocalizationOption("עִבְרִית", "he"); + yield return new LocalizationOption("हिन्दी", "hi"); + yield return new LocalizationOption("Hrvatski", "hr"); + yield return new LocalizationOption("Magyar", "hu"); + yield return new LocalizationOption("Bahasa Indonesia", "id"); + yield return new LocalizationOption("Íslenska", "is"); + yield return new LocalizationOption("Italiano", "it"); + yield return new LocalizationOption("日本語", "ja"); + yield return new LocalizationOption("Qazaqşa", "kk"); + yield return new LocalizationOption("한국어", "ko"); + yield return new LocalizationOption("Lietuvių", "lt"); + yield return new LocalizationOption("Latviešu", "lv"); + yield return new LocalizationOption("Македонски", "mk"); + yield return new LocalizationOption("മലയാളം", "ml"); + yield return new LocalizationOption("मराठी", "mr"); + yield return new LocalizationOption("Bahasa Melayu", "ms"); + yield return new LocalizationOption("Norsk bokmål", "nb"); + yield return new LocalizationOption("नेपाली", "ne"); + yield return new LocalizationOption("Nederlands", "nl"); + yield return new LocalizationOption("Norsk nynorsk", "nn"); + yield return new LocalizationOption("ਪੰਜਾਬੀ", "pa"); + yield return new LocalizationOption("Polski", "pl"); + yield return new LocalizationOption("Pirate", "pr"); + yield return new LocalizationOption("Português", "pt"); + yield return new LocalizationOption("Português (Brasil)", "pt-BR"); + yield return new LocalizationOption("Português (Portugal)", "pt-PT"); + yield return new LocalizationOption("Românește", "ro"); + yield return new LocalizationOption("Русский", "ru"); + yield return new LocalizationOption("Slovenčina", "sk"); + yield return new LocalizationOption("Slovenščina", "sl-SI"); + yield return new LocalizationOption("Shqip", "sq"); + yield return new LocalizationOption("Српски", "sr"); + yield return new LocalizationOption("Svenska", "sv"); + yield return new LocalizationOption("தமிழ்", "ta"); + yield return new LocalizationOption("తెలుగు", "te"); + yield return new LocalizationOption("ภาษาไทย", "th"); + yield return new LocalizationOption("Türkçe", "tr"); + yield return new LocalizationOption("Українська", "uk"); + yield return new LocalizationOption("اُردُو", "ur_PK"); yield return new LocalizationOption("Tiếng Việt", "vi"); + yield return new LocalizationOption("汉语 (简化字)", "zh-CN"); + yield return new LocalizationOption("漢語 (繁体字)", "zh-TW"); + yield return new LocalizationOption("廣東話 (香港)", "zh-HK"); } } } diff --git a/Emby.Server.Implementations/Localization/countries.json b/Emby.Server.Implementations/Localization/countries.json index b08a3ae79..22ffc5e09 100644 --- a/Emby.Server.Implementations/Localization/countries.json +++ b/Emby.Server.Implementations/Localization/countries.json @@ -630,7 +630,7 @@ "TwoLetterISORegionName": "MD" }, { - "DisplayName": "Réunion", + "DisplayName": "Réunion", "Name": "RE", "ThreeLetterISORegionName": "REU", "TwoLetterISORegionName": "RE" diff --git a/Emby.Server.Implementations/Localization/iso6392.txt b/Emby.Server.Implementations/Localization/iso6392.txt index 488901822..66fba3330 100644 --- a/Emby.Server.Implementations/Localization/iso6392.txt +++ b/Emby.Server.Implementations/Localization/iso6392.txt @@ -349,7 +349,8 @@ pli||pi|Pali|pali pol||pl|Polish|polonais pon|||Pohnpeian|pohnpei por||pt|Portuguese|portugais -pob||pt-br|Portuguese (Brazil)|portugais +pop||pt-pt|Portuguese (Portugal)|portugais (pt-pt) +pob||pt-br|Portuguese (Brazil)|portugais (pt-br) pra|||Prakrit languages|prâkrit, langues pro|||Provençal, Old (to 1500)|provençal ancien (jusqu'à 1500) pus||ps|Pushto; Pashto|pachto diff --git a/Emby.Server.Implementations/MediaEncoder/EncodingManager.cs b/Emby.Server.Implementations/MediaEncoder/EncodingManager.cs index 8aaa1f7bb..6e1dc725d 100644 --- a/Emby.Server.Implementations/MediaEncoder/EncodingManager.cs +++ b/Emby.Server.Implementations/MediaEncoder/EncodingManager.cs @@ -9,6 +9,7 @@ using System.IO; using System.Linq; using System.Threading; using System.Threading.Tasks; +using Jellyfin.Extensions; using MediaBrowser.Controller.Chapters; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; @@ -23,7 +24,6 @@ namespace Emby.Server.Implementations.MediaEncoder { public class EncodingManager : IEncodingManager { - private readonly CultureInfo _usCulture = new CultureInfo("en-US"); private readonly IFileSystem _fileSystem; private readonly ILogger<EncodingManager> _logger; private readonly IMediaEncoder _encoder; @@ -121,7 +121,7 @@ namespace Emby.Server.Implementations.MediaEncoder var path = GetChapterImagePath(video, chapter.StartPositionTicks); - if (!currentImages.Contains(path, StringComparer.OrdinalIgnoreCase)) + if (!currentImages.Contains(path, StringComparison.OrdinalIgnoreCase)) { if (extractImages) { @@ -193,7 +193,7 @@ namespace Emby.Server.Implementations.MediaEncoder private string GetChapterImagePath(Video video, long chapterPositionTicks) { - var filename = video.DateModified.Ticks.ToString(_usCulture) + "_" + chapterPositionTicks.ToString(_usCulture) + ".jpg"; + var filename = video.DateModified.Ticks.ToString(CultureInfo.InvariantCulture) + "_" + chapterPositionTicks.ToString(CultureInfo.InvariantCulture) + ".jpg"; return Path.Combine(GetChapterImagesPath(video), filename); } @@ -220,7 +220,7 @@ namespace Emby.Server.Implementations.MediaEncoder { var deadImages = images .Except(chapters.Select(i => i.ImagePath).Where(i => !string.IsNullOrEmpty(i)), StringComparer.OrdinalIgnoreCase) - .Where(i => BaseItem.SupportedImageExtensions.Contains(Path.GetExtension(i), StringComparer.OrdinalIgnoreCase)) + .Where(i => BaseItem.SupportedImageExtensions.Contains(Path.GetExtension(i), StringComparison.OrdinalIgnoreCase)) .ToList(); foreach (var image in deadImages) diff --git a/Emby.Server.Implementations/Net/SocketFactory.cs b/Emby.Server.Implementations/Net/SocketFactory.cs index 6d0c8731e..fd3fc31c9 100644 --- a/Emby.Server.Implementations/Net/SocketFactory.cs +++ b/Emby.Server.Implementations/Net/SocketFactory.cs @@ -19,7 +19,7 @@ namespace Emby.Server.Implementations.Net throw new ArgumentException("localPort cannot be less than zero.", nameof(localPort)); } - var retVal = new Socket(AddressFamily.InterNetwork, System.Net.Sockets.SocketType.Dgram, System.Net.Sockets.ProtocolType.Udp); + var retVal = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp); try { retVal.EnableBroadcast = true; @@ -44,7 +44,7 @@ namespace Emby.Server.Implementations.Net throw new ArgumentException("localPort cannot be less than zero.", nameof(localPort)); } - var retVal = new Socket(AddressFamily.InterNetwork, System.Net.Sockets.SocketType.Dgram, System.Net.Sockets.ProtocolType.Udp); + var retVal = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp); try { retVal.EnableBroadcast = true; @@ -85,7 +85,7 @@ namespace Emby.Server.Implementations.Net throw new ArgumentException("localPort cannot be less than zero.", nameof(localPort)); } - var retVal = new Socket(AddressFamily.InterNetwork, System.Net.Sockets.SocketType.Dgram, System.Net.Sockets.ProtocolType.Udp); + var retVal = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp); try { diff --git a/Emby.Server.Implementations/Net/UdpSocket.cs b/Emby.Server.Implementations/Net/UdpSocket.cs index 9b799e854..0c451ccb6 100644 --- a/Emby.Server.Implementations/Net/UdpSocket.cs +++ b/Emby.Server.Implementations/Net/UdpSocket.cs @@ -16,11 +16,7 @@ namespace Emby.Server.Implementations.Net public sealed class UdpSocket : ISocket, IDisposable { - private Socket _socket; private readonly int _localPort; - private bool _disposed = false; - - public Socket Socket => _socket; private readonly SocketAsyncEventArgs _receiveSocketAsyncEventArgs = new SocketAsyncEventArgs() { @@ -32,6 +28,8 @@ namespace Emby.Server.Implementations.Net SocketFlags = SocketFlags.None }; + private Socket _socket; + private bool _disposed = false; private TaskCompletionSource<SocketReceiveResult> _currentReceiveTaskCompletionSource; private TaskCompletionSource<int> _currentSendTaskCompletionSource; @@ -64,6 +62,8 @@ namespace Emby.Server.Implementations.Net InitReceiveSocketAsyncEventArgs(); } + public Socket Socket => _socket; + public IPAddress LocalIPAddress { get; } private void InitReceiveSocketAsyncEventArgs() diff --git a/Emby.Server.Implementations/Playlists/PlaylistManager.cs b/Emby.Server.Implementations/Playlists/PlaylistManager.cs index b07798fa4..02df2fffe 100644 --- a/Emby.Server.Implementations/Playlists/PlaylistManager.cs +++ b/Emby.Server.Implementations/Playlists/PlaylistManager.cs @@ -72,7 +72,7 @@ namespace Emby.Server.Implementations.Playlists var parentFolder = GetPlaylistsFolder(Guid.Empty); if (parentFolder == null) { - throw new ArgumentException(); + throw new ArgumentException(nameof(parentFolder)); } if (string.IsNullOrEmpty(options.MediaType)) @@ -527,7 +527,7 @@ namespace Emby.Server.Implementations.Playlists var relativeUri = folderUri.MakeRelativeUri(fileAbsoluteUri); string relativePath = Uri.UnescapeDataString(relativeUri.ToString()); - if (fileAbsoluteUri.Scheme.Equals("file", StringComparison.CurrentCultureIgnoreCase)) + if (fileAbsoluteUri.Scheme.Equals("file", StringComparison.OrdinalIgnoreCase)) { relativePath = relativePath.Replace(Path.AltDirectorySeparatorChar, Path.DirectorySeparatorChar); } diff --git a/Emby.Server.Implementations/Playlists/ManualPlaylistsFolder.cs b/Emby.Server.Implementations/Playlists/PlaylistsFolder.cs similarity index 94% rename from Emby.Server.Implementations/Playlists/ManualPlaylistsFolder.cs rename to Emby.Server.Implementations/Playlists/PlaylistsFolder.cs index 4160f3a50..8ec9f6161 100644 --- a/Emby.Server.Implementations/Playlists/ManualPlaylistsFolder.cs +++ b/Emby.Server.Implementations/Playlists/PlaylistsFolder.cs @@ -4,6 +4,7 @@ using System.Collections.Generic; using System.Linq; using System.Text.Json.Serialization; using Jellyfin.Data.Entities; +using Jellyfin.Data.Enums; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Playlists; using MediaBrowser.Model.Querying; @@ -17,6 +18,15 @@ namespace Emby.Server.Implementations.Playlists Name = "Playlists"; } + [JsonIgnore] + public override bool IsHidden => true; + + [JsonIgnore] + public override bool SupportsInheritedParentImages => false; + + [JsonIgnore] + public override string CollectionType => MediaBrowser.Model.Entities.CollectionType.Playlists; + public override bool IsVisible(User user) { return base.IsVisible(user) && GetChildren(user, true).Any(); @@ -27,15 +37,6 @@ namespace Emby.Server.Implementations.Playlists return base.GetEligibleChildrenForRecursiveChildren(user).OfType<Playlist>(); } - [JsonIgnore] - public override bool IsHidden => true; - - [JsonIgnore] - public override bool SupportsInheritedParentImages => false; - - [JsonIgnore] - public override string CollectionType => MediaBrowser.Model.Entities.CollectionType.Playlists; - protected override QueryResult<BaseItem> GetItemsInternal(InternalItemsQuery query) { if (query.User == null) @@ -45,7 +46,7 @@ namespace Emby.Server.Implementations.Playlists } query.Recursive = true; - query.IncludeItemTypes = new[] { "Playlist" }; + query.IncludeItemTypes = new[] { BaseItemKind.Playlist }; query.Parent = null; return LibraryManager.GetItemsResult(query); } diff --git a/Emby.Server.Implementations/Plugins/PluginManager.cs b/Emby.Server.Implementations/Plugins/PluginManager.cs index b8e1dc2c0..a805924dd 100644 --- a/Emby.Server.Implementations/Plugins/PluginManager.cs +++ b/Emby.Server.Implementations/Plugins/PluginManager.cs @@ -8,10 +8,10 @@ using System.Reflection; using System.Text; using System.Text.Json; using System.Threading.Tasks; -using MediaBrowser.Common; -using MediaBrowser.Common.Extensions; using Jellyfin.Extensions.Json; using Jellyfin.Extensions.Json.Converters; +using MediaBrowser.Common; +using MediaBrowser.Common.Extensions; using MediaBrowser.Common.Net; using MediaBrowser.Common.Plugins; using MediaBrowser.Model.Configuration; @@ -39,14 +39,6 @@ namespace Emby.Server.Implementations.Plugins private IHttpClientFactory? _httpClientFactory; - private IHttpClientFactory HttpClientFactory - { - get - { - return _httpClientFactory ?? (_httpClientFactory = _appHost.Resolve<IHttpClientFactory>()); - } - } - /// <summary> /// Initializes a new instance of the <see cref="PluginManager"/> class. /// </summary> @@ -86,6 +78,14 @@ namespace Emby.Server.Implementations.Plugins _plugins = Directory.Exists(_pluginsPath) ? DiscoverPlugins().ToList() : new List<LocalPlugin>(); } + private IHttpClientFactory HttpClientFactory + { + get + { + return _httpClientFactory ??= _appHost.Resolve<IHttpClientFactory>(); + } + } + /// <summary> /// Gets the Plugins. /// </summary> @@ -126,7 +126,8 @@ namespace Emby.Server.Implementations.Plugins { assembly = Assembly.LoadFrom(file); - assembly.GetExportedTypes(); + // Load all required types to verify that the plugin will load + assembly.GetTypes(); } catch (FileLoadException ex) { @@ -134,7 +135,7 @@ namespace Emby.Server.Implementations.Plugins ChangePluginState(plugin, PluginStatus.Malfunctioned); continue; } - catch (TypeLoadException ex) // Undocumented exception + catch (SystemException ex) when (ex is TypeLoadException or ReflectionTypeLoadException) // Undocumented exception { _logger.LogError(ex, "Failed to load assembly {Path}. This error occurs when a plugin references an incompatible version of one of the shared libraries. Disabling plugin.", file); ChangePluginState(plugin, PluginStatus.NotSupported); @@ -359,11 +360,6 @@ namespace Emby.Server.Implementations.Plugins /// <inheritdoc/> public async Task<bool> GenerateManifest(PackageInfo packageInfo, Version version, string path, PluginStatus status) { - if (packageInfo == null) - { - return false; - } - var versionInfo = packageInfo.Versions.First(v => v.Version == version.ToString()); var imagePath = string.Empty; @@ -616,7 +612,7 @@ namespace Emby.Server.Implementations.Plugins if (versionIndex != -1) { // Get the version number from the filename if possible. - metafile = Path.GetFileName(dir[..versionIndex]) ?? dir[..versionIndex]; + metafile = Path.GetFileName(dir[..versionIndex]); version = Version.TryParse(dir.AsSpan()[(versionIndex + 1)..], out Version? parsedVersion) ? parsedVersion : _appVersion; } else @@ -681,7 +677,6 @@ namespace Emby.Server.Implementations.Plugins continue; } - var manifest = entry.Manifest; var cleaned = false; var path = entry.Path; if (_config.RemoveOldPlugins) @@ -706,12 +701,6 @@ namespace Emby.Server.Implementations.Plugins } else { - if (manifest == null) - { - _logger.LogWarning("Unable to disable plugin {Path}", entry.Path); - continue; - } - ChangePluginState(entry, PluginStatus.Deleted); } } diff --git a/Emby.Server.Implementations/QuickConnect/QuickConnectManager.cs b/Emby.Server.Implementations/QuickConnect/QuickConnectManager.cs index ae773c658..532c8d1e3 100644 --- a/Emby.Server.Implementations/QuickConnect/QuickConnectManager.cs +++ b/Emby.Server.Implementations/QuickConnect/QuickConnectManager.cs @@ -18,7 +18,7 @@ namespace Emby.Server.Implementations.QuickConnect /// <summary> /// Quick connect implementation. /// </summary> - public class QuickConnectManager : IQuickConnect, IDisposable + public class QuickConnectManager : IQuickConnect { /// <summary> /// The length of user facing codes. @@ -30,9 +30,8 @@ namespace Emby.Server.Implementations.QuickConnect /// </summary> private const int Timeout = 10; - private readonly RNGCryptoServiceProvider _rng = new (); - private readonly ConcurrentDictionary<string, QuickConnectResult> _currentRequests = new (); - private readonly ConcurrentDictionary<string, (DateTime Timestamp, AuthenticationResult AuthenticationResult)> _authorizedSecrets = new (); + private readonly ConcurrentDictionary<string, QuickConnectResult> _currentRequests = new(); + private readonly ConcurrentDictionary<string, (DateTime Timestamp, AuthenticationResult AuthenticationResult)> _authorizedSecrets = new(); private readonly IServerConfigurationManager _config; private readonly ILogger<QuickConnectManager> _logger; @@ -140,7 +139,7 @@ namespace Emby.Server.Implementations.QuickConnect uint scale = uint.MaxValue; while (scale == uint.MaxValue) { - _rng.GetBytes(raw); + RandomNumberGenerator.Fill(raw); scale = BitConverter.ToUInt32(raw); } @@ -199,31 +198,10 @@ namespace Emby.Server.Implementations.QuickConnect return result.AuthenticationResult; } - /// <summary> - /// Dispose. - /// </summary> - public void Dispose() - { - Dispose(true); - GC.SuppressFinalize(this); - } - - /// <summary> - /// Dispose. - /// </summary> - /// <param name="disposing">Dispose unmanaged resources.</param> - protected virtual void Dispose(bool disposing) - { - if (disposing) - { - _rng.Dispose(); - } - } - private string GenerateSecureRandom(int length = 32) { Span<byte> bytes = stackalloc byte[length]; - _rng.GetBytes(bytes); + RandomNumberGenerator.Fill(bytes); return Convert.ToHexString(bytes); } diff --git a/Emby.Server.Implementations/ScheduledTasks/ScheduledTaskWorker.cs b/Emby.Server.Implementations/ScheduledTasks/ScheduledTaskWorker.cs index fb93c375d..299f10544 100644 --- a/Emby.Server.Implementations/ScheduledTasks/ScheduledTaskWorker.cs +++ b/Emby.Server.Implementations/ScheduledTasks/ScheduledTaskWorker.cs @@ -9,10 +9,11 @@ using System.Linq; using System.Text.Json; using System.Threading; using System.Threading.Tasks; +using Emby.Server.Implementations.ScheduledTasks.Triggers; using Jellyfin.Data.Events; +using Jellyfin.Extensions.Json; using MediaBrowser.Common.Configuration; using MediaBrowser.Common.Extensions; -using Jellyfin.Extensions.Json; using MediaBrowser.Common.Progress; using MediaBrowser.Model.Tasks; using Microsoft.Extensions.Logging; @@ -24,6 +25,11 @@ namespace Emby.Server.Implementations.ScheduledTasks /// </summary> public class ScheduledTaskWorker : IScheduledTaskWorker { + /// <summary> + /// The options for the json Serializer. + /// </summary> + private readonly JsonSerializerOptions _jsonOptions = JsonDefaults.Options; + /// <summary> /// Gets or sets the application paths. /// </summary> @@ -66,11 +72,6 @@ namespace Emby.Server.Implementations.ScheduledTasks /// </summary> private string _id; - /// <summary> - /// The options for the json Serializer. - /// </summary> - private readonly JsonSerializerOptions _jsonOptions = JsonDefaults.Options; - /// <summary> /// Initializes a new instance of the <see cref="ScheduledTaskWorker" /> class. /// </summary> @@ -365,7 +366,7 @@ namespace Emby.Server.Implementations.ScheduledTasks /// </summary> /// <param name="options">Task options.</param> /// <returns>Task.</returns> - /// <exception cref="InvalidOperationException">Cannot execute a Task that is already running</exception> + /// <exception cref="InvalidOperationException">Cannot execute a Task that is already running.</exception> public async Task Execute(TaskOptions options) { var task = Task.Run(async () => await ExecuteInternal(options).ConfigureAwait(false)); @@ -638,7 +639,7 @@ namespace Emby.Server.Implementations.ScheduledTasks { try { - _logger.LogInformation(Name + ": Cancelling"); + _logger.LogInformation("{Name}: Cancelling", Name); token.Cancel(); } catch (Exception ex) @@ -652,16 +653,16 @@ namespace Emby.Server.Implementations.ScheduledTasks { try { - _logger.LogInformation(Name + ": Waiting on Task"); + _logger.LogInformation("{Name}: Waiting on Task", Name); var exited = task.Wait(2000); if (exited) { - _logger.LogInformation(Name + ": Task exited"); + _logger.LogInformation("{Name}: Task exited", Name); } else { - _logger.LogInformation(Name + ": Timed out waiting for task to stop"); + _logger.LogInformation("{Name}: Timed out waiting for task to stop", Name); } } catch (Exception ex) @@ -674,7 +675,7 @@ namespace Emby.Server.Implementations.ScheduledTasks { try { - _logger.LogDebug(Name + ": Disposing CancellationToken"); + _logger.LogDebug("{Name}: Disposing CancellationToken", Name); token.Dispose(); } catch (Exception ex) diff --git a/Emby.Server.Implementations/ScheduledTasks/TaskManager.cs b/Emby.Server.Implementations/ScheduledTasks/TaskManager.cs index 4f0df75bf..0431858fc 100644 --- a/Emby.Server.Implementations/ScheduledTasks/TaskManager.cs +++ b/Emby.Server.Implementations/ScheduledTasks/TaskManager.cs @@ -19,16 +19,6 @@ namespace Emby.Server.Implementations.ScheduledTasks /// </summary> public class TaskManager : ITaskManager { - public event EventHandler<GenericEventArgs<IScheduledTaskWorker>> TaskExecuting; - - public event EventHandler<TaskCompletionEventArgs> TaskCompleted; - - /// <summary> - /// Gets the list of Scheduled Tasks. - /// </summary> - /// <value>The scheduled tasks.</value> - public IScheduledTaskWorker[] ScheduledTasks { get; private set; } - /// <summary> /// The _task queue. /// </summary> @@ -53,10 +43,20 @@ namespace Emby.Server.Implementations.ScheduledTasks ScheduledTasks = Array.Empty<IScheduledTaskWorker>(); } + public event EventHandler<GenericEventArgs<IScheduledTaskWorker>> TaskExecuting; + + public event EventHandler<TaskCompletionEventArgs> TaskCompleted; + + /// <summary> + /// Gets the list of Scheduled Tasks. + /// </summary> + /// <value>The scheduled tasks.</value> + public IScheduledTaskWorker[] ScheduledTasks { get; private set; } + /// <summary> /// Cancels if running and queue. /// </summary> - /// <typeparam name="T"></typeparam> + /// <typeparam name="T">The task type.</typeparam> /// <param name="options">Task options.</param> public void CancelIfRunningAndQueue<T>(TaskOptions options) where T : IScheduledTask @@ -76,7 +76,7 @@ namespace Emby.Server.Implementations.ScheduledTasks /// <summary> /// Cancels if running. /// </summary> - /// <typeparam name="T"></typeparam> + /// <typeparam name="T">The task type.</typeparam> public void CancelIfRunning<T>() where T : IScheduledTask { @@ -87,7 +87,7 @@ namespace Emby.Server.Implementations.ScheduledTasks /// <summary> /// Queues the scheduled task. /// </summary> - /// <typeparam name="T"></typeparam> + /// <typeparam name="T">The task type.</typeparam> /// <param name="options">Task options.</param> public void QueueScheduledTask<T>(TaskOptions options) where T : IScheduledTask diff --git a/Emby.Server.Implementations/ScheduledTasks/Tasks/ChapterImagesTask.cs b/Emby.Server.Implementations/ScheduledTasks/Tasks/ChapterImagesTask.cs index b764a139c..a5786a3d7 100644 --- a/Emby.Server.Implementations/ScheduledTasks/Tasks/ChapterImagesTask.cs +++ b/Emby.Server.Implementations/ScheduledTasks/Tasks/ChapterImagesTask.cs @@ -4,6 +4,7 @@ using System.IO; using System.Linq; using System.Threading; using System.Threading.Tasks; +using Jellyfin.Extensions; using MediaBrowser.Common.Configuration; using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; @@ -16,7 +17,7 @@ using MediaBrowser.Model.Globalization; using MediaBrowser.Model.IO; using MediaBrowser.Model.Tasks; -namespace Emby.Server.Implementations.ScheduledTasks +namespace Emby.Server.Implementations.ScheduledTasks.Tasks { /// <summary> /// Class ChapterImagesTask. @@ -39,6 +40,12 @@ namespace Emby.Server.Implementations.ScheduledTasks /// <summary> /// Initializes a new instance of the <see cref="ChapterImagesTask" /> class. /// </summary> + /// <param name="libraryManager">The library manager.</param>. + /// <param name="itemRepo">The item repository.</param> + /// <param name="appPaths">The application paths.</param> + /// <param name="encodingManager">The encoding manager.</param> + /// <param name="fileSystem">The filesystem.</param> + /// <param name="localization">The localization manager.</param> public ChapterImagesTask( ILibraryManager libraryManager, IItemRepository itemRepo, @@ -137,7 +144,7 @@ namespace Emby.Server.Implementations.ScheduledTasks var key = video.Path + video.DateModified.Ticks; - var extract = !previouslyFailedImages.Contains(key, StringComparer.OrdinalIgnoreCase); + var extract = !previouslyFailedImages.Contains(key, StringComparison.OrdinalIgnoreCase); try { diff --git a/Emby.Server.Implementations/ScheduledTasks/Tasks/DeleteCacheFileTask.cs b/Emby.Server.Implementations/ScheduledTasks/Tasks/DeleteCacheFileTask.cs index a575b260c..0941902fc 100644 --- a/Emby.Server.Implementations/ScheduledTasks/Tasks/DeleteCacheFileTask.cs +++ b/Emby.Server.Implementations/ScheduledTasks/Tasks/DeleteCacheFileTask.cs @@ -161,11 +161,11 @@ namespace Emby.Server.Implementations.ScheduledTasks.Tasks } catch (UnauthorizedAccessException ex) { - _logger.LogError(ex, "Error deleting directory {path}", directory); + _logger.LogError(ex, "Error deleting directory {Path}", directory); } catch (IOException ex) { - _logger.LogError(ex, "Error deleting directory {path}", directory); + _logger.LogError(ex, "Error deleting directory {Path}", directory); } } } @@ -179,11 +179,11 @@ namespace Emby.Server.Implementations.ScheduledTasks.Tasks } catch (UnauthorizedAccessException ex) { - _logger.LogError(ex, "Error deleting file {path}", path); + _logger.LogError(ex, "Error deleting file {Path}", path); } catch (IOException ex) { - _logger.LogError(ex, "Error deleting file {path}", path); + _logger.LogError(ex, "Error deleting file {Path}", path); } } } diff --git a/Emby.Server.Implementations/ScheduledTasks/Tasks/DeleteTranscodeFileTask.cs b/Emby.Server.Implementations/ScheduledTasks/Tasks/DeleteTranscodeFileTask.cs index b13fc7fc6..099d781cd 100644 --- a/Emby.Server.Implementations/ScheduledTasks/Tasks/DeleteTranscodeFileTask.cs +++ b/Emby.Server.Implementations/ScheduledTasks/Tasks/DeleteTranscodeFileTask.cs @@ -141,11 +141,11 @@ namespace Emby.Server.Implementations.ScheduledTasks.Tasks } catch (UnauthorizedAccessException ex) { - _logger.LogError(ex, "Error deleting directory {path}", directory); + _logger.LogError(ex, "Error deleting directory {Path}", directory); } catch (IOException ex) { - _logger.LogError(ex, "Error deleting directory {path}", directory); + _logger.LogError(ex, "Error deleting directory {Path}", directory); } } } @@ -159,11 +159,11 @@ namespace Emby.Server.Implementations.ScheduledTasks.Tasks } catch (UnauthorizedAccessException ex) { - _logger.LogError(ex, "Error deleting file {path}", path); + _logger.LogError(ex, "Error deleting file {Path}", path); } catch (IOException ex) { - _logger.LogError(ex, "Error deleting file {path}", path); + _logger.LogError(ex, "Error deleting file {Path}", path); } } } diff --git a/Emby.Server.Implementations/ScheduledTasks/Tasks/OptimizeDatabaseTask.cs b/Emby.Server.Implementations/ScheduledTasks/Tasks/OptimizeDatabaseTask.cs index 1ad1d0f50..35a4aeef6 100644 --- a/Emby.Server.Implementations/ScheduledTasks/Tasks/OptimizeDatabaseTask.cs +++ b/Emby.Server.Implementations/ScheduledTasks/Tasks/OptimizeDatabaseTask.cs @@ -22,6 +22,9 @@ namespace Emby.Server.Implementations.ScheduledTasks.Tasks /// <summary> /// Initializes a new instance of the <see cref="OptimizeDatabaseTask" /> class. /// </summary> + /// <param name="logger">The logger.</param> + /// <param name="localization">The localization manager.</param> + /// <param name="provider">The jellyfin DB context provider.</param> public OptimizeDatabaseTask( ILogger<OptimizeDatabaseTask> logger, ILocalizationManager localization, diff --git a/Emby.Server.Implementations/ScheduledTasks/Tasks/PeopleValidationTask.cs b/Emby.Server.Implementations/ScheduledTasks/Tasks/PeopleValidationTask.cs index 57d294a40..34780111b 100644 --- a/Emby.Server.Implementations/ScheduledTasks/Tasks/PeopleValidationTask.cs +++ b/Emby.Server.Implementations/ScheduledTasks/Tasks/PeopleValidationTask.cs @@ -8,7 +8,7 @@ using MediaBrowser.Controller.Library; using MediaBrowser.Model.Globalization; using MediaBrowser.Model.Tasks; -namespace Emby.Server.Implementations.ScheduledTasks +namespace Emby.Server.Implementations.ScheduledTasks.Tasks { /// <summary> /// Class PeopleValidationTask. @@ -32,9 +32,24 @@ namespace Emby.Server.Implementations.ScheduledTasks _localization = localization; } + public string Name => _localization.GetLocalizedString("TaskRefreshPeople"); + + public string Description => _localization.GetLocalizedString("TaskRefreshPeopleDescription"); + + public string Category => _localization.GetLocalizedString("TasksLibraryCategory"); + + public string Key => "RefreshPeople"; + + public bool IsHidden => false; + + public bool IsEnabled => true; + + public bool IsLogged => true; + /// <summary> /// Creates the triggers that define when the task will run. /// </summary> + /// <returns>An <see cref="IEnumerable{TaskTriggerInfo}"/> containing the default trigger infos for this task.</returns> public IEnumerable<TaskTriggerInfo> GetDefaultTriggers() { return new[] @@ -57,19 +72,5 @@ namespace Emby.Server.Implementations.ScheduledTasks { return _libraryManager.ValidatePeople(cancellationToken, progress); } - - public string Name => _localization.GetLocalizedString("TaskRefreshPeople"); - - public string Description => _localization.GetLocalizedString("TaskRefreshPeopleDescription"); - - public string Category => _localization.GetLocalizedString("TasksLibraryCategory"); - - public string Key => "RefreshPeople"; - - public bool IsHidden => false; - - public bool IsEnabled => true; - - public bool IsLogged => true; } } diff --git a/Emby.Server.Implementations/ScheduledTasks/Tasks/PluginUpdateTask.cs b/Emby.Server.Implementations/ScheduledTasks/Tasks/PluginUpdateTask.cs index 11a5fb79f..b3973cecb 100644 --- a/Emby.Server.Implementations/ScheduledTasks/Tasks/PluginUpdateTask.cs +++ b/Emby.Server.Implementations/ScheduledTasks/Tasks/PluginUpdateTask.cs @@ -12,7 +12,7 @@ using MediaBrowser.Model.Globalization; using MediaBrowser.Model.Tasks; using Microsoft.Extensions.Logging; -namespace Emby.Server.Implementations.ScheduledTasks +namespace Emby.Server.Implementations.ScheduledTasks.Tasks { /// <summary> /// Plugin Update Task. diff --git a/Emby.Server.Implementations/ScheduledTasks/Tasks/RefreshMediaLibraryTask.cs b/Emby.Server.Implementations/ScheduledTasks/Tasks/RefreshMediaLibraryTask.cs index 51b620404..f7b3cfedc 100644 --- a/Emby.Server.Implementations/ScheduledTasks/Tasks/RefreshMediaLibraryTask.cs +++ b/Emby.Server.Implementations/ScheduledTasks/Tasks/RefreshMediaLibraryTask.cs @@ -9,7 +9,7 @@ using MediaBrowser.Controller.Library; using MediaBrowser.Model.Globalization; using MediaBrowser.Model.Tasks; -namespace Emby.Server.Implementations.ScheduledTasks +namespace Emby.Server.Implementations.ScheduledTasks.Tasks { /// <summary> /// Class RefreshMediaLibraryTask. @@ -33,6 +33,18 @@ namespace Emby.Server.Implementations.ScheduledTasks _localization = localization; } + /// <inheritdoc /> + public string Name => _localization.GetLocalizedString("TaskRefreshLibrary"); + + /// <inheritdoc /> + public string Description => _localization.GetLocalizedString("TaskRefreshLibraryDescription"); + + /// <inheritdoc /> + public string Category => _localization.GetLocalizedString("TasksLibraryCategory"); + + /// <inheritdoc /> + public string Key => "RefreshLibrary"; + /// <summary> /// Creates the triggers that define when the task will run. /// </summary> @@ -60,26 +72,5 @@ namespace Emby.Server.Implementations.ScheduledTasks return ((LibraryManager)_libraryManager).ValidateMediaLibraryInternal(progress, cancellationToken); } - - /// <inheritdoc /> - public string Name => _localization.GetLocalizedString("TaskRefreshLibrary"); - - /// <inheritdoc /> - public string Description => _localization.GetLocalizedString("TaskRefreshLibraryDescription"); - - /// <inheritdoc /> - public string Category => _localization.GetLocalizedString("TasksLibraryCategory"); - - /// <inheritdoc /> - public string Key => "RefreshLibrary"; - - /// <inheritdoc /> - public bool IsHidden => false; - - /// <inheritdoc /> - public bool IsEnabled => true; - - /// <inheritdoc /> - public bool IsLogged => true; } } diff --git a/Emby.Server.Implementations/ScheduledTasks/Triggers/DailyTrigger.cs b/Emby.Server.Implementations/ScheduledTasks/Triggers/DailyTrigger.cs index 29ab6a73d..dc5eb7391 100644 --- a/Emby.Server.Implementations/ScheduledTasks/Triggers/DailyTrigger.cs +++ b/Emby.Server.Implementations/ScheduledTasks/Triggers/DailyTrigger.cs @@ -3,7 +3,7 @@ using System.Threading; using MediaBrowser.Model.Tasks; using Microsoft.Extensions.Logging; -namespace Emby.Server.Implementations.ScheduledTasks +namespace Emby.Server.Implementations.ScheduledTasks.Triggers { /// <summary> /// Represents a task trigger that fires everyday. @@ -41,7 +41,7 @@ namespace Emby.Server.Implementations.ScheduledTasks /// <param name="logger">The logger.</param> /// <param name="taskName">The name of the task.</param> /// <param name="isApplicationStartup">if set to <c>true</c> [is application startup].</param> - public void Start(TaskResult lastResult, ILogger logger, string taskName, bool isApplicationStartup) + public void Start(TaskResult? lastResult, ILogger logger, string taskName, bool isApplicationStartup) { DisposeTimer(); @@ -54,7 +54,7 @@ namespace Emby.Server.Implementations.ScheduledTasks logger.LogInformation("Daily trigger for {Task} set to fire at {TriggerDate:yyyy-MM-dd HH:mm:ss.fff zzz}, which is {DueTime:c} from now.", taskName, triggerDate, dueTime); - _timer = new Timer(state => OnTriggered(), null, dueTime, TimeSpan.FromMilliseconds(-1)); + _timer = new Timer(_ => OnTriggered(), null, dueTime, TimeSpan.FromMilliseconds(-1)); } /// <summary> diff --git a/Emby.Server.Implementations/ScheduledTasks/Triggers/IntervalTrigger.cs b/Emby.Server.Implementations/ScheduledTasks/Triggers/IntervalTrigger.cs index 30568e809..927f57e95 100644 --- a/Emby.Server.Implementations/ScheduledTasks/Triggers/IntervalTrigger.cs +++ b/Emby.Server.Implementations/ScheduledTasks/Triggers/IntervalTrigger.cs @@ -4,7 +4,7 @@ using System.Threading; using MediaBrowser.Model.Tasks; using Microsoft.Extensions.Logging; -namespace Emby.Server.Implementations.ScheduledTasks +namespace Emby.Server.Implementations.ScheduledTasks.Triggers { /// <summary> /// Represents a task trigger that runs repeatedly on an interval. @@ -43,7 +43,7 @@ namespace Emby.Server.Implementations.ScheduledTasks /// <param name="logger">The logger.</param> /// <param name="taskName">The name of the task.</param> /// <param name="isApplicationStartup">if set to <c>true</c> [is application startup].</param> - public void Start(TaskResult lastResult, ILogger logger, string taskName, bool isApplicationStartup) + public void Start(TaskResult? lastResult, ILogger logger, string taskName, bool isApplicationStartup) { DisposeTimer(); @@ -72,7 +72,7 @@ namespace Emby.Server.Implementations.ScheduledTasks dueTime = maxDueTime; } - _timer = new Timer(state => OnTriggered(), null, dueTime, TimeSpan.FromMilliseconds(-1)); + _timer = new Timer(_ => OnTriggered(), null, dueTime, TimeSpan.FromMilliseconds(-1)); } /// <summary> diff --git a/Emby.Server.Implementations/ScheduledTasks/Triggers/StartupTrigger.cs b/Emby.Server.Implementations/ScheduledTasks/Triggers/StartupTrigger.cs index 18b9a8b75..b16693c07 100644 --- a/Emby.Server.Implementations/ScheduledTasks/Triggers/StartupTrigger.cs +++ b/Emby.Server.Implementations/ScheduledTasks/Triggers/StartupTrigger.cs @@ -5,7 +5,7 @@ using System.Threading.Tasks; using MediaBrowser.Model.Tasks; using Microsoft.Extensions.Logging; -namespace Emby.Server.Implementations.ScheduledTasks +namespace Emby.Server.Implementations.ScheduledTasks.Triggers { /// <summary> /// Class StartupTaskTrigger. @@ -40,7 +40,7 @@ namespace Emby.Server.Implementations.ScheduledTasks /// <param name="logger">The logger.</param> /// <param name="taskName">The name of the task.</param> /// <param name="isApplicationStartup">if set to <c>true</c> [is application startup].</param> - public async void Start(TaskResult lastResult, ILogger logger, string taskName, bool isApplicationStartup) + public async void Start(TaskResult? lastResult, ILogger logger, string taskName, bool isApplicationStartup) { if (isApplicationStartup) { diff --git a/Emby.Server.Implementations/ScheduledTasks/Triggers/WeeklyTrigger.cs b/Emby.Server.Implementations/ScheduledTasks/Triggers/WeeklyTrigger.cs index 36ae190b0..2392b20fd 100644 --- a/Emby.Server.Implementations/ScheduledTasks/Triggers/WeeklyTrigger.cs +++ b/Emby.Server.Implementations/ScheduledTasks/Triggers/WeeklyTrigger.cs @@ -3,7 +3,7 @@ using System.Threading; using MediaBrowser.Model.Tasks; using Microsoft.Extensions.Logging; -namespace Emby.Server.Implementations.ScheduledTasks +namespace Emby.Server.Implementations.ScheduledTasks.Triggers { /// <summary> /// Represents a task trigger that fires on a weekly basis. @@ -44,13 +44,13 @@ namespace Emby.Server.Implementations.ScheduledTasks /// <param name="logger">The logger.</param> /// <param name="taskName">The name of the task.</param> /// <param name="isApplicationStartup">if set to <c>true</c> [is application startup].</param> - public void Start(TaskResult lastResult, ILogger logger, string taskName, bool isApplicationStartup) + public void Start(TaskResult? lastResult, ILogger logger, string taskName, bool isApplicationStartup) { DisposeTimer(); var triggerDate = GetNextTriggerDateTime(); - _timer = new Timer(state => OnTriggered(), null, triggerDate - DateTime.Now, TimeSpan.FromMilliseconds(-1)); + _timer = new Timer(_ => OnTriggered(), null, triggerDate - DateTime.Now, TimeSpan.FromMilliseconds(-1)); } /// <summary> diff --git a/Emby.Server.Implementations/Serialization/MyXmlSerializer.cs b/Emby.Server.Implementations/Serialization/MyXmlSerializer.cs index 059211a0b..1bac2600c 100644 --- a/Emby.Server.Implementations/Serialization/MyXmlSerializer.cs +++ b/Emby.Server.Implementations/Serialization/MyXmlSerializer.cs @@ -21,7 +21,7 @@ namespace Emby.Server.Implementations.Serialization private static XmlSerializer GetSerializer(Type type) => _serializers.GetOrAdd( type.FullName ?? throw new ArgumentException($"Invalid type {type}."), - (_, t) => new XmlSerializer(t), + static (_, t) => new XmlSerializer(t), type); /// <summary> diff --git a/Emby.Server.Implementations/ServerApplicationPaths.cs b/Emby.Server.Implementations/ServerApplicationPaths.cs index 6cf9a8f71..369a2b0d8 100644 --- a/Emby.Server.Implementations/ServerApplicationPaths.cs +++ b/Emby.Server.Implementations/ServerApplicationPaths.cs @@ -12,6 +12,11 @@ namespace Emby.Server.Implementations /// <summary> /// Initializes a new instance of the <see cref="ServerApplicationPaths" /> class. /// </summary> + /// <param name="programDataPath">The path for Jellyfin's data.</param> + /// <param name="logDirectoryPath">The path for Jellyfin's logging directory.</param> + /// <param name="configurationDirectoryPath">The path for Jellyfin's configuration directory.</param> + /// <param name="cacheDirectoryPath">The path for Jellyfin's cache directory.</param> + /// <param name="webDirectoryPath">The path for Jellyfin's web UI.</param> public ServerApplicationPaths( string programDataPath, string logDirectoryPath, diff --git a/Emby.Server.Implementations/Session/SessionManager.cs b/Emby.Server.Implementations/Session/SessionManager.cs index 4111590c8..6c679ea20 100644 --- a/Emby.Server.Implementations/Session/SessionManager.cs +++ b/Emby.Server.Implementations/Session/SessionManager.cs @@ -60,7 +60,7 @@ namespace Emby.Server.Implementations.Session /// <summary> /// The active connections. /// </summary> - private readonly ConcurrentDictionary<string, SessionInfo> _activeConnections = new (StringComparer.OrdinalIgnoreCase); + private readonly ConcurrentDictionary<string, SessionInfo> _activeConnections = new(StringComparer.OrdinalIgnoreCase); private Timer _idleTimer; @@ -741,6 +741,8 @@ namespace Emby.Server.Implementations.Session /// <summary> /// Used to report playback progress for an item. /// </summary> + /// <param name="info">The playback progress info.</param> + /// <param name="isAutomated">Whether this is an automated update.</param> /// <returns>Task.</returns> public async Task OnPlaybackProgress(PlaybackProgressInfo info, bool isAutomated) { @@ -1199,16 +1201,18 @@ namespace Emby.Server.Implementations.Session } /// <inheritdoc /> - public async Task SendSyncPlayCommand(SessionInfo session, SendCommand command, CancellationToken cancellationToken) + public async Task SendSyncPlayCommand(string sessionId, SendCommand command, CancellationToken cancellationToken) { CheckDisposed(); + var session = GetSession(sessionId); await SendMessageToSession(session, SessionMessageType.SyncPlayCommand, command, cancellationToken).ConfigureAwait(false); } /// <inheritdoc /> - public async Task SendSyncPlayGroupUpdate<T>(SessionInfo session, GroupUpdate<T> command, CancellationToken cancellationToken) + public async Task SendSyncPlayGroupUpdate<T>(string sessionId, GroupUpdate<T> command, CancellationToken cancellationToken) { CheckDisposed(); + var session = GetSession(sessionId); await SendMessageToSession(session, SessionMessageType.SyncPlayGroupUpdate, command, cancellationToken).ConfigureAwait(false); } @@ -1288,7 +1292,7 @@ namespace Emby.Server.Implementations.Session { ["ItemId"] = command.ItemId, ["ItemName"] = command.ItemName, - ["ItemType"] = command.ItemType + ["ItemType"] = command.ItemType.ToString() } }; diff --git a/Emby.Server.Implementations/Session/SessionWebSocketListener.cs b/Emby.Server.Implementations/Session/SessionWebSocketListener.cs index 2a14a8c7b..a085ee546 100644 --- a/Emby.Server.Implementations/Session/SessionWebSocketListener.cs +++ b/Emby.Server.Implementations/Session/SessionWebSocketListener.cs @@ -6,6 +6,7 @@ using System.Linq; using System.Net.WebSockets; using System.Threading; using System.Threading.Tasks; +using MediaBrowser.Common.Extensions; using MediaBrowser.Controller.Net; using MediaBrowser.Controller.Session; using MediaBrowser.Model.Net; @@ -50,16 +51,10 @@ namespace Emby.Server.Implementations.Session /// </summary> private readonly object _webSocketsLock = new object(); - /// <summary> - /// The _session manager. - /// </summary> private readonly ISessionManager _sessionManager; - - /// <summary> - /// The _logger. - /// </summary> private readonly ILogger<SessionWebSocketListener> _logger; private readonly ILoggerFactory _loggerFactory; + private readonly IAuthorizationContext _authorizationContext; /// <summary> /// The KeepAlive cancellation token. @@ -72,14 +67,17 @@ namespace Emby.Server.Implementations.Session /// <param name="logger">The logger.</param> /// <param name="sessionManager">The session manager.</param> /// <param name="loggerFactory">The logger factory.</param> + /// <param name="authorizationContext">The authorization context.</param> public SessionWebSocketListener( ILogger<SessionWebSocketListener> logger, ISessionManager sessionManager, - ILoggerFactory loggerFactory) + ILoggerFactory loggerFactory, + IAuthorizationContext authorizationContext) { _logger = logger; _sessionManager = sessionManager; _loggerFactory = loggerFactory; + _authorizationContext = authorizationContext; } /// <inheritdoc /> @@ -97,9 +95,9 @@ namespace Emby.Server.Implementations.Session => Task.CompletedTask; /// <inheritdoc /> - public async Task ProcessWebSocketConnectedAsync(IWebSocketConnection connection) + public async Task ProcessWebSocketConnectedAsync(IWebSocketConnection connection, HttpContext httpContext) { - var session = await GetSession(connection.QueryString, connection.RemoteEndPoint.ToString()).ConfigureAwait(false); + var session = await GetSession(httpContext, connection.RemoteEndPoint?.ToString()).ConfigureAwait(false); if (session != null) { EnsureController(session, connection); @@ -107,25 +105,28 @@ namespace Emby.Server.Implementations.Session } else { - _logger.LogWarning("Unable to determine session based on query string: {0}", connection.QueryString); + _logger.LogWarning("Unable to determine session based on query string: {0}", httpContext.Request.QueryString); } } - private Task<SessionInfo> GetSession(IQueryCollection queryString, string remoteEndpoint) + private async Task<SessionInfo> GetSession(HttpContext httpContext, string remoteEndpoint) { - if (queryString == null) + var authorizationInfo = await _authorizationContext.GetAuthorizationInfo(httpContext) + .ConfigureAwait(false); + + if (!authorizationInfo.IsAuthenticated) { return null; } - var token = queryString["api_key"]; - if (string.IsNullOrWhiteSpace(token)) + var deviceId = authorizationInfo.DeviceId; + if (httpContext.Request.Query.TryGetValue("deviceId", out var queryDeviceId)) { - return null; + deviceId = queryDeviceId; } - var deviceId = queryString["deviceId"]; - return _sessionManager.GetSessionByAuthenticationToken(token, deviceId, remoteEndpoint); + return await _sessionManager.GetSessionByAuthenticationToken(authorizationInfo.Token, deviceId, remoteEndpoint) + .ConfigureAwait(false); } private void EnsureController(SessionInfo session, IWebSocketConnection connection) diff --git a/Emby.Server.Implementations/Sorting/AiredEpisodeOrderComparer.cs b/Emby.Server.Implementations/Sorting/AiredEpisodeOrderComparer.cs index 2b0ab536f..db8b68949 100644 --- a/Emby.Server.Implementations/Sorting/AiredEpisodeOrderComparer.cs +++ b/Emby.Server.Implementations/Sorting/AiredEpisodeOrderComparer.cs @@ -10,6 +10,12 @@ namespace Emby.Server.Implementations.Sorting { public class AiredEpisodeOrderComparer : IBaseItemComparer { + /// <summary> + /// Gets the name. + /// </summary> + /// <value>The name.</value> + public string Name => ItemSortBy.AiredEpisodeOrder; + /// <summary> /// Compares the specified x. /// </summary> @@ -28,16 +34,6 @@ namespace Emby.Server.Implementations.Sorting throw new ArgumentNullException(nameof(y)); } - if (x.PremiereDate.HasValue && y.PremiereDate.HasValue) - { - var val = DateTime.Compare(x.PremiereDate.Value, y.PremiereDate.Value); - - if (val != 0) - { - // return val; - } - } - var episode1 = x as Episode; var episode2 = y as Episode; @@ -156,14 +152,14 @@ namespace Emby.Server.Implementations.Sorting { var xValue = ((x.ParentIndexNumber ?? -1) * 1000) + (x.IndexNumber ?? -1); var yValue = ((y.ParentIndexNumber ?? -1) * 1000) + (y.IndexNumber ?? -1); + var comparisonResult = xValue.CompareTo(yValue); + // If equal, compare premiere dates + if (comparisonResult == 0 && x.PremiereDate.HasValue && y.PremiereDate.HasValue) + { + comparisonResult = DateTime.Compare(x.PremiereDate.Value, y.PremiereDate.Value); + } - return xValue.CompareTo(yValue); + return comparisonResult; } - - /// <summary> - /// Gets the name. - /// </summary> - /// <value>The name.</value> - public string Name => ItemSortBy.AiredEpisodeOrder; } } diff --git a/Emby.Server.Implementations/Sorting/AlbumArtistComparer.cs b/Emby.Server.Implementations/Sorting/AlbumArtistComparer.cs index 42e644970..67a9fbd3b 100644 --- a/Emby.Server.Implementations/Sorting/AlbumArtistComparer.cs +++ b/Emby.Server.Implementations/Sorting/AlbumArtistComparer.cs @@ -12,6 +12,12 @@ namespace Emby.Server.Implementations.Sorting /// </summary> public class AlbumArtistComparer : IBaseItemComparer { + /// <summary> + /// Gets the name. + /// </summary> + /// <value>The name.</value> + public string Name => ItemSortBy.AlbumArtist; + /// <summary> /// Compares the specified x. /// </summary> @@ -20,7 +26,7 @@ namespace Emby.Server.Implementations.Sorting /// <returns>System.Int32.</returns> public int Compare(BaseItem? x, BaseItem? y) { - return string.Compare(GetValue(x), GetValue(y), StringComparison.CurrentCultureIgnoreCase); + return string.Compare(GetValue(x), GetValue(y), StringComparison.OrdinalIgnoreCase); } /// <summary> @@ -34,11 +40,5 @@ namespace Emby.Server.Implementations.Sorting return audio?.AlbumArtists.FirstOrDefault(); } - - /// <summary> - /// Gets the name. - /// </summary> - /// <value>The name.</value> - public string Name => ItemSortBy.AlbumArtist; } } diff --git a/Emby.Server.Implementations/Sorting/AlbumComparer.cs b/Emby.Server.Implementations/Sorting/AlbumComparer.cs index 1db3f5e9c..4bed0fca1 100644 --- a/Emby.Server.Implementations/Sorting/AlbumComparer.cs +++ b/Emby.Server.Implementations/Sorting/AlbumComparer.cs @@ -11,6 +11,12 @@ namespace Emby.Server.Implementations.Sorting /// </summary> public class AlbumComparer : IBaseItemComparer { + /// <summary> + /// Gets the name. + /// </summary> + /// <value>The name.</value> + public string Name => ItemSortBy.Album; + /// <summary> /// Compares the specified x. /// </summary> @@ -19,7 +25,7 @@ namespace Emby.Server.Implementations.Sorting /// <returns>System.Int32.</returns> public int Compare(BaseItem? x, BaseItem? y) { - return string.Compare(GetValue(x), GetValue(y), StringComparison.CurrentCultureIgnoreCase); + return string.Compare(GetValue(x), GetValue(y), StringComparison.OrdinalIgnoreCase); } /// <summary> @@ -27,17 +33,9 @@ namespace Emby.Server.Implementations.Sorting /// </summary> /// <param name="x">The x.</param> /// <returns>System.String.</returns> - private static string? GetValue(BaseItem? x) + private static string GetValue(BaseItem? x) { - var audio = x as Audio; - - return audio == null ? string.Empty : audio.Album; + return x is Audio audio ? audio.Album : string.Empty; } - - /// <summary> - /// Gets the name. - /// </summary> - /// <value>The name.</value> - public string Name => ItemSortBy.Album; } } diff --git a/Emby.Server.Implementations/Sorting/ArtistComparer.cs b/Emby.Server.Implementations/Sorting/ArtistComparer.cs index 7b7ba5753..a8bb55e2b 100644 --- a/Emby.Server.Implementations/Sorting/ArtistComparer.cs +++ b/Emby.Server.Implementations/Sorting/ArtistComparer.cs @@ -17,7 +17,7 @@ namespace Emby.Server.Implementations.Sorting /// <inheritdoc /> public int Compare(BaseItem? x, BaseItem? y) { - return string.Compare(GetValue(x), GetValue(y), StringComparison.CurrentCultureIgnoreCase); + return string.Compare(GetValue(x), GetValue(y), StringComparison.OrdinalIgnoreCase); } /// <summary> diff --git a/Emby.Server.Implementations/Sorting/CriticRatingComparer.cs b/Emby.Server.Implementations/Sorting/CriticRatingComparer.cs index d20dedc2d..ba1835e4f 100644 --- a/Emby.Server.Implementations/Sorting/CriticRatingComparer.cs +++ b/Emby.Server.Implementations/Sorting/CriticRatingComparer.cs @@ -9,6 +9,12 @@ namespace Emby.Server.Implementations.Sorting /// </summary> public class CriticRatingComparer : IBaseItemComparer { + /// <summary> + /// Gets the name. + /// </summary> + /// <value>The name.</value> + public string Name => ItemSortBy.CriticRating; + /// <summary> /// Compares the specified x. /// </summary> @@ -24,11 +30,5 @@ namespace Emby.Server.Implementations.Sorting { return x?.CriticRating ?? 0; } - - /// <summary> - /// Gets the name. - /// </summary> - /// <value>The name.</value> - public string Name => ItemSortBy.CriticRating; } } diff --git a/Emby.Server.Implementations/Sorting/DateCreatedComparer.cs b/Emby.Server.Implementations/Sorting/DateCreatedComparer.cs index d3f10f78c..8b460166c 100644 --- a/Emby.Server.Implementations/Sorting/DateCreatedComparer.cs +++ b/Emby.Server.Implementations/Sorting/DateCreatedComparer.cs @@ -10,6 +10,12 @@ namespace Emby.Server.Implementations.Sorting /// </summary> public class DateCreatedComparer : IBaseItemComparer { + /// <summary> + /// Gets the name. + /// </summary> + /// <value>The name.</value> + public string Name => ItemSortBy.DateCreated; + /// <summary> /// Compares the specified x. /// </summary> @@ -30,11 +36,5 @@ namespace Emby.Server.Implementations.Sorting return DateTime.Compare(x.DateCreated, y.DateCreated); } - - /// <summary> - /// Gets the name. - /// </summary> - /// <value>The name.</value> - public string Name => ItemSortBy.DateCreated; } } diff --git a/Emby.Server.Implementations/Sorting/DatePlayedComparer.cs b/Emby.Server.Implementations/Sorting/DatePlayedComparer.cs index 08a44319f..ec818253b 100644 --- a/Emby.Server.Implementations/Sorting/DatePlayedComparer.cs +++ b/Emby.Server.Implementations/Sorting/DatePlayedComparer.cs @@ -32,6 +32,12 @@ namespace Emby.Server.Implementations.Sorting /// <value>The user data repository.</value> public IUserDataManager UserDataRepository { get; set; } + /// <summary> + /// Gets the name. + /// </summary> + /// <value>The name.</value> + public string Name => ItemSortBy.DatePlayed; + /// <summary> /// Compares the specified x. /// </summary> @@ -59,11 +65,5 @@ namespace Emby.Server.Implementations.Sorting return DateTime.MinValue; } - - /// <summary> - /// Gets the name. - /// </summary> - /// <value>The name.</value> - public string Name => ItemSortBy.DatePlayed; } } diff --git a/Emby.Server.Implementations/Sorting/NameComparer.cs b/Emby.Server.Implementations/Sorting/NameComparer.cs index 4de81a69e..c2875eeb9 100644 --- a/Emby.Server.Implementations/Sorting/NameComparer.cs +++ b/Emby.Server.Implementations/Sorting/NameComparer.cs @@ -10,6 +10,12 @@ namespace Emby.Server.Implementations.Sorting /// </summary> public class NameComparer : IBaseItemComparer { + /// <summary> + /// Gets the name. + /// </summary> + /// <value>The name.</value> + public string Name => ItemSortBy.Name; + /// <summary> /// Compares the specified x. /// </summary> @@ -28,13 +34,7 @@ namespace Emby.Server.Implementations.Sorting throw new ArgumentNullException(nameof(y)); } - return string.Compare(x.Name, y.Name, StringComparison.CurrentCultureIgnoreCase); + return string.Compare(x.Name, y.Name, StringComparison.OrdinalIgnoreCase); } - - /// <summary> - /// Gets the name. - /// </summary> - /// <value>The name.</value> - public string Name => ItemSortBy.Name; } } diff --git a/Emby.Server.Implementations/Sorting/PlayCountComparer.cs b/Emby.Server.Implementations/Sorting/PlayCountComparer.cs index 04e4865cb..45c9044c5 100644 --- a/Emby.Server.Implementations/Sorting/PlayCountComparer.cs +++ b/Emby.Server.Implementations/Sorting/PlayCountComparer.cs @@ -19,6 +19,24 @@ namespace Emby.Server.Implementations.Sorting /// <value>The user.</value> public User User { get; set; } + /// <summary> + /// Gets the name. + /// </summary> + /// <value>The name.</value> + public string Name => ItemSortBy.PlayCount; + + /// <summary> + /// Gets or sets the user data repository. + /// </summary> + /// <value>The user data repository.</value> + public IUserDataManager UserDataRepository { get; set; } + + /// <summary> + /// Gets or sets the user manager. + /// </summary> + /// <value>The user manager.</value> + public IUserManager UserManager { get; set; } + /// <summary> /// Compares the specified x. /// </summary> @@ -41,23 +59,5 @@ namespace Emby.Server.Implementations.Sorting return userdata == null ? 0 : userdata.PlayCount; } - - /// <summary> - /// Gets the name. - /// </summary> - /// <value>The name.</value> - public string Name => ItemSortBy.PlayCount; - - /// <summary> - /// Gets or sets the user data repository. - /// </summary> - /// <value>The user data repository.</value> - public IUserDataManager UserDataRepository { get; set; } - - /// <summary> - /// Gets or sets the user manager. - /// </summary> - /// <value>The user manager.</value> - public IUserManager UserManager { get; set; } } } diff --git a/Emby.Server.Implementations/Sorting/PremiereDateComparer.cs b/Emby.Server.Implementations/Sorting/PremiereDateComparer.cs index c98f97bf1..b217556ef 100644 --- a/Emby.Server.Implementations/Sorting/PremiereDateComparer.cs +++ b/Emby.Server.Implementations/Sorting/PremiereDateComparer.cs @@ -10,6 +10,12 @@ namespace Emby.Server.Implementations.Sorting /// </summary> public class PremiereDateComparer : IBaseItemComparer { + /// <summary> + /// Gets the name. + /// </summary> + /// <value>The name.</value> + public string Name => ItemSortBy.PremiereDate; + /// <summary> /// Compares the specified x. /// </summary> @@ -52,11 +58,5 @@ namespace Emby.Server.Implementations.Sorting return DateTime.MinValue; } - - /// <summary> - /// Gets the name. - /// </summary> - /// <value>The name.</value> - public string Name => ItemSortBy.PremiereDate; } } diff --git a/Emby.Server.Implementations/Sorting/ProductionYearComparer.cs b/Emby.Server.Implementations/Sorting/ProductionYearComparer.cs index df9f9957d..d2022df7a 100644 --- a/Emby.Server.Implementations/Sorting/ProductionYearComparer.cs +++ b/Emby.Server.Implementations/Sorting/ProductionYearComparer.cs @@ -9,6 +9,12 @@ namespace Emby.Server.Implementations.Sorting /// </summary> public class ProductionYearComparer : IBaseItemComparer { + /// <summary> + /// Gets the name. + /// </summary> + /// <value>The name.</value> + public string Name => ItemSortBy.ProductionYear; + /// <summary> /// Compares the specified x. /// </summary> @@ -44,11 +50,5 @@ namespace Emby.Server.Implementations.Sorting return 0; } - - /// <summary> - /// Gets the name. - /// </summary> - /// <value>The name.</value> - public string Name => ItemSortBy.ProductionYear; } } diff --git a/Emby.Server.Implementations/Sorting/RandomComparer.cs b/Emby.Server.Implementations/Sorting/RandomComparer.cs index af3bc2750..bf0168222 100644 --- a/Emby.Server.Implementations/Sorting/RandomComparer.cs +++ b/Emby.Server.Implementations/Sorting/RandomComparer.cs @@ -10,6 +10,12 @@ namespace Emby.Server.Implementations.Sorting /// </summary> public class RandomComparer : IBaseItemComparer { + /// <summary> + /// Gets the name. + /// </summary> + /// <value>The name.</value> + public string Name => ItemSortBy.Random; + /// <summary> /// Compares the specified x. /// </summary> @@ -20,11 +26,5 @@ namespace Emby.Server.Implementations.Sorting { return Guid.NewGuid().CompareTo(Guid.NewGuid()); } - - /// <summary> - /// Gets the name. - /// </summary> - /// <value>The name.</value> - public string Name => ItemSortBy.Random; } } diff --git a/Emby.Server.Implementations/Sorting/RuntimeComparer.cs b/Emby.Server.Implementations/Sorting/RuntimeComparer.cs index 129315303..e32e5552e 100644 --- a/Emby.Server.Implementations/Sorting/RuntimeComparer.cs +++ b/Emby.Server.Implementations/Sorting/RuntimeComparer.cs @@ -12,6 +12,12 @@ namespace Emby.Server.Implementations.Sorting /// </summary> public class RuntimeComparer : IBaseItemComparer { + /// <summary> + /// Gets the name. + /// </summary> + /// <value>The name.</value> + public string Name => ItemSortBy.Runtime; + /// <summary> /// Compares the specified x. /// </summary> @@ -32,11 +38,5 @@ namespace Emby.Server.Implementations.Sorting return (x.RunTimeTicks ?? 0).CompareTo(y.RunTimeTicks ?? 0); } - - /// <summary> - /// Gets the name. - /// </summary> - /// <value>The name.</value> - public string Name => ItemSortBy.Runtime; } } diff --git a/Emby.Server.Implementations/Sorting/SeriesSortNameComparer.cs b/Emby.Server.Implementations/Sorting/SeriesSortNameComparer.cs index 4123a59f8..0bd9600b9 100644 --- a/Emby.Server.Implementations/Sorting/SeriesSortNameComparer.cs +++ b/Emby.Server.Implementations/Sorting/SeriesSortNameComparer.cs @@ -25,7 +25,7 @@ namespace Emby.Server.Implementations.Sorting /// <returns>System.Int32.</returns> public int Compare(BaseItem x, BaseItem y) { - return string.Compare(GetValue(x), GetValue(y), StringComparison.CurrentCultureIgnoreCase); + return string.Compare(GetValue(x), GetValue(y), StringComparison.OrdinalIgnoreCase); } private static string GetValue(BaseItem item) diff --git a/Emby.Server.Implementations/Sorting/SortNameComparer.cs b/Emby.Server.Implementations/Sorting/SortNameComparer.cs index 8d30716d3..79be9a89a 100644 --- a/Emby.Server.Implementations/Sorting/SortNameComparer.cs +++ b/Emby.Server.Implementations/Sorting/SortNameComparer.cs @@ -12,6 +12,12 @@ namespace Emby.Server.Implementations.Sorting /// </summary> public class SortNameComparer : IBaseItemComparer { + /// <summary> + /// Gets the name. + /// </summary> + /// <value>The name.</value> + public string Name => ItemSortBy.SortName; + /// <summary> /// Compares the specified x. /// </summary> @@ -30,13 +36,7 @@ namespace Emby.Server.Implementations.Sorting throw new ArgumentNullException(nameof(y)); } - return string.Compare(x.SortName, y.SortName, StringComparison.CurrentCultureIgnoreCase); + return string.Compare(x.SortName, y.SortName, StringComparison.OrdinalIgnoreCase); } - - /// <summary> - /// Gets the name. - /// </summary> - /// <value>The name.</value> - public string Name => ItemSortBy.SortName; } } diff --git a/Emby.Server.Implementations/Sorting/StudioComparer.cs b/Emby.Server.Implementations/Sorting/StudioComparer.cs index 6826aee3b..4d89cfa8b 100644 --- a/Emby.Server.Implementations/Sorting/StudioComparer.cs +++ b/Emby.Server.Implementations/Sorting/StudioComparer.cs @@ -13,6 +13,12 @@ namespace Emby.Server.Implementations.Sorting { public class StudioComparer : IBaseItemComparer { + /// <summary> + /// Gets the name. + /// </summary> + /// <value>The name.</value> + public string Name => ItemSortBy.Studio; + /// <summary> /// Compares the specified x. /// </summary> @@ -33,11 +39,5 @@ namespace Emby.Server.Implementations.Sorting return AlphanumericComparator.CompareValues(x.Studios.FirstOrDefault(), y.Studios.FirstOrDefault()); } - - /// <summary> - /// Gets the name. - /// </summary> - /// <value>The name.</value> - public string Name => ItemSortBy.Studio; } } diff --git a/Emby.Server.Implementations/SyncPlay/Group.cs b/Emby.Server.Implementations/SyncPlay/Group.cs index bc20ddff8..75cf890e5 100644 --- a/Emby.Server.Implementations/SyncPlay/Group.cs +++ b/Emby.Server.Implementations/SyncPlay/Group.cs @@ -164,26 +164,26 @@ namespace Emby.Server.Implementations.SyncPlay /// <summary> /// Filters sessions of this group. /// </summary> - /// <param name="from">The current session.</param> + /// <param name="fromId">The current session identifier.</param> /// <param name="type">The filtering type.</param> /// <returns>The list of sessions matching the filter.</returns> - private IEnumerable<SessionInfo> FilterSessions(SessionInfo from, SyncPlayBroadcastType type) + private IEnumerable<string> FilterSessions(string fromId, SyncPlayBroadcastType type) { return type switch { - SyncPlayBroadcastType.CurrentSession => new SessionInfo[] { from }, + SyncPlayBroadcastType.CurrentSession => new string[] { fromId }, SyncPlayBroadcastType.AllGroup => _participants .Values - .Select(session => session.Session), + .Select(member => member.SessionId), SyncPlayBroadcastType.AllExceptCurrentSession => _participants .Values - .Select(session => session.Session) - .Where(session => !session.Id.Equals(from.Id, StringComparison.OrdinalIgnoreCase)), + .Select(member => member.SessionId) + .Where(sessionId => !sessionId.Equals(fromId, StringComparison.OrdinalIgnoreCase)), SyncPlayBroadcastType.AllReady => _participants .Values - .Where(session => !session.IsBuffering) - .Select(session => session.Session), - _ => Enumerable.Empty<SessionInfo>() + .Where(member => !member.IsBuffering) + .Select(member => member.SessionId), + _ => Enumerable.Empty<string>() }; } @@ -225,7 +225,7 @@ namespace Emby.Server.Implementations.SyncPlay // Get list of users. var users = _participants .Values - .Select(participant => _userManager.GetUserById(participant.Session.UserId)); + .Select(participant => _userManager.GetUserById(participant.UserId)); // Find problematic users. var usersWithNoAccess = users.Where(user => !HasAccessToQueue(user, queue)); @@ -353,7 +353,7 @@ namespace Emby.Server.Implementations.SyncPlay /// <returns>The group info for the clients.</returns> public GroupInfoDto GetInfo() { - var participants = _participants.Values.Select(session => session.Session.UserName).Distinct().ToList(); + var participants = _participants.Values.Select(session => session.UserName).Distinct().ToList(); return new GroupInfoDto(GroupId, GroupName, _state.Type, participants, DateTime.UtcNow); } @@ -389,9 +389,9 @@ namespace Emby.Server.Implementations.SyncPlay { IEnumerable<Task> GetTasks() { - foreach (var session in FilterSessions(from, type)) + foreach (var sessionId in FilterSessions(from.Id, type)) { - yield return _sessionManager.SendSyncPlayGroupUpdate(session, message, cancellationToken); + yield return _sessionManager.SendSyncPlayGroupUpdate(sessionId, message, cancellationToken); } } @@ -403,9 +403,9 @@ namespace Emby.Server.Implementations.SyncPlay { IEnumerable<Task> GetTasks() { - foreach (var session in FilterSessions(from, type)) + foreach (var sessionId in FilterSessions(from.Id, type)) { - yield return _sessionManager.SendSyncPlayCommand(session, message, cancellationToken); + yield return _sessionManager.SendSyncPlayCommand(sessionId, message, cancellationToken); } } @@ -659,8 +659,9 @@ namespace Emby.Server.Implementations.SyncPlay public PlayQueueUpdate GetPlayQueueUpdate(PlayQueueUpdateReason reason) { var startPositionTicks = PositionTicks; + var isPlaying = _state.Type.Equals(GroupStateType.Playing); - if (_state.Type.Equals(GroupStateType.Playing)) + if (isPlaying) { var currentTime = DateTime.UtcNow; var elapsedTime = currentTime - LastActivity; @@ -679,6 +680,7 @@ namespace Emby.Server.Implementations.SyncPlay PlayQueue.GetPlaylist(), PlayQueue.PlayingItemIndex, startPositionTicks, + isPlaying, PlayQueue.ShuffleMode, PlayQueue.RepeatMode); } diff --git a/Emby.Server.Implementations/SyncPlay/SyncPlayManager.cs b/Emby.Server.Implementations/SyncPlay/SyncPlayManager.cs index 993456196..53e3b3577 100644 --- a/Emby.Server.Implementations/SyncPlay/SyncPlayManager.cs +++ b/Emby.Server.Implementations/SyncPlay/SyncPlayManager.cs @@ -160,7 +160,7 @@ namespace Emby.Server.Implementations.SyncPlay _logger.LogWarning("Session {SessionId} tried to join group {GroupId} that does not exist.", session.Id, request.GroupId); var error = new GroupUpdate<string>(Guid.Empty, GroupUpdateType.GroupDoesNotExist, string.Empty); - _sessionManager.SendSyncPlayGroupUpdate(session, error, CancellationToken.None); + _sessionManager.SendSyncPlayGroupUpdate(session.Id, error, CancellationToken.None); return; } @@ -172,7 +172,7 @@ namespace Emby.Server.Implementations.SyncPlay _logger.LogWarning("Session {SessionId} tried to join group {GroupId} but does not have access to some content of the playing queue.", session.Id, group.GroupId.ToString()); var error = new GroupUpdate<string>(group.GroupId, GroupUpdateType.LibraryAccessDenied, string.Empty); - _sessionManager.SendSyncPlayGroupUpdate(session, error, CancellationToken.None); + _sessionManager.SendSyncPlayGroupUpdate(session.Id, error, CancellationToken.None); return; } @@ -249,8 +249,7 @@ namespace Emby.Server.Implementations.SyncPlay _logger.LogWarning("Session {SessionId} does not belong to any group.", session.Id); var error = new GroupUpdate<string>(Guid.Empty, GroupUpdateType.NotInGroup, string.Empty); - _sessionManager.SendSyncPlayGroupUpdate(session, error, CancellationToken.None); - return; + _sessionManager.SendSyncPlayGroupUpdate(session.Id, error, CancellationToken.None); } } } @@ -329,7 +328,7 @@ namespace Emby.Server.Implementations.SyncPlay _logger.LogWarning("Session {SessionId} does not belong to any group.", session.Id); var error = new GroupUpdate<string>(Guid.Empty, GroupUpdateType.NotInGroup, string.Empty); - _sessionManager.SendSyncPlayGroupUpdate(session, error, CancellationToken.None); + _sessionManager.SendSyncPlayGroupUpdate(session.Id, error, CancellationToken.None); } } @@ -365,7 +364,7 @@ namespace Emby.Server.Implementations.SyncPlay { var session = e.SessionInfo; - if (_sessionToGroupMap.TryGetValue(session.Id, out var group)) + if (_sessionToGroupMap.TryGetValue(session.Id, out _)) { var leaveGroupRequest = new LeaveGroupRequest(); LeaveGroup(session, leaveGroupRequest, CancellationToken.None); @@ -378,7 +377,7 @@ namespace Emby.Server.Implementations.SyncPlay var newSessionsCounter = _activeUsers.AddOrUpdate( userId, 1, - (key, sessionsCounter) => sessionsCounter + toAdd); + (_, sessionsCounter) => sessionsCounter + toAdd); // Should never happen. if (newSessionsCounter < 0) diff --git a/Emby.Server.Implementations/TV/TVSeriesManager.cs b/Emby.Server.Implementations/TV/TVSeriesManager.cs index 4d990c871..c994ffc90 100644 --- a/Emby.Server.Implementations/TV/TVSeriesManager.cs +++ b/Emby.Server.Implementations/TV/TVSeriesManager.cs @@ -116,13 +116,14 @@ namespace Emby.Server.Implementations.TV .GetItemList( new InternalItemsQuery(user) { - IncludeItemTypes = new[] { nameof(Episode) }, - OrderBy = new[] { new ValueTuple<string, SortOrder>(ItemSortBy.DatePlayed, SortOrder.Descending) }, + IncludeItemTypes = new[] { BaseItemKind.Episode }, + OrderBy = new[] { (ItemSortBy.DatePlayed, SortOrder.Descending) }, SeriesPresentationUniqueKey = presentationUniqueKey, Limit = limit, DtoOptions = new DtoOptions { Fields = new[] { ItemFields.SeriesPresentationUniqueKey }, EnableImages = false }, GroupBySeriesPresentationUniqueKey = true - }, parentsFolders.ToList()) + }, + parentsFolders.ToList()) .Cast<Episode>() .Where(episode => !string.IsNullOrEmpty(episode.SeriesPresentationUniqueKey)) .Select(GetUniqueSeriesKey); @@ -191,8 +192,8 @@ namespace Emby.Server.Implementations.TV { AncestorWithPresentationUniqueKey = null, SeriesPresentationUniqueKey = seriesKey, - IncludeItemTypes = new[] { nameof(Episode) }, - OrderBy = new[] { new ValueTuple<string, SortOrder>(ItemSortBy.SortName, SortOrder.Descending) }, + IncludeItemTypes = new[] { BaseItemKind.Episode }, + OrderBy = new[] { (ItemSortBy.SortName, SortOrder.Descending) }, IsPlayed = true, Limit = 1, ParentIndexNumberNotEquals = 0, @@ -209,8 +210,8 @@ namespace Emby.Server.Implementations.TV { AncestorWithPresentationUniqueKey = null, SeriesPresentationUniqueKey = seriesKey, - IncludeItemTypes = new[] { nameof(Episode) }, - OrderBy = new[] { new ValueTuple<string, SortOrder>(ItemSortBy.SortName, SortOrder.Ascending) }, + IncludeItemTypes = new[] { BaseItemKind.Episode }, + OrderBy = new[] { (ItemSortBy.SortName, SortOrder.Ascending) }, Limit = 1, IsPlayed = false, IsVirtualItem = false, @@ -226,7 +227,7 @@ namespace Emby.Server.Implementations.TV AncestorWithPresentationUniqueKey = null, SeriesPresentationUniqueKey = seriesKey, ParentIndexNumber = 0, - IncludeItemTypes = new[] { nameof(Episode) }, + IncludeItemTypes = new[] { BaseItemKind.Episode }, IsPlayed = false, IsVirtualItem = false, DtoOptions = dtoOptions diff --git a/Emby.Server.Implementations/Udp/UdpServer.cs b/Emby.Server.Implementations/Udp/UdpServer.cs index 8179e26c5..c8ab99de4 100644 --- a/Emby.Server.Implementations/Udp/UdpServer.cs +++ b/Emby.Server.Implementations/Udp/UdpServer.cs @@ -29,10 +29,10 @@ namespace Emby.Server.Implementations.Udp private readonly IServerApplicationHost _appHost; private readonly IConfiguration _config; - private Socket _udpSocket; - private IPEndPoint _endpoint; private readonly byte[] _receiveBuffer = new byte[8192]; + private Socket _udpSocket; + private IPEndPoint _endpoint; private bool _disposed = false; /// <summary> @@ -58,7 +58,7 @@ namespace Emby.Server.Implementations.Udp _udpSocket.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true); } - private async Task RespondToV2Message(string messageText, EndPoint endpoint, CancellationToken cancellationToken) + private async Task RespondToV2Message(EndPoint endpoint, CancellationToken cancellationToken) { string? localUrl = _config[AddressOverrideConfigKey]; if (string.IsNullOrEmpty(localUrl)) @@ -76,7 +76,7 @@ namespace Emby.Server.Implementations.Udp try { - await _udpSocket.SendToAsync(JsonSerializer.SerializeToUtf8Bytes(response), SocketFlags.None, endpoint).ConfigureAwait(false); + await _udpSocket.SendToAsync(JsonSerializer.SerializeToUtf8Bytes(response), SocketFlags.None, endpoint, cancellationToken).ConfigureAwait(false); } catch (SocketException ex) { @@ -97,25 +97,15 @@ namespace Emby.Server.Implementations.Udp private async Task BeginReceiveAsync(CancellationToken cancellationToken) { - var infiniteTask = Task.Delay(-1, cancellationToken); while (!cancellationToken.IsCancellationRequested) { try { - var task = _udpSocket.ReceiveFromAsync(_receiveBuffer, SocketFlags.None, _endpoint); - await Task.WhenAny(task, infiniteTask).ConfigureAwait(false); - - if (!task.IsCompleted) - { - return; - } - - var result = task.Result; - + var result = await _udpSocket.ReceiveFromAsync(_receiveBuffer, SocketFlags.None, _endpoint, cancellationToken).ConfigureAwait(false); var text = Encoding.UTF8.GetString(_receiveBuffer, 0, result.ReceivedBytes); if (text.Contains("who is JellyfinServer?", StringComparison.OrdinalIgnoreCase)) { - await RespondToV2Message(text, result.RemoteEndPoint, cancellationToken).ConfigureAwait(false); + await RespondToV2Message(result.RemoteEndPoint, cancellationToken).ConfigureAwait(false); } } catch (SocketException ex) diff --git a/Emby.Server.Implementations/Updates/InstallationManager.cs b/Emby.Server.Implementations/Updates/InstallationManager.cs index 7b0afa4e2..5eb4c9ffa 100644 --- a/Emby.Server.Implementations/Updates/InstallationManager.cs +++ b/Emby.Server.Implementations/Updates/InstallationManager.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.IO; +using System.IO.Compression; using System.Linq; using System.Net.Http; using System.Net.Http.Json; @@ -10,8 +11,8 @@ using System.Text.Json; using System.Threading; using System.Threading.Tasks; using Jellyfin.Data.Events; -using MediaBrowser.Common.Configuration; using Jellyfin.Extensions.Json; +using MediaBrowser.Common.Configuration; using MediaBrowser.Common.Net; using MediaBrowser.Common.Plugins; using MediaBrowser.Common.Updates; @@ -19,7 +20,6 @@ using MediaBrowser.Controller; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Events; using MediaBrowser.Controller.Events.Updates; -using MediaBrowser.Model.IO; using MediaBrowser.Model.Plugins; using MediaBrowser.Model.Updates; using Microsoft.Extensions.Logging; @@ -47,13 +47,12 @@ namespace Emby.Server.Implementations.Updates /// </summary> /// <value>The application host.</value> private readonly IServerApplicationHost _applicationHost; - private readonly IZipClient _zipClient; private readonly object _currentInstallationsLock = new object(); /// <summary> /// The current installations. /// </summary> - private readonly List<(InstallationInfo info, CancellationTokenSource token)> _currentInstallations; + private readonly List<(InstallationInfo Info, CancellationTokenSource Token)> _currentInstallations; /// <summary> /// The completed installations. @@ -69,7 +68,6 @@ namespace Emby.Server.Implementations.Updates /// <param name="eventManager">The <see cref="IEventManager"/>.</param> /// <param name="httpClientFactory">The <see cref="IHttpClientFactory"/>.</param> /// <param name="config">The <see cref="IServerConfigurationManager"/>.</param> - /// <param name="zipClient">The <see cref="IZipClient"/>.</param> /// <param name="pluginManager">The <see cref="IPluginManager"/>.</param> public InstallationManager( ILogger<InstallationManager> logger, @@ -78,7 +76,6 @@ namespace Emby.Server.Implementations.Updates IEventManager eventManager, IHttpClientFactory httpClientFactory, IServerConfigurationManager config, - IZipClient zipClient, IPluginManager pluginManager) { _currentInstallations = new List<(InstallationInfo, CancellationTokenSource)>(); @@ -90,7 +87,6 @@ namespace Emby.Server.Implementations.Updates _eventManager = eventManager; _httpClientFactory = httpClientFactory; _config = config; - _zipClient = zipClient; _jsonSerializerOptions = JsonDefaults.Options; _pluginManager = pluginManager; } @@ -403,13 +399,13 @@ namespace Emby.Server.Implementations.Updates { lock (_currentInstallationsLock) { - var install = _currentInstallations.Find(x => x.info.Id == id); + var install = _currentInstallations.Find(x => x.Info.Id == id); if (install == default((InstallationInfo, CancellationTokenSource))) { return false; } - install.token.Cancel(); + install.Token.Cancel(); _currentInstallations.Remove(install); return true; } @@ -560,7 +556,8 @@ namespace Emby.Server.Implementations.Updates } stream.Position = 0; - _zipClient.ExtractAllFromZip(stream, targetDir, true); + using var reader = new ZipArchive(stream); + reader.ExtractToDirectory(targetDir, true); await _pluginManager.GenerateManifest(package.PackageInfo, package.Version, targetDir, status).ConfigureAwait(false); _pluginManager.ImportPluginFrom(targetDir); } @@ -571,7 +568,7 @@ namespace Emby.Server.Implementations.Updates ?? _pluginManager.Plugins.FirstOrDefault(p => p.Name.Equals(package.Name, StringComparison.OrdinalIgnoreCase) && p.Version.Equals(package.Version)); await PerformPackageInstallation(package, plugin?.Manifest.Status ?? PluginStatus.Active, cancellationToken).ConfigureAwait(false); - _logger.LogInformation(plugin == null ? "New plugin installed: {PluginName} {PluginVersion}" : "Plugin updated: {PluginName} {PluginVersion}", package.Name, package.Version); + _logger.LogInformation("Plugin {Action}: {PluginName} {PluginVersion}", plugin == null ? "installed" : "updated", package.Name, package.Version); return plugin != null; } diff --git a/Jellyfin.Api/Attributes/AcceptsFileAttribute.cs b/Jellyfin.Api/Attributes/AcceptsFileAttribute.cs index 49b6689cd..58552d847 100644 --- a/Jellyfin.Api/Attributes/AcceptsFileAttribute.cs +++ b/Jellyfin.Api/Attributes/AcceptsFileAttribute.cs @@ -1,4 +1,6 @@ -using System; +#pragma warning disable CA1813 // Avoid unsealed attributes + +using System; namespace Jellyfin.Api.Attributes { diff --git a/Jellyfin.Api/Attributes/AcceptsImageFileAttribute.cs b/Jellyfin.Api/Attributes/AcceptsImageFileAttribute.cs index 001f27409..244a29da4 100644 --- a/Jellyfin.Api/Attributes/AcceptsImageFileAttribute.cs +++ b/Jellyfin.Api/Attributes/AcceptsImageFileAttribute.cs @@ -3,7 +3,7 @@ /// <summary> /// Produces file attribute of "image/*". /// </summary> - public class AcceptsImageFileAttribute : AcceptsFileAttribute + public sealed class AcceptsImageFileAttribute : AcceptsFileAttribute { private const string ContentType = "image/*"; diff --git a/Jellyfin.Api/Attributes/HttpSubscribeAttribute.cs b/Jellyfin.Api/Attributes/HttpSubscribeAttribute.cs index 2fdd1e489..af8727552 100644 --- a/Jellyfin.Api/Attributes/HttpSubscribeAttribute.cs +++ b/Jellyfin.Api/Attributes/HttpSubscribeAttribute.cs @@ -7,7 +7,7 @@ namespace Jellyfin.Api.Attributes /// <summary> /// Identifies an action that supports the HTTP GET method. /// </summary> - public class HttpSubscribeAttribute : HttpMethodAttribute + public sealed class HttpSubscribeAttribute : HttpMethodAttribute { private static readonly IEnumerable<string> _supportedMethods = new[] { "SUBSCRIBE" }; diff --git a/Jellyfin.Api/Attributes/HttpUnsubscribeAttribute.cs b/Jellyfin.Api/Attributes/HttpUnsubscribeAttribute.cs index d6d7e4563..1c0b70e71 100644 --- a/Jellyfin.Api/Attributes/HttpUnsubscribeAttribute.cs +++ b/Jellyfin.Api/Attributes/HttpUnsubscribeAttribute.cs @@ -7,7 +7,7 @@ namespace Jellyfin.Api.Attributes /// <summary> /// Identifies an action that supports the HTTP GET method. /// </summary> - public class HttpUnsubscribeAttribute : HttpMethodAttribute + public sealed class HttpUnsubscribeAttribute : HttpMethodAttribute { private static readonly IEnumerable<string> _supportedMethods = new[] { "UNSUBSCRIBE" }; diff --git a/Jellyfin.Api/Attributes/ParameterObsoleteAttribute.cs b/Jellyfin.Api/Attributes/ParameterObsoleteAttribute.cs index 56c9772b6..514e7ce97 100644 --- a/Jellyfin.Api/Attributes/ParameterObsoleteAttribute.cs +++ b/Jellyfin.Api/Attributes/ParameterObsoleteAttribute.cs @@ -6,7 +6,7 @@ namespace Jellyfin.Api.Attributes /// Attribute to mark a parameter as obsolete. /// </summary> [AttributeUsage(AttributeTargets.Parameter)] - public class ParameterObsoleteAttribute : Attribute + public sealed class ParameterObsoleteAttribute : Attribute { } } diff --git a/Jellyfin.Api/Attributes/ProducesAudioFileAttribute.cs b/Jellyfin.Api/Attributes/ProducesAudioFileAttribute.cs index 3adb700eb..9fc25f192 100644 --- a/Jellyfin.Api/Attributes/ProducesAudioFileAttribute.cs +++ b/Jellyfin.Api/Attributes/ProducesAudioFileAttribute.cs @@ -3,7 +3,7 @@ /// <summary> /// Produces file attribute of "image/*". /// </summary> - public class ProducesAudioFileAttribute : ProducesFileAttribute + public sealed class ProducesAudioFileAttribute : ProducesFileAttribute { private const string ContentType = "audio/*"; diff --git a/Jellyfin.Api/Attributes/ProducesFileAttribute.cs b/Jellyfin.Api/Attributes/ProducesFileAttribute.cs index 62a576ede..2bf77d729 100644 --- a/Jellyfin.Api/Attributes/ProducesFileAttribute.cs +++ b/Jellyfin.Api/Attributes/ProducesFileAttribute.cs @@ -1,4 +1,6 @@ -using System; +#pragma warning disable CA1813 // Avoid unsealed attributes + +using System; namespace Jellyfin.Api.Attributes { diff --git a/Jellyfin.Api/Attributes/ProducesImageFileAttribute.cs b/Jellyfin.Api/Attributes/ProducesImageFileAttribute.cs index e15813676..1e5b542e2 100644 --- a/Jellyfin.Api/Attributes/ProducesImageFileAttribute.cs +++ b/Jellyfin.Api/Attributes/ProducesImageFileAttribute.cs @@ -3,7 +3,7 @@ /// <summary> /// Produces file attribute of "image/*". /// </summary> - public class ProducesImageFileAttribute : ProducesFileAttribute + public sealed class ProducesImageFileAttribute : ProducesFileAttribute { private const string ContentType = "image/*"; diff --git a/Jellyfin.Api/Attributes/ProducesPlaylistFileAttribute.cs b/Jellyfin.Api/Attributes/ProducesPlaylistFileAttribute.cs index 5d928ab91..5b15cb1a5 100644 --- a/Jellyfin.Api/Attributes/ProducesPlaylistFileAttribute.cs +++ b/Jellyfin.Api/Attributes/ProducesPlaylistFileAttribute.cs @@ -3,7 +3,7 @@ /// <summary> /// Produces file attribute of "image/*". /// </summary> - public class ProducesPlaylistFileAttribute : ProducesFileAttribute + public sealed class ProducesPlaylistFileAttribute : ProducesFileAttribute { private const string ContentType = "application/x-mpegURL"; diff --git a/Jellyfin.Api/Attributes/ProducesVideoFileAttribute.cs b/Jellyfin.Api/Attributes/ProducesVideoFileAttribute.cs index d8b2856dc..6857d45ec 100644 --- a/Jellyfin.Api/Attributes/ProducesVideoFileAttribute.cs +++ b/Jellyfin.Api/Attributes/ProducesVideoFileAttribute.cs @@ -3,7 +3,7 @@ /// <summary> /// Produces file attribute of "video/*". /// </summary> - public class ProducesVideoFileAttribute : ProducesFileAttribute + public sealed class ProducesVideoFileAttribute : ProducesFileAttribute { private const string ContentType = "video/*"; diff --git a/Jellyfin.Api/Auth/AnonymousLanAccessPolicy/AnonymousLanAccessHandler.cs b/Jellyfin.Api/Auth/AnonymousLanAccessPolicy/AnonymousLanAccessHandler.cs new file mode 100644 index 000000000..88af08dd3 --- /dev/null +++ b/Jellyfin.Api/Auth/AnonymousLanAccessPolicy/AnonymousLanAccessHandler.cs @@ -0,0 +1,47 @@ +using System.Threading.Tasks; +using MediaBrowser.Common.Net; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; + +namespace Jellyfin.Api.Auth.AnonymousLanAccessPolicy +{ + /// <summary> + /// LAN access handler. Allows anonymous users. + /// </summary> + public class AnonymousLanAccessHandler : AuthorizationHandler<AnonymousLanAccessRequirement> + { + private readonly INetworkManager _networkManager; + private readonly IHttpContextAccessor _httpContextAccessor; + + /// <summary> + /// Initializes a new instance of the <see cref="AnonymousLanAccessHandler"/> class. + /// </summary> + /// <param name="networkManager">Instance of the <see cref="INetworkManager"/> interface.</param> + /// <param name="httpContextAccessor">Instance of the <see cref="IHttpContextAccessor"/> interface.</param> + public AnonymousLanAccessHandler( + INetworkManager networkManager, + IHttpContextAccessor httpContextAccessor) + { + _networkManager = networkManager; + _httpContextAccessor = httpContextAccessor; + } + + /// <inheritdoc /> + protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, AnonymousLanAccessRequirement requirement) + { + var ip = _httpContextAccessor.HttpContext?.Connection.RemoteIpAddress; + + // Loopback will be on LAN, so we can accept null. + if (ip == null || _networkManager.IsInLocalNetwork(ip)) + { + context.Succeed(requirement); + } + else + { + context.Fail(); + } + + return Task.CompletedTask; + } + } +} diff --git a/Jellyfin.Api/Auth/AnonymousLanAccessPolicy/AnonymousLanAccessRequirement.cs b/Jellyfin.Api/Auth/AnonymousLanAccessPolicy/AnonymousLanAccessRequirement.cs new file mode 100644 index 000000000..49af24ff3 --- /dev/null +++ b/Jellyfin.Api/Auth/AnonymousLanAccessPolicy/AnonymousLanAccessRequirement.cs @@ -0,0 +1,11 @@ +using Microsoft.AspNetCore.Authorization; + +namespace Jellyfin.Api.Auth.AnonymousLanAccessPolicy +{ + /// <summary> + /// The local network authorization requirement. Allows anonymous users. + /// </summary> + public class AnonymousLanAccessRequirement : IAuthorizationRequirement + { + } +} diff --git a/Jellyfin.Api/Auth/BaseAuthorizationHandler.cs b/Jellyfin.Api/Auth/BaseAuthorizationHandler.cs index 392498c53..13d3257df 100644 --- a/Jellyfin.Api/Auth/BaseAuthorizationHandler.cs +++ b/Jellyfin.Api/Auth/BaseAuthorizationHandler.cs @@ -1,4 +1,4 @@ -using System.Security.Claims; +using System.Security.Claims; using Jellyfin.Api.Helpers; using Jellyfin.Data.Enums; using MediaBrowser.Common.Extensions; diff --git a/Jellyfin.Api/Auth/CustomAuthenticationHandler.cs b/Jellyfin.Api/Auth/CustomAuthenticationHandler.cs index 369e846ae..bd3e7d9e3 100644 --- a/Jellyfin.Api/Auth/CustomAuthenticationHandler.cs +++ b/Jellyfin.Api/Auth/CustomAuthenticationHandler.cs @@ -45,6 +45,11 @@ namespace Jellyfin.Api.Auth try { var authorizationInfo = await _authService.Authenticate(Request).ConfigureAwait(false); + if (!authorizationInfo.HasToken) + { + return AuthenticateResult.NoResult(); + } + var role = UserRoles.User; if (authorizationInfo.IsApiKey || authorizationInfo.User.HasPermission(PermissionKind.IsAdministrator)) { diff --git a/Jellyfin.Api/Auth/FirstTimeSetupOrDefaultPolicy/FirstTimeSetupOrDefaultHandler.cs b/Jellyfin.Api/Auth/FirstTimeSetupOrDefaultPolicy/FirstTimeSetupOrDefaultHandler.cs index 9815e252e..dd0bd4ec2 100644 --- a/Jellyfin.Api/Auth/FirstTimeSetupOrDefaultPolicy/FirstTimeSetupOrDefaultHandler.cs +++ b/Jellyfin.Api/Auth/FirstTimeSetupOrDefaultPolicy/FirstTimeSetupOrDefaultHandler.cs @@ -32,18 +32,18 @@ namespace Jellyfin.Api.Auth.FirstTimeSetupOrDefaultPolicy } /// <inheritdoc /> - protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, FirstTimeSetupOrDefaultRequirement firstTimeSetupOrDefaultRequirement) + protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, FirstTimeSetupOrDefaultRequirement requirement) { if (!_configurationManager.CommonConfiguration.IsStartupWizardCompleted) { - context.Succeed(firstTimeSetupOrDefaultRequirement); + context.Succeed(requirement); return Task.CompletedTask; } var validated = ValidateClaims(context.User); if (validated) { - context.Succeed(firstTimeSetupOrDefaultRequirement); + context.Succeed(requirement); } else { diff --git a/Jellyfin.Api/Auth/FirstTimeSetupOrElevatedPolicy/FirstTimeSetupOrElevatedHandler.cs b/Jellyfin.Api/Auth/FirstTimeSetupOrElevatedPolicy/FirstTimeSetupOrElevatedHandler.cs index decbe0c03..90b76ee99 100644 --- a/Jellyfin.Api/Auth/FirstTimeSetupOrElevatedPolicy/FirstTimeSetupOrElevatedHandler.cs +++ b/Jellyfin.Api/Auth/FirstTimeSetupOrElevatedPolicy/FirstTimeSetupOrElevatedHandler.cs @@ -33,18 +33,18 @@ namespace Jellyfin.Api.Auth.FirstTimeSetupOrElevatedPolicy } /// <inheritdoc /> - protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, FirstTimeSetupOrElevatedRequirement firstTimeSetupOrElevatedRequirement) + protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, FirstTimeSetupOrElevatedRequirement requirement) { if (!_configurationManager.CommonConfiguration.IsStartupWizardCompleted) { - context.Succeed(firstTimeSetupOrElevatedRequirement); + context.Succeed(requirement); return Task.CompletedTask; } var validated = ValidateClaims(context.User); if (validated && context.User.IsInRole(UserRoles.Administrator)) { - context.Succeed(firstTimeSetupOrElevatedRequirement); + context.Succeed(requirement); } else { diff --git a/Jellyfin.Api/Auth/SyncPlayAccessPolicy/SyncPlayAccessHandler.cs b/Jellyfin.Api/Auth/SyncPlayAccessPolicy/SyncPlayAccessHandler.cs index b898ac76c..e6c04eb08 100644 --- a/Jellyfin.Api/Auth/SyncPlayAccessPolicy/SyncPlayAccessHandler.cs +++ b/Jellyfin.Api/Auth/SyncPlayAccessPolicy/SyncPlayAccessHandler.cs @@ -51,7 +51,7 @@ namespace Jellyfin.Api.Auth.SyncPlayAccessPolicy { if (user.SyncPlayAccess == SyncPlayUserAccessType.CreateAndJoinGroups || user.SyncPlayAccess == SyncPlayUserAccessType.JoinGroups - || _syncPlayManager.IsUserActive(userId!.Value)) + || _syncPlayManager.IsUserActive(userId.Value)) { context.Succeed(requirement); } @@ -85,7 +85,7 @@ namespace Jellyfin.Api.Auth.SyncPlayAccessPolicy } else if (requirement.RequiredAccess == SyncPlayAccessRequirementType.IsInGroup) { - if (_syncPlayManager.IsUserActive(userId!.Value)) + if (_syncPlayManager.IsUserActive(userId.Value)) { context.Succeed(requirement); } diff --git a/Jellyfin.Api/Constants/Policies.cs b/Jellyfin.Api/Constants/Policies.cs index 632dedb3c..a72eeea28 100644 --- a/Jellyfin.Api/Constants/Policies.cs +++ b/Jellyfin.Api/Constants/Policies.cs @@ -45,6 +45,11 @@ namespace Jellyfin.Api.Constants /// </summary> public const string LocalAccessOrRequiresElevation = "LocalAccessOrRequiresElevation"; + /// <summary> + /// Policy name for requiring (anonymous) LAN access. + /// </summary> + public const string AnonymousLanAccessPolicy = "AnonymousLanAccessPolicy"; + /// <summary> /// Policy name for escaping schedule controls or requiring first time setup. /// </summary> diff --git a/Jellyfin.Api/Controllers/ArtistsController.cs b/Jellyfin.Api/Controllers/ArtistsController.cs index 154a56702..3df975563 100644 --- a/Jellyfin.Api/Controllers/ArtistsController.cs +++ b/Jellyfin.Api/Controllers/ArtistsController.cs @@ -133,8 +133,8 @@ namespace Jellyfin.Api.Controllers var query = new InternalItemsQuery(user) { - ExcludeItemTypes = RequestHelpers.GetItemTypeStrings(excludeItemTypes), - IncludeItemTypes = RequestHelpers.GetItemTypeStrings(includeItemTypes), + ExcludeItemTypes = excludeItemTypes, + IncludeItemTypes = includeItemTypes, MediaTypes = mediaTypes, StartIndex = startIndex, Limit = limit, @@ -337,8 +337,8 @@ namespace Jellyfin.Api.Controllers var query = new InternalItemsQuery(user) { - ExcludeItemTypes = RequestHelpers.GetItemTypeStrings(excludeItemTypes), - IncludeItemTypes = RequestHelpers.GetItemTypeStrings(includeItemTypes), + ExcludeItemTypes = excludeItemTypes, + IncludeItemTypes = includeItemTypes, MediaTypes = mediaTypes, StartIndex = startIndex, Limit = limit, diff --git a/Jellyfin.Api/Controllers/ClientLogController.cs b/Jellyfin.Api/Controllers/ClientLogController.cs new file mode 100644 index 000000000..98fd22430 --- /dev/null +++ b/Jellyfin.Api/Controllers/ClientLogController.cs @@ -0,0 +1,80 @@ +using System.Net.Mime; +using System.Threading.Tasks; +using Jellyfin.Api.Attributes; +using Jellyfin.Api.Constants; +using Jellyfin.Api.Helpers; +using Jellyfin.Api.Models.ClientLogDtos; +using MediaBrowser.Controller.ClientEvent; +using MediaBrowser.Controller.Configuration; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +namespace Jellyfin.Api.Controllers +{ + /// <summary> + /// Client log controller. + /// </summary> + [Authorize(Policy = Policies.DefaultAuthorization)] + public class ClientLogController : BaseJellyfinApiController + { + private const int MaxDocumentSize = 1_000_000; + private readonly IClientEventLogger _clientEventLogger; + private readonly IServerConfigurationManager _serverConfigurationManager; + + /// <summary> + /// Initializes a new instance of the <see cref="ClientLogController"/> class. + /// </summary> + /// <param name="clientEventLogger">Instance of the <see cref="IClientEventLogger"/> interface.</param> + /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param> + public ClientLogController( + IClientEventLogger clientEventLogger, + IServerConfigurationManager serverConfigurationManager) + { + _clientEventLogger = clientEventLogger; + _serverConfigurationManager = serverConfigurationManager; + } + + /// <summary> + /// Upload a document. + /// </summary> + /// <response code="200">Document saved.</response> + /// <response code="403">Event logging disabled.</response> + /// <response code="413">Upload size too large.</response> + /// <returns>Create response.</returns> + [HttpPost("Document")] + [ProducesResponseType(typeof(ClientLogDocumentResponseDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status413PayloadTooLarge)] + [AcceptsFile(MediaTypeNames.Text.Plain)] + [RequestSizeLimit(MaxDocumentSize)] + public async Task<ActionResult<ClientLogDocumentResponseDto>> LogFile() + { + if (!_serverConfigurationManager.Configuration.AllowClientLogUpload) + { + return Forbid(); + } + + if (Request.ContentLength > MaxDocumentSize) + { + // Manually validate to return proper status code. + return StatusCode(StatusCodes.Status413PayloadTooLarge, $"Payload must be less than {MaxDocumentSize:N0} bytes"); + } + + var (clientName, clientVersion) = GetRequestInformation(); + var fileName = await _clientEventLogger.WriteDocumentAsync(clientName, clientVersion, Request.Body) + .ConfigureAwait(false); + return Ok(new ClientLogDocumentResponseDto(fileName)); + } + + private (string ClientName, string ClientVersion) GetRequestInformation() + { + var clientName = ClaimHelpers.GetClient(HttpContext.User) ?? "unknown-client"; + var clientVersion = ClaimHelpers.GetIsApiKey(HttpContext.User) + ? "apikey" + : ClaimHelpers.GetVersion(HttpContext.User) ?? "unknown-version"; + + return (clientName, clientVersion); + } + } +} diff --git a/Jellyfin.Api/Controllers/DashboardController.cs b/Jellyfin.Api/Controllers/DashboardController.cs index 445733c24..87cb418d9 100644 --- a/Jellyfin.Api/Controllers/DashboardController.cs +++ b/Jellyfin.Api/Controllers/DashboardController.cs @@ -53,7 +53,7 @@ namespace Jellyfin.Api.Controllers if (enableInMainMenu.HasValue) { - configPages = configPages.Where(p => p!.EnableInMainMenu == enableInMainMenu.Value).ToList(); + configPages = configPages.Where(p => p.EnableInMainMenu == enableInMainMenu.Value).ToList(); } return configPages; diff --git a/Jellyfin.Api/Controllers/DisplayPreferencesController.cs b/Jellyfin.Api/Controllers/DisplayPreferencesController.cs index 87b4577b6..0b2604640 100644 --- a/Jellyfin.Api/Controllers/DisplayPreferencesController.cs +++ b/Jellyfin.Api/Controllers/DisplayPreferencesController.cs @@ -8,7 +8,7 @@ using Jellyfin.Data.Entities; using Jellyfin.Data.Enums; using MediaBrowser.Common.Extensions; using MediaBrowser.Controller; -using MediaBrowser.Model.Entities; +using MediaBrowser.Model.Dto; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; @@ -137,27 +137,30 @@ namespace Jellyfin.Api.Controllers } var existingDisplayPreferences = _displayPreferencesManager.GetDisplayPreferences(userId, itemId, client); - existingDisplayPreferences.IndexBy = Enum.TryParse<IndexingKind>(displayPreferences.IndexBy, true, out var indexBy) ? indexBy : (IndexingKind?)null; + existingDisplayPreferences.IndexBy = Enum.TryParse<IndexingKind>(displayPreferences.IndexBy, true, out var indexBy) ? indexBy : null; existingDisplayPreferences.ShowBackdrop = displayPreferences.ShowBackdrop; existingDisplayPreferences.ShowSidebar = displayPreferences.ShowSidebar; existingDisplayPreferences.ScrollDirection = displayPreferences.ScrollDirection; existingDisplayPreferences.ChromecastVersion = displayPreferences.CustomPrefs.TryGetValue("chromecastVersion", out var chromecastVersion) + && !string.IsNullOrEmpty(chromecastVersion) ? Enum.Parse<ChromecastVersion>(chromecastVersion, true) : ChromecastVersion.Stable; displayPreferences.CustomPrefs.Remove("chromecastVersion"); - existingDisplayPreferences.EnableNextVideoInfoOverlay = displayPreferences.CustomPrefs.TryGetValue("enableNextVideoInfoOverlay", out var enableNextVideoInfoOverlay) - ? bool.Parse(enableNextVideoInfoOverlay) - : true; + existingDisplayPreferences.EnableNextVideoInfoOverlay = !displayPreferences.CustomPrefs.TryGetValue("enableNextVideoInfoOverlay", out var enableNextVideoInfoOverlay) + || string.IsNullOrEmpty(enableNextVideoInfoOverlay) + || bool.Parse(enableNextVideoInfoOverlay); displayPreferences.CustomPrefs.Remove("enableNextVideoInfoOverlay"); existingDisplayPreferences.SkipBackwardLength = displayPreferences.CustomPrefs.TryGetValue("skipBackLength", out var skipBackLength) + && !string.IsNullOrEmpty(skipBackLength) ? int.Parse(skipBackLength, CultureInfo.InvariantCulture) : 10000; displayPreferences.CustomPrefs.Remove("skipBackLength"); existingDisplayPreferences.SkipForwardLength = displayPreferences.CustomPrefs.TryGetValue("skipForwardLength", out var skipForwardLength) + && !string.IsNullOrEmpty(skipForwardLength) ? int.Parse(skipForwardLength, CultureInfo.InvariantCulture) : 30000; displayPreferences.CustomPrefs.Remove("skipForwardLength"); @@ -196,7 +199,7 @@ namespace Jellyfin.Api.Controllers } var itemPrefs = _displayPreferencesManager.GetItemDisplayPreferences(existingDisplayPreferences.UserId, itemId, existingDisplayPreferences.Client); - itemPrefs.SortBy = displayPreferences.SortBy; + itemPrefs.SortBy = displayPreferences.SortBy ?? "SortName"; itemPrefs.SortOrder = displayPreferences.SortOrder; itemPrefs.RememberIndexing = displayPreferences.RememberIndexing; itemPrefs.RememberSorting = displayPreferences.RememberSorting; diff --git a/Jellyfin.Api/Controllers/DlnaController.cs b/Jellyfin.Api/Controllers/DlnaController.cs index 052a6aff2..35c3a3d92 100644 --- a/Jellyfin.Api/Controllers/DlnaController.cs +++ b/Jellyfin.Api/Controllers/DlnaController.cs @@ -126,7 +126,7 @@ namespace Jellyfin.Api.Controllers return NotFound(); } - _dlnaManager.UpdateProfile(deviceProfile); + _dlnaManager.UpdateProfile(profileId, deviceProfile); return NoContent(); } } diff --git a/Jellyfin.Api/Controllers/DlnaServerController.cs b/Jellyfin.Api/Controllers/DlnaServerController.cs index 694d16ad9..b1c576c33 100644 --- a/Jellyfin.Api/Controllers/DlnaServerController.cs +++ b/Jellyfin.Api/Controllers/DlnaServerController.cs @@ -7,7 +7,10 @@ using System.Threading.Tasks; using Emby.Dlna; using Emby.Dlna.Main; using Jellyfin.Api.Attributes; +using Jellyfin.Api.Constants; using MediaBrowser.Controller.Dlna; +using MediaBrowser.Model.Net; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; @@ -17,6 +20,7 @@ namespace Jellyfin.Api.Controllers /// Dlna Server Controller. /// </summary> [Route("Dlna")] + [Authorize(Policy = Policies.AnonymousLanAccessPolicy)] public class DlnaServerController : BaseJellyfinApiController { private readonly IDlnaManager _dlnaManager; @@ -334,11 +338,7 @@ namespace Jellyfin.Api.Controllers return NotFound(); } - var contentType = "image/" + Path.GetExtension(fileName) - .TrimStart('.') - .ToLowerInvariant(); - - return File(icon.Stream, contentType); + return File(icon.Stream, MimeTypes.GetMimeType(fileName)); } private string GetAbsoluteUri() diff --git a/Jellyfin.Api/Controllers/DynamicHlsController.cs b/Jellyfin.Api/Controllers/DynamicHlsController.cs index a54003357..6ef3a2ff9 100644 --- a/Jellyfin.Api/Controllers/DynamicHlsController.cs +++ b/Jellyfin.Api/Controllers/DynamicHlsController.cs @@ -39,7 +39,8 @@ namespace Jellyfin.Api.Controllers [Authorize(Policy = Policies.DefaultAuthorization)] public class DynamicHlsController : BaseJellyfinApiController { - private const string DefaultEncoderPreset = "veryfast"; + private const string DefaultVodEncoderPreset = "veryfast"; + private const string DefaultEventEncoderPreset = "superfast"; private const TranscodingJobType TranscodingJobType = MediaBrowser.Controller.MediaEncoding.TranscodingJobType.Hls; private readonly ILibraryManager _libraryManager; @@ -105,6 +106,253 @@ namespace Jellyfin.Api.Controllers _encodingOptions = serverConfigurationManager.GetEncodingOptions(); } + /// <summary> + /// Gets a hls live stream. + /// </summary> + /// <param name="itemId">The item id.</param> + /// <param name="container">The audio container.</param> + /// <param name="static">Optional. If true, the original file will be streamed statically without any encoding. Use either no url extension or the original file extension. true/false.</param> + /// <param name="params">The streaming parameters.</param> + /// <param name="tag">The tag.</param> + /// <param name="deviceProfileId">Optional. The dlna device profile id to utilize.</param> + /// <param name="playSessionId">The play session id.</param> + /// <param name="segmentContainer">The segment container.</param> + /// <param name="segmentLength">The segment lenght.</param> + /// <param name="minSegments">The minimum number of segments.</param> + /// <param name="mediaSourceId">The media version id, if playing an alternate version.</param> + /// <param name="deviceId">The device id of the client requesting. Used to stop encoding processes when needed.</param> + /// <param name="audioCodec">Optional. Specify a audio codec to encode to, e.g. mp3. If omitted the server will auto-select using the url's extension. Options: aac, mp3, vorbis, wma.</param> + /// <param name="enableAutoStreamCopy">Whether or not to allow automatic stream copy if requested values match the original source. Defaults to true.</param> + /// <param name="allowVideoStreamCopy">Whether or not to allow copying of the video stream url.</param> + /// <param name="allowAudioStreamCopy">Whether or not to allow copying of the audio stream url.</param> + /// <param name="breakOnNonKeyFrames">Optional. Whether to break on non key frames.</param> + /// <param name="audioSampleRate">Optional. Specify a specific audio sample rate, e.g. 44100.</param> + /// <param name="maxAudioBitDepth">Optional. The maximum audio bit depth.</param> + /// <param name="audioBitRate">Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults.</param> + /// <param name="audioChannels">Optional. Specify a specific number of audio channels to encode to, e.g. 2.</param> + /// <param name="maxAudioChannels">Optional. Specify a maximum number of audio channels to encode to, e.g. 2.</param> + /// <param name="profile">Optional. Specify a specific an encoder profile (varies by encoder), e.g. main, baseline, high.</param> + /// <param name="level">Optional. Specify a level for the encoder profile (varies by encoder), e.g. 3, 3.1.</param> + /// <param name="framerate">Optional. A specific video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param> + /// <param name="maxFramerate">Optional. A specific maximum video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param> + /// <param name="copyTimestamps">Whether or not to copy timestamps when transcoding with an offset. Defaults to false.</param> + /// <param name="startTimeTicks">Optional. Specify a starting offset, in ticks. 1 tick = 10000 ms.</param> + /// <param name="width">Optional. The fixed horizontal resolution of the encoded video.</param> + /// <param name="height">Optional. The fixed vertical resolution of the encoded video.</param> + /// <param name="videoBitRate">Optional. Specify a video bitrate to encode to, e.g. 500000. If omitted this will be left to encoder defaults.</param> + /// <param name="subtitleStreamIndex">Optional. The index of the subtitle stream to use. If omitted no subtitles will be used.</param> + /// <param name="subtitleMethod">Optional. Specify the subtitle delivery method.</param> + /// <param name="maxRefFrames">Optional.</param> + /// <param name="maxVideoBitDepth">Optional. The maximum video bit depth.</param> + /// <param name="requireAvc">Optional. Whether to require avc.</param> + /// <param name="deInterlace">Optional. Whether to deinterlace the video.</param> + /// <param name="requireNonAnamorphic">Optional. Whether to require a non anamorphic stream.</param> + /// <param name="transcodingMaxAudioChannels">Optional. The maximum number of audio channels to transcode.</param> + /// <param name="cpuCoreLimit">Optional. The limit of how many cpu cores to use.</param> + /// <param name="liveStreamId">The live stream id.</param> + /// <param name="enableMpegtsM2TsMode">Optional. Whether to enable the MpegtsM2Ts mode.</param> + /// <param name="videoCodec">Optional. Specify a video codec to encode to, e.g. h264. If omitted the server will auto-select using the url's extension. Options: h265, h264, mpeg4, theora, vp8, vp9, vpx (deprecated), wmv.</param> + /// <param name="subtitleCodec">Optional. Specify a subtitle codec to encode to.</param> + /// <param name="transcodeReasons">Optional. The transcoding reason.</param> + /// <param name="audioStreamIndex">Optional. The index of the audio stream to use. If omitted the first audio stream will be used.</param> + /// <param name="videoStreamIndex">Optional. The index of the video stream to use. If omitted the first video stream will be used.</param> + /// <param name="context">Optional. The <see cref="EncodingContext"/>.</param> + /// <param name="streamOptions">Optional. The streaming options.</param> + /// <param name="maxWidth">Optional. The max width.</param> + /// <param name="maxHeight">Optional. The max height.</param> + /// <param name="enableSubtitlesInManifest">Optional. Whether to enable subtitles in the manifest.</param> + /// <response code="200">Hls live stream retrieved.</response> + /// <returns>A <see cref="FileResult"/> containing the hls file.</returns> + [HttpGet("Videos/{itemId}/live.m3u8")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesPlaylistFile] + public async Task<ActionResult> GetLiveHlsStream( + [FromRoute, Required] Guid itemId, + [FromQuery] string? container, + [FromQuery] bool? @static, + [FromQuery] string? @params, + [FromQuery] string? tag, + [FromQuery] string? deviceProfileId, + [FromQuery] string? playSessionId, + [FromQuery] string? segmentContainer, + [FromQuery] int? segmentLength, + [FromQuery] int? minSegments, + [FromQuery] string? mediaSourceId, + [FromQuery] string? deviceId, + [FromQuery] string? audioCodec, + [FromQuery] bool? enableAutoStreamCopy, + [FromQuery] bool? allowVideoStreamCopy, + [FromQuery] bool? allowAudioStreamCopy, + [FromQuery] bool? breakOnNonKeyFrames, + [FromQuery] int? audioSampleRate, + [FromQuery] int? maxAudioBitDepth, + [FromQuery] int? audioBitRate, + [FromQuery] int? audioChannels, + [FromQuery] int? maxAudioChannels, + [FromQuery] string? profile, + [FromQuery] string? level, + [FromQuery] float? framerate, + [FromQuery] float? maxFramerate, + [FromQuery] bool? copyTimestamps, + [FromQuery] long? startTimeTicks, + [FromQuery] int? width, + [FromQuery] int? height, + [FromQuery] int? videoBitRate, + [FromQuery] int? subtitleStreamIndex, + [FromQuery] SubtitleDeliveryMethod? subtitleMethod, + [FromQuery] int? maxRefFrames, + [FromQuery] int? maxVideoBitDepth, + [FromQuery] bool? requireAvc, + [FromQuery] bool? deInterlace, + [FromQuery] bool? requireNonAnamorphic, + [FromQuery] int? transcodingMaxAudioChannels, + [FromQuery] int? cpuCoreLimit, + [FromQuery] string? liveStreamId, + [FromQuery] bool? enableMpegtsM2TsMode, + [FromQuery] string? videoCodec, + [FromQuery] string? subtitleCodec, + [FromQuery] string? transcodeReasons, + [FromQuery] int? audioStreamIndex, + [FromQuery] int? videoStreamIndex, + [FromQuery] EncodingContext? context, + [FromQuery] Dictionary<string, string> streamOptions, + [FromQuery] int? maxWidth, + [FromQuery] int? maxHeight, + [FromQuery] bool? enableSubtitlesInManifest) + { + VideoRequestDto streamingRequest = new VideoRequestDto + { + Id = itemId, + Container = container, + Static = @static ?? false, + Params = @params, + Tag = tag, + DeviceProfileId = deviceProfileId, + PlaySessionId = playSessionId, + SegmentContainer = segmentContainer, + SegmentLength = segmentLength, + MinSegments = minSegments, + MediaSourceId = mediaSourceId, + DeviceId = deviceId, + AudioCodec = audioCodec, + EnableAutoStreamCopy = enableAutoStreamCopy ?? true, + AllowAudioStreamCopy = allowAudioStreamCopy ?? true, + AllowVideoStreamCopy = allowVideoStreamCopy ?? true, + BreakOnNonKeyFrames = breakOnNonKeyFrames ?? false, + AudioSampleRate = audioSampleRate, + MaxAudioChannels = maxAudioChannels, + AudioBitRate = audioBitRate, + MaxAudioBitDepth = maxAudioBitDepth, + AudioChannels = audioChannels, + Profile = profile, + Level = level, + Framerate = framerate, + MaxFramerate = maxFramerate, + CopyTimestamps = copyTimestamps ?? false, + StartTimeTicks = startTimeTicks, + Width = width, + Height = height, + VideoBitRate = videoBitRate, + SubtitleStreamIndex = subtitleStreamIndex, + SubtitleMethod = subtitleMethod ?? SubtitleDeliveryMethod.Encode, + MaxRefFrames = maxRefFrames, + MaxVideoBitDepth = maxVideoBitDepth, + RequireAvc = requireAvc ?? false, + DeInterlace = deInterlace ?? false, + RequireNonAnamorphic = requireNonAnamorphic ?? false, + TranscodingMaxAudioChannels = transcodingMaxAudioChannels, + CpuCoreLimit = cpuCoreLimit, + LiveStreamId = liveStreamId, + EnableMpegtsM2TsMode = enableMpegtsM2TsMode ?? false, + VideoCodec = videoCodec, + SubtitleCodec = subtitleCodec, + TranscodeReasons = transcodeReasons, + AudioStreamIndex = audioStreamIndex, + VideoStreamIndex = videoStreamIndex, + Context = context ?? EncodingContext.Streaming, + StreamOptions = streamOptions, + MaxHeight = maxHeight, + MaxWidth = maxWidth, + EnableSubtitlesInManifest = enableSubtitlesInManifest ?? true + }; + + // CTS lifecycle is managed internally. + var cancellationTokenSource = new CancellationTokenSource(); + // Due to CTS.Token calling ThrowIfDisposed (https://github.com/dotnet/runtime/issues/29970) we have to "cache" the token + // since it gets disposed when ffmpeg exits + var cancellationToken = cancellationTokenSource.Token; + using var state = await StreamingHelpers.GetStreamingState( + streamingRequest, + Request, + _authContext, + _mediaSourceManager, + _userManager, + _libraryManager, + _serverConfigurationManager, + _mediaEncoder, + _encodingHelper, + _dlnaManager, + _deviceManager, + _transcodingJobHelper, + TranscodingJobType, + cancellationToken) + .ConfigureAwait(false); + + TranscodingJobDto? job = null; + var playlistPath = Path.ChangeExtension(state.OutputFilePath, ".m3u8"); + + if (!System.IO.File.Exists(playlistPath)) + { + var transcodingLock = _transcodingJobHelper.GetTranscodingLock(playlistPath); + await transcodingLock.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + if (!System.IO.File.Exists(playlistPath)) + { + // If the playlist doesn't already exist, startup ffmpeg + try + { + job = await _transcodingJobHelper.StartFfMpeg( + state, + playlistPath, + GetCommandLineArguments(playlistPath, state, true, 0), + Request, + TranscodingJobType, + cancellationTokenSource) + .ConfigureAwait(false); + job.IsLiveOutput = true; + } + catch + { + state.Dispose(); + throw; + } + + minSegments = state.MinSegments; + if (minSegments > 0) + { + await HlsHelpers.WaitForMinimumSegmentCount(playlistPath, minSegments, _logger, cancellationToken).ConfigureAwait(false); + } + } + } + finally + { + transcodingLock.Release(); + } + } + + job ??= _transcodingJobHelper.OnTranscodeBeginRequest(playlistPath, TranscodingJobType); + + if (job != null) + { + _transcodingJobHelper.OnTranscodeEndRequest(job); + } + + var playlistText = HlsHelpers.GetLivePlaylistText(playlistPath, state); + + return Content(playlistText, MimeTypes.GetMimeType("playlist.m3u8")); + } + /// <summary> /// Gets a video hls playlist stream. /// </summary> @@ -1149,7 +1397,7 @@ namespace Jellyfin.Api.Controllers .AppendLine("#EXT-X-MEDIA-SEQUENCE:0"); var index = 0; - var segmentExtension = GetSegmentFileExtension(streamingRequest.SegmentContainer); + var segmentExtension = EncodingHelper.GetSegmentFileExtension(streamingRequest.SegmentContainer); var queryString = Request.QueryString; if (isHlsInFmp4) @@ -1214,7 +1462,7 @@ namespace Jellyfin.Api.Controllers var segmentPath = GetSegmentPath(state, playlistPath, segmentId); - var segmentExtension = GetSegmentFileExtension(state.Request.SegmentContainer); + var segmentExtension = EncodingHelper.GetSegmentFileExtension(state.Request.SegmentContainer); TranscodingJobDto? job; @@ -1286,7 +1534,7 @@ namespace Jellyfin.Api.Controllers job = await _transcodingJobHelper.StartFfMpeg( state, playlistPath, - GetCommandLineArguments(playlistPath, state, true, segmentId), + GetCommandLineArguments(playlistPath, state, false, segmentId), Request, TranscodingJobType, cancellationTokenSource).ConfigureAwait(false); @@ -1346,7 +1594,7 @@ namespace Jellyfin.Api.Controllers return segments; } - private string GetCommandLineArguments(string outputPath, StreamState state, bool isEncoding, int startNumber) + private string GetCommandLineArguments(string outputPath, StreamState state, bool isEventPlaylist, int startNumber) { var videoCodec = _encodingHelper.GetVideoEncoder(state, _encodingOptions); var threads = EncodingHelper.GetNumberOfThreads(state, _encodingOptions, videoCodec); @@ -1361,15 +1609,13 @@ namespace Jellyfin.Api.Controllers state.BaseRequest.BreakOnNonKeyFrames = false; } - // If isEncoding is true we're actually starting ffmpeg - var startNumberParam = isEncoding ? startNumber.ToString(CultureInfo.InvariantCulture) : "0"; var inputModifier = _encodingHelper.GetInputModifier(state, _encodingOptions); var mapArgs = state.IsOutputVideo ? _encodingHelper.GetMapArgs(state) : string.Empty; var directory = Path.GetDirectoryName(outputPath) ?? throw new ArgumentException($"Provided path ({outputPath}) is not valid.", nameof(outputPath)); var outputFileNameWithoutExtension = Path.GetFileNameWithoutExtension(outputPath); var outputPrefix = Path.Combine(directory, outputFileNameWithoutExtension); - var outputExtension = GetSegmentFileExtension(state.Request.SegmentContainer); + var outputExtension = EncodingHelper.GetSegmentFileExtension(state.Request.SegmentContainer); var outputTsArg = outputPrefix + "%d" + outputExtension; var segmentFormat = outputExtension.TrimStart('.'); @@ -1391,26 +1637,37 @@ namespace Jellyfin.Api.Controllers } else { - _logger.LogError("Invalid HLS segment container: " + segmentFormat); + _logger.LogError("Invalid HLS segment container: {SegmentFormat}", segmentFormat); } var maxMuxingQueueSize = _encodingOptions.MaxMuxingQueueSize > 128 ? _encodingOptions.MaxMuxingQueueSize.ToString(CultureInfo.InvariantCulture) : "128"; + var baseUrlParam = string.Empty; + if (isEventPlaylist) + { + baseUrlParam = string.Format( + CultureInfo.InvariantCulture, + " -hls_base_url \"hls/{0}/\"", + Path.GetFileNameWithoutExtension(outputPath)); + } + return string.Format( CultureInfo.InvariantCulture, - "{0} {1} -map_metadata -1 -map_chapters -1 -threads {2} {3} {4} {5} -copyts -avoid_negative_ts disabled -max_muxing_queue_size {6} -f hls -max_delay 5000000 -hls_time {7} -hls_segment_type {8} -start_number {9} -hls_segment_filename \"{10}\" -hls_playlist_type vod -hls_list_size 0 -y \"{11}\"", + "{0} {1} -map_metadata -1 -map_chapters -1 -threads {2} {3} {4} {5} -copyts -avoid_negative_ts disabled -max_muxing_queue_size {6} -f hls -max_delay 5000000 -hls_time {7} -hls_segment_type {8} -start_number {9}{10} -hls_segment_filename \"{12}\" -hls_playlist_type {11} -hls_list_size 0 -y \"{13}\"", inputModifier, _encodingHelper.GetInputArgument(state, _encodingOptions), threads, mapArgs, - GetVideoArguments(state, startNumber), + GetVideoArguments(state, startNumber, isEventPlaylist), GetAudioArguments(state), maxMuxingQueueSize, state.SegmentLength.ToString(CultureInfo.InvariantCulture), segmentFormat, - startNumberParam, + startNumber.ToString(CultureInfo.InvariantCulture), + baseUrlParam, + isEventPlaylist ? "event" : "vod", outputTsArg, outputPath).Trim(); } @@ -1505,8 +1762,9 @@ namespace Jellyfin.Api.Controllers /// </summary> /// <param name="state">The <see cref="StreamState"/>.</param> /// <param name="startNumber">The first number in the hls sequence.</param> + /// <param name="isEventPlaylist">Whether the playlist is EVENT or VOD.</param> /// <returns>The command line arguments for video transcoding.</returns> - private string GetVideoArguments(StreamState state, int startNumber) + private string GetVideoArguments(StreamState state, int startNumber, bool isEventPlaylist) { if (state.VideoStream == null) { @@ -1539,6 +1797,7 @@ namespace Jellyfin.Api.Controllers // See if we can save come cpu cycles by avoiding encoding. if (EncodingHelper.IsCopyCodec(codec)) { + // If h264_mp4toannexb is ever added, do not use it for live tv. if (state.VideoStream != null && !string.Equals(state.VideoStream.NalLengthSize, "0", StringComparison.OrdinalIgnoreCase)) { string bitStreamArgs = EncodingHelper.GetBitStreamArgs(state.VideoStream); @@ -1549,15 +1808,13 @@ namespace Jellyfin.Api.Controllers } args += " -start_at_zero"; - - // args += " -flags -global_header"; } else { - args += _encodingHelper.GetVideoQualityParam(state, codec, _encodingOptions, DefaultEncoderPreset); + args += _encodingHelper.GetVideoQualityParam(state, codec, _encodingOptions, isEventPlaylist ? DefaultEventEncoderPreset : DefaultVodEncoderPreset); // Set the key frame params for video encoding to match the hls segment time. - args += _encodingHelper.GetHlsVideoKeyFrameArguments(state, codec, state.SegmentLength, false, startNumber); + args += _encodingHelper.GetHlsVideoKeyFrameArguments(state, codec, state.SegmentLength, isEventPlaylist, startNumber); // Currenly b-frames in libx265 breaks the FMP4-HLS playback on iOS, disable it for now. if (string.Equals(codec, "libx265", StringComparison.OrdinalIgnoreCase)) @@ -1567,27 +1824,25 @@ namespace Jellyfin.Api.Controllers // args += " -mixed-refs 0 -refs 3 -x264opts b_pyramid=0:weightb=0:weightp=0"; - var hasGraphicalSubs = state.SubtitleStream != null && !state.SubtitleStream.IsTextSubtitleStream && state.SubtitleDeliveryMethod == SubtitleDeliveryMethod.Encode; - - if (hasGraphicalSubs) - { - // Graphical subs overlay and resolution params. - args += _encodingHelper.GetGraphicalSubtitleParam(state, _encodingOptions, codec); - } - else - { - // Resolution params. - args += _encodingHelper.GetOutputSizeParam(state, _encodingOptions, codec); - } + // video processing filters. + args += _encodingHelper.GetVideoProcessingFilterParam(state, _encodingOptions, codec); // -start_at_zero is necessary to use with -ss when seeking, // otherwise the target position cannot be determined. - if (!(state.SubtitleStream != null && state.SubtitleStream.IsExternal && !state.SubtitleStream.IsTextSubtitleStream)) + if (state.SubtitleStream != null) { - args += " -start_at_zero"; + // Disable start_at_zero for external graphical subs + if (!(state.SubtitleStream.IsExternal && !state.SubtitleStream.IsTextSubtitleStream)) + { + args += " -start_at_zero"; + } } + } - // args += " -flags -global_header"; + // TODO why was this not enabled for VOD? + if (isEventPlaylist) + { + args += " -flags -global_header"; } if (!string.IsNullOrEmpty(state.OutputVideoSync)) @@ -1600,22 +1855,12 @@ namespace Jellyfin.Api.Controllers return args; } - private string GetSegmentFileExtension(string? segmentContainer) - { - if (!string.IsNullOrWhiteSpace(segmentContainer)) - { - return "." + segmentContainer; - } - - return ".ts"; - } - private string GetSegmentPath(StreamState state, string playlist, int index) { var folder = Path.GetDirectoryName(playlist) ?? throw new ArgumentException($"Provided path ({playlist}) is not valid.", nameof(playlist)); var filename = Path.GetFileNameWithoutExtension(playlist); - return Path.Combine(folder, filename + index.ToString(CultureInfo.InvariantCulture) + GetSegmentFileExtension(state.Request.SegmentContainer)); + return Path.Combine(folder, filename + index.ToString(CultureInfo.InvariantCulture) + EncodingHelper.GetSegmentFileExtension(state.Request.SegmentContainer)); } private async Task<ActionResult> GetSegmentResult( @@ -1709,7 +1954,7 @@ namespace Jellyfin.Api.Controllers return Task.CompletedTask; }); - return FileStreamResponseHelpers.GetStaticFileResult(segmentPath, MimeTypes.GetMimeType(segmentPath)!, false, HttpContext); + return FileStreamResponseHelpers.GetStaticFileResult(segmentPath, MimeTypes.GetMimeType(segmentPath), false, HttpContext); } private long GetEndPositionTicks(StreamState state, int requestedIndex) @@ -1794,7 +2039,7 @@ namespace Jellyfin.Api.Controllers return; } - _logger.LogDebug("Deleting partial HLS file {path}", path); + _logger.LogDebug("Deleting partial HLS file {Path}", path); try { @@ -1802,15 +2047,15 @@ namespace Jellyfin.Api.Controllers } catch (IOException ex) { - _logger.LogError(ex, "Error deleting partial stream file(s) {path}", path); + _logger.LogError(ex, "Error deleting partial stream file(s) {Path}", path); var task = Task.Delay(100); - Task.WaitAll(task); + task.Wait(); DeleteFile(path, retryCount + 1); } catch (Exception ex) { - _logger.LogError(ex, "Error deleting partial stream file(s) {path}", path); + _logger.LogError(ex, "Error deleting partial stream file(s) {Path}", path); } } diff --git a/Jellyfin.Api/Controllers/FilterController.cs b/Jellyfin.Api/Controllers/FilterController.cs index 223b2a2b6..a4f12666d 100644 --- a/Jellyfin.Api/Controllers/FilterController.cs +++ b/Jellyfin.Api/Controllers/FilterController.cs @@ -1,7 +1,6 @@ using System; using System.Linq; using Jellyfin.Api.Constants; -using Jellyfin.Api.Helpers; using Jellyfin.Api.ModelBinders; using Jellyfin.Data.Enums; using MediaBrowser.Controller.Dto; @@ -71,7 +70,7 @@ namespace Jellyfin.Api.Controllers { User = user, MediaTypes = mediaTypes, - IncludeItemTypes = RequestHelpers.GetItemTypeStrings(includeItemTypes), + IncludeItemTypes = includeItemTypes, Recursive = true, EnableTotalRecordCount = false, DtoOptions = new DtoOptions @@ -166,7 +165,7 @@ namespace Jellyfin.Api.Controllers var filters = new QueryFilters(); var genreQuery = new InternalItemsQuery(user) { - IncludeItemTypes = RequestHelpers.GetItemTypeStrings(includeItemTypes), + IncludeItemTypes = includeItemTypes, DtoOptions = new DtoOptions { Fields = Array.Empty<ItemFields>(), @@ -198,16 +197,16 @@ namespace Jellyfin.Api.Controllers { filters.Genres = _libraryManager.GetMusicGenres(genreQuery).Items.Select(i => new NameGuidPair { - Name = i.Item1.Name, - Id = i.Item1.Id + Name = i.Item.Name, + Id = i.Item.Id }).ToArray(); } else { filters.Genres = _libraryManager.GetGenres(genreQuery).Items.Select(i => new NameGuidPair { - Name = i.Item1.Name, - Id = i.Item1.Id + Name = i.Item.Name, + Id = i.Item.Id }).ToArray(); } diff --git a/Jellyfin.Api/Controllers/GenresController.cs b/Jellyfin.Api/Controllers/GenresController.cs index 5aa457153..37e6ae184 100644 --- a/Jellyfin.Api/Controllers/GenresController.cs +++ b/Jellyfin.Api/Controllers/GenresController.cs @@ -101,8 +101,8 @@ namespace Jellyfin.Api.Controllers var query = new InternalItemsQuery(user) { - ExcludeItemTypes = RequestHelpers.GetItemTypeStrings(excludeItemTypes), - IncludeItemTypes = RequestHelpers.GetItemTypeStrings(includeItemTypes), + ExcludeItemTypes = excludeItemTypes, + IncludeItemTypes = includeItemTypes, StartIndex = startIndex, Limit = limit, IsFavorite = isFavorite, @@ -160,7 +160,7 @@ namespace Jellyfin.Api.Controllers Genre item = new Genre(); if (genreName.IndexOf(BaseItem.SlugChar, StringComparison.OrdinalIgnoreCase) != -1) { - var result = GetItemFromSlugName<Genre>(_libraryManager, genreName, dtoOptions); + var result = GetItemFromSlugName<Genre>(_libraryManager, genreName, dtoOptions, BaseItemKind.Genre); if (result != null) { @@ -182,27 +182,27 @@ namespace Jellyfin.Api.Controllers return _dtoService.GetBaseItemDto(item, dtoOptions); } - private T? GetItemFromSlugName<T>(ILibraryManager libraryManager, string name, DtoOptions dtoOptions) + private T? GetItemFromSlugName<T>(ILibraryManager libraryManager, string name, DtoOptions dtoOptions, BaseItemKind baseItemKind) where T : BaseItem, new() { var result = libraryManager.GetItemList(new InternalItemsQuery { Name = name.Replace(BaseItem.SlugChar, '&'), - IncludeItemTypes = new[] { typeof(T).Name }, + IncludeItemTypes = new[] { baseItemKind }, DtoOptions = dtoOptions }).OfType<T>().FirstOrDefault(); result ??= libraryManager.GetItemList(new InternalItemsQuery { Name = name.Replace(BaseItem.SlugChar, '/'), - IncludeItemTypes = new[] { typeof(T).Name }, + IncludeItemTypes = new[] { baseItemKind }, DtoOptions = dtoOptions }).OfType<T>().FirstOrDefault(); result ??= libraryManager.GetItemList(new InternalItemsQuery { Name = name.Replace(BaseItem.SlugChar, '?'), - IncludeItemTypes = new[] { typeof(T).Name }, + IncludeItemTypes = new[] { baseItemKind }, DtoOptions = dtoOptions }).OfType<T>().FirstOrDefault(); diff --git a/Jellyfin.Api/Controllers/HlsSegmentController.cs b/Jellyfin.Api/Controllers/HlsSegmentController.cs index 473bdc523..7325dca0a 100644 --- a/Jellyfin.Api/Controllers/HlsSegmentController.cs +++ b/Jellyfin.Api/Controllers/HlsSegmentController.cs @@ -64,12 +64,12 @@ namespace Jellyfin.Api.Controllers var transcodePath = _serverConfigurationManager.GetTranscodePath(); file = Path.GetFullPath(Path.Combine(transcodePath, file)); var fileDir = Path.GetDirectoryName(file); - if (string.IsNullOrEmpty(fileDir) || !fileDir.StartsWith(transcodePath)) + if (string.IsNullOrEmpty(fileDir) || !fileDir.StartsWith(transcodePath, StringComparison.InvariantCulture)) { return BadRequest("Invalid segment."); } - return FileStreamResponseHelpers.GetStaticFileResult(file, MimeTypes.GetMimeType(file)!, false, HttpContext); + return FileStreamResponseHelpers.GetStaticFileResult(file, MimeTypes.GetMimeType(file), false, HttpContext); } /// <summary> @@ -90,7 +90,7 @@ namespace Jellyfin.Api.Controllers var transcodePath = _serverConfigurationManager.GetTranscodePath(); file = Path.GetFullPath(Path.Combine(transcodePath, file)); var fileDir = Path.GetDirectoryName(file); - if (string.IsNullOrEmpty(fileDir) || !fileDir.StartsWith(transcodePath) || Path.GetExtension(file) != ".m3u8") + if (string.IsNullOrEmpty(fileDir) || !fileDir.StartsWith(transcodePath, StringComparison.InvariantCulture) || Path.GetExtension(file) != ".m3u8") { return BadRequest("Invalid segment."); } @@ -144,7 +144,7 @@ namespace Jellyfin.Api.Controllers file = Path.GetFullPath(Path.Combine(transcodeFolderPath, file)); var fileDir = Path.GetDirectoryName(file); - if (string.IsNullOrEmpty(fileDir) || !fileDir.StartsWith(transcodeFolderPath)) + if (string.IsNullOrEmpty(fileDir) || !fileDir.StartsWith(transcodeFolderPath, StringComparison.InvariantCulture)) { return BadRequest("Invalid segment."); } @@ -186,7 +186,7 @@ namespace Jellyfin.Api.Controllers return Task.CompletedTask; }); - return FileStreamResponseHelpers.GetStaticFileResult(path, MimeTypes.GetMimeType(path)!, false, HttpContext); + return FileStreamResponseHelpers.GetStaticFileResult(path, MimeTypes.GetMimeType(path), false, HttpContext); } } } diff --git a/Jellyfin.Api/Controllers/ImageByNameController.cs b/Jellyfin.Api/Controllers/ImageByNameController.cs index 99ab7f232..89bbf22c9 100644 --- a/Jellyfin.Api/Controllers/ImageByNameController.cs +++ b/Jellyfin.Api/Controllers/ImageByNameController.cs @@ -82,7 +82,7 @@ namespace Jellyfin.Api.Controllers return NotFound(); } - if (!path.StartsWith(_applicationPaths.GeneralPath)) + if (!path.StartsWith(_applicationPaths.GeneralPath, StringComparison.InvariantCulture)) { return BadRequest("Invalid image path."); } @@ -177,7 +177,7 @@ namespace Jellyfin.Api.Controllers if (!string.IsNullOrEmpty(path) && System.IO.File.Exists(path)) { - if (!path.StartsWith(basePath)) + if (!path.StartsWith(basePath, StringComparison.InvariantCulture)) { return BadRequest("Invalid image path."); } @@ -196,7 +196,7 @@ namespace Jellyfin.Api.Controllers if (!string.IsNullOrEmpty(path) && System.IO.File.Exists(path)) { - if (!path.StartsWith(basePath)) + if (!path.StartsWith(basePath, StringComparison.InvariantCulture)) { return BadRequest("Invalid image path."); } diff --git a/Jellyfin.Api/Controllers/ImageController.cs b/Jellyfin.Api/Controllers/ImageController.cs index 9dc280e13..e72589cfa 100644 --- a/Jellyfin.Api/Controllers/ImageController.cs +++ b/Jellyfin.Api/Controllers/ImageController.cs @@ -106,7 +106,7 @@ namespace Jellyfin.Api.Controllers await using var memoryStream = await GetMemoryStream(Request.Body).ConfigureAwait(false); // Handle image/png; charset=utf-8 - var mimeType = Request.ContentType.Split(';').FirstOrDefault(); + var mimeType = Request.ContentType?.Split(';').FirstOrDefault(); var userDataPath = Path.Combine(_serverConfigurationManager.ApplicationPaths.UserConfigurationDirectoryPath, user.Username); if (user.ProfileImage != null) { @@ -153,7 +153,7 @@ namespace Jellyfin.Api.Controllers await using var memoryStream = await GetMemoryStream(Request.Body).ConfigureAwait(false); // Handle image/png; charset=utf-8 - var mimeType = Request.ContentType.Split(';').FirstOrDefault(); + var mimeType = Request.ContentType?.Split(';').FirstOrDefault(); var userDataPath = Path.Combine(_serverConfigurationManager.ApplicationPaths.UserConfigurationDirectoryPath, user.Username); if (user.ProfileImage != null) { @@ -341,7 +341,7 @@ namespace Jellyfin.Api.Controllers await using var memoryStream = await GetMemoryStream(Request.Body).ConfigureAwait(false); // Handle image/png; charset=utf-8 - var mimeType = Request.ContentType.Split(';').FirstOrDefault(); + var mimeType = Request.ContentType?.Split(';').FirstOrDefault(); await _providerManager.SaveImage(item, memoryStream, mimeType, imageType, null, CancellationToken.None).ConfigureAwait(false); await item.UpdateToRepositoryAsync(ItemUpdateType.ImageUpdate, CancellationToken.None).ConfigureAwait(false); @@ -377,7 +377,7 @@ namespace Jellyfin.Api.Controllers await using var memoryStream = await GetMemoryStream(Request.Body).ConfigureAwait(false); // Handle image/png; charset=utf-8 - var mimeType = Request.ContentType.Split(';').FirstOrDefault(); + var mimeType = Request.ContentType?.Split(';').FirstOrDefault(); await _providerManager.SaveImage(item, memoryStream, mimeType, imageType, null, CancellationToken.None).ConfigureAwait(false); await item.UpdateToRepositoryAsync(ItemUpdateType.ImageUpdate, CancellationToken.None).ConfigureAwait(false); @@ -1878,8 +1878,8 @@ namespace Jellyfin.Api.Controllers if (!supportsWebP) { var userAgent = Request.Headers[HeaderNames.UserAgent].ToString(); - if (userAgent.IndexOf("crosswalk", StringComparison.OrdinalIgnoreCase) != -1 && - userAgent.IndexOf("android", StringComparison.OrdinalIgnoreCase) != -1) + if (userAgent.Contains("crosswalk", StringComparison.OrdinalIgnoreCase) + && userAgent.Contains("android", StringComparison.OrdinalIgnoreCase)) { supportsWebP = true; } @@ -1905,10 +1905,7 @@ namespace Jellyfin.Api.Controllers private bool SupportsFormat(IReadOnlyCollection<string> requestAcceptTypes, string acceptParam, ImageFormat format, bool acceptAll) { - var normalized = format.ToString().ToLowerInvariant(); - var mimeType = "image/" + normalized; - - if (requestAcceptTypes.Contains(mimeType)) + if (requestAcceptTypes.Contains(format.GetMimeType())) { return true; } @@ -1918,6 +1915,8 @@ namespace Jellyfin.Api.Controllers return true; } + // Review if this should be jpeg, jpg or both for ImageFormat.Jpg + var normalized = format.ToString().ToLowerInvariant(); return string.Equals(acceptParam, normalized, StringComparison.OrdinalIgnoreCase); } @@ -2007,7 +2006,7 @@ namespace Jellyfin.Api.Controllers Response.Headers.Add(HeaderNames.CacheControl, "public"); } - Response.Headers.Add(HeaderNames.LastModified, dateImageModified.ToUniversalTime().ToString("ddd, dd MMM yyyy HH:mm:ss \"GMT\"", new CultureInfo("en-US", false))); + Response.Headers.Add(HeaderNames.LastModified, dateImageModified.ToUniversalTime().ToString("ddd, dd MMM yyyy HH:mm:ss \"GMT\"", CultureInfo.InvariantCulture)); // if the image was not modified since "ifModifiedSinceHeader"-header, return a HTTP status code 304 not modified if (!(dateImageModified > ifModifiedSinceHeader) && cacheDuration.HasValue) @@ -2026,7 +2025,7 @@ namespace Jellyfin.Api.Controllers return NoContent(); } - return PhysicalFile(imagePath, imageContentType); + return PhysicalFile(imagePath, imageContentType ?? MediaTypeNames.Text.Plain); } } } diff --git a/Jellyfin.Api/Controllers/InstantMixController.cs b/Jellyfin.Api/Controllers/InstantMixController.cs index 4774ed4ef..a6c2e07c9 100644 --- a/Jellyfin.Api/Controllers/InstantMixController.cs +++ b/Jellyfin.Api/Controllers/InstantMixController.cs @@ -80,7 +80,7 @@ namespace Jellyfin.Api.Controllers : null; var dtoOptions = new DtoOptions { Fields = fields } .AddClientFields(Request) - .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes!); + .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); var items = _musicManager.GetInstantMixFromItem(item, user, dtoOptions); return GetResult(items, user, limit, dtoOptions); } @@ -116,7 +116,7 @@ namespace Jellyfin.Api.Controllers : null; var dtoOptions = new DtoOptions { Fields = fields } .AddClientFields(Request) - .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes!); + .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); var items = _musicManager.GetInstantMixFromItem(album, user, dtoOptions); return GetResult(items, user, limit, dtoOptions); } @@ -152,7 +152,7 @@ namespace Jellyfin.Api.Controllers : null; var dtoOptions = new DtoOptions { Fields = fields } .AddClientFields(Request) - .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes!); + .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); var items = _musicManager.GetInstantMixFromItem(playlist, user, dtoOptions); return GetResult(items, user, limit, dtoOptions); } @@ -187,7 +187,7 @@ namespace Jellyfin.Api.Controllers : null; var dtoOptions = new DtoOptions { Fields = fields } .AddClientFields(Request) - .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes!); + .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); var items = _musicManager.GetInstantMixFromGenres(new[] { name }, user, dtoOptions); return GetResult(items, user, limit, dtoOptions); } @@ -223,7 +223,7 @@ namespace Jellyfin.Api.Controllers : null; var dtoOptions = new DtoOptions { Fields = fields } .AddClientFields(Request) - .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes!); + .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); var items = _musicManager.GetInstantMixFromItem(item, user, dtoOptions); return GetResult(items, user, limit, dtoOptions); } @@ -259,7 +259,7 @@ namespace Jellyfin.Api.Controllers : null; var dtoOptions = new DtoOptions { Fields = fields } .AddClientFields(Request) - .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes!); + .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); var items = _musicManager.GetInstantMixFromItem(item, user, dtoOptions); return GetResult(items, user, limit, dtoOptions); } @@ -332,7 +332,7 @@ namespace Jellyfin.Api.Controllers : null; var dtoOptions = new DtoOptions { Fields = fields } .AddClientFields(Request) - .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes!); + .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); var items = _musicManager.GetInstantMixFromItem(item, user, dtoOptions); return GetResult(items, user, limit, dtoOptions); } diff --git a/Jellyfin.Api/Controllers/ItemLookupController.cs b/Jellyfin.Api/Controllers/ItemLookupController.cs index 448510c06..c49f85616 100644 --- a/Jellyfin.Api/Controllers/ItemLookupController.cs +++ b/Jellyfin.Api/Controllers/ItemLookupController.cs @@ -5,8 +5,6 @@ using System.Text.Json; using System.Threading; using System.Threading.Tasks; using Jellyfin.Api.Constants; -using MediaBrowser.Controller; -using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.Audio; using MediaBrowser.Controller.Entities.Movies; @@ -30,7 +28,6 @@ namespace Jellyfin.Api.Controllers public class ItemLookupController : BaseJellyfinApiController { private readonly IProviderManager _providerManager; - private readonly IServerApplicationPaths _appPaths; private readonly IFileSystem _fileSystem; private readonly ILibraryManager _libraryManager; private readonly ILogger<ItemLookupController> _logger; @@ -39,19 +36,16 @@ namespace Jellyfin.Api.Controllers /// Initializes a new instance of the <see cref="ItemLookupController"/> class. /// </summary> /// <param name="providerManager">Instance of the <see cref="IProviderManager"/> interface.</param> - /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param> /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param> /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> /// <param name="logger">Instance of the <see cref="ILogger{ItemLookupController}"/> interface.</param> public ItemLookupController( IProviderManager providerManager, - IServerConfigurationManager serverConfigurationManager, IFileSystem fileSystem, ILibraryManager libraryManager, ILogger<ItemLookupController> logger) { _providerManager = providerManager; - _appPaths = serverConfigurationManager.ApplicationPaths; _fileSystem = fileSystem; _libraryManager = libraryManager; _logger = logger; @@ -269,8 +263,10 @@ namespace Jellyfin.Api.Controllers ImageRefreshMode = MetadataRefreshMode.FullRefresh, ReplaceAllMetadata = true, ReplaceAllImages = replaceAllImages, - SearchResult = searchResult - }, CancellationToken.None).ConfigureAwait(false); + SearchResult = searchResult, + RemoveOldMetadata = true + }, + CancellationToken.None).ConfigureAwait(false); return NoContent(); } diff --git a/Jellyfin.Api/Controllers/ItemUpdateController.cs b/Jellyfin.Api/Controllers/ItemUpdateController.cs index 64d7b2f3e..fd137f98f 100644 --- a/Jellyfin.Api/Controllers/ItemUpdateController.cs +++ b/Jellyfin.Api/Controllers/ItemUpdateController.cs @@ -263,8 +263,8 @@ namespace Jellyfin.Api.Controllers item.DateCreated = NormalizeDateTime(request.DateCreated.Value); } - item.EndDate = request.EndDate.HasValue ? NormalizeDateTime(request.EndDate.Value) : (DateTime?)null; - item.PremiereDate = request.PremiereDate.HasValue ? NormalizeDateTime(request.PremiereDate.Value) : (DateTime?)null; + item.EndDate = request.EndDate.HasValue ? NormalizeDateTime(request.EndDate.Value) : null; + item.PremiereDate = request.PremiereDate.HasValue ? NormalizeDateTime(request.PremiereDate.Value) : null; item.ProductionYear = request.ProductionYear; item.OfficialRating = string.IsNullOrWhiteSpace(request.OfficialRating) ? null : request.OfficialRating; item.CustomRating = request.CustomRating; diff --git a/Jellyfin.Api/Controllers/ItemsController.cs b/Jellyfin.Api/Controllers/ItemsController.cs index 52eefc5c2..f8192955e 100644 --- a/Jellyfin.Api/Controllers/ItemsController.cs +++ b/Jellyfin.Api/Controllers/ItemsController.cs @@ -8,8 +8,8 @@ using Jellyfin.Api.ModelBinders; using Jellyfin.Data.Enums; using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; -using MediaBrowser.Controller.Entities.Audio; using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Session; using MediaBrowser.Model.Dto; using MediaBrowser.Model.Entities; using MediaBrowser.Model.Globalization; @@ -33,6 +33,7 @@ namespace Jellyfin.Api.Controllers private readonly ILocalizationManager _localization; private readonly IDtoService _dtoService; private readonly ILogger<ItemsController> _logger; + private readonly ISessionManager _sessionManager; /// <summary> /// Initializes a new instance of the <see cref="ItemsController"/> class. @@ -42,18 +43,21 @@ namespace Jellyfin.Api.Controllers /// <param name="localization">Instance of the <see cref="ILocalizationManager"/> interface.</param> /// <param name="dtoService">Instance of the <see cref="IDtoService"/> interface.</param> /// <param name="logger">Instance of the <see cref="ILogger"/> interface.</param> + /// <param name="sessionManager">Instance of the <see cref="ISessionManager"/> interface.</param> public ItemsController( IUserManager userManager, ILibraryManager libraryManager, ILocalizationManager localization, IDtoService dtoService, - ILogger<ItemsController> logger) + ILogger<ItemsController> logger, + ISessionManager sessionManager) { _userManager = userManager; _libraryManager = libraryManager; _localization = localization; _dtoService = dtoService; _logger = logger; + _sessionManager = sessionManager; } /// <summary> @@ -224,9 +228,7 @@ namespace Jellyfin.Api.Controllers [FromQuery] bool enableTotalRecordCount = true, [FromQuery] bool? enableImages = true) { - var user = !userId.Equals(Guid.Empty) - ? _userManager.GetUserById(userId) - : null; + var user = userId == Guid.Empty ? null : _userManager.GetUserById(userId); var dtoOptions = new DtoOptions { Fields = fields } .AddClientFields(Request) .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); @@ -287,12 +289,12 @@ namespace Jellyfin.Api.Controllers if ((recursive.HasValue && recursive.Value) || ids.Length != 0 || item is not UserRootFolder) { - var query = new InternalItemsQuery(user!) + var query = new InternalItemsQuery(user) { IsPlayed = isPlayed, MediaTypes = mediaTypes, - IncludeItemTypes = RequestHelpers.GetItemTypeStrings(includeItemTypes), - ExcludeItemTypes = RequestHelpers.GetItemTypeStrings(excludeItemTypes), + IncludeItemTypes = includeItemTypes, + ExcludeItemTypes = excludeItemTypes, Recursive = recursive ?? false, OrderBy = RequestHelpers.GetOrderBy(sortBy, sortOrder), IsFavorite = isFavorite, @@ -454,7 +456,7 @@ namespace Jellyfin.Api.Controllers { query.AlbumIds = albums.SelectMany(i => { - return _libraryManager.GetItemIds(new InternalItemsQuery { IncludeItemTypes = new[] { nameof(MusicAlbum) }, Name = i, Limit = 1 }); + return _libraryManager.GetItemIds(new InternalItemsQuery { IncludeItemTypes = new[] { BaseItemKind.MusicAlbum }, Name = i, Limit = 1 }); }).ToArray(); } @@ -478,9 +480,9 @@ namespace Jellyfin.Api.Controllers if (query.OrderBy.Count == 0) { // Albums by artist - if (query.ArtistIds.Length > 0 && query.IncludeItemTypes.Length == 1 && string.Equals(query.IncludeItemTypes[0], "MusicAlbum", StringComparison.OrdinalIgnoreCase)) + if (query.ArtistIds.Length > 0 && query.IncludeItemTypes.Length == 1 && query.IncludeItemTypes[0] == BaseItemKind.MusicAlbum) { - query.OrderBy = new[] { new ValueTuple<string, SortOrder>(ItemSortBy.ProductionYear, SortOrder.Descending), new ValueTuple<string, SortOrder>(ItemSortBy.SortName, SortOrder.Ascending) }; + query.OrderBy = new[] { (ItemSortBy.ProductionYear, SortOrder.Descending), (ItemSortBy.SortName, SortOrder.Ascending) }; } } @@ -763,6 +765,7 @@ namespace Jellyfin.Api.Controllers /// <param name="includeItemTypes">Optional. If specified, results will be filtered based on the item type. This allows multiple, comma delimited.</param> /// <param name="enableTotalRecordCount">Optional. Enable the total record count.</param> /// <param name="enableImages">Optional. Include image information in output.</param> + /// <param name="excludeActiveSessions">Optional. Whether to exclude the currently active sessions.</param> /// <response code="200">Items returned.</response> /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the items that are resumable.</returns> [HttpGet("Users/{userId}/Items/Resume")] @@ -781,7 +784,8 @@ namespace Jellyfin.Api.Controllers [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] excludeItemTypes, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] includeItemTypes, [FromQuery] bool enableTotalRecordCount = true, - [FromQuery] bool? enableImages = true) + [FromQuery] bool? enableImages = true, + [FromQuery] bool excludeActiveSessions = false) { var user = _userManager.GetUserById(userId); var parentIdGuid = parentId ?? Guid.Empty; @@ -801,6 +805,15 @@ namespace Jellyfin.Api.Controllers .ToArray(); } + var excludeItemIds = Array.Empty<Guid>(); + if (excludeActiveSessions) + { + excludeItemIds = _sessionManager.Sessions + .Where(s => s.UserId == userId && s.NowPlayingItem != null) + .Select(s => s.NowPlayingItem.Id) + .ToArray(); + } + var itemsResult = _libraryManager.GetItemsResult(new InternalItemsQuery(user) { OrderBy = new[] { (ItemSortBy.DatePlayed, SortOrder.Descending) }, @@ -815,9 +828,10 @@ namespace Jellyfin.Api.Controllers CollapseBoxSetItems = false, EnableTotalRecordCount = enableTotalRecordCount, AncestorIds = ancestorIds, - IncludeItemTypes = RequestHelpers.GetItemTypeStrings(includeItemTypes), - ExcludeItemTypes = RequestHelpers.GetItemTypeStrings(excludeItemTypes), - SearchTerm = searchTerm + IncludeItemTypes = includeItemTypes, + ExcludeItemTypes = excludeItemTypes, + SearchTerm = searchTerm, + ExcludeItemIds = excludeItemIds }); var returnItems = _dtoService.GetBaseItemDtos(itemsResult.Items, dtoOptions, user); diff --git a/Jellyfin.Api/Controllers/LibraryController.cs b/Jellyfin.Api/Controllers/LibraryController.cs index 0be853ca4..f1b9c2f67 100644 --- a/Jellyfin.Api/Controllers/LibraryController.cs +++ b/Jellyfin.Api/Controllers/LibraryController.cs @@ -14,6 +14,8 @@ using Jellyfin.Api.Extensions; using Jellyfin.Api.ModelBinders; using Jellyfin.Api.Models.LibraryDtos; using Jellyfin.Data.Entities; +using Jellyfin.Data.Enums; +using Jellyfin.Extensions; using MediaBrowser.Common.Progress; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Dto; @@ -22,7 +24,6 @@ using MediaBrowser.Controller.Entities.Audio; using MediaBrowser.Controller.Entities.Movies; using MediaBrowser.Controller.Entities.TV; using MediaBrowser.Controller.Library; -using MediaBrowser.Controller.LiveTv; using MediaBrowser.Controller.Net; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Activity; @@ -36,7 +37,6 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; -using Book = MediaBrowser.Controller.Entities.Book; namespace Jellyfin.Api.Controllers { @@ -413,14 +413,14 @@ namespace Jellyfin.Api.Controllers var counts = new ItemCounts { - AlbumCount = GetCount(typeof(MusicAlbum), user, isFavorite), - EpisodeCount = GetCount(typeof(Episode), user, isFavorite), - MovieCount = GetCount(typeof(Movie), user, isFavorite), - SeriesCount = GetCount(typeof(Series), user, isFavorite), - SongCount = GetCount(typeof(Audio), user, isFavorite), - MusicVideoCount = GetCount(typeof(MusicVideo), user, isFavorite), - BoxSetCount = GetCount(typeof(BoxSet), user, isFavorite), - BookCount = GetCount(typeof(Book), user, isFavorite) + AlbumCount = GetCount(BaseItemKind.MusicAlbum, user, isFavorite), + EpisodeCount = GetCount(BaseItemKind.Episode, user, isFavorite), + MovieCount = GetCount(BaseItemKind.Movie, user, isFavorite), + SeriesCount = GetCount(BaseItemKind.Series, user, isFavorite), + SongCount = GetCount(BaseItemKind.Audio, user, isFavorite), + MusicVideoCount = GetCount(BaseItemKind.MusicVideo, user, isFavorite), + BoxSetCount = GetCount(BaseItemKind.BoxSet, user, isFavorite), + BookCount = GetCount(BaseItemKind.Book, user, isFavorite) }; return counts; @@ -529,7 +529,7 @@ namespace Jellyfin.Api.Controllers { var series = _libraryManager.GetItemList(new InternalItemsQuery { - IncludeItemTypes = new[] { nameof(Series) }, + IncludeItemTypes = new[] { BaseItemKind.Series }, DtoOptions = new DtoOptions(false) { EnableImages = false @@ -559,7 +559,7 @@ namespace Jellyfin.Api.Controllers { var movies = _libraryManager.GetItemList(new InternalItemsQuery { - IncludeItemTypes = new[] { nameof(Movie) }, + IncludeItemTypes = new[] { BaseItemKind.Movie }, DtoOptions = new DtoOptions(false) { EnableImages = false @@ -715,30 +715,31 @@ namespace Jellyfin.Api.Controllers bool? isMovie = item is Movie || (program != null && program.IsMovie) || item is Trailer; bool? isSeries = item is Series || (program != null && program.IsSeries); - var includeItemTypes = new List<string>(); + var includeItemTypes = new List<BaseItemKind>(); if (isMovie.Value) { - includeItemTypes.Add(nameof(Movie)); + includeItemTypes.Add(BaseItemKind.Movie); if (_serverConfigurationManager.Configuration.EnableExternalContentInSuggestions) { - includeItemTypes.Add(nameof(Trailer)); - includeItemTypes.Add(nameof(LiveTvProgram)); + includeItemTypes.Add(BaseItemKind.Trailer); + includeItemTypes.Add(BaseItemKind.LiveTvProgram); } } else if (isSeries.Value) { - includeItemTypes.Add(nameof(Series)); + includeItemTypes.Add(BaseItemKind.Series); } else { // For non series and movie types these columns are typically null - isSeries = null; + // isSeries = null; isMovie = null; - includeItemTypes.Add(item.GetType().Name); + includeItemTypes.Add(item.GetBaseItemKind()); } var query = new InternalItemsQuery(user) { + Genres = item.Genres, Limit = limit, IncludeItemTypes = includeItemTypes.ToArray(), SimilarTo = item, @@ -785,7 +786,7 @@ namespace Jellyfin.Api.Controllers var typesList = types.ToList(); var plugins = _providerManager.GetAllMetadataPlugins() - .Where(i => types.Contains(i.ItemType, StringComparer.OrdinalIgnoreCase)) + .Where(i => types.Contains(i.ItemType, StringComparison.OrdinalIgnoreCase)) .OrderBy(i => typesList.IndexOf(i.ItemType)) .ToList(); @@ -871,11 +872,11 @@ namespace Jellyfin.Api.Controllers return result; } - private int GetCount(Type type, User? user, bool? isFavorite) + private int GetCount(BaseItemKind itemKind, User? user, bool? isFavorite) { var query = new InternalItemsQuery(user) { - IncludeItemTypes = new[] { type.Name }, + IncludeItemTypes = new[] { itemKind }, Limit = 0, Recursive = true, IsVirtualItem = false, @@ -940,10 +941,10 @@ namespace Jellyfin.Api.Controllers } var metadataOptions = _serverConfigurationManager.Configuration.MetadataOptions - .Where(i => itemTypes.Contains(i.ItemType ?? string.Empty, StringComparer.OrdinalIgnoreCase)) + .Where(i => itemTypes.Contains(i.ItemType ?? string.Empty, StringComparison.OrdinalIgnoreCase)) .ToArray(); - return metadataOptions.Length == 0 || metadataOptions.Any(i => !i.DisabledMetadataSavers.Contains(name, StringComparer.OrdinalIgnoreCase)); + return metadataOptions.Length == 0 || metadataOptions.Any(i => !i.DisabledMetadataSavers.Contains(name, StringComparison.OrdinalIgnoreCase)); } private bool IsMetadataFetcherEnabledByDefault(string name, string type, bool isNewLibrary) @@ -967,7 +968,7 @@ namespace Jellyfin.Api.Controllers .ToArray(); return metadataOptions.Length == 0 - || metadataOptions.Any(i => !i.DisabledMetadataFetchers.Contains(name, StringComparer.OrdinalIgnoreCase)); + || metadataOptions.Any(i => !i.DisabledMetadataFetchers.Contains(name, StringComparison.OrdinalIgnoreCase)); } private bool IsImageFetcherEnabledByDefault(string name, string type, bool isNewLibrary) @@ -997,7 +998,7 @@ namespace Jellyfin.Api.Controllers return true; } - return metadataOptions.Any(i => !i.DisabledImageFetchers.Contains(name, StringComparer.OrdinalIgnoreCase)); + return metadataOptions.Any(i => !i.DisabledImageFetchers.Contains(name, StringComparison.OrdinalIgnoreCase)); } } } diff --git a/Jellyfin.Api/Controllers/LiveTvController.cs b/Jellyfin.Api/Controllers/LiveTvController.cs index 93dc76729..9e2ef8c60 100644 --- a/Jellyfin.Api/Controllers/LiveTvController.cs +++ b/Jellyfin.Api/Controllers/LiveTvController.cs @@ -278,25 +278,26 @@ namespace Jellyfin.Api.Controllers return _liveTvManager.GetRecordings( new RecordingQuery - { - ChannelId = channelId, - UserId = userId ?? Guid.Empty, - StartIndex = startIndex, - Limit = limit, - Status = status, - SeriesTimerId = seriesTimerId, - IsInProgress = isInProgress, - EnableTotalRecordCount = enableTotalRecordCount, - IsMovie = isMovie, - IsNews = isNews, - IsSeries = isSeries, - IsKids = isKids, - IsSports = isSports, - IsLibraryItem = isLibraryItem, - Fields = fields, - ImageTypeLimit = imageTypeLimit, - EnableImages = enableImages - }, dtoOptions); + { + ChannelId = channelId, + UserId = userId ?? Guid.Empty, + StartIndex = startIndex, + Limit = limit, + Status = status, + SeriesTimerId = seriesTimerId, + IsInProgress = isInProgress, + EnableTotalRecordCount = enableTotalRecordCount, + IsMovie = isMovie, + IsNews = isNews, + IsSeries = isSeries, + IsKids = isKids, + IsSports = isSports, + IsLibraryItem = isLibraryItem, + Fields = fields, + ImageTypeLimit = imageTypeLimit, + EnableImages = enableImages + }, + dtoOptions); } /// <summary> @@ -489,14 +490,14 @@ namespace Jellyfin.Api.Controllers [FromQuery] bool? isScheduled) { return await _liveTvManager.GetTimers( - new TimerQuery - { - ChannelId = channelId, - SeriesTimerId = seriesTimerId, - IsActive = isActive, - IsScheduled = isScheduled - }, CancellationToken.None) - .ConfigureAwait(false); + new TimerQuery + { + ChannelId = channelId, + SeriesTimerId = seriesTimerId, + IsActive = isActive, + IsScheduled = isScheduled + }, + CancellationToken.None).ConfigureAwait(false); } /// <summary> @@ -867,7 +868,8 @@ namespace Jellyfin.Api.Controllers { SortOrder = sortOrder ?? SortOrder.Ascending, SortBy = sortBy - }, CancellationToken.None).ConfigureAwait(false); + }, + CancellationToken.None).ConfigureAwait(false); } /// <summary> @@ -1199,15 +1201,15 @@ namespace Jellyfin.Api.Controllers [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesVideoFile] - public async Task<ActionResult> GetLiveStreamFile([FromRoute, Required] string streamId, [FromRoute, Required] string container) + public ActionResult GetLiveStreamFile([FromRoute, Required] string streamId, [FromRoute, Required] string container) { - var liveStreamInfo = await _mediaSourceManager.GetDirectStreamProviderByUniqueId(streamId, CancellationToken.None).ConfigureAwait(false); + var liveStreamInfo = _mediaSourceManager.GetLiveStreamInfoByUniqueId(streamId); if (liveStreamInfo == null) { return NotFound(); } - var liveStream = new ProgressiveFileStream(liveStreamInfo.GetFilePath(), null, _transcodingJobHelper); + var liveStream = new ProgressiveFileStream(liveStreamInfo.GetStream()); return new FileStreamResult(liveStream, MimeTypes.GetMimeType("file." + container)); } diff --git a/Jellyfin.Api/Controllers/MediaInfoController.cs b/Jellyfin.Api/Controllers/MediaInfoController.cs index 7c78928f7..b422eb78c 100644 --- a/Jellyfin.Api/Controllers/MediaInfoController.cs +++ b/Jellyfin.Api/Controllers/MediaInfoController.cs @@ -184,7 +184,7 @@ namespace Jellyfin.Api.Controllers audioStreamIndex, subtitleStreamIndex, maxAudioChannels, - info!.PlaySessionId!, + info.PlaySessionId!, userId ?? Guid.Empty, enableDirectPlay.Value, enableDirectStream.Value, @@ -316,7 +316,7 @@ namespace Jellyfin.Api.Controllers byte[] buffer = ArrayPool<byte>.Shared.Rent(size); try { - new Random().NextBytes(buffer); + Random.Shared.NextBytes(buffer); return File(buffer, MediaTypeNames.Application.Octet); } finally diff --git a/Jellyfin.Api/Controllers/MoviesController.cs b/Jellyfin.Api/Controllers/MoviesController.cs index 99c90d19e..db72ff2f8 100644 --- a/Jellyfin.Api/Controllers/MoviesController.cs +++ b/Jellyfin.Api/Controllers/MoviesController.cs @@ -11,9 +11,7 @@ using MediaBrowser.Common.Extensions; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; -using MediaBrowser.Controller.Entities.Movies; using MediaBrowser.Controller.Library; -using MediaBrowser.Controller.LiveTv; using MediaBrowser.Model.Dto; using MediaBrowser.Model.Entities; using MediaBrowser.Model.Querying; @@ -84,7 +82,7 @@ namespace Jellyfin.Api.Controllers { IncludeItemTypes = new[] { - nameof(Movie), + BaseItemKind.Movie, // nameof(Trailer), // nameof(LiveTvProgram) }, @@ -99,11 +97,11 @@ namespace Jellyfin.Api.Controllers var recentlyPlayedMovies = _libraryManager.GetItemList(query); - var itemTypes = new List<string> { nameof(Movie) }; + var itemTypes = new List<BaseItemKind> { BaseItemKind.Movie }; if (_serverConfigurationManager.Configuration.EnableExternalContentInSuggestions) { - itemTypes.Add(nameof(Trailer)); - itemTypes.Add(nameof(LiveTvProgram)); + itemTypes.Add(BaseItemKind.Trailer); + itemTypes.Add(BaseItemKind.LiveTvProgram); } var likedMovies = _libraryManager.GetItemList(new InternalItemsQuery(user) @@ -182,11 +180,11 @@ namespace Jellyfin.Api.Controllers DtoOptions dtoOptions, RecommendationType type) { - var itemTypes = new List<string> { nameof(Movie) }; + var itemTypes = new List<BaseItemKind> { BaseItemKind.Movie }; if (_serverConfigurationManager.Configuration.EnableExternalContentInSuggestions) { - itemTypes.Add(nameof(Trailer)); - itemTypes.Add(nameof(LiveTvProgram)); + itemTypes.Add(BaseItemKind.Trailer); + itemTypes.Add(BaseItemKind.LiveTvProgram); } foreach (var name in names) @@ -224,11 +222,11 @@ namespace Jellyfin.Api.Controllers private IEnumerable<RecommendationDto> GetWithActor(User? user, IEnumerable<string> names, int itemLimit, DtoOptions dtoOptions, RecommendationType type) { - var itemTypes = new List<string> { nameof(Movie) }; + var itemTypes = new List<BaseItemKind> { BaseItemKind.Movie }; if (_serverConfigurationManager.Configuration.EnableExternalContentInSuggestions) { - itemTypes.Add(nameof(Trailer)); - itemTypes.Add(nameof(LiveTvProgram)); + itemTypes.Add(BaseItemKind.Trailer); + itemTypes.Add(BaseItemKind.LiveTvProgram); } foreach (var name in names) @@ -264,11 +262,11 @@ namespace Jellyfin.Api.Controllers private IEnumerable<RecommendationDto> GetSimilarTo(User? user, IEnumerable<BaseItem> baselineItems, int itemLimit, DtoOptions dtoOptions, RecommendationType type) { - var itemTypes = new List<string> { nameof(Movie) }; + var itemTypes = new List<BaseItemKind> { BaseItemKind.Movie }; if (_serverConfigurationManager.Configuration.EnableExternalContentInSuggestions) { - itemTypes.Add(nameof(Trailer)); - itemTypes.Add(nameof(LiveTvProgram)); + itemTypes.Add(BaseItemKind.Trailer); + itemTypes.Add(BaseItemKind.LiveTvProgram); } foreach (var item in baselineItems) diff --git a/Jellyfin.Api/Controllers/MusicGenresController.cs b/Jellyfin.Api/Controllers/MusicGenresController.cs index 27eec2b9a..c4c03aa4f 100644 --- a/Jellyfin.Api/Controllers/MusicGenresController.cs +++ b/Jellyfin.Api/Controllers/MusicGenresController.cs @@ -101,8 +101,8 @@ namespace Jellyfin.Api.Controllers var query = new InternalItemsQuery(user) { - ExcludeItemTypes = RequestHelpers.GetItemTypeStrings(excludeItemTypes), - IncludeItemTypes = RequestHelpers.GetItemTypeStrings(includeItemTypes), + ExcludeItemTypes = excludeItemTypes, + IncludeItemTypes = includeItemTypes, StartIndex = startIndex, Limit = limit, IsFavorite = isFavorite, @@ -149,7 +149,7 @@ namespace Jellyfin.Api.Controllers if (genreName.IndexOf(BaseItem.SlugChar, StringComparison.OrdinalIgnoreCase) != -1) { - item = GetItemFromSlugName<MusicGenre>(_libraryManager, genreName, dtoOptions); + item = GetItemFromSlugName<MusicGenre>(_libraryManager, genreName, dtoOptions, BaseItemKind.MusicGenre); } else { @@ -166,27 +166,27 @@ namespace Jellyfin.Api.Controllers return _dtoService.GetBaseItemDto(item, dtoOptions); } - private T? GetItemFromSlugName<T>(ILibraryManager libraryManager, string name, DtoOptions dtoOptions) + private T? GetItemFromSlugName<T>(ILibraryManager libraryManager, string name, DtoOptions dtoOptions, BaseItemKind baseItemKind) where T : BaseItem, new() { var result = libraryManager.GetItemList(new InternalItemsQuery { Name = name.Replace(BaseItem.SlugChar, '&'), - IncludeItemTypes = new[] { typeof(T).Name }, + IncludeItemTypes = new[] { baseItemKind }, DtoOptions = dtoOptions }).OfType<T>().FirstOrDefault(); result ??= libraryManager.GetItemList(new InternalItemsQuery { Name = name.Replace(BaseItem.SlugChar, '/'), - IncludeItemTypes = new[] { typeof(T).Name }, + IncludeItemTypes = new[] { baseItemKind }, DtoOptions = dtoOptions }).OfType<T>().FirstOrDefault(); result ??= libraryManager.GetItemList(new InternalItemsQuery { Name = name.Replace(BaseItem.SlugChar, '?'), - IncludeItemTypes = new[] { typeof(T).Name }, + IncludeItemTypes = new[] { baseItemKind }, DtoOptions = dtoOptions }).OfType<T>().FirstOrDefault(); diff --git a/Jellyfin.Api/Controllers/PersonsController.cs b/Jellyfin.Api/Controllers/PersonsController.cs index b98307f87..cb4894d77 100644 --- a/Jellyfin.Api/Controllers/PersonsController.cs +++ b/Jellyfin.Api/Controllers/PersonsController.cs @@ -26,7 +26,6 @@ namespace Jellyfin.Api.Controllers private readonly ILibraryManager _libraryManager; private readonly IDtoService _dtoService; private readonly IUserManager _userManager; - private readonly IUserDataManager _userDataManager; /// <summary> /// Initializes a new instance of the <see cref="PersonsController"/> class. @@ -34,17 +33,14 @@ namespace Jellyfin.Api.Controllers /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> /// <param name="dtoService">Instance of the <see cref="IDtoService"/> interface.</param> /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> - /// <param name="userDataManager">Instance of the <see cref="IUserDataManager"/> interface.</param> public PersonsController( ILibraryManager libraryManager, IDtoService dtoService, - IUserManager userManager, - IUserDataManager userDataManager) + IUserManager userManager) { _libraryManager = libraryManager; _dtoService = dtoService; _userManager = userManager; - _userDataManager = userDataManager; } /// <summary> diff --git a/Jellyfin.Api/Controllers/PluginsController.cs b/Jellyfin.Api/Controllers/PluginsController.cs index 0ae6109bc..b41df1abb 100644 --- a/Jellyfin.Api/Controllers/PluginsController.cs +++ b/Jellyfin.Api/Controllers/PluginsController.cs @@ -9,7 +9,6 @@ using Jellyfin.Api.Attributes; using Jellyfin.Api.Constants; using Jellyfin.Api.Models.PluginDtos; using Jellyfin.Extensions.Json; -using MediaBrowser.Common.Configuration; using MediaBrowser.Common.Plugins; using MediaBrowser.Common.Updates; using MediaBrowser.Model.Net; @@ -28,7 +27,6 @@ namespace Jellyfin.Api.Controllers { private readonly IInstallationManager _installationManager; private readonly IPluginManager _pluginManager; - private readonly IConfigurationManager _config; private readonly JsonSerializerOptions _serializerOptions; /// <summary> @@ -36,16 +34,13 @@ namespace Jellyfin.Api.Controllers /// </summary> /// <param name="installationManager">Instance of the <see cref="IInstallationManager"/> interface.</param> /// <param name="pluginManager">Instance of the <see cref="IPluginManager"/> interface.</param> - /// <param name="config">Instance of the <see cref="IConfigurationManager"/> interface.</param> public PluginsController( IInstallationManager installationManager, - IPluginManager pluginManager, - IConfigurationManager config) + IPluginManager pluginManager) { _installationManager = installationManager; _pluginManager = pluginManager; _serializerOptions = JsonDefaults.Options; - _config = config; } /// <summary> diff --git a/Jellyfin.Api/Controllers/RemoteImageController.cs b/Jellyfin.Api/Controllers/RemoteImageController.cs index 7a2c23991..dbee56e14 100644 --- a/Jellyfin.Api/Controllers/RemoteImageController.cs +++ b/Jellyfin.Api/Controllers/RemoteImageController.cs @@ -3,17 +3,13 @@ using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.IO; using System.Linq; -using System.Net.Http; using System.Threading; using System.Threading.Tasks; using Jellyfin.Api.Constants; -using MediaBrowser.Common.Extensions; -using MediaBrowser.Common.Net; using MediaBrowser.Controller; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Entities; -using MediaBrowser.Model.IO; using MediaBrowser.Model.Providers; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; @@ -29,7 +25,6 @@ namespace Jellyfin.Api.Controllers { private readonly IProviderManager _providerManager; private readonly IServerApplicationPaths _applicationPaths; - private readonly IHttpClientFactory _httpClientFactory; private readonly ILibraryManager _libraryManager; /// <summary> @@ -37,17 +32,14 @@ namespace Jellyfin.Api.Controllers /// </summary> /// <param name="providerManager">Instance of the <see cref="IProviderManager"/> interface.</param> /// <param name="applicationPaths">Instance of the <see cref="IServerApplicationPaths"/> interface.</param> - /// <param name="httpClientFactory">Instance of the <see cref="IHttpClientFactory"/> interface.</param> /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> public RemoteImageController( IProviderManager providerManager, IServerApplicationPaths applicationPaths, - IHttpClientFactory httpClientFactory, ILibraryManager libraryManager) { _providerManager = providerManager; _applicationPaths = applicationPaths; - _httpClientFactory = httpClientFactory; _libraryManager = libraryManager; } @@ -88,7 +80,8 @@ namespace Jellyfin.Api.Controllers IncludeAllLanguages = includeAllLanguages, IncludeDisabledProviders = true, ImageType = type - }, CancellationToken.None) + }, + CancellationToken.None) .ConfigureAwait(false); var imageArray = images.ToArray(); @@ -182,36 +175,5 @@ namespace Jellyfin.Api.Controllers { return Path.Combine(_applicationPaths.CachePath, "remote-images", filename.Substring(0, 1), filename); } - - /// <summary> - /// Downloads the image. - /// </summary> - /// <param name="url">The URL.</param> - /// <param name="urlHash">The URL hash.</param> - /// <param name="pointerCachePath">The pointer cache path.</param> - /// <returns>Task.</returns> - private async Task DownloadImage(Uri url, Guid urlHash, string pointerCachePath) - { - var httpClient = _httpClientFactory.CreateClient(NamedClient.Default); - using var response = await httpClient.GetAsync(url).ConfigureAwait(false); - if (response.Content.Headers.ContentType?.MediaType == null) - { - throw new ResourceNotFoundException(nameof(response.Content.Headers.ContentType)); - } - - var ext = response.Content.Headers.ContentType.MediaType.Split('/')[^1]; - var fullCachePath = GetFullCachePath(urlHash + "." + ext); - - var fullCacheDirectory = Path.GetDirectoryName(fullCachePath) ?? throw new ResourceNotFoundException($"Provided path ({fullCachePath}) is not valid."); - Directory.CreateDirectory(fullCacheDirectory); - // use FileShare.None as this bypasses dotnet bug dotnet/runtime#42790 . - await using var fileStream = new FileStream(fullCachePath, FileMode.Create, FileAccess.Write, FileShare.None, IODefaults.FileStreamBufferSize, AsyncFile.UseAsyncIO); - await response.Content.CopyToAsync(fileStream).ConfigureAwait(false); - - var pointerCacheDirectory = Path.GetDirectoryName(pointerCachePath) ?? throw new ArgumentException($"Provided path ({pointerCachePath}) is not valid.", nameof(pointerCachePath)); - Directory.CreateDirectory(pointerCacheDirectory); - await System.IO.File.WriteAllTextAsync(pointerCachePath, fullCachePath, CancellationToken.None) - .ConfigureAwait(false); - } } } diff --git a/Jellyfin.Api/Controllers/SearchController.cs b/Jellyfin.Api/Controllers/SearchController.cs index 73bdf9018..26acb4cdc 100644 --- a/Jellyfin.Api/Controllers/SearchController.cs +++ b/Jellyfin.Api/Controllers/SearchController.cs @@ -4,7 +4,6 @@ using System.ComponentModel.DataAnnotations; using System.Globalization; using System.Linq; using Jellyfin.Api.Constants; -using Jellyfin.Api.Helpers; using Jellyfin.Api.ModelBinders; using Jellyfin.Data.Enums; using MediaBrowser.Controller.Drawing; @@ -110,8 +109,8 @@ namespace Jellyfin.Api.Controllers IncludeStudios = includeStudios, StartIndex = startIndex, UserId = userId ?? Guid.Empty, - IncludeItemTypes = RequestHelpers.GetItemTypeStrings(includeItemTypes), - ExcludeItemTypes = RequestHelpers.GetItemTypeStrings(excludeItemTypes), + IncludeItemTypes = includeItemTypes, + ExcludeItemTypes = excludeItemTypes, MediaTypes = mediaTypes, ParentId = parentId, diff --git a/Jellyfin.Api/Controllers/SessionController.cs b/Jellyfin.Api/Controllers/SessionController.cs index 3a04cb3a4..a6bbd40cc 100644 --- a/Jellyfin.Api/Controllers/SessionController.cs +++ b/Jellyfin.Api/Controllers/SessionController.cs @@ -127,7 +127,7 @@ namespace Jellyfin.Api.Controllers [ProducesResponseType(StatusCodes.Status204NoContent)] public async Task<ActionResult> DisplayContent( [FromRoute, Required] string sessionId, - [FromQuery, Required] string itemType, + [FromQuery, Required] BaseItemKind itemType, [FromQuery, Required] string itemId, [FromQuery, Required] string itemName) { diff --git a/Jellyfin.Api/Controllers/StartupController.cs b/Jellyfin.Api/Controllers/StartupController.cs index a01a617fc..c49bde93f 100644 --- a/Jellyfin.Api/Controllers/StartupController.cs +++ b/Jellyfin.Api/Controllers/StartupController.cs @@ -93,7 +93,7 @@ namespace Jellyfin.Api.Controllers NetworkConfiguration settings = _config.GetNetworkConfiguration(); settings.EnableRemoteAccess = startupRemoteAccessDto.EnableRemoteAccess; settings.EnableUPnP = startupRemoteAccessDto.EnableAutomaticPortMapping; - _config.SaveConfiguration("network", settings); + _config.SaveConfiguration(NetworkConfigurationStore.StoreKey, settings); return NoContent(); } diff --git a/Jellyfin.Api/Controllers/StudiosController.cs b/Jellyfin.Api/Controllers/StudiosController.cs index da8f8b199..4422ef32c 100644 --- a/Jellyfin.Api/Controllers/StudiosController.cs +++ b/Jellyfin.Api/Controllers/StudiosController.cs @@ -97,8 +97,8 @@ namespace Jellyfin.Api.Controllers var query = new InternalItemsQuery(user) { - ExcludeItemTypes = RequestHelpers.GetItemTypeStrings(excludeItemTypes), - IncludeItemTypes = RequestHelpers.GetItemTypeStrings(includeItemTypes), + ExcludeItemTypes = excludeItemTypes, + IncludeItemTypes = includeItemTypes, StartIndex = startIndex, Limit = limit, IsFavorite = isFavorite, diff --git a/Jellyfin.Api/Controllers/SubtitleController.cs b/Jellyfin.Api/Controllers/SubtitleController.cs index 11f67ee89..16acedcf3 100644 --- a/Jellyfin.Api/Controllers/SubtitleController.cs +++ b/Jellyfin.Api/Controllers/SubtitleController.cs @@ -127,7 +127,7 @@ namespace Jellyfin.Api.Controllers { var video = (Video)_libraryManager.GetItemById(itemId); - return await _subtitleManager.SearchSubtitles(video, language, isPerfectMatch, CancellationToken.None).ConfigureAwait(false); + return await _subtitleManager.SearchSubtitles(video, language, isPerfectMatch, false, CancellationToken.None).ConfigureAwait(false); } /// <summary> @@ -376,7 +376,7 @@ namespace Jellyfin.Api.Controllers var endPositionTicks = Math.Min(runtime, positionTicks + segmentLengthTicks); var url = string.Format( - CultureInfo.CurrentCulture, + CultureInfo.InvariantCulture, "stream.vtt?CopyTimestamps=true&AddVttTimeMap=true&StartPositionTicks={0}&EndPositionTicks={1}&api_key={2}", positionTicks.ToString(CultureInfo.InvariantCulture), endPositionTicks.ToString(CultureInfo.InvariantCulture), @@ -417,6 +417,8 @@ namespace Jellyfin.Api.Controllers IsForced = body.IsForced, Stream = memoryStream }).ConfigureAwait(false); + _providerManager.QueueRefresh(video.Id, new MetadataRefreshOptions(new DirectoryService(_fileSystem)), RefreshPriority.High); + return NoContent(); } @@ -526,7 +528,7 @@ namespace Jellyfin.Api.Controllers if (fontFile != null && fileSize != null && fileSize > 0) { - _logger.LogDebug("Fallback font size is {fileSize} Bytes", fileSize); + _logger.LogDebug("Fallback font size is {FileSize} Bytes", fileSize); return PhysicalFile(fontFile.FullName, MimeTypes.GetMimeType(fontFile.FullName)); } else diff --git a/Jellyfin.Api/Controllers/SuggestionsController.cs b/Jellyfin.Api/Controllers/SuggestionsController.cs index 97eec4bd2..af77c801f 100644 --- a/Jellyfin.Api/Controllers/SuggestionsController.cs +++ b/Jellyfin.Api/Controllers/SuggestionsController.cs @@ -58,7 +58,7 @@ namespace Jellyfin.Api.Controllers public ActionResult<QueryResult<BaseItemDto>> GetSuggestions( [FromRoute, Required] Guid userId, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] mediaType, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] type, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] type, [FromQuery] int? startIndex, [FromQuery] int? limit, [FromQuery] bool enableTotalRecordCount = false) diff --git a/Jellyfin.Api/Controllers/SystemController.cs b/Jellyfin.Api/Controllers/SystemController.cs index e6584f0fe..411c987f3 100644 --- a/Jellyfin.Api/Controllers/SystemController.cs +++ b/Jellyfin.Api/Controllers/SystemController.cs @@ -3,7 +3,6 @@ using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.IO; using System.Linq; -using System.Net; using System.Net.Mime; using System.Threading.Tasks; using Jellyfin.Api.Attributes; @@ -66,7 +65,7 @@ namespace Jellyfin.Api.Controllers [ProducesResponseType(StatusCodes.Status200OK)] public ActionResult<SystemInfo> GetSystemInfo() { - return _appHost.GetSystemInfo(Request.HttpContext.Connection.RemoteIpAddress ?? IPAddress.Loopback); + return _appHost.GetSystemInfo(Request); } /// <summary> @@ -78,7 +77,7 @@ namespace Jellyfin.Api.Controllers [ProducesResponseType(StatusCodes.Status200OK)] public ActionResult<PublicSystemInfo> GetPublicSystemInfo() { - return _appHost.GetPublicSystemInfo(Request.HttpContext.Connection.RemoteIpAddress ?? IPAddress.Loopback); + return _appHost.GetPublicSystemInfo(Request); } /// <summary> @@ -201,7 +200,7 @@ namespace Jellyfin.Api.Controllers // For older files, assume fully static var fileShare = file.LastWriteTimeUtc < DateTime.UtcNow.AddHours(-1) ? FileShare.Read : FileShare.ReadWrite; - FileStream stream = new FileStream(file.FullName, FileMode.Open, FileAccess.Read, fileShare, IODefaults.FileStreamBufferSize, AsyncFile.UseAsyncIO); + FileStream stream = new FileStream(file.FullName, FileMode.Open, FileAccess.Read, fileShare, IODefaults.FileStreamBufferSize, FileOptions.Asynchronous); return File(stream, "text/plain; charset=utf-8"); } @@ -212,10 +211,13 @@ namespace Jellyfin.Api.Controllers /// <returns>An <see cref="IEnumerable{WakeOnLanInfo}"/> with the WakeOnLan infos.</returns> [HttpGet("WakeOnLanInfo")] [Authorize(Policy = Policies.DefaultAuthorization)] + [Obsolete("This endpoint is obsolete.")] [ProducesResponseType(StatusCodes.Status200OK)] public ActionResult<IEnumerable<WakeOnLanInfo>> GetWakeOnLanInfo() { - var result = _appHost.GetWakeOnLanInfo(); + var result = _network.GetMacAddresses() + .Select(i => new WakeOnLanInfo(i)) + .ToList(); return Ok(result); } } diff --git a/Jellyfin.Api/Controllers/TimeSyncController.cs b/Jellyfin.Api/Controllers/TimeSyncController.cs index 7df51c7af..e7c5a7125 100644 --- a/Jellyfin.Api/Controllers/TimeSyncController.cs +++ b/Jellyfin.Api/Controllers/TimeSyncController.cs @@ -21,10 +21,10 @@ namespace Jellyfin.Api.Controllers public ActionResult<UtcTimeResponse> GetUtcTime() { // Important to keep the following line at the beginning - var requestReceptionTime = DateTime.UtcNow.ToUniversalTime(); + var requestReceptionTime = DateTime.UtcNow; // Important to keep the following line at the end - var responseTransmissionTime = DateTime.UtcNow.ToUniversalTime(); + var responseTransmissionTime = DateTime.UtcNow; // Implementing NTP on such a high level results in this useless // information being sent. On the other hand it enables future additions. diff --git a/Jellyfin.Api/Controllers/TvShowsController.cs b/Jellyfin.Api/Controllers/TvShowsController.cs index 7c5b8a43b..e20bcd7a7 100644 --- a/Jellyfin.Api/Controllers/TvShowsController.cs +++ b/Jellyfin.Api/Controllers/TvShowsController.cs @@ -61,7 +61,7 @@ namespace Jellyfin.Api.Controllers /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param> /// <param name="seriesId">Optional. Filter by series id.</param> /// <param name="parentId">Optional. Specify this to localize the search to a specific item or folder. Omit to use the root.</param> - /// <param name="enableImges">Optional. Include image information in output.</param> + /// <param name="enableImages">Optional. Include image information in output.</param> /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param> /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> /// <param name="enableUserData">Optional. Include user data.</param> @@ -78,7 +78,7 @@ namespace Jellyfin.Api.Controllers [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, [FromQuery] string? seriesId, [FromQuery] Guid? parentId, - [FromQuery] bool? enableImges, + [FromQuery] bool? enableImages, [FromQuery] int? imageTypeLimit, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes, [FromQuery] bool? enableUserData, @@ -88,7 +88,7 @@ namespace Jellyfin.Api.Controllers { var options = new DtoOptions { Fields = fields } .AddClientFields(Request) - .AddAdditionalDtoOptions(enableImges, enableUserData, imageTypeLimit, enableImageTypes!); + .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); var result = _tvSeriesManager.GetNextUp( new NextUpQuery @@ -125,7 +125,7 @@ namespace Jellyfin.Api.Controllers /// <param name="limit">Optional. The maximum number of records to return.</param> /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param> /// <param name="parentId">Optional. Specify this to localize the search to a specific item or folder. Omit to use the root.</param> - /// <param name="enableImges">Optional. Include image information in output.</param> + /// <param name="enableImages">Optional. Include image information in output.</param> /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param> /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> /// <param name="enableUserData">Optional. Include user data.</param> @@ -138,7 +138,7 @@ namespace Jellyfin.Api.Controllers [FromQuery] int? limit, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, [FromQuery] Guid? parentId, - [FromQuery] bool? enableImges, + [FromQuery] bool? enableImages, [FromQuery] int? imageTypeLimit, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes, [FromQuery] bool? enableUserData) @@ -147,17 +147,17 @@ namespace Jellyfin.Api.Controllers ? _userManager.GetUserById(userId.Value) : null; - var minPremiereDate = DateTime.Now.Date.ToUniversalTime().AddDays(-1); + var minPremiereDate = DateTime.UtcNow.Date.AddDays(-1); var parentIdGuid = parentId ?? Guid.Empty; var options = new DtoOptions { Fields = fields } .AddClientFields(Request) - .AddAdditionalDtoOptions(enableImges, enableUserData, imageTypeLimit, enableImageTypes!); + .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); var itemsResult = _libraryManager.GetItemList(new InternalItemsQuery(user) { - IncludeItemTypes = new[] { nameof(Episode) }, + IncludeItemTypes = new[] { BaseItemKind.Episode }, OrderBy = new[] { (ItemSortBy.PremiereDate, SortOrder.Ascending), (ItemSortBy.SortName, SortOrder.Ascending) }, MinPremiereDate = minPremiereDate, StartIndex = startIndex, @@ -223,7 +223,7 @@ namespace Jellyfin.Api.Controllers var dtoOptions = new DtoOptions { Fields = fields } .AddClientFields(Request) - .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes!); + .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); if (seasonId.HasValue) // Season id was supplied. Get episodes by season id. { @@ -350,7 +350,7 @@ namespace Jellyfin.Api.Controllers var dtoOptions = new DtoOptions { Fields = fields } .AddClientFields(Request) - .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes!); + .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); var returnItems = _dtoService.GetBaseItemDtos(seasons, dtoOptions, user); diff --git a/Jellyfin.Api/Controllers/UniversalAudioController.cs b/Jellyfin.Api/Controllers/UniversalAudioController.cs index 20a02bf4a..bc9527a0b 100644 --- a/Jellyfin.Api/Controllers/UniversalAudioController.cs +++ b/Jellyfin.Api/Controllers/UniversalAudioController.cs @@ -155,7 +155,7 @@ namespace Jellyfin.Api.Controllers null, null, maxAudioChannels, - info!.PlaySessionId!, + info.PlaySessionId!, userId ?? Guid.Empty, true, true, diff --git a/Jellyfin.Api/Controllers/UserLibraryController.cs b/Jellyfin.Api/Controllers/UserLibraryController.cs index a33a0826c..8b99170d9 100644 --- a/Jellyfin.Api/Controllers/UserLibraryController.cs +++ b/Jellyfin.Api/Controllers/UserLibraryController.cs @@ -6,7 +6,6 @@ using System.Threading; using System.Threading.Tasks; using Jellyfin.Api.Constants; using Jellyfin.Api.Extensions; -using Jellyfin.Api.Helpers; using Jellyfin.Api.ModelBinders; using Jellyfin.Data.Enums; using Jellyfin.Extensions; @@ -213,7 +212,7 @@ namespace Jellyfin.Api.Controllers if (item is IHasTrailers hasTrailers) { - var trailers = hasTrailers.GetTrailers(); + var trailers = hasTrailers.LocalTrailers; var dtosTrailers = _dtoService.GetBaseItemDtos(trailers, dtoOptions, user, item); var allTrailers = new BaseItemDto[dtosExtras.Length + dtosTrailers.Count]; dtosExtras.CopyTo(allTrailers, 0); @@ -297,12 +296,13 @@ namespace Jellyfin.Api.Controllers new LatestItemsQuery { GroupItems = groupItems, - IncludeItemTypes = RequestHelpers.GetItemTypeStrings(includeItemTypes), + IncludeItemTypes = includeItemTypes, IsPlayed = isPlayed, Limit = limit, ParentId = parentId ?? Guid.Empty, UserId = userId, - }, dtoOptions); + }, + dtoOptions); var dtos = list.Select(i => { diff --git a/Jellyfin.Api/Controllers/VideoHlsController.cs b/Jellyfin.Api/Controllers/VideoHlsController.cs deleted file mode 100644 index 9c5e968dd..000000000 --- a/Jellyfin.Api/Controllers/VideoHlsController.cs +++ /dev/null @@ -1,583 +0,0 @@ -using System; -using System.Collections.Generic; -using System.ComponentModel.DataAnnotations; -using System.Globalization; -using System.IO; -using System.Threading; -using System.Threading.Tasks; -using Jellyfin.Api.Attributes; -using Jellyfin.Api.Constants; -using Jellyfin.Api.Helpers; -using Jellyfin.Api.Models.PlaybackDtos; -using Jellyfin.Api.Models.StreamingDtos; -using MediaBrowser.Common.Configuration; -using MediaBrowser.Controller.Configuration; -using MediaBrowser.Controller.Devices; -using MediaBrowser.Controller.Dlna; -using MediaBrowser.Controller.Library; -using MediaBrowser.Controller.MediaEncoding; -using MediaBrowser.Controller.Net; -using MediaBrowser.Model.Configuration; -using MediaBrowser.Model.Dlna; -using MediaBrowser.Model.Net; -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Logging; - -namespace Jellyfin.Api.Controllers -{ - /// <summary> - /// The video hls controller. - /// </summary> - [Route("")] - [Authorize(Policy = Policies.DefaultAuthorization)] - public class VideoHlsController : BaseJellyfinApiController - { - private const string DefaultEncoderPreset = "superfast"; - private const TranscodingJobType TranscodingJobType = MediaBrowser.Controller.MediaEncoding.TranscodingJobType.Hls; - - private readonly EncodingHelper _encodingHelper; - private readonly IDlnaManager _dlnaManager; - private readonly IAuthorizationContext _authContext; - private readonly IUserManager _userManager; - private readonly ILibraryManager _libraryManager; - private readonly IMediaSourceManager _mediaSourceManager; - private readonly IServerConfigurationManager _serverConfigurationManager; - private readonly IMediaEncoder _mediaEncoder; - private readonly IDeviceManager _deviceManager; - private readonly TranscodingJobHelper _transcodingJobHelper; - private readonly ILogger<VideoHlsController> _logger; - private readonly EncodingOptions _encodingOptions; - - /// <summary> - /// Initializes a new instance of the <see cref="VideoHlsController"/> class. - /// </summary> - /// <param name="mediaEncoder">Instance of the <see cref="IMediaEncoder"/> interface.</param> - /// <param name="dlnaManager">Instance of the <see cref="IDlnaManager"/> interface.</param> - /// <param name="userManger">Instance of the <see cref="IUserManager"/> interface.</param> - /// <param name="authorizationContext">Instance of the <see cref="IAuthorizationContext"/> interface.</param> - /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> - /// <param name="mediaSourceManager">Instance of the <see cref="IMediaSourceManager"/> interface.</param> - /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param> - /// <param name="deviceManager">Instance of the <see cref="IDeviceManager"/> interface.</param> - /// <param name="transcodingJobHelper">The <see cref="TranscodingJobHelper"/> singleton.</param> - /// <param name="logger">Instance of the <see cref="ILogger{VideoHlsController}"/>.</param> - /// <param name="encodingHelper">Instance of <see cref="EncodingHelper"/>.</param> - public VideoHlsController( - IMediaEncoder mediaEncoder, - IDlnaManager dlnaManager, - IUserManager userManger, - IAuthorizationContext authorizationContext, - ILibraryManager libraryManager, - IMediaSourceManager mediaSourceManager, - IServerConfigurationManager serverConfigurationManager, - IDeviceManager deviceManager, - TranscodingJobHelper transcodingJobHelper, - ILogger<VideoHlsController> logger, - EncodingHelper encodingHelper) - { - _dlnaManager = dlnaManager; - _authContext = authorizationContext; - _userManager = userManger; - _libraryManager = libraryManager; - _mediaSourceManager = mediaSourceManager; - _serverConfigurationManager = serverConfigurationManager; - _mediaEncoder = mediaEncoder; - _deviceManager = deviceManager; - _transcodingJobHelper = transcodingJobHelper; - _logger = logger; - _encodingHelper = encodingHelper; - - _encodingOptions = serverConfigurationManager.GetEncodingOptions(); - } - - /// <summary> - /// Gets a hls live stream. - /// </summary> - /// <param name="itemId">The item id.</param> - /// <param name="container">The audio container.</param> - /// <param name="static">Optional. If true, the original file will be streamed statically without any encoding. Use either no url extension or the original file extension. true/false.</param> - /// <param name="params">The streaming parameters.</param> - /// <param name="tag">The tag.</param> - /// <param name="deviceProfileId">Optional. The dlna device profile id to utilize.</param> - /// <param name="playSessionId">The play session id.</param> - /// <param name="segmentContainer">The segment container.</param> - /// <param name="segmentLength">The segment lenght.</param> - /// <param name="minSegments">The minimum number of segments.</param> - /// <param name="mediaSourceId">The media version id, if playing an alternate version.</param> - /// <param name="deviceId">The device id of the client requesting. Used to stop encoding processes when needed.</param> - /// <param name="audioCodec">Optional. Specify a audio codec to encode to, e.g. mp3. If omitted the server will auto-select using the url's extension. Options: aac, mp3, vorbis, wma.</param> - /// <param name="enableAutoStreamCopy">Whether or not to allow automatic stream copy if requested values match the original source. Defaults to true.</param> - /// <param name="allowVideoStreamCopy">Whether or not to allow copying of the video stream url.</param> - /// <param name="allowAudioStreamCopy">Whether or not to allow copying of the audio stream url.</param> - /// <param name="breakOnNonKeyFrames">Optional. Whether to break on non key frames.</param> - /// <param name="audioSampleRate">Optional. Specify a specific audio sample rate, e.g. 44100.</param> - /// <param name="maxAudioBitDepth">Optional. The maximum audio bit depth.</param> - /// <param name="audioBitRate">Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults.</param> - /// <param name="audioChannels">Optional. Specify a specific number of audio channels to encode to, e.g. 2.</param> - /// <param name="maxAudioChannels">Optional. Specify a maximum number of audio channels to encode to, e.g. 2.</param> - /// <param name="profile">Optional. Specify a specific an encoder profile (varies by encoder), e.g. main, baseline, high.</param> - /// <param name="level">Optional. Specify a level for the encoder profile (varies by encoder), e.g. 3, 3.1.</param> - /// <param name="framerate">Optional. A specific video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param> - /// <param name="maxFramerate">Optional. A specific maximum video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param> - /// <param name="copyTimestamps">Whether or not to copy timestamps when transcoding with an offset. Defaults to false.</param> - /// <param name="startTimeTicks">Optional. Specify a starting offset, in ticks. 1 tick = 10000 ms.</param> - /// <param name="width">Optional. The fixed horizontal resolution of the encoded video.</param> - /// <param name="height">Optional. The fixed vertical resolution of the encoded video.</param> - /// <param name="videoBitRate">Optional. Specify a video bitrate to encode to, e.g. 500000. If omitted this will be left to encoder defaults.</param> - /// <param name="subtitleStreamIndex">Optional. The index of the subtitle stream to use. If omitted no subtitles will be used.</param> - /// <param name="subtitleMethod">Optional. Specify the subtitle delivery method.</param> - /// <param name="maxRefFrames">Optional.</param> - /// <param name="maxVideoBitDepth">Optional. The maximum video bit depth.</param> - /// <param name="requireAvc">Optional. Whether to require avc.</param> - /// <param name="deInterlace">Optional. Whether to deinterlace the video.</param> - /// <param name="requireNonAnamorphic">Optional. Whether to require a non anamorphic stream.</param> - /// <param name="transcodingMaxAudioChannels">Optional. The maximum number of audio channels to transcode.</param> - /// <param name="cpuCoreLimit">Optional. The limit of how many cpu cores to use.</param> - /// <param name="liveStreamId">The live stream id.</param> - /// <param name="enableMpegtsM2TsMode">Optional. Whether to enable the MpegtsM2Ts mode.</param> - /// <param name="videoCodec">Optional. Specify a video codec to encode to, e.g. h264. If omitted the server will auto-select using the url's extension. Options: h265, h264, mpeg4, theora, vp8, vp9, vpx (deprecated), wmv.</param> - /// <param name="subtitleCodec">Optional. Specify a subtitle codec to encode to.</param> - /// <param name="transcodeReasons">Optional. The transcoding reason.</param> - /// <param name="audioStreamIndex">Optional. The index of the audio stream to use. If omitted the first audio stream will be used.</param> - /// <param name="videoStreamIndex">Optional. The index of the video stream to use. If omitted the first video stream will be used.</param> - /// <param name="context">Optional. The <see cref="EncodingContext"/>.</param> - /// <param name="streamOptions">Optional. The streaming options.</param> - /// <param name="maxWidth">Optional. The max width.</param> - /// <param name="maxHeight">Optional. The max height.</param> - /// <param name="enableSubtitlesInManifest">Optional. Whether to enable subtitles in the manifest.</param> - /// <response code="200">Hls live stream retrieved.</response> - /// <returns>A <see cref="FileResult"/> containing the hls file.</returns> - [HttpGet("Videos/{itemId}/live.m3u8")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesPlaylistFile] - public async Task<ActionResult> GetLiveHlsStream( - [FromRoute, Required] Guid itemId, - [FromQuery] string? container, - [FromQuery] bool? @static, - [FromQuery] string? @params, - [FromQuery] string? tag, - [FromQuery] string? deviceProfileId, - [FromQuery] string? playSessionId, - [FromQuery] string? segmentContainer, - [FromQuery] int? segmentLength, - [FromQuery] int? minSegments, - [FromQuery] string? mediaSourceId, - [FromQuery] string? deviceId, - [FromQuery] string? audioCodec, - [FromQuery] bool? enableAutoStreamCopy, - [FromQuery] bool? allowVideoStreamCopy, - [FromQuery] bool? allowAudioStreamCopy, - [FromQuery] bool? breakOnNonKeyFrames, - [FromQuery] int? audioSampleRate, - [FromQuery] int? maxAudioBitDepth, - [FromQuery] int? audioBitRate, - [FromQuery] int? audioChannels, - [FromQuery] int? maxAudioChannels, - [FromQuery] string? profile, - [FromQuery] string? level, - [FromQuery] float? framerate, - [FromQuery] float? maxFramerate, - [FromQuery] bool? copyTimestamps, - [FromQuery] long? startTimeTicks, - [FromQuery] int? width, - [FromQuery] int? height, - [FromQuery] int? videoBitRate, - [FromQuery] int? subtitleStreamIndex, - [FromQuery] SubtitleDeliveryMethod? subtitleMethod, - [FromQuery] int? maxRefFrames, - [FromQuery] int? maxVideoBitDepth, - [FromQuery] bool? requireAvc, - [FromQuery] bool? deInterlace, - [FromQuery] bool? requireNonAnamorphic, - [FromQuery] int? transcodingMaxAudioChannels, - [FromQuery] int? cpuCoreLimit, - [FromQuery] string? liveStreamId, - [FromQuery] bool? enableMpegtsM2TsMode, - [FromQuery] string? videoCodec, - [FromQuery] string? subtitleCodec, - [FromQuery] string? transcodeReasons, - [FromQuery] int? audioStreamIndex, - [FromQuery] int? videoStreamIndex, - [FromQuery] EncodingContext? context, - [FromQuery] Dictionary<string, string> streamOptions, - [FromQuery] int? maxWidth, - [FromQuery] int? maxHeight, - [FromQuery] bool? enableSubtitlesInManifest) - { - VideoRequestDto streamingRequest = new VideoRequestDto - { - Id = itemId, - Container = container, - Static = @static ?? false, - Params = @params, - Tag = tag, - DeviceProfileId = deviceProfileId, - PlaySessionId = playSessionId, - SegmentContainer = segmentContainer, - SegmentLength = segmentLength, - MinSegments = minSegments, - MediaSourceId = mediaSourceId, - DeviceId = deviceId, - AudioCodec = audioCodec, - EnableAutoStreamCopy = enableAutoStreamCopy ?? true, - AllowAudioStreamCopy = allowAudioStreamCopy ?? true, - AllowVideoStreamCopy = allowVideoStreamCopy ?? true, - BreakOnNonKeyFrames = breakOnNonKeyFrames ?? false, - AudioSampleRate = audioSampleRate, - MaxAudioChannels = maxAudioChannels, - AudioBitRate = audioBitRate, - MaxAudioBitDepth = maxAudioBitDepth, - AudioChannels = audioChannels, - Profile = profile, - Level = level, - Framerate = framerate, - MaxFramerate = maxFramerate, - CopyTimestamps = copyTimestamps ?? false, - StartTimeTicks = startTimeTicks, - Width = width, - Height = height, - VideoBitRate = videoBitRate, - SubtitleStreamIndex = subtitleStreamIndex, - SubtitleMethod = subtitleMethod ?? SubtitleDeliveryMethod.Encode, - MaxRefFrames = maxRefFrames, - MaxVideoBitDepth = maxVideoBitDepth, - RequireAvc = requireAvc ?? false, - DeInterlace = deInterlace ?? false, - RequireNonAnamorphic = requireNonAnamorphic ?? false, - TranscodingMaxAudioChannels = transcodingMaxAudioChannels, - CpuCoreLimit = cpuCoreLimit, - LiveStreamId = liveStreamId, - EnableMpegtsM2TsMode = enableMpegtsM2TsMode ?? false, - VideoCodec = videoCodec, - SubtitleCodec = subtitleCodec, - TranscodeReasons = transcodeReasons, - AudioStreamIndex = audioStreamIndex, - VideoStreamIndex = videoStreamIndex, - Context = context ?? EncodingContext.Streaming, - StreamOptions = streamOptions, - MaxHeight = maxHeight, - MaxWidth = maxWidth, - EnableSubtitlesInManifest = enableSubtitlesInManifest ?? true - }; - - // CTS lifecycle is managed internally. - var cancellationTokenSource = new CancellationTokenSource(); - using var state = await StreamingHelpers.GetStreamingState( - streamingRequest, - Request, - _authContext, - _mediaSourceManager, - _userManager, - _libraryManager, - _serverConfigurationManager, - _mediaEncoder, - _encodingHelper, - _dlnaManager, - _deviceManager, - _transcodingJobHelper, - TranscodingJobType, - cancellationTokenSource.Token) - .ConfigureAwait(false); - - TranscodingJobDto? job = null; - var playlistPath = Path.ChangeExtension(state.OutputFilePath, ".m3u8"); - - if (!System.IO.File.Exists(playlistPath)) - { - var transcodingLock = _transcodingJobHelper.GetTranscodingLock(playlistPath); - await transcodingLock.WaitAsync(cancellationTokenSource.Token).ConfigureAwait(false); - try - { - if (!System.IO.File.Exists(playlistPath)) - { - // If the playlist doesn't already exist, startup ffmpeg - try - { - job = await _transcodingJobHelper.StartFfMpeg( - state, - playlistPath, - GetCommandLineArguments(playlistPath, state), - Request, - TranscodingJobType, - cancellationTokenSource) - .ConfigureAwait(false); - job.IsLiveOutput = true; - } - catch - { - state.Dispose(); - throw; - } - - minSegments = state.MinSegments; - if (minSegments > 0) - { - await HlsHelpers.WaitForMinimumSegmentCount(playlistPath, minSegments, _logger, cancellationTokenSource.Token).ConfigureAwait(false); - } - } - } - finally - { - transcodingLock.Release(); - } - } - - job ??= _transcodingJobHelper.OnTranscodeBeginRequest(playlistPath, TranscodingJobType); - - if (job != null) - { - _transcodingJobHelper.OnTranscodeEndRequest(job); - } - - var playlistText = HlsHelpers.GetLivePlaylistText(playlistPath, state); - - return Content(playlistText, MimeTypes.GetMimeType("playlist.m3u8")); - } - - /// <summary> - /// Gets the command line arguments for ffmpeg. - /// </summary> - /// <param name="outputPath">The output path of the file.</param> - /// <param name="state">The <see cref="StreamState"/>.</param> - /// <returns>The command line arguments as a string.</returns> - private string GetCommandLineArguments(string outputPath, StreamState state) - { - var videoCodec = _encodingHelper.GetVideoEncoder(state, _encodingOptions); - var threads = EncodingHelper.GetNumberOfThreads(state, _encodingOptions, videoCodec); // GetNumberOfThreads is static. - var inputModifier = _encodingHelper.GetInputModifier(state, _encodingOptions); - var mapArgs = state.IsOutputVideo ? _encodingHelper.GetMapArgs(state) : string.Empty; - - var directory = Path.GetDirectoryName(outputPath) ?? throw new ArgumentException($"Provided path ({outputPath}) is not valid.", nameof(outputPath)); - var outputFileNameWithoutExtension = Path.GetFileNameWithoutExtension(outputPath); - var outputPrefix = Path.Combine(directory, outputFileNameWithoutExtension); - var outputExtension = EncodingHelper.GetSegmentFileExtension(state.Request.SegmentContainer); - var outputTsArg = outputPrefix + "%d" + outputExtension; - - var segmentFormat = outputExtension.TrimStart('.'); - if (string.Equals(segmentFormat, "ts", StringComparison.OrdinalIgnoreCase)) - { - segmentFormat = "mpegts"; - } - else if (string.Equals(segmentFormat, "mp4", StringComparison.OrdinalIgnoreCase)) - { - var outputFmp4HeaderArg = string.Empty; - if (OperatingSystem.IsWindows()) - { - // on Windows, the path of fmp4 header file needs to be configured - outputFmp4HeaderArg = " -hls_fmp4_init_filename \"" + outputPrefix + "-1" + outputExtension + "\""; - } - else - { - // on Linux/Unix, ffmpeg generate fmp4 header file to m3u8 output folder - outputFmp4HeaderArg = " -hls_fmp4_init_filename \"" + outputFileNameWithoutExtension + "-1" + outputExtension + "\""; - } - - segmentFormat = "fmp4" + outputFmp4HeaderArg; - } - else - { - _logger.LogError("Invalid HLS segment container: {SegmentFormat}", segmentFormat); - } - - var maxMuxingQueueSize = _encodingOptions.MaxMuxingQueueSize > 128 - ? _encodingOptions.MaxMuxingQueueSize.ToString(CultureInfo.InvariantCulture) - : "128"; - - var baseUrlParam = string.Format( - CultureInfo.InvariantCulture, - "\"hls/{0}/\"", - Path.GetFileNameWithoutExtension(outputPath)); - - return string.Format( - CultureInfo.InvariantCulture, - "{0} {1} -map_metadata -1 -map_chapters -1 -threads {2} {3} {4} {5} -copyts -avoid_negative_ts disabled -max_muxing_queue_size {6} -f hls -max_delay 5000000 -hls_time {7} -hls_segment_type {8} -start_number 0 -hls_base_url {9} -hls_playlist_type event -hls_segment_filename \"{10}\" -y \"{11}\"", - inputModifier, - _encodingHelper.GetInputArgument(state, _encodingOptions), - threads, - mapArgs, - GetVideoArguments(state), - GetAudioArguments(state), - maxMuxingQueueSize, - state.SegmentLength.ToString(CultureInfo.InvariantCulture), - segmentFormat, - baseUrlParam, - outputTsArg, - outputPath).Trim(); - } - - /// <summary> - /// Gets the audio arguments for transcoding. - /// </summary> - /// <param name="state">The <see cref="StreamState"/>.</param> - /// <returns>The command line arguments for audio transcoding.</returns> - private string GetAudioArguments(StreamState state) - { - if (state.AudioStream == null) - { - return string.Empty; - } - - var audioCodec = _encodingHelper.GetAudioEncoder(state); - - if (!state.IsOutputVideo) - { - if (EncodingHelper.IsCopyCodec(audioCodec)) - { - var bitStreamArgs = EncodingHelper.GetAudioBitStreamArguments(state, state.Request.SegmentContainer, state.MediaSource.Container); - - return "-acodec copy -strict -2" + bitStreamArgs; - } - - var audioTranscodeParams = string.Empty; - - audioTranscodeParams += "-acodec " + audioCodec; - - if (state.OutputAudioBitrate.HasValue) - { - audioTranscodeParams += " -ab " + state.OutputAudioBitrate.Value.ToString(CultureInfo.InvariantCulture); - } - - if (state.OutputAudioChannels.HasValue) - { - audioTranscodeParams += " -ac " + state.OutputAudioChannels.Value.ToString(CultureInfo.InvariantCulture); - } - - if (state.OutputAudioSampleRate.HasValue) - { - audioTranscodeParams += " -ar " + state.OutputAudioSampleRate.Value.ToString(CultureInfo.InvariantCulture); - } - - audioTranscodeParams += " -vn"; - return audioTranscodeParams; - } - - if (EncodingHelper.IsCopyCodec(audioCodec)) - { - var bitStreamArgs = EncodingHelper.GetAudioBitStreamArguments(state, state.Request.SegmentContainer, state.MediaSource.Container); - - return "-acodec copy -strict -2" + bitStreamArgs; - } - - var args = "-codec:a:0 " + audioCodec; - - var channels = state.OutputAudioChannels; - - if (channels.HasValue) - { - args += " -ac " + channels.Value; - } - - var bitrate = state.OutputAudioBitrate; - - if (bitrate.HasValue) - { - args += " -ab " + bitrate.Value.ToString(CultureInfo.InvariantCulture); - } - - if (state.OutputAudioSampleRate.HasValue) - { - args += " -ar " + state.OutputAudioSampleRate.Value.ToString(CultureInfo.InvariantCulture); - } - - args += _encodingHelper.GetAudioFilterParam(state, _encodingOptions); - - return args; - } - - /// <summary> - /// Gets the video arguments for transcoding. - /// </summary> - /// <param name="state">The <see cref="StreamState"/>.</param> - /// <returns>The command line arguments for video transcoding.</returns> - private string GetVideoArguments(StreamState state) - { - if (state.VideoStream == null) - { - return string.Empty; - } - - if (!state.IsOutputVideo) - { - return string.Empty; - } - - var codec = _encodingHelper.GetVideoEncoder(state, _encodingOptions); - - var args = "-codec:v:0 " + codec; - - // Prefer hvc1 to hev1. - if (string.Equals(state.ActualOutputVideoCodec, "h265", StringComparison.OrdinalIgnoreCase) - || string.Equals(state.ActualOutputVideoCodec, "hevc", StringComparison.OrdinalIgnoreCase) - || string.Equals(codec, "h265", StringComparison.OrdinalIgnoreCase) - || string.Equals(codec, "hevc", StringComparison.OrdinalIgnoreCase)) - { - args += " -tag:v:0 hvc1"; - } - - // if (state.EnableMpegtsM2TsMode) - // { - // args += " -mpegts_m2ts_mode 1"; - // } - - // See if we can save come cpu cycles by avoiding encoding. - if (EncodingHelper.IsCopyCodec(codec)) - { - // If h264_mp4toannexb is ever added, do not use it for live tv. - if (state.VideoStream != null && !string.Equals(state.VideoStream.NalLengthSize, "0", StringComparison.OrdinalIgnoreCase)) - { - string bitStreamArgs = EncodingHelper.GetBitStreamArgs(state.VideoStream); - if (!string.IsNullOrEmpty(bitStreamArgs)) - { - args += " " + bitStreamArgs; - } - } - - args += " -start_at_zero"; - } - else - { - args += _encodingHelper.GetVideoQualityParam(state, codec, _encodingOptions, DefaultEncoderPreset); - - // Set the key frame params for video encoding to match the hls segment time. - args += _encodingHelper.GetHlsVideoKeyFrameArguments(state, codec, state.SegmentLength, true, null); - - // Currenly b-frames in libx265 breaks the FMP4-HLS playback on iOS, disable it for now. - if (string.Equals(codec, "libx265", StringComparison.OrdinalIgnoreCase)) - { - args += " -bf 0"; - } - - var hasGraphicalSubs = state.SubtitleStream != null && !state.SubtitleStream.IsTextSubtitleStream && state.SubtitleDeliveryMethod == SubtitleDeliveryMethod.Encode; - - if (hasGraphicalSubs) - { - // Graphical subs overlay and resolution params. - args += _encodingHelper.GetGraphicalSubtitleParam(state, _encodingOptions, codec); - } - else - { - // Resolution params. - args += _encodingHelper.GetOutputSizeParam(state, _encodingOptions, codec); - } - - if (state.SubtitleStream == null || !state.SubtitleStream.IsExternal || state.SubtitleStream.IsTextSubtitleStream) - { - args += " -start_at_zero"; - } - } - - args += " -flags -global_header"; - - if (!string.IsNullOrEmpty(state.OutputVideoSync)) - { - args += " -vsync " + state.OutputVideoSync; - } - - args += _encodingHelper.GetOutputFFlags(state); - - return args; - } - } -} diff --git a/Jellyfin.Api/Controllers/VideosController.cs b/Jellyfin.Api/Controllers/VideosController.cs index bc6fc904a..3c079a71d 100644 --- a/Jellyfin.Api/Controllers/VideosController.cs +++ b/Jellyfin.Api/Controllers/VideosController.cs @@ -451,22 +451,23 @@ namespace Jellyfin.Api.Controllers if (@static.HasValue && @static.Value && state.DirectStreamProvider != null) { - StreamingHelpers.AddDlnaHeaders(state, Response.Headers, true, startTimeTicks, Request, _dlnaManager); + StreamingHelpers.AddDlnaHeaders(state, Response.Headers, true, state.Request.StartTimeTicks, Request, _dlnaManager); - await new ProgressiveFileCopier(state.DirectStreamProvider, null, _transcodingJobHelper, CancellationToken.None) + var liveStreamInfo = _mediaSourceManager.GetLiveStreamInfo(streamingRequest.LiveStreamId); + if (liveStreamInfo == null) { - AllowEndOfFile = false - }.WriteToAsync(Response.Body, CancellationToken.None) - .ConfigureAwait(false); + return NotFound(); + } + var liveStream = new ProgressiveFileStream(liveStreamInfo.GetStream()); // TODO (moved from MediaBrowser.Api): Don't hardcode contentType - return File(Response.Body, MimeTypes.GetMimeType("file.ts")!); + return File(liveStream, MimeTypes.GetMimeType("file.ts")); } // Static remote stream if (@static.HasValue && @static.Value && state.InputProtocol == MediaProtocol.Http) { - StreamingHelpers.AddDlnaHeaders(state, Response.Headers, true, startTimeTicks, Request, _dlnaManager); + StreamingHelpers.AddDlnaHeaders(state, Response.Headers, true, state.Request.StartTimeTicks, Request, _dlnaManager); var httpClient = _httpClientFactory.CreateClient(NamedClient.Default); return await FileStreamResponseHelpers.GetStaticRemoteStreamResult(state, isHeadRequest, httpClient, HttpContext).ConfigureAwait(false); @@ -483,7 +484,7 @@ namespace Jellyfin.Api.Controllers var transcodingJob = _transcodingJobHelper.GetTranscodingJob(outputPath, TranscodingJobType.Progressive); var isTranscodeCached = outputPathExists && transcodingJob != null; - StreamingHelpers.AddDlnaHeaders(state, Response.Headers, (@static.HasValue && @static.Value) || isTranscodeCached, startTimeTicks, Request, _dlnaManager); + StreamingHelpers.AddDlnaHeaders(state, Response.Headers, (@static.HasValue && @static.Value) || isTranscodeCached, state.Request.StartTimeTicks, Request, _dlnaManager); // Static stream if (@static.HasValue && @static.Value) @@ -492,13 +493,8 @@ namespace Jellyfin.Api.Controllers if (state.MediaSource.IsInfiniteStream) { - await new ProgressiveFileCopier(state.MediaPath, null, _transcodingJobHelper, CancellationToken.None) - { - AllowEndOfFile = false - }.WriteToAsync(Response.Body, CancellationToken.None) - .ConfigureAwait(false); - - return File(Response.Body, contentType); + var liveStream = new ProgressiveFileStream(state.MediaPath, null, _transcodingJobHelper); + return File(liveStream, contentType); } return FileStreamResponseHelpers.GetStaticFileResult( diff --git a/Jellyfin.Api/Controllers/YearsController.cs b/Jellyfin.Api/Controllers/YearsController.cs index d6dc6650c..8be6fd1b5 100644 --- a/Jellyfin.Api/Controllers/YearsController.cs +++ b/Jellyfin.Api/Controllers/YearsController.cs @@ -8,6 +8,7 @@ using Jellyfin.Api.Helpers; using Jellyfin.Api.ModelBinders; using Jellyfin.Data.Entities; using Jellyfin.Data.Enums; +using Jellyfin.Extensions; using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; @@ -101,8 +102,8 @@ namespace Jellyfin.Api.Controllers var query = new InternalItemsQuery(user) { - ExcludeItemTypes = RequestHelpers.GetItemTypeStrings(excludeItemTypes), - IncludeItemTypes = RequestHelpers.GetItemTypeStrings(includeItemTypes), + ExcludeItemTypes = excludeItemTypes, + IncludeItemTypes = includeItemTypes, MediaTypes = mediaTypes, DtoOptions = dtoOptions }; @@ -209,7 +210,7 @@ namespace Jellyfin.Api.Controllers } // Include MediaTypes - if (mediaTypes.Count > 0 && !mediaTypes.Contains(f.MediaType ?? string.Empty, StringComparer.OrdinalIgnoreCase)) + if (mediaTypes.Count > 0 && !mediaTypes.Contains(f.MediaType ?? string.Empty, StringComparison.OrdinalIgnoreCase)) { return false; } diff --git a/Jellyfin.Api/Helpers/AudioHelper.cs b/Jellyfin.Api/Helpers/AudioHelper.cs index ddcde1cf6..bec961dad 100644 --- a/Jellyfin.Api/Helpers/AudioHelper.cs +++ b/Jellyfin.Api/Helpers/AudioHelper.cs @@ -1,4 +1,5 @@ -using System.Net.Http; +using System.IO; +using System.Net.Http; using System.Threading; using System.Threading.Tasks; using Jellyfin.Api.Models.StreamingDtos; @@ -120,14 +121,15 @@ namespace Jellyfin.Api.Helpers { StreamingHelpers.AddDlnaHeaders(state, _httpContextAccessor.HttpContext.Response.Headers, true, streamingRequest.StartTimeTicks, _httpContextAccessor.HttpContext.Request, _dlnaManager); - await new ProgressiveFileCopier(state.DirectStreamProvider, null, _transcodingJobHelper, CancellationToken.None) - { - AllowEndOfFile = false - }.WriteToAsync(_httpContextAccessor.HttpContext.Response.Body, CancellationToken.None) - .ConfigureAwait(false); + var liveStreamInfo = _mediaSourceManager.GetLiveStreamInfo(streamingRequest.LiveStreamId); + if (liveStreamInfo == null) + { + throw new FileNotFoundException(); + } + var liveStream = new ProgressiveFileStream(liveStreamInfo.GetStream()); // TODO (moved from MediaBrowser.Api): Don't hardcode contentType - return new FileStreamResult(_httpContextAccessor.HttpContext.Response.Body, MimeTypes.GetMimeType("file.ts")!); + return new FileStreamResult(liveStream, MimeTypes.GetMimeType("file.ts")); } // Static remote stream @@ -145,7 +147,7 @@ namespace Jellyfin.Api.Helpers } var outputPath = state.OutputFilePath; - var outputPathExists = System.IO.File.Exists(outputPath); + var outputPathExists = File.Exists(outputPath); var transcodingJob = _transcodingJobHelper.GetTranscodingJob(outputPath, TranscodingJobType.Progressive); var isTranscodeCached = outputPathExists && transcodingJob != null; @@ -159,13 +161,8 @@ namespace Jellyfin.Api.Helpers if (state.MediaSource.IsInfiniteStream) { - await new ProgressiveFileCopier(state.MediaPath, null, _transcodingJobHelper, CancellationToken.None) - { - AllowEndOfFile = false - }.WriteToAsync(_httpContextAccessor.HttpContext.Response.Body, CancellationToken.None) - .ConfigureAwait(false); - - return new FileStreamResult(_httpContextAccessor.HttpContext.Response.Body, contentType); + var stream = new ProgressiveFileStream(state.MediaPath, null, _transcodingJobHelper); + return new FileStreamResult(stream, contentType); } return FileStreamResponseHelpers.GetStaticFileResult( diff --git a/Jellyfin.Api/Helpers/ClaimHelpers.cs b/Jellyfin.Api/Helpers/ClaimHelpers.cs index 29e6b4193..c1c2f93b4 100644 --- a/Jellyfin.Api/Helpers/ClaimHelpers.cs +++ b/Jellyfin.Api/Helpers/ClaimHelpers.cs @@ -20,7 +20,7 @@ namespace Jellyfin.Api.Helpers var value = GetClaimValue(user, InternalClaimTypes.UserId); return string.IsNullOrEmpty(value) ? null - : (Guid?)Guid.Parse(value); + : Guid.Parse(value); } /// <summary> diff --git a/Jellyfin.Api/Helpers/ClassMigrationHelper.cs b/Jellyfin.Api/Helpers/ClassMigrationHelper.cs deleted file mode 100644 index a911a3324..000000000 --- a/Jellyfin.Api/Helpers/ClassMigrationHelper.cs +++ /dev/null @@ -1,71 +0,0 @@ -using System; -using System.Reflection; - -namespace Jellyfin.Api.Helpers -{ - /// <summary> - /// A static class for copying matching properties from one object to another. - /// TODO: remove at the point when a fixed migration path has been decided upon. - /// </summary> - public static class ClassMigrationHelper - { - /// <summary> - /// Extension for 'Object' that copies the properties to a destination object. - /// </summary> - /// <param name="source">The source.</param> - /// <param name="destination">The destination.</param> - public static void CopyProperties(this object source, object destination) - { - // If any this null throw an exception. - if (source == null || destination == null) - { - throw new Exception("Source or/and Destination Objects are null"); - } - - // Getting the Types of the objects. - Type typeDest = destination.GetType(); - Type typeSrc = source.GetType(); - - // Iterate the Properties of the source instance and populate them from their destination counterparts. - PropertyInfo[] srcProps = typeSrc.GetProperties(); - foreach (PropertyInfo srcProp in srcProps) - { - if (!srcProp.CanRead) - { - continue; - } - - var targetProperty = typeDest.GetProperty(srcProp.Name); - if (targetProperty == null) - { - continue; - } - - if (!targetProperty.CanWrite) - { - continue; - } - - var obj = targetProperty.GetSetMethod(true); - if (obj != null && obj.IsPrivate) - { - continue; - } - - var target = targetProperty.GetSetMethod(); - if (target != null && (target.Attributes & MethodAttributes.Static) != 0) - { - continue; - } - - if (!targetProperty.PropertyType.IsAssignableFrom(srcProp.PropertyType)) - { - continue; - } - - // Passed all tests, lets set the value. - targetProperty.SetValue(destination, srcProp.GetValue(source, null), null); - } - } - } -} diff --git a/Jellyfin.Api/Helpers/DynamicHlsHelper.cs b/Jellyfin.Api/Helpers/DynamicHlsHelper.cs index 4abe4c5d5..02af2e435 100644 --- a/Jellyfin.Api/Helpers/DynamicHlsHelper.cs +++ b/Jellyfin.Api/Helpers/DynamicHlsHelper.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Globalization; using System.Linq; @@ -462,6 +462,11 @@ namespace Jellyfin.Api.Helpers private void AddSubtitles(StreamState state, IEnumerable<MediaStream> subtitles, StringBuilder builder, ClaimsPrincipal user) { + if (state.SubtitleDeliveryMethod == SubtitleDeliveryMethod.Drop) + { + return; + } + var selectedIndex = state.SubtitleStream == null || state.SubtitleDeliveryMethod != SubtitleDeliveryMethod.Hls ? (int?)null : state.SubtitleStream.Index; const string Format = "#EXT-X-MEDIA:TYPE=SUBTITLES,GROUP-ID=\"subs\",NAME=\"{0}\",DEFAULT={1},FORCED={2},AUTOSELECT=YES,URI=\"{3}\",LANGUAGE=\"{4}\""; diff --git a/Jellyfin.Api/Helpers/FileStreamResponseHelpers.cs b/Jellyfin.Api/Helpers/FileStreamResponseHelpers.cs index b0fd59e5e..6385b62c9 100644 --- a/Jellyfin.Api/Helpers/FileStreamResponseHelpers.cs +++ b/Jellyfin.Api/Helpers/FileStreamResponseHelpers.cs @@ -1,6 +1,7 @@ using System; using System.IO; using System.Net.Http; +using System.Net.Mime; using System.Threading; using System.Threading.Tasks; using Jellyfin.Api.Models.PlaybackDtos; @@ -40,7 +41,7 @@ namespace Jellyfin.Api.Helpers // Can't dispose the response as it's required up the call chain. var response = await httpClient.GetAsync(new Uri(state.MediaPath), cancellationToken).ConfigureAwait(false); - var contentType = response.Content.Headers.ContentType?.ToString(); + var contentType = response.Content.Headers.ContentType?.ToString() ?? MediaTypeNames.Text.Plain; httpContext.Response.Headers[HeaderNames.AcceptRanges] = "none"; diff --git a/Jellyfin.Api/Helpers/HlsHelpers.cs b/Jellyfin.Api/Helpers/HlsHelpers.cs index f36769dc2..456762147 100644 --- a/Jellyfin.Api/Helpers/HlsHelpers.cs +++ b/Jellyfin.Api/Helpers/HlsHelpers.cs @@ -38,7 +38,7 @@ namespace Jellyfin.Api.Helpers FileAccess.Read, FileShare.ReadWrite, IODefaults.FileStreamBufferSize, - (AsyncFile.UseAsyncIO ? FileOptions.Asynchronous : FileOptions.None) | FileOptions.SequentialScan); + FileOptions.Asynchronous | FileOptions.SequentialScan); await using (fileStream.ConfigureAwait(false)) { using var reader = new StreamReader(fileStream); diff --git a/Jellyfin.Api/Helpers/ProgressiveFileCopier.cs b/Jellyfin.Api/Helpers/ProgressiveFileCopier.cs deleted file mode 100644 index 81970b041..000000000 --- a/Jellyfin.Api/Helpers/ProgressiveFileCopier.cs +++ /dev/null @@ -1,187 +0,0 @@ -using System; -using System.Buffers; -using System.IO; -using System.Threading; -using System.Threading.Tasks; -using Jellyfin.Api.Models.PlaybackDtos; -using MediaBrowser.Common.Extensions; -using MediaBrowser.Controller.Library; -using MediaBrowser.Model.IO; - -namespace Jellyfin.Api.Helpers -{ - /// <summary> - /// Progressive file copier. - /// </summary> - public class ProgressiveFileCopier - { - private readonly TranscodingJobDto? _job; - private readonly string? _path; - private readonly CancellationToken _cancellationToken; - private readonly IDirectStreamProvider? _directStreamProvider; - private readonly TranscodingJobHelper _transcodingJobHelper; - private long _bytesWritten; - - /// <summary> - /// Initializes a new instance of the <see cref="ProgressiveFileCopier"/> class. - /// </summary> - /// <param name="path">The path to copy from.</param> - /// <param name="job">The transcoding job.</param> - /// <param name="transcodingJobHelper">Instance of the <see cref="TranscodingJobHelper"/>.</param> - /// <param name="cancellationToken">The cancellation token.</param> - public ProgressiveFileCopier(string path, TranscodingJobDto? job, TranscodingJobHelper transcodingJobHelper, CancellationToken cancellationToken) - { - _path = path; - _job = job; - _cancellationToken = cancellationToken; - _transcodingJobHelper = transcodingJobHelper; - } - - /// <summary> - /// Initializes a new instance of the <see cref="ProgressiveFileCopier"/> class. - /// </summary> - /// <param name="directStreamProvider">Instance of the <see cref="IDirectStreamProvider"/> interface.</param> - /// <param name="job">The transcoding job.</param> - /// <param name="transcodingJobHelper">Instance of the <see cref="TranscodingJobHelper"/>.</param> - /// <param name="cancellationToken">The cancellation token.</param> - public ProgressiveFileCopier(IDirectStreamProvider directStreamProvider, TranscodingJobDto? job, TranscodingJobHelper transcodingJobHelper, CancellationToken cancellationToken) - { - _directStreamProvider = directStreamProvider; - _job = job; - _cancellationToken = cancellationToken; - _transcodingJobHelper = transcodingJobHelper; - } - - /// <summary> - /// Gets or sets a value indicating whether allow read end of file. - /// </summary> - public bool AllowEndOfFile { get; set; } = true; - - /// <summary> - /// Gets or sets copy start position. - /// </summary> - public long StartPosition { get; set; } - - /// <summary> - /// Write source stream to output. - /// </summary> - /// <param name="outputStream">Output stream.</param> - /// <param name="cancellationToken">Cancellation token.</param> - /// <returns>A <see cref="Task"/>.</returns> - public async Task WriteToAsync(Stream outputStream, CancellationToken cancellationToken) - { - using var linkedCancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, _cancellationToken); - cancellationToken = linkedCancellationTokenSource.Token; - - try - { - if (_directStreamProvider != null) - { - await _directStreamProvider.CopyToAsync(outputStream, cancellationToken).ConfigureAwait(false); - return; - } - - var fileOptions = FileOptions.SequentialScan; - var allowAsyncFileRead = false; - - if (AsyncFile.UseAsyncIO) - { - fileOptions |= FileOptions.Asynchronous; - allowAsyncFileRead = true; - } - - if (_path == null) - { - throw new ResourceNotFoundException(nameof(_path)); - } - - await using var inputStream = new FileStream(_path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite, IODefaults.FileStreamBufferSize, fileOptions); - - var eofCount = 0; - const int EmptyReadLimit = 20; - if (StartPosition > 0) - { - inputStream.Position = StartPosition; - } - - while (eofCount < EmptyReadLimit || !AllowEndOfFile) - { - var bytesRead = await CopyToInternalAsync(inputStream, outputStream, allowAsyncFileRead, cancellationToken).ConfigureAwait(false); - - if (bytesRead == 0) - { - if (_job == null || _job.HasExited) - { - eofCount++; - } - - await Task.Delay(100, cancellationToken).ConfigureAwait(false); - } - else - { - eofCount = 0; - } - } - } - finally - { - if (_job != null) - { - _transcodingJobHelper.OnTranscodeEndRequest(_job); - } - } - } - - private async Task<int> CopyToInternalAsync(Stream source, Stream destination, bool readAsync, CancellationToken cancellationToken) - { - var array = ArrayPool<byte>.Shared.Rent(IODefaults.CopyToBufferSize); - try - { - int bytesRead; - int totalBytesRead = 0; - - if (readAsync) - { - bytesRead = await source.ReadAsync(array, 0, array.Length, cancellationToken).ConfigureAwait(false); - } - else - { - bytesRead = source.Read(array, 0, array.Length); - } - - while (bytesRead != 0) - { - var bytesToWrite = bytesRead; - - if (bytesToWrite > 0) - { - await destination.WriteAsync(array, 0, Convert.ToInt32(bytesToWrite), cancellationToken).ConfigureAwait(false); - - _bytesWritten += bytesRead; - totalBytesRead += bytesRead; - - if (_job != null) - { - _job.BytesDownloaded = Math.Max(_job.BytesDownloaded ?? _bytesWritten, _bytesWritten); - } - } - - if (readAsync) - { - bytesRead = await source.ReadAsync(array, 0, array.Length, cancellationToken).ConfigureAwait(false); - } - else - { - bytesRead = source.Read(array, 0, array.Length); - } - } - - return totalBytesRead; - } - finally - { - ArrayPool<byte>.Shared.Return(array); - } - } - } -} diff --git a/Jellyfin.Api/Helpers/ProgressiveFileStream.cs b/Jellyfin.Api/Helpers/ProgressiveFileStream.cs index d4cc0172d..3fa07720a 100644 --- a/Jellyfin.Api/Helpers/ProgressiveFileStream.cs +++ b/Jellyfin.Api/Helpers/ProgressiveFileStream.cs @@ -13,12 +13,10 @@ namespace Jellyfin.Api.Helpers /// </summary> public class ProgressiveFileStream : Stream { - private readonly FileStream _fileStream; + private readonly Stream _stream; private readonly TranscodingJobDto? _job; - private readonly TranscodingJobHelper _transcodingJobHelper; + private readonly TranscodingJobHelper? _transcodingJobHelper; private readonly int _timeoutMs; - private readonly bool _allowAsyncFileRead; - private int _bytesWritten; private bool _disposed; /// <summary> @@ -33,23 +31,25 @@ namespace Jellyfin.Api.Helpers _job = job; _transcodingJobHelper = transcodingJobHelper; _timeoutMs = timeoutMs; - _bytesWritten = 0; - var fileOptions = FileOptions.SequentialScan; - _allowAsyncFileRead = false; + _stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite, IODefaults.FileStreamBufferSize, FileOptions.Asynchronous | FileOptions.SequentialScan); + } - // use non-async filestream along with read due to https://github.com/dotnet/corefx/issues/6039 - if (AsyncFile.UseAsyncIO) - { - fileOptions |= FileOptions.Asynchronous; - _allowAsyncFileRead = true; - } - - _fileStream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite, IODefaults.FileStreamBufferSize, fileOptions); + /// <summary> + /// Initializes a new instance of the <see cref="ProgressiveFileStream"/> class. + /// </summary> + /// <param name="stream">The stream to progressively copy.</param> + /// <param name="timeoutMs">The timeout duration in milliseconds.</param> + public ProgressiveFileStream(Stream stream, int timeoutMs = 30000) + { + _job = null; + _transcodingJobHelper = null; + _timeoutMs = timeoutMs; + _stream = stream; } /// <inheritdoc /> - public override bool CanRead => _fileStream.CanRead; + public override bool CanRead => _stream.CanRead; /// <inheritdoc /> public override bool CanSeek => false; @@ -70,61 +70,58 @@ namespace Jellyfin.Api.Helpers /// <inheritdoc /> public override void Flush() { - _fileStream.Flush(); + // Not supported } /// <inheritdoc /> public override int Read(byte[] buffer, int offset, int count) + => Read(buffer.AsSpan(offset, count)); + + /// <inheritdoc /> + public override int Read(Span<byte> buffer) { - return _fileStream.Read(buffer, offset, count); + int totalBytesRead = 0; + var stopwatch = Stopwatch.StartNew(); + + while (KeepReading(stopwatch.ElapsedMilliseconds)) + { + totalBytesRead += _stream.Read(buffer); + if (totalBytesRead > 0) + { + break; + } + + Thread.Sleep(50); + } + + UpdateBytesWritten(totalBytesRead); + + return totalBytesRead; } /// <inheritdoc /> public override async Task<int> ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + => await ReadAsync(buffer.AsMemory(offset, count), cancellationToken).ConfigureAwait(false); + + /// <inheritdoc /> + public override async ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken cancellationToken = default) { int totalBytesRead = 0; - int remainingBytesToRead = count; var stopwatch = Stopwatch.StartNew(); - int newOffset = offset; - while (remainingBytesToRead > 0) + while (KeepReading(stopwatch.ElapsedMilliseconds)) { - cancellationToken.ThrowIfCancellationRequested(); - int bytesRead; - if (_allowAsyncFileRead) + totalBytesRead += await _stream.ReadAsync(buffer, cancellationToken).ConfigureAwait(false); + if (totalBytesRead > 0) { - bytesRead = await _fileStream.ReadAsync(buffer, newOffset, remainingBytesToRead, cancellationToken).ConfigureAwait(false); - } - else - { - bytesRead = _fileStream.Read(buffer, newOffset, remainingBytesToRead); + break; } - remainingBytesToRead -= bytesRead; - newOffset += bytesRead; - - if (bytesRead > 0) - { - _bytesWritten += bytesRead; - totalBytesRead += bytesRead; - - if (_job != null) - { - _job.BytesDownloaded = Math.Max(_job.BytesDownloaded ?? _bytesWritten, _bytesWritten); - } - } - else - { - // If the job is null it's a live stream and will require user action to close, but don't keep it open indefinitely - if (_job?.HasExited ?? stopwatch.ElapsedMilliseconds > _timeoutMs) - { - break; - } - - await Task.Delay(50, cancellationToken).ConfigureAwait(false); - } + await Task.Delay(50, cancellationToken).ConfigureAwait(false); } + UpdateBytesWritten(totalBytesRead); + return totalBytesRead; } @@ -152,11 +149,11 @@ namespace Jellyfin.Api.Helpers { if (disposing) { - _fileStream.Dispose(); + _stream.Dispose(); if (_job != null) { - _transcodingJobHelper.OnTranscodeEndRequest(_job); + _transcodingJobHelper?.OnTranscodeEndRequest(_job); } } } @@ -166,5 +163,19 @@ namespace Jellyfin.Api.Helpers base.Dispose(disposing); } } + + private void UpdateBytesWritten(int totalBytesRead) + { + if (_job != null) + { + _job.BytesDownloaded += totalBytesRead; + } + } + + private bool KeepReading(long elapsed) + { + // If the job is null it's a live stream and will require user action to close, but don't keep it open indefinitely + return !_job?.HasExited ?? elapsed < _timeoutMs; + } } } diff --git a/Jellyfin.Api/Helpers/RequestHelpers.cs b/Jellyfin.Api/Helpers/RequestHelpers.cs index 0efd3443b..2cfd36d00 100644 --- a/Jellyfin.Api/Helpers/RequestHelpers.cs +++ b/Jellyfin.Api/Helpers/RequestHelpers.cs @@ -30,7 +30,7 @@ namespace Jellyfin.Api.Helpers { if (sortBy.Count == 0) { - return Array.Empty<ValueTuple<string, SortOrder>>(); + return Array.Empty<(string, SortOrder)>(); } var result = new (string, SortOrder)[sortBy.Count]; @@ -104,7 +104,7 @@ namespace Jellyfin.Api.Helpers } internal static QueryResult<BaseItemDto> CreateQueryResult( - QueryResult<(BaseItem, ItemCounts)> result, + QueryResult<(BaseItem Item, ItemCounts ItemCounts)> result, DtoOptions dtoOptions, IDtoService dtoService, bool includeItemTypes, @@ -137,21 +137,5 @@ namespace Jellyfin.Api.Helpers TotalRecordCount = result.TotalRecordCount }; } - - internal static string[] GetItemTypeStrings(IReadOnlyList<BaseItemKind> itemKinds) - { - if (itemKinds.Count == 0) - { - return Array.Empty<string>(); - } - - var itemTypes = new string[itemKinds.Count]; - for (var i = 0; i < itemKinds.Count; i++) - { - itemTypes[i] = itemKinds[i].ToString(); - } - - return itemTypes; - } } } diff --git a/Jellyfin.Api/Helpers/StreamingHelpers.cs b/Jellyfin.Api/Helpers/StreamingHelpers.cs index 0041251e3..ed071bcd7 100644 --- a/Jellyfin.Api/Helpers/StreamingHelpers.cs +++ b/Jellyfin.Api/Helpers/StreamingHelpers.cs @@ -6,6 +6,7 @@ using System.Linq; using System.Threading; using System.Threading.Tasks; using Jellyfin.Api.Models.StreamingDtos; +using Jellyfin.Extensions; using MediaBrowser.Common.Configuration; using MediaBrowser.Common.Extensions; using MediaBrowser.Controller.Configuration; @@ -81,7 +82,7 @@ namespace Jellyfin.Api.Helpers throw new ResourceNotFoundException(nameof(httpRequest.Path)); } - var url = httpRequest.Path.Value.Split('.')[^1]; + var url = httpRequest.Path.Value.AsSpan().RightPart('.').ToString(); if (string.IsNullOrEmpty(streamingRequest.AudioCodec)) { @@ -89,6 +90,7 @@ namespace Jellyfin.Api.Helpers } var enableDlnaHeaders = !string.IsNullOrWhiteSpace(streamingRequest.Params) || + streamingRequest.StreamOptions.ContainsKey("dlnaheaders") || string.Equals(httpRequest.Headers["GetContentFeatures.DLNA.ORG"], "1", StringComparison.OrdinalIgnoreCase); var state = new StreamState(mediaSourceManager, transcodingJobType, transcodingJobHelper) @@ -147,7 +149,7 @@ namespace Jellyfin.Api.Helpers mediaSource = string.IsNullOrEmpty(streamingRequest.MediaSourceId) ? mediaSources[0] - : mediaSources.Find(i => string.Equals(i.Id, streamingRequest.MediaSourceId, StringComparison.InvariantCulture)); + : mediaSources.Find(i => string.Equals(i.Id, streamingRequest.MediaSourceId, StringComparison.Ordinal)); if (mediaSource == null && Guid.Parse(streamingRequest.MediaSourceId) == streamingRequest.Id) { diff --git a/Jellyfin.Api/Helpers/TranscodingJobHelper.cs b/Jellyfin.Api/Helpers/TranscodingJobHelper.cs index 4e1e98df0..3526d56c6 100644 --- a/Jellyfin.Api/Helpers/TranscodingJobHelper.cs +++ b/Jellyfin.Api/Helpers/TranscodingJobHelper.cs @@ -11,6 +11,7 @@ using System.Threading.Tasks; using Jellyfin.Api.Models.PlaybackDtos; using Jellyfin.Api.Models.StreamingDtos; using Jellyfin.Data.Enums; +using MediaBrowser.Common; using MediaBrowser.Common.Configuration; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Library; @@ -86,8 +87,8 @@ namespace Jellyfin.Api.Helpers DeleteEncodedMediaCache(); - sessionManager!.PlaybackProgress += OnPlaybackProgress; - sessionManager!.PlaybackStart += OnPlaybackProgress; + sessionManager.PlaybackProgress += OnPlaybackProgress; + sessionManager.PlaybackStart += OnPlaybackProgress; } /// <summary> @@ -217,7 +218,8 @@ namespace Jellyfin.Api.Helpers return KillTranscodingJobs( j => string.IsNullOrWhiteSpace(playSessionId) ? string.Equals(deviceId, j.DeviceId, StringComparison.OrdinalIgnoreCase) - : string.Equals(playSessionId, j.PlaySessionId, StringComparison.OrdinalIgnoreCase), deleteFiles); + : string.Equals(playSessionId, j.PlaySessionId, StringComparison.OrdinalIgnoreCase), + deleteFiles); } /// <summary> @@ -282,6 +284,7 @@ namespace Jellyfin.Api.Helpers lock (job.ProcessLock!) { + #pragma warning disable CA1849 // Can't await in lock block job.TranscodingThrottler?.Stop().GetAwaiter().GetResult(); var process = job.Process; @@ -307,6 +310,7 @@ namespace Jellyfin.Api.Helpers { } } + #pragma warning restore CA1849 } if (delete(job.Path!)) @@ -540,8 +544,7 @@ namespace Jellyfin.Api.Helpers state, cancellationTokenSource); - var commandLineLogMessage = process.StartInfo.FileName + " " + process.StartInfo.Arguments; - _logger.LogInformation(commandLineLogMessage); + _logger.LogInformation("{Filename} {Arguments}", process.StartInfo.FileName, process.StartInfo.Arguments); var logFilePrefix = "FFmpeg.Transcode-"; if (state.VideoRequest != null @@ -557,10 +560,11 @@ namespace Jellyfin.Api.Helpers $"{logFilePrefix}{DateTime.Now:yyyy-MM-dd_HH-mm-ss}_{state.Request.MediaSourceId}_{Guid.NewGuid().ToString()[..8]}.log"); // FFmpeg writes debug/error info to stderr. This is useful when debugging so let's put it in the log directory. - Stream logStream = new FileStream(logFilePath, FileMode.Create, FileAccess.Write, FileShare.Read, IODefaults.FileStreamBufferSize, AsyncFile.UseAsyncIO); + Stream logStream = new FileStream(logFilePath, FileMode.Create, FileAccess.Write, FileShare.Read, IODefaults.FileStreamBufferSize, FileOptions.Asynchronous); + var commandLineLogMessage = process.StartInfo.FileName + " " + process.StartInfo.Arguments; var commandLineLogMessageBytes = Encoding.UTF8.GetBytes(request.Path + Environment.NewLine + Environment.NewLine + JsonSerializer.Serialize(state.MediaSource) + Environment.NewLine + Environment.NewLine + commandLineLogMessage + Environment.NewLine + Environment.NewLine); - await logStream.WriteAsync(commandLineLogMessageBytes, 0, commandLineLogMessageBytes.Length, cancellationTokenSource.Token).ConfigureAwait(false); + await logStream.WriteAsync(commandLineLogMessageBytes, cancellationTokenSource.Token).ConfigureAwait(false); process.Exited += (sender, args) => OnFfMpegProcessExited(process, transcodingJob, state); @@ -607,6 +611,10 @@ namespace Jellyfin.Api.Helpers { StartThrottler(state, transcodingJob); } + else if (transcodingJob.ExitCode != 0) + { + throw new FfmpegException(string.Format(CultureInfo.InvariantCulture, "FFmpeg exited with code {0}", transcodingJob.ExitCode)); + } _logger.LogDebug("StartFfMpeg() finished successfully"); @@ -743,6 +751,7 @@ namespace Jellyfin.Api.Helpers private void OnFfMpegProcessExited(Process process, TranscodingJobDto job, StreamState state) { job.HasExited = true; + job.ExitCode = process.ExitCode; _logger.LogDebug("Disposing stream resources"); state.Dispose(); @@ -878,8 +887,8 @@ namespace Jellyfin.Api.Helpers if (disposing) { _loggerFactory.Dispose(); - _sessionManager!.PlaybackProgress -= OnPlaybackProgress; - _sessionManager!.PlaybackStart -= OnPlaybackProgress; + _sessionManager.PlaybackProgress -= OnPlaybackProgress; + _sessionManager.PlaybackStart -= OnPlaybackProgress; } } } diff --git a/Jellyfin.Api/Jellyfin.Api.csproj b/Jellyfin.Api/Jellyfin.Api.csproj index 2fca88f24..b5af07408 100644 --- a/Jellyfin.Api/Jellyfin.Api.csproj +++ b/Jellyfin.Api/Jellyfin.Api.csproj @@ -6,18 +6,21 @@ </PropertyGroup> <PropertyGroup> - <TargetFramework>net5.0</TargetFramework> + <TargetFramework>net6.0</TargetFramework> <GenerateDocumentationFile>true</GenerateDocumentationFile> <!-- https://github.com/microsoft/ApplicationInsights-dotnet/issues/2047 --> <NoWarn>AD0001</NoWarn> - <AnalysisMode>AllDisabledByDefault</AnalysisMode> + </PropertyGroup> + + <PropertyGroup Condition=" '$(Configuration)' == 'Debug' "> + <TreatWarningsAsErrors>false</TreatWarningsAsErrors> </PropertyGroup> <ItemGroup> - <PackageReference Include="Microsoft.AspNetCore.Authorization" Version="5.0.9" /> - <PackageReference Include="Microsoft.Extensions.Http" Version="5.0.0" /> - <PackageReference Include="Swashbuckle.AspNetCore" Version="6.2.1" /> - <PackageReference Include="Swashbuckle.AspNetCore.ReDoc" Version="6.2.1" /> + <PackageReference Include="Microsoft.AspNetCore.Authorization" Version="6.0.1" /> + <PackageReference Include="Microsoft.Extensions.Http" Version="6.0.0" /> + <PackageReference Include="Swashbuckle.AspNetCore" Version="6.2.3" /> + <PackageReference Include="Swashbuckle.AspNetCore.ReDoc" Version="6.2.3" /> </ItemGroup> <ItemGroup> @@ -28,7 +31,7 @@ <!-- Code Analyzers--> <ItemGroup Condition=" '$(Configuration)' == 'Debug' "> <PackageReference Include="SerilogAnalyzer" Version="0.15.0" PrivateAssets="All" /> - <PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="All" /> + <PackageReference Include="StyleCop.Analyzers" Version="1.2.0-beta.376" PrivateAssets="All" /> <PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" PrivateAssets="All" /> </ItemGroup> diff --git a/Jellyfin.Api/ModelBinders/NullableEnumModelBinder.cs b/Jellyfin.Api/ModelBinders/NullableEnumModelBinder.cs index be2045fba..d2e78ac88 100644 --- a/Jellyfin.Api/ModelBinders/NullableEnumModelBinder.cs +++ b/Jellyfin.Api/ModelBinders/NullableEnumModelBinder.cs @@ -32,7 +32,8 @@ namespace Jellyfin.Api.ModelBinders { try { - var convertedValue = converter.ConvertFromString(valueProviderResult.FirstValue); + // REVIEW: This shouldn't be null here + var convertedValue = converter.ConvertFromString(valueProviderResult.FirstValue!); bindingContext.Result = ModelBindingResult.Success(convertedValue); } catch (FormatException e) @@ -44,4 +45,4 @@ namespace Jellyfin.Api.ModelBinders return Task.CompletedTask; } } -} \ No newline at end of file +} diff --git a/Jellyfin.Api/ModelBinders/NullableEnumModelBinderProvider.cs b/Jellyfin.Api/ModelBinders/NullableEnumModelBinderProvider.cs index bc12ad05d..2ccfd0c06 100644 --- a/Jellyfin.Api/ModelBinders/NullableEnumModelBinderProvider.cs +++ b/Jellyfin.Api/ModelBinders/NullableEnumModelBinderProvider.cs @@ -24,4 +24,4 @@ namespace Jellyfin.Api.ModelBinders return new NullableEnumModelBinder(logger); } } -} \ No newline at end of file +} diff --git a/Jellyfin.Api/Models/ClientLogDtos/ClientLogDocumentResponseDto.cs b/Jellyfin.Api/Models/ClientLogDtos/ClientLogDocumentResponseDto.cs new file mode 100644 index 000000000..44509a9c0 --- /dev/null +++ b/Jellyfin.Api/Models/ClientLogDtos/ClientLogDocumentResponseDto.cs @@ -0,0 +1,22 @@ +namespace Jellyfin.Api.Models.ClientLogDtos +{ + /// <summary> + /// Client log document response dto. + /// </summary> + public class ClientLogDocumentResponseDto + { + /// <summary> + /// Initializes a new instance of the <see cref="ClientLogDocumentResponseDto"/> class. + /// </summary> + /// <param name="fileName">The file name.</param> + public ClientLogDocumentResponseDto(string fileName) + { + FileName = fileName; + } + + /// <summary> + /// Gets the resulting filename. + /// </summary> + public string FileName { get; } + } +} diff --git a/Jellyfin.Api/Models/LibraryStructureDto/MediaPathDto.cs b/Jellyfin.Api/Models/LibraryStructureDto/MediaPathDto.cs index f65988259..8b26ec317 100644 --- a/Jellyfin.Api/Models/LibraryStructureDto/MediaPathDto.cs +++ b/Jellyfin.Api/Models/LibraryStructureDto/MediaPathDto.cs @@ -24,4 +24,4 @@ namespace Jellyfin.Api.Models.LibraryStructureDto /// </summary> public MediaPathInfo? PathInfo { get; set; } } -} \ No newline at end of file +} diff --git a/Jellyfin.Api/Models/LiveTvDtos/SetChannelMappingDto.cs b/Jellyfin.Api/Models/LiveTvDtos/SetChannelMappingDto.cs index 2ddaa89e8..e7501bd9f 100644 --- a/Jellyfin.Api/Models/LiveTvDtos/SetChannelMappingDto.cs +++ b/Jellyfin.Api/Models/LiveTvDtos/SetChannelMappingDto.cs @@ -25,4 +25,4 @@ namespace Jellyfin.Api.Models.LiveTvDtos [Required] public string ProviderChannelId { get; set; } = string.Empty; } -} \ No newline at end of file +} diff --git a/Jellyfin.Api/Models/MediaInfoDtos/PlaybackInfoDto.cs b/Jellyfin.Api/Models/MediaInfoDtos/PlaybackInfoDto.cs index 2cfdba507..c6bd5e56e 100644 --- a/Jellyfin.Api/Models/MediaInfoDtos/PlaybackInfoDto.cs +++ b/Jellyfin.Api/Models/MediaInfoDtos/PlaybackInfoDto.cs @@ -83,4 +83,4 @@ namespace Jellyfin.Api.Models.MediaInfoDtos /// </summary> public bool? AutoOpenLiveStream { get; set; } } -} \ No newline at end of file +} diff --git a/Jellyfin.Api/Models/PlaybackDtos/TranscodingJobDto.cs b/Jellyfin.Api/Models/PlaybackDtos/TranscodingJobDto.cs index 291e571dc..ab67c8732 100644 --- a/Jellyfin.Api/Models/PlaybackDtos/TranscodingJobDto.cs +++ b/Jellyfin.Api/Models/PlaybackDtos/TranscodingJobDto.cs @@ -106,6 +106,11 @@ namespace Jellyfin.Api.Models.PlaybackDtos /// </summary> public bool HasExited { get; set; } + /// <summary> + /// Gets or sets exit code. + /// </summary> + public int ExitCode { get; set; } + /// <summary> /// Gets or sets a value indicating whether is user paused. /// </summary> @@ -129,7 +134,7 @@ namespace Jellyfin.Api.Models.PlaybackDtos /// <summary> /// Gets or sets bytes downloaded. /// </summary> - public long? BytesDownloaded { get; set; } + public long BytesDownloaded { get; set; } /// <summary> /// Gets or sets bytes transcoded. diff --git a/Jellyfin.Api/Models/PlaybackDtos/TranscodingThrottler.cs b/Jellyfin.Api/Models/PlaybackDtos/TranscodingThrottler.cs index 7b32d76ba..7a1ca252c 100644 --- a/Jellyfin.Api/Models/PlaybackDtos/TranscodingThrottler.cs +++ b/Jellyfin.Api/Models/PlaybackDtos/TranscodingThrottler.cs @@ -141,7 +141,7 @@ namespace Jellyfin.Api.Models.PlaybackDtos private bool IsThrottleAllowed(TranscodingJobDto job, int thresholdSeconds) { - var bytesDownloaded = job.BytesDownloaded ?? 0; + var bytesDownloaded = job.BytesDownloaded; var transcodingPositionTicks = job.TranscodingPositionTicks ?? 0; var downloadPositionTicks = job.DownloadPositionTicks ?? 0; @@ -197,7 +197,7 @@ namespace Jellyfin.Api.Models.PlaybackDtos } } - _logger.LogDebug("No throttle data for " + path); + _logger.LogDebug("No throttle data for {Path}", path); return false; } diff --git a/Jellyfin.Api/Models/StreamingDtos/StreamState.cs b/Jellyfin.Api/Models/StreamingDtos/StreamState.cs index e95f2d1f4..cbabf087b 100644 --- a/Jellyfin.Api/Models/StreamingDtos/StreamState.cs +++ b/Jellyfin.Api/Models/StreamingDtos/StreamState.cs @@ -55,11 +55,14 @@ namespace Jellyfin.Api.Models.StreamingDtos /// <summary> /// Gets the video request. /// </summary> - public VideoRequestDto? VideoRequest => Request! as VideoRequestDto; + public VideoRequestDto? VideoRequest => Request as VideoRequestDto; /// <summary> /// Gets or sets the direct stream provicer. /// </summary> + /// <remarks> + /// Deprecated. + /// </remarks> public IDirectStreamProvider? DirectStreamProvider { get; set; } /// <summary> diff --git a/Jellyfin.Api/Models/SubtitleDtos/UploadSubtitleDto.cs b/Jellyfin.Api/Models/SubtitleDtos/UploadSubtitleDto.cs index 30473255e..be0595798 100644 --- a/Jellyfin.Api/Models/SubtitleDtos/UploadSubtitleDto.cs +++ b/Jellyfin.Api/Models/SubtitleDtos/UploadSubtitleDto.cs @@ -31,4 +31,4 @@ namespace Jellyfin.Api.Models.SubtitleDtos [Required] public string Data { get; set; } = string.Empty; } -} \ No newline at end of file +} diff --git a/Jellyfin.Data/Enums/BaseItemKind.cs b/Jellyfin.Data/Enums/BaseItemKind.cs index 875781746..6fac6c487 100644 --- a/Jellyfin.Data/Enums/BaseItemKind.cs +++ b/Jellyfin.Data/Enums/BaseItemKind.cs @@ -134,7 +134,7 @@ PlaylistsFolder, /// <summary> - /// Item is program + /// Item is program. /// </summary> Program, diff --git a/Jellyfin.Data/Enums/UnratedItem.cs b/Jellyfin.Data/Enums/UnratedItem.cs index 871794086..21ec65af5 100644 --- a/Jellyfin.Data/Enums/UnratedItem.cs +++ b/Jellyfin.Data/Enums/UnratedItem.cs @@ -31,7 +31,7 @@ namespace Jellyfin.Data.Enums Book = 4, /// <summary> - /// A live TV channel + /// A live TV channel. /// </summary> LiveTvChannel = 5, diff --git a/Jellyfin.Data/Jellyfin.Data.csproj b/Jellyfin.Data/Jellyfin.Data.csproj index 65bbd49da..f2779d8f2 100644 --- a/Jellyfin.Data/Jellyfin.Data.csproj +++ b/Jellyfin.Data/Jellyfin.Data.csproj @@ -1,7 +1,7 @@ <Project Sdk="Microsoft.NET.Sdk"> <PropertyGroup> - <TargetFramework>net5.0</TargetFramework> + <TargetFramework>net6.0</TargetFramework> <GenerateAssemblyInfo>false</GenerateAssemblyInfo> <GenerateDocumentationFile>true</GenerateDocumentationFile> <PublishRepositoryUrl>true</PublishRepositoryUrl> @@ -24,18 +24,18 @@ </PropertyGroup> <ItemGroup> - <PackageReference Include="Microsoft.SourceLink.GitHub" Version="1.0.0" PrivateAssets="All" /> + <PackageReference Include="Microsoft.SourceLink.GitHub" Version="1.1.1" PrivateAssets="All" /> </ItemGroup> <!-- Code analysers--> <ItemGroup Condition=" '$(Configuration)' == 'Debug' "> <PackageReference Include="SerilogAnalyzer" Version="0.15.0" PrivateAssets="All" /> - <PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="All" /> + <PackageReference Include="StyleCop.Analyzers" Version="1.2.0-beta.376" PrivateAssets="All" /> <PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" PrivateAssets="All" /> </ItemGroup> <ItemGroup> - <PackageReference Include="Microsoft.Extensions.Logging" Version="5.0.0" /> + <PackageReference Include="Microsoft.Extensions.Logging" Version="6.0.0" /> </ItemGroup> <ItemGroup> diff --git a/Jellyfin.Drawing.Skia/Jellyfin.Drawing.Skia.csproj b/Jellyfin.Drawing.Skia/Jellyfin.Drawing.Skia.csproj index 8cee5dcae..4cc215903 100644 --- a/Jellyfin.Drawing.Skia/Jellyfin.Drawing.Skia.csproj +++ b/Jellyfin.Drawing.Skia/Jellyfin.Drawing.Skia.csproj @@ -6,7 +6,7 @@ </PropertyGroup> <PropertyGroup> - <TargetFramework>net5.0</TargetFramework> + <TargetFramework>net6.0</TargetFramework> <GenerateAssemblyInfo>false</GenerateAssemblyInfo> <GenerateDocumentationFile>true</GenerateDocumentationFile> </PropertyGroup> @@ -28,15 +28,10 @@ <ProjectReference Include="..\MediaBrowser.Common\MediaBrowser.Common.csproj" /> </ItemGroup> - <ItemGroup> - <!-- Needed for https://github.com/dotnet/roslyn-analyzers/issues/4382 which is in the SDK yet --> - <PackageReference Include="Microsoft.CodeAnalysis.NetAnalyzers" Version="5.0.3" PrivateAssets="All" /> - </ItemGroup> - <!-- Code analysers--> <ItemGroup Condition=" '$(Configuration)' == 'Debug' "> <PackageReference Include="SerilogAnalyzer" Version="0.15.0" PrivateAssets="All" /> - <PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="All" /> + <PackageReference Include="StyleCop.Analyzers" Version="1.2.0-beta.376" PrivateAssets="All" /> <PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" PrivateAssets="All" /> </ItemGroup> diff --git a/Jellyfin.Networking/Configuration/NetworkConfiguration.cs b/Jellyfin.Networking/Configuration/NetworkConfiguration.cs index faf814c06..61db223d9 100644 --- a/Jellyfin.Networking/Configuration/NetworkConfiguration.cs +++ b/Jellyfin.Networking/Configuration/NetworkConfiguration.cs @@ -226,5 +226,10 @@ namespace Jellyfin.Networking.Configuration /// Gets or sets the known proxies. If the proxy is a network, it's added to the KnownNetworks. /// </summary> public string[] KnownProxies { get; set; } = Array.Empty<string>(); + + /// <summary> + /// Gets or sets a value indicating whether the published server uri is based on information in HTTP requests. + /// </summary> + public bool EnablePublishedServerUriByRequest { get; set; } = false; } } diff --git a/Jellyfin.Networking/Configuration/NetworkConfigurationFactory.cs b/Jellyfin.Networking/Configuration/NetworkConfigurationFactory.cs index ac0485d87..14726565a 100644 --- a/Jellyfin.Networking/Configuration/NetworkConfigurationFactory.cs +++ b/Jellyfin.Networking/Configuration/NetworkConfigurationFactory.cs @@ -16,11 +16,7 @@ namespace Jellyfin.Networking.Configuration { return new[] { - new ConfigurationStore - { - Key = "network", - ConfigurationType = typeof(NetworkConfiguration) - } + new NetworkConfigurationStore() }; } } diff --git a/Jellyfin.Networking/Configuration/NetworkConfigurationStore.cs b/Jellyfin.Networking/Configuration/NetworkConfigurationStore.cs new file mode 100644 index 000000000..a268ebb68 --- /dev/null +++ b/Jellyfin.Networking/Configuration/NetworkConfigurationStore.cs @@ -0,0 +1,24 @@ +using MediaBrowser.Common.Configuration; + +namespace Jellyfin.Networking.Configuration +{ + /// <summary> + /// A configuration that stores network related settings. + /// </summary> + public class NetworkConfigurationStore : ConfigurationStore + { + /// <summary> + /// The name of the configuration in the storage. + /// </summary> + public const string StoreKey = "network"; + + /// <summary> + /// Initializes a new instance of the <see cref="NetworkConfigurationStore"/> class. + /// </summary> + public NetworkConfigurationStore() + { + ConfigurationType = typeof(NetworkConfiguration); + Key = StoreKey; + } + } +} diff --git a/Jellyfin.Networking/Jellyfin.Networking.csproj b/Jellyfin.Networking/Jellyfin.Networking.csproj index 227a41ce4..a6af8566c 100644 --- a/Jellyfin.Networking/Jellyfin.Networking.csproj +++ b/Jellyfin.Networking/Jellyfin.Networking.csproj @@ -1,6 +1,6 @@ <Project Sdk="Microsoft.NET.Sdk"> <PropertyGroup> - <TargetFramework>net5.0</TargetFramework> + <TargetFramework>net6.0</TargetFramework> <GenerateAssemblyInfo>false</GenerateAssemblyInfo> <GenerateDocumentationFile>true</GenerateDocumentationFile> </PropertyGroup> @@ -12,7 +12,7 @@ <!-- Code Analyzers--> <ItemGroup Condition=" '$(Configuration)' == 'Debug' "> <PackageReference Include="SerilogAnalyzer" Version="0.15.0" PrivateAssets="All" /> - <PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="All" /> + <PackageReference Include="StyleCop.Analyzers" Version="1.2.0-beta.376" PrivateAssets="All" /> <PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" PrivateAssets="All" /> </ItemGroup> diff --git a/Jellyfin.Networking/Manager/NetworkManager.cs b/Jellyfin.Networking/Manager/NetworkManager.cs index 4078fd126..58b30ad2d 100644 --- a/Jellyfin.Networking/Manager/NetworkManager.cs +++ b/Jellyfin.Networking/Manager/NetworkManager.cs @@ -455,10 +455,10 @@ namespace Jellyfin.Networking.Manager } // No bind address, so return all internal interfaces. - return CreateCollection(_internalInterfaces.Where(p => !p.IsLoopback())); + return CreateCollection(_internalInterfaces); } - return new Collection<IPObject>(_bindAddresses); + return new Collection<IPObject>(_bindAddresses.Where(a => IsInLocalNetwork(a)).ToArray()); } /// <inheritdoc/> @@ -481,7 +481,7 @@ namespace Jellyfin.Networking.Manager } // As private addresses can be redefined by Configuration.LocalNetworkAddresses - return _lanSubnets.ContainsAddress(address) && !_excludedSubnets.ContainsAddress(address); + return address.IsLoopback() || (_lanSubnets.ContainsAddress(address) && !_excludedSubnets.ContainsAddress(address)); } /// <inheritdoc/> @@ -647,16 +647,6 @@ namespace Jellyfin.Networking.Manager _interfaceAddresses.AddItem(address, false); _interfaceNames[parts[2]] = Math.Abs(index); } - - if (IsIP4Enabled) - { - _interfaceAddresses.AddItem(IPNetAddress.IP4Loopback); - } - - if (IsIP6Enabled) - { - _interfaceAddresses.AddItem(IPNetAddress.IP6Loopback); - } } InitialiseLAN(config); @@ -737,7 +727,7 @@ namespace Jellyfin.Networking.Manager private void ConfigurationUpdated(object? sender, ConfigurationUpdateEventArgs evt) { - if (evt.Key.Equals("network", StringComparison.Ordinal)) + if (evt.Key.Equals(NetworkConfigurationStore.StoreKey, StringComparison.Ordinal)) { UpdateSettings((NetworkConfiguration)evt.NewConfiguration); } @@ -1037,17 +1027,14 @@ namespace Jellyfin.Networking.Manager // Subnets are the same as the calculated internal interface. _lanSubnets = new Collection<IPObject>(); - // We must listen on loopback for LiveTV to function regardless of the settings. if (IsIP6Enabled) { - _lanSubnets.AddItem(IPNetAddress.IP6Loopback); _lanSubnets.AddItem(IPNetAddress.Parse("fc00::/7")); // ULA _lanSubnets.AddItem(IPNetAddress.Parse("fe80::/10")); // Site local } if (IsIP4Enabled) { - _lanSubnets.AddItem(IPNetAddress.IP4Loopback); _lanSubnets.AddItem(IPNetAddress.Parse("10.0.0.0/8")); _lanSubnets.AddItem(IPNetAddress.Parse("172.16.0.0/12")); _lanSubnets.AddItem(IPNetAddress.Parse("192.168.0.0/16")); @@ -1055,17 +1042,6 @@ namespace Jellyfin.Networking.Manager } else { - // We must listen on loopback for LiveTV to function regardless of the settings. - if (IsIP6Enabled) - { - _lanSubnets.AddItem(IPNetAddress.IP6Loopback); - } - - if (IsIP4Enabled) - { - _lanSubnets.AddItem(IPNetAddress.IP4Loopback); - } - // Internal interfaces must be private, not excluded and part of the LocalNetworkSubnet. _internalInterfaces = CreateCollection(_interfaceAddresses.Where(IsInLocalNetwork)); } diff --git a/Jellyfin.Server.Implementations/Devices/DeviceManager.cs b/Jellyfin.Server.Implementations/Devices/DeviceManager.cs index 0655c9813..6c77421c7 100644 --- a/Jellyfin.Server.Implementations/Devices/DeviceManager.cs +++ b/Jellyfin.Server.Implementations/Devices/DeviceManager.cs @@ -7,6 +7,7 @@ using Jellyfin.Data.Entities.Security; using Jellyfin.Data.Enums; using Jellyfin.Data.Events; using Jellyfin.Data.Queries; +using Jellyfin.Extensions; using MediaBrowser.Controller.Devices; using MediaBrowser.Controller.Library; using MediaBrowser.Model.Devices; @@ -23,7 +24,7 @@ namespace Jellyfin.Server.Implementations.Devices { private readonly JellyfinDbProvider _dbProvider; private readonly IUserManager _userManager; - private readonly ConcurrentDictionary<string, ClientCapabilities> _capabilitiesMap = new (); + private readonly ConcurrentDictionary<string, ClientCapabilities> _capabilitiesMap = new(); /// <summary> /// Initializes a new instance of the <see cref="DeviceManager"/> class. @@ -172,8 +173,8 @@ namespace Jellyfin.Server.Implementations.Devices var sessions = dbContext.Devices .Include(d => d.User) .AsQueryable() - .OrderBy(d => d.DeviceId) - .ThenByDescending(d => d.DateLastActivity) + .OrderByDescending(d => d.DateLastActivity) + .ThenBy(d => d.DeviceId) .AsAsyncEnumerable(); if (supportsSync.HasValue) @@ -219,7 +220,7 @@ namespace Jellyfin.Server.Implementations.Devices return true; } - return user.GetPreference(PreferenceKind.EnabledDevices).Contains(deviceId, StringComparer.OrdinalIgnoreCase) + return user.GetPreference(PreferenceKind.EnabledDevices).Contains(deviceId, StringComparison.OrdinalIgnoreCase) || !GetCapabilities(deviceId).SupportsPersistentIdentifier; } diff --git a/Jellyfin.Server.Implementations/Events/EventManager.cs b/Jellyfin.Server.Implementations/Events/EventManager.cs index 8c5d8f2ce..7f7c4750d 100644 --- a/Jellyfin.Server.Implementations/Events/EventManager.cs +++ b/Jellyfin.Server.Implementations/Events/EventManager.cs @@ -57,7 +57,7 @@ namespace Jellyfin.Server.Implementations.Events } catch (Exception e) { - _logger.LogError(e, "Uncaught exception in EventConsumer {type}: ", service.GetType()); + _logger.LogError(e, "Uncaught exception in EventConsumer {Type}: ", service.GetType()); } } } diff --git a/Jellyfin.Server.Implementations/Jellyfin.Server.Implementations.csproj b/Jellyfin.Server.Implementations/Jellyfin.Server.Implementations.csproj index a75b28593..86aec1399 100644 --- a/Jellyfin.Server.Implementations/Jellyfin.Server.Implementations.csproj +++ b/Jellyfin.Server.Implementations/Jellyfin.Server.Implementations.csproj @@ -1,15 +1,19 @@ <Project Sdk="Microsoft.NET.Sdk"> <PropertyGroup> - <TargetFramework>net5.0</TargetFramework> + <TargetFramework>net6.0</TargetFramework> <GenerateAssemblyInfo>false</GenerateAssemblyInfo> <GenerateDocumentationFile>true</GenerateDocumentationFile> </PropertyGroup> + <PropertyGroup Condition=" '$(Configuration)' == 'Debug' "> + <TreatWarningsAsErrors>false</TreatWarningsAsErrors> + </PropertyGroup> + <!-- Code analysers--> <ItemGroup Condition=" '$(Configuration)' == 'Debug' "> <PackageReference Include="SerilogAnalyzer" Version="0.15.0" PrivateAssets="All" /> - <PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="All" /> + <PackageReference Include="StyleCop.Analyzers" Version="1.2.0-beta.376" PrivateAssets="All" /> <PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" PrivateAssets="All" /> </ItemGroup> @@ -18,14 +22,14 @@ </ItemGroup> <ItemGroup> - <PackageReference Include="System.Linq.Async" Version="5.0.0" /> - <PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="5.0.9" /> - <PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="5.0.9" /> - <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="5.0.9"> + <PackageReference Include="System.Linq.Async" Version="5.1.0" /> + <PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="6.0.1" /> + <PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="6.0.1" /> + <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="6.0.1"> <PrivateAssets>all</PrivateAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> </PackageReference> - <PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="5.0.9"> + <PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="6.0.1"> <PrivateAssets>all</PrivateAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> </PackageReference> diff --git a/Jellyfin.Server.Implementations/ModelBuilderExtensions.cs b/Jellyfin.Server.Implementations/ModelBuilderExtensions.cs index 80ad65a42..e73a90cff 100644 --- a/Jellyfin.Server.Implementations/ModelBuilderExtensions.cs +++ b/Jellyfin.Server.Implementations/ModelBuilderExtensions.cs @@ -45,4 +45,4 @@ namespace Jellyfin.Server.Implementations modelBuilder.UseValueConverterForType<DateTime?>(new DateTimeKindValueConverter(kind)); } } -} \ No newline at end of file +} diff --git a/Jellyfin.Server.Implementations/Security/AuthorizationContext.cs b/Jellyfin.Server.Implementations/Security/AuthorizationContext.cs index 244abf469..d59d36e88 100644 --- a/Jellyfin.Server.Implementations/Security/AuthorizationContext.cs +++ b/Jellyfin.Server.Implementations/Security/AuthorizationContext.cs @@ -2,12 +2,12 @@ using System; using System.Collections.Generic; -using System.Linq; using System.Net; using System.Threading.Tasks; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Net; using Microsoft.AspNetCore.Http; +using Microsoft.EntityFrameworkCore; using Microsoft.Net.Http.Headers; namespace Jellyfin.Server.Implementations.Security @@ -27,7 +27,7 @@ namespace Jellyfin.Server.Implementations.Security { if (requestContext.Request.HttpContext.Items.TryGetValue("AuthorizationInfo", out var cached) && cached != null) { - return Task.FromResult((AuthorizationInfo)cached!); // Cache should never contain null + return Task.FromResult((AuthorizationInfo)cached); // Cache should never contain null } return GetAuthorization(requestContext); @@ -185,9 +185,21 @@ namespace Jellyfin.Server.Implementations.Security authInfo.IsAuthenticated = true; authInfo.Client = key.Name; authInfo.Token = key.AccessToken; - authInfo.DeviceId = string.Empty; - authInfo.Device = string.Empty; - authInfo.Version = string.Empty; + if (string.IsNullOrWhiteSpace(authInfo.DeviceId)) + { + authInfo.DeviceId = string.Empty; + } + + if (string.IsNullOrWhiteSpace(authInfo.Device)) + { + authInfo.Device = string.Empty; + } + + if (string.IsNullOrWhiteSpace(authInfo.Version)) + { + authInfo.Version = string.Empty; + } + authInfo.IsApiKey = true; } } diff --git a/Jellyfin.Server.Implementations/Users/DefaultAuthenticationProvider.cs b/Jellyfin.Server.Implementations/Users/DefaultAuthenticationProvider.cs index 6a78e7ee6..7480a05c2 100644 --- a/Jellyfin.Server.Implementations/Users/DefaultAuthenticationProvider.cs +++ b/Jellyfin.Server.Implementations/Users/DefaultAuthenticationProvider.cs @@ -1,9 +1,6 @@ using System; -using System.Linq; -using System.Text; using System.Threading.Tasks; using Jellyfin.Data.Entities; -using MediaBrowser.Common.Cryptography; using MediaBrowser.Controller.Authentication; using MediaBrowser.Model.Cryptography; @@ -61,35 +58,25 @@ namespace Jellyfin.Server.Implementations.Users } // Handle the case when the stored password is null, but the user tried to login with a password - if (resolvedUser.Password != null) + if (resolvedUser.Password == null) { - byte[] passwordBytes = Encoding.UTF8.GetBytes(password); - - PasswordHash readyHash = PasswordHash.Parse(resolvedUser.Password); - if (_cryptographyProvider.GetSupportedHashMethods().Contains(readyHash.Id) - || _cryptographyProvider.DefaultHashMethod == readyHash.Id) - { - byte[] calculatedHash = _cryptographyProvider.ComputeHash( - readyHash.Id, - passwordBytes, - readyHash.Salt.ToArray()); - - if (readyHash.Hash.SequenceEqual(calculatedHash)) - { - success = true; - } - } - else - { - throw new AuthenticationException($"Requested crypto method not available in provider: {readyHash.Id}"); - } + throw new AuthenticationException("Invalid username or password"); } + PasswordHash readyHash = PasswordHash.Parse(resolvedUser.Password); + success = _cryptographyProvider.Verify(readyHash, password); + if (!success) { throw new AuthenticationException("Invalid username or password"); } + // Migrate old hashes to the new default + if (!string.Equals(readyHash.Id, _cryptographyProvider.DefaultHashMethod, StringComparison.Ordinal)) + { + ChangePassword(resolvedUser, password); + } + return Task.FromResult(new ProviderAuthenticationResult { Username = username diff --git a/Jellyfin.Server.Implementations/Users/DefaultPasswordResetProvider.cs b/Jellyfin.Server.Implementations/Users/DefaultPasswordResetProvider.cs index 6e98ad863..5e84255f9 100644 --- a/Jellyfin.Server.Implementations/Users/DefaultPasswordResetProvider.cs +++ b/Jellyfin.Server.Implementations/Users/DefaultPasswordResetProvider.cs @@ -93,13 +93,9 @@ namespace Jellyfin.Server.Implementations.Users /// <inheritdoc /> public async Task<ForgotPasswordResult> StartForgotPasswordProcess(User user, bool isInNetwork) { - string pin; - using (var cryptoRandom = RandomNumberGenerator.Create()) - { - byte[] bytes = new byte[4]; - cryptoRandom.GetBytes(bytes); - pin = BitConverter.ToString(bytes); - } + byte[] bytes = new byte[4]; + RandomNumberGenerator.Fill(bytes); + string pin = BitConverter.ToString(bytes); DateTime expireTime = DateTime.UtcNow.AddMinutes(30); string filePath = _passwordResetFileBase + user.Id + ".json"; @@ -114,7 +110,6 @@ namespace Jellyfin.Server.Implementations.Users await using (FileStream fileStream = AsyncFile.OpenWrite(filePath)) { await JsonSerializer.SerializeAsync(fileStream, spr).ConfigureAwait(false); - await fileStream.FlushAsync().ConfigureAwait(false); } user.EasyPassword = pin; @@ -123,6 +118,7 @@ namespace Jellyfin.Server.Implementations.Users { Action = ForgotPasswordAction.PinCode, PinExpirationDate = expireTime, + PinFile = filePath }; } diff --git a/Jellyfin.Server.Implementations/Users/UserManager.cs b/Jellyfin.Server.Implementations/Users/UserManager.cs index 02377bfd7..c41b343c7 100644 --- a/Jellyfin.Server.Implementations/Users/UserManager.cs +++ b/Jellyfin.Server.Implementations/Users/UserManager.cs @@ -5,7 +5,6 @@ using System.Collections.Concurrent; using System.Collections.Generic; using System.Globalization; using System.Linq; -using System.Text; using System.Text.RegularExpressions; using System.Threading.Tasks; using Jellyfin.Data.Entities; @@ -13,7 +12,6 @@ using Jellyfin.Data.Enums; using Jellyfin.Data.Events; using Jellyfin.Data.Events.Users; using MediaBrowser.Common; -using MediaBrowser.Common.Cryptography; using MediaBrowser.Common.Extensions; using MediaBrowser.Common.Net; using MediaBrowser.Controller.Authentication; @@ -396,12 +394,12 @@ namespace Jellyfin.Server.Implementations.Users var user = Users.FirstOrDefault(i => string.Equals(username, i.Username, StringComparison.OrdinalIgnoreCase)); var authResult = await AuthenticateLocalUser(username, password, user, remoteEndPoint) .ConfigureAwait(false); - var authenticationProvider = authResult.authenticationProvider; - var success = authResult.success; + var authenticationProvider = authResult.AuthenticationProvider; + var success = authResult.Success; if (user == null) { - string updatedUsername = authResult.username; + string updatedUsername = authResult.Username; if (success && authenticationProvider != null @@ -530,11 +528,7 @@ namespace Jellyfin.Server.Implementations.Users } } - return new PinRedeemResult - { - Success = false, - UsersReset = Array.Empty<string>() - }; + return new PinRedeemResult(); } /// <inheritdoc /> @@ -701,6 +695,11 @@ namespace Jellyfin.Server.Implementations.Users /// <inheritdoc/> public async Task ClearProfileImageAsync(User user) { + if (user.ProfileImage == null) + { + return; + } + await using var dbContext = _dbProvider.CreateContext(); dbContext.Remove(user.ProfileImage); await dbContext.SaveChangesAsync().ConfigureAwait(false); @@ -786,7 +785,7 @@ namespace Jellyfin.Server.Implementations.Users return providers; } - private async Task<(IAuthenticationProvider? authenticationProvider, string username, bool success)> AuthenticateLocalUser( + private async Task<(IAuthenticationProvider? AuthenticationProvider, string Username, bool Success)> AuthenticateLocalUser( string username, string password, User? user, @@ -799,8 +798,8 @@ namespace Jellyfin.Server.Implementations.Users { var providerAuthResult = await AuthenticateWithProvider(provider, username, password, user).ConfigureAwait(false); - var updatedUsername = providerAuthResult.username; - success = providerAuthResult.success; + var updatedUsername = providerAuthResult.Username; + success = providerAuthResult.Success; if (success) { @@ -817,17 +816,13 @@ namespace Jellyfin.Server.Implementations.Users { // Check easy password var passwordHash = PasswordHash.Parse(user.EasyPassword); - var hash = _cryptoProvider.ComputeHash( - passwordHash.Id, - Encoding.UTF8.GetBytes(password), - passwordHash.Salt.ToArray()); - success = passwordHash.Hash.SequenceEqual(hash); + success = _cryptoProvider.Verify(passwordHash, password); } return (authenticationProvider, username, success); } - private async Task<(string username, bool success)> AuthenticateWithProvider( + private async Task<(string Username, bool Success)> AuthenticateWithProvider( IAuthenticationProvider provider, string username, string password, diff --git a/Jellyfin.Server/Configuration/CorsPolicyProvider.cs b/Jellyfin.Server/Configuration/CorsPolicyProvider.cs index 0d04b6bb1..b061be33b 100644 --- a/Jellyfin.Server/Configuration/CorsPolicyProvider.cs +++ b/Jellyfin.Server/Configuration/CorsPolicyProvider.cs @@ -23,7 +23,7 @@ namespace Jellyfin.Server.Configuration } /// <inheritdoc /> - public Task<CorsPolicy> GetPolicyAsync(HttpContext context, string policyName) + public Task<CorsPolicy?> GetPolicyAsync(HttpContext context, string? policyName) { var corsHosts = _serverConfigurationManager.Configuration.CorsHosts; var builder = new CorsPolicyBuilder() @@ -43,7 +43,7 @@ namespace Jellyfin.Server.Configuration .AllowCredentials(); } - return Task.FromResult(builder.Build()); + return Task.FromResult<CorsPolicy?>(builder.Build()); } } } diff --git a/Jellyfin.Server/CoreAppHost.cs b/Jellyfin.Server/CoreAppHost.cs index 21bd9ba01..67e50b92d 100644 --- a/Jellyfin.Server/CoreAppHost.cs +++ b/Jellyfin.Server/CoreAppHost.cs @@ -22,7 +22,6 @@ using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Net; using MediaBrowser.Controller.Security; using MediaBrowser.Model.Activity; -using MediaBrowser.Model.IO; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; @@ -42,67 +41,61 @@ namespace Jellyfin.Server /// <param name="loggerFactory">The <see cref="ILoggerFactory" /> to be used by the <see cref="CoreAppHost" />.</param> /// <param name="options">The <see cref="StartupOptions" /> to be used by the <see cref="CoreAppHost" />.</param> /// <param name="startupConfig">The <see cref="IConfiguration" /> to be used by the <see cref="CoreAppHost" />.</param> - /// <param name="fileSystem">The <see cref="IFileSystem" /> to be used by the <see cref="CoreAppHost" />.</param> - /// <param name="collection">The <see cref="IServiceCollection"/> to be used by the <see cref="CoreAppHost"/>.</param> public CoreAppHost( IServerApplicationPaths applicationPaths, ILoggerFactory loggerFactory, IStartupOptions options, - IConfiguration startupConfig, - IFileSystem fileSystem, - IServiceCollection collection) + IConfiguration startupConfig) : base( applicationPaths, loggerFactory, options, - startupConfig, - fileSystem, - collection) + startupConfig) { } /// <inheritdoc/> - protected override void RegisterServices() + protected override void RegisterServices(IServiceCollection serviceCollection) { // Register an image encoder bool useSkiaEncoder = SkiaEncoder.IsNativeLibAvailable(); Type imageEncoderType = useSkiaEncoder ? typeof(SkiaEncoder) : typeof(NullImageEncoder); - ServiceCollection.AddSingleton(typeof(IImageEncoder), imageEncoderType); + serviceCollection.AddSingleton(typeof(IImageEncoder), imageEncoderType); // Log a warning if the Skia encoder could not be used if (!useSkiaEncoder) { - Logger.LogWarning($"Skia not available. Will fallback to {nameof(NullImageEncoder)}."); + Logger.LogWarning("Skia not available. Will fallback to {ImageEncoder}.", nameof(NullImageEncoder)); } - ServiceCollection.AddDbContextPool<JellyfinDb>( + serviceCollection.AddDbContextPool<JellyfinDb>( options => options .UseLoggerFactory(LoggerFactory) .UseSqlite($"Filename={Path.Combine(ApplicationPaths.DataPath, "jellyfin.db")}")); - ServiceCollection.AddEventServices(); - ServiceCollection.AddSingleton<IBaseItemManager, BaseItemManager>(); - ServiceCollection.AddSingleton<IEventManager, EventManager>(); - ServiceCollection.AddSingleton<JellyfinDbProvider>(); + serviceCollection.AddEventServices(); + serviceCollection.AddSingleton<IBaseItemManager, BaseItemManager>(); + serviceCollection.AddSingleton<IEventManager, EventManager>(); + serviceCollection.AddSingleton<JellyfinDbProvider>(); - ServiceCollection.AddSingleton<IActivityManager, ActivityManager>(); - ServiceCollection.AddSingleton<IUserManager, UserManager>(); - ServiceCollection.AddSingleton<IDisplayPreferencesManager, DisplayPreferencesManager>(); - ServiceCollection.AddSingleton<IDeviceManager, DeviceManager>(); + serviceCollection.AddSingleton<IActivityManager, ActivityManager>(); + serviceCollection.AddSingleton<IUserManager, UserManager>(); + serviceCollection.AddSingleton<IDisplayPreferencesManager, DisplayPreferencesManager>(); + serviceCollection.AddSingleton<IDeviceManager, DeviceManager>(); // TODO search the assemblies instead of adding them manually? - ServiceCollection.AddSingleton<IWebSocketListener, SessionWebSocketListener>(); - ServiceCollection.AddSingleton<IWebSocketListener, ActivityLogWebSocketListener>(); - ServiceCollection.AddSingleton<IWebSocketListener, ScheduledTasksWebSocketListener>(); - ServiceCollection.AddSingleton<IWebSocketListener, SessionInfoWebSocketListener>(); + serviceCollection.AddSingleton<IWebSocketListener, SessionWebSocketListener>(); + serviceCollection.AddSingleton<IWebSocketListener, ActivityLogWebSocketListener>(); + serviceCollection.AddSingleton<IWebSocketListener, ScheduledTasksWebSocketListener>(); + serviceCollection.AddSingleton<IWebSocketListener, SessionInfoWebSocketListener>(); - ServiceCollection.AddSingleton<IAuthorizationContext, AuthorizationContext>(); + serviceCollection.AddSingleton<IAuthorizationContext, AuthorizationContext>(); - ServiceCollection.AddScoped<IAuthenticationManager, AuthenticationManager>(); + serviceCollection.AddScoped<IAuthenticationManager, AuthenticationManager>(); - base.RegisterServices(); + base.RegisterServices(serviceCollection); } /// <inheritdoc /> diff --git a/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs b/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs index f19e87aba..fa98fda69 100644 --- a/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs +++ b/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs @@ -7,6 +7,7 @@ using System.Net.Sockets; using System.Reflection; using Emby.Server.Implementations; using Jellyfin.Api.Auth; +using Jellyfin.Api.Auth.AnonymousLanAccessPolicy; using Jellyfin.Api.Auth.DefaultAuthorizationPolicy; using Jellyfin.Api.Auth.DownloadPolicy; using Jellyfin.Api.Auth.FirstTimeOrIgnoreParentalControlSetupPolicy; @@ -61,6 +62,7 @@ namespace Jellyfin.Server.Extensions serviceCollection.AddSingleton<IAuthorizationHandler, IgnoreParentalControlHandler>(); serviceCollection.AddSingleton<IAuthorizationHandler, FirstTimeOrIgnoreParentalControlSetupHandler>(); serviceCollection.AddSingleton<IAuthorizationHandler, LocalAccessHandler>(); + serviceCollection.AddSingleton<IAuthorizationHandler, AnonymousLanAccessHandler>(); serviceCollection.AddSingleton<IAuthorizationHandler, LocalAccessOrRequiresElevationHandler>(); serviceCollection.AddSingleton<IAuthorizationHandler, RequiresElevationHandler>(); serviceCollection.AddSingleton<IAuthorizationHandler, SyncPlayAccessHandler>(); @@ -157,6 +159,13 @@ namespace Jellyfin.Server.Extensions policy.AddAuthenticationSchemes(AuthenticationSchemes.CustomAuthentication); policy.AddRequirements(new SyncPlayAccessRequirement(SyncPlayAccessRequirementType.IsInGroup)); }); + options.AddPolicy( + Policies.AnonymousLanAccessPolicy, + policy => + { + policy.AddAuthenticationSchemes(AuthenticationSchemes.CustomAuthentication); + policy.AddRequirements(new AnonymousLanAccessRequirement()); + }); }); } @@ -188,7 +197,8 @@ namespace Jellyfin.Server.Extensions // https://github.com/dotnet/aspnetcore/blob/master/src/Middleware/HttpOverrides/src/ForwardedHeadersMiddleware.cs // Enable debug logging on Microsoft.AspNetCore.HttpOverrides.ForwardedHeadersMiddleware to help investigate issues. - options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto; + options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto | ForwardedHeaders.XForwardedHost; + if (config.KnownProxies.Length == 0) { options.KnownNetworks.Clear(); @@ -278,7 +288,7 @@ namespace Jellyfin.Server.Extensions { Type = SecuritySchemeType.ApiKey, In = ParameterLocation.Header, - Name = "X-Emby-Authorization", + Name = "Authorization", Description = "API key header parameter" }); @@ -406,6 +416,18 @@ namespace Jellyfin.Server.Extensions } }) }); + + // Support dictionary with nullable string value. + options.MapType<Dictionary<string, string?>>(() => + new OpenApiSchema + { + Type = "object", + AdditionalProperties = new OpenApiSchema + { + Type = "string", + Nullable = true + } + }); } } } diff --git a/Jellyfin.Server/Filters/SecurityRequirementsOperationFilter.cs b/Jellyfin.Server/Filters/SecurityRequirementsOperationFilter.cs index 802662ce2..077908895 100644 --- a/Jellyfin.Server/Filters/SecurityRequirementsOperationFilter.cs +++ b/Jellyfin.Server/Filters/SecurityRequirementsOperationFilter.cs @@ -75,4 +75,4 @@ namespace Jellyfin.Server.Filters } } } -} \ No newline at end of file +} diff --git a/Jellyfin.Server/Infrastructure/SymlinkFollowingPhysicalFileResultExecutor.cs b/Jellyfin.Server/Infrastructure/SymlinkFollowingPhysicalFileResultExecutor.cs new file mode 100644 index 000000000..73a619b8d --- /dev/null +++ b/Jellyfin.Server/Infrastructure/SymlinkFollowingPhysicalFileResultExecutor.cs @@ -0,0 +1,144 @@ +// The MIT License (MIT) +// +// Copyright (c) .NET Foundation and Contributors +// +// All rights reserved. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Extensions; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Infrastructure; +using Microsoft.Extensions.Logging; +using Microsoft.Net.Http.Headers; + +namespace Jellyfin.Server.Infrastructure +{ + /// <inheritdoc /> + public class SymlinkFollowingPhysicalFileResultExecutor : PhysicalFileResultExecutor + { + /// <summary> + /// Initializes a new instance of the <see cref="SymlinkFollowingPhysicalFileResultExecutor"/> class. + /// </summary> + /// <param name="loggerFactory">An instance of the <see cref="ILoggerFactory"/> interface.</param> + public SymlinkFollowingPhysicalFileResultExecutor(ILoggerFactory loggerFactory) : base(loggerFactory) + { + } + + /// <inheritdoc /> + protected override FileMetadata GetFileInfo(string path) + { + var fileInfo = new FileInfo(path); + var length = fileInfo.Length; + // This may or may not be fixed in .NET 6, but looks like it will not https://github.com/dotnet/aspnetcore/issues/34371 + if ((fileInfo.Attributes & FileAttributes.ReparsePoint) == FileAttributes.ReparsePoint) + { + using var fileHandle = File.OpenHandle(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + length = RandomAccess.GetLength(fileHandle); + } + + return new FileMetadata + { + Exists = fileInfo.Exists, + Length = length, + LastModified = fileInfo.LastWriteTimeUtc + }; + } + + /// <inheritdoc /> + protected override Task WriteFileAsync(ActionContext context, PhysicalFileResult result, RangeItemHeaderValue? range, long rangeLength) + { + if (context == null) + { + throw new ArgumentNullException(nameof(context)); + } + + if (result == null) + { + throw new ArgumentNullException(nameof(result)); + } + + if (range != null && rangeLength == 0) + { + return Task.CompletedTask; + } + + // It's a bit of wasted IO to perform this check again, but non-symlinks shouldn't use this code + if (!IsSymLink(result.FileName)) + { + return base.WriteFileAsync(context, result, range, rangeLength); + } + + var response = context.HttpContext.Response; + + if (range != null) + { + return SendFileAsync( + result.FileName, + response, + offset: range.From ?? 0L, + count: rangeLength); + } + + return SendFileAsync( + result.FileName, + response, + offset: 0, + count: null); + } + + private async Task SendFileAsync(string filePath, HttpResponse response, long offset, long? count) + { + var fileInfo = GetFileInfo(filePath); + if (offset < 0 || offset > fileInfo.Length) + { + throw new ArgumentOutOfRangeException(nameof(offset), offset, string.Empty); + } + + if (count.HasValue + && (count.Value < 0 || count.Value > fileInfo.Length - offset)) + { + throw new ArgumentOutOfRangeException(nameof(count), count, string.Empty); + } + + // Copied from SendFileFallback.SendFileAsync + const int BufferSize = 1024 * 16; + + await using var fileStream = new FileStream( + filePath, + FileMode.Open, + FileAccess.Read, + FileShare.ReadWrite, + bufferSize: BufferSize, + options: FileOptions.Asynchronous | FileOptions.SequentialScan); + + fileStream.Seek(offset, SeekOrigin.Begin); + await StreamCopyOperation + .CopyToAsync(fileStream, response.Body, count, BufferSize, CancellationToken.None) + .ConfigureAwait(true); + } + + private static bool IsSymLink(string path) => (File.GetAttributes(path) & FileAttributes.ReparsePoint) == FileAttributes.ReparsePoint; + } +} diff --git a/Jellyfin.Server/Jellyfin.Server.csproj b/Jellyfin.Server/Jellyfin.Server.csproj index 1fdad73b7..12efa15dd 100644 --- a/Jellyfin.Server/Jellyfin.Server.csproj +++ b/Jellyfin.Server/Jellyfin.Server.csproj @@ -8,12 +8,16 @@ <PropertyGroup> <AssemblyName>jellyfin</AssemblyName> <OutputType>Exe</OutputType> - <TargetFramework>net5.0</TargetFramework> + <TargetFramework>net6.0</TargetFramework> <ServerGarbageCollection>false</ServerGarbageCollection> <GenerateAssemblyInfo>false</GenerateAssemblyInfo> <GenerateDocumentationFile>true</GenerateDocumentationFile> </PropertyGroup> + <PropertyGroup Condition=" '$(Configuration)' == 'Debug' "> + <TreatWarningsAsErrors>false</TreatWarningsAsErrors> + </PropertyGroup> + <ItemGroup> <Compile Include="..\SharedVersion.cs" /> </ItemGroup> @@ -25,26 +29,26 @@ <!-- Code Analyzers--> <ItemGroup Condition=" '$(Configuration)' == 'Debug' "> <PackageReference Include="SerilogAnalyzer" Version="0.15.0" PrivateAssets="All" /> - <PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="All" /> + <PackageReference Include="StyleCop.Analyzers" Version="1.2.0-beta.376" PrivateAssets="All" /> <PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" PrivateAssets="All" /> </ItemGroup> <ItemGroup> <PackageReference Include="CommandLineParser" Version="2.8.0" /> - <PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="5.0.0" /> - <PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="5.0.0" /> - <PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks" Version="5.0.9" /> - <PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="5.0.9" /> - <PackageReference Include="prometheus-net" Version="5.0.1" /> - <PackageReference Include="prometheus-net.AspNetCore" Version="5.0.1" /> + <PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="6.0.0" /> + <PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="6.0.0" /> + <PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks" Version="6.0.1" /> + <PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="6.0.1" /> + <PackageReference Include="prometheus-net" Version="5.0.2" /> + <PackageReference Include="prometheus-net.AspNetCore" Version="5.0.2" /> <PackageReference Include="Serilog.AspNetCore" Version="4.1.0" /> <PackageReference Include="Serilog.Enrichers.Thread" Version="3.1.0" /> - <PackageReference Include="Serilog.Settings.Configuration" Version="3.2.0" /> + <PackageReference Include="Serilog.Settings.Configuration" Version="3.3.0" /> <PackageReference Include="Serilog.Sinks.Async" Version="1.5.0" /> - <PackageReference Include="Serilog.Sinks.Console" Version="4.0.0" /> + <PackageReference Include="Serilog.Sinks.Console" Version="4.0.1" /> <PackageReference Include="Serilog.Sinks.File" Version="5.0.0" /> - <PackageReference Include="Serilog.Sinks.Graylog" Version="2.2.2" /> - <PackageReference Include="SQLitePCLRaw.bundle_e_sqlite3" Version="2.0.6" /> + <PackageReference Include="Serilog.Sinks.Graylog" Version="2.3.0" /> + <PackageReference Include="SQLitePCLRaw.bundle_e_sqlite3" Version="2.0.7" /> </ItemGroup> <ItemGroup> diff --git a/Jellyfin.Server/Middleware/LegacyEmbyRouteRewriteMiddleware.cs b/Jellyfin.Server/Middleware/LegacyEmbyRouteRewriteMiddleware.cs index fdd8974d2..b214299df 100644 --- a/Jellyfin.Server/Middleware/LegacyEmbyRouteRewriteMiddleware.cs +++ b/Jellyfin.Server/Middleware/LegacyEmbyRouteRewriteMiddleware.cs @@ -51,4 +51,4 @@ namespace Jellyfin.Server.Middleware await _next(httpContext).ConfigureAwait(false); } } -} \ No newline at end of file +} diff --git a/Jellyfin.Server/Middleware/QueryStringDecodingMiddleware.cs b/Jellyfin.Server/Middleware/QueryStringDecodingMiddleware.cs index fd0ebbf43..cdd86e28e 100644 --- a/Jellyfin.Server/Middleware/QueryStringDecodingMiddleware.cs +++ b/Jellyfin.Server/Middleware/QueryStringDecodingMiddleware.cs @@ -27,7 +27,11 @@ namespace Jellyfin.Server.Middleware /// <returns>The async task.</returns> public async Task Invoke(HttpContext httpContext) { - httpContext.Features.Set<IQueryFeature>(new UrlDecodeQueryFeature(httpContext.Features.Get<IQueryFeature>())); + var feature = httpContext.Features.Get<IQueryFeature>(); + if (feature != null) + { + httpContext.Features.Set<IQueryFeature>(new UrlDecodeQueryFeature(feature)); + } await _next(httpContext).ConfigureAwait(false); } diff --git a/Jellyfin.Server/Middleware/ResponseTimeMiddleware.cs b/Jellyfin.Server/Middleware/ResponseTimeMiddleware.cs index 74874da1b..da9b69136 100644 --- a/Jellyfin.Server/Middleware/ResponseTimeMiddleware.cs +++ b/Jellyfin.Server/Middleware/ResponseTimeMiddleware.cs @@ -68,7 +68,7 @@ namespace Jellyfin.Server.Middleware if (_enableWarning && watch.ElapsedMilliseconds > _warningThreshold) { _logger.LogWarning( - "Slow HTTP Response from {url} to {remoteIp} in {elapsed:g} with Status Code {statusCode}", + "Slow HTTP Response from {Url} to {RemoteIp} in {Elapsed:g} with Status Code {StatusCode}", context.Request.GetDisplayUrl(), context.GetNormalizedRemoteIp(), watch.Elapsed, diff --git a/Jellyfin.Server/Middleware/RobotsRedirectionMiddleware.cs b/Jellyfin.Server/Middleware/RobotsRedirectionMiddleware.cs index 9d40d74fe..fabcd2da7 100644 --- a/Jellyfin.Server/Middleware/RobotsRedirectionMiddleware.cs +++ b/Jellyfin.Server/Middleware/RobotsRedirectionMiddleware.cs @@ -44,4 +44,4 @@ namespace Jellyfin.Server.Middleware await _next(httpContext).ConfigureAwait(false); } } -} \ No newline at end of file +} diff --git a/Jellyfin.Server/Middleware/UrlDecodeQueryFeature.cs b/Jellyfin.Server/Middleware/UrlDecodeQueryFeature.cs index c1f5b5dfa..2f1d79157 100644 --- a/Jellyfin.Server/Middleware/UrlDecodeQueryFeature.cs +++ b/Jellyfin.Server/Middleware/UrlDecodeQueryFeature.cs @@ -1,7 +1,6 @@ using System; using System.Collections.Generic; using System.Linq; -using System.Web; using Jellyfin.Extensions; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.Features; @@ -52,20 +51,14 @@ namespace Jellyfin.Server.Middleware return; } - // Unencode and re-parse querystring. - var unencodedKey = HttpUtility.UrlDecode(key); - - if (string.Equals(unencodedKey, key, StringComparison.Ordinal)) + if (!key.Contains('=', StringComparison.Ordinal)) { - // Don't do anything if it's not encoded. _store = value; return; } var pairs = new Dictionary<string, StringValues>(); - var queryString = unencodedKey.SpanSplit('&'); - - foreach (var pair in queryString) + foreach (var pair in key.SpanSplit('&')) { var i = pair.IndexOf('='); if (i == -1) diff --git a/Jellyfin.Server/Migrations/MigrationRunner.cs b/Jellyfin.Server/Migrations/MigrationRunner.cs index 7365c8dbc..e9a45c140 100644 --- a/Jellyfin.Server/Migrations/MigrationRunner.cs +++ b/Jellyfin.Server/Migrations/MigrationRunner.cs @@ -1,6 +1,11 @@ using System; +using System.Collections.Generic; +using System.IO; using System.Linq; +using Emby.Server.Implementations; +using Emby.Server.Implementations.Serialization; using MediaBrowser.Common.Configuration; +using MediaBrowser.Model.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; @@ -11,6 +16,14 @@ namespace Jellyfin.Server.Migrations /// </summary> public sealed class MigrationRunner { + /// <summary> + /// The list of known pre-startup migrations, in order of applicability. + /// </summary> + private static readonly Type[] _preStartupMigrationTypes = + { + typeof(PreStartupRoutines.CreateNetworkConfiguration) + }; + /// <summary> /// The list of known migrations, in order of applicability. /// </summary> @@ -41,17 +54,55 @@ namespace Jellyfin.Server.Migrations .Select(m => ActivatorUtilities.CreateInstance(host.ServiceProvider, m)) .OfType<IMigrationRoutine>() .ToArray(); - var migrationOptions = host.ConfigurationManager.GetConfiguration<MigrationOptions>(MigrationsListStore.StoreKey); - if (!host.ConfigurationManager.Configuration.IsStartupWizardCompleted && migrationOptions.Applied.Count == 0) + var migrationOptions = host.ConfigurationManager.GetConfiguration<MigrationOptions>(MigrationsListStore.StoreKey); + HandleStartupWizardCondition(migrations, migrationOptions, host.ConfigurationManager.Configuration.IsStartupWizardCompleted, logger); + PerformMigrations(migrations, migrationOptions, options => host.ConfigurationManager.SaveConfiguration(MigrationsListStore.StoreKey, options), logger); + } + + /// <summary> + /// Run all needed pre-startup migrations. + /// </summary> + /// <param name="appPaths">Application paths.</param> + /// <param name="loggerFactory">Factory for making the logger.</param> + public static void RunPreStartup(ServerApplicationPaths appPaths, ILoggerFactory loggerFactory) + { + var logger = loggerFactory.CreateLogger<MigrationRunner>(); + var migrations = _preStartupMigrationTypes + .Select(m => Activator.CreateInstance(m, appPaths, loggerFactory)) + .OfType<IMigrationRoutine>() + .ToArray(); + + var xmlSerializer = new MyXmlSerializer(); + var migrationConfigPath = Path.Join(appPaths.ConfigurationDirectoryPath, MigrationsListStore.StoreKey.ToLowerInvariant() + ".xml"); + var migrationOptions = File.Exists(migrationConfigPath) + ? (MigrationOptions)xmlSerializer.DeserializeFromFile(typeof(MigrationOptions), migrationConfigPath)! + : new MigrationOptions(); + + // We have to deserialize it manually since the configuration manager may overwrite it + var serverConfig = File.Exists(appPaths.SystemConfigurationFilePath) + ? (ServerConfiguration)xmlSerializer.DeserializeFromFile(typeof(ServerConfiguration), appPaths.SystemConfigurationFilePath)! + : new ServerConfiguration(); + + HandleStartupWizardCondition(migrations, migrationOptions, serverConfig.IsStartupWizardCompleted, logger); + PerformMigrations(migrations, migrationOptions, options => xmlSerializer.SerializeToFile(options, migrationConfigPath), logger); + } + + private static void HandleStartupWizardCondition(IEnumerable<IMigrationRoutine> migrations, MigrationOptions migrationOptions, bool isStartWizardCompleted, ILogger logger) + { + if (isStartWizardCompleted || migrationOptions.Applied.Count != 0) { - // If startup wizard is not finished, this is a fresh install. - // Don't run any migrations, just mark all of them as applied. - logger.LogInformation("Marking all known migrations as applied because this is a fresh install"); - migrationOptions.Applied.AddRange(migrations.Where(m => !m.PerformOnNewInstall).Select(m => (m.Id, m.Name))); - host.ConfigurationManager.SaveConfiguration(MigrationsListStore.StoreKey, migrationOptions); + return; } + // If startup wizard is not finished, this is a fresh install. + var onlyOldInstalls = migrations.Where(m => !m.PerformOnNewInstall).ToArray(); + logger.LogInformation("Marking following migrations as applied because this is a fresh install: {@OnlyOldInstalls}", onlyOldInstalls.Select(m => m.Name)); + migrationOptions.Applied.AddRange(onlyOldInstalls.Select(m => (m.Id, m.Name))); + } + + private static void PerformMigrations(IMigrationRoutine[] migrations, MigrationOptions migrationOptions, Action<MigrationOptions> saveConfiguration, ILogger logger) + { var appliedMigrationIds = migrationOptions.Applied.Select(m => m.Id).ToHashSet(); for (var i = 0; i < migrations.Length; i++) @@ -78,7 +129,7 @@ namespace Jellyfin.Server.Migrations // Mark the migration as completed logger.LogInformation("Migration '{Name}' applied successfully", migrationRoutine.Name); migrationOptions.Applied.Add((migrationRoutine.Id, migrationRoutine.Name)); - host.ConfigurationManager.SaveConfiguration(MigrationsListStore.StoreKey, migrationOptions); + saveConfiguration(migrationOptions); logger.LogDebug("Migration '{Name}' marked as applied in configuration.", migrationRoutine.Name); } } diff --git a/Jellyfin.Server/Migrations/PreStartupRoutines/CreateNetworkConfiguration.cs b/Jellyfin.Server/Migrations/PreStartupRoutines/CreateNetworkConfiguration.cs new file mode 100644 index 000000000..5e601ca84 --- /dev/null +++ b/Jellyfin.Server/Migrations/PreStartupRoutines/CreateNetworkConfiguration.cs @@ -0,0 +1,138 @@ +using System; +using System.IO; +using System.Xml; +using System.Xml.Serialization; +using Emby.Server.Implementations; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Server.Migrations.PreStartupRoutines; + +/// <inheritdoc /> +public class CreateNetworkConfiguration : IMigrationRoutine +{ + private readonly ServerApplicationPaths _applicationPaths; + private readonly ILogger<CreateNetworkConfiguration> _logger; + + /// <summary> + /// Initializes a new instance of the <see cref="CreateNetworkConfiguration"/> class. + /// </summary> + /// <param name="applicationPaths">An instance of <see cref="ServerApplicationPaths"/>.</param> + /// <param name="loggerFactory">An instance of the <see cref="ILoggerFactory"/> interface.</param> + public CreateNetworkConfiguration(ServerApplicationPaths applicationPaths, ILoggerFactory loggerFactory) + { + _applicationPaths = applicationPaths; + _logger = loggerFactory.CreateLogger<CreateNetworkConfiguration>(); + } + + /// <inheritdoc /> + public Guid Id => Guid.Parse("9B354818-94D5-4B68-AC49-E35CB85F9D84"); + + /// <inheritdoc /> + public string Name => nameof(CreateNetworkConfiguration); + + /// <inheritdoc /> + public bool PerformOnNewInstall => false; + + /// <inheritdoc /> + public void Perform() + { + string path = Path.Combine(_applicationPaths.ConfigurationDirectoryPath, "network.xml"); + if (File.Exists(path)) + { + _logger.LogDebug("Network configuration file already exists, skipping"); + return; + } + + var serverConfigSerializer = new XmlSerializer(typeof(OldNetworkConfiguration), new XmlRootAttribute("ServerConfiguration")); + using var xmlReader = XmlReader.Create(_applicationPaths.SystemConfigurationFilePath); + var networkSettings = serverConfigSerializer.Deserialize(xmlReader); + + var networkConfigSerializer = new XmlSerializer(typeof(OldNetworkConfiguration), new XmlRootAttribute("NetworkConfiguration")); + var xmlWriterSettings = new XmlWriterSettings { Indent = true }; + using var xmlWriter = XmlWriter.Create(path, xmlWriterSettings); + networkConfigSerializer.Serialize(xmlWriter, networkSettings); + } + +#pragma warning disable + public sealed class OldNetworkConfiguration + { + public const int DefaultHttpPort = 8096; + + public const int DefaultHttpsPort = 8920; + + private string _baseUrl = string.Empty; + + public bool RequireHttps { get; set; } + + public string CertificatePath { get; set; } = string.Empty; + + public string CertificatePassword { get; set; } = string.Empty; + + public string BaseUrl + { + get => _baseUrl; + set + { + // Normalize the start of the string + if (string.IsNullOrWhiteSpace(value)) + { + // If baseUrl is empty, set an empty prefix string + _baseUrl = string.Empty; + return; + } + + if (value[0] != '/') + { + // If baseUrl was not configured with a leading slash, append one for consistency + value = "/" + value; + } + + // Normalize the end of the string + if (value[^1] == '/') + { + // If baseUrl was configured with a trailing slash, remove it for consistency + value = value.Remove(value.Length - 1); + } + + _baseUrl = value; + } + } + + public int PublicHttpsPort { get; set; } = DefaultHttpsPort; + + public int HttpServerPortNumber { get; set; } = DefaultHttpPort; + + public int HttpsPortNumber { get; set; } = DefaultHttpsPort; + + public bool EnableHttps { get; set; } + + public int PublicPort { get; set; } = DefaultHttpPort; + + public bool EnableIPV6 { get; set; } + + public bool EnableIPV4 { get; set; } = true; + + public bool IgnoreVirtualInterfaces { get; set; } = true; + + public string VirtualInterfaceNames { get; set; } = "vEthernet*"; + + public bool TrustAllIP6Interfaces { get; set; } + + public string[] PublishedServerUriBySubnet { get; set; } = Array.Empty<string>(); + + public string[] RemoteIPFilter { get; set; } = Array.Empty<string>(); + + public bool IsRemoteIPFilterBlacklist { get; set; } + + public bool EnableUPnP { get; set; } + + public bool EnableRemoteAccess { get; set; } = true; + + public string[] LocalNetworkSubnets { get; set; } = Array.Empty<string>(); + + public string[] LocalNetworkAddresses { get; set; } = Array.Empty<string>(); + + public string[] KnownProxies { get; set; } = Array.Empty<string>(); + } +#pragma warning restore +} diff --git a/Jellyfin.Server/Migrations/Routines/MigrateDisplayPreferencesDb.cs b/Jellyfin.Server/Migrations/Routines/MigrateDisplayPreferencesDb.cs index 6ff59626d..74f2349f5 100644 --- a/Jellyfin.Server/Migrations/Routines/MigrateDisplayPreferencesDb.cs +++ b/Jellyfin.Server/Migrations/Routines/MigrateDisplayPreferencesDb.cs @@ -9,7 +9,7 @@ using Jellyfin.Data.Enums; using Jellyfin.Server.Implementations; using MediaBrowser.Controller; using MediaBrowser.Controller.Library; -using MediaBrowser.Model.Entities; +using MediaBrowser.Model.Dto; using Microsoft.Extensions.Logging; using SQLitePCL.pretty; @@ -114,13 +114,14 @@ namespace Jellyfin.Server.Migrations.Routines } var chromecastVersion = dto.CustomPrefs.TryGetValue("chromecastVersion", out var version) + && !string.IsNullOrEmpty(version) ? chromecastDict[version] : ChromecastVersion.Stable; dto.CustomPrefs.Remove("chromecastVersion"); var displayPreferences = new DisplayPreferences(dtoUserId, itemId, client) { - IndexBy = Enum.TryParse<IndexingKind>(dto.IndexBy, true, out var indexBy) ? indexBy : (IndexingKind?)null, + IndexBy = Enum.TryParse<IndexingKind>(dto.IndexBy, true, out var indexBy) ? indexBy : null, ShowBackdrop = dto.ShowBackdrop, ShowSidebar = dto.ShowSidebar, ScrollDirection = dto.ScrollDirection, diff --git a/Jellyfin.Server/Migrations/Routines/MigrateUserDb.cs b/Jellyfin.Server/Migrations/Routines/MigrateUserDb.cs index d9524645a..9b2d603c7 100644 --- a/Jellyfin.Server/Migrations/Routines/MigrateUserDb.cs +++ b/Jellyfin.Server/Migrations/Routines/MigrateUserDb.cs @@ -1,7 +1,6 @@ using System; using System.IO; using Emby.Server.Implementations.Data; -using Emby.Server.Implementations.Serialization; using Jellyfin.Data.Entities; using Jellyfin.Data.Enums; using Jellyfin.Extensions.Json; @@ -10,6 +9,7 @@ using Jellyfin.Server.Implementations.Users; using MediaBrowser.Controller; using MediaBrowser.Controller.Entities; using MediaBrowser.Model.Configuration; +using MediaBrowser.Model.Serialization; using MediaBrowser.Model.Users; using Microsoft.Extensions.Logging; using SQLitePCL.pretty; @@ -27,7 +27,7 @@ namespace Jellyfin.Server.Migrations.Routines private readonly ILogger<MigrateUserDb> _logger; private readonly IServerApplicationPaths _paths; private readonly JellyfinDbProvider _provider; - private readonly MyXmlSerializer _xmlSerializer; + private readonly IXmlSerializer _xmlSerializer; /// <summary> /// Initializes a new instance of the <see cref="MigrateUserDb"/> class. @@ -40,7 +40,7 @@ namespace Jellyfin.Server.Migrations.Routines ILogger<MigrateUserDb> logger, IServerApplicationPaths paths, JellyfinDbProvider provider, - MyXmlSerializer xmlSerializer) + IXmlSerializer xmlSerializer) { _logger = logger; _paths = paths; diff --git a/Jellyfin.Server/Program.cs b/Jellyfin.Server/Program.cs index 3c0ee069d..f40526e22 100644 --- a/Jellyfin.Server/Program.cs +++ b/Jellyfin.Server/Program.cs @@ -10,7 +10,6 @@ using System.Threading; using System.Threading.Tasks; using CommandLine; using Emby.Server.Implementations; -using Emby.Server.Implementations.IO; using Jellyfin.Server.Implementations; using MediaBrowser.Common.Configuration; using MediaBrowser.Common.Net; @@ -157,34 +156,37 @@ namespace Jellyfin.Server ApplicationHost.LogEnvironmentInfo(_logger, appPaths); + // If hosting the web client, validate the client content path + if (startupConfig.HostWebClient()) + { + string? webContentPath = appPaths.WebPath; + if (!Directory.Exists(webContentPath) || !Directory.EnumerateFiles(webContentPath).Any()) + { + _logger.LogError( + "The server is expected to host the web client, but the provided content directory is either " + + "invalid or empty: {WebContentPath}. If you do not want to host the web client with the " + + "server, you may set the '--nowebclient' command line flag, or set" + + "'{ConfigKey}=false' in your config settings.", + webContentPath, + ConfigurationExtensions.HostWebClientKey); + Environment.ExitCode = 1; + return; + } + } + PerformStaticInitialization(); - var serviceCollection = new ServiceCollection(); + Migrations.MigrationRunner.RunPreStartup(appPaths, _loggerFactory); var appHost = new CoreAppHost( appPaths, _loggerFactory, options, - startupConfig, - new ManagedFileSystem(_loggerFactory.CreateLogger<ManagedFileSystem>(), appPaths), - serviceCollection); + startupConfig); try { - // If hosting the web client, validate the client content path - if (startupConfig.HostWebClient()) - { - string? webContentPath = appHost.ConfigurationManager.ApplicationPaths.WebPath; - if (!Directory.Exists(webContentPath) || Directory.GetFiles(webContentPath).Length == 0) - { - throw new InvalidOperationException( - "The server is expected to host the web client, but the provided content directory is either " + - $"invalid or empty: {webContentPath}. If you do not want to host the web client with the " + - "server, you may set the '--nowebclient' command line flag, or set" + - $"'{ConfigurationExtensions.HostWebClientKey}=false' in your config settings."); - } - } - - appHost.Init(); + var serviceCollection = new ServiceCollection(); + appHost.Init(serviceCollection); var webHost = new WebHostBuilder().ConfigureWebHostBuilder(appHost, serviceCollection, options, startupConfig, appPaths).Build(); @@ -195,9 +197,9 @@ namespace Jellyfin.Server try { - await webHost.StartAsync().ConfigureAwait(false); + await webHost.StartAsync(_tokenSource.Token).ConfigureAwait(false); } - catch + catch (Exception ex) when (ex is not TaskCanceledException) { _logger.LogError("Kestrel failed to start! This is most likely due to an invalid address or port bind - correct your bind configuration in network.xml and try again."); throw; @@ -547,7 +549,7 @@ namespace Jellyfin.Server ?? throw new InvalidOperationException($"Invalid resource path: '{ResourcePath}'"); // Copy the resource contents to the expected file path for the config file - await using Stream dst = new FileStream(configPath, FileMode.CreateNew, FileAccess.Write, FileShare.None, IODefaults.FileStreamBufferSize, AsyncFile.UseAsyncIO); + await using Stream dst = new FileStream(configPath, FileMode.CreateNew, FileAccess.Write, FileShare.None, IODefaults.FileStreamBufferSize, FileOptions.Asynchronous); await resource.CopyToAsync(dst).ConfigureAwait(false); } @@ -594,7 +596,7 @@ namespace Jellyfin.Server try { // Serilog.Log is used by SerilogLoggerFactory when no logger is specified - Serilog.Log.Logger = new LoggerConfiguration() + Log.Logger = new LoggerConfiguration() .ReadFrom.Configuration(configuration) .Enrich.FromLogContext() .Enrich.WithThreadId() @@ -602,7 +604,7 @@ namespace Jellyfin.Server } catch (Exception ex) { - Serilog.Log.Logger = new LoggerConfiguration() + Log.Logger = new LoggerConfiguration() .WriteTo.Console(outputTemplate: "[{Timestamp:HH:mm:ss}] [{Level:u3}] [{ThreadId}] {SourceContext}: {Message:lj}{NewLine}{Exception}") .WriteTo.Async(x => x.File( Path.Combine(appPaths.LogDirectoryPath, "log_.log"), @@ -613,7 +615,7 @@ namespace Jellyfin.Server .Enrich.WithThreadId() .CreateLogger(); - Serilog.Log.Logger.Fatal(ex, "Failed to create/read logger configuration"); + Log.Logger.Fatal(ex, "Failed to create/read logger configuration"); } } @@ -648,7 +650,7 @@ namespace Jellyfin.Server private static string NormalizeCommandLineArgument(string arg) { - if (!arg.Contains(" ", StringComparison.OrdinalIgnoreCase)) + if (!arg.Contains(' ', StringComparison.Ordinal)) { return arg; } diff --git a/Jellyfin.Server/Startup.cs b/Jellyfin.Server/Startup.cs index 60cdc2f6f..8085c2630 100644 --- a/Jellyfin.Server/Startup.cs +++ b/Jellyfin.Server/Startup.cs @@ -7,6 +7,7 @@ using System.Text; using Jellyfin.Networking.Configuration; using Jellyfin.Server.Extensions; using Jellyfin.Server.Implementations; +using Jellyfin.Server.Infrastructure; using Jellyfin.Server.Middleware; using MediaBrowser.Common.Net; using MediaBrowser.Controller; @@ -14,6 +15,8 @@ using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Extensions; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Infrastructure; using Microsoft.AspNetCore.StaticFiles; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; @@ -56,6 +59,9 @@ namespace Jellyfin.Server { options.HttpsPort = _serverApplicationHost.HttpsPort; }); + + // TODO remove once this is fixed upstream https://github.com/dotnet/aspnetcore/issues/34371 + services.AddSingleton<IActionResultExecutor<PhysicalFileResult>, SymlinkFollowingPhysicalFileResultExecutor>(); services.AddJellyfinApi(_serverApplicationHost.GetApiPluginAssemblies(), _serverConfigurationManager.GetNetworkConfiguration()); services.AddJellyfinApiSwagger(); diff --git a/MediaBrowser.Common/Cryptography/CryptoExtensions.cs b/MediaBrowser.Common/Cryptography/CryptoExtensions.cs deleted file mode 100644 index 157b0ed10..000000000 --- a/MediaBrowser.Common/Cryptography/CryptoExtensions.cs +++ /dev/null @@ -1,35 +0,0 @@ -using System.Collections.Generic; -using System.Globalization; -using System.Text; -using MediaBrowser.Model.Cryptography; -using static MediaBrowser.Common.Cryptography.Constants; - -namespace MediaBrowser.Common.Cryptography -{ - /// <summary> - /// Class containing extension methods for working with Jellyfin cryptography objects. - /// </summary> - public static class CryptoExtensions - { - /// <summary> - /// Creates a new <see cref="PasswordHash" /> instance. - /// </summary> - /// <param name="cryptoProvider">The <see cref="ICryptoProvider" /> instance used.</param> - /// <param name="password">The password that will be hashed.</param> - /// <returns>A <see cref="PasswordHash" /> instance with the hash method, hash, salt and number of iterations.</returns> - public static PasswordHash CreatePasswordHash(this ICryptoProvider cryptoProvider, string password) - { - byte[] salt = cryptoProvider.GenerateSalt(); - return new PasswordHash( - cryptoProvider.DefaultHashMethod, - cryptoProvider.ComputeHashWithDefaultMethod( - Encoding.UTF8.GetBytes(password), - salt), - salt, - new Dictionary<string, string> - { - { "iterations", DefaultIterations.ToString(CultureInfo.InvariantCulture) } - }); - } - } -} diff --git a/MediaBrowser.MediaEncoding/FfmpegException.cs b/MediaBrowser.Common/FfmpegException.cs similarity index 97% rename from MediaBrowser.MediaEncoding/FfmpegException.cs rename to MediaBrowser.Common/FfmpegException.cs index 1697fd33a..be420196d 100644 --- a/MediaBrowser.MediaEncoding/FfmpegException.cs +++ b/MediaBrowser.Common/FfmpegException.cs @@ -1,6 +1,6 @@ using System; -namespace MediaBrowser.MediaEncoding +namespace MediaBrowser.Common { /// <summary> /// Represents errors that occur during interaction with FFmpeg. diff --git a/MediaBrowser.Common/IApplicationHost.cs b/MediaBrowser.Common/IApplicationHost.cs index 192a77611..53683cdbd 100644 --- a/MediaBrowser.Common/IApplicationHost.cs +++ b/MediaBrowser.Common/IApplicationHost.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using System.Reflection; using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; namespace MediaBrowser.Common { @@ -137,13 +138,7 @@ namespace MediaBrowser.Common /// <summary> /// Initializes this instance. /// </summary> - void Init(); - - /// <summary> - /// Creates the instance. - /// </summary> - /// <param name="type">The type.</param> - /// <returns>System.Object.</returns> - object CreateInstance(Type type); + /// <param name="serviceCollection">Instance of the <see cref="IServiceCollection"/> interface.</param> + void Init(IServiceCollection serviceCollection); } } diff --git a/MediaBrowser.Common/MediaBrowser.Common.csproj b/MediaBrowser.Common/MediaBrowser.Common.csproj index 12cfaf978..2a2fffce0 100644 --- a/MediaBrowser.Common/MediaBrowser.Common.csproj +++ b/MediaBrowser.Common/MediaBrowser.Common.csproj @@ -19,9 +19,9 @@ </ItemGroup> <ItemGroup> - <PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="5.0.0" /> - <PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="5.0.0" /> - <PackageReference Include="Microsoft.SourceLink.GitHub" Version="1.0.0" PrivateAssets="All" /> + <PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="6.0.0" /> + <PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="6.0.0" /> + <PackageReference Include="Microsoft.SourceLink.GitHub" Version="1.1.1" PrivateAssets="All" /> </ItemGroup> <ItemGroup> @@ -29,7 +29,7 @@ </ItemGroup> <PropertyGroup> - <TargetFramework>net5.0</TargetFramework> + <TargetFramework>net6.0</TargetFramework> <GenerateAssemblyInfo>false</GenerateAssemblyInfo> <GenerateDocumentationFile>true</GenerateDocumentationFile> <PublishRepositoryUrl>true</PublishRepositoryUrl> @@ -38,6 +38,10 @@ <SymbolPackageFormat>snupkg</SymbolPackageFormat> </PropertyGroup> + <PropertyGroup Condition=" '$(Configuration)' == 'Debug' "> + <TreatWarningsAsErrors>false</TreatWarningsAsErrors> + </PropertyGroup> + <PropertyGroup Condition=" '$(Stability)'=='Unstable'"> <!-- Include all symbols in the main nupkg until Azure Artifact Feed starts supporting ingesting NuGet symbol packages. --> <AllowedOutputExtensionsInPackageBuildOutputFolder>$(AllowedOutputExtensionsInPackageBuildOutputFolder);.pdb</AllowedOutputExtensionsInPackageBuildOutputFolder> @@ -46,7 +50,7 @@ <!-- Code analyzers--> <ItemGroup Condition=" '$(Configuration)' == 'Debug' "> <PackageReference Include="SerilogAnalyzer" Version="0.15.0" PrivateAssets="All" /> - <PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="All" /> + <PackageReference Include="StyleCop.Analyzers" Version="1.2.0-beta.376" PrivateAssets="All" /> <PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" PrivateAssets="All" /> </ItemGroup> diff --git a/MediaBrowser.Common/Net/IPNetAddress.cs b/MediaBrowser.Common/Net/IPNetAddress.cs index f6e3971bf..f1428d4be 100644 --- a/MediaBrowser.Common/Net/IPNetAddress.cs +++ b/MediaBrowser.Common/Net/IPNetAddress.cs @@ -195,7 +195,7 @@ namespace MediaBrowser.Common.Net return NetworkAddress.PrefixLength <= netaddrObj.PrefixLength; } - var altAddress = NetworkAddressOf(netaddrObj.Address, PrefixLength).address; + var altAddress = NetworkAddressOf(netaddrObj.Address, PrefixLength).Address; return NetworkAddress.Address.Equals(altAddress); } diff --git a/MediaBrowser.Common/Net/IPObject.cs b/MediaBrowser.Common/Net/IPObject.cs index 2612268fd..bd5368882 100644 --- a/MediaBrowser.Common/Net/IPObject.cs +++ b/MediaBrowser.Common/Net/IPObject.cs @@ -53,7 +53,7 @@ namespace MediaBrowser.Common.Net /// <param name="address">IP Address to convert.</param> /// <param name="prefixLength">Subnet prefix.</param> /// <returns>IPAddress.</returns> - public static (IPAddress address, byte prefixLength) NetworkAddressOf(IPAddress address, byte prefixLength) + public static (IPAddress Address, byte PrefixLength) NetworkAddressOf(IPAddress address, byte prefixLength) { if (address == null) { diff --git a/MediaBrowser.Controller/BaseItemManager/BaseItemManager.cs b/MediaBrowser.Controller/BaseItemManager/BaseItemManager.cs index abfdb41d8..d273b54fc 100644 --- a/MediaBrowser.Controller/BaseItemManager/BaseItemManager.cs +++ b/MediaBrowser.Controller/BaseItemManager/BaseItemManager.cs @@ -55,12 +55,7 @@ namespace MediaBrowser.Controller.BaseItemManager return typeOptions.MetadataFetchers.Contains(name.AsSpan(), StringComparison.OrdinalIgnoreCase); } - if (!libraryOptions.EnableInternetProviders) - { - return false; - } - - var itemConfig = _serverConfigurationManager.Configuration.MetadataOptions.FirstOrDefault(i => string.Equals(i.ItemType, GetType().Name, StringComparison.OrdinalIgnoreCase)); + var itemConfig = _serverConfigurationManager.Configuration.MetadataOptions.FirstOrDefault(i => string.Equals(i.ItemType, baseItem.GetType().Name, StringComparison.OrdinalIgnoreCase)); return itemConfig == null || !itemConfig.DisabledMetadataFetchers.Contains(name.AsSpan(), StringComparison.OrdinalIgnoreCase); } @@ -86,12 +81,7 @@ namespace MediaBrowser.Controller.BaseItemManager return typeOptions.ImageFetchers.Contains(name.AsSpan(), StringComparison.OrdinalIgnoreCase); } - if (!libraryOptions.EnableInternetProviders) - { - return false; - } - - var itemConfig = _serverConfigurationManager.Configuration.MetadataOptions.FirstOrDefault(i => string.Equals(i.ItemType, GetType().Name, StringComparison.OrdinalIgnoreCase)); + var itemConfig = _serverConfigurationManager.Configuration.MetadataOptions.FirstOrDefault(i => string.Equals(i.ItemType, baseItem.GetType().Name, StringComparison.OrdinalIgnoreCase)); return itemConfig == null || !itemConfig.DisabledImageFetchers.Contains(name.AsSpan(), StringComparison.OrdinalIgnoreCase); } diff --git a/MediaBrowser.Controller/Channels/ChannelLatestMediaSearch.cs b/MediaBrowser.Controller/Channels/ChannelLatestMediaSearch.cs index 6f0761e64..e02f42fa4 100644 --- a/MediaBrowser.Controller/Channels/ChannelLatestMediaSearch.cs +++ b/MediaBrowser.Controller/Channels/ChannelLatestMediaSearch.cs @@ -8,4 +8,4 @@ namespace MediaBrowser.Controller.Channels { public string UserId { get; set; } } -} \ No newline at end of file +} diff --git a/MediaBrowser.Controller/Channels/IHasFolderAttributes.cs b/MediaBrowser.Controller/Channels/IHasFolderAttributes.cs index 64af8496c..6c92785d2 100644 --- a/MediaBrowser.Controller/Channels/IHasFolderAttributes.cs +++ b/MediaBrowser.Controller/Channels/IHasFolderAttributes.cs @@ -6,4 +6,4 @@ namespace MediaBrowser.Controller.Channels { string[] Attributes { get; } } -} \ No newline at end of file +} diff --git a/MediaBrowser.Controller/Channels/ISupportsDelete.cs b/MediaBrowser.Controller/Channels/ISupportsDelete.cs index 204054374..30798a4b2 100644 --- a/MediaBrowser.Controller/Channels/ISupportsDelete.cs +++ b/MediaBrowser.Controller/Channels/ISupportsDelete.cs @@ -12,4 +12,4 @@ namespace MediaBrowser.Controller.Channels Task DeleteItem(string id, CancellationToken cancellationToken); } -} \ No newline at end of file +} diff --git a/MediaBrowser.Controller/Channels/ISupportsLatestMedia.cs b/MediaBrowser.Controller/Channels/ISupportsLatestMedia.cs index dbba7cba2..8ad93387e 100644 --- a/MediaBrowser.Controller/Channels/ISupportsLatestMedia.cs +++ b/MediaBrowser.Controller/Channels/ISupportsLatestMedia.cs @@ -18,4 +18,4 @@ namespace MediaBrowser.Controller.Channels /// <returns>The latest media.</returns> Task<IEnumerable<ChannelItemInfo>> GetLatestMedia(ChannelLatestMediaSearch request, CancellationToken cancellationToken); } -} \ No newline at end of file +} diff --git a/MediaBrowser.Controller/ClientEvent/ClientEventLogger.cs b/MediaBrowser.Controller/ClientEvent/ClientEventLogger.cs new file mode 100644 index 000000000..dea1c2f32 --- /dev/null +++ b/MediaBrowser.Controller/ClientEvent/ClientEventLogger.cs @@ -0,0 +1,31 @@ +using System; +using System.IO; +using System.Threading.Tasks; + +namespace MediaBrowser.Controller.ClientEvent +{ + /// <inheritdoc /> + public class ClientEventLogger : IClientEventLogger + { + private readonly IServerApplicationPaths _applicationPaths; + + /// <summary> + /// Initializes a new instance of the <see cref="ClientEventLogger"/> class. + /// </summary> + /// <param name="applicationPaths">Instance of the <see cref="IServerApplicationPaths"/> interface.</param> + public ClientEventLogger(IServerApplicationPaths applicationPaths) + { + _applicationPaths = applicationPaths; + } + + /// <inheritdoc /> + public async Task<string> WriteDocumentAsync(string clientName, string clientVersion, Stream fileContents) + { + var fileName = $"upload_{clientName}_{clientVersion}_{DateTime.UtcNow:yyyyMMddHHmmss}_{Guid.NewGuid():N}.log"; + var logFilePath = Path.Combine(_applicationPaths.LogDirectoryPath, fileName); + await using var fileStream = new FileStream(logFilePath, FileMode.CreateNew, FileAccess.Write, FileShare.None); + await fileContents.CopyToAsync(fileStream).ConfigureAwait(false); + return fileName; + } + } +} diff --git a/MediaBrowser.Controller/ClientEvent/IClientEventLogger.cs b/MediaBrowser.Controller/ClientEvent/IClientEventLogger.cs new file mode 100644 index 000000000..ad8a1bd24 --- /dev/null +++ b/MediaBrowser.Controller/ClientEvent/IClientEventLogger.cs @@ -0,0 +1,23 @@ +using System.IO; +using System.Threading.Tasks; + +namespace MediaBrowser.Controller.ClientEvent +{ + /// <summary> + /// The client event logger. + /// </summary> + public interface IClientEventLogger + { + /// <summary> + /// Writes a file to the log directory. + /// </summary> + /// <param name="clientName">The client name writing the document.</param> + /// <param name="clientVersion">The client version writing the document.</param> + /// <param name="fileContents">The file contents to write.</param> + /// <returns>The created file name.</returns> + Task<string> WriteDocumentAsync( + string clientName, + string clientVersion, + Stream fileContents); + } +} diff --git a/MediaBrowser.Controller/Collections/CollectionCreatedEventArgs.cs b/MediaBrowser.Controller/Collections/CollectionCreatedEventArgs.cs index 82b3a4977..1797d15ea 100644 --- a/MediaBrowser.Controller/Collections/CollectionCreatedEventArgs.cs +++ b/MediaBrowser.Controller/Collections/CollectionCreatedEventArgs.cs @@ -21,4 +21,4 @@ namespace MediaBrowser.Controller.Collections /// <value>The options.</value> public CollectionCreationOptions Options { get; set; } } -} \ No newline at end of file +} diff --git a/MediaBrowser.Controller/Dlna/IDlnaManager.cs b/MediaBrowser.Controller/Dlna/IDlnaManager.cs index a64919700..06da5ea09 100644 --- a/MediaBrowser.Controller/Dlna/IDlnaManager.cs +++ b/MediaBrowser.Controller/Dlna/IDlnaManager.cs @@ -37,8 +37,9 @@ namespace MediaBrowser.Controller.Dlna /// <summary> /// Updates the profile. /// </summary> + /// <param name="profileId">The profile id.</param> /// <param name="profile">The profile.</param> - void UpdateProfile(DeviceProfile profile); + void UpdateProfile(string profileId, DeviceProfile profile); /// <summary> /// Deletes the profile. @@ -74,6 +75,6 @@ namespace MediaBrowser.Controller.Dlna /// </summary> /// <param name="filename">The filename.</param> /// <returns>DlnaIconResponse.</returns> - ImageStream GetIcon(string filename); + ImageStream? GetIcon(string filename); } } diff --git a/MediaBrowser.Controller/Drawing/IImageProcessor.cs b/MediaBrowser.Controller/Drawing/IImageProcessor.cs index c7f61a90b..03882a0b9 100644 --- a/MediaBrowser.Controller/Drawing/IImageProcessor.cs +++ b/MediaBrowser.Controller/Drawing/IImageProcessor.cs @@ -58,7 +58,7 @@ namespace MediaBrowser.Controller.Drawing /// <returns>Guid.</returns> string GetImageCacheTag(BaseItem item, ItemImageInfo image); - string GetImageCacheTag(BaseItem item, ChapterInfo info); + string GetImageCacheTag(BaseItem item, ChapterInfo chapter); string? GetImageCacheTag(User user); @@ -75,7 +75,7 @@ namespace MediaBrowser.Controller.Drawing /// </summary> /// <param name="options">The options.</param> /// <returns>Task.</returns> - Task<(string path, string? mimeType, DateTime dateModified)> ProcessImage(ImageProcessingOptions options); + Task<(string Path, string? MimeType, DateTime DateModified)> ProcessImage(ImageProcessingOptions options); /// <summary> /// Gets the supported image output formats. diff --git a/MediaBrowser.Controller/Drawing/ImageStream.cs b/MediaBrowser.Controller/Drawing/ImageStream.cs index 5d552170f..f4c305799 100644 --- a/MediaBrowser.Controller/Drawing/ImageStream.cs +++ b/MediaBrowser.Controller/Drawing/ImageStream.cs @@ -8,11 +8,16 @@ namespace MediaBrowser.Controller.Drawing { public class ImageStream : IDisposable { + public ImageStream(Stream stream) + { + Stream = stream; + } + /// <summary> - /// Gets or sets the stream. + /// Gets the stream. /// </summary> /// <value>The stream.</value> - public Stream? Stream { get; set; } + public Stream Stream { get; } /// <summary> /// Gets or sets the format. diff --git a/MediaBrowser.Controller/Entities/Audio/Audio.cs b/MediaBrowser.Controller/Entities/Audio/Audio.cs index 536668e50..29f7bf92b 100644 --- a/MediaBrowser.Controller/Entities/Audio/Audio.cs +++ b/MediaBrowser.Controller/Entities/Audio/Audio.cs @@ -8,10 +8,8 @@ using System.Globalization; using System.Linq; using System.Text.Json.Serialization; using Jellyfin.Data.Enums; -using MediaBrowser.Controller.Persistence; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Dto; -using MediaBrowser.Model.Entities; namespace MediaBrowser.Controller.Entities.Audio { @@ -126,15 +124,6 @@ namespace MediaBrowser.Controller.Entities.Audio return base.GetBlockUnratedType(); } - public List<MediaStream> GetMediaStreams(MediaStreamType type) - { - return MediaSourceManager.GetMediaStreams(new MediaStreamQuery - { - ItemId = Id, - Type = type - }); - } - public SongInfo GetLookupInfo() { var info = GetItemLookupInfo<SongInfo>(); @@ -146,11 +135,7 @@ namespace MediaBrowser.Controller.Entities.Audio return info; } - protected override List<Tuple<BaseItem, MediaSourceType>> GetAllItemsForMediaSources() - { - var list = new List<Tuple<BaseItem, MediaSourceType>>(); - list.Add(new Tuple<BaseItem, MediaSourceType>(this, MediaSourceType.Default)); - return list; - } + protected override IEnumerable<(BaseItem Item, MediaSourceType MediaSourceType)> GetAllItemsForMediaSources() + => new[] { ((BaseItem)this, MediaSourceType.Default) }; } } diff --git a/MediaBrowser.Controller/Entities/Audio/MusicArtist.cs b/MediaBrowser.Controller/Entities/Audio/MusicArtist.cs index f30f8ce7f..11b95b94b 100644 --- a/MediaBrowser.Controller/Entities/Audio/MusicArtist.cs +++ b/MediaBrowser.Controller/Entities/Audio/MusicArtist.cs @@ -88,7 +88,7 @@ namespace MediaBrowser.Controller.Entities.Audio { if (query.IncludeItemTypes.Length == 0) { - query.IncludeItemTypes = new[] { nameof(Audio), nameof(MusicVideo), nameof(MusicAlbum) }; + query.IncludeItemTypes = new[] { BaseItemKind.Audio, BaseItemKind.MusicVideo, BaseItemKind.MusicAlbum }; query.ArtistIds = new[] { Id }; } diff --git a/MediaBrowser.Controller/Entities/Audio/MusicGenre.cs b/MediaBrowser.Controller/Entities/Audio/MusicGenre.cs index dc6fcc55a..73a25232e 100644 --- a/MediaBrowser.Controller/Entities/Audio/MusicGenre.cs +++ b/MediaBrowser.Controller/Entities/Audio/MusicGenre.cs @@ -6,6 +6,7 @@ using System; using System.Collections.Generic; using System.Text.Json.Serialization; using Diacritics.Extensions; +using Jellyfin.Data.Enums; using Microsoft.Extensions.Logging; namespace MediaBrowser.Controller.Entities.Audio @@ -66,7 +67,7 @@ namespace MediaBrowser.Controller.Entities.Audio public IList<BaseItem> GetTaggedItems(InternalItemsQuery query) { query.GenreIds = new[] { Id }; - query.IncludeItemTypes = new[] { nameof(MusicVideo), nameof(Audio), nameof(MusicAlbum), nameof(MusicArtist) }; + query.IncludeItemTypes = new[] { BaseItemKind.MusicVideo, BaseItemKind.Audio, BaseItemKind.MusicAlbum, BaseItemKind.MusicArtist }; return LibraryManager.GetItemList(query); } diff --git a/MediaBrowser.Controller/Entities/BaseItem.cs b/MediaBrowser.Controller/Entities/BaseItem.cs index f4c91973b..915971adc 100644 --- a/MediaBrowser.Controller/Entities/BaseItem.cs +++ b/MediaBrowser.Controller/Entities/BaseItem.cs @@ -5,7 +5,6 @@ using System; using System.Collections.Generic; using System.Globalization; -using System.IO; using System.Linq; using System.Text; using System.Text.Json.Serialization; @@ -23,7 +22,6 @@ using MediaBrowser.Controller.Entities.Audio; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Persistence; using MediaBrowser.Controller.Providers; -using MediaBrowser.Model.Configuration; using MediaBrowser.Model.Dto; using MediaBrowser.Model.Entities; using MediaBrowser.Model.Globalization; @@ -41,21 +39,9 @@ namespace MediaBrowser.Controller.Entities /// </summary> public abstract class BaseItem : IHasProviderIds, IHasLookupInfo<ItemLookupInfo>, IEquatable<BaseItem> { - /// <summary> - /// The trailer folder name. - /// </summary> - public const string TrailerFolderName = "trailers"; - public const string ThemeSongsFolderName = "theme-music"; - public const string ThemeSongFilename = "theme"; - public const string ThemeVideosFolderName = "backdrops"; - public const string ExtrasFolderName = "extras"; - public const string BehindTheScenesFolderName = "behind the scenes"; - public const string DeletedScenesFolderName = "deleted scenes"; - public const string InterviewFolderName = "interviews"; - public const string SceneFolderName = "scenes"; - public const string SampleFolderName = "samples"; - public const string ShortsFolderName = "shorts"; - public const string FeaturettesFolderName = "featurettes"; + private BaseItemKind? _baseItemKind; + + public const string ThemeSongFileName = "theme"; /// <summary> /// The supported image extensions. @@ -92,22 +78,7 @@ namespace MediaBrowser.Controller.Entities Model.Entities.ExtraType.Scene }; - public static readonly char[] SlugReplaceChars = { '?', '/', '&' }; - public static readonly string[] AllExtrasTypesFolderNames = - { - ExtrasFolderName, - BehindTheScenesFolderName, - DeletedScenesFolderName, - InterviewFolderName, - SceneFolderName, - SampleFolderName, - ShortsFolderName, - FeaturettesFolderName - }; - private string _sortName; - private Guid[] _themeSongIds; - private Guid[] _themeVideoIds; private string _forcedSortName; @@ -128,40 +99,6 @@ namespace MediaBrowser.Controller.Entities ExtraIds = Array.Empty<Guid>(); } - [JsonIgnore] - public Guid[] ThemeSongIds - { - get - { - return _themeSongIds ??= GetExtras() - .Where(extra => extra.ExtraType == Model.Entities.ExtraType.ThemeSong) - .Select(song => song.Id) - .ToArray(); - } - - private set - { - _themeSongIds = value; - } - } - - [JsonIgnore] - public Guid[] ThemeVideoIds - { - get - { - return _themeVideoIds ??= GetExtras() - .Where(extra => extra.ExtraType == Model.Entities.ExtraType.ThemeVideo) - .Select(song => song.Id) - .ToArray(); - } - - private set - { - _themeVideoIds = value; - } - } - [JsonIgnore] public string PreferredMetadataCountryCode { get; set; } @@ -339,13 +276,6 @@ namespace MediaBrowser.Controller.Entities [JsonIgnore] public string ExternalSeriesId { get; set; } - /// <summary> - /// Gets or sets the etag. - /// </summary> - /// <value>The etag.</value> - [JsonIgnore] - public string ExternalEtag { get; set; } - [JsonIgnore] public virtual bool IsHidden => false; @@ -358,11 +288,6 @@ namespace MediaBrowser.Controller.Entities { get { - // if (IsOffline) - // { - // return LocationType.Offline; - // } - var path = Path; if (string.IsNullOrEmpty(path)) { @@ -395,7 +320,7 @@ namespace MediaBrowser.Controller.Entities } [JsonIgnore] - public bool IsFileProtocol => IsPathProtocol(MediaProtocol.File); + public bool IsFileProtocol => PathProtocol == MediaProtocol.File; [JsonIgnore] public bool HasPathProtocol => PathProtocol.HasValue; @@ -587,14 +512,7 @@ namespace MediaBrowser.Controller.Entities } [JsonIgnore] - public virtual Guid DisplayParentId - { - get - { - var parentId = ParentId; - return parentId; - } - } + public virtual Guid DisplayParentId => ParentId; [JsonIgnore] public BaseItem DisplayParent @@ -857,13 +775,6 @@ namespace MediaBrowser.Controller.Entities return Id.ToString("N", CultureInfo.InvariantCulture); } - public bool IsPathProtocol(MediaProtocol protocol) - { - var current = PathProtocol; - - return current.HasValue && current.Value == protocol; - } - private List<Tuple<StringBuilder, bool>> GetSortChunks(string s1) { var list = new List<Tuple<StringBuilder, bool>>(); @@ -991,7 +902,7 @@ namespace MediaBrowser.Controller.Entities ReadOnlySpan<char> idString = Id.ToString("N", CultureInfo.InvariantCulture); - return System.IO.Path.Join(basePath, "library", idString.Slice(0, 2), idString); + return System.IO.Path.Join(basePath, "library", idString[..2], idString); } /// <summary> @@ -1158,7 +1069,7 @@ namespace MediaBrowser.Controller.Entities } var list = GetAllItemsForMediaSources(); - var result = list.Select(i => GetVersionInfo(enablePathSubstitution, i.Item1, i.Item2)).ToList(); + var result = list.Select(i => GetVersionInfo(enablePathSubstitution, i.Item, i.MediaSourceType)).ToList(); if (IsActiveRecording()) { @@ -1186,9 +1097,9 @@ namespace MediaBrowser.Controller.Entities .ToList(); } - protected virtual List<Tuple<BaseItem, MediaSourceType>> GetAllItemsForMediaSources() + protected virtual IEnumerable<(BaseItem Item, MediaSourceType MediaSourceType)> GetAllItemsForMediaSources() { - return new List<Tuple<BaseItem, MediaSourceType>>(); + return Enumerable.Empty<(BaseItem, MediaSourceType)>(); } private MediaSourceInfo GetVersionInfo(bool enablePathSubstitution, BaseItem item, MediaSourceType type) @@ -1306,8 +1217,7 @@ namespace MediaBrowser.Controller.Entities terms.Add(item.Name); } - var video = item as Video; - if (video != null) + if (item is Video video) { if (video.Video3DFormat.HasValue) { @@ -1342,114 +1252,7 @@ namespace MediaBrowser.Controller.Entities } } - return string.Join('/', terms.ToArray()); - } - - /// <summary> - /// Loads the theme songs. - /// </summary> - /// <returns>List{Audio.Audio}.</returns> - private static Audio.Audio[] LoadThemeSongs(List<FileSystemMetadata> fileSystemChildren, IDirectoryService directoryService) - { - var files = fileSystemChildren.Where(i => i.IsDirectory) - .Where(i => string.Equals(i.Name, ThemeSongsFolderName, StringComparison.OrdinalIgnoreCase)) - .SelectMany(i => FileSystem.GetFiles(i.FullName)) - .ToList(); - - // Support plex/xbmc convention - files.AddRange(fileSystemChildren - .Where(i => !i.IsDirectory && System.IO.Path.GetFileNameWithoutExtension(i.FullName.AsSpan()).Equals(ThemeSongFilename, StringComparison.OrdinalIgnoreCase))); - - return LibraryManager.ResolvePaths(files, directoryService, null, new LibraryOptions()) - .OfType<Audio.Audio>() - .Select(audio => - { - // Try to retrieve it from the db. If we don't find it, use the resolved version - var dbItem = LibraryManager.GetItemById(audio.Id) as Audio.Audio; - - if (dbItem != null) - { - audio = dbItem; - } - else - { - // item is new - audio.ExtraType = MediaBrowser.Model.Entities.ExtraType.ThemeSong; - } - - return audio; - - // Sort them so that the list can be easily compared for changes - }).OrderBy(i => i.Path).ToArray(); - } - - /// <summary> - /// Loads the video backdrops. - /// </summary> - /// <returns>List{Video}.</returns> - private static Video[] LoadThemeVideos(IEnumerable<FileSystemMetadata> fileSystemChildren, IDirectoryService directoryService) - { - var files = fileSystemChildren.Where(i => i.IsDirectory) - .Where(i => string.Equals(i.Name, ThemeVideosFolderName, StringComparison.OrdinalIgnoreCase)) - .SelectMany(i => FileSystem.GetFiles(i.FullName)); - - return LibraryManager.ResolvePaths(files, directoryService, null, new LibraryOptions()) - .OfType<Video>() - .Select(item => - { - // Try to retrieve it from the db. If we don't find it, use the resolved version - - if (LibraryManager.GetItemById(item.Id) is Video dbItem) - { - item = dbItem; - } - else - { - // item is new - item.ExtraType = Model.Entities.ExtraType.ThemeVideo; - } - - return item; - - // Sort them so that the list can be easily compared for changes - }).OrderBy(i => i.Path).ToArray(); - } - - protected virtual BaseItem[] LoadExtras(List<FileSystemMetadata> fileSystemChildren, IDirectoryService directoryService) - { - var extras = new List<Video>(); - - var libraryOptions = new LibraryOptions(); - var folders = fileSystemChildren.Where(i => i.IsDirectory).ToList(); - foreach (var extraFolderName in AllExtrasTypesFolderNames) - { - var files = folders - .Where(i => string.Equals(i.Name, extraFolderName, StringComparison.OrdinalIgnoreCase)) - .SelectMany(i => FileSystem.GetFiles(i.FullName)); - - // Re-using the same instance of LibraryOptions since it looks like it's never being altered. - extras.AddRange(LibraryManager.ResolvePaths(files, directoryService, null, libraryOptions) - .OfType<Video>() - .Select(item => - { - // Try to retrieve it from the db. If we don't find it, use the resolved version - if (LibraryManager.GetItemById(item.Id) is Video dbItem) - { - item = dbItem; - } - - // Use some hackery to get the extra type based on foldername - item.ExtraType = Enum.TryParse(extraFolderName.Replace(" ", string.Empty, StringComparison.Ordinal), true, out ExtraType extraType) - ? extraType - : Model.Entities.ExtraType.Unknown; - - return item; - - // Sort them so that the list can be easily compared for changes - }).OrderBy(i => i.Path)); - } - - return extras.ToArray(); + return string.Join('/', terms); } public Task RefreshMetadata(CancellationToken cancellationToken) @@ -1481,21 +1284,16 @@ namespace MediaBrowser.Controller.Entities { try { - var files = IsFileProtocol ? - GetFileSystemChildren(options.DirectoryService).ToList() : - new List<FileSystemMetadata>(); - - var ownedItemsChanged = await RefreshedOwnedItems(options, files, cancellationToken).ConfigureAwait(false); - await LibraryManager.UpdateImagesAsync(this).ConfigureAwait(false); // ensure all image properties in DB are fresh - - if (ownedItemsChanged) + if (IsFileProtocol) { - requiresSave = true; + requiresSave = await RefreshedOwnedItems(options, GetFileSystemChildren(options.DirectoryService).ToList(), cancellationToken).ConfigureAwait(false); } + + await LibraryManager.UpdateImagesAsync(this).ConfigureAwait(false); // ensure all image properties in DB are fresh } catch (Exception ex) { - Logger.LogError(ex, "Error refreshing owned items for {path}", Path ?? Name); + Logger.LogError(ex, "Error refreshing owned items for {Path}", Path ?? Name); } } @@ -1567,36 +1365,12 @@ namespace MediaBrowser.Controller.Entities /// <returns><c>true</c> if any items have changed, else <c>false</c>.</returns> protected virtual async Task<bool> RefreshedOwnedItems(MetadataRefreshOptions options, List<FileSystemMetadata> fileSystemChildren, CancellationToken cancellationToken) { - var themeSongsChanged = false; - - var themeVideosChanged = false; - - var extrasChanged = false; - - var localTrailersChanged = false; - - if (IsFileProtocol && SupportsOwnedItems) + if (!IsFileProtocol || !SupportsOwnedItems || IsInMixedFolder || this is ICollectionFolder or UserRootFolder or AggregateFolder || this.GetType() == typeof(Folder)) { - if (SupportsThemeMedia) - { - if (!IsInMixedFolder) - { - themeSongsChanged = await RefreshThemeSongs(this, options, fileSystemChildren, cancellationToken).ConfigureAwait(false); - - themeVideosChanged = await RefreshThemeVideos(this, options, fileSystemChildren, cancellationToken).ConfigureAwait(false); - - extrasChanged = await RefreshExtras(this, options, fileSystemChildren, cancellationToken).ConfigureAwait(false); - } - } - - var hasTrailers = this as IHasTrailers; - if (hasTrailers != null) - { - localTrailersChanged = await RefreshLocalTrailers(hasTrailers, options, fileSystemChildren, cancellationToken).ConfigureAwait(false); - } + return false; } - return themeSongsChanged || themeVideosChanged || extrasChanged || localTrailersChanged; + return await RefreshExtras(this, options, fileSystemChildren, cancellationToken).ConfigureAwait(false); } protected virtual FileSystemMetadata[] GetFileSystemChildren(IDirectoryService directoryService) @@ -1606,98 +1380,24 @@ namespace MediaBrowser.Controller.Entities return directoryService.GetFileSystemEntries(path); } - private async Task<bool> RefreshLocalTrailers(IHasTrailers item, MetadataRefreshOptions options, List<FileSystemMetadata> fileSystemChildren, CancellationToken cancellationToken) - { - var newItems = LibraryManager.FindTrailers(this, fileSystemChildren, options.DirectoryService); - - var newItemIds = newItems.Select(i => i.Id); - - var itemsChanged = !item.LocalTrailerIds.SequenceEqual(newItemIds); - var ownerId = item.Id; - - var tasks = newItems.Select(i => - { - var subOptions = new MetadataRefreshOptions(options); - - if (i.ExtraType != Model.Entities.ExtraType.Trailer || - i.OwnerId != ownerId || - !i.ParentId.Equals(Guid.Empty)) - { - i.ExtraType = Model.Entities.ExtraType.Trailer; - i.OwnerId = ownerId; - i.ParentId = Guid.Empty; - subOptions.ForceSave = true; - } - - return RefreshMetadataForOwnedItem(i, true, subOptions, cancellationToken); - }); - - await Task.WhenAll(tasks).ConfigureAwait(false); - - item.LocalTrailerIds = newItemIds.ToArray(); - - return itemsChanged; - } - private async Task<bool> RefreshExtras(BaseItem item, MetadataRefreshOptions options, List<FileSystemMetadata> fileSystemChildren, CancellationToken cancellationToken) { - var extras = LoadExtras(fileSystemChildren, options.DirectoryService); - var themeVideos = LoadThemeVideos(fileSystemChildren, options.DirectoryService); - var themeSongs = LoadThemeSongs(fileSystemChildren, options.DirectoryService); - var newExtras = new BaseItem[extras.Length + themeVideos.Length + themeSongs.Length]; - extras.CopyTo(newExtras, 0); - themeVideos.CopyTo(newExtras, extras.Length); - themeSongs.CopyTo(newExtras, extras.Length + themeVideos.Length); - - var newExtraIds = newExtras.Select(i => i.Id).ToArray(); - + var extras = LibraryManager.FindExtras(item, fileSystemChildren, options.DirectoryService).ToArray(); + var newExtraIds = extras.Select(i => i.Id).ToArray(); var extrasChanged = !item.ExtraIds.SequenceEqual(newExtraIds); - if (extrasChanged) + if (!extrasChanged && !options.ReplaceAllMetadata && options.MetadataRefreshMode != MetadataRefreshMode.FullRefresh) { - var ownerId = item.Id; - - var tasks = newExtras.Select(i => - { - var subOptions = new MetadataRefreshOptions(options); - if (i.OwnerId != ownerId || i.ParentId != Guid.Empty) - { - i.OwnerId = ownerId; - i.ParentId = Guid.Empty; - subOptions.ForceSave = true; - } - - return RefreshMetadataForOwnedItem(i, true, subOptions, cancellationToken); - }); - - await Task.WhenAll(tasks).ConfigureAwait(false); - - item.ExtraIds = newExtraIds; + return false; } - return extrasChanged; - } - - private async Task<bool> RefreshThemeVideos(BaseItem item, MetadataRefreshOptions options, IEnumerable<FileSystemMetadata> fileSystemChildren, CancellationToken cancellationToken) - { - var newThemeVideos = LoadThemeVideos(fileSystemChildren, options.DirectoryService); - - var newThemeVideoIds = newThemeVideos.Select(i => i.Id).ToArray(); - - var themeVideosChanged = !item.ThemeVideoIds.SequenceEqual(newThemeVideoIds); - var ownerId = item.Id; - var tasks = newThemeVideos.Select(i => + var tasks = extras.Select(i => { var subOptions = new MetadataRefreshOptions(options); - - if (!i.ExtraType.HasValue || - i.ExtraType.Value != Model.Entities.ExtraType.ThemeVideo || - i.OwnerId != ownerId || - !i.ParentId.Equals(Guid.Empty)) + if (i.OwnerId != ownerId || i.ParentId != Guid.Empty) { - i.ExtraType = Model.Entities.ExtraType.ThemeVideo; i.OwnerId = ownerId; i.ParentId = Guid.Empty; subOptions.ForceSave = true; @@ -1708,48 +1408,9 @@ namespace MediaBrowser.Controller.Entities await Task.WhenAll(tasks).ConfigureAwait(false); - // They are expected to be sorted by SortName - item.ThemeVideoIds = newThemeVideos.OrderBy(i => i.SortName).Select(i => i.Id).ToArray(); + item.ExtraIds = newExtraIds; - return themeVideosChanged; - } - - /// <summary> - /// Refreshes the theme songs. - /// </summary> - private async Task<bool> RefreshThemeSongs(BaseItem item, MetadataRefreshOptions options, List<FileSystemMetadata> fileSystemChildren, CancellationToken cancellationToken) - { - var newThemeSongs = LoadThemeSongs(fileSystemChildren, options.DirectoryService); - var newThemeSongIds = newThemeSongs.Select(i => i.Id).ToArray(); - - var themeSongsChanged = !item.ThemeSongIds.SequenceEqual(newThemeSongIds); - - var ownerId = item.Id; - - var tasks = newThemeSongs.Select(i => - { - var subOptions = new MetadataRefreshOptions(options); - - if (!i.ExtraType.HasValue || - i.ExtraType.Value != Model.Entities.ExtraType.ThemeSong || - i.OwnerId != ownerId || - !i.ParentId.Equals(Guid.Empty)) - { - i.ExtraType = Model.Entities.ExtraType.ThemeSong; - i.OwnerId = ownerId; - i.ParentId = Guid.Empty; - subOptions.ForceSave = true; - } - - return RefreshMetadataForOwnedItem(i, true, subOptions, cancellationToken); - }); - - await Task.WhenAll(tasks).ConfigureAwait(false); - - // They are expected to be sorted by SortName - item.ThemeSongIds = newThemeSongs.OrderBy(i => i.SortName).Select(i => i.Id).ToArray(); - - return themeSongsChanged; + return true; } public string GetPresentationUniqueKey() @@ -1982,7 +1643,7 @@ namespace MediaBrowser.Controller.Entities private bool IsVisibleViaTags(User user) { - if (user.GetPreference(PreferenceKind.BlockedTags).Any(i => Tags.Contains(i, StringComparer.OrdinalIgnoreCase))) + if (user.GetPreference(PreferenceKind.BlockedTags).Any(i => Tags.Contains(i, StringComparison.OrdinalIgnoreCase))) { return false; } @@ -2061,7 +1722,7 @@ namespace MediaBrowser.Controller.Entities public BaseItemKind GetBaseItemKind() { - return Enum.Parse<BaseItemKind>(GetClientTypeName()); + return _baseItemKind ??= Enum.Parse<BaseItemKind>(GetClientTypeName()); } /// <summary> @@ -2151,7 +1812,7 @@ namespace MediaBrowser.Controller.Entities var current = Studios; - if (!current.Contains(name, StringComparer.OrdinalIgnoreCase)) + if (!current.Contains(name, StringComparison.OrdinalIgnoreCase)) { int curLen = current.Length; if (curLen == 0) @@ -2186,7 +1847,7 @@ namespace MediaBrowser.Controller.Entities } var genres = Genres; - if (!genres.Contains(name, StringComparer.OrdinalIgnoreCase)) + if (!genres.Contains(name, StringComparison.OrdinalIgnoreCase)) { var list = genres.ToList(); list.Add(name); @@ -2287,7 +1948,11 @@ namespace MediaBrowser.Controller.Entities var existingImage = GetImageInfo(image.Type, index); - if (existingImage != null) + if (existingImage == null) + { + AddImage(image); + } + else { existingImage.Path = image.Path; existingImage.DateModified = image.DateModified; @@ -2295,15 +1960,6 @@ namespace MediaBrowser.Controller.Entities existingImage.Height = image.Height; existingImage.BlurHash = image.BlurHash; } - else - { - var current = ImageInfos; - var currentCount = current.Length; - var newArr = new ItemImageInfo[currentCount + 1]; - current.CopyTo(newArr, 0); - newArr[currentCount] = image; - ImageInfos = newArr; - } } public void SetImagePath(ImageType type, int index, FileSystemMetadata file) @@ -2317,7 +1973,7 @@ namespace MediaBrowser.Controller.Entities if (image == null) { - ImageInfos = ImageInfos.Concat(new[] { GetImageInfo(file, type) }).ToArray(); + AddImage(GetImageInfo(file, type)); } else { @@ -2361,14 +2017,24 @@ namespace MediaBrowser.Controller.Entities public void RemoveImage(ItemImageInfo image) { - RemoveImages(new List<ItemImageInfo> { image }); + RemoveImages(new[] { image }); } - public void RemoveImages(List<ItemImageInfo> deletedImages) + public void RemoveImages(IEnumerable<ItemImageInfo> deletedImages) { ImageInfos = ImageInfos.Except(deletedImages).ToArray(); } + public void AddImage(ItemImageInfo image) + { + var current = ImageInfos; + var currentCount = current.Length; + var newArr = new ItemImageInfo[currentCount + 1]; + current.CopyTo(newArr, 0); + newArr[currentCount] = image; + ImageInfos = newArr; + } + public virtual Task UpdateToRepositoryAsync(ItemUpdateType updateReason, CancellationToken cancellationToken) => LibraryManager.UpdateItemAsync(this, GetParent(), updateReason, cancellationToken); @@ -2387,12 +2053,12 @@ namespace MediaBrowser.Controller.Entities .ToList(); var deletedImages = ImageInfos - .Where(image => image.IsLocalFile && !allFiles.Contains(image.Path, StringComparer.OrdinalIgnoreCase)) + .Where(image => image.IsLocalFile && !allFiles.Contains(image.Path, StringComparison.OrdinalIgnoreCase)) .ToList(); if (deletedImages.Count > 0) { - ImageInfos = ImageInfos.Except(deletedImages).ToArray(); + RemoveImages(deletedImages); } return deletedImages.Count > 0; @@ -2514,11 +2180,11 @@ namespace MediaBrowser.Controller.Entities } /// <summary> - /// Adds the images. + /// Adds the images, updating metadata if they already are part of this item. /// </summary> /// <param name="imageType">Type of the image.</param> /// <param name="images">The images.</param> - /// <returns><c>true</c> if XXXX, <c>false</c> otherwise.</returns> + /// <returns><c>true</c> if images were added or updated, <c>false</c> otherwise.</returns> /// <exception cref="ArgumentException">Cannot call AddImages with chapter images.</exception> public bool AddImages(ImageType imageType, List<FileSystemMetadata> images) { @@ -2531,7 +2197,6 @@ namespace MediaBrowser.Controller.Entities .ToList(); var newImageList = new List<FileSystemMetadata>(); - var imageAdded = false; var imageUpdated = false; foreach (var newImage in images) @@ -2547,7 +2212,6 @@ namespace MediaBrowser.Controller.Entities if (existing == null) { newImageList.Add(newImage); - imageAdded = true; } else { @@ -2568,19 +2232,6 @@ namespace MediaBrowser.Controller.Entities } } - if (imageAdded || images.Count != existingImages.Count) - { - var newImagePaths = images.Select(i => i.FullName).ToList(); - - var deleted = existingImages - .FindAll(i => i.IsLocalFile && !newImagePaths.Contains(i.Path.AsSpan(), StringComparison.OrdinalIgnoreCase) && !File.Exists(i.Path)); - - if (deleted.Count > 0) - { - ImageInfos = ImageInfos.Except(deleted).ToArray(); - } - } - if (newImageList.Count > 0) { ImageInfos = ImageInfos.Concat(newImageList.Select(i => GetImageInfo(i, imageType))).ToArray(); @@ -2631,7 +2282,7 @@ namespace MediaBrowser.Controller.Entities public bool AllowsMultipleImages(ImageType type) { - return type == ImageType.Backdrop || type == ImageType.Screenshot || type == ImageType.Chapter; + return type == ImageType.Backdrop || type == ImageType.Chapter; } public Task SwapImagesAsync(ImageType type, int index1, int index2) @@ -2749,7 +2400,7 @@ namespace MediaBrowser.Controller.Entities protected static string GetMappedPath(BaseItem item, string path, MediaProtocol? protocol) { - if (protocol.HasValue && protocol.Value == MediaProtocol.File) + if (protocol == MediaProtocol.File) { return LibraryManager.GetPathAfterNetworkSubstitution(path, item); } @@ -2777,8 +2428,10 @@ namespace MediaBrowser.Controller.Entities protected Task RefreshMetadataForOwnedItem(BaseItem ownedItem, bool copyTitleMetadata, MetadataRefreshOptions options, CancellationToken cancellationToken) { - var newOptions = new MetadataRefreshOptions(options); - newOptions.SearchResult = null; + var newOptions = new MetadataRefreshOptions(options) + { + SearchResult = null + }; var item = this; @@ -2839,8 +2492,10 @@ namespace MediaBrowser.Controller.Entities protected Task RefreshMetadataForOwnedVideo(MetadataRefreshOptions options, bool copyTitleMetadata, string path, CancellationToken cancellationToken) { - var newOptions = new MetadataRefreshOptions(options); - newOptions.SearchResult = null; + var newOptions = new MetadataRefreshOptions(options) + { + SearchResult = null + }; var id = LibraryManager.GetNewItemId(path, typeof(Video)); @@ -2854,14 +2509,6 @@ namespace MediaBrowser.Controller.Entities newOptions.ForceSave = true; } - // var parentId = Id; - // if (!video.IsOwnedItem || video.ParentId != parentId) - // { - // video.IsOwnedItem = true; - // video.ParentId = parentId; - // newOptions.ForceSave = true; - // } - if (video == null) { return Task.FromResult(true); @@ -2945,9 +2592,9 @@ namespace MediaBrowser.Controller.Entities .Select(i => i.OfficialRating) .Where(i => !string.IsNullOrEmpty(i)) .Distinct(StringComparer.OrdinalIgnoreCase) - .Select(i => new Tuple<string, int?>(i, LocalizationManager.GetRatingLevel(i))) + .Select(rating => (rating, LocalizationManager.GetRatingLevel(rating))) .OrderBy(i => i.Item2 ?? 1000) - .Select(i => i.Item1); + .Select(i => i.rating); OfficialRating = ratings.FirstOrDefault() ?? currentOfficialRating; @@ -2957,14 +2604,14 @@ namespace MediaBrowser.Controller.Entities StringComparison.OrdinalIgnoreCase); } - public IEnumerable<BaseItem> GetThemeSongs() + public IReadOnlyList<BaseItem> GetThemeSongs() { - return ThemeSongIds.Select(LibraryManager.GetItemById); + return GetExtras().Where(e => e.ExtraType == Model.Entities.ExtraType.ThemeSong).ToArray(); } - public IEnumerable<BaseItem> GetThemeVideos() + public IReadOnlyList<BaseItem> GetThemeVideos() { - return ThemeVideoIds.Select(LibraryManager.GetItemById); + return GetExtras().Where(e => e.ExtraType == Model.Entities.ExtraType.ThemeVideo).ToArray(); } /// <summary> @@ -2992,18 +2639,6 @@ namespace MediaBrowser.Controller.Entities .Where(i => i.ExtraType.HasValue && extraTypes.Contains(i.ExtraType.Value)); } - public IEnumerable<BaseItem> GetTrailers() - { - if (this is IHasTrailers) - { - return ((IHasTrailers)this).LocalTrailerIds.Select(LibraryManager.GetItemById).Where(i => i != null).OrderBy(i => i.SortName); - } - else - { - return Array.Empty<BaseItem>(); - } - } - public virtual long GetRunTimeTicksForPlayState() { return RunTimeTicks ?? 0; @@ -3016,7 +2651,7 @@ namespace MediaBrowser.Controller.Entities } /// <inheritdoc /> - public bool Equals(BaseItem other) => object.Equals(Id, other?.Id); + public bool Equals(BaseItem other) => Id == other?.Id; /// <inheritdoc /> public override int GetHashCode() => HashCode.Combine(Id); diff --git a/MediaBrowser.Controller/Entities/BaseItemExtensions.cs b/MediaBrowser.Controller/Entities/BaseItemExtensions.cs index e88121212..e0583e630 100644 --- a/MediaBrowser.Controller/Entities/BaseItemExtensions.cs +++ b/MediaBrowser.Controller/Entities/BaseItemExtensions.cs @@ -44,14 +44,15 @@ namespace MediaBrowser.Controller.Entities /// <param name="file">The file.</param> public static void SetImagePath(this BaseItem item, ImageType imageType, string file) { - if (file.StartsWith("http", System.StringComparison.OrdinalIgnoreCase)) + if (file.StartsWith("http", StringComparison.OrdinalIgnoreCase)) { item.SetImage( - new ItemImageInfo - { - Path = file, - Type = imageType - }, 0); + new ItemImageInfo + { + Path = file, + Type = imageType + }, + 0); } else { diff --git a/MediaBrowser.Controller/Entities/Folder.cs b/MediaBrowser.Controller/Entities/Folder.cs index dd08c31ed..55551e70e 100644 --- a/MediaBrowser.Controller/Entities/Folder.cs +++ b/MediaBrowser.Controller/Entities/Folder.cs @@ -303,7 +303,7 @@ namespace MediaBrowser.Controller.Entities if (dictionary.ContainsKey(id)) { Logger.LogError( - "Found folder containing items with duplicate id. Path: {path}, Child Name: {ChildName}", + "Found folder containing items with duplicate id. Path: {Path}, Child Name: {ChildName}", Path ?? Name, child.Path ?? child.Name); } @@ -425,7 +425,7 @@ namespace MediaBrowser.Controller.Entities { if (item.IsFileProtocol) { - Logger.LogDebug("Removed item: " + item.Path); + Logger.LogDebug("Removed item: {Path}", item.Path); item.SetParent(null); LibraryManager.DeleteItem(item, new DeleteOptions { DeleteFileLocation = false }, this, false); @@ -792,7 +792,7 @@ namespace MediaBrowser.Controller.Entities private bool RequiresPostFiltering2(InternalItemsQuery query) { - if (query.IncludeItemTypes.Length == 1 && string.Equals(query.IncludeItemTypes[0], nameof(BoxSet), StringComparison.OrdinalIgnoreCase)) + if (query.IncludeItemTypes.Length == 1 && query.IncludeItemTypes[0] == BaseItemKind.BoxSet) { Logger.LogDebug("Query requires post-filtering due to BoxSet query"); return true; @@ -807,7 +807,7 @@ namespace MediaBrowser.Controller.Entities { if (this is not ICollectionFolder) { - Logger.LogDebug("Query requires post-filtering due to LinkedChildren. Type: " + GetType().Name); + Logger.LogDebug("{Type}: Query requires post-filtering due to LinkedChildren.", GetType().Name); return true; } } @@ -882,7 +882,7 @@ namespace MediaBrowser.Controller.Entities if (query.IsPlayed.HasValue) { - if (query.IncludeItemTypes.Length == 1 && query.IncludeItemTypes.Contains(nameof(Series))) + if (query.IncludeItemTypes.Length == 1 && query.IncludeItemTypes.Contains(BaseItemKind.Series)) { Logger.LogDebug("Query requires post-filtering due to IsPlayed"); return true; @@ -1013,20 +1013,22 @@ namespace MediaBrowser.Controller.Entities items = CollapseBoxSetItemsIfNeeded(items, query, this, user, ConfigurationManager, CollectionManager); } + #pragma warning disable CA1309 if (!string.IsNullOrEmpty(query.NameStartsWithOrGreater)) { - items = items.Where(i => string.Compare(query.NameStartsWithOrGreater, i.SortName, StringComparison.CurrentCultureIgnoreCase) < 1); + items = items.Where(i => string.Compare(query.NameStartsWithOrGreater, i.SortName, StringComparison.InvariantCultureIgnoreCase) < 1); } if (!string.IsNullOrEmpty(query.NameStartsWith)) { - items = items.Where(i => i.SortName.StartsWith(query.NameStartsWith, StringComparison.CurrentCultureIgnoreCase)); + items = items.Where(i => i.SortName.StartsWith(query.NameStartsWith, StringComparison.InvariantCultureIgnoreCase)); } if (!string.IsNullOrEmpty(query.NameLessThan)) { - items = items.Where(i => string.Compare(query.NameLessThan, i.SortName, StringComparison.CurrentCultureIgnoreCase) == 1); + items = items.Where(i => string.Compare(query.NameLessThan, i.SortName, StringComparison.InvariantCultureIgnoreCase) == 1); } + #pragma warning restore CA1309 // This must be the last filter if (!string.IsNullOrEmpty(query.AdjacentTo)) @@ -1099,7 +1101,7 @@ namespace MediaBrowser.Controller.Entities return false; } - if (query.IncludeItemTypes.Length == 0 || query.IncludeItemTypes.Contains("Movie", StringComparer.OrdinalIgnoreCase)) + if (query.IncludeItemTypes.Length == 0 || query.IncludeItemTypes.Contains(BaseItemKind.Movie)) { param = true; } diff --git a/MediaBrowser.Controller/Entities/Genre.cs b/MediaBrowser.Controller/Entities/Genre.cs index 338f96204..4be673237 100644 --- a/MediaBrowser.Controller/Entities/Genre.cs +++ b/MediaBrowser.Controller/Entities/Genre.cs @@ -6,7 +6,7 @@ using System; using System.Collections.Generic; using System.Text.Json.Serialization; using Diacritics.Extensions; -using MediaBrowser.Controller.Entities.Audio; +using Jellyfin.Data.Enums; using Microsoft.Extensions.Logging; namespace MediaBrowser.Controller.Entities @@ -66,10 +66,10 @@ namespace MediaBrowser.Controller.Entities query.GenreIds = new[] { Id }; query.ExcludeItemTypes = new[] { - nameof(MusicVideo), - nameof(Entities.Audio.Audio), - nameof(MusicAlbum), - nameof(MusicArtist) + BaseItemKind.MusicVideo, + BaseItemKind.Audio, + BaseItemKind.MusicAlbum, + BaseItemKind.MusicArtist }; return LibraryManager.GetItemList(query); diff --git a/MediaBrowser.Controller/Entities/IHasScreenshots.cs b/MediaBrowser.Controller/Entities/IHasScreenshots.cs deleted file mode 100644 index ae01c223e..000000000 --- a/MediaBrowser.Controller/Entities/IHasScreenshots.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace MediaBrowser.Controller.Entities -{ - /// <summary> - /// The item has screenshots. - /// </summary> - public interface IHasScreenshots - { - } -} diff --git a/MediaBrowser.Controller/Entities/IHasShares.cs b/MediaBrowser.Controller/Entities/IHasShares.cs index dca5af873..e6fa27703 100644 --- a/MediaBrowser.Controller/Entities/IHasShares.cs +++ b/MediaBrowser.Controller/Entities/IHasShares.cs @@ -8,4 +8,4 @@ namespace MediaBrowser.Controller.Entities { Share[] Shares { get; set; } } -} \ No newline at end of file +} diff --git a/MediaBrowser.Controller/Entities/IHasSpecialFeatures.cs b/MediaBrowser.Controller/Entities/IHasSpecialFeatures.cs index f317a02ff..f47d2162f 100644 --- a/MediaBrowser.Controller/Entities/IHasSpecialFeatures.cs +++ b/MediaBrowser.Controller/Entities/IHasSpecialFeatures.cs @@ -10,9 +10,9 @@ namespace MediaBrowser.Controller.Entities public interface IHasSpecialFeatures { /// <summary> - /// Gets or sets the special feature ids. + /// Gets the special feature ids. /// </summary> /// <value>The special feature ids.</value> - IReadOnlyList<Guid> SpecialFeatureIds { get; set; } + IReadOnlyList<Guid> SpecialFeatureIds { get; } } } diff --git a/MediaBrowser.Controller/Entities/IHasTrailers.cs b/MediaBrowser.Controller/Entities/IHasTrailers.cs index f4271678d..bb4a6ea94 100644 --- a/MediaBrowser.Controller/Entities/IHasTrailers.cs +++ b/MediaBrowser.Controller/Entities/IHasTrailers.cs @@ -2,7 +2,6 @@ #pragma warning disable CS1591 -using System; using System.Collections.Generic; using MediaBrowser.Model.Entities; @@ -17,18 +16,10 @@ namespace MediaBrowser.Controller.Entities IReadOnlyList<MediaUrl> RemoteTrailers { get; set; } /// <summary> - /// Gets or sets the local trailer ids. + /// Gets the local trailers. /// </summary> - /// <value>The local trailer ids.</value> - IReadOnlyList<Guid> LocalTrailerIds { get; set; } - - /// <summary> - /// Gets or sets the remote trailer ids. - /// </summary> - /// <value>The remote trailer ids.</value> - IReadOnlyList<Guid> RemoteTrailerIds { get; set; } - - Guid Id { get; set; } + /// <value>The local trailers.</value> + IReadOnlyList<BaseItem> LocalTrailers { get; } } /// <summary> @@ -42,57 +33,6 @@ namespace MediaBrowser.Controller.Entities /// <param name="item">Media item.</param> /// <returns><see cref="IReadOnlyList{Guid}" />.</returns> public static int GetTrailerCount(this IHasTrailers item) - => item.LocalTrailerIds.Count + item.RemoteTrailerIds.Count; - - /// <summary> - /// Gets the trailer ids. - /// </summary> - /// <param name="item">Media item.</param> - /// <returns><see cref="IReadOnlyList{Guid}" />.</returns> - public static IReadOnlyList<Guid> GetTrailerIds(this IHasTrailers item) - { - var localIds = item.LocalTrailerIds; - var remoteIds = item.RemoteTrailerIds; - - var all = new Guid[localIds.Count + remoteIds.Count]; - var index = 0; - foreach (var id in localIds) - { - all[index++] = id; - } - - foreach (var id in remoteIds) - { - all[index++] = id; - } - - return all; - } - - /// <summary> - /// Gets the trailers. - /// </summary> - /// <param name="item">Media item.</param> - /// <returns><see cref="IReadOnlyList{BaseItem}" />.</returns> - public static IReadOnlyList<BaseItem> GetTrailers(this IHasTrailers item) - { - var localIds = item.LocalTrailerIds; - var remoteIds = item.RemoteTrailerIds; - var libraryManager = BaseItem.LibraryManager; - - var all = new BaseItem[localIds.Count + remoteIds.Count]; - var index = 0; - foreach (var id in localIds) - { - all[index++] = libraryManager.GetItemById(id); - } - - foreach (var id in remoteIds) - { - all[index++] = libraryManager.GetItemById(id); - } - - return all; - } + => item.LocalTrailers.Count + item.RemoteTrailers.Count; } } diff --git a/MediaBrowser.Controller/Entities/InternalItemsQuery.cs b/MediaBrowser.Controller/Entities/InternalItemsQuery.cs index 0baa7725e..db1697c79 100644 --- a/MediaBrowser.Controller/Entities/InternalItemsQuery.cs +++ b/MediaBrowser.Controller/Entities/InternalItemsQuery.cs @@ -27,18 +27,18 @@ namespace MediaBrowser.Controller.Entities ExcludeArtistIds = Array.Empty<Guid>(); ExcludeInheritedTags = Array.Empty<string>(); ExcludeItemIds = Array.Empty<Guid>(); - ExcludeItemTypes = Array.Empty<string>(); + ExcludeItemTypes = Array.Empty<BaseItemKind>(); ExcludeTags = Array.Empty<string>(); GenreIds = Array.Empty<Guid>(); Genres = Array.Empty<string>(); GroupByPresentationUniqueKey = true; ImageTypes = Array.Empty<ImageType>(); - IncludeItemTypes = Array.Empty<string>(); + IncludeItemTypes = Array.Empty<BaseItemKind>(); ItemIds = Array.Empty<Guid>(); MediaTypes = Array.Empty<string>(); MinSimilarityScore = 20; OfficialRatings = Array.Empty<string>(); - OrderBy = Array.Empty<ValueTuple<string, SortOrder>>(); + OrderBy = Array.Empty<(string, SortOrder)>(); PersonIds = Array.Empty<Guid>(); PersonTypes = Array.Empty<string>(); PresetViews = Array.Empty<string>(); @@ -87,9 +87,9 @@ namespace MediaBrowser.Controller.Entities public string[] MediaTypes { get; set; } - public string[] IncludeItemTypes { get; set; } + public BaseItemKind[] IncludeItemTypes { get; set; } - public string[] ExcludeItemTypes { get; set; } + public BaseItemKind[] ExcludeItemTypes { get; set; } public string[] ExcludeTags { get; set; } @@ -229,7 +229,7 @@ namespace MediaBrowser.Controller.Entities public Guid ParentId { get; set; } - public string? ParentType { get; set; } + public BaseItemKind? ParentType { get; set; } public Guid[] AncestorIds { get; set; } @@ -271,7 +271,7 @@ namespace MediaBrowser.Controller.Entities public bool? HasChapterImages { get; set; } - public IReadOnlyList<(string, SortOrder)> OrderBy { get; set; } + public IReadOnlyList<(string OrderBy, SortOrder SortOrder)> OrderBy { get; set; } public DateTime? MinDateCreated { get; set; } @@ -314,7 +314,7 @@ namespace MediaBrowser.Controller.Entities else { ParentId = value.Id; - ParentType = value.GetType().Name; + ParentType = value.GetBaseItemKind(); } } } diff --git a/MediaBrowser.Controller/Entities/LinkedChildComparer.cs b/MediaBrowser.Controller/Entities/LinkedChildComparer.cs index 4e58e2942..de8b16808 100644 --- a/MediaBrowser.Controller/Entities/LinkedChildComparer.cs +++ b/MediaBrowser.Controller/Entities/LinkedChildComparer.cs @@ -32,4 +32,4 @@ namespace MediaBrowser.Controller.Entities return ((obj.Path ?? string.Empty) + (obj.LibraryItemId ?? string.Empty) + obj.Type).GetHashCode(StringComparison.Ordinal); } } -} \ No newline at end of file +} diff --git a/MediaBrowser.Controller/Entities/LinkedChildType.cs b/MediaBrowser.Controller/Entities/LinkedChildType.cs index 9ddb7b620..d39e36ff2 100644 --- a/MediaBrowser.Controller/Entities/LinkedChildType.cs +++ b/MediaBrowser.Controller/Entities/LinkedChildType.cs @@ -15,4 +15,4 @@ /// </summary> Shortcut = 1 } -} \ No newline at end of file +} diff --git a/MediaBrowser.Controller/Entities/Movies/BoxSet.cs b/MediaBrowser.Controller/Entities/Movies/BoxSet.cs index e46f99cd5..882abc927 100644 --- a/MediaBrowser.Controller/Entities/Movies/BoxSet.cs +++ b/MediaBrowser.Controller/Entities/Movies/BoxSet.cs @@ -9,7 +9,6 @@ using System.Text.Json.Serialization; using Jellyfin.Data.Entities; using Jellyfin.Data.Enums; using MediaBrowser.Controller.Providers; -using MediaBrowser.Model.Entities; using MediaBrowser.Model.Querying; namespace MediaBrowser.Controller.Entities.Movies @@ -21,10 +20,6 @@ namespace MediaBrowser.Controller.Entities.Movies { public BoxSet() { - RemoteTrailers = Array.Empty<MediaUrl>(); - LocalTrailerIds = Array.Empty<Guid>(); - RemoteTrailerIds = Array.Empty<Guid>(); - DisplayOrder = ItemSortBy.PremiereDate; } @@ -38,10 +33,10 @@ namespace MediaBrowser.Controller.Entities.Movies public override bool SupportsPeople => true; /// <inheritdoc /> - public IReadOnlyList<Guid> LocalTrailerIds { get; set; } - - /// <inheritdoc /> - public IReadOnlyList<Guid> RemoteTrailerIds { get; set; } + [JsonIgnore] + public IReadOnlyList<BaseItem> LocalTrailers => GetExtras() + .Where(extra => extra.ExtraType == Model.Entities.ExtraType.Trailer) + .ToArray(); /// <summary> /// Gets or sets the display order. diff --git a/MediaBrowser.Controller/Entities/Movies/Movie.cs b/MediaBrowser.Controller/Entities/Movies/Movie.cs index b54bbf5eb..77e70f8fb 100644 --- a/MediaBrowser.Controller/Entities/Movies/Movie.cs +++ b/MediaBrowser.Controller/Entities/Movies/Movie.cs @@ -7,12 +7,9 @@ using System.Collections.Generic; using System.Globalization; using System.Linq; using System.Text.Json.Serialization; -using System.Threading; -using System.Threading.Tasks; using Jellyfin.Data.Enums; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Entities; -using MediaBrowser.Model.IO; using MediaBrowser.Model.Providers; namespace MediaBrowser.Controller.Entities.Movies @@ -22,22 +19,18 @@ namespace MediaBrowser.Controller.Entities.Movies /// </summary> public class Movie : Video, IHasSpecialFeatures, IHasTrailers, IHasLookupInfo<MovieInfo>, ISupportsBoxSetGrouping { - public Movie() - { - SpecialFeatureIds = Array.Empty<Guid>(); - RemoteTrailers = Array.Empty<MediaUrl>(); - LocalTrailerIds = Array.Empty<Guid>(); - RemoteTrailerIds = Array.Empty<Guid>(); - } + /// <inheritdoc /> + [JsonIgnore] + public IReadOnlyList<Guid> SpecialFeatureIds => GetExtras() + .Where(extra => extra.ExtraType != null && extra is Video) + .Select(extra => extra.Id) + .ToArray(); /// <inheritdoc /> - public IReadOnlyList<Guid> SpecialFeatureIds { get; set; } - - /// <inheritdoc /> - public IReadOnlyList<Guid> LocalTrailerIds { get; set; } - - /// <inheritdoc /> - public IReadOnlyList<Guid> RemoteTrailerIds { get; set; } + [JsonIgnore] + public IReadOnlyList<BaseItem> LocalTrailers => GetExtras() + .Where(extra => extra.ExtraType == Model.Entities.ExtraType.Trailer) + .ToArray(); /// <summary> /// Gets or sets the name of the TMDB collection. @@ -66,54 +59,6 @@ namespace MediaBrowser.Controller.Entities.Movies return 2.0 / 3; } - protected override async Task<bool> RefreshedOwnedItems(MetadataRefreshOptions options, List<FileSystemMetadata> fileSystemChildren, CancellationToken cancellationToken) - { - var hasChanges = await base.RefreshedOwnedItems(options, fileSystemChildren, cancellationToken).ConfigureAwait(false); - - // Must have a parent to have special features - // In other words, it must be part of the Parent/Child tree - if (IsFileProtocol && SupportsOwnedItems && !IsInMixedFolder) - { - var specialFeaturesChanged = await RefreshSpecialFeatures(options, fileSystemChildren, cancellationToken).ConfigureAwait(false); - - if (specialFeaturesChanged) - { - hasChanges = true; - } - } - - return hasChanges; - } - - private async Task<bool> RefreshSpecialFeatures(MetadataRefreshOptions options, List<FileSystemMetadata> fileSystemChildren, CancellationToken cancellationToken) - { - var newItems = LibraryManager.FindExtras(this, fileSystemChildren, options.DirectoryService).ToList(); - var newItemIds = newItems.Select(i => i.Id).ToArray(); - - var itemsChanged = !SpecialFeatureIds.SequenceEqual(newItemIds); - - var ownerId = Id; - - var tasks = newItems.Select(i => - { - var subOptions = new MetadataRefreshOptions(options); - - if (i.OwnerId != ownerId) - { - i.OwnerId = ownerId; - subOptions.ForceSave = true; - } - - return RefreshMetadataForOwnedItem(i, false, subOptions, cancellationToken); - }); - - await Task.WhenAll(tasks).ConfigureAwait(false); - - SpecialFeatureIds = newItemIds; - - return itemsChanged; - } - /// <inheritdoc /> public override UnratedItem GetBlockUnratedType() { diff --git a/MediaBrowser.Controller/Entities/TV/Episode.cs b/MediaBrowser.Controller/Entities/TV/Episode.cs index 27c3ff81b..c8a0e21eb 100644 --- a/MediaBrowser.Controller/Entities/TV/Episode.cs +++ b/MediaBrowser.Controller/Entities/TV/Episode.cs @@ -11,6 +11,7 @@ using Jellyfin.Data.Enums; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Entities; using MediaBrowser.Model.IO; +using MediaBrowser.Model.Providers; using Microsoft.Extensions.Logging; namespace MediaBrowser.Controller.Entities.TV @@ -20,18 +21,11 @@ namespace MediaBrowser.Controller.Entities.TV /// </summary> public class Episode : Video, IHasTrailers, IHasLookupInfo<EpisodeInfo>, IHasSeries { - public Episode() - { - RemoteTrailers = Array.Empty<MediaUrl>(); - LocalTrailerIds = Array.Empty<Guid>(); - RemoteTrailerIds = Array.Empty<Guid>(); - } - /// <inheritdoc /> - public IReadOnlyList<Guid> LocalTrailerIds { get; set; } - - /// <inheritdoc /> - public IReadOnlyList<Guid> RemoteTrailerIds { get; set; } + [JsonIgnore] + public IReadOnlyList<BaseItem> LocalTrailers => GetExtras() + .Where(extra => extra.ExtraType == Model.Entities.ExtraType.Trailer) + .ToArray(); /// <summary> /// Gets or sets the season in which it aired. @@ -344,5 +338,22 @@ namespace MediaBrowser.Controller.Entities.TV return hasChanges; } + + public override List<ExternalUrl> GetRelatedUrls() + { + var list = base.GetRelatedUrls(); + + var imdbId = this.GetProviderId(MetadataProvider.Imdb); + if (!string.IsNullOrEmpty(imdbId)) + { + list.Add(new ExternalUrl + { + Name = "Trakt", + Url = string.Format(CultureInfo.InvariantCulture, "https://trakt.tv/episodes/{0}", imdbId) + }); + } + + return list; + } } } diff --git a/MediaBrowser.Controller/Entities/TV/Series.cs b/MediaBrowser.Controller/Entities/TV/Series.cs index e4933e968..a3c4a81fd 100644 --- a/MediaBrowser.Controller/Entities/TV/Series.cs +++ b/MediaBrowser.Controller/Entities/TV/Series.cs @@ -27,9 +27,6 @@ namespace MediaBrowser.Controller.Entities.TV { public Series() { - RemoteTrailers = Array.Empty<MediaUrl>(); - LocalTrailerIds = Array.Empty<Guid>(); - RemoteTrailerIds = Array.Empty<Guid>(); AirDays = Array.Empty<DayOfWeek>(); } @@ -53,10 +50,10 @@ namespace MediaBrowser.Controller.Entities.TV public override bool SupportsPeople => true; /// <inheritdoc /> - public IReadOnlyList<Guid> LocalTrailerIds { get; set; } - - /// <inheritdoc /> - public IReadOnlyList<Guid> RemoteTrailerIds { get; set; } + [JsonIgnore] + public IReadOnlyList<BaseItem> LocalTrailers => GetExtras() + .Where(extra => extra.ExtraType == Model.Entities.ExtraType.Trailer) + .ToArray(); /// <summary> /// Gets or sets the display order. @@ -131,7 +128,7 @@ namespace MediaBrowser.Controller.Entities.TV { AncestorWithPresentationUniqueKey = null, SeriesPresentationUniqueKey = seriesKey, - IncludeItemTypes = new[] { nameof(Season) }, + IncludeItemTypes = new[] { BaseItemKind.Season }, IsVirtualItem = false, Limit = 0, DtoOptions = new DtoOptions(false) @@ -159,7 +156,7 @@ namespace MediaBrowser.Controller.Entities.TV if (query.IncludeItemTypes.Length == 0) { - query.IncludeItemTypes = new[] { nameof(Episode) }; + query.IncludeItemTypes = new[] { BaseItemKind.Episode }; } query.IsVirtualItem = false; @@ -213,7 +210,7 @@ namespace MediaBrowser.Controller.Entities.TV query.AncestorWithPresentationUniqueKey = null; query.SeriesPresentationUniqueKey = seriesKey; - query.IncludeItemTypes = new[] { nameof(Season) }; + query.IncludeItemTypes = new[] { BaseItemKind.Season }; query.OrderBy = new[] { (ItemSortBy.SortName, SortOrder.Ascending) }; if (user != null && !user.DisplayMissingEpisodes) @@ -239,7 +236,7 @@ namespace MediaBrowser.Controller.Entities.TV if (query.IncludeItemTypes.Length == 0) { - query.IncludeItemTypes = new[] { nameof(Episode), nameof(Season) }; + query.IncludeItemTypes = new[] { BaseItemKind.Episode, BaseItemKind.Season }; } query.IsVirtualItem = false; @@ -259,7 +256,7 @@ namespace MediaBrowser.Controller.Entities.TV { AncestorWithPresentationUniqueKey = null, SeriesPresentationUniqueKey = seriesKey, - IncludeItemTypes = new[] { nameof(Episode), nameof(Season) }, + IncludeItemTypes = new[] { BaseItemKind.Episode, BaseItemKind.Season }, OrderBy = new[] { (ItemSortBy.SortName, SortOrder.Ascending) }, DtoOptions = options }; @@ -363,7 +360,7 @@ namespace MediaBrowser.Controller.Entities.TV { AncestorWithPresentationUniqueKey = queryFromSeries ? null : seriesKey, SeriesPresentationUniqueKey = queryFromSeries ? seriesKey : null, - IncludeItemTypes = new[] { nameof(Episode) }, + IncludeItemTypes = new[] { BaseItemKind.Episode }, OrderBy = new[] { (ItemSortBy.SortName, SortOrder.Ascending) }, DtoOptions = options }; diff --git a/MediaBrowser.Controller/Entities/TagExtensions.cs b/MediaBrowser.Controller/Entities/TagExtensions.cs index 2ce396daf..ec3eb0f70 100644 --- a/MediaBrowser.Controller/Entities/TagExtensions.cs +++ b/MediaBrowser.Controller/Entities/TagExtensions.cs @@ -2,6 +2,7 @@ using System; using System.Linq; +using Jellyfin.Extensions; namespace MediaBrowser.Controller.Entities { @@ -16,7 +17,7 @@ namespace MediaBrowser.Controller.Entities var current = item.Tags; - if (!current.Contains(name, StringComparer.OrdinalIgnoreCase)) + if (!current.Contains(name, StringComparison.OrdinalIgnoreCase)) { if (current.Length == 0) { diff --git a/MediaBrowser.Controller/Entities/UserRootFolder.cs b/MediaBrowser.Controller/Entities/UserRootFolder.cs index c07fb40b3..e547db523 100644 --- a/MediaBrowser.Controller/Entities/UserRootFolder.cs +++ b/MediaBrowser.Controller/Entities/UserRootFolder.cs @@ -24,6 +24,14 @@ namespace MediaBrowser.Controller.Entities private readonly object _childIdsLock = new object(); private List<Guid> _childrenIds = null; + /// <summary> + /// Initializes a new instance of the <see cref="UserRootFolder"/> class. + /// </summary> + public UserRootFolder() + { + IsRoot = true; + } + [JsonIgnore] public override bool SupportsInheritedParentImages => false; @@ -44,14 +52,6 @@ namespace MediaBrowser.Controller.Entities } } - /// <summary> - /// Initializes a new instance of the <see cref="UserRootFolder"/> class. - /// </summary> - public UserRootFolder() - { - IsRoot = true; - } - protected override List<BaseItem> LoadChildren() { lock (_childIdsLock) diff --git a/MediaBrowser.Controller/Entities/UserView.cs b/MediaBrowser.Controller/Entities/UserView.cs index 62f3c4b55..5c9be7337 100644 --- a/MediaBrowser.Controller/Entities/UserView.cs +++ b/MediaBrowser.Controller/Entities/UserView.cs @@ -8,6 +8,7 @@ using System.Linq; using System.Text.Json.Serialization; using System.Threading.Tasks; using Jellyfin.Data.Entities; +using Jellyfin.Extensions; using MediaBrowser.Controller.TV; using MediaBrowser.Model.Querying; @@ -102,7 +103,7 @@ namespace MediaBrowser.Controller.Entities parent = LibraryManager.GetItemById(ParentId) as Folder ?? parent; } - return new UserViewBuilder(UserViewManager, LibraryManager, Logger, UserDataManager, TVSeriesManager, ConfigurationManager) + return new UserViewBuilder(UserViewManager, LibraryManager, Logger, UserDataManager, TVSeriesManager) .GetUserItems(parent, this, CollectionType, query); } @@ -170,12 +171,12 @@ namespace MediaBrowser.Controller.Entities public static bool IsEligibleForGrouping(string viewType) { - return _viewTypesEligibleForGrouping.Contains(viewType ?? string.Empty, StringComparer.OrdinalIgnoreCase); + return _viewTypesEligibleForGrouping.Contains(viewType ?? string.Empty, StringComparison.OrdinalIgnoreCase); } public static bool EnableOriginalFolder(string viewType) { - return _originalFolderViewTypes.Contains(viewType ?? string.Empty, StringComparer.OrdinalIgnoreCase); + return _originalFolderViewTypes.Contains(viewType ?? string.Empty, StringComparison.OrdinalIgnoreCase); } protected override Task ValidateChildrenInternal(IProgress<double> progress, bool recursive, bool refreshChildMetadata, Providers.MetadataRefreshOptions refreshOptions, Providers.IDirectoryService directoryService, System.Threading.CancellationToken cancellationToken) diff --git a/MediaBrowser.Controller/Entities/UserViewBuilder.cs b/MediaBrowser.Controller/Entities/UserViewBuilder.cs index 266fda767..fe44f1169 100644 --- a/MediaBrowser.Controller/Entities/UserViewBuilder.cs +++ b/MediaBrowser.Controller/Entities/UserViewBuilder.cs @@ -8,8 +8,7 @@ using System.Globalization; using System.Linq; using Jellyfin.Data.Entities; using Jellyfin.Data.Enums; -using MediaBrowser.Controller.Configuration; -using MediaBrowser.Controller.Entities.Movies; +using Jellyfin.Extensions; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.TV; using MediaBrowser.Model.Entities; @@ -17,8 +16,6 @@ using MediaBrowser.Model.Querying; using Microsoft.Extensions.Logging; using Episode = MediaBrowser.Controller.Entities.TV.Episode; using MetadataProvider = MediaBrowser.Model.Entities.MetadataProvider; -using Movie = MediaBrowser.Controller.Entities.Movies.Movie; -using Season = MediaBrowser.Controller.Entities.TV.Season; using Series = MediaBrowser.Controller.Entities.TV.Series; namespace MediaBrowser.Controller.Entities @@ -30,22 +27,19 @@ namespace MediaBrowser.Controller.Entities private readonly ILogger<BaseItem> _logger; private readonly IUserDataManager _userDataManager; private readonly ITVSeriesManager _tvSeriesManager; - private readonly IServerConfigurationManager _config; public UserViewBuilder( IUserViewManager userViewManager, ILibraryManager libraryManager, ILogger<BaseItem> logger, IUserDataManager userDataManager, - ITVSeriesManager tvSeriesManager, - IServerConfigurationManager config) + ITVSeriesManager tvSeriesManager) { _userViewManager = userViewManager; _libraryManager = libraryManager; _logger = logger; _userDataManager = userDataManager; _tvSeriesManager = tvSeriesManager; - _config = config; } public QueryResult<BaseItem> GetUserItems(Folder queryParent, Folder displayParent, string viewType, InternalItemsQuery query) @@ -144,7 +138,7 @@ namespace MediaBrowser.Controller.Entities if (query.IncludeItemTypes.Length == 0) { - query.IncludeItemTypes = new[] { nameof(Movie) }; + query.IncludeItemTypes = new[] { BaseItemKind.Movie }; } return parent.QueryRecursive(query); @@ -169,7 +163,7 @@ namespace MediaBrowser.Controller.Entities query.Parent = parent; query.SetUser(user); query.IsFavorite = true; - query.IncludeItemTypes = new[] { nameof(Movie) }; + query.IncludeItemTypes = new[] { BaseItemKind.Movie }; return _libraryManager.GetItemsResult(query); } @@ -180,7 +174,7 @@ namespace MediaBrowser.Controller.Entities query.Parent = parent; query.SetUser(user); query.IsFavorite = true; - query.IncludeItemTypes = new[] { nameof(Series) }; + query.IncludeItemTypes = new[] { BaseItemKind.Series }; return _libraryManager.GetItemsResult(query); } @@ -191,7 +185,7 @@ namespace MediaBrowser.Controller.Entities query.Parent = parent; query.SetUser(user); query.IsFavorite = true; - query.IncludeItemTypes = new[] { nameof(Episode) }; + query.IncludeItemTypes = new[] { BaseItemKind.Episode }; return _libraryManager.GetItemsResult(query); } @@ -202,7 +196,7 @@ namespace MediaBrowser.Controller.Entities query.Parent = parent; query.SetUser(user); - query.IncludeItemTypes = new[] { nameof(Movie) }; + query.IncludeItemTypes = new[] { BaseItemKind.Movie }; return _libraryManager.GetItemsResult(query); } @@ -210,7 +204,7 @@ namespace MediaBrowser.Controller.Entities private QueryResult<BaseItem> GetMovieCollections(User user, InternalItemsQuery query) { query.Parent = null; - query.IncludeItemTypes = new[] { nameof(BoxSet) }; + query.IncludeItemTypes = new[] { BaseItemKind.BoxSet }; query.SetUser(user); query.Recursive = true; @@ -224,7 +218,7 @@ namespace MediaBrowser.Controller.Entities query.Parent = parent; query.SetUser(user); query.Limit = GetSpecialItemsLimit(); - query.IncludeItemTypes = new[] { nameof(Movie) }; + query.IncludeItemTypes = new[] { BaseItemKind.Movie }; return ConvertToResult(_libraryManager.GetItemList(query)); } @@ -237,7 +231,7 @@ namespace MediaBrowser.Controller.Entities query.Parent = parent; query.SetUser(user); query.Limit = GetSpecialItemsLimit(); - query.IncludeItemTypes = new[] { nameof(Movie) }; + query.IncludeItemTypes = new[] { BaseItemKind.Movie }; return ConvertToResult(_libraryManager.GetItemList(query)); } @@ -256,7 +250,7 @@ namespace MediaBrowser.Controller.Entities { var genres = parent.QueryRecursive(new InternalItemsQuery(user) { - IncludeItemTypes = new[] { nameof(Movie) }, + IncludeItemTypes = new[] { BaseItemKind.Movie }, Recursive = true, EnableTotalRecordCount = false }).Items @@ -287,7 +281,7 @@ namespace MediaBrowser.Controller.Entities query.GenreIds = new[] { displayParent.Id }; query.SetUser(user); - query.IncludeItemTypes = new[] { nameof(Movie) }; + query.IncludeItemTypes = new[] { BaseItemKind.Movie }; return _libraryManager.GetItemsResult(query); } @@ -303,9 +297,9 @@ namespace MediaBrowser.Controller.Entities { query.IncludeItemTypes = new[] { - nameof(Series), - nameof(Season), - nameof(Episode) + BaseItemKind.Series, + BaseItemKind.Season, + BaseItemKind.Episode }; } @@ -333,7 +327,7 @@ namespace MediaBrowser.Controller.Entities query.Parent = parent; query.SetUser(user); query.Limit = GetSpecialItemsLimit(); - query.IncludeItemTypes = new[] { nameof(Episode) }; + query.IncludeItemTypes = new[] { BaseItemKind.Episode }; query.IsVirtualItem = false; return ConvertToResult(_libraryManager.GetItemList(query)); @@ -364,7 +358,7 @@ namespace MediaBrowser.Controller.Entities query.Parent = parent; query.SetUser(user); query.Limit = GetSpecialItemsLimit(); - query.IncludeItemTypes = new[] { nameof(Episode) }; + query.IncludeItemTypes = new[] { BaseItemKind.Episode }; return ConvertToResult(_libraryManager.GetItemList(query)); } @@ -375,7 +369,7 @@ namespace MediaBrowser.Controller.Entities query.Parent = parent; query.SetUser(user); - query.IncludeItemTypes = new[] { nameof(Series) }; + query.IncludeItemTypes = new[] { BaseItemKind.Series }; return _libraryManager.GetItemsResult(query); } @@ -384,7 +378,7 @@ namespace MediaBrowser.Controller.Entities { var genres = parent.QueryRecursive(new InternalItemsQuery(user) { - IncludeItemTypes = new[] { nameof(Series) }, + IncludeItemTypes = new[] { BaseItemKind.Series }, Recursive = true, EnableTotalRecordCount = false }).Items @@ -415,7 +409,7 @@ namespace MediaBrowser.Controller.Entities query.GenreIds = new[] { displayParent.Id }; query.SetUser(user); - query.IncludeItemTypes = new[] { nameof(Series) }; + query.IncludeItemTypes = new[] { BaseItemKind.Series }; return _libraryManager.GetItemsResult(query); } @@ -498,17 +492,17 @@ namespace MediaBrowser.Controller.Entities public static bool Filter(BaseItem item, User user, InternalItemsQuery query, IUserDataManager userDataManager, ILibraryManager libraryManager) { - if (query.MediaTypes.Length > 0 && !query.MediaTypes.Contains(item.MediaType ?? string.Empty, StringComparer.OrdinalIgnoreCase)) + if (query.MediaTypes.Length > 0 && !query.MediaTypes.Contains(item.MediaType ?? string.Empty, StringComparison.OrdinalIgnoreCase)) { return false; } - if (query.IncludeItemTypes.Length > 0 && !query.IncludeItemTypes.Contains(item.GetClientTypeName(), StringComparer.OrdinalIgnoreCase)) + if (query.IncludeItemTypes.Length > 0 && !query.IncludeItemTypes.Contains(item.GetBaseItemKind())) { return false; } - if (query.ExcludeItemTypes.Length > 0 && query.ExcludeItemTypes.Contains(item.GetClientTypeName(), StringComparer.OrdinalIgnoreCase)) + if (query.ExcludeItemTypes.Length > 0 && query.ExcludeItemTypes.Contains(item.GetBaseItemKind())) { return false; } @@ -749,10 +743,9 @@ namespace MediaBrowser.Controller.Entities var val = query.HasTrailer.Value; var trailerCount = 0; - var hasTrailers = item as IHasTrailers; - if (hasTrailers != null) + if (item is IHasTrailers hasTrailers) { - trailerCount = hasTrailers.GetTrailerIds().Count; + trailerCount = hasTrailers.GetTrailerCount(); } var ok = val ? trailerCount > 0 : trailerCount == 0; @@ -767,7 +760,7 @@ namespace MediaBrowser.Controller.Entities { var filterValue = query.HasThemeSong.Value; - var themeCount = item.ThemeSongIds.Length; + var themeCount = item.GetThemeSongs().Count; var ok = filterValue ? themeCount > 0 : themeCount == 0; if (!ok) @@ -780,7 +773,7 @@ namespace MediaBrowser.Controller.Entities { var filterValue = query.HasThemeVideo.Value; - var themeCount = item.ThemeVideoIds.Length; + var themeCount = item.GetThemeVideos().Count; var ok = filterValue ? themeCount > 0 : themeCount == 0; if (!ok) @@ -790,7 +783,7 @@ namespace MediaBrowser.Controller.Entities } // Apply genre filter - if (query.Genres.Count > 0 && !query.Genres.Any(v => item.Genres.Contains(v, StringComparer.OrdinalIgnoreCase))) + if (query.Genres.Count > 0 && !query.Genres.Any(v => item.Genres.Contains(v, StringComparison.OrdinalIgnoreCase))) { return false; } @@ -814,7 +807,7 @@ namespace MediaBrowser.Controller.Entities if (query.StudioIds.Length > 0 && !query.StudioIds.Any(id => { var studioItem = libraryManager.GetItemById(id); - return studioItem != null && item.Studios.Contains(studioItem.Name, StringComparer.OrdinalIgnoreCase); + return studioItem != null && item.Studios.Contains(studioItem.Name, StringComparison.OrdinalIgnoreCase); })) { return false; @@ -824,7 +817,7 @@ namespace MediaBrowser.Controller.Entities if (query.GenreIds.Count > 0 && !query.GenreIds.Any(id => { var genreItem = libraryManager.GetItemById(id); - return genreItem != null && item.Genres.Contains(genreItem.Name, StringComparer.OrdinalIgnoreCase); + return genreItem != null && item.Genres.Contains(genreItem.Name, StringComparison.OrdinalIgnoreCase); })) { return false; @@ -857,7 +850,7 @@ namespace MediaBrowser.Controller.Entities var tags = query.Tags; if (tags.Length > 0) { - if (!tags.Any(v => item.Tags.Contains(v, StringComparer.OrdinalIgnoreCase))) + if (!tags.Any(v => item.Tags.Contains(v, StringComparison.OrdinalIgnoreCase))) { return false; } @@ -975,7 +968,7 @@ namespace MediaBrowser.Controller.Entities { var folder = i as ICollectionFolder; - return folder != null && viewTypes.Contains(folder.CollectionType ?? string.Empty, StringComparer.OrdinalIgnoreCase); + return folder != null && viewTypes.Contains(folder.CollectionType ?? string.Empty, StringComparison.OrdinalIgnoreCase); }).ToArray(); } @@ -984,7 +977,7 @@ namespace MediaBrowser.Controller.Entities { var folder = i as ICollectionFolder; - return folder != null && viewTypes.Contains(folder.CollectionType ?? string.Empty, StringComparer.OrdinalIgnoreCase); + return folder != null && viewTypes.Contains(folder.CollectionType ?? string.Empty, StringComparison.OrdinalIgnoreCase); }).ToArray(); } diff --git a/MediaBrowser.Controller/Entities/Video.cs b/MediaBrowser.Controller/Entities/Video.cs index 7dd95b85c..3e125602a 100644 --- a/MediaBrowser.Controller/Entities/Video.cs +++ b/MediaBrowser.Controller/Entities/Video.cs @@ -9,6 +9,7 @@ using System.Linq; using System.Text.Json.Serialization; using System.Threading; using System.Threading.Tasks; +using Jellyfin.Extensions; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.LiveTv; using MediaBrowser.Controller.Persistence; @@ -33,6 +34,7 @@ namespace MediaBrowser.Controller.Entities AdditionalParts = Array.Empty<string>(); LocalAlternateVersions = Array.Empty<string>(); SubtitleFiles = Array.Empty<string>(); + AudioFiles = Array.Empty<string>(); LinkedAlternateVersions = Array.Empty<LinkedChild>(); } @@ -97,6 +99,12 @@ namespace MediaBrowser.Controller.Entities /// <value>The subtitle paths.</value> public string[] SubtitleFiles { get; set; } + /// <summary> + /// Gets or sets the audio paths. + /// </summary> + /// <value>The audio paths.</value> + public string[] AudioFiles { get; set; } + /// <summary> /// Gets or sets a value indicating whether this instance has subtitles. /// </summary> @@ -185,7 +193,7 @@ namespace MediaBrowser.Controller.Entities { if (SourceType == SourceType.Channel) { - return !Tags.Contains("livestream", StringComparer.OrdinalIgnoreCase); + return !Tags.Contains("livestream", StringComparison.OrdinalIgnoreCase); } return !IsActiveRecording(); @@ -509,35 +517,35 @@ namespace MediaBrowser.Controller.Entities }).FirstOrDefault(); } - protected override List<Tuple<BaseItem, MediaSourceType>> GetAllItemsForMediaSources() + protected override IEnumerable<(BaseItem Item, MediaSourceType MediaSourceType)> GetAllItemsForMediaSources() { - var list = new List<Tuple<BaseItem, MediaSourceType>>(); + var list = new List<(BaseItem, MediaSourceType)> + { + (this, MediaSourceType.Default) + }; - list.Add(new Tuple<BaseItem, MediaSourceType>(this, MediaSourceType.Default)); - list.AddRange(GetLinkedAlternateVersions().Select(i => new Tuple<BaseItem, MediaSourceType>(i, MediaSourceType.Grouping))); + list.AddRange(GetLinkedAlternateVersions().Select(i => ((BaseItem)i, MediaSourceType.Grouping))); if (!string.IsNullOrEmpty(PrimaryVersionId)) { - var primary = LibraryManager.GetItemById(PrimaryVersionId) as Video; - if (primary != null) + if (LibraryManager.GetItemById(PrimaryVersionId) is Video primary) { var existingIds = list.Select(i => i.Item1.Id).ToList(); - list.Add(new Tuple<BaseItem, MediaSourceType>(primary, MediaSourceType.Grouping)); - list.AddRange(primary.GetLinkedAlternateVersions().Where(i => !existingIds.Contains(i.Id)).Select(i => new Tuple<BaseItem, MediaSourceType>(i, MediaSourceType.Grouping))); + list.Add((primary, MediaSourceType.Grouping)); + list.AddRange(primary.GetLinkedAlternateVersions().Where(i => !existingIds.Contains(i.Id)).Select(i => ((BaseItem)i, MediaSourceType.Grouping))); } } var localAlternates = list .SelectMany(i => { - var video = i.Item1 as Video; - return video == null ? new List<Guid>() : video.GetLocalAlternateVersionIds(); + return i.Item1 is Video video ? video.GetLocalAlternateVersionIds() : Enumerable.Empty<Guid>(); }) .Select(LibraryManager.GetItemById) .Where(i => i != null) .ToList(); - list.AddRange(localAlternates.Select(i => new Tuple<BaseItem, MediaSourceType>(i, MediaSourceType.Default))); + list.AddRange(localAlternates.Select(i => (i, MediaSourceType.Default))); return list; } diff --git a/MediaBrowser.Controller/Entities/Year.cs b/MediaBrowser.Controller/Entities/Year.cs index 0853200dd..afdaf448b 100644 --- a/MediaBrowser.Controller/Entities/Year.cs +++ b/MediaBrowser.Controller/Entities/Year.cs @@ -57,9 +57,7 @@ namespace MediaBrowser.Controller.Entities public IList<BaseItem> GetTaggedItems(InternalItemsQuery query) { - var usCulture = new CultureInfo("en-US"); - - if (!int.TryParse(Name, NumberStyles.Integer, usCulture, out var year)) + if (!int.TryParse(Name, NumberStyles.Integer, CultureInfo.InvariantCulture, out var year)) { return new List<BaseItem>(); } diff --git a/MediaBrowser.Controller/IO/FileData.cs b/MediaBrowser.Controller/IO/FileData.cs index b8a0bf331..2429ac42d 100644 --- a/MediaBrowser.Controller/IO/FileData.cs +++ b/MediaBrowser.Controller/IO/FileData.cs @@ -69,7 +69,7 @@ namespace MediaBrowser.Controller.IO if (string.IsNullOrEmpty(newPath)) { // invalid shortcut - could be old or target could just be unavailable - logger.LogWarning("Encountered invalid shortcut: " + fullName); + logger.LogWarning("Encountered invalid shortcut: {Path}", fullName); continue; } @@ -83,7 +83,7 @@ namespace MediaBrowser.Controller.IO } catch (Exception ex) { - logger.LogError(ex, "Error resolving shortcut from {path}", fullName); + logger.LogError(ex, "Error resolving shortcut from {Path}", fullName); } } else if (flattenFolderDepth > 0 && isDirectory) diff --git a/MediaBrowser.Controller/IServerApplicationHost.cs b/MediaBrowser.Controller/IServerApplicationHost.cs index 3da0a5875..75ec5f213 100644 --- a/MediaBrowser.Controller/IServerApplicationHost.cs +++ b/MediaBrowser.Controller/IServerApplicationHost.cs @@ -2,7 +2,6 @@ #pragma warning disable CS1591 -using System.Collections.Generic; using System.Net; using MediaBrowser.Common; using MediaBrowser.Model.System; @@ -42,50 +41,42 @@ namespace MediaBrowser.Controller /// <value>The name of the friendly.</value> string FriendlyName { get; } - /// <summary> - /// Gets the configured published server url. - /// </summary> - string PublishedServerUrl { get; } - /// <summary> /// Gets the system info. /// </summary> - /// <param name="source">The originator of the request.</param> + /// <param name="request">The HTTP request.</param> /// <returns>SystemInfo.</returns> - SystemInfo GetSystemInfo(IPAddress source); + SystemInfo GetSystemInfo(HttpRequest request); - PublicSystemInfo GetPublicSystemInfo(IPAddress address); + PublicSystemInfo GetPublicSystemInfo(HttpRequest request); /// <summary> /// Gets a URL specific for the request. /// </summary> /// <param name="request">The <see cref="HttpRequest"/> instance.</param> - /// <param name="port">Optional port number.</param> /// <returns>An accessible URL.</returns> - string GetSmartApiUrl(HttpRequest request, int? port = null); + string GetSmartApiUrl(HttpRequest request); /// <summary> /// Gets a URL specific for the request. /// </summary> /// <param name="remoteAddr">The remote <see cref="IPAddress"/> of the connection.</param> - /// <param name="port">Optional port number.</param> /// <returns>An accessible URL.</returns> - string GetSmartApiUrl(IPAddress remoteAddr, int? port = null); + string GetSmartApiUrl(IPAddress remoteAddr); /// <summary> /// Gets a URL specific for the request. /// </summary> /// <param name="hostname">The hostname used in the connection.</param> - /// <param name="port">Optional port number.</param> /// <returns>An accessible URL.</returns> - string GetSmartApiUrl(string hostname, int? port = null); + string GetSmartApiUrl(string hostname); /// <summary> - /// Gets a localhost URL that can be used to access the API using the loop-back IP address. - /// over HTTP (not HTTPS). + /// Gets an URL that can be used to access the API over LAN. /// </summary> + /// <param name="allowHttps">A value indicating whether to allow HTTPS.</param> /// <returns>The API URL.</returns> - string GetLoopbackHttpApiUrl(); + string GetApiUrlForLocalAccess(bool allowHttps = true); /// <summary> /// Gets a local (LAN) URL that can be used to access the API. @@ -103,8 +94,6 @@ namespace MediaBrowser.Controller /// <returns>The API URL.</returns> string GetLocalApiUrl(string hostname, string scheme = null, int? port = null); - IEnumerable<WakeOnLanInfo> GetWakeOnLanInfo(); - string ExpandVirtualPath(string path); string ReverseVirtualPath(string path); diff --git a/MediaBrowser.Controller/Library/IDirectStreamProvider.cs b/MediaBrowser.Controller/Library/IDirectStreamProvider.cs new file mode 100644 index 000000000..96f8b7eba --- /dev/null +++ b/MediaBrowser.Controller/Library/IDirectStreamProvider.cs @@ -0,0 +1,19 @@ +using System.IO; + +namespace MediaBrowser.Controller.Library +{ + /// <summary> + /// The direct live TV stream provider. + /// </summary> + /// <remarks> + /// Deprecated. + /// </remarks> + public interface IDirectStreamProvider + { + /// <summary> + /// Gets the live stream, shared streams seek to the end of the file first. + /// </summary> + /// <returns>The stream.</returns> + Stream GetStream(); + } +} diff --git a/MediaBrowser.Controller/Library/ILibraryManager.cs b/MediaBrowser.Controller/Library/ILibraryManager.cs index d40e56c7d..8db528330 100644 --- a/MediaBrowser.Controller/Library/ILibraryManager.cs +++ b/MediaBrowser.Controller/Library/ILibraryManager.cs @@ -6,7 +6,6 @@ using System; using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; -using Emby.Naming.Common; using Jellyfin.Data.Entities; using Jellyfin.Data.Enums; using MediaBrowser.Controller.Dto; @@ -59,10 +58,12 @@ namespace MediaBrowser.Controller.Library /// </summary> /// <param name="fileInfo">The file information.</param> /// <param name="parent">The parent.</param> + /// <param name="directoryService">An instance of <see cref="IDirectoryService"/>.</param> /// <returns>BaseItem.</returns> BaseItem ResolvePath( FileSystemMetadata fileInfo, - Folder parent = null); + Folder parent = null, + IDirectoryService directoryService = null); /// <summary> /// Resolves a set of files into a list of BaseItem. @@ -211,7 +212,7 @@ namespace MediaBrowser.Controller.Library /// <returns>IEnumerable{BaseItem}.</returns> IEnumerable<BaseItem> Sort(IEnumerable<BaseItem> items, User user, IEnumerable<string> sortBy, SortOrder sortOrder); - IEnumerable<BaseItem> Sort(IEnumerable<BaseItem> items, User user, IEnumerable<ValueTuple<string, SortOrder>> orderBy); + IEnumerable<BaseItem> Sort(IEnumerable<BaseItem> items, User user, IEnumerable<(string OrderBy, SortOrder SortOrder)> orderBy); /// <summary> /// Gets the user root folder. @@ -396,20 +397,6 @@ namespace MediaBrowser.Controller.Library string viewType, string sortName); - /// <summary> - /// Determines whether [is video file] [the specified path]. - /// </summary> - /// <param name="path">The path.</param> - /// <returns><c>true</c> if [is video file] [the specified path]; otherwise, <c>false</c>.</returns> - bool IsVideoFile(string path); - - /// <summary> - /// Determines whether [is audio file] [the specified path]. - /// </summary> - /// <param name="path">The path.</param> - /// <returns><c>true</c> if [is audio file] [the specified path]; otherwise, <c>false</c>.</returns> - bool IsAudioFile(string path); - /// <summary> /// Gets the season number from path. /// </summary> @@ -440,29 +427,14 @@ namespace MediaBrowser.Controller.Library /// <returns>Guid.</returns> Guid GetNewItemId(string key, Type type); - /// <summary> - /// Finds the trailers. - /// </summary> - /// <param name="owner">The owner.</param> - /// <param name="fileSystemChildren">The file system children.</param> - /// <param name="directoryService">The directory service.</param> - /// <returns>IEnumerable<Trailer>.</returns> - IEnumerable<Video> FindTrailers( - BaseItem owner, - List<FileSystemMetadata> fileSystemChildren, - IDirectoryService directoryService); - /// <summary> /// Finds the extras. /// </summary> /// <param name="owner">The owner.</param> /// <param name="fileSystemChildren">The file system children.</param> - /// <param name="directoryService">The directory service.</param> - /// <returns>IEnumerable<Video>.</returns> - IEnumerable<Video> FindExtras( - BaseItem owner, - List<FileSystemMetadata> fileSystemChildren, - IDirectoryService directoryService); + /// <param name="directoryService">An instance of <see cref="IDirectoryService"/>.</param> + /// <returns>IEnumerable<BaseItem>.</returns> + IEnumerable<BaseItem> FindExtras(BaseItem owner, List<FileSystemMetadata> fileSystemChildren, IDirectoryService directoryService); /// <summary> /// Gets the collection folders. @@ -601,17 +573,17 @@ namespace MediaBrowser.Controller.Library void RemoveMediaPath(string virtualFolderName, string mediaPath); - QueryResult<(BaseItem, ItemCounts)> GetGenres(InternalItemsQuery query); + QueryResult<(BaseItem Item, ItemCounts ItemCounts)> GetGenres(InternalItemsQuery query); - QueryResult<(BaseItem, ItemCounts)> GetMusicGenres(InternalItemsQuery query); + QueryResult<(BaseItem Item, ItemCounts ItemCounts)> GetMusicGenres(InternalItemsQuery query); - QueryResult<(BaseItem, ItemCounts)> GetStudios(InternalItemsQuery query); + QueryResult<(BaseItem Item, ItemCounts ItemCounts)> GetStudios(InternalItemsQuery query); - QueryResult<(BaseItem, ItemCounts)> GetArtists(InternalItemsQuery query); + QueryResult<(BaseItem Item, ItemCounts ItemCounts)> GetArtists(InternalItemsQuery query); - QueryResult<(BaseItem, ItemCounts)> GetAlbumArtists(InternalItemsQuery query); + QueryResult<(BaseItem Item, ItemCounts ItemCounts)> GetAlbumArtists(InternalItemsQuery query); - QueryResult<(BaseItem, ItemCounts)> GetAllArtists(InternalItemsQuery query); + QueryResult<(BaseItem Item, ItemCounts ItemCounts)> GetAllArtists(InternalItemsQuery query); int GetCount(InternalItemsQuery query); @@ -625,11 +597,5 @@ namespace MediaBrowser.Controller.Library BaseItem GetParentItem(string parentId, Guid? userId); BaseItem GetParentItem(Guid? parentId, Guid? userId); - - /// <summary> - /// Gets or creates a static instance of <see cref="NamingOptions"/>. - /// </summary> - /// <returns>An instance of the <see cref="NamingOptions"/> class.</returns> - NamingOptions GetNamingOptions(); } } diff --git a/MediaBrowser.Controller/Library/ILiveStream.cs b/MediaBrowser.Controller/Library/ILiveStream.cs index 323aa4876..4c44a17fd 100644 --- a/MediaBrowser.Controller/Library/ILiveStream.cs +++ b/MediaBrowser.Controller/Library/ILiveStream.cs @@ -2,6 +2,7 @@ #pragma warning disable CA1711, CS1591 +using System.IO; using System.Threading; using System.Threading.Tasks; using MediaBrowser.Model.Dto; @@ -25,5 +26,7 @@ namespace MediaBrowser.Controller.Library Task Open(CancellationToken openCancellationToken); Task Close(); + + Stream GetStream(); } } diff --git a/MediaBrowser.Controller/Library/IMediaSourceManager.cs b/MediaBrowser.Controller/Library/IMediaSourceManager.cs index fd3631da9..f1758a9d8 100644 --- a/MediaBrowser.Controller/Library/IMediaSourceManager.cs +++ b/MediaBrowser.Controller/Library/IMediaSourceManager.cs @@ -4,7 +4,6 @@ using System; using System.Collections.Generic; -using System.IO; using System.Threading; using System.Threading.Tasks; using Jellyfin.Data.Entities; @@ -31,13 +30,6 @@ namespace MediaBrowser.Controller.Library /// <returns>IEnumerable<MediaStream>.</returns> List<MediaStream> GetMediaStreams(Guid itemId); - /// <summary> - /// Gets the media streams. - /// </summary> - /// <param name="mediaSourceId">The media source identifier.</param> - /// <returns>IEnumerable<MediaStream>.</returns> - List<MediaStream> GetMediaStreams(string mediaSourceId); - /// <summary> /// Gets the media streams. /// </summary> @@ -110,6 +102,20 @@ namespace MediaBrowser.Controller.Library Task<Tuple<MediaSourceInfo, IDirectStreamProvider>> GetLiveStreamWithDirectStreamProvider(string id, CancellationToken cancellationToken); + /// <summary> + /// Gets the live stream info. + /// </summary> + /// <param name="id">The identifier.</param> + /// <returns>An instance of <see cref="ILiveStream"/>.</returns> + public ILiveStream GetLiveStreamInfo(string id); + + /// <summary> + /// Gets the live stream info using the stream's unique id. + /// </summary> + /// <param name="uniqueId">The unique identifier.</param> + /// <returns>An instance of <see cref="ILiveStream"/>.</returns> + public ILiveStream GetLiveStreamInfoByUniqueId(string uniqueId); + /// <summary> /// Closes the media source. /// </summary> @@ -126,14 +132,5 @@ namespace MediaBrowser.Controller.Library void SetDefaultAudioAndSubtitleStreamIndexes(BaseItem item, MediaSourceInfo source, User user); Task AddMediaInfoWithProbe(MediaSourceInfo mediaSource, bool isAudio, string cacheKey, bool addProbeDelay, bool isLiveStream, CancellationToken cancellationToken); - - Task<IDirectStreamProvider> GetDirectStreamProviderByUniqueId(string uniqueId, CancellationToken cancellationToken); - } - - public interface IDirectStreamProvider - { - Task CopyToAsync(Stream stream, CancellationToken cancellationToken); - - string GetFilePath(); } } diff --git a/MediaBrowser.Controller/Library/ItemResolveArgs.cs b/MediaBrowser.Controller/Library/ItemResolveArgs.cs index bfc1e4857..91d162b41 100644 --- a/MediaBrowser.Controller/Library/ItemResolveArgs.cs +++ b/MediaBrowser.Controller/Library/ItemResolveArgs.cs @@ -36,6 +36,7 @@ namespace MediaBrowser.Controller.Library DirectoryService = directoryService; } + // TODO remove dependencies as properties, they should be injected where it makes sense public IDirectoryService DirectoryService { get; } /// <summary> @@ -236,6 +237,40 @@ namespace MediaBrowser.Controller.Library return CollectionType; } + /// <summary> + /// Gets the configured content type for the path. + /// </summary> + /// <remarks> + /// This is subject to future refactoring as it relies on a static property in BaseItem. + /// </remarks> + /// <returns>The configured content type.</returns> + public string GetConfiguredContentType() + { + return BaseItem.LibraryManager.GetConfiguredContentType(Path); + } + + /// <summary> + /// Gets the file system children that do not hit the ignore file check. + /// </summary> + /// <remarks> + /// This is subject to future refactoring as it relies on a static property in BaseItem. + /// </remarks> + /// <returns>The file system children that are not ignored.</returns> + public IEnumerable<FileSystemMetadata> GetActualFileSystemChildren() + { + var numberOfChildren = FileSystemChildren.Length; + for (var i = 0; i < numberOfChildren; i++) + { + var child = FileSystemChildren[i]; + if (BaseItem.LibraryManager.IgnoreFile(child, Parent)) + { + continue; + } + + yield return child; + } + } + /// <summary> /// Returns a hash code for this instance. /// </summary> diff --git a/MediaBrowser.Controller/LiveTv/ActiveRecordingInfo.cs b/MediaBrowser.Controller/LiveTv/ActiveRecordingInfo.cs index 463061e68..1a81a8a31 100644 --- a/MediaBrowser.Controller/LiveTv/ActiveRecordingInfo.cs +++ b/MediaBrowser.Controller/LiveTv/ActiveRecordingInfo.cs @@ -16,4 +16,4 @@ namespace MediaBrowser.Controller.LiveTv public CancellationTokenSource CancellationTokenSource { get; set; } } -} \ No newline at end of file +} diff --git a/MediaBrowser.Controller/LiveTv/ILiveTvManager.cs b/MediaBrowser.Controller/LiveTv/ILiveTvManager.cs index dbd18165d..6dc5665b2 100644 --- a/MediaBrowser.Controller/LiveTv/ILiveTvManager.cs +++ b/MediaBrowser.Controller/LiveTv/ILiveTvManager.cs @@ -251,7 +251,7 @@ namespace MediaBrowser.Controller.LiveTv /// <param name="fields">The fields.</param> /// <param name="user">The user.</param> /// <returns>Task.</returns> - Task AddInfoToProgramDto(IReadOnlyCollection<(BaseItem, BaseItemDto)> programs, IReadOnlyList<ItemFields> fields, User user = null); + Task AddInfoToProgramDto(IReadOnlyCollection<(BaseItem Item, BaseItemDto ItemDto)> programs, IReadOnlyList<ItemFields> fields, User user = null); /// <summary> /// Saves the tuner host. @@ -292,7 +292,7 @@ namespace MediaBrowser.Controller.LiveTv /// <param name="items">The items.</param> /// <param name="options">The options.</param> /// <param name="user">The user.</param> - void AddChannelInfo(IReadOnlyCollection<(BaseItemDto, LiveTvChannel)> items, DtoOptions options, User user); + void AddChannelInfo(IReadOnlyCollection<(BaseItemDto ItemDto, LiveTvChannel Channel)> items, DtoOptions options, User user); Task<List<ChannelInfo>> GetChannelsForListingsProvider(string id, CancellationToken cancellationToken); diff --git a/MediaBrowser.Controller/LiveTv/LiveTvChannel.cs b/MediaBrowser.Controller/LiveTv/LiveTvChannel.cs index 074e023e8..e63874f21 100644 --- a/MediaBrowser.Controller/LiveTv/LiveTvChannel.cs +++ b/MediaBrowser.Controller/LiveTv/LiveTvChannel.cs @@ -5,9 +5,9 @@ using System; using System.Collections.Generic; using System.Globalization; -using System.Linq; using System.Text.Json.Serialization; using Jellyfin.Data.Enums; +using Jellyfin.Extensions; using MediaBrowser.Controller.Entities; using MediaBrowser.Model.Dto; using MediaBrowser.Model.Entities; @@ -74,7 +74,7 @@ namespace MediaBrowser.Controller.LiveTv /// </summary> /// <value><c>true</c> if this instance is kids; otherwise, <c>false</c>.</value> [JsonIgnore] - public bool IsKids => Tags.Contains("Kids", StringComparer.OrdinalIgnoreCase); + public bool IsKids => Tags.Contains("Kids", StringComparison.OrdinalIgnoreCase); [JsonIgnore] public bool IsRepeat { get; set; } @@ -107,9 +107,7 @@ namespace MediaBrowser.Controller.LiveTv { if (!string.IsNullOrEmpty(Number)) { - double number = 0; - - if (double.TryParse(Number, NumberStyles.Any, CultureInfo.InvariantCulture, out number)) + if (double.TryParse(Number, NumberStyles.Any, CultureInfo.InvariantCulture, out double number)) { return string.Format(CultureInfo.InvariantCulture, "{0:00000.0}", number) + "-" + (Name ?? string.Empty); } diff --git a/MediaBrowser.Controller/LiveTv/LiveTvProgram.cs b/MediaBrowser.Controller/LiveTv/LiveTvProgram.cs index 111dc0d27..6c4a5ea17 100644 --- a/MediaBrowser.Controller/LiveTv/LiveTvProgram.cs +++ b/MediaBrowser.Controller/LiveTv/LiveTvProgram.cs @@ -8,6 +8,7 @@ using System.Globalization; using System.Linq; using System.Text.Json.Serialization; using Jellyfin.Data.Enums; +using Jellyfin.Extensions; using MediaBrowser.Common.Configuration; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Providers; @@ -66,7 +67,7 @@ namespace MediaBrowser.Controller.LiveTv /// </summary> /// <value><c>true</c> if this instance is sports; otherwise, <c>false</c>.</value> [JsonIgnore] - public bool IsSports => Tags.Contains("Sports", StringComparer.OrdinalIgnoreCase); + public bool IsSports => Tags.Contains("Sports", StringComparison.OrdinalIgnoreCase); /// <summary> /// Gets or sets a value indicating whether this instance is series. @@ -80,28 +81,28 @@ namespace MediaBrowser.Controller.LiveTv /// </summary> /// <value><c>true</c> if this instance is live; otherwise, <c>false</c>.</value> [JsonIgnore] - public bool IsLive => Tags.Contains("Live", StringComparer.OrdinalIgnoreCase); + public bool IsLive => Tags.Contains("Live", StringComparison.OrdinalIgnoreCase); /// <summary> /// Gets a value indicating whether this instance is news. /// </summary> /// <value><c>true</c> if this instance is news; otherwise, <c>false</c>.</value> [JsonIgnore] - public bool IsNews => Tags.Contains("News", StringComparer.OrdinalIgnoreCase); + public bool IsNews => Tags.Contains("News", StringComparison.OrdinalIgnoreCase); /// <summary> /// Gets a value indicating whether this instance is kids. /// </summary> /// <value><c>true</c> if this instance is kids; otherwise, <c>false</c>.</value> [JsonIgnore] - public bool IsKids => Tags.Contains("Kids", StringComparer.OrdinalIgnoreCase); + public bool IsKids => Tags.Contains("Kids", StringComparison.OrdinalIgnoreCase); /// <summary> /// Gets a value indicating whether this instance is premiere. /// </summary> /// <value><c>true</c> if this instance is premiere; otherwise, <c>false</c>.</value> [JsonIgnore] - public bool IsPremiere => Tags.Contains("Premiere", StringComparer.OrdinalIgnoreCase); + public bool IsPremiere => Tags.Contains("Premiere", StringComparison.OrdinalIgnoreCase); /// <summary> /// Gets the folder containing the item. diff --git a/MediaBrowser.Controller/LiveTv/TimerInfo.cs b/MediaBrowser.Controller/LiveTv/TimerInfo.cs index 1a2e8acb3..62541ea8b 100644 --- a/MediaBrowser.Controller/LiveTv/TimerInfo.cs +++ b/MediaBrowser.Controller/LiveTv/TimerInfo.cs @@ -4,8 +4,8 @@ using System; using System.Collections.Generic; -using System.Linq; using System.Text.Json.Serialization; +using Jellyfin.Extensions; using MediaBrowser.Model.LiveTv; namespace MediaBrowser.Controller.LiveTv @@ -123,11 +123,11 @@ namespace MediaBrowser.Controller.LiveTv public bool IsMovie { get; set; } - public bool IsKids => Tags.Contains("Kids", StringComparer.OrdinalIgnoreCase); + public bool IsKids => Tags.Contains("Kids", StringComparison.OrdinalIgnoreCase); - public bool IsSports => Tags.Contains("Sports", StringComparer.OrdinalIgnoreCase); + public bool IsSports => Tags.Contains("Sports", StringComparison.OrdinalIgnoreCase); - public bool IsNews => Tags.Contains("News", StringComparer.OrdinalIgnoreCase); + public bool IsNews => Tags.Contains("News", StringComparison.OrdinalIgnoreCase); public bool IsSeries { get; set; } @@ -136,10 +136,10 @@ namespace MediaBrowser.Controller.LiveTv /// </summary> /// <value><c>true</c> if this instance is live; otherwise, <c>false</c>.</value> [JsonIgnore] - public bool IsLive => Tags.Contains("Live", StringComparer.OrdinalIgnoreCase); + public bool IsLive => Tags.Contains("Live", StringComparison.OrdinalIgnoreCase); [JsonIgnore] - public bool IsPremiere => Tags.Contains("Premiere", StringComparer.OrdinalIgnoreCase); + public bool IsPremiere => Tags.Contains("Premiere", StringComparison.OrdinalIgnoreCase); public int? ProductionYear { get; set; } diff --git a/MediaBrowser.Controller/MediaBrowser.Controller.csproj b/MediaBrowser.Controller/MediaBrowser.Controller.csproj index 0f697bccc..432159d5d 100644 --- a/MediaBrowser.Controller/MediaBrowser.Controller.csproj +++ b/MediaBrowser.Controller/MediaBrowser.Controller.csproj @@ -13,12 +13,16 @@ <PackageLicenseExpression>GPL-3.0-only</PackageLicenseExpression> </PropertyGroup> + <PropertyGroup Condition=" '$(Configuration)' == 'Debug' "> + <TreatWarningsAsErrors>false</TreatWarningsAsErrors> + </PropertyGroup> + <ItemGroup> - <PackageReference Include="Diacritics" Version="2.1.20036.1" /> - <PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="5.0.0" /> - <PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="5.0.0" /> - <PackageReference Include="Microsoft.SourceLink.GitHub" Version="1.0.0" PrivateAssets="All" /> - <PackageReference Include="System.Threading.Tasks.Dataflow" Version="5.0.0" /> + <PackageReference Include="Diacritics" Version="3.3.10" /> + <PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="6.0.0" /> + <PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="6.0.0" /> + <PackageReference Include="Microsoft.SourceLink.GitHub" Version="1.1.1" PrivateAssets="All" /> + <PackageReference Include="System.Threading.Tasks.Dataflow" Version="6.0.0" /> </ItemGroup> <ItemGroup> @@ -32,18 +36,13 @@ </ItemGroup> <PropertyGroup> - <TargetFramework>net5.0</TargetFramework> + <TargetFramework>net6.0</TargetFramework> <GenerateAssemblyInfo>false</GenerateAssemblyInfo> <GenerateDocumentationFile>true</GenerateDocumentationFile> <PublishRepositoryUrl>true</PublishRepositoryUrl> <EmbedUntrackedSources>true</EmbedUntrackedSources> <IncludeSymbols>true</IncludeSymbols> <SymbolPackageFormat>snupkg</SymbolPackageFormat> - <TreatWarningsAsErrors>false</TreatWarningsAsErrors> - </PropertyGroup> - - <PropertyGroup Condition=" '$(Configuration)' == 'Release'"> - <TreatWarningsAsErrors>true</TreatWarningsAsErrors> </PropertyGroup> <PropertyGroup Condition=" '$(Stability)'=='Unstable'"> @@ -54,7 +53,7 @@ <!-- Code Analyzers--> <ItemGroup Condition=" '$(Configuration)' == 'Debug' "> <PackageReference Include="SerilogAnalyzer" Version="0.15.0" PrivateAssets="All" /> - <PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="All" /> + <PackageReference Include="StyleCop.Analyzers" Version="1.2.0-beta.376" PrivateAssets="All" /> <PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" PrivateAssets="All" /> </ItemGroup> diff --git a/MediaBrowser.Controller/MediaEncoding/BaseEncodingJobOptions.cs b/MediaBrowser.Controller/MediaEncoding/BaseEncodingJobOptions.cs index dd6f468da..462585ce3 100644 --- a/MediaBrowser.Controller/MediaEncoding/BaseEncodingJobOptions.cs +++ b/MediaBrowser.Controller/MediaEncoding/BaseEncodingJobOptions.cs @@ -201,4 +201,4 @@ namespace MediaBrowser.Controller.MediaEncoding return null; } } -} \ No newline at end of file +} diff --git a/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs b/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs index ec44150a2..bde10dbbf 100644 --- a/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs +++ b/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs @@ -11,6 +11,7 @@ using System.Text; using System.Text.RegularExpressions; using System.Threading; using Jellyfin.Data.Enums; +using Jellyfin.Extensions; using MediaBrowser.Model.Configuration; using MediaBrowser.Model.Dlna; using MediaBrowser.Model.Dto; @@ -21,12 +22,17 @@ namespace MediaBrowser.Controller.MediaEncoding { public class EncodingHelper { - private static readonly CultureInfo _usCulture = new CultureInfo("en-US"); + private const string QsvAlias = "qs"; + private const string VaapiAlias = "va"; + private const string D3d11vaAlias = "dx11"; + private const string VideotoolboxAlias = "vt"; + private const string OpenclAlias = "ocl"; + private const string CudaAlias = "cu"; private readonly IMediaEncoder _mediaEncoder; private readonly ISubtitleEncoder _subtitleEncoder; - private static readonly string[] _videoProfiles = new[] + private static readonly string[] _videoProfilesH264 = new[] { "ConstrainedBaseline", "Baseline", @@ -34,10 +40,15 @@ namespace MediaBrowser.Controller.MediaEncoding "Main", "High", "ProgressiveHigh", - "ConstrainedHigh" + "ConstrainedHigh", + "High10" }; - private static readonly Version minVersionForCudaOverlay = new Version(4, 4); + private static readonly string[] _videoProfilesH265 = new[] + { + "Main", + "Main10" + }; public EncodingHelper( IMediaEncoder mediaEncoder, @@ -64,15 +75,13 @@ namespace MediaBrowser.Controller.MediaEncoding var codecMap = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase) { - { "qsv", hwEncoder + "_qsv" }, - { hwEncoder + "_qsv", hwEncoder + "_qsv" }, - { "nvenc", hwEncoder + "_nvenc" }, { "amf", hwEncoder + "_amf" }, - { "omx", hwEncoder + "_omx" }, - { hwEncoder + "_v4l2m2m", hwEncoder + "_v4l2m2m" }, - { "mediacodec", hwEncoder + "_mediacodec" }, + { "nvenc", hwEncoder + "_nvenc" }, + { "qsv", hwEncoder + "_qsv" }, { "vaapi", hwEncoder + "_vaapi" }, - { "videotoolbox", hwEncoder + "_videotoolbox" } + { "videotoolbox", hwEncoder + "_videotoolbox" }, + { "v4l2m2m", hwEncoder + "_v4l2m2m" }, + { "omx", hwEncoder + "_omx" }, }; if (!string.IsNullOrEmpty(hwType) @@ -93,11 +102,9 @@ namespace MediaBrowser.Controller.MediaEncoding private bool IsVaapiSupported(EncodingJobInfo state) { - var videoStream = state.VideoStream; - // vaapi will throw an error with this input // [vaapi @ 0x7faed8000960] No VAAPI support for codec mpeg4 profile -99. - if (string.Equals(videoStream?.Codec, "mpeg4", StringComparison.OrdinalIgnoreCase)) + if (string.Equals(state.VideoStream?.Codec, "mpeg4", StringComparison.OrdinalIgnoreCase)) { return false; } @@ -105,82 +112,59 @@ namespace MediaBrowser.Controller.MediaEncoding return _mediaEncoder.SupportsHwaccel("vaapi"); } - private bool IsCudaSupported() + private bool IsVaapiFullSupported() + { + return _mediaEncoder.SupportsHwaccel("vaapi") + && _mediaEncoder.SupportsFilter("scale_vaapi") + && _mediaEncoder.SupportsFilter("deinterlace_vaapi") + && _mediaEncoder.SupportsFilter("tonemap_vaapi") + && _mediaEncoder.SupportsFilterWithOption(FilterOptionType.OverlayVaapiFrameSync) + && _mediaEncoder.SupportsFilter("hwupload_vaapi"); + } + + private bool IsOpenclFullSupported() + { + return _mediaEncoder.SupportsHwaccel("opencl") + && _mediaEncoder.SupportsFilter("scale_opencl") + && _mediaEncoder.SupportsFilterWithOption(FilterOptionType.TonemapOpenclBt2390) + && _mediaEncoder.SupportsFilterWithOption(FilterOptionType.OverlayOpenclFrameSync); + } + + private bool IsCudaFullSupported() { return _mediaEncoder.SupportsHwaccel("cuda") - && _mediaEncoder.SupportsFilter("scale_cuda") + && _mediaEncoder.SupportsFilterWithOption(FilterOptionType.ScaleCudaFormat) && _mediaEncoder.SupportsFilter("yadif_cuda") + && _mediaEncoder.SupportsFilterWithOption(FilterOptionType.TonemapCudaName) + && _mediaEncoder.SupportsFilter("overlay_cuda") && _mediaEncoder.SupportsFilter("hwupload_cuda"); } - private bool IsOpenclTonemappingSupported(EncodingJobInfo state, EncodingOptions options) + private bool IsHwTonemapAvailable(EncodingJobInfo state, EncodingOptions options) { - var videoStream = state.VideoStream; - if (videoStream == null) + if (state.VideoStream == null) { return false; } return options.EnableTonemapping - && (string.Equals(videoStream.ColorTransfer, "smpte2084", StringComparison.OrdinalIgnoreCase) - || string.Equals(videoStream.ColorTransfer, "arib-std-b67", StringComparison.OrdinalIgnoreCase)) - && IsColorDepth10(state) - && _mediaEncoder.SupportsHwaccel("opencl") - && _mediaEncoder.SupportsFilter("tonemap_opencl"); + && (string.Equals(state.VideoStream.ColorTransfer, "smpte2084", StringComparison.OrdinalIgnoreCase) + || string.Equals(state.VideoStream.ColorTransfer, "arib-std-b67", StringComparison.OrdinalIgnoreCase)) + && GetVideoColorBitDepth(state) == 10; } - private bool IsCudaTonemappingSupported(EncodingJobInfo state, EncodingOptions options) + private bool IsVaapiVppTonemapAvailable(EncodingJobInfo state, EncodingOptions options) { - var videoStream = state.VideoStream; - if (videoStream == null) + if (state.VideoStream == null) { return false; } - return options.EnableTonemapping - && (string.Equals(videoStream.ColorTransfer, "smpte2084", StringComparison.OrdinalIgnoreCase) - || string.Equals(videoStream.ColorTransfer, "arib-std-b67", StringComparison.OrdinalIgnoreCase)) - && IsColorDepth10(state) - && _mediaEncoder.SupportsHwaccel("cuda") - && _mediaEncoder.SupportsFilterWithOption(FilterOptionType.TonemapCudaName); - } - - private bool IsVppTonemappingSupported(EncodingJobInfo state, EncodingOptions options) - { - var videoStream = state.VideoStream; - if (videoStream == null) - { - // Remote stream doesn't have media info, disable vpp tonemapping. - return false; - } - - var codec = videoStream.Codec; - if (string.Equals(options.HardwareAccelerationType, "vaapi", StringComparison.OrdinalIgnoreCase)) - { - // Limited to HEVC for now since the filter doesn't accept master data from VP9. - return options.EnableVppTonemapping - && string.Equals(videoStream.ColorTransfer, "smpte2084", StringComparison.OrdinalIgnoreCase) - && IsColorDepth10(state) - && string.Equals(codec, "hevc", StringComparison.OrdinalIgnoreCase) - && _mediaEncoder.SupportsHwaccel("vaapi") - && _mediaEncoder.SupportsFilter("tonemap_vaapi"); - } - - // Hybrid VPP tonemapping for QSV with VAAPI - if (OperatingSystem.IsLinux() && string.Equals(options.HardwareAccelerationType, "qsv", StringComparison.OrdinalIgnoreCase)) - { - // Limited to HEVC for now since the filter doesn't accept master data from VP9. - return options.EnableVppTonemapping - && string.Equals(videoStream.ColorTransfer, "smpte2084", StringComparison.OrdinalIgnoreCase) - && IsColorDepth10(state) - && string.Equals(codec, "hevc", StringComparison.OrdinalIgnoreCase) - && _mediaEncoder.SupportsHwaccel("vaapi") - && _mediaEncoder.SupportsFilter("tonemap_vaapi") - && _mediaEncoder.SupportsHwaccel("qsv"); - } - // Native VPP tonemapping may come to QSV in the future. - return false; + + return options.EnableVppTonemapping + && string.Equals(state.VideoStream.ColorTransfer, "smpte2084", StringComparison.OrdinalIgnoreCase) + && GetVideoColorBitDepth(state) == 10; } /// <summary> @@ -465,11 +449,20 @@ namespace MediaBrowser.Controller.MediaEncoding return "copy"; } - public int GetVideoProfileScore(string profile) + public int GetVideoProfileScore(string videoCodec, string videoProfile) { // strip spaces because they may be stripped out on the query string - profile = profile.Replace(" ", string.Empty, StringComparison.Ordinal); - return Array.FindIndex(_videoProfiles, x => string.Equals(x, profile, StringComparison.OrdinalIgnoreCase)); + string profile = videoProfile.Replace(" ", string.Empty, StringComparison.Ordinal); + if (string.Equals("h264", videoCodec, StringComparison.OrdinalIgnoreCase)) + { + return Array.FindIndex(_videoProfilesH264, x => string.Equals(x, profile, StringComparison.OrdinalIgnoreCase)); + } + else if (string.Equals("hevc", videoCodec, StringComparison.OrdinalIgnoreCase)) + { + return Array.FindIndex(_videoProfilesH265, x => string.Equals(x, profile, StringComparison.OrdinalIgnoreCase)); + } + + return -1; } public string GetInputPathArgument(EncodingJobInfo state) @@ -528,6 +521,312 @@ namespace MediaBrowser.Controller.MediaEncoding return codec.ToLowerInvariant(); } + private string GetVideoToolboxDeviceArgs(string alias) + { + alias ??= VideotoolboxAlias; + + // device selection in vt is not supported. + return " -init_hw_device videotoolbox=" + alias; + } + + private string GetCudaDeviceArgs(int deviceIndex, string alias) + { + alias ??= CudaAlias; + deviceIndex = deviceIndex >= 0 + ? deviceIndex + : 0; + + return string.Format( + CultureInfo.InvariantCulture, + " -init_hw_device cuda={0}:{1}", + alias, + deviceIndex); + } + + private string GetOpenclDeviceArgs(int deviceIndex, string deviceVendorName, string srcDeviceAlias, string alias) + { + alias ??= OpenclAlias; + deviceIndex = deviceIndex >= 0 + ? deviceIndex + : 0; + var vendorOpts = string.IsNullOrEmpty(deviceVendorName) + ? ":0.0" + : ":." + deviceIndex + ",device_vendor=\"" + deviceVendorName + "\""; + var options = string.IsNullOrEmpty(srcDeviceAlias) + ? vendorOpts + : "@" + srcDeviceAlias; + + return string.Format( + CultureInfo.InvariantCulture, + " -init_hw_device opencl={0}{1}", + alias, + options); + } + + private string GetD3d11vaDeviceArgs(int deviceIndex, string deviceVendorId, string alias) + { + alias ??= D3d11vaAlias; + deviceIndex = deviceIndex >= 0 ? deviceIndex : 0; + var options = string.IsNullOrEmpty(deviceVendorId) + ? deviceIndex.ToString(CultureInfo.InvariantCulture) + : ",vendor=" + deviceVendorId; + + return string.Format( + CultureInfo.InvariantCulture, + " -init_hw_device d3d11va={0}:{1}", + alias, + options); + } + + private string GetVaapiDeviceArgs(string renderNodePath, string kernelDriver, string driver, string alias) + { + alias ??= VaapiAlias; + renderNodePath = renderNodePath ?? "/dev/dri/renderD128"; + var options = string.IsNullOrEmpty(kernelDriver) || string.IsNullOrEmpty(driver) + ? renderNodePath + : ",kernel_driver=" + kernelDriver + ",driver=" + driver; + + return string.Format( + CultureInfo.InvariantCulture, + " -init_hw_device vaapi={0}:{1}", + alias, + options); + } + + private string GetQsvDeviceArgs(string alias) + { + var arg = " -init_hw_device qsv=" + (alias ?? QsvAlias); + if (OperatingSystem.IsLinux()) + { + // derive qsv from vaapi device + return GetVaapiDeviceArgs(null, "i915", "iHD", VaapiAlias) + arg + "@" + VaapiAlias; + } + + if (OperatingSystem.IsWindows()) + { + // derive qsv from d3d11va device + return GetD3d11vaDeviceArgs(0, "0x8086", D3d11vaAlias) + arg + "@" + D3d11vaAlias; + } + + return null; + } + + private string GetFilterHwDeviceArgs(string alias) + { + return string.IsNullOrEmpty(alias) + ? string.Empty + : " -filter_hw_device " + alias; + } + + public string GetGraphicalSubCanvasSize(EncodingJobInfo state) + { + if (state.SubtitleStream != null + && state.SubtitleDeliveryMethod == SubtitleDeliveryMethod.Encode + && !state.SubtitleStream.IsTextSubtitleStream) + { + var inW = state.VideoStream?.Width; + var inH = state.VideoStream?.Height; + var reqW = state.BaseRequest.Width; + var reqH = state.BaseRequest.Height; + var reqMaxW = state.BaseRequest.MaxWidth; + var reqMaxH = state.BaseRequest.MaxHeight; + + // setup a relative small canvas_size for overlay_qsv/vaapi to reduce transfer overhead + var (overlayW, overlayH) = GetFixedOutputSize(inW, inH, reqW, reqH, reqMaxW, 1080); + + if (overlayW.HasValue && overlayH.HasValue) + { + return string.Format( + CultureInfo.InvariantCulture, + " -canvas_size {0}x{1}", + overlayW.Value, + overlayH.Value); + } + } + + return string.Empty; + } + + /// <summary> + /// Gets the input video hwaccel argument. + /// </summary> + /// <param name="state">Encoding state.</param> + /// <param name="options">Encoding options.</param> + /// <returns>Input video hwaccel arguments.</returns> + public string GetInputVideoHwaccelArgs(EncodingJobInfo state, EncodingOptions options) + { + if (!state.IsVideoRequest) + { + return string.Empty; + } + + var vidEncoder = GetVideoEncoder(state, options) ?? string.Empty; + if (IsCopyCodec(vidEncoder)) + { + return string.Empty; + } + + var args = new StringBuilder(); + var isWindows = OperatingSystem.IsWindows(); + var isLinux = OperatingSystem.IsLinux(); + var isMacOS = OperatingSystem.IsMacOS(); + var optHwaccelType = options.HardwareAccelerationType; + var vidDecoder = GetHardwareVideoDecoder(state, options) ?? string.Empty; + var isHwTonemapAvailable = IsHwTonemapAvailable(state, options); + + if (string.Equals(optHwaccelType, "vaapi", StringComparison.OrdinalIgnoreCase)) + { + if (!isLinux || !_mediaEncoder.SupportsHwaccel("vaapi")) + { + return string.Empty; + } + + var isVaapiDecoder = vidDecoder.Contains("vaapi", StringComparison.OrdinalIgnoreCase); + var isVaapiEncoder = vidEncoder.Contains("vaapi", StringComparison.OrdinalIgnoreCase); + if (!isVaapiDecoder && !isVaapiEncoder) + { + return string.Empty; + } + + args.Append(GetVaapiDeviceArgs(options.VaapiDevice, null, null, VaapiAlias)); + var filterDevArgs = GetFilterHwDeviceArgs(VaapiAlias); + + if (isHwTonemapAvailable && IsOpenclFullSupported()) + { + if (_mediaEncoder.IsVaapiDeviceInteliHD || _mediaEncoder.IsVaapiDeviceInteli965) + { + if (!isVaapiDecoder) + { + args.Append(GetOpenclDeviceArgs(0, null, VaapiAlias, OpenclAlias)); + filterDevArgs = GetFilterHwDeviceArgs(OpenclAlias); + } + } + else if (_mediaEncoder.IsVaapiDeviceAmd) + { + args.Append(GetOpenclDeviceArgs(0, "Advanced Micro Devices", null, OpenclAlias)); + filterDevArgs = GetFilterHwDeviceArgs(OpenclAlias); + } + else + { + args.Append(GetOpenclDeviceArgs(0, null, null, OpenclAlias)); + filterDevArgs = GetFilterHwDeviceArgs(OpenclAlias); + } + } + + args.Append(filterDevArgs); + } + else if (string.Equals(optHwaccelType, "qsv", StringComparison.OrdinalIgnoreCase)) + { + if ((!isLinux && !isWindows) || !_mediaEncoder.SupportsHwaccel("qsv")) + { + return string.Empty; + } + + var isD3d11vaDecoder = vidDecoder.Contains("d3d11va", StringComparison.OrdinalIgnoreCase); + var isVaapiDecoder = vidDecoder.Contains("vaapi", StringComparison.OrdinalIgnoreCase); + var isQsvDecoder = vidDecoder.Contains("qsv", StringComparison.OrdinalIgnoreCase); + var isQsvEncoder = vidEncoder.Contains("qsv", StringComparison.OrdinalIgnoreCase); + var isHwDecoder = isQsvDecoder || isVaapiDecoder || isD3d11vaDecoder; + if (!isHwDecoder && !isQsvEncoder) + { + return string.Empty; + } + + args.Append(GetQsvDeviceArgs(QsvAlias)); + var filterDevArgs = GetFilterHwDeviceArgs(QsvAlias); + // child device used by qsv. + if (_mediaEncoder.SupportsHwaccel("vaapi") || _mediaEncoder.SupportsHwaccel("d3d11va")) + { + if (isHwTonemapAvailable && IsOpenclFullSupported()) + { + var srcAlias = isLinux ? VaapiAlias : D3d11vaAlias; + args.Append(GetOpenclDeviceArgs(0, null, srcAlias, OpenclAlias)); + if (!isHwDecoder) + { + filterDevArgs = GetFilterHwDeviceArgs(OpenclAlias); + } + } + } + + args.Append(filterDevArgs); + } + else if (string.Equals(optHwaccelType, "nvenc", StringComparison.OrdinalIgnoreCase)) + { + if ((!isLinux && !isWindows) || !IsCudaFullSupported()) + { + return string.Empty; + } + + var isCuvidDecoder = vidDecoder.Contains("cuvid", StringComparison.OrdinalIgnoreCase); + var isNvdecDecoder = vidDecoder.Contains("cuda", StringComparison.OrdinalIgnoreCase); + var isNvencEncoder = vidEncoder.Contains("nvenc", StringComparison.OrdinalIgnoreCase); + var isHwDecoder = isNvdecDecoder || isCuvidDecoder; + if (!isHwDecoder && !isNvencEncoder) + { + return string.Empty; + } + + args.Append(GetCudaDeviceArgs(0, CudaAlias)) + .Append(GetFilterHwDeviceArgs(CudaAlias)); + + // workaround for "No decoder surfaces left" error, + // but will increase vram usage. https://trac.ffmpeg.org/ticket/7562 + args.Append(" -extra_hw_frames 3"); + } + else if (string.Equals(optHwaccelType, "amf", StringComparison.OrdinalIgnoreCase)) + { + if (!isWindows || !_mediaEncoder.SupportsHwaccel("d3d11va")) + { + return string.Empty; + } + + var isD3d11vaDecoder = vidDecoder.Contains("d3d11va", StringComparison.OrdinalIgnoreCase); + var isAmfEncoder = vidEncoder.Contains("amf", StringComparison.OrdinalIgnoreCase); + if (!isD3d11vaDecoder && !isAmfEncoder) + { + return string.Empty; + } + + // no dxva video processor hw filter. + args.Append(GetD3d11vaDeviceArgs(0, "0x1002", D3d11vaAlias)); + var filterDevArgs = string.Empty; + if (IsOpenclFullSupported()) + { + args.Append(GetOpenclDeviceArgs(0, null, D3d11vaAlias, OpenclAlias)); + filterDevArgs = GetFilterHwDeviceArgs(OpenclAlias); + } + + args.Append(filterDevArgs); + } + else if (string.Equals(optHwaccelType, "videotoolbox", StringComparison.OrdinalIgnoreCase)) + { + if (!isMacOS || !_mediaEncoder.SupportsHwaccel("videotoolbox")) + { + return string.Empty; + } + + var isVideotoolboxDecoder = vidDecoder.Contains("videotoolbox", StringComparison.OrdinalIgnoreCase); + var isVideotoolboxEncoder = vidEncoder.Contains("videotoolbox", StringComparison.OrdinalIgnoreCase); + if (!isVideotoolboxDecoder && !isVideotoolboxEncoder) + { + return string.Empty; + } + + // no videotoolbox hw filter. + args.Append(GetVideoToolboxDeviceArgs(VideotoolboxAlias)); + } + + if (!string.IsNullOrEmpty(vidDecoder)) + { + args.Append(vidDecoder); + } + + // hw transpose filters should be added manually. + args.Append(" -autorotate 0"); + + return args.ToString().Trim(); + } + /// <summary> /// Gets the input argument. /// </summary> @@ -537,152 +836,27 @@ namespace MediaBrowser.Controller.MediaEncoding public string GetInputArgument(EncodingJobInfo state, EncodingOptions options) { var arg = new StringBuilder(); - var videoDecoder = GetHardwareAcceleratedVideoDecoder(state, options) ?? string.Empty; - var outputVideoCodec = GetVideoEncoder(state, options) ?? string.Empty; - var isWindows = OperatingSystem.IsWindows(); - var isLinux = OperatingSystem.IsLinux(); - var isMacOS = OperatingSystem.IsMacOS(); -#pragma warning disable CA1508 // Defaults to string.Empty - var isSwDecoder = string.IsNullOrEmpty(videoDecoder); -#pragma warning restore CA1508 - var isD3d11vaDecoder = videoDecoder.IndexOf("d3d11va", StringComparison.OrdinalIgnoreCase) != -1; - var isVaapiDecoder = videoDecoder.IndexOf("vaapi", StringComparison.OrdinalIgnoreCase) != -1; - var isVaapiEncoder = outputVideoCodec.IndexOf("vaapi", StringComparison.OrdinalIgnoreCase) != -1; - var isQsvDecoder = videoDecoder.IndexOf("qsv", StringComparison.OrdinalIgnoreCase) != -1; - var isQsvEncoder = outputVideoCodec.IndexOf("qsv", StringComparison.OrdinalIgnoreCase) != -1; - var isNvdecDecoder = videoDecoder.Contains("cuda", StringComparison.OrdinalIgnoreCase); - var isCuvidHevcDecoder = videoDecoder.Contains("hevc_cuvid", StringComparison.OrdinalIgnoreCase); - var isCuvidVp9Decoder = videoDecoder.Contains("vp9_cuvid", StringComparison.OrdinalIgnoreCase); - var isOpenclTonemappingSupported = IsOpenclTonemappingSupported(state, options); - var isVppTonemappingSupported = IsVppTonemappingSupported(state, options); - var isCudaTonemappingSupported = IsCudaTonemappingSupported(state, options); + var inputVidHwaccelArgs = GetInputVideoHwaccelArgs(state, options); - if (!IsCopyCodec(outputVideoCodec)) + if (!string.IsNullOrEmpty(inputVidHwaccelArgs)) { - if (state.IsVideoRequest - && _mediaEncoder.SupportsHwaccel("vaapi") - && string.Equals(options.HardwareAccelerationType, "vaapi", StringComparison.OrdinalIgnoreCase)) - { - if (isVaapiDecoder) - { - if (isOpenclTonemappingSupported && !isVppTonemappingSupported) - { - arg.Append("-init_hw_device vaapi=va:") - .Append(options.VaapiDevice) - .Append(" -init_hw_device opencl=ocl@va ") - .Append("-hwaccel_device va ") - .Append("-hwaccel_output_format vaapi ") - .Append("-filter_hw_device ocl "); - } - else - { - arg.Append("-hwaccel_output_format vaapi ") - .Append("-vaapi_device ") - .Append(options.VaapiDevice) - .Append(' '); - } - } - else if (!isVaapiDecoder && isVaapiEncoder) - { - arg.Append("-vaapi_device ") - .Append(options.VaapiDevice) - .Append(' '); - } - - arg.Append("-autorotate 0 "); - } - - if (state.IsVideoRequest - && string.Equals(options.HardwareAccelerationType, "qsv", StringComparison.OrdinalIgnoreCase)) - { - var hasGraphicalSubs = state.SubtitleStream != null && !state.SubtitleStream.IsTextSubtitleStream && state.SubtitleDeliveryMethod == SubtitleDeliveryMethod.Encode; - - if (isQsvEncoder) - { - if (isQsvDecoder) - { - if (isLinux) - { - if (hasGraphicalSubs) - { - arg.Append("-init_hw_device qsv=hw -filter_hw_device hw "); - } - else - { - arg.Append("-hwaccel qsv "); - } - } - - if (isWindows) - { - arg.Append("-hwaccel qsv "); - } - } - - // While using SW decoder - else if (isSwDecoder) - { - arg.Append("-init_hw_device qsv=hw -filter_hw_device hw "); - } - - // Hybrid VPP tonemapping with VAAPI - else if (isVaapiDecoder && isVppTonemappingSupported) - { - arg.Append("-init_hw_device vaapi=va:") - .Append(options.VaapiDevice) - .Append(" -init_hw_device qsv@va ") - .Append("-hwaccel_output_format vaapi "); - } - - arg.Append("-autorotate 0 "); - } - } - - if (state.IsVideoRequest - && string.Equals(options.HardwareAccelerationType, "nvenc", StringComparison.OrdinalIgnoreCase) - && isNvdecDecoder) - { - // Fix for 'No decoder surfaces left' error. https://trac.ffmpeg.org/ticket/7562 - arg.Append("-hwaccel_output_format cuda -extra_hw_frames 3 -autorotate 0 "); - } - - if (state.IsVideoRequest - && ((string.Equals(options.HardwareAccelerationType, "nvenc", StringComparison.OrdinalIgnoreCase) - && (isNvdecDecoder || isCuvidHevcDecoder || isCuvidVp9Decoder || isSwDecoder)))) - { - if (!isCudaTonemappingSupported && isOpenclTonemappingSupported) - { - arg.Append("-init_hw_device opencl=ocl:") - .Append(options.OpenclDevice) - .Append(" -filter_hw_device ocl "); - } - } - - if (state.IsVideoRequest - && string.Equals(options.HardwareAccelerationType, "amf", StringComparison.OrdinalIgnoreCase) - && (isD3d11vaDecoder || isSwDecoder)) - { - if (isOpenclTonemappingSupported) - { - arg.Append("-init_hw_device opencl=ocl:") - .Append(options.OpenclDevice) - .Append(" -filter_hw_device ocl "); - } - } - - if (state.IsVideoRequest - && string.Equals(options.HardwareAccelerationType, "videotoolbox", StringComparison.OrdinalIgnoreCase)) - { - arg.Append("-hwaccel videotoolbox "); - } + arg.Append(inputVidHwaccelArgs); } - arg.Append("-i ") + var canvasArgs = GetGraphicalSubCanvasSize(state); + if (!string.IsNullOrEmpty(canvasArgs)) + { + arg.Append(canvasArgs); + } + + arg.Append(" -i ") .Append(GetInputPathArgument(state)); + // sub2video for external graphical subtitles if (state.SubtitleStream != null && state.SubtitleDeliveryMethod == SubtitleDeliveryMethod.Encode - && state.SubtitleStream.IsExternal && !state.SubtitleStream.IsTextSubtitleStream) + && !state.SubtitleStream.IsTextSubtitleStream + && state.SubtitleStream.IsExternal) { var subtitlePath = state.SubtitleStream.Path; @@ -695,7 +869,31 @@ namespace MediaBrowser.Controller.MediaEncoding } } - arg.Append(" -i \"").Append(subtitlePath).Append('\"'); + // Also seek the external subtitles stream. + var seekSubParam = GetFastSeekCommandLineParameter(state, options); + if (!string.IsNullOrEmpty(seekSubParam)) + { + arg.Append(' ').Append(seekSubParam); + } + + if (!string.IsNullOrEmpty(canvasArgs)) + { + arg.Append(canvasArgs); + } + + arg.Append(" -i file:\"").Append(subtitlePath).Append('\"'); + } + + if (state.AudioStream != null && state.AudioStream.IsExternal) + { + // Also seek the external audio stream. + var seekAudioParam = GetFastSeekCommandLineParameter(state, options); + if (!string.IsNullOrEmpty(seekAudioParam)) + { + arg.Append(' ').Append(seekAudioParam); + } + + arg.Append(" -i \"").Append(state.AudioStream.Path).Append('"'); } return arg.ToString(); @@ -811,12 +1009,32 @@ namespace MediaBrowser.Controller.MediaEncoding return FormattableString.Invariant($" -maxrate {bitrate} -bufsize {bufsize}"); } + if (string.Equals(videoCodec, "h264_amf", StringComparison.OrdinalIgnoreCase) + || string.Equals(videoCodec, "hevc_amf", StringComparison.OrdinalIgnoreCase)) + { + return FormattableString.Invariant($" -qmin 18 -qmax 32 -b:v {bitrate} -maxrate {bitrate} -bufsize {bufsize}"); + } + + if (string.Equals(videoCodec, "h264_vaapi", StringComparison.OrdinalIgnoreCase) + || string.Equals(videoCodec, "hevc_vaapi", StringComparison.OrdinalIgnoreCase)) + { + // VBR in i965 driver may result in pixelated output. + if (_mediaEncoder.IsVaapiDeviceInteli965) + { + return FormattableString.Invariant($" -rc_mode CBR -b:v {bitrate} -maxrate {bitrate} -bufsize {bufsize}"); + } + else + { + return FormattableString.Invariant($" -rc_mode VBR -b:v {bitrate} -maxrate {bitrate} -bufsize {bufsize}"); + } + } + return FormattableString.Invariant($" -b:v {bitrate} -maxrate {bitrate} -bufsize {bufsize}"); } public static string NormalizeTranscodingLevel(EncodingJobInfo state, string level) { - if (double.TryParse(level, NumberStyles.Any, _usCulture, out double requestLevel)) + if (double.TryParse(level, NumberStyles.Any, CultureInfo.InvariantCulture, out double requestLevel)) { if (string.Equals(state.ActualOutputVideoCodec, "hevc", StringComparison.OrdinalIgnoreCase) || string.Equals(state.ActualOutputVideoCodec, "h265", StringComparison.OrdinalIgnoreCase)) @@ -847,8 +1065,10 @@ namespace MediaBrowser.Controller.MediaEncoding /// Gets the text subtitle param. /// </summary> /// <param name="state">The state.</param> + /// <param name="enableAlpha">Enable alpha processing.</param> + /// <param name="enableSub2video">Enable sub2video mode.</param> /// <returns>System.String.</returns> - public string GetTextSubtitleParam(EncodingJobInfo state) + public string GetTextSubtitlesFilter(EncodingJobInfo state, bool enableAlpha, bool enableSub2video) { var seconds = Math.Round(TimeSpan.FromTicks(state.StartTimeTicks ?? 0).TotalSeconds); @@ -857,6 +1077,9 @@ namespace MediaBrowser.Controller.MediaEncoding ? string.Empty : string.Format(CultureInfo.InvariantCulture, ",setpts=PTS -{0}/TB", seconds); + var alphaParam = enableAlpha ? ":alpha=1" : string.Empty; + var sub2videoParam = enableSub2video ? ":sub2video=1" : string.Empty; + // TODO // var fallbackFontPath = Path.Combine(_appPaths.ProgramDataPath, "fonts", "DroidSansFallback.ttf"); // string fallbackFontParam = string.Empty; @@ -878,7 +1101,6 @@ namespace MediaBrowser.Controller.MediaEncoding if (state.SubtitleStream.IsExternal) { var subtitlePath = state.SubtitleStream.Path; - var charsetParam = string.Empty; if (!string.IsNullOrEmpty(state.SubtitleStream.Language)) @@ -898,9 +1120,11 @@ namespace MediaBrowser.Controller.MediaEncoding // TODO: Perhaps also use original_size=1920x800 ?? return string.Format( CultureInfo.InvariantCulture, - "subtitles=filename='{0}'{1}{2}", + "subtitles=f='{0}'{1}{2}{3}{4}", _mediaEncoder.EscapeSubtitleFilterPath(subtitlePath), charsetParam, + alphaParam, + sub2videoParam, // fallbackFontParam, setPtsParam); } @@ -909,9 +1133,11 @@ namespace MediaBrowser.Controller.MediaEncoding return string.Format( CultureInfo.InvariantCulture, - "subtitles='{0}:si={1}'{2}", + "subtitles='{0}:si={1}{2}{3}'{4}", _mediaEncoder.EscapeSubtitleFilterPath(mediaPath), - state.InternalSubtitleStreamOffset.ToString(_usCulture), + state.InternalSubtitleStreamOffset.ToString(CultureInfo.InvariantCulture), + alphaParam, + sub2videoParam, // fallbackFontParam, setPtsParam); } @@ -996,11 +1222,11 @@ namespace MediaBrowser.Controller.MediaEncoding || string.Equals(codec, "h264_vaapi", StringComparison.OrdinalIgnoreCase) || string.Equals(codec, "hevc_vaapi", StringComparison.OrdinalIgnoreCase)) { - args += " " + keyFrameArg; + args += keyFrameArg; } else { - args += " " + keyFrameArg + gopArg; + args += keyFrameArg + gopArg; } return args; @@ -1018,45 +1244,41 @@ namespace MediaBrowser.Controller.MediaEncoding { var param = string.Empty; - if (!string.Equals(videoEncoder, "h264_omx", StringComparison.OrdinalIgnoreCase) - && !string.Equals(videoEncoder, "h264_qsv", StringComparison.OrdinalIgnoreCase) - && !string.Equals(videoEncoder, "h264_vaapi", StringComparison.OrdinalIgnoreCase) - && !string.Equals(videoEncoder, "h264_nvenc", StringComparison.OrdinalIgnoreCase) - && !string.Equals(videoEncoder, "h264_amf", StringComparison.OrdinalIgnoreCase) - && !string.Equals(videoEncoder, "h264_v4l2m2m", StringComparison.OrdinalIgnoreCase) - && !string.Equals(videoEncoder, "hevc_qsv", StringComparison.OrdinalIgnoreCase) - && !string.Equals(videoEncoder, "hevc_vaapi", StringComparison.OrdinalIgnoreCase) - && !string.Equals(videoEncoder, "hevc_nvenc", StringComparison.OrdinalIgnoreCase) - && !string.Equals(videoEncoder, "hevc_amf", StringComparison.OrdinalIgnoreCase)) + // Tutorials: Enable Intel GuC / HuC firmware loading for Low Power Encoding. + // https://01.org/linuxgraphics/downloads/firmware + // https://wiki.archlinux.org/title/intel_graphics#Enable_GuC_/_HuC_firmware_loading + // Intel Low Power Encoding can save unnecessary CPU-GPU synchronization, + // which will reduce overhead in performance intensive tasks such as 4k transcoding and tonemapping. + var intelLowPowerHwEncoding = false; + + if (string.Equals(encodingOptions.HardwareAccelerationType, "vaapi", StringComparison.OrdinalIgnoreCase)) { - param += " -pix_fmt yuv420p"; + var isIntelVaapiDriver = _mediaEncoder.IsVaapiDeviceInteliHD || _mediaEncoder.IsVaapiDeviceInteli965; + + if (string.Equals(videoEncoder, "h264_vaapi", StringComparison.OrdinalIgnoreCase)) + { + intelLowPowerHwEncoding = encodingOptions.EnableIntelLowPowerH264HwEncoder && isIntelVaapiDriver; + } + else if (string.Equals(videoEncoder, "hevc_vaapi", StringComparison.OrdinalIgnoreCase)) + { + intelLowPowerHwEncoding = encodingOptions.EnableIntelLowPowerHevcHwEncoder && isIntelVaapiDriver; + } + } + else if (string.Equals(encodingOptions.HardwareAccelerationType, "qsv", StringComparison.OrdinalIgnoreCase)) + { + if (string.Equals(videoEncoder, "h264_qsv", StringComparison.OrdinalIgnoreCase)) + { + intelLowPowerHwEncoding = encodingOptions.EnableIntelLowPowerH264HwEncoder; + } + else if (string.Equals(videoEncoder, "hevc_qsv", StringComparison.OrdinalIgnoreCase)) + { + intelLowPowerHwEncoding = encodingOptions.EnableIntelLowPowerHevcHwEncoder; + } } - if (string.Equals(videoEncoder, "h264_nvenc", StringComparison.OrdinalIgnoreCase) - || string.Equals(videoEncoder, "h264_amf", StringComparison.OrdinalIgnoreCase) - || string.Equals(videoEncoder, "hevc_nvenc", StringComparison.OrdinalIgnoreCase) - || string.Equals(videoEncoder, "hevc_amf", StringComparison.OrdinalIgnoreCase)) + if (intelLowPowerHwEncoding) { - var videoStream = state.VideoStream; - var isColorDepth10 = IsColorDepth10(state); - var videoDecoder = GetHardwareAcceleratedVideoDecoder(state, encodingOptions) ?? string.Empty; - var isNvdecDecoder = videoDecoder.Contains("cuda", StringComparison.OrdinalIgnoreCase); - - if (!isNvdecDecoder) - { - if (isColorDepth10 - && _mediaEncoder.SupportsHwaccel("opencl") - && encodingOptions.EnableTonemapping - && !string.IsNullOrEmpty(videoStream.VideoRange) - && videoStream.VideoRange.Contains("HDR", StringComparison.OrdinalIgnoreCase)) - { - param += " -pix_fmt nv12"; - } - else - { - param += " -pix_fmt yuv420p"; - } - } + param += " -low_power 1"; } if (string.Equals(videoEncoder, "h264_v4l2m2m", StringComparison.OrdinalIgnoreCase)) @@ -1064,8 +1286,7 @@ namespace MediaBrowser.Controller.MediaEncoding param += " -pix_fmt nv21"; } - var isVc1 = state.VideoStream != null && - string.Equals(state.VideoStream.Codec, "vc1", StringComparison.OrdinalIgnoreCase); + var isVc1 = string.Equals(state.VideoStream?.Codec, "vc1", StringComparison.OrdinalIgnoreCase); var isLibX265 = string.Equals(videoEncoder, "libx265", StringComparison.OrdinalIgnoreCase); if (string.Equals(videoEncoder, "libx264", StringComparison.OrdinalIgnoreCase) || isLibX265) @@ -1105,7 +1326,7 @@ namespace MediaBrowser.Controller.MediaEncoding { string[] valid_h264_qsv = { "veryslow", "slower", "slow", "medium", "fast", "faster", "veryfast" }; - if (valid_h264_qsv.Contains(encodingOptions.EncoderPreset, StringComparer.OrdinalIgnoreCase)) + if (valid_h264_qsv.Contains(encodingOptions.EncoderPreset, StringComparison.OrdinalIgnoreCase)) { param += " -preset " + encodingOptions.EncoderPreset; } @@ -1176,19 +1397,6 @@ namespace MediaBrowser.Controller.MediaEncoding break; } - var videoStream = state.VideoStream; - var isColorDepth10 = IsColorDepth10(state); - - if (isColorDepth10 - && _mediaEncoder.SupportsHwaccel("opencl") - && encodingOptions.EnableTonemapping - && !string.IsNullOrEmpty(videoStream.VideoRange) - && videoStream.VideoRange.Contains("HDR", StringComparison.OrdinalIgnoreCase)) - { - // Enhance workload when tone mapping with AMF on some APUs - param += " -preanalysis true"; - } - if (string.Equals(videoEncoder, "hevc_amf", StringComparison.OrdinalIgnoreCase)) { param += " -header_insertion_mode gop -gops_per_idr 1"; @@ -1217,7 +1425,7 @@ namespace MediaBrowser.Controller.MediaEncoding param += string.Format( CultureInfo.InvariantCulture, " -speed 16 -quality good -profile:v {0} -slices 8 -crf {1} -qmin {2} -qmax {3}", - profileScore.ToString(_usCulture), + profileScore.ToString(CultureInfo.InvariantCulture), crf, qmin, qmax); @@ -1289,7 +1497,7 @@ namespace MediaBrowser.Controller.MediaEncoding var framerate = GetFramerateParam(state); if (framerate.HasValue) { - param += string.Format(CultureInfo.InvariantCulture, " -r {0}", framerate.Value.ToString(_usCulture)); + param += string.Format(CultureInfo.InvariantCulture, " -r {0}", framerate.Value.ToString(CultureInfo.InvariantCulture)); } var targetVideoCodec = state.ActualOutputVideoCodec; @@ -1361,13 +1569,6 @@ namespace MediaBrowser.Controller.MediaEncoding profile = "constrained_high"; } - // Currently hevc_amf only support encoding HEVC Main Profile, otherwise force Main Profile. - if (string.Equals(videoEncoder, "hevc_amf", StringComparison.OrdinalIgnoreCase) - && profile.Contains("main10", StringComparison.OrdinalIgnoreCase)) - { - profile = "main"; - } - if (!string.IsNullOrEmpty(profile)) { if (!string.Equals(videoEncoder, "h264_omx", StringComparison.OrdinalIgnoreCase) @@ -1384,7 +1585,7 @@ namespace MediaBrowser.Controller.MediaEncoding { level = NormalizeTranscodingLevel(state, level); - // libx264, QSV, AMF, VAAPI can adjust the given level to match the output. + // libx264, QSV, AMF can adjust the given level to match the output. if (string.Equals(videoEncoder, "h264_qsv", StringComparison.OrdinalIgnoreCase) || string.Equals(videoEncoder, "libx264", StringComparison.OrdinalIgnoreCase)) { @@ -1393,7 +1594,7 @@ namespace MediaBrowser.Controller.MediaEncoding else if (string.Equals(videoEncoder, "hevc_qsv", StringComparison.OrdinalIgnoreCase)) { // hevc_qsv use -level 51 instead of -level 153. - if (double.TryParse(level, NumberStyles.Any, _usCulture, out double hevcLevel)) + if (double.TryParse(level, NumberStyles.Any, CultureInfo.InvariantCulture, out double hevcLevel)) { param += " -level " + (hevcLevel / 3); } @@ -1404,10 +1605,13 @@ namespace MediaBrowser.Controller.MediaEncoding param += " -level " + level; } else if (string.Equals(videoEncoder, "h264_nvenc", StringComparison.OrdinalIgnoreCase) - || string.Equals(videoEncoder, "hevc_nvenc", StringComparison.OrdinalIgnoreCase)) + || string.Equals(videoEncoder, "hevc_nvenc", StringComparison.OrdinalIgnoreCase) + || string.Equals(videoEncoder, "h264_vaapi", StringComparison.OrdinalIgnoreCase) + || string.Equals(videoEncoder, "hevc_vaapi", StringComparison.OrdinalIgnoreCase)) { // level option may cause NVENC to fail. // NVENC cannot adjust the given level, just throw an error. + // level option may cause corrupted frames on AMD VAAPI. } else if (!string.Equals(videoEncoder, "h264_omx", StringComparison.OrdinalIgnoreCase) || !string.Equals(videoEncoder, "libx265", StringComparison.OrdinalIgnoreCase)) @@ -1473,7 +1677,7 @@ namespace MediaBrowser.Controller.MediaEncoding // Source and target codecs must match if (string.IsNullOrEmpty(videoStream.Codec) - || !state.SupportedVideoCodecs.Contains(videoStream.Codec, StringComparer.OrdinalIgnoreCase)) + || !state.SupportedVideoCodecs.Contains(videoStream.Codec, StringComparison.OrdinalIgnoreCase)) { return false; } @@ -1491,10 +1695,10 @@ namespace MediaBrowser.Controller.MediaEncoding var requestedProfile = requestedProfiles[0]; // strip spaces because they may be stripped out on the query string as well if (!string.IsNullOrEmpty(videoStream.Profile) - && !requestedProfiles.Contains(videoStream.Profile.Replace(" ", string.Empty, StringComparison.Ordinal), StringComparer.OrdinalIgnoreCase)) + && !requestedProfiles.Contains(videoStream.Profile.Replace(" ", string.Empty, StringComparison.Ordinal), StringComparison.OrdinalIgnoreCase)) { - var currentScore = GetVideoProfileScore(videoStream.Profile); - var requestedScore = GetVideoProfileScore(requestedProfile); + var currentScore = GetVideoProfileScore(videoStream.Codec, videoStream.Profile); + var requestedScore = GetVideoProfileScore(videoStream.Codec, requestedProfile); if (currentScore == -1 || currentScore > requestedScore) { @@ -1555,7 +1759,7 @@ namespace MediaBrowser.Controller.MediaEncoding // If a specific level was requested, the source must match or be less than var level = state.GetRequestedLevel(videoStream.Codec); if (!string.IsNullOrEmpty(level) - && double.TryParse(level, NumberStyles.Any, _usCulture, out var requestLevel)) + && double.TryParse(level, NumberStyles.Any, CultureInfo.InvariantCulture, out var requestLevel)) { if (!videoStream.Level.HasValue) { @@ -1598,7 +1802,7 @@ namespace MediaBrowser.Controller.MediaEncoding // Source and target codecs must match if (string.IsNullOrEmpty(audioStream.Codec) - || !supportedAudioCodecs.Contains(audioStream.Codec, StringComparer.OrdinalIgnoreCase)) + || !supportedAudioCodecs.Contains(audioStream.Codec, StringComparison.OrdinalIgnoreCase)) { return false; } @@ -1705,7 +1909,8 @@ namespace MediaBrowser.Controller.MediaEncoding { if (string.Equals(codec, "h265", StringComparison.OrdinalIgnoreCase) || string.Equals(codec, "hevc", StringComparison.OrdinalIgnoreCase) - || string.Equals(codec, "vp9", StringComparison.OrdinalIgnoreCase)) + || string.Equals(codec, "vp9", StringComparison.OrdinalIgnoreCase) + || string.Equals(codec, "av1", StringComparison.OrdinalIgnoreCase)) { return .6; } @@ -1803,7 +2008,7 @@ namespace MediaBrowser.Controller.MediaEncoding && state.AudioStream.Channels.Value > 5 && !encodingOptions.DownMixAudioBoost.Equals(1)) { - filters.Add("volume=" + encodingOptions.DownMixAudioBoost.ToString(_usCulture)); + filters.Add("volume=" + encodingOptions.DownMixAudioBoost.ToString(CultureInfo.InvariantCulture)); } var isCopyingTimestamps = state.CopyTimestamps || state.TranscodingType != TranscodingJobType.Progressive; @@ -1942,19 +2147,35 @@ namespace MediaBrowser.Controller.MediaEncoding /// <summary> /// Gets the fast seek command line parameter. /// </summary> - /// <param name="request">The request.</param> + /// <param name="state">The state.</param> + /// <param name="options">The options.</param> /// <returns>System.String.</returns> /// <value>The fast seek command line parameter.</value> - public string GetFastSeekCommandLineParameter(BaseEncodingJobOptions request) + public string GetFastSeekCommandLineParameter(EncodingJobInfo state, EncodingOptions options) { - var time = request.StartTimeTicks ?? 0; + var time = state.BaseRequest.StartTimeTicks ?? 0; + var seekParam = string.Empty; if (time > 0) { - return string.Format(CultureInfo.InvariantCulture, "-ss {0}", _mediaEncoder.GetTimeParameter(time)); + seekParam += string.Format(CultureInfo.InvariantCulture, "-ss {0}", _mediaEncoder.GetTimeParameter(time)); + + if (state.IsVideoRequest) + { + var outputVideoCodec = GetVideoEncoder(state, options); + + // Important: If this is ever re-enabled, make sure not to use it with wtv because it breaks seeking + if (!string.Equals(state.InputContainer, "wtv", StringComparison.OrdinalIgnoreCase) + && state.TranscodingType != TranscodingJobType.Progressive + && !state.EnableBreakOnNonKeyFrames(outputVideoCodec) + && (state.BaseRequest.StartTimeTicks ?? 0) > 0) + { + seekParam += " -noaccurate_seek"; + } + } } - return string.Empty; + return seekParam; } /// <summary> @@ -2001,10 +2222,24 @@ namespace MediaBrowser.Controller.MediaEncoding if (state.AudioStream != null) { - args += string.Format( - CultureInfo.InvariantCulture, - " -map 0:{0}", - state.AudioStream.Index); + if (state.AudioStream.IsExternal) + { + int externalAudioMapIndex = state.SubtitleStream != null && state.SubtitleStream.IsExternal ? 2 : 1; + int externalAudioStream = state.MediaSource.MediaStreams.Where(i => i.Path == state.AudioStream.Path).ToList().IndexOf(state.AudioStream); + + args += string.Format( + CultureInfo.InvariantCulture, + " -map {0}:{1}", + externalAudioMapIndex, + externalAudioStream); + } + else + { + args += string.Format( + CultureInfo.InvariantCulture, + " -map 0:{0}", + state.AudioStream.Index); + } } else { @@ -2063,180 +2298,7 @@ namespace MediaBrowser.Controller.MediaEncoding return returnFirstIfNoIndex ? streams.FirstOrDefault() : null; } - /// <summary> - /// Gets the graphical subtitle parameter. - /// </summary> - /// <param name="state">Encoding state.</param> - /// <param name="options">Encoding options.</param> - /// <param name="outputVideoCodec">Video codec to use.</param> - /// <returns>Graphical subtitle parameter.</returns> - public string GetGraphicalSubtitleParam( - EncodingJobInfo state, - EncodingOptions options, - string outputVideoCodec) - { - outputVideoCodec ??= string.Empty; - - var outputSizeParam = ReadOnlySpan<char>.Empty; - var request = state.BaseRequest; - - outputSizeParam = GetOutputSizeParamInternal(state, options, outputVideoCodec); - - var videoSizeParam = string.Empty; - var videoDecoder = GetHardwareAcceleratedVideoDecoder(state, options) ?? string.Empty; - var isLinux = OperatingSystem.IsLinux(); - - var isVaapiDecoder = videoDecoder.IndexOf("vaapi", StringComparison.OrdinalIgnoreCase) != -1; - var isVaapiH264Encoder = outputVideoCodec.IndexOf("h264_vaapi", StringComparison.OrdinalIgnoreCase) != -1; - var isVaapiHevcEncoder = outputVideoCodec.IndexOf("hevc_vaapi", StringComparison.OrdinalIgnoreCase) != -1; - var isQsvH264Encoder = outputVideoCodec.Contains("h264_qsv", StringComparison.OrdinalIgnoreCase); - var isQsvHevcEncoder = outputVideoCodec.Contains("hevc_qsv", StringComparison.OrdinalIgnoreCase); - var isNvdecDecoder = videoDecoder.Contains("cuda", StringComparison.OrdinalIgnoreCase); - var isNvencEncoder = outputVideoCodec.Contains("nvenc", StringComparison.OrdinalIgnoreCase); - var isTonemappingSupportedOnVaapi = string.Equals(options.HardwareAccelerationType, "vaapi", StringComparison.OrdinalIgnoreCase) && isVaapiDecoder && (isVaapiH264Encoder || isVaapiHevcEncoder); - var isTonemappingSupportedOnQsv = string.Equals(options.HardwareAccelerationType, "qsv", StringComparison.OrdinalIgnoreCase) && isVaapiDecoder && (isQsvH264Encoder || isQsvHevcEncoder); - var isOpenclTonemappingSupported = IsOpenclTonemappingSupported(state, options); - var isVppTonemappingSupported = IsVppTonemappingSupported(state, options); - - var mediaEncoderVersion = _mediaEncoder.GetMediaEncoderVersion(); - var isCudaOverlaySupported = _mediaEncoder.SupportsFilter("overlay_cuda") && mediaEncoderVersion != null && mediaEncoderVersion >= minVersionForCudaOverlay; - var isCudaFormatConversionSupported = _mediaEncoder.SupportsFilterWithOption(FilterOptionType.ScaleCudaFormat); - - // Tonemapping and burn-in graphical subtitles requires overlay_vaapi. - // But it's still in ffmpeg mailing list. Disable it for now. - if (isTonemappingSupportedOnVaapi && isOpenclTonemappingSupported && !isVppTonemappingSupported) - { - return GetOutputSizeParam(state, options, outputVideoCodec); - } - - // Setup subtitle scaling - if (state.VideoStream != null && state.VideoStream.Width.HasValue && state.VideoStream.Height.HasValue) - { - // Adjust the size of graphical subtitles to fit the video stream. - var videoStream = state.VideoStream; - var inputWidth = videoStream.Width; - var inputHeight = videoStream.Height; - var (width, height) = GetFixedOutputSize(inputWidth, inputHeight, request.Width, request.Height, request.MaxWidth, request.MaxHeight); - - if (width.HasValue && height.HasValue) - { - videoSizeParam = string.Format( - CultureInfo.InvariantCulture, - "scale={0}x{1}", - width.Value, - height.Value); - } - - if (!string.IsNullOrEmpty(videoSizeParam) - && !(isTonemappingSupportedOnQsv && isVppTonemappingSupported)) - { - // upload graphical subtitle to QSV - if (isLinux && (string.Equals(outputVideoCodec, "h264_qsv", StringComparison.OrdinalIgnoreCase) - || string.Equals(outputVideoCodec, "hevc_qsv", StringComparison.OrdinalIgnoreCase))) - { - videoSizeParam += ",hwupload=extra_hw_frames=64"; - } - } - - if (!string.IsNullOrEmpty(videoSizeParam)) - { - // upload graphical subtitle to cuda - if (isNvdecDecoder && isNvencEncoder && isCudaOverlaySupported && isCudaFormatConversionSupported) - { - videoSizeParam += ",hwupload_cuda"; - } - } - } - - var mapPrefix = state.SubtitleStream.IsExternal ? - 1 : - 0; - - var subtitleStreamIndex = state.SubtitleStream.IsExternal - ? 0 - : state.SubtitleStream.Index; - - // Setup default filtergraph utilizing FFMpeg overlay() and FFMpeg scale() (see the return of this function for index reference) - // Always put the scaler before the overlay for better performance - var retStr = outputSizeParam.IsEmpty - ? " -filter_complex \"[{0}:{1}]{4}[sub];[0:{2}][sub]overlay\"" - : " -filter_complex \"[{0}:{1}]{4}[sub];[0:{2}]{3}[base];[base][sub]overlay\""; - - // When the input may or may not be hardware VAAPI decodable - if (string.Equals(outputVideoCodec, "h264_vaapi", StringComparison.OrdinalIgnoreCase) - || string.Equals(outputVideoCodec, "hevc_vaapi", StringComparison.OrdinalIgnoreCase)) - { - /* - [base]: HW scaling video to OutputSize - [sub]: SW scaling subtitle to FixedOutputSize - [base][sub]: SW overlay - */ - retStr = outputSizeParam.IsEmpty - ? " -filter_complex \"[{0}:{1}]{4}[sub];[0:{2}]hwdownload[base];[base][sub]overlay,format=nv12,hwupload\"" - : " -filter_complex \"[{0}:{1}]{4}[sub];[0:{2}]{3},hwdownload[base];[base][sub]overlay,format=nv12,hwupload\""; - } - - // If we're hardware VAAPI decoding and software encoding, download frames from the decoder first - else if (_mediaEncoder.SupportsHwaccel("vaapi") && videoDecoder.IndexOf("vaapi", StringComparison.OrdinalIgnoreCase) != -1 - && (string.Equals(outputVideoCodec, "libx264", StringComparison.OrdinalIgnoreCase) - || string.Equals(outputVideoCodec, "libx265", StringComparison.OrdinalIgnoreCase))) - { - /* - [base]: SW scaling video to OutputSize - [sub]: SW scaling subtitle to FixedOutputSize - [base][sub]: SW overlay - */ - retStr = outputSizeParam.IsEmpty - ? " -filter_complex \"[{0}:{1}]{4}[sub];[0:{2}][sub]overlay\"" - : " -filter_complex \"[{0}:{1}]{4}[sub];[0:{2}]{3}[base];[base][sub]overlay\""; - } - else if (string.Equals(outputVideoCodec, "h264_qsv", StringComparison.OrdinalIgnoreCase) - || string.Equals(outputVideoCodec, "hevc_qsv", StringComparison.OrdinalIgnoreCase)) - { - /* - QSV in FFMpeg can now setup hardware overlay for transcodes. - For software decoding and hardware encoding option, frames must be hwuploaded into hardware - with fixed frame size. - Currently only supports linux. - */ - if (isTonemappingSupportedOnQsv && isVppTonemappingSupported) - { - retStr = " -filter_complex \"[{0}:{1}]{4}[sub];[0:{2}]{3},hwdownload,format=nv12[base];[base][sub]overlay\""; - } - else if (isLinux) - { - retStr = outputSizeParam.IsEmpty - ? " -filter_complex \"[{0}:{1}]{4}[sub];[0:{2}][sub]overlay_qsv\"" - : " -filter_complex \"[{0}:{1}]{4}[sub];[0:{2}]{3}[base];[base][sub]overlay_qsv\""; - } - } - else if (isNvdecDecoder && isNvencEncoder) - { - if (isCudaOverlaySupported && isCudaFormatConversionSupported) - { - retStr = outputSizeParam.IsEmpty - ? " -filter_complex \"[{0}:{1}]{4}[sub];[0:{2}]scale_cuda=format=yuv420p[base];[base][sub]overlay_cuda\"" - : " -filter_complex \"[{0}:{1}]{4}[sub];[0:{2}]{3}[base];[base][sub]overlay_cuda\""; - } - else - { - retStr = outputSizeParam.IsEmpty - ? " -filter_complex \"[{0}:{1}]{4}[sub];[0:{2}][sub]overlay,format=nv12|yuv420p,hwupload_cuda\"" - : " -filter_complex \"[{0}:{1}]{4}[sub];[0:{2}]{3}[base];[base][sub]overlay,format=nv12|yuv420p,hwupload_cuda\""; - } - } - - return string.Format( - CultureInfo.InvariantCulture, - retStr, - mapPrefix, - subtitleStreamIndex, - state.VideoStream.Index, - outputSizeParam.ToString(), - videoSizeParam); - } - - public static (int? width, int? height) GetFixedOutputSize( + public static (int? Width, int? Height) GetFixedOutputSize( int? videoWidth, int? videoHeight, int? requestedWidth, @@ -2254,41 +2316,41 @@ namespace MediaBrowser.Controller.MediaEncoding return (null, null); } - decimal inputWidth = Convert.ToDecimal(videoWidth ?? requestedWidth, CultureInfo.InvariantCulture); - decimal inputHeight = Convert.ToDecimal(videoHeight ?? requestedHeight, CultureInfo.InvariantCulture); - decimal outputWidth = requestedWidth.HasValue ? Convert.ToDecimal(requestedWidth.Value) : inputWidth; - decimal outputHeight = requestedHeight.HasValue ? Convert.ToDecimal(requestedHeight.Value) : inputHeight; - decimal maximumWidth = requestedMaxWidth.HasValue ? Convert.ToDecimal(requestedMaxWidth.Value) : outputWidth; - decimal maximumHeight = requestedMaxHeight.HasValue ? Convert.ToDecimal(requestedMaxHeight.Value) : outputHeight; + int inputWidth = Convert.ToInt32(videoWidth ?? requestedWidth, CultureInfo.InvariantCulture); + int inputHeight = Convert.ToInt32(videoHeight ?? requestedHeight, CultureInfo.InvariantCulture); + int outputWidth = requestedWidth ?? inputWidth; + int outputHeight = requestedHeight ?? inputHeight; + + // Don't transcode video to bigger than 4k when using HW. + int maximumWidth = Math.Min(requestedMaxWidth ?? outputWidth, 4096); + int maximumHeight = Math.Min(requestedMaxHeight ?? outputHeight, 4096); if (outputWidth > maximumWidth || outputHeight > maximumHeight) { - var scale = Math.Min(maximumWidth / outputWidth, maximumHeight / outputHeight); - outputWidth = Math.Min(maximumWidth, Math.Truncate(outputWidth * scale)); - outputHeight = Math.Min(maximumHeight, Math.Truncate(outputHeight * scale)); + var scaleW = (double)maximumWidth / (double)outputWidth; + var scaleH = (double)maximumHeight / (double)outputHeight; + var scale = Math.Min(scaleW, scaleH); + outputWidth = Math.Min(maximumWidth, (int)(outputWidth * scale)); + outputHeight = Math.Min(maximumHeight, (int)(outputHeight * scale)); } - outputWidth = 2 * Math.Truncate(outputWidth / 2); - outputHeight = 2 * Math.Truncate(outputHeight / 2); + outputWidth = 2 * (outputWidth / 2); + outputHeight = 2 * (outputHeight / 2); - return (Convert.ToInt32(outputWidth), Convert.ToInt32(outputHeight)); + return (outputWidth, outputHeight); } - public List<string> GetScalingFilters( - EncodingJobInfo state, - EncodingOptions options, + public static string GetHwScaleFilter( + string hwScaleSuffix, + string videoFormat, int? videoWidth, int? videoHeight, - Video3DFormat? threedFormat, - string videoDecoder, - string videoEncoder, int? requestedWidth, int? requestedHeight, int? requestedMaxWidth, int? requestedMaxHeight) { - var filters = new List<string>(); - var (width, height) = GetFixedOutputSize( + var (outWidth, outHeight) = GetFixedOutputSize( videoWidth, videoHeight, requestedWidth, @@ -2296,280 +2358,202 @@ namespace MediaBrowser.Controller.MediaEncoding requestedMaxWidth, requestedMaxHeight); - if ((string.Equals(videoEncoder, "h264_vaapi", StringComparison.OrdinalIgnoreCase) - || string.Equals(videoEncoder, "h264_qsv", StringComparison.OrdinalIgnoreCase) - || string.Equals(videoEncoder, "hevc_vaapi", StringComparison.OrdinalIgnoreCase) - || string.Equals(videoEncoder, "hevc_qsv", StringComparison.OrdinalIgnoreCase)) - && width.HasValue - && height.HasValue) + var isFormatFixed = !string.IsNullOrEmpty(videoFormat); + var isSizeFixed = !videoWidth.HasValue + || outWidth.Value != videoWidth.Value + || !videoHeight.HasValue + || outHeight.Value != videoHeight.Value; + + var arg1 = isSizeFixed ? ("=w=" + outWidth.Value + ":h=" + outHeight.Value) : string.Empty; + var arg2 = isFormatFixed ? ("format=" + videoFormat) : string.Empty; + if (isFormatFixed) { - // Given the input dimensions (inputWidth, inputHeight), determine the output dimensions - // (outputWidth, outputHeight). The user may request precise output dimensions or maximum - // output dimensions. Output dimensions are guaranteed to be even. - var outputWidth = width.Value; - var outputHeight = height.Value; - var qsv_or_vaapi = string.Equals(videoEncoder, "h264_qsv", StringComparison.OrdinalIgnoreCase) - || string.Equals(videoEncoder, "hevc_qsv", StringComparison.OrdinalIgnoreCase); - var isDeintEnabled = state.DeInterlace("h264", true) - || state.DeInterlace("avc", true) - || state.DeInterlace("h265", true) - || state.DeInterlace("hevc", true); - - var isVaapiDecoder = videoDecoder.Contains("vaapi", StringComparison.OrdinalIgnoreCase); - var isVaapiH264Encoder = videoEncoder.Contains("h264_vaapi", StringComparison.OrdinalIgnoreCase); - var isVaapiHevcEncoder = videoEncoder.Contains("hevc_vaapi", StringComparison.OrdinalIgnoreCase); - var isQsvH264Encoder = videoEncoder.Contains("h264_qsv", StringComparison.OrdinalIgnoreCase); - var isQsvHevcEncoder = videoEncoder.Contains("hevc_qsv", StringComparison.OrdinalIgnoreCase); - var isOpenclTonemappingSupported = IsOpenclTonemappingSupported(state, options); - var isVppTonemappingSupported = IsVppTonemappingSupported(state, options); - var isTonemappingSupportedOnVaapi = string.Equals(options.HardwareAccelerationType, "vaapi", StringComparison.OrdinalIgnoreCase) && isVaapiDecoder && (isVaapiH264Encoder || isVaapiHevcEncoder); - var isTonemappingSupportedOnQsv = string.Equals(options.HardwareAccelerationType, "qsv", StringComparison.OrdinalIgnoreCase) && isVaapiDecoder && (isQsvH264Encoder || isQsvHevcEncoder); - var isP010PixFmtRequired = (isTonemappingSupportedOnVaapi && (isOpenclTonemappingSupported || isVppTonemappingSupported)) - || (isTonemappingSupportedOnQsv && isVppTonemappingSupported); - - var outputPixFmt = "format=nv12"; - if (isP010PixFmtRequired) - { - outputPixFmt = "format=p010"; - } - - if (isTonemappingSupportedOnQsv && isVppTonemappingSupported) - { - qsv_or_vaapi = false; - } - - if (!videoWidth.HasValue - || outputWidth != videoWidth.Value - || !videoHeight.HasValue - || outputHeight != videoHeight.Value) - { - // Force nv12 pixel format to enable 10-bit to 8-bit colour conversion. - // use vpp_qsv filter to avoid green bar when the fixed output size is requested. - filters.Add( - string.Format( - CultureInfo.InvariantCulture, - "{0}=w={1}:h={2}{3}{4}", - qsv_or_vaapi ? "vpp_qsv" : "scale_vaapi", - outputWidth, - outputHeight, - ":" + outputPixFmt, - (qsv_or_vaapi && isDeintEnabled) ? ":deinterlace=1" : string.Empty)); - } - - // Assert 10-bit is P010 so as we can avoid the extra scaler to get a bit more fps on high res HDR videos. - else if (!isP010PixFmtRequired) - { - filters.Add( - string.Format( - CultureInfo.InvariantCulture, - "{0}={1}{2}", - qsv_or_vaapi ? "vpp_qsv" : "scale_vaapi", - outputPixFmt, - (qsv_or_vaapi && isDeintEnabled) ? ":deinterlace=1" : string.Empty)); - } - } - else if ((videoDecoder ?? string.Empty).Contains("cuda", StringComparison.OrdinalIgnoreCase) - && width.HasValue - && height.HasValue) - { - var outputWidth = width.Value; - var outputHeight = height.Value; - - var isNvencEncoder = videoEncoder.Contains("nvenc", StringComparison.OrdinalIgnoreCase); - var isOpenclTonemappingSupported = IsOpenclTonemappingSupported(state, options); - var isCudaTonemappingSupported = IsCudaTonemappingSupported(state, options); - var isTonemappingSupportedOnNvenc = string.Equals(options.HardwareAccelerationType, "nvenc", StringComparison.OrdinalIgnoreCase); - var mediaEncoderVersion = _mediaEncoder.GetMediaEncoderVersion(); - var isCudaOverlaySupported = _mediaEncoder.SupportsFilter("overlay_cuda") && mediaEncoderVersion != null && mediaEncoderVersion >= minVersionForCudaOverlay; - var isCudaFormatConversionSupported = _mediaEncoder.SupportsFilterWithOption(FilterOptionType.ScaleCudaFormat); - var hasGraphicalSubs = state.SubtitleStream != null && !state.SubtitleStream.IsTextSubtitleStream && state.SubtitleDeliveryMethod == SubtitleDeliveryMethod.Encode; - - var outputPixFmt = string.Empty; - if (isCudaFormatConversionSupported) - { - outputPixFmt = (hasGraphicalSubs && isCudaOverlaySupported && isNvencEncoder) - ? "format=yuv420p" - : "format=nv12"; - if ((isOpenclTonemappingSupported || isCudaTonemappingSupported) - && isTonemappingSupportedOnNvenc) - { - outputPixFmt = "format=p010"; - } - } - - if (!videoWidth.HasValue - || outputWidth != videoWidth.Value - || !videoHeight.HasValue - || outputHeight != videoHeight.Value) - { - filters.Add( - string.Format( - CultureInfo.InvariantCulture, - "scale_cuda=w={0}:h={1}{2}", - outputWidth, - outputHeight, - isCudaFormatConversionSupported ? (":" + outputPixFmt) : string.Empty)); - } - else if (isCudaFormatConversionSupported) - { - filters.Add( - string.Format( - CultureInfo.InvariantCulture, - "scale_cuda={0}", - outputPixFmt)); - } - } - else if ((videoDecoder ?? string.Empty).IndexOf("cuvid", StringComparison.OrdinalIgnoreCase) != -1 - && width.HasValue - && height.HasValue) - { - // Nothing to do, it's handled as an input resize filter - } - else - { - var isExynosV4L2 = string.Equals(videoEncoder, "h264_v4l2m2m", StringComparison.OrdinalIgnoreCase); - - // If fixed dimensions were supplied - if (requestedWidth.HasValue && requestedHeight.HasValue) - { - if (isExynosV4L2) - { - var widthParam = requestedWidth.Value.ToString(_usCulture); - var heightParam = requestedHeight.Value.ToString(_usCulture); - - filters.Add( - string.Format( - CultureInfo.InvariantCulture, - "scale=trunc({0}/64)*64:trunc({1}/2)*2", - widthParam, - heightParam)); - } - else - { - filters.Add(GetFixedSizeScalingFilter(threedFormat, requestedWidth.Value, requestedHeight.Value)); - } - } - - // If Max dimensions were supplied, for width selects lowest even number between input width and width req size and selects lowest even number from in width*display aspect and requested size - else if (requestedMaxWidth.HasValue && requestedMaxHeight.HasValue) - { - var maxWidthParam = requestedMaxWidth.Value.ToString(_usCulture); - var maxHeightParam = requestedMaxHeight.Value.ToString(_usCulture); - - if (isExynosV4L2) - { - filters.Add( - string.Format( - CultureInfo.InvariantCulture, - "scale=trunc(min(max(iw\\,ih*dar)\\,min({0}\\,{1}*dar))/64)*64:trunc(min(max(iw/dar\\,ih)\\,min({0}/dar\\,{1}))/2)*2", - maxWidthParam, - maxHeightParam)); - } - else - { - filters.Add( - string.Format( - CultureInfo.InvariantCulture, - "scale=trunc(min(max(iw\\,ih*dar)\\,min({0}\\,{1}*dar))/2)*2:trunc(min(max(iw/dar\\,ih)\\,min({0}/dar\\,{1}))/2)*2", - maxWidthParam, - maxHeightParam)); - } - } - - // If a fixed width was requested - else if (requestedWidth.HasValue) - { - if (threedFormat.HasValue) - { - // This method can handle 0 being passed in for the requested height - filters.Add(GetFixedSizeScalingFilter(threedFormat, requestedWidth.Value, 0)); - } - else - { - var widthParam = requestedWidth.Value.ToString(_usCulture); - - filters.Add( - string.Format( - CultureInfo.InvariantCulture, - "scale={0}:trunc(ow/a/2)*2", - widthParam)); - } - } - - // If a fixed height was requested - else if (requestedHeight.HasValue) - { - var heightParam = requestedHeight.Value.ToString(_usCulture); - - if (isExynosV4L2) - { - filters.Add( - string.Format( - CultureInfo.InvariantCulture, - "scale=trunc(oh*a/64)*64:{0}", - heightParam)); - } - else - { - filters.Add( - string.Format( - CultureInfo.InvariantCulture, - "scale=trunc(oh*a/2)*2:{0}", - heightParam)); - } - } - - // If a max width was requested - else if (requestedMaxWidth.HasValue) - { - var maxWidthParam = requestedMaxWidth.Value.ToString(_usCulture); - - if (isExynosV4L2) - { - filters.Add( - string.Format( - CultureInfo.InvariantCulture, - "scale=trunc(min(max(iw\\,ih*dar)\\,{0})/64)*64:trunc(ow/dar/2)*2", - maxWidthParam)); - } - else - { - filters.Add( - string.Format( - CultureInfo.InvariantCulture, - "scale=trunc(min(max(iw\\,ih*dar)\\,{0})/2)*2:trunc(ow/dar/2)*2", - maxWidthParam)); - } - } - - // If a max height was requested - else if (requestedMaxHeight.HasValue) - { - var maxHeightParam = requestedMaxHeight.Value.ToString(_usCulture); - - if (isExynosV4L2) - { - filters.Add( - string.Format( - CultureInfo.InvariantCulture, - "scale=trunc(oh*a/64)*64:min(max(iw/dar\\,ih)\\,{0})", - maxHeightParam)); - } - else - { - filters.Add( - string.Format( - CultureInfo.InvariantCulture, - "scale=trunc(oh*a/2)*2:min(max(iw/dar\\,ih)\\,{0})", - maxHeightParam)); - } - } + arg2 = (isSizeFixed ? ':' : '=') + arg2; } - return filters; + if (!string.IsNullOrEmpty(hwScaleSuffix) && (isSizeFixed || isFormatFixed)) + { + return string.Format( + CultureInfo.InvariantCulture, + "scale_{0}{1}{2}", + hwScaleSuffix, + arg1, + arg2); + } + + return string.Empty; } - private string GetFixedSizeScalingFilter(Video3DFormat? threedFormat, int requestedWidth, int requestedHeight) + public static string GetCustomSwScaleFilter( + int? videoWidth, + int? videoHeight, + int? requestedWidth, + int? requestedHeight, + int? requestedMaxWidth, + int? requestedMaxHeight) + { + var (outWidth, outHeight) = GetFixedOutputSize( + videoWidth, + videoHeight, + requestedWidth, + requestedHeight, + requestedMaxWidth, + requestedMaxHeight); + + if (outWidth.HasValue && outHeight.HasValue) + { + return string.Format( + CultureInfo.InvariantCulture, + "scale=s={0}x{1}:flags=fast_bilinear", + outWidth.Value, + outHeight.Value); + } + + return string.Empty; + } + + public static string GetAlphaSrcFilter( + EncodingJobInfo state, + int? videoWidth, + int? videoHeight, + int? requestedWidth, + int? requestedHeight, + int? requestedMaxWidth, + int? requestedMaxHeight, + int? framerate) + { + var reqTicks = state.BaseRequest.StartTimeTicks ?? 0; + var startTime = TimeSpan.FromTicks(reqTicks).ToString(@"hh\\\:mm\\\:ss\\\.fff", CultureInfo.InvariantCulture); + var (outWidth, outHeight) = GetFixedOutputSize( + videoWidth, + videoHeight, + requestedWidth, + requestedHeight, + requestedMaxWidth, + requestedMaxHeight); + + if (outWidth.HasValue && outHeight.HasValue) + { + return string.Format( + CultureInfo.InvariantCulture, + "alphasrc=s={0}x{1}:r={2}:start='{3}'", + outWidth.Value, + outHeight.Value, + framerate ?? 10, + reqTicks > 0 ? startTime : 0); + } + + return string.Empty; + } + + public static string GetSwScaleFilter( + EncodingJobInfo state, + EncodingOptions options, + string videoEncoder, + int? videoWidth, + int? videoHeight, + Video3DFormat? threedFormat, + int? requestedWidth, + int? requestedHeight, + int? requestedMaxWidth, + int? requestedMaxHeight) + { + var isV4l2 = string.Equals(videoEncoder, "h264_v4l2m2m", StringComparison.OrdinalIgnoreCase); + var scaleVal = isV4l2 ? 64 : 2; + + // If fixed dimensions were supplied + if (requestedWidth.HasValue && requestedHeight.HasValue) + { + if (isV4l2) + { + var widthParam = requestedWidth.Value.ToString(CultureInfo.InvariantCulture); + var heightParam = requestedHeight.Value.ToString(CultureInfo.InvariantCulture); + + return string.Format( + CultureInfo.InvariantCulture, + "scale=trunc({0}/64)*64:trunc({1}/2)*2", + widthParam, + heightParam); + } + else + { + return GetFixedSwScaleFilter(threedFormat, requestedWidth.Value, requestedHeight.Value); + } + } + + // If Max dimensions were supplied, for width selects lowest even number between input width and width req size and selects lowest even number from in width*display aspect and requested size + else if (requestedMaxWidth.HasValue && requestedMaxHeight.HasValue) + { + var maxWidthParam = requestedMaxWidth.Value.ToString(CultureInfo.InvariantCulture); + var maxHeightParam = requestedMaxHeight.Value.ToString(CultureInfo.InvariantCulture); + + return string.Format( + CultureInfo.InvariantCulture, + "scale=trunc(min(max(iw\\,ih*dar)\\,min({0}\\,{1}*dar))/{2})*{2}:trunc(min(max(iw/dar\\,ih)\\,min({0}/dar\\,{1}))/2)*2", + maxWidthParam, + maxHeightParam, + scaleVal); + } + + // If a fixed width was requested + else if (requestedWidth.HasValue) + { + if (threedFormat.HasValue) + { + // This method can handle 0 being passed in for the requested height + return GetFixedSwScaleFilter(threedFormat, requestedWidth.Value, 0); + } + else + { + var widthParam = requestedWidth.Value.ToString(CultureInfo.InvariantCulture); + + return string.Format( + CultureInfo.InvariantCulture, + "scale={0}:trunc(ow/a/2)*2", + widthParam); + } + } + + // If a fixed height was requested + else if (requestedHeight.HasValue) + { + var heightParam = requestedHeight.Value.ToString(CultureInfo.InvariantCulture); + + return string.Format( + CultureInfo.InvariantCulture, + "scale=trunc(oh*a/{1})*{1}:{0}", + heightParam, + scaleVal); + } + + // If a max width was requested + else if (requestedMaxWidth.HasValue) + { + var maxWidthParam = requestedMaxWidth.Value.ToString(CultureInfo.InvariantCulture); + + return string.Format( + CultureInfo.InvariantCulture, + "scale=trunc(min(max(iw\\,ih*dar)\\,{0})/{1})*{1}:trunc(ow/dar/2)*2", + maxWidthParam, + scaleVal); + } + + // If a max height was requested + else if (requestedMaxHeight.HasValue) + { + var maxHeightParam = requestedMaxHeight.Value.ToString(CultureInfo.InvariantCulture); + + return string.Format( + CultureInfo.InvariantCulture, + "scale=trunc(oh*a/{1})*{1}:min(max(iw/dar\\,ih)\\,{0})", + maxHeightParam, + scaleVal); + } + + return string.Empty; + } + + private static string GetFixedSwScaleFilter(Video3DFormat? threedFormat, int requestedWidth, int requestedHeight) { var widthParam = requestedWidth.ToString(CultureInfo.InvariantCulture); var heightParam = requestedHeight.ToString(CultureInfo.InvariantCulture); @@ -2617,559 +2601,1561 @@ namespace MediaBrowser.Controller.MediaEncoding return string.Format(CultureInfo.InvariantCulture, filter, widthParam, heightParam); } - /// <summary> - /// Gets the output size parameter. - /// </summary> - /// <param name="state">Encoding state.</param> - /// <param name="options">Encoding options.</param> - /// <param name="outputVideoCodec">Video codec to use.</param> - /// <returns>The output size parameter.</returns> - public string GetOutputSizeParam( - EncodingJobInfo state, - EncodingOptions options, - string outputVideoCodec) + public static string GetSwDeinterlaceFilter(EncodingJobInfo state, EncodingOptions options) { - string filters = GetOutputSizeParamInternal(state, options, outputVideoCodec); - return string.IsNullOrEmpty(filters) ? string.Empty : " -vf \"" + filters + "\""; + var doubleRateDeint = options.DeinterlaceDoubleRate && state.VideoStream?.AverageFrameRate <= 30; + return string.Format( + CultureInfo.InvariantCulture, + "{0}={1}:-1:0", + string.Equals(options.DeinterlaceMethod, "bwdif", StringComparison.OrdinalIgnoreCase) ? "bwdif" : "yadif", + doubleRateDeint ? "1" : "0"); } - /// <summary> - /// Gets the output size parameter. - /// If we're going to put a fixed size on the command line, this will calculate it. - /// </summary> - /// <param name="state">Encoding state.</param> - /// <param name="options">Encoding options.</param> - /// <param name="outputVideoCodec">Video codec to use.</param> - /// <returns>The output size parameter.</returns> - public string GetOutputSizeParamInternal( - EncodingJobInfo state, - EncodingOptions options, - string outputVideoCodec) + public static string GetHwDeinterlaceFilter(EncodingJobInfo state, EncodingOptions options, string hwDeintSuffix) { - // http://sonnati.wordpress.com/2012/10/19/ffmpeg-the-swiss-army-knife-of-internet-streaming-part-vi/ - - var request = state.BaseRequest; - var videoStream = state.VideoStream; - var filters = new List<string>(); - - var videoDecoder = GetHardwareAcceleratedVideoDecoder(state, options) ?? string.Empty; - var inputWidth = videoStream?.Width; - var inputHeight = videoStream?.Height; - var threeDFormat = state.MediaSource.Video3DFormat; - - var isSwDecoder = string.IsNullOrEmpty(videoDecoder); - var isD3d11vaDecoder = videoDecoder.IndexOf("d3d11va", StringComparison.OrdinalIgnoreCase) != -1; - var isVaapiDecoder = videoDecoder.IndexOf("vaapi", StringComparison.OrdinalIgnoreCase) != -1; - var isVaapiEncoder = outputVideoCodec.IndexOf("vaapi", StringComparison.OrdinalIgnoreCase) != -1; - var isVaapiH264Encoder = outputVideoCodec.IndexOf("h264_vaapi", StringComparison.OrdinalIgnoreCase) != -1; - var isVaapiHevcEncoder = outputVideoCodec.IndexOf("hevc_vaapi", StringComparison.OrdinalIgnoreCase) != -1; - var isQsvH264Encoder = outputVideoCodec.IndexOf("h264_qsv", StringComparison.OrdinalIgnoreCase) != -1; - var isQsvHevcEncoder = outputVideoCodec.IndexOf("hevc_qsv", StringComparison.OrdinalIgnoreCase) != -1; - var isNvdecDecoder = videoDecoder.Contains("cuda", StringComparison.OrdinalIgnoreCase); - var isNvencEncoder = outputVideoCodec.Contains("nvenc", StringComparison.OrdinalIgnoreCase); - var isCuvidH264Decoder = videoDecoder.Contains("h264_cuvid", StringComparison.OrdinalIgnoreCase); - var isCuvidHevcDecoder = videoDecoder.Contains("hevc_cuvid", StringComparison.OrdinalIgnoreCase); - var isCuvidVp9Decoder = videoDecoder.Contains("vp9_cuvid", StringComparison.OrdinalIgnoreCase); - var isLibX264Encoder = outputVideoCodec.IndexOf("libx264", StringComparison.OrdinalIgnoreCase) != -1; - var isLibX265Encoder = outputVideoCodec.IndexOf("libx265", StringComparison.OrdinalIgnoreCase) != -1; - var isLinux = OperatingSystem.IsLinux(); - var isColorDepth10 = IsColorDepth10(state); - - var isTonemappingSupportedOnNvenc = string.Equals(options.HardwareAccelerationType, "nvenc", StringComparison.OrdinalIgnoreCase) && (isNvdecDecoder || isCuvidHevcDecoder || isCuvidVp9Decoder || isSwDecoder); - var isTonemappingSupportedOnAmf = string.Equals(options.HardwareAccelerationType, "amf", StringComparison.OrdinalIgnoreCase) && (isD3d11vaDecoder || isSwDecoder); - var isTonemappingSupportedOnVaapi = string.Equals(options.HardwareAccelerationType, "vaapi", StringComparison.OrdinalIgnoreCase) && isVaapiDecoder && (isVaapiH264Encoder || isVaapiHevcEncoder); - var isTonemappingSupportedOnQsv = string.Equals(options.HardwareAccelerationType, "qsv", StringComparison.OrdinalIgnoreCase) && isVaapiDecoder && (isQsvH264Encoder || isQsvHevcEncoder); - var isOpenclTonemappingSupported = IsOpenclTonemappingSupported(state, options); - var isVppTonemappingSupported = IsVppTonemappingSupported(state, options); - var isCudaTonemappingSupported = IsCudaTonemappingSupported(state, options); - var mediaEncoderVersion = _mediaEncoder.GetMediaEncoderVersion(); - var isCudaOverlaySupported = _mediaEncoder.SupportsFilter("overlay_cuda") && mediaEncoderVersion != null && mediaEncoderVersion >= minVersionForCudaOverlay; - - var hasSubs = state.SubtitleStream != null && state.SubtitleDeliveryMethod == SubtitleDeliveryMethod.Encode; - var hasTextSubs = state.SubtitleStream != null && state.SubtitleStream.IsTextSubtitleStream && state.SubtitleDeliveryMethod == SubtitleDeliveryMethod.Encode; - var hasGraphicalSubs = state.SubtitleStream != null && !state.SubtitleStream.IsTextSubtitleStream && state.SubtitleDeliveryMethod == SubtitleDeliveryMethod.Encode; - - // If double rate deinterlacing is enabled and the input framerate is 30fps or below, otherwise the output framerate will be too high for many devices - var doubleRateDeinterlace = options.DeinterlaceDoubleRate && (videoStream?.AverageFrameRate ?? 60) <= 30; - - var isScalingInAdvance = false; - var isCudaDeintInAdvance = false; - var isHwuploadCudaRequired = false; - var isNoTonemapFilterApplied = true; - var isDeinterlaceH264 = state.DeInterlace("h264", true) || state.DeInterlace("avc", true); - var isDeinterlaceHevc = state.DeInterlace("h265", true) || state.DeInterlace("hevc", true); - - // Add OpenCL tonemapping filter for NVENC/AMF/VAAPI. - if ((isTonemappingSupportedOnNvenc && !isCudaTonemappingSupported) || isTonemappingSupportedOnAmf || (isTonemappingSupportedOnVaapi && !isVppTonemappingSupported)) + var doubleRateDeint = options.DeinterlaceDoubleRate && (state.VideoStream?.AverageFrameRate ?? 60) <= 30; + if (hwDeintSuffix.Contains("cuda", StringComparison.OrdinalIgnoreCase)) { - // NVIDIA Pascal and Turing or higher are recommended. - // AMD Polaris and Vega or higher are recommended. - // Intel Kaby Lake or newer is required. - if (isOpenclTonemappingSupported) - { - isNoTonemapFilterApplied = false; - var inputHdrParams = GetInputHdrParams(videoStream.ColorTransfer); - if (!string.IsNullOrEmpty(inputHdrParams)) - { - filters.Add(inputHdrParams); - } - - var parameters = "tonemap_opencl=format=nv12:primaries=bt709:transfer=bt709:matrix=bt709:tonemap={0}:desat={1}:threshold={2}:peak={3}"; - - if (options.TonemappingParam != 0) - { - parameters += ":param={4}"; - } - - if (!string.Equals(options.TonemappingRange, "auto", StringComparison.OrdinalIgnoreCase)) - { - parameters += ":range={5}"; - } - - if (isSwDecoder || isD3d11vaDecoder) - { - isScalingInAdvance = true; - // Add zscale filter before tone mapping filter for performance. - var (width, height) = GetFixedOutputSize(inputWidth, inputHeight, request.Width, request.Height, request.MaxWidth, request.MaxHeight); - if (width.HasValue && height.HasValue) - { - filters.Add( - string.Format( - CultureInfo.InvariantCulture, - "zscale=s={0}x{1}", - width.Value, - height.Value)); - } - - // Convert to hardware pixel format p010 when using SW decoder. - filters.Add("format=p010"); - } - - if ((isDeinterlaceH264 || isDeinterlaceHevc) && isNvdecDecoder) - { - isCudaDeintInAdvance = true; - filters.Add( - string.Format( - CultureInfo.InvariantCulture, - "yadif_cuda={0}:-1:0", - doubleRateDeinterlace ? "1" : "0")); - } - - if (isVaapiDecoder || isNvdecDecoder) - { - isScalingInAdvance = true; - filters.AddRange( - GetScalingFilters( - state, - options, - inputWidth, - inputHeight, - threeDFormat, - videoDecoder, - outputVideoCodec, - request.Width, - request.Height, - request.MaxWidth, - request.MaxHeight)); - } - - // hwmap the HDR data to opencl device by cl-va p010 interop. - if (isVaapiDecoder) - { - filters.Add("hwmap"); - } - - // convert cuda device data to p010 host data. - if (isNvdecDecoder) - { - filters.Add("hwdownload,format=p010"); - } - - if (isNvdecDecoder - || isCuvidHevcDecoder - || isCuvidVp9Decoder - || isSwDecoder - || isD3d11vaDecoder) - { - // Upload the HDR10 or HLG data to the OpenCL device, - // use tonemap_opencl filter for tone mapping, - // and then download the SDR data to memory. - filters.Add("hwupload"); - } - - // Fallback to hable if bt2390 is chosen but not supported in tonemap_opencl. - var isBt2390SupportedInOpenclTonemap = _mediaEncoder.SupportsFilterWithOption(FilterOptionType.TonemapOpenclBt2390); - if (string.Equals(options.TonemappingAlgorithm, "bt2390", StringComparison.OrdinalIgnoreCase) - && !isBt2390SupportedInOpenclTonemap) - { - options.TonemappingAlgorithm = "hable"; - } - - filters.Add( - string.Format( - CultureInfo.InvariantCulture, - parameters, - options.TonemappingAlgorithm, - options.TonemappingDesat, - options.TonemappingThreshold, - options.TonemappingPeak, - options.TonemappingParam, - options.TonemappingRange)); - - if (isNvdecDecoder - || isCuvidHevcDecoder - || isCuvidVp9Decoder - || isSwDecoder - || isD3d11vaDecoder) - { - filters.Add("hwdownload"); - filters.Add("format=nv12"); - } - - if (isNvdecDecoder && isNvencEncoder) - { - isHwuploadCudaRequired = true; - } - - if (isVaapiDecoder) - { - // Reverse the data route from opencl to vaapi. - filters.Add("hwmap=derive_device=vaapi:reverse=1"); - } - - var outputSdrParams = GetOutputSdrParams(options.TonemappingRange); - if (!string.IsNullOrEmpty(outputSdrParams)) - { - filters.Add(outputSdrParams); - } - } + return string.Format( + CultureInfo.InvariantCulture, + "yadif_cuda={0}:-1:0", + doubleRateDeint ? "1" : "0"); + } + else if (hwDeintSuffix.Contains("vaapi", StringComparison.OrdinalIgnoreCase)) + { + return string.Format( + CultureInfo.InvariantCulture, + "deinterlace_vaapi=rate={0}", + doubleRateDeint ? "field" : "frame"); + } + else if (hwDeintSuffix.Contains("qsv", StringComparison.OrdinalIgnoreCase)) + { + return "deinterlace_qsv=mode=2"; } - // When the input may or may not be hardware VAAPI decodable. - if ((isVaapiH264Encoder || isVaapiHevcEncoder) - && !(isTonemappingSupportedOnVaapi && (isOpenclTonemappingSupported || isVppTonemappingSupported))) + return string.Empty; + } + + public static string GetHwTonemapFilter(EncodingOptions options, string hwTonemapSuffix, string videoFormat) + { + if (string.IsNullOrEmpty(hwTonemapSuffix)) { - filters.Add("format=nv12|vaapi"); - filters.Add("hwupload"); + return string.Empty; } - // When burning in graphical subtitles using overlay_qsv, upload videostream to the same qsv context. - else if (isLinux && hasGraphicalSubs && (isQsvH264Encoder || isQsvHevcEncoder) - && !(isTonemappingSupportedOnQsv && isVppTonemappingSupported)) + var args = "tonemap_{0}=format={1}:p=bt709:t=bt709:m=bt709"; + + if (!hwTonemapSuffix.Contains("vaapi", StringComparison.OrdinalIgnoreCase)) { - filters.Add("hwupload=extra_hw_frames=64"); - } - - // If we're hardware VAAPI decoding and software encoding, download frames from the decoder first. - else if ((IsVaapiSupported(state) && isVaapiDecoder) && (isLibX264Encoder || isLibX265Encoder) - && !(isTonemappingSupportedOnQsv && isVppTonemappingSupported)) - { - var codec = videoStream.Codec; - - // Assert 10-bit hardware VAAPI decodable - if (isColorDepth10 && (string.Equals(codec, "hevc", StringComparison.OrdinalIgnoreCase) - || string.Equals(codec, "h265", StringComparison.OrdinalIgnoreCase) - || string.Equals(codec, "vp9", StringComparison.OrdinalIgnoreCase))) - { - /* - Download data from GPU to CPU as p010le format. - Colorspace conversion is unnecessary here as libx264 will handle it. - If this step is missing, it will fail on AMD but not on intel. - */ - filters.Add("hwdownload"); - filters.Add("format=p010le"); - } - - // Assert 8-bit hardware VAAPI decodable - else if (!isColorDepth10) - { - filters.Add("hwdownload"); - filters.Add("format=nv12"); - } - } - - // Add hardware deinterlace filter before scaling filter. - if (isDeinterlaceH264 || isDeinterlaceHevc) - { - if (isVaapiEncoder - || (isTonemappingSupportedOnQsv && isVppTonemappingSupported)) - { - filters.Add( - string.Format( - CultureInfo.InvariantCulture, - "deinterlace_vaapi=rate={0}", - doubleRateDeinterlace ? "field" : "frame")); - } - else if (isNvdecDecoder && !isCudaDeintInAdvance) - { - filters.Add( - string.Format( - CultureInfo.InvariantCulture, - "yadif_cuda={0}:-1:0", - doubleRateDeinterlace ? "1" : "0")); - } - } - - // Add software deinterlace filter before scaling filter. - if ((isDeinterlaceH264 || isDeinterlaceHevc) - && !isVaapiH264Encoder - && !isVaapiHevcEncoder - && !isQsvH264Encoder - && !isQsvHevcEncoder - && !isNvdecDecoder - && !isCuvidH264Decoder) - { - if (string.Equals(options.DeinterlaceMethod, "bwdif", StringComparison.OrdinalIgnoreCase)) - { - filters.Add( - string.Format( - CultureInfo.InvariantCulture, - "bwdif={0}:-1:0", - doubleRateDeinterlace ? "1" : "0")); - } - else - { - filters.Add( - string.Format( - CultureInfo.InvariantCulture, - "yadif={0}:-1:0", - doubleRateDeinterlace ? "1" : "0")); - } - } - - // Add scaling filter: scale_*=format=nv12 or scale_*=w=*:h=*:format=nv12 or scale=expr - if (!isScalingInAdvance) - { - filters.AddRange( - GetScalingFilters( - state, - options, - inputWidth, - inputHeight, - threeDFormat, - videoDecoder, - outputVideoCodec, - request.Width, - request.Height, - request.MaxWidth, - request.MaxHeight)); - } - - // Add Cuda tonemapping filter. - if (isNvdecDecoder && isCudaTonemappingSupported) - { - isNoTonemapFilterApplied = false; - var inputHdrParams = GetInputHdrParams(videoStream.ColorTransfer); - if (!string.IsNullOrEmpty(inputHdrParams)) - { - filters.Add(inputHdrParams); - } - - var parameters = (hasGraphicalSubs && isCudaOverlaySupported && isNvencEncoder) - ? "tonemap_cuda=format=yuv420p:primaries=bt709:transfer=bt709:matrix=bt709:tonemap={0}:peak={1}:desat={2}" - : "tonemap_cuda=format=nv12:primaries=bt709:transfer=bt709:matrix=bt709:tonemap={0}:peak={1}:desat={2}"; + args += ":tonemap={2}:peak={3}:desat={4}"; if (options.TonemappingParam != 0) { - parameters += ":param={3}"; + args += ":param={5}"; } if (!string.Equals(options.TonemappingRange, "auto", StringComparison.OrdinalIgnoreCase)) { - parameters += ":range={4}"; - } - - filters.Add( - string.Format( - CultureInfo.InvariantCulture, - parameters, - options.TonemappingAlgorithm, - options.TonemappingPeak, - options.TonemappingDesat, - options.TonemappingParam, - options.TonemappingRange)); - - if (isLibX264Encoder - || isLibX265Encoder - || hasTextSubs - || (hasGraphicalSubs && !isCudaOverlaySupported && isNvencEncoder)) - { - if (isNvencEncoder) - { - isHwuploadCudaRequired = true; - } - - filters.Add("hwdownload"); - filters.Add("format=nv12"); - } - - var outputSdrParams = GetOutputSdrParams(options.TonemappingRange); - if (!string.IsNullOrEmpty(outputSdrParams)) - { - filters.Add(outputSdrParams); + args += ":range={6}"; } } - // Add VPP tonemapping filter for VAAPI. - // Full hardware based video post processing, faster than OpenCL but lacks fine tuning options. - if ((isTonemappingSupportedOnVaapi || isTonemappingSupportedOnQsv) - && isVppTonemappingSupported) - { - filters.Add("tonemap_vaapi=format=nv12:transfer=bt709:matrix=bt709:primaries=bt709"); - } - - // Another case is when using Nvenc decoder. - if (isNvdecDecoder && !isOpenclTonemappingSupported && !isCudaTonemappingSupported) - { - var codec = videoStream.Codec; - var isCudaFormatConversionSupported = _mediaEncoder.SupportsFilterWithOption(FilterOptionType.ScaleCudaFormat); - - // Assert 10-bit hardware decodable - if (isColorDepth10 && (string.Equals(codec, "hevc", StringComparison.OrdinalIgnoreCase) - || string.Equals(codec, "h265", StringComparison.OrdinalIgnoreCase) - || string.Equals(codec, "vp9", StringComparison.OrdinalIgnoreCase))) - { - if (isCudaFormatConversionSupported) - { - if (isLibX264Encoder - || isLibX265Encoder - || hasTextSubs - || (hasGraphicalSubs && !isCudaOverlaySupported && isNvencEncoder)) - { - if (isNvencEncoder) - { - isHwuploadCudaRequired = true; - } - - filters.Add("hwdownload"); - filters.Add("format=nv12"); - } - } - else - { - // Download data from GPU to CPU as p010 format. - filters.Add("hwdownload"); - filters.Add("format=p010"); - - // Cuda lacks of a pixel format converter. - if (isNvencEncoder) - { - isHwuploadCudaRequired = true; - filters.Add("format=yuv420p"); - } - } - } - - // Assert 8-bit hardware decodable - else if (!isColorDepth10 - && (isLibX264Encoder - || isLibX265Encoder - || hasTextSubs - || (hasGraphicalSubs && !isCudaOverlaySupported && isNvencEncoder))) - { - if (isNvencEncoder) - { - isHwuploadCudaRequired = true; - } - - filters.Add("hwdownload"); - filters.Add("format=nv12"); - } - } - - // Add parameters to use VAAPI with burn-in text subtitles (GH issue #642) - if (isVaapiH264Encoder - || isVaapiHevcEncoder - || (isTonemappingSupportedOnQsv && isVppTonemappingSupported)) - { - if (hasTextSubs) - { - // Convert hw context from ocl to va. - // For tonemapping and text subs burn-in. - if (isTonemappingSupportedOnVaapi && isOpenclTonemappingSupported && !isVppTonemappingSupported) - { - filters.Add("scale_vaapi"); - } - - // Test passed on Intel and AMD gfx - filters.Add("hwmap=mode=read+write"); - filters.Add("format=nv12"); - } - } - - if (hasTextSubs) - { - var subParam = GetTextSubtitleParam(state); - - filters.Add(subParam); - - // Ensure proper filters are passed to ffmpeg in case of hardware acceleration via VA-API - // Reference: https://trac.ffmpeg.org/wiki/Hardware/VAAPI - if (isVaapiH264Encoder || isVaapiHevcEncoder) - { - filters.Add("hwmap"); - } - - if (isTonemappingSupportedOnQsv && isVppTonemappingSupported) - { - filters.Add("hwmap,format=vaapi"); - } - - if (isNvdecDecoder && isNvencEncoder) - { - isHwuploadCudaRequired = true; - } - } - - // Interop the VAAPI data to QSV for hybrid tonemapping - if (isTonemappingSupportedOnQsv && isVppTonemappingSupported && !hasGraphicalSubs) - { - filters.Add("hwmap=derive_device=qsv,scale_qsv"); - } - - if (isHwuploadCudaRequired && !hasGraphicalSubs) - { - filters.Add("hwupload_cuda"); - } - - // If no tonemap filter is applied, - // tag the video range as SDR to prevent the encoder from encoding HDR video. - if (isNoTonemapFilterApplied) - { - var outputSdrParams = GetOutputSdrParams(null); - if (!string.IsNullOrEmpty(outputSdrParams)) - { - filters.Add(outputSdrParams); - } - } - - var output = string.Empty; - if (filters.Count > 0) - { - output += string.Format( + return string.Format( CultureInfo.InvariantCulture, - "{0}", - string.Join(',', filters)); - } - - return output; + args, + hwTonemapSuffix, + videoFormat ?? "nv12", + options.TonemappingAlgorithm, + options.TonemappingPeak, + options.TonemappingDesat, + options.TonemappingParam, + options.TonemappingRange); } - public static string GetInputHdrParams(string colorTransfer) + /// <summary> + /// Gets the parameter of software filter chain. + /// </summary> + /// <param name="state">Encoding state.</param> + /// <param name="options">Encoding options.</param> + /// <param name="vidEncoder">Video encoder to use.</param> + /// <returns>The tuple contains three lists: main, sub and overlay filters.</returns> + public (List<string> MainFilters, List<string> SubFilters, List<string> OverlayFilters) GetSwVidFilterChain( + EncodingJobInfo state, + EncodingOptions options, + string vidEncoder) + { + var inW = state.VideoStream?.Width; + var inH = state.VideoStream?.Height; + var reqW = state.BaseRequest.Width; + var reqH = state.BaseRequest.Height; + var reqMaxW = state.BaseRequest.MaxWidth; + var reqMaxH = state.BaseRequest.MaxHeight; + var threeDFormat = state.MediaSource.Video3DFormat; + + var vidDecoder = GetHardwareVideoDecoder(state, options) ?? string.Empty; + var isSwDecoder = string.IsNullOrEmpty(vidDecoder); + var isVaapiEncoder = vidEncoder.Contains("vaapi", StringComparison.OrdinalIgnoreCase); + + var doDeintH264 = state.DeInterlace("h264", true) || state.DeInterlace("avc", true); + var doDeintHevc = state.DeInterlace("h265", true) || state.DeInterlace("hevc", true); + var doDeintH2645 = doDeintH264 || doDeintHevc; + + var hasSubs = state.SubtitleStream != null && state.SubtitleDeliveryMethod == SubtitleDeliveryMethod.Encode; + var hasTextSubs = hasSubs && state.SubtitleStream.IsTextSubtitleStream; + var hasGraphicalSubs = hasSubs && !state.SubtitleStream.IsTextSubtitleStream; + + /* Make main filters for video stream */ + var mainFilters = new List<string>(); + + mainFilters.Add(GetOverwriteColorPropertiesParam(state, false)); + + // INPUT sw surface(memory/copy-back from vram) + // sw deint + if (doDeintH2645) + { + var deintFilter = GetSwDeinterlaceFilter(state, options); + mainFilters.Add(deintFilter); + } + + var outFormat = isSwDecoder ? "yuv420p" : "nv12"; + var swScaleFilter = GetSwScaleFilter(state, options, vidEncoder, inW, inH, threeDFormat, reqW, reqH, reqMaxW, reqMaxH); + if (isVaapiEncoder) + { + outFormat = "nv12"; + } + + // sw scale + mainFilters.Add(swScaleFilter); + mainFilters.Add("format=" + outFormat); + + // sw tonemap <= TODO: finsh the fast tonemap filter + + // OUTPUT yuv420p/nv12 surface(memory) + + /* Make sub and overlay filters for subtitle stream */ + var subFilters = new List<string>(); + var overlayFilters = new List<string>(); + if (hasTextSubs) + { + // subtitles=f='*.ass':alpha=0 + var textSubtitlesFilter = GetTextSubtitlesFilter(state, false, false); + mainFilters.Add(textSubtitlesFilter); + } + else if (hasGraphicalSubs) + { + // [0:s]scale=s=1280x720 + var subSwScaleFilter = GetCustomSwScaleFilter(inW, inH, reqW, reqH, reqMaxW, reqMaxH); + subFilters.Add(subSwScaleFilter); + overlayFilters.Add("overlay=eof_action=endall:shortest=1:repeatlast=0"); + } + + return (mainFilters, subFilters, overlayFilters); + } + + /// <summary> + /// Gets the parameter of Nvidia NVENC filter chain. + /// </summary> + /// <param name="state">Encoding state.</param> + /// <param name="options">Encoding options.</param> + /// <param name="vidEncoder">Video encoder to use.</param> + /// <returns>The tuple contains three lists: main, sub and overlay filters.</returns> + public (List<string> MainFilters, List<string> SubFilters, List<string> OverlayFilters) GetNvidiaVidFilterChain( + EncodingJobInfo state, + EncodingOptions options, + string vidEncoder) + { + if (!string.Equals(options.HardwareAccelerationType, "nvenc", StringComparison.OrdinalIgnoreCase)) + { + return (null, null, null); + } + + var vidDecoder = GetHardwareVideoDecoder(state, options) ?? string.Empty; + var isSwDecoder = string.IsNullOrEmpty(vidDecoder); + var isSwEncoder = !vidEncoder.Contains("nvenc", StringComparison.OrdinalIgnoreCase); + + // legacy cuvid(resize/deint/sw) pipeline(copy-back) + if ((isSwDecoder && isSwEncoder) + || !IsCudaFullSupported() + || !options.EnableEnhancedNvdecDecoder + || !_mediaEncoder.SupportsFilter("alphasrc")) + { + return GetSwVidFilterChain(state, options, vidEncoder); + } + + // prefered nvdec + cuda filters + nvenc pipeline + return GetNvidiaVidFiltersPrefered(state, options, vidDecoder, vidEncoder); + } + + public (List<string> MainFilters, List<string> SubFilters, List<string> OverlayFilters) GetNvidiaVidFiltersPrefered( + EncodingJobInfo state, + EncodingOptions options, + string vidDecoder, + string vidEncoder) + { + var inW = state.VideoStream?.Width; + var inH = state.VideoStream?.Height; + var reqW = state.BaseRequest.Width; + var reqH = state.BaseRequest.Height; + var reqMaxW = state.BaseRequest.MaxWidth; + var reqMaxH = state.BaseRequest.MaxHeight; + var threeDFormat = state.MediaSource.Video3DFormat; + + var isNvdecDecoder = vidDecoder.Contains("cuda", StringComparison.OrdinalIgnoreCase); + var isNvencEncoder = vidEncoder.Contains("nvenc", StringComparison.OrdinalIgnoreCase); + var isSwDecoder = string.IsNullOrEmpty(vidDecoder); + var isSwEncoder = !isNvencEncoder; + var isCuInCuOut = isNvdecDecoder && isNvencEncoder; + + var doubleRateDeint = options.DeinterlaceDoubleRate && (state.VideoStream?.AverageFrameRate ?? 60) <= 30; + var doDeintH264 = state.DeInterlace("h264", true) || state.DeInterlace("avc", true); + var doDeintHevc = state.DeInterlace("h265", true) || state.DeInterlace("hevc", true); + var doDeintH2645 = doDeintH264 || doDeintHevc; + var doCuTonemap = IsHwTonemapAvailable(state, options); + + var hasSubs = state.SubtitleStream != null && state.SubtitleDeliveryMethod == SubtitleDeliveryMethod.Encode; + var hasTextSubs = hasSubs && state.SubtitleStream.IsTextSubtitleStream; + var hasGraphicalSubs = hasSubs && !state.SubtitleStream.IsTextSubtitleStream; + var hasAssSubs = hasSubs + && (string.Equals(state.SubtitleStream.Codec, "ass", StringComparison.OrdinalIgnoreCase) + || string.Equals(state.SubtitleStream.Codec, "ssa", StringComparison.OrdinalIgnoreCase)); + + /* Make main filters for video stream */ + var mainFilters = new List<string>(); + + mainFilters.Add(GetOverwriteColorPropertiesParam(state, doCuTonemap)); + + if (isSwDecoder) + { + // INPUT sw surface(memory) + // sw deint + if (doDeintH2645) + { + var swDeintFilter = GetSwDeinterlaceFilter(state, options); + mainFilters.Add(swDeintFilter); + } + + var outFormat = doCuTonemap ? "yuv420p10le" : "yuv420p"; + var swScaleFilter = GetSwScaleFilter(state, options, vidEncoder, inW, inH, threeDFormat, reqW, reqH, reqMaxW, reqMaxH); + // sw scale + mainFilters.Add(swScaleFilter); + mainFilters.Add("format=" + outFormat); + + // sw => hw + if (doCuTonemap) + { + mainFilters.Add("hwupload"); + } + } + + if (isNvdecDecoder) + { + // INPUT cuda surface(vram) + // hw deint + if (doDeintH2645) + { + var deintFilter = GetHwDeinterlaceFilter(state, options, "cuda"); + mainFilters.Add(deintFilter); + } + + var outFormat = doCuTonemap ? string.Empty : "yuv420p"; + var hwScaleFilter = GetHwScaleFilter("cuda", outFormat, inW, inH, reqW, reqH, reqMaxW, reqMaxH); + // hw scale + mainFilters.Add(hwScaleFilter); + } + + // hw tonemap + if (doCuTonemap) + { + var tonemapFilter = GetHwTonemapFilter(options, "cuda", "yuv420p"); + mainFilters.Add(tonemapFilter); + } + + var memoryOutput = false; + var isUploadForOclTonemap = isSwDecoder && doCuTonemap; + if ((isNvdecDecoder && isSwEncoder) || isUploadForOclTonemap) + { + memoryOutput = true; + + // OUTPUT yuv420p surface(memory) + mainFilters.Add("hwdownload"); + mainFilters.Add("format=yuv420p"); + } + + // OUTPUT yuv420p surface(memory) + if (isSwDecoder && isNvencEncoder) + { + memoryOutput = true; + } + + if (memoryOutput) + { + // text subtitles + if (hasTextSubs) + { + var textSubtitlesFilter = GetTextSubtitlesFilter(state, false, false); + mainFilters.Add(textSubtitlesFilter); + } + } + + // OUTPUT cuda(yuv420p) surface(vram) + + /* Make sub and overlay filters for subtitle stream */ + var subFilters = new List<string>(); + var overlayFilters = new List<string>(); + if (isCuInCuOut) + { + if (hasSubs) + { + if (hasGraphicalSubs) + { + // scale=s=1280x720,format=yuva420p,hwupload + var subSwScaleFilter = GetCustomSwScaleFilter(inW, inH, reqW, reqH, reqMaxW, reqMaxH); + subFilters.Add(subSwScaleFilter); + subFilters.Add("format=yuva420p"); + } + else if (hasTextSubs) + { + // alphasrc=s=1280x720:r=10:start=0,format=yuva420p,subtitles,hwupload + var alphaSrcFilter = GetAlphaSrcFilter(state, inW, inH, reqW, reqH, reqMaxW, reqMaxH, hasAssSubs ? 10 : 5); + var subTextSubtitlesFilter = GetTextSubtitlesFilter(state, true, true); + subFilters.Add(alphaSrcFilter); + subFilters.Add("format=yuva420p"); + subFilters.Add(subTextSubtitlesFilter); + } + + subFilters.Add("hwupload"); + overlayFilters.Add("overlay_cuda=eof_action=endall:shortest=1:repeatlast=0"); + } + } + else + { + if (hasGraphicalSubs) + { + var subSwScaleFilter = GetCustomSwScaleFilter(inW, inH, reqW, reqH, reqMaxW, reqMaxH); + subFilters.Add(subSwScaleFilter); + overlayFilters.Add("overlay=eof_action=endall:shortest=1:repeatlast=0"); + } + } + + return (mainFilters, subFilters, overlayFilters); + } + + /// <summary> + /// Gets the parameter of AMD AMF filter chain. + /// </summary> + /// <param name="state">Encoding state.</param> + /// <param name="options">Encoding options.</param> + /// <param name="vidEncoder">Video encoder to use.</param> + /// <returns>The tuple contains three lists: main, sub and overlay filters.</returns> + public (List<string> MainFilters, List<string> SubFilters, List<string> OverlayFilters) GetAmdVidFilterChain( + EncodingJobInfo state, + EncodingOptions options, + string vidEncoder) + { + if (!string.Equals(options.HardwareAccelerationType, "amf", StringComparison.OrdinalIgnoreCase)) + { + return (null, null, null); + } + + var isWindows = OperatingSystem.IsWindows(); + var vidDecoder = GetHardwareVideoDecoder(state, options) ?? string.Empty; + var isSwDecoder = string.IsNullOrEmpty(vidDecoder); + var isSwEncoder = !vidEncoder.Contains("amf", StringComparison.OrdinalIgnoreCase); + var isAmfDx11OclSupported = isWindows && _mediaEncoder.SupportsHwaccel("d3d11va") && IsOpenclFullSupported(); + + // legacy d3d11va pipeline(copy-back) + if ((isSwDecoder && isSwEncoder) + || !isAmfDx11OclSupported + || !_mediaEncoder.SupportsFilter("alphasrc")) + { + return GetSwVidFilterChain(state, options, vidEncoder); + } + + // prefered d3d11va + opencl filters + amf pipeline + return GetAmdDx11VidFiltersPrefered(state, options, vidDecoder, vidEncoder); + } + + public (List<string> MainFilters, List<string> SubFilters, List<string> OverlayFilters) GetAmdDx11VidFiltersPrefered( + EncodingJobInfo state, + EncodingOptions options, + string vidDecoder, + string vidEncoder) + { + var inW = state.VideoStream?.Width; + var inH = state.VideoStream?.Height; + var reqW = state.BaseRequest.Width; + var reqH = state.BaseRequest.Height; + var reqMaxW = state.BaseRequest.MaxWidth; + var reqMaxH = state.BaseRequest.MaxHeight; + var threeDFormat = state.MediaSource.Video3DFormat; + + var isD3d11vaDecoder = vidDecoder.Contains("d3d11va", StringComparison.OrdinalIgnoreCase); + var isAmfEncoder = vidEncoder.Contains("amf", StringComparison.OrdinalIgnoreCase); + var isSwDecoder = string.IsNullOrEmpty(vidDecoder); + var isSwEncoder = !isAmfEncoder; + var isDxInDxOut = isD3d11vaDecoder && isAmfEncoder; + + var doDeintH264 = state.DeInterlace("h264", true) || state.DeInterlace("avc", true); + var doDeintHevc = state.DeInterlace("h265", true) || state.DeInterlace("hevc", true); + var doDeintH2645 = doDeintH264 || doDeintHevc; + var doOclTonemap = IsHwTonemapAvailable(state, options); + + var hasSubs = state.SubtitleStream != null && state.SubtitleDeliveryMethod == SubtitleDeliveryMethod.Encode; + var hasTextSubs = hasSubs && state.SubtitleStream.IsTextSubtitleStream; + var hasGraphicalSubs = hasSubs && !state.SubtitleStream.IsTextSubtitleStream; + var hasAssSubs = hasSubs + && (string.Equals(state.SubtitleStream.Codec, "ass", StringComparison.OrdinalIgnoreCase) + || string.Equals(state.SubtitleStream.Codec, "ssa", StringComparison.OrdinalIgnoreCase)); + + /* Make main filters for video stream */ + var mainFilters = new List<string>(); + + mainFilters.Add(GetOverwriteColorPropertiesParam(state, doOclTonemap)); + + if (isSwDecoder) + { + // INPUT sw surface(memory) + // sw deint + if (doDeintH2645) + { + var swDeintFilter = GetSwDeinterlaceFilter(state, options); + mainFilters.Add(swDeintFilter); + } + + var outFormat = doOclTonemap ? "yuv420p10le" : "yuv420p"; + var swScaleFilter = GetSwScaleFilter(state, options, vidEncoder, inW, inH, threeDFormat, reqW, reqH, reqMaxW, reqMaxH); + // sw scale + mainFilters.Add(swScaleFilter); + mainFilters.Add("format=" + outFormat); + + // keep video at memory except ocl tonemap, + // since the overhead caused by hwupload >>> using sw filter. + // sw => hw + if (doOclTonemap) + { + mainFilters.Add("hwupload"); + } + } + + if (isD3d11vaDecoder) + { + // INPUT d3d11 surface(vram) + // map from d3d11va to opencl via d3d11-opencl interop. + mainFilters.Add("hwmap=derive_device=opencl"); + + // hw deint <= TODO: finsh the 'yadif_opencl' filter + + var outFormat = doOclTonemap ? string.Empty : "nv12"; + var hwScaleFilter = GetHwScaleFilter("opencl", outFormat, inW, inH, reqW, reqH, reqMaxW, reqMaxH); + // hw scale + mainFilters.Add(hwScaleFilter); + } + + // hw tonemap + if (doOclTonemap) + { + var tonemapFilter = GetHwTonemapFilter(options, "opencl", "nv12"); + mainFilters.Add(tonemapFilter); + } + + var memoryOutput = false; + var isUploadForOclTonemap = isSwDecoder && doOclTonemap; + if ((isD3d11vaDecoder && isSwEncoder) || isUploadForOclTonemap) + { + memoryOutput = true; + + // OUTPUT nv12 surface(memory) + // prefer hwmap to hwdownload on opencl. + var hwTransferFilter = hasGraphicalSubs ? "hwdownload" : "hwmap"; + mainFilters.Add(hwTransferFilter); + mainFilters.Add("format=nv12"); + } + + // OUTPUT yuv420p surface + if (isSwDecoder && isAmfEncoder) + { + memoryOutput = true; + } + + if (memoryOutput) + { + // text subtitles + if (hasTextSubs) + { + var textSubtitlesFilter = GetTextSubtitlesFilter(state, false, false); + mainFilters.Add(textSubtitlesFilter); + } + } + + if (isDxInDxOut && !hasSubs) + { + // OUTPUT d3d11(nv12) surface(vram) + // reverse-mapping via d3d11-opencl interop. + mainFilters.Add("hwmap=derive_device=d3d11va:reverse=1"); + mainFilters.Add("format=d3d11"); + } + + /* Make sub and overlay filters for subtitle stream */ + var subFilters = new List<string>(); + var overlayFilters = new List<string>(); + if (isDxInDxOut) + { + if (hasSubs) + { + if (hasGraphicalSubs) + { + // scale=s=1280x720,format=yuva420p,hwupload + var subSwScaleFilter = GetCustomSwScaleFilter(inW, inH, reqW, reqH, reqMaxW, reqMaxH); + subFilters.Add(subSwScaleFilter); + subFilters.Add("format=yuva420p"); + } + else if (hasTextSubs) + { + // alphasrc=s=1280x720:r=10:start=0,format=yuva420p,subtitles,hwupload + var alphaSrcFilter = GetAlphaSrcFilter(state, inW, inH, reqW, reqH, reqMaxW, reqMaxH, hasAssSubs ? 10 : 5); + var subTextSubtitlesFilter = GetTextSubtitlesFilter(state, true, true); + subFilters.Add(alphaSrcFilter); + subFilters.Add("format=yuva420p"); + subFilters.Add(subTextSubtitlesFilter); + } + + subFilters.Add("hwupload"); + overlayFilters.Add("overlay_opencl=eof_action=endall:shortest=1:repeatlast=0"); + overlayFilters.Add("hwmap=derive_device=d3d11va:reverse=1"); + overlayFilters.Add("format=d3d11"); + } + } + else if (memoryOutput) + { + if (hasGraphicalSubs) + { + var subSwScaleFilter = GetCustomSwScaleFilter(inW, inH, reqW, reqH, reqMaxW, reqMaxH); + subFilters.Add(subSwScaleFilter); + overlayFilters.Add("overlay=eof_action=endall:shortest=1:repeatlast=0"); + } + } + + return (mainFilters, subFilters, overlayFilters); + } + + /// <summary> + /// Gets the parameter of Intel QSV filter chain. + /// </summary> + /// <param name="state">Encoding state.</param> + /// <param name="options">Encoding options.</param> + /// <param name="vidEncoder">Video encoder to use.</param> + /// <returns>The tuple contains three lists: main, sub and overlay filters.</returns> + public (List<string> MainFilters, List<string> SubFilters, List<string> OverlayFilters) GetIntelVidFilterChain( + EncodingJobInfo state, + EncodingOptions options, + string vidEncoder) + { + if (!string.Equals(options.HardwareAccelerationType, "qsv", StringComparison.OrdinalIgnoreCase)) + { + return (null, null, null); + } + + var isWindows = OperatingSystem.IsWindows(); + var isLinux = OperatingSystem.IsLinux(); + var vidDecoder = GetHardwareVideoDecoder(state, options) ?? string.Empty; + var isSwDecoder = string.IsNullOrEmpty(vidDecoder); + var isSwEncoder = !vidEncoder.Contains("qsv", StringComparison.OrdinalIgnoreCase); + var isQsvOclSupported = _mediaEncoder.SupportsHwaccel("qsv") && IsOpenclFullSupported(); + var isIntelDx11OclSupported = isWindows + && _mediaEncoder.SupportsHwaccel("d3d11va") + && isQsvOclSupported; + var isIntelVaapiOclSupported = isLinux + && IsVaapiSupported(state) + && isQsvOclSupported; + + // legacy qsv pipeline(copy-back) + if ((isSwDecoder && isSwEncoder) + || (!isIntelVaapiOclSupported && !isIntelDx11OclSupported) + || !_mediaEncoder.SupportsFilter("alphasrc")) + { + return GetSwVidFilterChain(state, options, vidEncoder); + } + + // prefered qsv(vaapi) + opencl filters pipeline + if (isIntelVaapiOclSupported) + { + return GetIntelQsvVaapiVidFiltersPrefered(state, options, vidDecoder, vidEncoder); + } + + // prefered qsv(d3d11) + opencl filters pipeline + if (isIntelDx11OclSupported) + { + return GetIntelQsvDx11VidFiltersPrefered(state, options, vidDecoder, vidEncoder); + } + + return (null, null, null); + } + + public (List<string> MainFilters, List<string> SubFilters, List<string> OverlayFilters) GetIntelQsvDx11VidFiltersPrefered( + EncodingJobInfo state, + EncodingOptions options, + string vidDecoder, + string vidEncoder) + { + var inW = state.VideoStream?.Width; + var inH = state.VideoStream?.Height; + var reqW = state.BaseRequest.Width; + var reqH = state.BaseRequest.Height; + var reqMaxW = state.BaseRequest.MaxWidth; + var reqMaxH = state.BaseRequest.MaxHeight; + var threeDFormat = state.MediaSource.Video3DFormat; + + var isD3d11vaDecoder = vidDecoder.Contains("d3d11va", StringComparison.OrdinalIgnoreCase); + var isQsvDecoder = vidDecoder.Contains("qsv", StringComparison.OrdinalIgnoreCase); + var isQsvEncoder = vidEncoder.Contains("qsv", StringComparison.OrdinalIgnoreCase); + var isHwDecoder = isD3d11vaDecoder || isQsvDecoder; + var isSwDecoder = string.IsNullOrEmpty(vidDecoder); + var isSwEncoder = !isQsvEncoder; + var isQsvInQsvOut = isHwDecoder && isQsvEncoder; + + var doDeintH264 = state.DeInterlace("h264", true) || state.DeInterlace("avc", true); + var doDeintHevc = state.DeInterlace("h265", true) || state.DeInterlace("hevc", true); + var doDeintH2645 = doDeintH264 || doDeintHevc; + var doOclTonemap = IsHwTonemapAvailable(state, options); + + var hasSubs = state.SubtitleStream != null && state.SubtitleDeliveryMethod == SubtitleDeliveryMethod.Encode; + var hasTextSubs = hasSubs && state.SubtitleStream.IsTextSubtitleStream; + var hasGraphicalSubs = hasSubs && !state.SubtitleStream.IsTextSubtitleStream; + var hasAssSubs = hasSubs + && (string.Equals(state.SubtitleStream.Codec, "ass", StringComparison.OrdinalIgnoreCase) + || string.Equals(state.SubtitleStream.Codec, "ssa", StringComparison.OrdinalIgnoreCase)); + + /* Make main filters for video stream */ + var mainFilters = new List<string>(); + + mainFilters.Add(GetOverwriteColorPropertiesParam(state, doOclTonemap)); + + if (isSwDecoder) + { + // INPUT sw surface(memory) + // sw deint + if (doDeintH2645) + { + var swDeintFilter = GetSwDeinterlaceFilter(state, options); + mainFilters.Add(swDeintFilter); + } + + var outFormat = doOclTonemap ? "yuv420p10le" : "yuv420p"; + var swScaleFilter = GetSwScaleFilter(state, options, vidEncoder, inW, inH, threeDFormat, reqW, reqH, reqMaxW, reqMaxH); + // sw scale + mainFilters.Add(swScaleFilter); + mainFilters.Add("format=" + outFormat); + + // keep video at memory except ocl tonemap, + // since the overhead caused by hwupload >>> using sw filter. + // sw => hw + if (doOclTonemap) + { + mainFilters.Add("hwupload"); + } + } + else if (isD3d11vaDecoder || isQsvDecoder) + { + var outFormat = doOclTonemap ? string.Empty : "nv12"; + var hwScaleFilter = GetHwScaleFilter("qsv", outFormat, inW, inH, reqW, reqH, reqMaxW, reqMaxH); + + if (isD3d11vaDecoder) + { + if (!string.IsNullOrEmpty(hwScaleFilter) || doDeintH2645) + { + // INPUT d3d11 surface(vram) + // map from d3d11va to qsv. + mainFilters.Add("hwmap=derive_device=qsv"); + } + } + + // hw deint + if (doDeintH2645) + { + var deintFilter = GetHwDeinterlaceFilter(state, options, "qsv"); + mainFilters.Add(deintFilter); + } + + // hw scale + mainFilters.Add(hwScaleFilter); + } + + if (doOclTonemap && isHwDecoder) + { + // map from qsv to opencl via qsv(d3d11)-opencl interop. + mainFilters.Add("hwmap=derive_device=opencl"); + } + + // hw tonemap + if (doOclTonemap) + { + var tonemapFilter = GetHwTonemapFilter(options, "opencl", "nv12"); + mainFilters.Add(tonemapFilter); + } + + var memoryOutput = false; + var isUploadForOclTonemap = isSwDecoder && doOclTonemap; + var isHwmapUsable = isSwEncoder && doOclTonemap; + if ((isHwDecoder && isSwEncoder) || isUploadForOclTonemap) + { + memoryOutput = true; + + // OUTPUT nv12 surface(memory) + // prefer hwmap to hwdownload on opencl. + // qsv hwmap is not fully implemented for the time being. + mainFilters.Add(isHwmapUsable ? "hwmap" : "hwdownload"); + mainFilters.Add("format=nv12"); + } + + // OUTPUT nv12 surface(memory) + if (isSwDecoder && isQsvEncoder) + { + memoryOutput = true; + } + + if (memoryOutput) + { + // text subtitles + if (hasTextSubs) + { + var textSubtitlesFilter = GetTextSubtitlesFilter(state, false, false); + mainFilters.Add(textSubtitlesFilter); + } + } + + if (isQsvInQsvOut && doOclTonemap) + { + // OUTPUT qsv(nv12) surface(vram) + // reverse-mapping via qsv(d3d11)-opencl interop. + mainFilters.Add("hwmap=derive_device=qsv:reverse=1"); + mainFilters.Add("format=qsv"); + } + + /* Make sub and overlay filters for subtitle stream */ + var subFilters = new List<string>(); + var overlayFilters = new List<string>(); + if (isQsvInQsvOut) + { + if (hasSubs) + { + if (hasGraphicalSubs) + { + // scale,format=bgra,hwupload + // overlay_qsv can handle overlay scaling, + // add a dummy scale filter to pair with -canvas_size. + subFilters.Add("scale=flags=fast_bilinear"); + subFilters.Add("format=bgra"); + } + else if (hasTextSubs) + { + // alphasrc=s=1280x720:r=10:start=0,format=bgra,subtitles,hwupload + var alphaSrcFilter = GetAlphaSrcFilter(state, inW, inH, reqW, reqH, reqMaxW, 1080, hasAssSubs ? 10 : 5); + var subTextSubtitlesFilter = GetTextSubtitlesFilter(state, true, true); + subFilters.Add(alphaSrcFilter); + subFilters.Add("format=bgra"); + subFilters.Add(subTextSubtitlesFilter); + } + + // qsv requires a fixed pool size. + subFilters.Add("hwupload=extra_hw_frames=32"); + + var (overlayW, overlayH) = GetFixedOutputSize(inW, inH, reqW, reqH, reqMaxW, reqMaxH); + var overlaySize = (overlayW.HasValue && overlayH.HasValue) + ? (":w=" + overlayW.Value + ":h=" + overlayH.Value) + : string.Empty; + var overlayQsvFilter = string.Format( + CultureInfo.InvariantCulture, + "overlay_qsv=eof_action=endall:shortest=1:repeatlast=0{0}", + overlaySize); + overlayFilters.Add(overlayQsvFilter); + } + } + else if (memoryOutput) + { + if (hasGraphicalSubs) + { + var subSwScaleFilter = GetCustomSwScaleFilter(inW, inH, reqW, reqH, reqMaxW, reqMaxH); + subFilters.Add(subSwScaleFilter); + overlayFilters.Add("overlay=eof_action=endall:shortest=1:repeatlast=0"); + } + } + + return (mainFilters, subFilters, overlayFilters); + } + + public (List<string> MainFilters, List<string> SubFilters, List<string> OverlayFilters) GetIntelQsvVaapiVidFiltersPrefered( + EncodingJobInfo state, + EncodingOptions options, + string vidDecoder, + string vidEncoder) + { + var inW = state.VideoStream?.Width; + var inH = state.VideoStream?.Height; + var reqW = state.BaseRequest.Width; + var reqH = state.BaseRequest.Height; + var reqMaxW = state.BaseRequest.MaxWidth; + var reqMaxH = state.BaseRequest.MaxHeight; + var threeDFormat = state.MediaSource.Video3DFormat; + + var isVaapiDecoder = vidDecoder.Contains("vaapi", StringComparison.OrdinalIgnoreCase); + var isQsvDecoder = vidDecoder.Contains("qsv", StringComparison.OrdinalIgnoreCase); + var isQsvEncoder = vidEncoder.Contains("qsv", StringComparison.OrdinalIgnoreCase); + var isHwDecoder = isVaapiDecoder || isQsvDecoder; + var isSwDecoder = string.IsNullOrEmpty(vidDecoder); + var isSwEncoder = !isQsvEncoder; + var isQsvInQsvOut = isHwDecoder && isQsvEncoder; + + var doDeintH264 = state.DeInterlace("h264", true) || state.DeInterlace("avc", true); + var doDeintHevc = state.DeInterlace("h265", true) || state.DeInterlace("hevc", true); + var doVaVppTonemap = IsVaapiVppTonemapAvailable(state, options); + var doOclTonemap = !doVaVppTonemap && IsHwTonemapAvailable(state, options); + var doTonemap = doVaVppTonemap || doOclTonemap; + var doDeintH2645 = doDeintH264 || doDeintHevc; + + var hasSubs = state.SubtitleStream != null && state.SubtitleDeliveryMethod == SubtitleDeliveryMethod.Encode; + var hasTextSubs = hasSubs && state.SubtitleStream.IsTextSubtitleStream; + var hasGraphicalSubs = hasSubs && !state.SubtitleStream.IsTextSubtitleStream; + var hasAssSubs = hasSubs + && (string.Equals(state.SubtitleStream.Codec, "ass", StringComparison.OrdinalIgnoreCase) + || string.Equals(state.SubtitleStream.Codec, "ssa", StringComparison.OrdinalIgnoreCase)); + + /* Make main filters for video stream */ + var mainFilters = new List<string>(); + + mainFilters.Add(GetOverwriteColorPropertiesParam(state, doTonemap)); + + if (isSwDecoder) + { + // INPUT sw surface(memory) + // sw deint + if (doDeintH2645) + { + var swDeintFilter = GetSwDeinterlaceFilter(state, options); + mainFilters.Add(swDeintFilter); + } + + var outFormat = doOclTonemap ? "yuv420p10le" : "yuv420p"; + var swScaleFilter = GetSwScaleFilter(state, options, vidEncoder, inW, inH, threeDFormat, reqW, reqH, reqMaxW, reqMaxH); + // sw scale + mainFilters.Add(swScaleFilter); + mainFilters.Add("format=" + outFormat); + + // keep video at memory except ocl tonemap, + // since the overhead caused by hwupload >>> using sw filter. + // sw => hw + if (doOclTonemap) + { + mainFilters.Add("hwupload"); + } + } + else if (isVaapiDecoder || isQsvDecoder) + { + // INPUT vaapi/qsv surface(vram) + // hw deint + if (doDeintH2645) + { + var deintFilter = GetHwDeinterlaceFilter(state, options, isVaapiDecoder ? "vaapi" : "qsv"); + mainFilters.Add(deintFilter); + } + + var outFormat = doTonemap ? string.Empty : "nv12"; + var hwScaleFilter = GetHwScaleFilter(isVaapiDecoder ? "vaapi" : "qsv", outFormat, inW, inH, reqW, reqH, reqMaxW, reqMaxH); + // hw scale + mainFilters.Add(hwScaleFilter); + } + + // vaapi vpp tonemap + if (doVaVppTonemap && isHwDecoder) + { + if (isQsvDecoder) + { + // map from qsv to vaapi. + mainFilters.Add("hwmap=derive_device=vaapi"); + } + + var tonemapFilter = GetHwTonemapFilter(options, "vaapi", "nv12"); + mainFilters.Add(tonemapFilter); + + if (isQsvDecoder) + { + // map from vaapi to qsv. + mainFilters.Add("hwmap=derive_device=qsv"); + } + } + + if (doOclTonemap && isHwDecoder) + { + // map from qsv to opencl via qsv(vaapi)-opencl interop. + mainFilters.Add("hwmap=derive_device=opencl"); + } + + // ocl tonemap + if (doOclTonemap) + { + var tonemapFilter = GetHwTonemapFilter(options, "opencl", "nv12"); + mainFilters.Add(tonemapFilter); + } + + var memoryOutput = false; + var isUploadForOclTonemap = isSwDecoder && doOclTonemap; + var isHwmapUsable = isSwEncoder && (doOclTonemap || isVaapiDecoder); + if ((isHwDecoder && isSwEncoder) || isUploadForOclTonemap) + { + memoryOutput = true; + + // OUTPUT nv12 surface(memory) + // prefer hwmap to hwdownload on opencl/vaapi. + // qsv hwmap is not fully implemented for the time being. + mainFilters.Add(isHwmapUsable ? "hwmap" : "hwdownload"); + mainFilters.Add("format=nv12"); + } + + // OUTPUT nv12 surface(memory) + if (isSwDecoder && isQsvEncoder) + { + memoryOutput = true; + } + + if (memoryOutput) + { + // text subtitles + if (hasTextSubs) + { + var textSubtitlesFilter = GetTextSubtitlesFilter(state, false, false); + mainFilters.Add(textSubtitlesFilter); + } + } + + if (isQsvInQsvOut) + { + if (doOclTonemap) + { + // OUTPUT qsv(nv12) surface(vram) + // reverse-mapping via qsv(vaapi)-opencl interop. + // add extra pool size to avoid the 'cannot allocate memory' error on hevc_qsv. + mainFilters.Add("hwmap=derive_device=qsv:reverse=1:extra_hw_frames=16"); + mainFilters.Add("format=qsv"); + } + else if (isVaapiDecoder) + { + mainFilters.Add("hwmap=derive_device=qsv"); + mainFilters.Add("format=qsv"); + } + } + + /* Make sub and overlay filters for subtitle stream */ + var subFilters = new List<string>(); + var overlayFilters = new List<string>(); + if (isQsvInQsvOut) + { + if (hasSubs) + { + if (hasGraphicalSubs) + { + subFilters.Add("scale=flags=fast_bilinear"); + subFilters.Add("format=bgra"); + } + else if (hasTextSubs) + { + var alphaSrcFilter = GetAlphaSrcFilter(state, inW, inH, reqW, reqH, reqMaxW, 1080, hasAssSubs ? 10 : 5); + var subTextSubtitlesFilter = GetTextSubtitlesFilter(state, true, true); + subFilters.Add(alphaSrcFilter); + subFilters.Add("format=bgra"); + subFilters.Add(subTextSubtitlesFilter); + } + + // qsv requires a fixed pool size. + subFilters.Add("hwupload=extra_hw_frames=32"); + + var (overlayW, overlayH) = GetFixedOutputSize(inW, inH, reqW, reqH, reqMaxW, reqMaxH); + var overlaySize = (overlayW.HasValue && overlayH.HasValue) + ? (":w=" + overlayW.Value + ":h=" + overlayH.Value) + : string.Empty; + var overlayQsvFilter = string.Format( + CultureInfo.InvariantCulture, + "overlay_qsv=eof_action=endall:shortest=1:repeatlast=0{0}", + overlaySize); + overlayFilters.Add(overlayQsvFilter); + } + } + else if (memoryOutput) + { + if (hasGraphicalSubs) + { + var subSwScaleFilter = GetCustomSwScaleFilter(inW, inH, reqW, reqH, reqMaxW, reqMaxH); + subFilters.Add(subSwScaleFilter); + overlayFilters.Add("overlay=eof_action=pass:shortest=1:repeatlast=0"); + } + } + + return (mainFilters, subFilters, overlayFilters); + } + + /// <summary> + /// Gets the parameter of Intel/AMD VAAPI filter chain. + /// </summary> + /// <param name="state">Encoding state.</param> + /// <param name="options">Encoding options.</param> + /// <param name="vidEncoder">Video encoder to use.</param> + /// <returns>The tuple contains three lists: main, sub and overlay filters.</returns> + public (List<string> MainFilters, List<string> SubFilters, List<string> OverlayFilters) GetVaapiVidFilterChain( + EncodingJobInfo state, + EncodingOptions options, + string vidEncoder) + { + if (!string.Equals(options.HardwareAccelerationType, "vaapi", StringComparison.OrdinalIgnoreCase)) + { + return (null, null, null); + } + + var isLinux = OperatingSystem.IsLinux(); + var vidDecoder = GetHardwareVideoDecoder(state, options) ?? string.Empty; + var isSwDecoder = string.IsNullOrEmpty(vidDecoder); + var isSwEncoder = !vidEncoder.Contains("vaapi", StringComparison.OrdinalIgnoreCase); + var isVaapiOclSupported = isLinux && IsVaapiSupported(state) && IsVaapiFullSupported() && IsOpenclFullSupported(); + + // legacy vaapi pipeline(copy-back) + if ((isSwDecoder && isSwEncoder) + || !isVaapiOclSupported + || !_mediaEncoder.SupportsFilter("alphasrc")) + { + var swFilterChain = GetSwVidFilterChain(state, options, vidEncoder); + + if (!isSwEncoder) + { + var newfilters = new List<string>(); + var noOverlay = swFilterChain.OverlayFilters.Count == 0; + newfilters.AddRange(noOverlay ? swFilterChain.MainFilters : swFilterChain.OverlayFilters); + newfilters.Add("hwupload"); + + var mainFilters = noOverlay ? newfilters : swFilterChain.MainFilters; + var overlayFilters = noOverlay ? swFilterChain.OverlayFilters : newfilters; + return (mainFilters, swFilterChain.SubFilters, overlayFilters); + } + + return swFilterChain; + } + + // prefered vaapi + opencl filters pipeline + if (_mediaEncoder.IsVaapiDeviceInteliHD) + { + // Intel iHD path, with extra vpp tonemap and overlay support. + return GetVaapiFullVidFiltersPrefered(state, options, vidDecoder, vidEncoder); + } + + // Intel i965 and Amd radeonsi/r600 path, only featuring scale and deinterlace support. + return GetVaapiLimitedVidFiltersPrefered(state, options, vidDecoder, vidEncoder); + } + + public (List<string> MainFilters, List<string> SubFilters, List<string> OverlayFilters) GetVaapiFullVidFiltersPrefered( + EncodingJobInfo state, + EncodingOptions options, + string vidDecoder, + string vidEncoder) + { + var inW = state.VideoStream?.Width; + var inH = state.VideoStream?.Height; + var reqW = state.BaseRequest.Width; + var reqH = state.BaseRequest.Height; + var reqMaxW = state.BaseRequest.MaxWidth; + var reqMaxH = state.BaseRequest.MaxHeight; + var threeDFormat = state.MediaSource.Video3DFormat; + + var isVaapiDecoder = vidDecoder.Contains("vaapi", StringComparison.OrdinalIgnoreCase); + var isVaapiEncoder = vidEncoder.Contains("vaapi", StringComparison.OrdinalIgnoreCase); + var isSwDecoder = string.IsNullOrEmpty(vidDecoder); + var isSwEncoder = !isVaapiEncoder; + var isVaInVaOut = isVaapiDecoder && isVaapiEncoder; + + var doDeintH264 = state.DeInterlace("h264", true) || state.DeInterlace("avc", true); + var doDeintHevc = state.DeInterlace("h265", true) || state.DeInterlace("hevc", true); + var doVaVppTonemap = isVaapiDecoder && IsVaapiVppTonemapAvailable(state, options); + var doOclTonemap = !doVaVppTonemap && IsHwTonemapAvailable(state, options); + var doTonemap = doVaVppTonemap || doOclTonemap; + var doDeintH2645 = doDeintH264 || doDeintHevc; + + var hasSubs = state.SubtitleStream != null && state.SubtitleDeliveryMethod == SubtitleDeliveryMethod.Encode; + var hasTextSubs = hasSubs && state.SubtitleStream.IsTextSubtitleStream; + var hasGraphicalSubs = hasSubs && !state.SubtitleStream.IsTextSubtitleStream; + var hasAssSubs = hasSubs + && (string.Equals(state.SubtitleStream.Codec, "ass", StringComparison.OrdinalIgnoreCase) + || string.Equals(state.SubtitleStream.Codec, "ssa", StringComparison.OrdinalIgnoreCase)); + + /* Make main filters for video stream */ + var mainFilters = new List<string>(); + + mainFilters.Add(GetOverwriteColorPropertiesParam(state, doTonemap)); + + if (isSwDecoder) + { + // INPUT sw surface(memory) + // sw deint + if (doDeintH2645) + { + var swDeintFilter = GetSwDeinterlaceFilter(state, options); + mainFilters.Add(swDeintFilter); + } + + var outFormat = doOclTonemap ? "yuv420p10le" : "nv12"; + var swScaleFilter = GetSwScaleFilter(state, options, vidEncoder, inW, inH, threeDFormat, reqW, reqH, reqMaxW, reqMaxH); + // sw scale + mainFilters.Add(swScaleFilter); + mainFilters.Add("format=" + outFormat); + + // keep video at memory except ocl tonemap, + // since the overhead caused by hwupload >>> using sw filter. + // sw => hw + if (doOclTonemap) + { + mainFilters.Add("hwupload"); + } + } + else if (isVaapiDecoder) + { + // INPUT vaapi surface(vram) + // hw deint + if (doDeintH2645) + { + var deintFilter = GetHwDeinterlaceFilter(state, options, "vaapi"); + mainFilters.Add(deintFilter); + } + + var outFormat = doTonemap ? string.Empty : "nv12"; + var hwScaleFilter = GetHwScaleFilter("vaapi", outFormat, inW, inH, reqW, reqH, reqMaxW, reqMaxH); + // hw scale + mainFilters.Add(hwScaleFilter); + } + + // vaapi vpp tonemap + if (doVaVppTonemap && isVaapiDecoder) + { + var tonemapFilter = GetHwTonemapFilter(options, "vaapi", "nv12"); + mainFilters.Add(tonemapFilter); + } + + if (doOclTonemap && isVaapiDecoder) + { + // map from vaapi to opencl via vaapi-opencl interop(Intel only). + mainFilters.Add("hwmap=derive_device=opencl"); + } + + // ocl tonemap + if (doOclTonemap) + { + var tonemapFilter = GetHwTonemapFilter(options, "opencl", "nv12"); + mainFilters.Add(tonemapFilter); + } + + if (doOclTonemap && isVaInVaOut) + { + // OUTPUT vaapi(nv12) surface(vram) + // reverse-mapping via vaapi-opencl interop. + mainFilters.Add("hwmap=derive_device=vaapi:reverse=1"); + mainFilters.Add("format=vaapi"); + } + + var memoryOutput = false; + var isUploadForOclTonemap = isSwDecoder && doOclTonemap; + var isHwmapNotUsable = isUploadForOclTonemap && isVaapiEncoder; + if ((isVaapiDecoder && isSwEncoder) || isUploadForOclTonemap) + { + memoryOutput = true; + + // OUTPUT nv12 surface(memory) + // prefer hwmap to hwdownload on opencl/vaapi. + mainFilters.Add(isHwmapNotUsable ? "hwdownload" : "hwmap"); + mainFilters.Add("format=nv12"); + } + + // OUTPUT nv12 surface(memory) + if (isSwDecoder && isVaapiEncoder) + { + memoryOutput = true; + } + + if (memoryOutput) + { + // text subtitles + if (hasTextSubs) + { + var textSubtitlesFilter = GetTextSubtitlesFilter(state, false, false); + mainFilters.Add(textSubtitlesFilter); + } + } + + if (memoryOutput && isVaapiEncoder) + { + if (!hasGraphicalSubs) + { + mainFilters.Add("hwupload_vaapi"); + } + } + + /* Make sub and overlay filters for subtitle stream */ + var subFilters = new List<string>(); + var overlayFilters = new List<string>(); + if (isVaInVaOut) + { + if (hasSubs) + { + if (hasGraphicalSubs) + { + subFilters.Add("scale=flags=fast_bilinear"); + subFilters.Add("format=bgra"); + } + else if (hasTextSubs) + { + var alphaSrcFilter = GetAlphaSrcFilter(state, inW, inH, reqW, reqH, reqMaxW, 1080, hasAssSubs ? 10 : 5); + var subTextSubtitlesFilter = GetTextSubtitlesFilter(state, true, true); + subFilters.Add(alphaSrcFilter); + subFilters.Add("format=bgra"); + subFilters.Add(subTextSubtitlesFilter); + } + + subFilters.Add("hwupload"); + + var (overlayW, overlayH) = GetFixedOutputSize(inW, inH, reqW, reqH, reqMaxW, reqMaxH); + var overlaySize = (overlayW.HasValue && overlayH.HasValue) + ? (":w=" + overlayW.Value + ":h=" + overlayH.Value) + : string.Empty; + var overlayVaapiFilter = string.Format( + CultureInfo.InvariantCulture, + "overlay_vaapi=eof_action=endall:shortest=1:repeatlast=0{0}", + overlaySize); + overlayFilters.Add(overlayVaapiFilter); + } + } + else if (memoryOutput) + { + if (hasGraphicalSubs) + { + var subSwScaleFilter = GetCustomSwScaleFilter(inW, inH, reqW, reqH, reqMaxW, reqMaxH); + subFilters.Add(subSwScaleFilter); + overlayFilters.Add("overlay=eof_action=pass:shortest=1:repeatlast=0"); + + if (isVaapiEncoder) + { + overlayFilters.Add("hwupload_vaapi"); + } + } + } + + return (mainFilters, subFilters, overlayFilters); + } + + public (List<string> MainFilters, List<string> SubFilters, List<string> OverlayFilters) GetVaapiLimitedVidFiltersPrefered( + EncodingJobInfo state, + EncodingOptions options, + string vidDecoder, + string vidEncoder) + { + var inW = state.VideoStream?.Width; + var inH = state.VideoStream?.Height; + var reqW = state.BaseRequest.Width; + var reqH = state.BaseRequest.Height; + var reqMaxW = state.BaseRequest.MaxWidth; + var reqMaxH = state.BaseRequest.MaxHeight; + var threeDFormat = state.MediaSource.Video3DFormat; + + var isVaapiDecoder = vidDecoder.Contains("vaapi", StringComparison.OrdinalIgnoreCase); + var isVaapiEncoder = vidEncoder.Contains("vaapi", StringComparison.OrdinalIgnoreCase); + var isSwDecoder = string.IsNullOrEmpty(vidDecoder); + var isSwEncoder = !isVaapiEncoder; + var isVaInVaOut = isVaapiDecoder && isVaapiEncoder; + var isi965Driver = _mediaEncoder.IsVaapiDeviceInteli965; + var isAmdDriver = _mediaEncoder.IsVaapiDeviceAmd; + + var doDeintH264 = state.DeInterlace("h264", true) || state.DeInterlace("avc", true); + var doDeintHevc = state.DeInterlace("h265", true) || state.DeInterlace("hevc", true); + var doDeintH2645 = doDeintH264 || doDeintHevc; + var doOclTonemap = IsHwTonemapAvailable(state, options); + + var hasSubs = state.SubtitleStream != null && state.SubtitleDeliveryMethod == SubtitleDeliveryMethod.Encode; + var hasTextSubs = hasSubs && state.SubtitleStream.IsTextSubtitleStream; + var hasGraphicalSubs = hasSubs && !state.SubtitleStream.IsTextSubtitleStream; + + /* Make main filters for video stream */ + var mainFilters = new List<string>(); + + mainFilters.Add(GetOverwriteColorPropertiesParam(state, doOclTonemap)); + + var outFormat = string.Empty; + if (isSwDecoder) + { + // INPUT sw surface(memory) + // sw deint + if (doDeintH2645) + { + var swDeintFilter = GetSwDeinterlaceFilter(state, options); + mainFilters.Add(swDeintFilter); + } + + outFormat = doOclTonemap ? "yuv420p10le" : "nv12"; + var swScaleFilter = GetSwScaleFilter(state, options, vidEncoder, inW, inH, threeDFormat, reqW, reqH, reqMaxW, reqMaxH); + // sw scale + mainFilters.Add(swScaleFilter); + mainFilters.Add("format=" + outFormat); + + // keep video at memory except ocl tonemap, + // since the overhead caused by hwupload >>> using sw filter. + // sw => hw + if (doOclTonemap) + { + mainFilters.Add("hwupload"); + } + } + else if (isVaapiDecoder) + { + // INPUT vaapi surface(vram) + // hw deint + if (doDeintH2645) + { + var deintFilter = GetHwDeinterlaceFilter(state, options, "vaapi"); + mainFilters.Add(deintFilter); + } + + outFormat = doOclTonemap ? string.Empty : "nv12"; + var hwScaleFilter = GetHwScaleFilter("vaapi", outFormat, inW, inH, reqW, reqH, reqMaxW, reqMaxH); + // hw scale + mainFilters.Add(hwScaleFilter); + } + + if (doOclTonemap && isVaapiDecoder) + { + if (isi965Driver) + { + // map from vaapi to opencl via vaapi-opencl interop(Intel only). + mainFilters.Add("hwmap=derive_device=opencl"); + } + else + { + mainFilters.Add("hwdownload"); + mainFilters.Add("format=p010le"); + mainFilters.Add("hwupload"); + } + } + + // ocl tonemap + if (doOclTonemap) + { + var tonemapFilter = GetHwTonemapFilter(options, "opencl", "nv12"); + mainFilters.Add(tonemapFilter); + } + + if (doOclTonemap && isVaInVaOut) + { + if (isi965Driver) + { + // OUTPUT vaapi(nv12) surface(vram) + // reverse-mapping via vaapi-opencl interop. + mainFilters.Add("hwmap=derive_device=vaapi:reverse=1"); + mainFilters.Add("format=vaapi"); + } + } + + var memoryOutput = false; + var isUploadForOclTonemap = doOclTonemap && (isSwDecoder || (isVaapiDecoder && !isi965Driver)); + var isHwmapNotUsable = hasGraphicalSubs || isUploadForOclTonemap; + var isHwmapForSubs = hasSubs && isVaapiDecoder; + var isHwUnmapForTextSubs = hasTextSubs && isVaInVaOut && !isUploadForOclTonemap; + if ((isVaapiDecoder && isSwEncoder) || isUploadForOclTonemap || isHwmapForSubs) + { + memoryOutput = true; + + // OUTPUT nv12 surface(memory) + // prefer hwmap to hwdownload on opencl/vaapi. + mainFilters.Add(isHwmapNotUsable ? "hwdownload" : "hwmap"); + mainFilters.Add("format=nv12"); + } + + // OUTPUT nv12 surface(memory) + if (isSwDecoder && isVaapiEncoder) + { + memoryOutput = true; + } + + if (memoryOutput) + { + // text subtitles + if (hasTextSubs) + { + var textSubtitlesFilter = GetTextSubtitlesFilter(state, false, false); + mainFilters.Add(textSubtitlesFilter); + } + } + + if (isHwUnmapForTextSubs) + { + mainFilters.Add("hwmap"); + mainFilters.Add("format=vaapi"); + } + else if (memoryOutput && isVaapiEncoder) + { + if (!hasGraphicalSubs) + { + mainFilters.Add("hwupload_vaapi"); + } + } + + /* Make sub and overlay filters for subtitle stream */ + var subFilters = new List<string>(); + var overlayFilters = new List<string>(); + if (memoryOutput) + { + if (hasGraphicalSubs) + { + var subSwScaleFilter = GetCustomSwScaleFilter(inW, inH, reqW, reqH, reqMaxW, reqMaxH); + subFilters.Add(subSwScaleFilter); + overlayFilters.Add("overlay=eof_action=pass:shortest=1:repeatlast=0"); + + if (isVaapiEncoder) + { + overlayFilters.Add("hwupload_vaapi"); + } + } + } + + return (mainFilters, subFilters, overlayFilters); + } + + /// <summary> + /// Gets the parameter of video processing filters. + /// </summary> + /// <param name="state">Encoding state.</param> + /// <param name="options">Encoding options.</param> + /// <param name="outputVideoCodec">Video codec to use.</param> + /// <returns>The video processing filters parameter.</returns> + public string GetVideoProcessingFilterParam( + EncodingJobInfo state, + EncodingOptions options, + string outputVideoCodec) + { + var videoStream = state.VideoStream; + if (videoStream == null) + { + return string.Empty; + } + + var hasSubs = state.SubtitleStream != null && state.SubtitleDeliveryMethod == SubtitleDeliveryMethod.Encode; + var hasTextSubs = hasSubs && state.SubtitleStream.IsTextSubtitleStream; + var hasGraphicalSubs = hasSubs && !state.SubtitleStream.IsTextSubtitleStream; + + List<string> mainFilters; + List<string> subFilters; + List<string> overlayFilters; + + if (string.Equals(options.HardwareAccelerationType, "vaapi", StringComparison.OrdinalIgnoreCase)) + { + (mainFilters, subFilters, overlayFilters) = GetVaapiVidFilterChain(state, options, outputVideoCodec); + } + else if (string.Equals(options.HardwareAccelerationType, "qsv", StringComparison.OrdinalIgnoreCase)) + { + (mainFilters, subFilters, overlayFilters) = GetIntelVidFilterChain(state, options, outputVideoCodec); + } + else if (string.Equals(options.HardwareAccelerationType, "nvenc", StringComparison.OrdinalIgnoreCase)) + { + (mainFilters, subFilters, overlayFilters) = GetNvidiaVidFilterChain(state, options, outputVideoCodec); + } + else if (string.Equals(options.HardwareAccelerationType, "amf", StringComparison.OrdinalIgnoreCase)) + { + (mainFilters, subFilters, overlayFilters) = GetAmdVidFilterChain(state, options, outputVideoCodec); + } + else + { + (mainFilters, subFilters, overlayFilters) = GetSwVidFilterChain(state, options, outputVideoCodec); + } + + mainFilters?.RemoveAll(filter => string.IsNullOrEmpty(filter)); + subFilters?.RemoveAll(filter => string.IsNullOrEmpty(filter)); + overlayFilters?.RemoveAll(filter => string.IsNullOrEmpty(filter)); + + var mainStr = string.Empty; + if (mainFilters?.Count > 0) + { + mainStr = string.Format( + CultureInfo.InvariantCulture, + "{0}", + string.Join(',', mainFilters)); + } + + if (overlayFilters?.Count == 0) + { + // -vf "scale..." + return string.IsNullOrEmpty(mainStr) ? string.Empty : " -vf \"" + mainStr + "\""; + } + + if (overlayFilters?.Count > 0 + && subFilters?.Count > 0 + && state.SubtitleStream != null) + { + // overlay graphical/text subtitles + var subStr = string.Format( + CultureInfo.InvariantCulture, + "{0}", + string.Join(',', subFilters)); + + var overlayStr = string.Format( + CultureInfo.InvariantCulture, + "{0}", + string.Join(',', overlayFilters)); + + var mapPrefix = Convert.ToInt32(state.SubtitleStream.IsExternal); + var subtitleStreamIndex = state.SubtitleStream.IsExternal + ? 0 + : state.SubtitleStream.Index; + + if (hasSubs) + { + // -filter_complex "[0:s]scale=s[sub]..." + var filterStr = string.IsNullOrEmpty(mainStr) + ? " -filter_complex \"[{0}:{1}]{4}[sub];[0:{2}][sub]{5}\"" + : " -filter_complex \"[{0}:{1}]{4}[sub];[0:{2}]{3}[main];[main][sub]{5}\""; + + if (hasTextSubs) + { + filterStr = string.IsNullOrEmpty(mainStr) + ? " -filter_complex \"{4}[sub];[0:{2}][sub]{5}\"" + : " -filter_complex \"{4}[sub];[0:{2}]{3}[main];[main][sub]{5}\""; + } + + return string.Format( + CultureInfo.InvariantCulture, + filterStr, + mapPrefix, + subtitleStreamIndex, + state.VideoStream.Index, + mainStr, + subStr, + overlayStr); + } + } + + return string.Empty; + } + + public string GetOverwriteColorPropertiesParam(EncodingJobInfo state, bool isTonemapAvailable) + { + if (isTonemapAvailable) + { + return GetInputHdrParam(state.VideoStream?.ColorTransfer); + } + + return GetOutputSdrParam(null); + } + + public string GetInputHdrParam(string colorTransfer) { if (string.Equals(colorTransfer, "arib-std-b67", StringComparison.OrdinalIgnoreCase)) { // HLG return "setparams=color_primaries=bt2020:color_trc=arib-std-b67:colorspace=bt2020nc"; } - else - { - // HDR10 - return "setparams=color_primaries=bt2020:color_trc=smpte2084:colorspace=bt2020nc"; - } + + // HDR10 + return "setparams=color_primaries=bt2020:color_trc=smpte2084:colorspace=bt2020nc"; } - public static string GetOutputSdrParams(string tonemappingRange) + public string GetOutputSdrParam(string tonemappingRange) { // SDR if (string.Equals(tonemappingRange, "tv", StringComparison.OrdinalIgnoreCase)) @@ -3185,6 +4171,619 @@ namespace MediaBrowser.Controller.MediaEncoding return "setparams=color_primaries=bt709:color_trc=bt709:colorspace=bt709"; } + public static int GetVideoColorBitDepth(EncodingJobInfo state) + { + var videoStream = state.VideoStream; + if (videoStream != null) + { + if (videoStream.BitDepth.HasValue) + { + return videoStream.BitDepth.Value; + } + else if (string.Equals(videoStream.PixelFormat, "yuv420p", StringComparison.OrdinalIgnoreCase) + || string.Equals(videoStream.PixelFormat, "yuv444p", StringComparison.OrdinalIgnoreCase)) + { + return 8; + } + else if (string.Equals(videoStream.PixelFormat, "yuv420p10le", StringComparison.OrdinalIgnoreCase) + || string.Equals(videoStream.PixelFormat, "yuv444p10le", StringComparison.OrdinalIgnoreCase)) + { + return 10; + } + else if (string.Equals(videoStream.PixelFormat, "yuv420p12le", StringComparison.OrdinalIgnoreCase) + || string.Equals(videoStream.PixelFormat, "yuv444p12le", StringComparison.OrdinalIgnoreCase)) + { + return 12; + } + else + { + return 8; + } + } + + return 0; + } + + /// <summary> + /// Gets the ffmpeg option string for the hardware accelerated video decoder. + /// </summary> + /// <param name="state">The encoding job info.</param> + /// <param name="options">The encoding options.</param> + /// <returns>The option string or null if none available.</returns> + protected string GetHardwareVideoDecoder(EncodingJobInfo state, EncodingOptions options) + { + var videoStream = state.VideoStream; + if (videoStream == null) + { + return null; + } + + // Only use alternative encoders for video files. + var videoType = state.MediaSource.VideoType ?? VideoType.VideoFile; + if (videoType != VideoType.VideoFile) + { + return null; + } + + if (IsCopyCodec(state.OutputVideoCodec)) + { + return null; + } + + if (!string.IsNullOrEmpty(videoStream.Codec) && !string.IsNullOrEmpty(options.HardwareAccelerationType)) + { + var bitDepth = GetVideoColorBitDepth(state); + + // Only HEVC, VP9 and AV1 formats have 10-bit hardware decoder support now. + if (bitDepth == 10 + && !(string.Equals(videoStream.Codec, "hevc", StringComparison.OrdinalIgnoreCase) + || string.Equals(videoStream.Codec, "h265", StringComparison.OrdinalIgnoreCase) + || string.Equals(videoStream.Codec, "vp9", StringComparison.OrdinalIgnoreCase) + || string.Equals(videoStream.Codec, "av1", StringComparison.OrdinalIgnoreCase))) + { + return null; + } + + if (string.Equals(options.HardwareAccelerationType, "qsv", StringComparison.OrdinalIgnoreCase)) + { + return GetQsvHwVidDecoder(state, options, videoStream, bitDepth); + } + + if (string.Equals(options.HardwareAccelerationType, "nvenc", StringComparison.OrdinalIgnoreCase)) + { + return GetNvdecVidDecoder(state, options, videoStream, bitDepth); + } + + if (string.Equals(options.HardwareAccelerationType, "amf", StringComparison.OrdinalIgnoreCase)) + { + return GetAmfVidDecoder(state, options, videoStream, bitDepth); + } + + if (string.Equals(options.HardwareAccelerationType, "vaapi", StringComparison.OrdinalIgnoreCase)) + { + return GetVaapiVidDecoder(state, options, videoStream, bitDepth); + } + + if (string.Equals(options.HardwareAccelerationType, "videotoolbox", StringComparison.OrdinalIgnoreCase)) + { + return GetVideotoolboxVidDecoder(state, options, videoStream, bitDepth); + } + + if (string.Equals(options.HardwareAccelerationType, "omx", StringComparison.OrdinalIgnoreCase)) + { + return GetOmxVidDecoder(state, options, videoStream, bitDepth); + } + } + + var whichCodec = videoStream.Codec; + if (string.Equals(whichCodec, "avc", StringComparison.OrdinalIgnoreCase)) + { + whichCodec = "h264"; + } + else if (string.Equals(whichCodec, "h265", StringComparison.OrdinalIgnoreCase)) + { + whichCodec = "hevc"; + } + + // Avoid a second attempt if no hardware acceleration is being used + options.HardwareDecodingCodecs = Array.FindAll(options.HardwareDecodingCodecs, val => !string.Equals(val, whichCodec, StringComparison.OrdinalIgnoreCase)); + + // leave blank so ffmpeg will decide + return null; + } + + /// <summary> + /// Gets a hw decoder name. + /// </summary> + /// <param name="options">Encoding options.</param> + /// <param name="decoderPrefix">Decoder prefix.</param> + /// <param name="decoderSuffix">Decoder suffix.</param> + /// <param name="videoCodec">Video codec to use.</param> + /// <param name="bitDepth">Video color bit depth.</param> + /// <returns>Hardware decoder name.</returns> + public string GetHwDecoderName(EncodingOptions options, string decoderPrefix, string decoderSuffix, string videoCodec, int bitDepth) + { + if (string.IsNullOrEmpty(decoderPrefix) || string.IsNullOrEmpty(decoderSuffix)) + { + return null; + } + + var decoderName = decoderPrefix + '_' + decoderSuffix; + + var isCodecAvailable = _mediaEncoder.SupportsDecoder(decoderName) && options.HardwareDecodingCodecs.Contains(videoCodec, StringComparison.OrdinalIgnoreCase); + if (bitDepth == 10 && isCodecAvailable) + { + if (string.Equals(videoCodec, "hevc", StringComparison.OrdinalIgnoreCase) + && options.HardwareDecodingCodecs.Contains("hevc", StringComparison.OrdinalIgnoreCase) + && !options.EnableDecodingColorDepth10Hevc) + { + return null; + } + + if (string.Equals(videoCodec, "vp9", StringComparison.OrdinalIgnoreCase) + && options.HardwareDecodingCodecs.Contains("vp9", StringComparison.OrdinalIgnoreCase) + && !options.EnableDecodingColorDepth10Vp9) + { + return null; + } + } + + if (string.Equals(decoderSuffix, "cuvid", StringComparison.OrdinalIgnoreCase) && options.EnableEnhancedNvdecDecoder) + { + return null; + } + + if (string.Equals(decoderSuffix, "qsv", StringComparison.OrdinalIgnoreCase) && options.PreferSystemNativeHwDecoder) + { + return null; + } + + return isCodecAvailable ? (" -c:v " + decoderName) : null; + } + + /// <summary> + /// Gets a hwaccel type to use as a hardware decoder depending on the system. + /// </summary> + /// <param name="state">Encoding state.</param> + /// <param name="options">Encoding options.</param> + /// <param name="videoCodec">Video codec to use.</param> + /// <param name="bitDepth">Video color bit depth.</param> + /// <param name="outputHwSurface">Specifies if output hw surface.</param> + /// <returns>Hardware accelerator type.</returns> + public string GetHwaccelType(EncodingJobInfo state, EncodingOptions options, string videoCodec, int bitDepth, bool outputHwSurface) + { + var isWindows = OperatingSystem.IsWindows(); + var isLinux = OperatingSystem.IsLinux(); + var isMacOS = OperatingSystem.IsMacOS(); + var isD3d11Supported = isWindows && _mediaEncoder.SupportsHwaccel("d3d11va"); + var isVaapiSupported = isLinux && IsVaapiSupported(state); + var isCudaSupported = (isLinux || isWindows) && IsCudaFullSupported(); + var isQsvSupported = (isLinux || isWindows) && _mediaEncoder.SupportsHwaccel("qsv"); + var isVideotoolboxSupported = isMacOS && _mediaEncoder.SupportsHwaccel("videotoolbox"); + var isCodecAvailable = options.HardwareDecodingCodecs.Contains(videoCodec, StringComparison.OrdinalIgnoreCase); + + // Set the av1 codec explicitly to trigger hw accelerator, otherwise libdav1d will be used. + var isAv1 = string.Equals(videoCodec, "av1", StringComparison.OrdinalIgnoreCase); + + if (bitDepth == 10 && isCodecAvailable) + { + if (string.Equals(videoCodec, "hevc", StringComparison.OrdinalIgnoreCase) + && options.HardwareDecodingCodecs.Contains("hevc", StringComparison.OrdinalIgnoreCase) + && !options.EnableDecodingColorDepth10Hevc) + { + return null; + } + + if (string.Equals(videoCodec, "vp9", StringComparison.OrdinalIgnoreCase) + && options.HardwareDecodingCodecs.Contains("vp9", StringComparison.OrdinalIgnoreCase) + && !options.EnableDecodingColorDepth10Vp9) + { + return null; + } + } + + // Intel qsv/d3d11va/vaapi + if (string.Equals(options.HardwareAccelerationType, "qsv", StringComparison.OrdinalIgnoreCase)) + { + if (options.PreferSystemNativeHwDecoder) + { + if (isVaapiSupported && isCodecAvailable) + { + return " -hwaccel vaapi" + (outputHwSurface ? " -hwaccel_output_format vaapi" : string.Empty) + (isAv1 ? " -c:v av1" : string.Empty); + } + + if (isD3d11Supported && isCodecAvailable) + { + return " -hwaccel d3d11va" + (outputHwSurface ? " -hwaccel_output_format d3d11" : string.Empty) + (isAv1 ? " -c:v av1" : string.Empty); + } + } + else + { + if (isQsvSupported && isCodecAvailable) + { + return " -hwaccel qsv" + (outputHwSurface ? " -hwaccel_output_format qsv" : string.Empty); + } + } + } + + // Nvidia cuda + if (string.Equals(options.HardwareAccelerationType, "nvenc", StringComparison.OrdinalIgnoreCase)) + { + if (options.EnableEnhancedNvdecDecoder && isCudaSupported && isCodecAvailable) + { + return " -hwaccel cuda" + (outputHwSurface ? " -hwaccel_output_format cuda" : string.Empty) + (isAv1 ? " -c:v av1" : string.Empty); + } + } + + // Amd d3d11va + if (string.Equals(options.HardwareAccelerationType, "amf", StringComparison.OrdinalIgnoreCase)) + { + if (isD3d11Supported && isCodecAvailable) + { + return " -hwaccel d3d11va" + (outputHwSurface ? " -hwaccel_output_format d3d11" : string.Empty) + (isAv1 ? " -c:v av1" : string.Empty); + } + } + + // Vaapi + if (string.Equals(options.HardwareAccelerationType, "vaapi", StringComparison.OrdinalIgnoreCase) + && isVaapiSupported + && isCodecAvailable) + { + return " -hwaccel vaapi" + (outputHwSurface ? " -hwaccel_output_format vaapi" : string.Empty) + (isAv1 ? " -c:v av1" : string.Empty); + } + + if (string.Equals(options.HardwareAccelerationType, "videotoolbox", StringComparison.OrdinalIgnoreCase) + && isVideotoolboxSupported + && isCodecAvailable) + { + return " -hwaccel videotoolbox" + (outputHwSurface ? " -hwaccel_output_format videotoolbox_vld" : string.Empty); + } + + return null; + } + + public string GetQsvHwVidDecoder(EncodingJobInfo state, EncodingOptions options, MediaStream videoStream, int bitDepth) + { + var isWindows = OperatingSystem.IsWindows(); + var isLinux = OperatingSystem.IsLinux(); + + if ((!isWindows && !isLinux) + || !string.Equals(options.HardwareAccelerationType, "qsv", StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + var isQsvOclSupported = _mediaEncoder.SupportsHwaccel("qsv") && IsOpenclFullSupported(); + var isIntelDx11OclSupported = isWindows + && _mediaEncoder.SupportsHwaccel("d3d11va") + && isQsvOclSupported; + var isIntelVaapiOclSupported = isLinux + && IsVaapiSupported(state) + && isQsvOclSupported; + var hwSurface = (isIntelDx11OclSupported || isIntelVaapiOclSupported) + && _mediaEncoder.SupportsFilter("alphasrc"); + + var is8bitSwFormatsQsv = string.Equals("yuv420p", videoStream.PixelFormat, StringComparison.OrdinalIgnoreCase); + var is8_10bitSwFormatsQsv = is8bitSwFormatsQsv || string.Equals("yuv420p10le", videoStream.PixelFormat, StringComparison.OrdinalIgnoreCase); + // TODO: add more 8/10bit and 4:4:4 formats for Qsv after finishing the ffcheck tool + + if (is8bitSwFormatsQsv) + { + if (string.Equals(videoStream.Codec, "avc", StringComparison.OrdinalIgnoreCase) + || string.Equals(videoStream.Codec, "h264", StringComparison.OrdinalIgnoreCase)) + { + return GetHwaccelType(state, options, "h264", bitDepth, hwSurface) + GetHwDecoderName(options, "h264", "qsv", "h264", bitDepth); + } + + if (string.Equals(videoStream.Codec, "vc1", StringComparison.OrdinalIgnoreCase)) + { + return GetHwaccelType(state, options, "vc1", bitDepth, hwSurface) + GetHwDecoderName(options, "vc1", "qsv", "vc1", bitDepth); + } + + if (string.Equals(videoStream.Codec, "vp8", StringComparison.OrdinalIgnoreCase)) + { + return GetHwaccelType(state, options, "vp8", bitDepth, hwSurface) + GetHwDecoderName(options, "vp8", "qsv", "vp8", bitDepth); + } + + if (string.Equals(videoStream.Codec, "mpeg2video", StringComparison.OrdinalIgnoreCase)) + { + return GetHwaccelType(state, options, "mpeg2video", bitDepth, hwSurface) + GetHwDecoderName(options, "mpeg2", "qsv", "mpeg2video", bitDepth); + } + } + + if (is8_10bitSwFormatsQsv) + { + if (string.Equals(videoStream.Codec, "hevc", StringComparison.OrdinalIgnoreCase) + || string.Equals(videoStream.Codec, "h265", StringComparison.OrdinalIgnoreCase)) + { + return GetHwaccelType(state, options, "hevc", bitDepth, hwSurface) + GetHwDecoderName(options, "hevc", "qsv", "hevc", bitDepth); + } + + if (string.Equals(videoStream.Codec, "vp9", StringComparison.OrdinalIgnoreCase)) + { + return GetHwaccelType(state, options, "vp9", bitDepth, hwSurface) + GetHwDecoderName(options, "vp9", "qsv", "vp9", bitDepth); + } + + if (string.Equals(videoStream.Codec, "av1", StringComparison.OrdinalIgnoreCase)) + { + return GetHwaccelType(state, options, "av1", bitDepth, hwSurface) + GetHwDecoderName(options, "av1", "qsv", "av1", bitDepth); + } + } + + return null; + } + + public string GetNvdecVidDecoder(EncodingJobInfo state, EncodingOptions options, MediaStream videoStream, int bitDepth) + { + if ((!OperatingSystem.IsWindows() && !OperatingSystem.IsLinux()) + || !string.Equals(options.HardwareAccelerationType, "nvenc", StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + var hwSurface = IsCudaFullSupported() + && options.EnableEnhancedNvdecDecoder + && _mediaEncoder.SupportsFilter("alphasrc"); + var is8bitSwFormatsNvdec = string.Equals("yuv420p", videoStream.PixelFormat, StringComparison.OrdinalIgnoreCase); + var is8_10bitSwFormatsNvdec = is8bitSwFormatsNvdec || string.Equals("yuv420p10le", videoStream.PixelFormat, StringComparison.OrdinalIgnoreCase); + // TODO: add more 8/10/12bit and 4:4:4 formats for Nvdec after finishing the ffcheck tool + + if (is8bitSwFormatsNvdec) + { + if (string.Equals("avc", videoStream.Codec, StringComparison.OrdinalIgnoreCase) + || string.Equals("h264", videoStream.Codec, StringComparison.OrdinalIgnoreCase)) + { + return GetHwaccelType(state, options, "h264", bitDepth, hwSurface) + GetHwDecoderName(options, "h264", "cuvid", "h264", bitDepth); + } + + if (string.Equals("mpeg2video", videoStream.Codec, StringComparison.OrdinalIgnoreCase)) + { + return GetHwaccelType(state, options, "mpeg2video", bitDepth, hwSurface) + GetHwDecoderName(options, "mpeg2", "cuvid", "mpeg2video", bitDepth); + } + + if (string.Equals("vc1", videoStream.Codec, StringComparison.OrdinalIgnoreCase)) + { + return GetHwaccelType(state, options, "vc1", bitDepth, hwSurface) + GetHwDecoderName(options, "vc1", "cuvid", "vc1", bitDepth); + } + + if (string.Equals("mpeg4", videoStream.Codec, StringComparison.OrdinalIgnoreCase)) + { + return GetHwaccelType(state, options, "mpeg4", bitDepth, hwSurface) + GetHwDecoderName(options, "mpeg4", "cuvid", "mpeg4", bitDepth); + } + + if (string.Equals("vp8", videoStream.Codec, StringComparison.OrdinalIgnoreCase)) + { + return GetHwaccelType(state, options, "vp8", bitDepth, hwSurface) + GetHwDecoderName(options, "vp8", "cuvid", "vp8", bitDepth); + } + } + + if (is8_10bitSwFormatsNvdec) + { + if (string.Equals("hevc", videoStream.Codec, StringComparison.OrdinalIgnoreCase) + || string.Equals("h265", videoStream.Codec, StringComparison.OrdinalIgnoreCase)) + { + return GetHwaccelType(state, options, "hevc", bitDepth, hwSurface) + GetHwDecoderName(options, "hevc", "cuvid", "hevc", bitDepth); + } + + if (string.Equals("vp9", videoStream.Codec, StringComparison.OrdinalIgnoreCase)) + { + return GetHwaccelType(state, options, "vp9", bitDepth, hwSurface) + GetHwDecoderName(options, "vp9", "cuvid", "vp9", bitDepth); + } + + if (string.Equals("av1", videoStream.Codec, StringComparison.OrdinalIgnoreCase)) + { + return GetHwaccelType(state, options, "av1", bitDepth, hwSurface) + GetHwDecoderName(options, "av1", "cuvid", "av1", bitDepth); + } + } + + return null; + } + + public string GetAmfVidDecoder(EncodingJobInfo state, EncodingOptions options, MediaStream videoStream, int bitDepth) + { + if (!OperatingSystem.IsWindows() + || !string.Equals(options.HardwareAccelerationType, "amf", StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + var hwSurface = _mediaEncoder.SupportsHwaccel("d3d11va") + && IsOpenclFullSupported() + && _mediaEncoder.SupportsFilter("alphasrc"); + var is8bitSwFormatsAmf = string.Equals("yuv420p", videoStream.PixelFormat, StringComparison.OrdinalIgnoreCase); + var is8_10bitSwFormatsAmf = is8bitSwFormatsAmf || string.Equals("yuv420p10le", videoStream.PixelFormat, StringComparison.OrdinalIgnoreCase); + + if (is8bitSwFormatsAmf) + { + if (string.Equals("avc", videoStream.Codec, StringComparison.OrdinalIgnoreCase) + || string.Equals("h264", videoStream.Codec, StringComparison.OrdinalIgnoreCase)) + { + return GetHwaccelType(state, options, "h264", bitDepth, hwSurface); + } + + if (string.Equals("mpeg2video", videoStream.Codec, StringComparison.OrdinalIgnoreCase)) + { + return GetHwaccelType(state, options, "mpeg2video", bitDepth, hwSurface); + } + + if (string.Equals("vc1", videoStream.Codec, StringComparison.OrdinalIgnoreCase)) + { + return GetHwaccelType(state, options, "vc1", bitDepth, hwSurface); + } + + if (string.Equals("mpeg4", videoStream.Codec, StringComparison.OrdinalIgnoreCase)) + { + return GetHwaccelType(state, options, "mpeg4", bitDepth, hwSurface); + } + } + + if (is8_10bitSwFormatsAmf) + { + if (string.Equals("hevc", videoStream.Codec, StringComparison.OrdinalIgnoreCase) + || string.Equals("h265", videoStream.Codec, StringComparison.OrdinalIgnoreCase)) + { + return GetHwaccelType(state, options, "hevc", bitDepth, hwSurface); + } + + if (string.Equals("vp9", videoStream.Codec, StringComparison.OrdinalIgnoreCase)) + { + return GetHwaccelType(state, options, "vp9", bitDepth, hwSurface); + } + + if (string.Equals("av1", videoStream.Codec, StringComparison.OrdinalIgnoreCase)) + { + return GetHwaccelType(state, options, "av1", bitDepth, hwSurface); + } + } + + return null; + } + + public string GetVaapiVidDecoder(EncodingJobInfo state, EncodingOptions options, MediaStream videoStream, int bitDepth) + { + if (!OperatingSystem.IsLinux() + || !string.Equals(options.HardwareAccelerationType, "vaapi", StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + var hwSurface = IsVaapiSupported(state) + && IsVaapiFullSupported() + && IsOpenclFullSupported() + && _mediaEncoder.SupportsFilter("alphasrc"); + var is8bitSwFormatsVaapi = string.Equals("yuv420p", videoStream.PixelFormat, StringComparison.OrdinalIgnoreCase); + var is8_10bitSwFormatsVaapi = is8bitSwFormatsVaapi || string.Equals("yuv420p10le", videoStream.PixelFormat, StringComparison.OrdinalIgnoreCase); + + if (is8bitSwFormatsVaapi) + { + if (string.Equals("avc", videoStream.Codec, StringComparison.OrdinalIgnoreCase) + || string.Equals("h264", videoStream.Codec, StringComparison.OrdinalIgnoreCase)) + { + return GetHwaccelType(state, options, "h264", bitDepth, hwSurface); + } + + if (string.Equals("mpeg2video", videoStream.Codec, StringComparison.OrdinalIgnoreCase)) + { + return GetHwaccelType(state, options, "mpeg2video", bitDepth, hwSurface); + } + + if (string.Equals("vc1", videoStream.Codec, StringComparison.OrdinalIgnoreCase)) + { + return GetHwaccelType(state, options, "vc1", bitDepth, hwSurface); + } + + if (string.Equals("vp8", videoStream.Codec, StringComparison.OrdinalIgnoreCase)) + { + return GetHwaccelType(state, options, "vp8", bitDepth, hwSurface); + } + } + + if (is8_10bitSwFormatsVaapi) + { + if (string.Equals("hevc", videoStream.Codec, StringComparison.OrdinalIgnoreCase) + || string.Equals("h265", videoStream.Codec, StringComparison.OrdinalIgnoreCase)) + { + return GetHwaccelType(state, options, "hevc", bitDepth, hwSurface); + } + + if (string.Equals("vp9", videoStream.Codec, StringComparison.OrdinalIgnoreCase)) + { + return GetHwaccelType(state, options, "vp9", bitDepth, hwSurface); + } + + if (string.Equals("av1", videoStream.Codec, StringComparison.OrdinalIgnoreCase)) + { + return GetHwaccelType(state, options, "av1", bitDepth, hwSurface); + } + } + + return null; + } + + public string GetVideotoolboxVidDecoder(EncodingJobInfo state, EncodingOptions options, MediaStream videoStream, int bitDepth) + { + if (!OperatingSystem.IsMacOS() + || !string.Equals(options.HardwareAccelerationType, "videotoolbox", StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + var is8bitSwFormatsVt = string.Equals("yuv420p", videoStream.PixelFormat, StringComparison.OrdinalIgnoreCase); + var is8_10bitSwFormatsVt = is8bitSwFormatsVt || string.Equals("yuv420p10le", videoStream.PixelFormat, StringComparison.OrdinalIgnoreCase); + + if (is8bitSwFormatsVt) + { + if (string.Equals("avc", videoStream.Codec, StringComparison.OrdinalIgnoreCase) + || string.Equals("h264", videoStream.Codec, StringComparison.OrdinalIgnoreCase)) + { + return GetHwaccelType(state, options, "h264", bitDepth, false); + } + + if (string.Equals("mpeg2video", videoStream.Codec, StringComparison.OrdinalIgnoreCase)) + { + return GetHwaccelType(state, options, "mpeg2video", bitDepth, false); + } + + if (string.Equals("mpeg4", videoStream.Codec, StringComparison.OrdinalIgnoreCase)) + { + return GetHwaccelType(state, options, "mpeg4", bitDepth, false); + } + } + + if (is8_10bitSwFormatsVt) + { + if (string.Equals("hevc", videoStream.Codec, StringComparison.OrdinalIgnoreCase) + || string.Equals("h265", videoStream.Codec, StringComparison.OrdinalIgnoreCase)) + { + return GetHwaccelType(state, options, "hevc", bitDepth, false); + } + + if (string.Equals("vp9", videoStream.Codec, StringComparison.OrdinalIgnoreCase)) + { + return GetHwaccelType(state, options, "vp9", bitDepth, false); + } + } + + return null; + } + + public string GetOmxVidDecoder(EncodingJobInfo state, EncodingOptions options, MediaStream videoStream, int bitDepth) + { + if (!OperatingSystem.IsLinux() + || !string.Equals(options.HardwareAccelerationType, "omx", StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + var is8bitSwFormatsOmx = string.Equals("yuv420p", videoStream.PixelFormat, StringComparison.OrdinalIgnoreCase); + + if (is8bitSwFormatsOmx) + { + if (string.Equals("avc", videoStream.Codec, StringComparison.OrdinalIgnoreCase) + || string.Equals("h264", videoStream.Codec, StringComparison.OrdinalIgnoreCase)) + { + return GetHwDecoderName(options, "h264", "mmal", "h264", bitDepth); + } + + if (string.Equals("mpeg2video", videoStream.Codec, StringComparison.OrdinalIgnoreCase)) + { + return GetHwDecoderName(options, "mpeg2", "mmal", "mpeg2video", bitDepth); + } + + if (string.Equals("mpeg4", videoStream.Codec, StringComparison.OrdinalIgnoreCase)) + { + return GetHwDecoderName(options, "mpeg4", "mmal", "mpeg4", bitDepth); + } + + if (string.Equals("vc1", videoStream.Codec, StringComparison.OrdinalIgnoreCase)) + { + return GetHwDecoderName(options, "vc1", "mmal", "vc1", bitDepth); + } + } + + return null; + } + /// <summary> /// Gets the number of threads. /// </summary> @@ -3285,7 +4884,7 @@ namespace MediaBrowser.Controller.MediaEncoding inputModifier = inputModifier.Trim(); - inputModifier += " " + GetFastSeekCommandLineParameter(state.BaseRequest); + inputModifier += " " + GetFastSeekCommandLineParameter(state, encodingOptions); inputModifier = inputModifier.Trim(); if (state.InputProtocol == MediaProtocol.Rtsp) @@ -3339,61 +4938,8 @@ namespace MediaBrowser.Controller.MediaEncoding inputModifier += " -fflags " + string.Join(string.Empty, flags); } - var videoDecoder = GetHardwareAcceleratedVideoDecoder(state, encodingOptions); - - if (!string.IsNullOrEmpty(videoDecoder)) - { - inputModifier += " " + videoDecoder; - - if (!IsCopyCodec(state.OutputVideoCodec) - && videoDecoder.Contains("cuvid", StringComparison.OrdinalIgnoreCase)) - { - var videoStream = state.VideoStream; - var inputWidth = videoStream?.Width; - var inputHeight = videoStream?.Height; - var request = state.BaseRequest; - - var (width, height) = GetFixedOutputSize(inputWidth, inputHeight, request.Width, request.Height, request.MaxWidth, request.MaxHeight); - - if (videoDecoder.Contains("cuvid", StringComparison.OrdinalIgnoreCase) - && width.HasValue - && height.HasValue) - { - if (width.HasValue && height.HasValue) - { - inputModifier += string.Format( - CultureInfo.InvariantCulture, - " -resize {0}x{1}", - width.Value, - height.Value); - } - - if (state.DeInterlace("h264", true)) - { - inputModifier += " -deint 1"; - - if (!encodingOptions.DeinterlaceDoubleRate || (videoStream?.AverageFrameRate ?? 60) > 30) - { - inputModifier += " -drop_second_field 1"; - } - } - } - } - } - if (state.IsVideoRequest) { - var outputVideoCodec = GetVideoEncoder(state, encodingOptions); - - // Important: If this is ever re-enabled, make sure not to use it with wtv because it breaks seeking - if (!string.Equals(state.InputContainer, "wtv", StringComparison.OrdinalIgnoreCase) - && state.TranscodingType != TranscodingJobType.Progressive - && !state.EnableBreakOnNonKeyFrames(outputVideoCodec) - && (state.BaseRequest.StartTimeTicks ?? 0) > 0) - { - inputModifier += " -noaccurate_seek"; - } - if (!string.IsNullOrEmpty(state.InputContainer) && state.VideoType == VideoType.VideoFile && string.IsNullOrEmpty(encodingOptions.HardwareAccelerationType)) { var inputFormat = GetInputFormat(state.InputContainer); @@ -3550,12 +5096,12 @@ namespace MediaBrowser.Controller.MediaEncoding // Transcoding to 2ch ac3 almost always causes a playback failure // Keep it in the supported codecs list, but shift it to the end of the list so that if transcoding happens, another codec is used var shiftAudioCodecs = new[] { "ac3", "eac3" }; - if (audioCodecs.All(i => shiftAudioCodecs.Contains(i, StringComparer.OrdinalIgnoreCase))) + if (audioCodecs.All(i => shiftAudioCodecs.Contains(i, StringComparison.OrdinalIgnoreCase))) { return; } - while (shiftAudioCodecs.Contains(audioCodecs[0], StringComparer.OrdinalIgnoreCase)) + while (shiftAudioCodecs.Contains(audioCodecs[0], StringComparison.OrdinalIgnoreCase)) { var removed = shiftAudioCodecs[0]; audioCodecs.RemoveAt(0); @@ -3578,12 +5124,12 @@ namespace MediaBrowser.Controller.MediaEncoding } var shiftVideoCodecs = new[] { "hevc", "h265" }; - if (videoCodecs.All(i => shiftVideoCodecs.Contains(i, StringComparer.OrdinalIgnoreCase))) + if (videoCodecs.All(i => shiftVideoCodecs.Contains(i, StringComparison.OrdinalIgnoreCase))) { return; } - while (shiftVideoCodecs.Contains(videoCodecs[0], StringComparer.OrdinalIgnoreCase)) + while (shiftVideoCodecs.Contains(videoCodecs[0], StringComparison.OrdinalIgnoreCase)) { var removed = shiftVideoCodecs[0]; videoCodecs.RemoveAt(0); @@ -3606,322 +5152,6 @@ namespace MediaBrowser.Controller.MediaEncoding } } - /// <summary> - /// Gets the ffmpeg option string for the hardware accelerated video decoder. - /// </summary> - /// <param name="state">The encoding job info.</param> - /// <param name="encodingOptions">The encoding options.</param> - /// <returns>The option string or null if none available.</returns> - protected string GetHardwareAcceleratedVideoDecoder(EncodingJobInfo state, EncodingOptions encodingOptions) - { - var videoStream = state.VideoStream; - - if (videoStream == null) - { - return null; - } - - var videoType = state.MediaSource.VideoType ?? VideoType.VideoFile; - // Only use alternative encoders for video files. - // When using concat with folder rips, if the mfx session fails to initialize, ffmpeg will be stuck retrying and will not exit gracefully - // Since transcoding of folder rips is experimental anyway, it's not worth adding additional variables such as this. - if (videoType != VideoType.VideoFile) - { - return null; - } - - if (IsCopyCodec(state.OutputVideoCodec)) - { - return null; - } - - if (!string.IsNullOrEmpty(videoStream.Codec) && !string.IsNullOrEmpty(encodingOptions.HardwareAccelerationType)) - { - var isColorDepth10 = IsColorDepth10(state); - - // Only hevc and vp9 formats have 10-bit hardware decoder support now. - if (isColorDepth10 && !(string.Equals(videoStream.Codec, "hevc", StringComparison.OrdinalIgnoreCase) - || string.Equals(videoStream.Codec, "h265", StringComparison.OrdinalIgnoreCase) - || string.Equals(videoStream.Codec, "vp9", StringComparison.OrdinalIgnoreCase))) - { - return null; - } - - // Hybrid VPP tonemapping with VAAPI - if (string.Equals(encodingOptions.HardwareAccelerationType, "qsv", StringComparison.OrdinalIgnoreCase) - && IsVppTonemappingSupported(state, encodingOptions)) - { - var outputVideoCodec = GetVideoEncoder(state, encodingOptions) ?? string.Empty; - var isQsvEncoder = outputVideoCodec.Contains("qsv", StringComparison.OrdinalIgnoreCase); - if (isQsvEncoder) - { - // Since tonemap_vaapi only support HEVC for now, no need to check the codec again. - return GetHwaccelType(state, encodingOptions, "hevc", isColorDepth10); - } - } - - if (string.Equals(encodingOptions.HardwareAccelerationType, "qsv", StringComparison.OrdinalIgnoreCase)) - { - switch (videoStream.Codec.ToLowerInvariant()) - { - case "avc": - case "h264": - return GetHwDecoderName(encodingOptions, "h264_qsv", "h264", isColorDepth10); - case "hevc": - case "h265": - return GetHwDecoderName(encodingOptions, "hevc_qsv", "hevc", isColorDepth10); - case "mpeg2video": - return GetHwDecoderName(encodingOptions, "mpeg2_qsv", "mpeg2video", isColorDepth10); - case "vc1": - return GetHwDecoderName(encodingOptions, "vc1_qsv", "vc1", isColorDepth10); - case "vp8": - return GetHwDecoderName(encodingOptions, "vp8_qsv", "vp8", isColorDepth10); - case "vp9": - return GetHwDecoderName(encodingOptions, "vp9_qsv", "vp9", isColorDepth10); - } - } - else if (string.Equals(encodingOptions.HardwareAccelerationType, "nvenc", StringComparison.OrdinalIgnoreCase)) - { - switch (videoStream.Codec.ToLowerInvariant()) - { - case "avc": - case "h264": - return encodingOptions.EnableEnhancedNvdecDecoder && IsCudaSupported() - ? GetHwaccelType(state, encodingOptions, "h264", isColorDepth10) - : GetHwDecoderName(encodingOptions, "h264_cuvid", "h264", isColorDepth10); - case "hevc": - case "h265": - return encodingOptions.EnableEnhancedNvdecDecoder && IsCudaSupported() - ? GetHwaccelType(state, encodingOptions, "hevc", isColorDepth10) - : GetHwDecoderName(encodingOptions, "hevc_cuvid", "hevc", isColorDepth10); - case "mpeg2video": - return encodingOptions.EnableEnhancedNvdecDecoder && IsCudaSupported() - ? GetHwaccelType(state, encodingOptions, "mpeg2video", isColorDepth10) - : GetHwDecoderName(encodingOptions, "mpeg2_cuvid", "mpeg2video", isColorDepth10); - case "vc1": - return encodingOptions.EnableEnhancedNvdecDecoder && IsCudaSupported() - ? GetHwaccelType(state, encodingOptions, "vc1", isColorDepth10) - : GetHwDecoderName(encodingOptions, "vc1_cuvid", "vc1", isColorDepth10); - case "mpeg4": - return encodingOptions.EnableEnhancedNvdecDecoder && IsCudaSupported() - ? GetHwaccelType(state, encodingOptions, "mpeg4", isColorDepth10) - : GetHwDecoderName(encodingOptions, "mpeg4_cuvid", "mpeg4", isColorDepth10); - case "vp8": - return encodingOptions.EnableEnhancedNvdecDecoder && IsCudaSupported() - ? GetHwaccelType(state, encodingOptions, "vp8", isColorDepth10) - : GetHwDecoderName(encodingOptions, "vp8_cuvid", "vp8", isColorDepth10); - case "vp9": - return encodingOptions.EnableEnhancedNvdecDecoder && IsCudaSupported() - ? GetHwaccelType(state, encodingOptions, "vp9", isColorDepth10) - : GetHwDecoderName(encodingOptions, "vp9_cuvid", "vp9", isColorDepth10); - } - } - else if (string.Equals(encodingOptions.HardwareAccelerationType, "mediacodec", StringComparison.OrdinalIgnoreCase)) - { - switch (videoStream.Codec.ToLowerInvariant()) - { - case "avc": - case "h264": - return GetHwDecoderName(encodingOptions, "h264_mediacodec", "h264", isColorDepth10); - case "hevc": - case "h265": - return GetHwDecoderName(encodingOptions, "hevc_mediacodec", "hevc", isColorDepth10); - case "mpeg2video": - return GetHwDecoderName(encodingOptions, "mpeg2_mediacodec", "mpeg2video", isColorDepth10); - case "mpeg4": - return GetHwDecoderName(encodingOptions, "mpeg4_mediacodec", "mpeg4", isColorDepth10); - case "vp8": - return GetHwDecoderName(encodingOptions, "vp8_mediacodec", "vp8", isColorDepth10); - case "vp9": - return GetHwDecoderName(encodingOptions, "vp9_mediacodec", "vp9", isColorDepth10); - } - } - else if (string.Equals(encodingOptions.HardwareAccelerationType, "omx", StringComparison.OrdinalIgnoreCase)) - { - switch (videoStream.Codec.ToLowerInvariant()) - { - case "avc": - case "h264": - return GetHwDecoderName(encodingOptions, "h264_mmal", "h264", isColorDepth10); - case "mpeg2video": - return GetHwDecoderName(encodingOptions, "mpeg2_mmal", "mpeg2video", isColorDepth10); - case "mpeg4": - return GetHwDecoderName(encodingOptions, "mpeg4_mmal", "mpeg4", isColorDepth10); - case "vc1": - return GetHwDecoderName(encodingOptions, "vc1_mmal", "vc1", isColorDepth10); - } - } - else if (string.Equals(encodingOptions.HardwareAccelerationType, "amf", StringComparison.OrdinalIgnoreCase)) - { - switch (videoStream.Codec.ToLowerInvariant()) - { - case "avc": - case "h264": - return GetHwaccelType(state, encodingOptions, "h264", isColorDepth10); - case "hevc": - case "h265": - return GetHwaccelType(state, encodingOptions, "hevc", isColorDepth10); - case "mpeg2video": - return GetHwaccelType(state, encodingOptions, "mpeg2video", isColorDepth10); - case "vc1": - return GetHwaccelType(state, encodingOptions, "vc1", isColorDepth10); - case "mpeg4": - return GetHwaccelType(state, encodingOptions, "mpeg4", isColorDepth10); - case "vp9": - return GetHwaccelType(state, encodingOptions, "vp9", isColorDepth10); - } - } - else if (string.Equals(encodingOptions.HardwareAccelerationType, "vaapi", StringComparison.OrdinalIgnoreCase)) - { - switch (videoStream.Codec.ToLowerInvariant()) - { - case "avc": - case "h264": - return GetHwaccelType(state, encodingOptions, "h264", isColorDepth10); - case "hevc": - case "h265": - return GetHwaccelType(state, encodingOptions, "hevc", isColorDepth10); - case "mpeg2video": - return GetHwaccelType(state, encodingOptions, "mpeg2video", isColorDepth10); - case "vc1": - return GetHwaccelType(state, encodingOptions, "vc1", isColorDepth10); - case "vp8": - return GetHwaccelType(state, encodingOptions, "vp8", isColorDepth10); - case "vp9": - return GetHwaccelType(state, encodingOptions, "vp9", isColorDepth10); - } - } - else if (string.Equals(encodingOptions.HardwareAccelerationType, "videotoolbox", StringComparison.OrdinalIgnoreCase)) - { - switch (videoStream.Codec.ToLowerInvariant()) - { - case "avc": - case "h264": - return GetHwDecoderName(encodingOptions, "h264_opencl", "h264", isColorDepth10); - case "hevc": - case "h265": - return GetHwDecoderName(encodingOptions, "hevc_opencl", "hevc", isColorDepth10); - case "mpeg2video": - return GetHwDecoderName(encodingOptions, "mpeg2_opencl", "mpeg2video", isColorDepth10); - case "mpeg4": - return GetHwDecoderName(encodingOptions, "mpeg4_opencl", "mpeg4", isColorDepth10); - case "vc1": - return GetHwDecoderName(encodingOptions, "vc1_opencl", "vc1", isColorDepth10); - case "vp8": - return GetHwDecoderName(encodingOptions, "vp8_opencl", "vp8", isColorDepth10); - case "vp9": - return GetHwDecoderName(encodingOptions, "vp9_opencl", "vp9", isColorDepth10); - } - } - } - - var whichCodec = videoStream.Codec?.ToLowerInvariant(); - switch (whichCodec) - { - case "avc": - whichCodec = "h264"; - break; - case "h265": - whichCodec = "hevc"; - break; - } - - // Avoid a second attempt if no hardware acceleration is being used - encodingOptions.HardwareDecodingCodecs = encodingOptions.HardwareDecodingCodecs.Where(val => val != whichCodec).ToArray(); - - // leave blank so ffmpeg will decide - return null; - } - - /// <summary> - /// Gets a hw decoder name. - /// </summary> - /// <param name="options">Encoding options.</param> - /// <param name="decoder">Decoder to use.</param> - /// <param name="videoCodec">Video codec to use.</param> - /// <param name="isColorDepth10">Specifies if color depth 10.</param> - /// <returns>Hardware decoder name.</returns> - public string GetHwDecoderName(EncodingOptions options, string decoder, string videoCodec, bool isColorDepth10) - { - var isCodecAvailable = _mediaEncoder.SupportsDecoder(decoder) && options.HardwareDecodingCodecs.Contains(videoCodec, StringComparer.OrdinalIgnoreCase); - if (isColorDepth10 && isCodecAvailable) - { - if ((options.HardwareDecodingCodecs.Contains("hevc", StringComparer.OrdinalIgnoreCase) && !options.EnableDecodingColorDepth10Hevc) - || (options.HardwareDecodingCodecs.Contains("vp9", StringComparer.OrdinalIgnoreCase) && !options.EnableDecodingColorDepth10Vp9)) - { - return null; - } - } - - return isCodecAvailable ? ("-c:v " + decoder) : null; - } - - /// <summary> - /// Gets a hwaccel type to use as a hardware decoder(dxva/vaapi) depending on the system. - /// </summary> - /// <param name="state">Encoding state.</param> - /// <param name="options">Encoding options.</param> - /// <param name="videoCodec">Video codec to use.</param> - /// <param name="isColorDepth10">Specifies if color depth 10.</param> - /// <returns>Hardware accelerator type.</returns> - public string GetHwaccelType(EncodingJobInfo state, EncodingOptions options, string videoCodec, bool isColorDepth10) - { - var isWindows = OperatingSystem.IsWindows(); - var isLinux = OperatingSystem.IsLinux(); - var isWindows8orLater = Environment.OSVersion.Version.Major > 6 || (Environment.OSVersion.Version.Major == 6 && Environment.OSVersion.Version.Minor > 1); - var isDxvaSupported = _mediaEncoder.SupportsHwaccel("dxva2") || _mediaEncoder.SupportsHwaccel("d3d11va"); - var isCodecAvailable = options.HardwareDecodingCodecs.Contains(videoCodec, StringComparer.OrdinalIgnoreCase); - - if (isColorDepth10 && isCodecAvailable) - { - if ((options.HardwareDecodingCodecs.Contains("hevc", StringComparer.OrdinalIgnoreCase) && !options.EnableDecodingColorDepth10Hevc) - || (options.HardwareDecodingCodecs.Contains("vp9", StringComparer.OrdinalIgnoreCase) && !options.EnableDecodingColorDepth10Vp9)) - { - return null; - } - } - - if (string.Equals(options.HardwareAccelerationType, "amf", StringComparison.OrdinalIgnoreCase)) - { - // Currently there is no AMF decoder on Linux, only have h264 encoder. - if (isDxvaSupported && options.HardwareDecodingCodecs.Contains(videoCodec, StringComparer.OrdinalIgnoreCase)) - { - if (isWindows && isWindows8orLater) - { - return "-hwaccel d3d11va"; - } - - if (isWindows && !isWindows8orLater) - { - return "-hwaccel dxva2"; - } - } - } - - if (string.Equals(options.HardwareAccelerationType, "vaapi", StringComparison.OrdinalIgnoreCase) - || (string.Equals(options.HardwareAccelerationType, "qsv", StringComparison.OrdinalIgnoreCase) - && IsVppTonemappingSupported(state, options))) - { - if (IsVaapiSupported(state) && options.HardwareDecodingCodecs.Contains(videoCodec, StringComparer.OrdinalIgnoreCase)) - { - if (isLinux) - { - return "-hwaccel vaapi"; - } - } - } - - if (string.Equals(options.HardwareAccelerationType, "nvenc", StringComparison.OrdinalIgnoreCase)) - { - if (options.HardwareDecodingCodecs.Contains(videoCodec, StringComparer.OrdinalIgnoreCase)) - { - return "-hwaccel cuda"; - } - } - - return null; - } - public string GetSubtitleEmbedArguments(EncodingJobInfo state) { if (state.SubtitleStream == null || state.SubtitleDeliveryMethod != SubtitleDeliveryMethod.Embed) @@ -4039,25 +5269,12 @@ namespace MediaBrowser.Controller.MediaEncoding var hasCopyTs = false; - // Add resolution params, if specified - if (!hasGraphicalSubs) - { - var outputSizeParam = GetOutputSizeParam(state, encodingOptions, videoCodec); + // video processing filters. + var videoProcessParam = GetVideoProcessingFilterParam(state, encodingOptions, videoCodec); - args += outputSizeParam; + args += videoProcessParam; - hasCopyTs = outputSizeParam.IndexOf("copyts", StringComparison.OrdinalIgnoreCase) != -1; - } - - // This is for graphical subs - if (hasGraphicalSubs) - { - var graphicalSubtitleParam = GetGraphicalSubtitleParam(state, encodingOptions, videoCodec); - - args += graphicalSubtitleParam; - - hasCopyTs = graphicalSubtitleParam.IndexOf("copyts", StringComparison.OrdinalIgnoreCase) != -1; - } + hasCopyTs = videoProcessParam.Contains("copyts", StringComparison.OrdinalIgnoreCase); if (state.RunTimeTicks.HasValue && state.BaseRequest.CopyTimestamps) { @@ -4122,12 +5339,12 @@ namespace MediaBrowser.Controller.MediaEncoding if (bitrate.HasValue) { - args += " -ab " + bitrate.Value.ToString(_usCulture); + args += " -ab " + bitrate.Value.ToString(CultureInfo.InvariantCulture); } if (state.OutputAudioSampleRate.HasValue) { - args += " -ar " + state.OutputAudioSampleRate.Value.ToString(_usCulture); + args += " -ar " + state.OutputAudioSampleRate.Value.ToString(CultureInfo.InvariantCulture); } args += GetAudioFilterParam(state, encodingOptions); @@ -4143,12 +5360,12 @@ namespace MediaBrowser.Controller.MediaEncoding if (bitrate.HasValue) { - audioTranscodeParams.Add("-ab " + bitrate.Value.ToString(_usCulture)); + audioTranscodeParams.Add("-ab " + bitrate.Value.ToString(CultureInfo.InvariantCulture)); } if (state.OutputAudioChannels.HasValue) { - audioTranscodeParams.Add("-ac " + state.OutputAudioChannels.Value.ToString(_usCulture)); + audioTranscodeParams.Add("-ac " + state.OutputAudioChannels.Value.ToString(CultureInfo.InvariantCulture)); } // opus will fail on 44100 @@ -4156,7 +5373,7 @@ namespace MediaBrowser.Controller.MediaEncoding { if (state.OutputAudioSampleRate.HasValue) { - audioTranscodeParams.Add("-ar " + state.OutputAudioSampleRate.Value.ToString(_usCulture)); + audioTranscodeParams.Add("-ar " + state.OutputAudioSampleRate.Value.ToString(CultureInfo.InvariantCulture)); } } @@ -4182,41 +5399,5 @@ namespace MediaBrowser.Controller.MediaEncoding { return string.Equals(codec, "copy", StringComparison.OrdinalIgnoreCase); } - - public static bool IsColorDepth10(EncodingJobInfo state) - { - var result = false; - var videoStream = state.VideoStream; - - if (videoStream != null) - { - if (videoStream.BitDepth.HasValue) - { - return videoStream.BitDepth.Value == 10; - } - - if (!string.IsNullOrEmpty(videoStream.PixelFormat)) - { - result = videoStream.PixelFormat.Contains("p10", StringComparison.OrdinalIgnoreCase); - if (result) - { - return true; - } - } - - if (!string.IsNullOrEmpty(videoStream.Profile)) - { - result = videoStream.Profile.Contains("Main 10", StringComparison.OrdinalIgnoreCase) - || videoStream.Profile.Contains("High 10", StringComparison.OrdinalIgnoreCase) - || videoStream.Profile.Contains("Profile 2", StringComparison.OrdinalIgnoreCase); - if (result) - { - return true; - } - } - } - - return result; - } } } diff --git a/MediaBrowser.Controller/MediaEncoding/EncodingJobInfo.cs b/MediaBrowser.Controller/MediaEncoding/EncodingJobInfo.cs index b09b7dba6..c4affa567 100644 --- a/MediaBrowser.Controller/MediaEncoding/EncodingJobInfo.cs +++ b/MediaBrowser.Controller/MediaEncoding/EncodingJobInfo.cs @@ -110,23 +110,7 @@ namespace MediaBrowser.Controller.MediaEncoding public string OutputContainer { get; set; } - public string OutputVideoSync - { - get - { - // For live tv + in progress recordings - if (string.Equals(InputContainer, "mpegts", StringComparison.OrdinalIgnoreCase) - || string.Equals(InputContainer, "ts", StringComparison.OrdinalIgnoreCase)) - { - if (!MediaSource.RunTimeTicks.HasValue) - { - return "cfr"; - } - } - - return "-1"; - } - } + public string OutputVideoSync { get; set; } public string AlbumCoverPath { get; set; } @@ -541,7 +525,12 @@ namespace MediaBrowser.Controller.MediaEncoding return MimeType; } - return MimeTypes.GetMimeType(outputPath, enableStreamDefault); + if (enableStreamDefault) + { + return MimeTypes.GetMimeType(outputPath); + } + + return MimeTypes.GetMimeType(outputPath, null); } public bool DeInterlace(string videoCodec, bool forceDeinterlaceIfSourceIsInterlaced) diff --git a/MediaBrowser.Controller/MediaEncoding/FilterOptionType.cs b/MediaBrowser.Controller/MediaEncoding/FilterOptionType.cs index 7ce707b19..a4869cb67 100644 --- a/MediaBrowser.Controller/MediaEncoding/FilterOptionType.cs +++ b/MediaBrowser.Controller/MediaEncoding/FilterOptionType.cs @@ -18,6 +18,16 @@ namespace MediaBrowser.Controller.MediaEncoding /// <summary> /// The tonemap_opencl_bt2390. /// </summary> - TonemapOpenclBt2390 = 2 + TonemapOpenclBt2390 = 2, + + /// <summary> + /// The overlay_opencl_framesync. + /// </summary> + OverlayOpenclFrameSync = 3, + + /// <summary> + /// The overlay_vaapi_framesync. + /// </summary> + OverlayVaapiFrameSync = 4 } } diff --git a/MediaBrowser.Controller/MediaEncoding/IAttachmentExtractor.cs b/MediaBrowser.Controller/MediaEncoding/IAttachmentExtractor.cs index c38e7ec3b..4e7e26624 100644 --- a/MediaBrowser.Controller/MediaEncoding/IAttachmentExtractor.cs +++ b/MediaBrowser.Controller/MediaEncoding/IAttachmentExtractor.cs @@ -12,7 +12,7 @@ namespace MediaBrowser.Controller.MediaEncoding { public interface IAttachmentExtractor { - Task<(MediaAttachment attachment, Stream stream)> GetAttachment( + Task<(MediaAttachment Attachment, Stream Stream)> GetAttachment( BaseItem item, string mediaSourceId, int attachmentStreamIndex, diff --git a/MediaBrowser.Controller/MediaEncoding/IMediaEncoder.cs b/MediaBrowser.Controller/MediaEncoding/IMediaEncoder.cs index c5522bc3c..fd3eb8105 100644 --- a/MediaBrowser.Controller/MediaEncoding/IMediaEncoder.cs +++ b/MediaBrowser.Controller/MediaEncoding/IMediaEncoder.cs @@ -7,6 +7,7 @@ using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; using MediaBrowser.Model.Dlna; +using MediaBrowser.Model.Drawing; using MediaBrowser.Model.Dto; using MediaBrowser.Model.Entities; using MediaBrowser.Model.MediaInfo; @@ -24,6 +25,30 @@ namespace MediaBrowser.Controller.MediaEncoding /// <value>The encoder path.</value> string EncoderPath { get; } + /// <summary> + /// Gets the version of encoder. + /// </summary> + /// <returns>The version of encoder.</returns> + Version EncoderVersion { get; } + + /// <summary> + /// Gets a value indicating whether the configured Vaapi device is from AMD(radeonsi/r600 Mesa driver). + /// </summary> + /// <value><c>true</c> if the Vaapi device is an AMD(radeonsi/r600 Mesa driver) GPU, <c>false</c> otherwise.</value> + bool IsVaapiDeviceAmd { get; } + + /// <summary> + /// Gets a value indicating whether the configured Vaapi device is from Intel(iHD driver). + /// </summary> + /// <value><c>true</c> if the Vaapi device is an Intel(iHD driver) GPU, <c>false</c> otherwise.</value> + bool IsVaapiDeviceInteliHD { get; } + + /// <summary> + /// Gets a value indicating whether the configured Vaapi device is from Intel(legacy i965 driver). + /// </summary> + /// <value><c>true</c> if the Vaapi device is an Intel(legacy i965 driver) GPU, <c>false</c> otherwise.</value> + bool IsVaapiDeviceInteli965 { get; } + /// <summary> /// Whether given encoder codec is supported. /// </summary> @@ -59,12 +84,6 @@ namespace MediaBrowser.Controller.MediaEncoding /// <returns><c>true</c> if the filter is supported, <c>false</c> otherwise.</returns> bool SupportsFilterWithOption(FilterOptionType option); - /// <summary> - /// Get the version of media encoder. - /// </summary> - /// <returns>The version of media encoder.</returns> - Version GetMediaEncoderVersion(); - /// <summary> /// Extracts the audio image. /// </summary> @@ -95,35 +114,10 @@ namespace MediaBrowser.Controller.MediaEncoding /// <param name="mediaSource">Media source information.</param> /// <param name="imageStream">Media stream information.</param> /// <param name="imageStreamIndex">Index of the stream to extract from.</param> + /// <param name="targetFormat">The format of the file to write.</param> /// <param name="cancellationToken">CancellationToken to use for operation.</param> /// <returns>Location of video image.</returns> - Task<string> ExtractVideoImage(string inputFile, string container, MediaSourceInfo mediaSource, MediaStream imageStream, int? imageStreamIndex, CancellationToken cancellationToken); - - /// <summary> - /// Extracts the video images on interval. - /// </summary> - /// <param name="inputFile">Input file.</param> - /// <param name="container">Video container type.</param> - /// <param name="videoStream">Media stream information.</param> - /// <param name="mediaSource">Media source information.</param> - /// <param name="threedFormat">Video 3D format.</param> - /// <param name="interval">Time interval.</param> - /// <param name="targetDirectory">Directory to write images.</param> - /// <param name="filenamePrefix">Filename prefix to use.</param> - /// <param name="maxWidth">Maximum width of image.</param> - /// <param name="cancellationToken">CancellationToken to use for operation.</param> - /// <returns>A task.</returns> - Task ExtractVideoImagesOnInterval( - string inputFile, - string container, - MediaStream videoStream, - MediaSourceInfo mediaSource, - Video3DFormat? threedFormat, - TimeSpan interval, - string targetDirectory, - string filenamePrefix, - int? maxWidth, - CancellationToken cancellationToken); + Task<string> ExtractVideoImage(string inputFile, string container, MediaSourceInfo mediaSource, MediaStream imageStream, int? imageStreamIndex, ImageFormat? targetFormat, CancellationToken cancellationToken); /// <summary> /// Gets the media info. diff --git a/MediaBrowser.Controller/MediaEncoding/JobLogger.cs b/MediaBrowser.Controller/MediaEncoding/JobLogger.cs index aa5e2c403..8b2837ee3 100644 --- a/MediaBrowser.Controller/MediaEncoding/JobLogger.cs +++ b/MediaBrowser.Controller/MediaEncoding/JobLogger.cs @@ -13,7 +13,6 @@ namespace MediaBrowser.Controller.MediaEncoding { public class JobLogger { - private static readonly CultureInfo _usCulture = CultureInfo.ReadOnly(new CultureInfo("en-US")); private readonly ILogger _logger; public JobLogger(ILogger logger) @@ -42,7 +41,7 @@ namespace MediaBrowser.Controller.MediaEncoding break; } - await target.WriteAsync(bytes, 0, bytes.Length).ConfigureAwait(false); + await target.WriteAsync(bytes).ConfigureAwait(false); // Check again, the stream could have been closed if (!target.CanWrite) @@ -87,7 +86,7 @@ namespace MediaBrowser.Controller.MediaEncoding { var rate = parts[i + 1]; - if (float.TryParse(rate, NumberStyles.Any, _usCulture, out var val)) + if (float.TryParse(rate, NumberStyles.Any, CultureInfo.InvariantCulture, out var val)) { framerate = val; } @@ -96,7 +95,7 @@ namespace MediaBrowser.Controller.MediaEncoding { var rate = part.Split('=', 2)[^1]; - if (float.TryParse(rate, NumberStyles.Any, _usCulture, out var val)) + if (float.TryParse(rate, NumberStyles.Any, CultureInfo.InvariantCulture, out var val)) { framerate = val; } @@ -106,7 +105,7 @@ namespace MediaBrowser.Controller.MediaEncoding { var time = part.Split('=', 2)[^1]; - if (TimeSpan.TryParse(time, _usCulture, out var val)) + if (TimeSpan.TryParse(time, CultureInfo.InvariantCulture, out var val)) { var currentMs = startMs + val.TotalMilliseconds; @@ -120,7 +119,7 @@ namespace MediaBrowser.Controller.MediaEncoding var size = part.Split('=', 2)[^1]; int? scale = null; - if (size.IndexOf("kb", StringComparison.OrdinalIgnoreCase) != -1) + if (size.Contains("kb", StringComparison.OrdinalIgnoreCase)) { scale = 1024; size = size.Replace("kb", string.Empty, StringComparison.OrdinalIgnoreCase); @@ -128,7 +127,7 @@ namespace MediaBrowser.Controller.MediaEncoding if (scale.HasValue) { - if (long.TryParse(size, NumberStyles.Any, _usCulture, out var val)) + if (long.TryParse(size, NumberStyles.Any, CultureInfo.InvariantCulture, out var val)) { bytesTranscoded = val * scale.Value; } @@ -139,7 +138,7 @@ namespace MediaBrowser.Controller.MediaEncoding var rate = part.Split('=', 2)[^1]; int? scale = null; - if (rate.IndexOf("kbits/s", StringComparison.OrdinalIgnoreCase) != -1) + if (rate.Contains("kbits/s", StringComparison.OrdinalIgnoreCase)) { scale = 1024; rate = rate.Replace("kbits/s", string.Empty, StringComparison.OrdinalIgnoreCase); @@ -147,7 +146,7 @@ namespace MediaBrowser.Controller.MediaEncoding if (scale.HasValue) { - if (float.TryParse(rate, NumberStyles.Any, _usCulture, out var val)) + if (float.TryParse(rate, NumberStyles.Any, CultureInfo.InvariantCulture, out var val)) { bitRate = (int)Math.Ceiling(val * scale.Value); } diff --git a/MediaBrowser.Controller/MediaEncoding/TranscodingJobType.cs b/MediaBrowser.Controller/MediaEncoding/TranscodingJobType.cs index 66b628371..c1bb387e1 100644 --- a/MediaBrowser.Controller/MediaEncoding/TranscodingJobType.cs +++ b/MediaBrowser.Controller/MediaEncoding/TranscodingJobType.cs @@ -20,4 +20,4 @@ /// </summary> Dash } -} \ No newline at end of file +} diff --git a/MediaBrowser.Controller/Net/BasePeriodicWebSocketListener.cs b/MediaBrowser.Controller/Net/BasePeriodicWebSocketListener.cs index 0813a8e7d..eadc09fd4 100644 --- a/MediaBrowser.Controller/Net/BasePeriodicWebSocketListener.cs +++ b/MediaBrowser.Controller/Net/BasePeriodicWebSocketListener.cs @@ -11,6 +11,7 @@ using System.Threading; using System.Threading.Tasks; using MediaBrowser.Model.Net; using MediaBrowser.Model.Session; +using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; namespace MediaBrowser.Controller.Net @@ -95,7 +96,7 @@ namespace MediaBrowser.Controller.Net } /// <inheritdoc /> - public Task ProcessWebSocketConnectedAsync(IWebSocketConnection connection) => Task.CompletedTask; + public Task ProcessWebSocketConnectedAsync(IWebSocketConnection connection, HttpContext httpContext) => Task.CompletedTask; /// <summary> /// Starts sending messages over a web socket. diff --git a/MediaBrowser.Controller/Net/IWebSocketConnection.cs b/MediaBrowser.Controller/Net/IWebSocketConnection.cs index c8c5caf80..2c6483ae2 100644 --- a/MediaBrowser.Controller/Net/IWebSocketConnection.cs +++ b/MediaBrowser.Controller/Net/IWebSocketConnection.cs @@ -29,12 +29,6 @@ namespace MediaBrowser.Controller.Net /// <value>The date of last Keeplive received.</value> DateTime LastKeepAliveDate { get; set; } - /// <summary> - /// Gets the query string. - /// </summary> - /// <value>The query string.</value> - IQueryCollection QueryString { get; } - /// <summary> /// Gets or sets the receive action. /// </summary> diff --git a/MediaBrowser.Controller/Net/IWebSocketListener.cs b/MediaBrowser.Controller/Net/IWebSocketListener.cs index f1a75d518..672bb8cbf 100644 --- a/MediaBrowser.Controller/Net/IWebSocketListener.cs +++ b/MediaBrowser.Controller/Net/IWebSocketListener.cs @@ -1,4 +1,5 @@ using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; namespace MediaBrowser.Controller.Net { @@ -18,7 +19,8 @@ namespace MediaBrowser.Controller.Net /// Processes a new web socket connection. /// </summary> /// <param name="connection">An instance of the <see cref="IWebSocketConnection"/> interface.</param> + /// <param name="httpContext">The current http context.</param> /// <returns>Task.</returns> - Task ProcessWebSocketConnectedAsync(IWebSocketConnection connection); + Task ProcessWebSocketConnectedAsync(IWebSocketConnection connection, HttpContext httpContext); } } diff --git a/MediaBrowser.Controller/Net/WebSocketListenerState.cs b/MediaBrowser.Controller/Net/WebSocketListenerState.cs index 70604d60a..2410801d6 100644 --- a/MediaBrowser.Controller/Net/WebSocketListenerState.cs +++ b/MediaBrowser.Controller/Net/WebSocketListenerState.cs @@ -14,4 +14,4 @@ namespace MediaBrowser.Controller.Net public long IntervalMs { get; set; } } -} \ No newline at end of file +} diff --git a/MediaBrowser.Controller/Persistence/IItemRepository.cs b/MediaBrowser.Controller/Persistence/IItemRepository.cs index a084f9196..837bf0bb2 100644 --- a/MediaBrowser.Controller/Persistence/IItemRepository.cs +++ b/MediaBrowser.Controller/Persistence/IItemRepository.cs @@ -161,17 +161,17 @@ namespace MediaBrowser.Controller.Persistence int GetCount(InternalItemsQuery query); - QueryResult<(BaseItem, ItemCounts)> GetGenres(InternalItemsQuery query); + QueryResult<(BaseItem Item, ItemCounts ItemCounts)> GetGenres(InternalItemsQuery query); - QueryResult<(BaseItem, ItemCounts)> GetMusicGenres(InternalItemsQuery query); + QueryResult<(BaseItem Item, ItemCounts ItemCounts)> GetMusicGenres(InternalItemsQuery query); - QueryResult<(BaseItem, ItemCounts)> GetStudios(InternalItemsQuery query); + QueryResult<(BaseItem Item, ItemCounts ItemCounts)> GetStudios(InternalItemsQuery query); - QueryResult<(BaseItem, ItemCounts)> GetArtists(InternalItemsQuery query); + QueryResult<(BaseItem Item, ItemCounts ItemCounts)> GetArtists(InternalItemsQuery query); - QueryResult<(BaseItem, ItemCounts)> GetAlbumArtists(InternalItemsQuery query); + QueryResult<(BaseItem Item, ItemCounts ItemCounts)> GetAlbumArtists(InternalItemsQuery query); - QueryResult<(BaseItem, ItemCounts)> GetAllArtists(InternalItemsQuery query); + QueryResult<(BaseItem Item, ItemCounts ItemCounts)> GetAllArtists(InternalItemsQuery query); List<string> GetMusicGenreNames(); diff --git a/MediaBrowser.Controller/Playlists/Playlist.cs b/MediaBrowser.Controller/Playlists/Playlist.cs index 5e671a725..89f3bdf46 100644 --- a/MediaBrowser.Controller/Playlists/Playlist.cs +++ b/MediaBrowser.Controller/Playlists/Playlist.cs @@ -189,7 +189,7 @@ namespace MediaBrowser.Controller.Playlists return LibraryManager.GetItemList(new InternalItemsQuery(user) { Recursive = true, - IncludeItemTypes = new[] { nameof(Audio) }, + IncludeItemTypes = new[] { BaseItemKind.Audio }, GenreIds = new[] { musicGenre.Id }, OrderBy = new[] { (ItemSortBy.AlbumArtist, SortOrder.Ascending), (ItemSortBy.Album, SortOrder.Ascending), (ItemSortBy.SortName, SortOrder.Ascending) }, DtoOptions = options @@ -201,7 +201,7 @@ namespace MediaBrowser.Controller.Playlists return LibraryManager.GetItemList(new InternalItemsQuery(user) { Recursive = true, - IncludeItemTypes = new[] { nameof(Audio) }, + IncludeItemTypes = new[] { BaseItemKind.Audio }, ArtistIds = new[] { musicArtist.Id }, OrderBy = new[] { (ItemSortBy.AlbumArtist, SortOrder.Ascending), (ItemSortBy.Album, SortOrder.Ascending), (ItemSortBy.SortName, SortOrder.Ascending) }, DtoOptions = options diff --git a/MediaBrowser.Controller/Providers/DirectoryService.cs b/MediaBrowser.Controller/Providers/DirectoryService.cs index b31270270..d4de97651 100644 --- a/MediaBrowser.Controller/Providers/DirectoryService.cs +++ b/MediaBrowser.Controller/Providers/DirectoryService.cs @@ -12,11 +12,11 @@ namespace MediaBrowser.Controller.Providers { private readonly IFileSystem _fileSystem; - private readonly ConcurrentDictionary<string, FileSystemMetadata[]> _cache = new (StringComparer.Ordinal); + private readonly ConcurrentDictionary<string, FileSystemMetadata[]> _cache = new(StringComparer.Ordinal); - private readonly ConcurrentDictionary<string, FileSystemMetadata> _fileCache = new (StringComparer.Ordinal); + private readonly ConcurrentDictionary<string, FileSystemMetadata> _fileCache = new(StringComparer.Ordinal); - private readonly ConcurrentDictionary<string, List<string>> _filePathCache = new (StringComparer.Ordinal); + private readonly ConcurrentDictionary<string, List<string>> _filePathCache = new(StringComparer.Ordinal); public DirectoryService(IFileSystem fileSystem) { @@ -25,7 +25,7 @@ namespace MediaBrowser.Controller.Providers public FileSystemMetadata[] GetFileSystemEntries(string path) { - return _cache.GetOrAdd(path, (p, fileSystem) => fileSystem.GetFileSystemEntries(p).ToArray(), _fileSystem); + return _cache.GetOrAdd(path, static (p, fileSystem) => fileSystem.GetFileSystemEntries(p).ToArray(), _fileSystem); } public List<FileSystemMetadata> GetFiles(string path) @@ -69,7 +69,7 @@ namespace MediaBrowser.Controller.Providers _filePathCache.TryRemove(path, out _); } - var filePaths = _filePathCache.GetOrAdd(path, (p, fileSystem) => fileSystem.GetFilePaths(p).ToList(), _fileSystem); + var filePaths = _filePathCache.GetOrAdd(path, static (p, fileSystem) => fileSystem.GetFilePaths(p).ToList(), _fileSystem); if (sort) { diff --git a/MediaBrowser.Controller/Providers/IExternalId.cs b/MediaBrowser.Controller/Providers/IExternalId.cs index e2dbef2bc..0d847520d 100644 --- a/MediaBrowser.Controller/Providers/IExternalId.cs +++ b/MediaBrowser.Controller/Providers/IExternalId.cs @@ -1,5 +1,3 @@ -#nullable disable - using MediaBrowser.Model.Entities; using MediaBrowser.Model.Providers; @@ -35,7 +33,7 @@ namespace MediaBrowser.Controller.Providers /// <summary> /// Gets the URL format string for this id. /// </summary> - string UrlFormatString { get; } + string? UrlFormatString { get; } /// <summary> /// Determines whether this id supports a given item type. diff --git a/MediaBrowser.Controller/Providers/ImageRefreshOptions.cs b/MediaBrowser.Controller/Providers/ImageRefreshOptions.cs index 2ac4c728b..a9d16a49e 100644 --- a/MediaBrowser.Controller/Providers/ImageRefreshOptions.cs +++ b/MediaBrowser.Controller/Providers/ImageRefreshOptions.cs @@ -1,5 +1,3 @@ -#nullable disable - #pragma warning disable CA1819, CS1591 using System; @@ -29,6 +27,11 @@ namespace MediaBrowser.Controller.Providers public bool IsAutomated { get; set; } + /// <summary> + /// Gets or sets a value indicating whether old metadata should be removed if it isn't replaced. + /// </summary> + public bool RemoveOldMetadata { get; set; } + public bool IsReplacingImage(ImageType type) { return ImageRefreshMode == MetadataRefreshMode.FullRefresh && diff --git a/MediaBrowser.Controller/Providers/ItemInfo.cs b/MediaBrowser.Controller/Providers/ItemInfo.cs index b8dd416a2..3a97127ea 100644 --- a/MediaBrowser.Controller/Providers/ItemInfo.cs +++ b/MediaBrowser.Controller/Providers/ItemInfo.cs @@ -1,5 +1,3 @@ -#nullable disable - #pragma warning disable CS1591 using System; diff --git a/MediaBrowser.Controller/Providers/MetadataRefreshOptions.cs b/MediaBrowser.Controller/Providers/MetadataRefreshOptions.cs index a42c7f8b5..a38bbaf69 100644 --- a/MediaBrowser.Controller/Providers/MetadataRefreshOptions.cs +++ b/MediaBrowser.Controller/Providers/MetadataRefreshOptions.cs @@ -4,6 +4,7 @@ using System; using System.Linq; +using Jellyfin.Extensions; using MediaBrowser.Controller.Entities; using MediaBrowser.Model.Providers; @@ -29,6 +30,7 @@ namespace MediaBrowser.Controller.Providers ReplaceAllImages = copy.ReplaceAllImages; ReplaceImages = copy.ReplaceImages; SearchResult = copy.SearchResult; + RemoveOldMetadata = copy.RemoveOldMetadata; if (copy.RefreshPaths != null && copy.RefreshPaths.Length > 0) { @@ -58,7 +60,7 @@ namespace MediaBrowser.Controller.Providers { if (RefreshPaths != null && RefreshPaths.Length > 0) { - return RefreshPaths.Contains(item.Path ?? string.Empty, StringComparer.OrdinalIgnoreCase); + return RefreshPaths.Contains(item.Path ?? string.Empty, StringComparison.OrdinalIgnoreCase); } return true; diff --git a/MediaBrowser.Controller/Providers/MetadataResult.cs b/MediaBrowser.Controller/Providers/MetadataResult.cs index 2085ae4ad..58a0fa2a9 100644 --- a/MediaBrowser.Controller/Providers/MetadataResult.cs +++ b/MediaBrowser.Controller/Providers/MetadataResult.cs @@ -14,7 +14,7 @@ namespace MediaBrowser.Controller.Providers { // Images aren't always used so the allocation is a waste a lot of the time private List<LocalImageInfo> _images; - private List<(string url, ImageType type)> _remoteImages; + private List<(string Url, ImageType Type)> _remoteImages; public MetadataResult() { @@ -27,9 +27,9 @@ namespace MediaBrowser.Controller.Providers set => _images = value; } - public List<(string url, ImageType type)> RemoteImages + public List<(string Url, ImageType Type)> RemoteImages { - get => _remoteImages ??= new List<(string url, ImageType type)>(); + get => _remoteImages ??= new List<(string Url, ImageType Type)>(); set => _remoteImages = value; } diff --git a/MediaBrowser.Controller/Providers/RefreshPriority.cs b/MediaBrowser.Controller/Providers/RefreshPriority.cs index 3619f679d..e4c39cea1 100644 --- a/MediaBrowser.Controller/Providers/RefreshPriority.cs +++ b/MediaBrowser.Controller/Providers/RefreshPriority.cs @@ -20,4 +20,4 @@ /// </summary> Low = 2 } -} \ No newline at end of file +} diff --git a/MediaBrowser.Controller/Session/ISessionManager.cs b/MediaBrowser.Controller/Session/ISessionManager.cs index 1f34d2bf1..c86556095 100644 --- a/MediaBrowser.Controller/Session/ISessionManager.cs +++ b/MediaBrowser.Controller/Session/ISessionManager.cs @@ -157,21 +157,21 @@ namespace MediaBrowser.Controller.Session /// <summary> /// Sends a SyncPlayCommand to a session. /// </summary> - /// <param name="session">The session.</param> + /// <param name="sessionId">The identifier of the session.</param> /// <param name="command">The command.</param> /// <param name="cancellationToken">The cancellation token.</param> /// <returns>Task.</returns> - Task SendSyncPlayCommand(SessionInfo session, SendCommand command, CancellationToken cancellationToken); + Task SendSyncPlayCommand(string sessionId, SendCommand command, CancellationToken cancellationToken); /// <summary> /// Sends a SyncPlayGroupUpdate to a session. /// </summary> - /// <param name="session">The session.</param> + /// <param name="sessionId">The identifier of the session.</param> /// <param name="command">The group update.</param> /// <param name="cancellationToken">The cancellation token.</param> /// <typeparam name="T">Type of group.</typeparam> /// <returns>Task.</returns> - Task SendSyncPlayGroupUpdate<T>(SessionInfo session, GroupUpdate<T> command, CancellationToken cancellationToken); + Task SendSyncPlayGroupUpdate<T>(string sessionId, GroupUpdate<T> command, CancellationToken cancellationToken); /// <summary> /// Sends the browse command. diff --git a/MediaBrowser.Controller/Subtitles/ISubtitleManager.cs b/MediaBrowser.Controller/Subtitles/ISubtitleManager.cs index 3330dd540..52aa44024 100644 --- a/MediaBrowser.Controller/Subtitles/ISubtitleManager.cs +++ b/MediaBrowser.Controller/Subtitles/ISubtitleManager.cs @@ -31,12 +31,14 @@ namespace MediaBrowser.Controller.Subtitles /// <param name="video">The video.</param> /// <param name="language">Subtitle language.</param> /// <param name="isPerfectMatch">Require perfect match.</param> + /// <param name="isAutomated">Request is automated.</param> /// <param name="cancellationToken">CancellationToken to use for the operation.</param> /// <returns>Subtitles, wrapped in task.</returns> Task<RemoteSubtitleInfo[]> SearchSubtitles( Video video, string language, bool? isPerfectMatch, + bool isAutomated, CancellationToken cancellationToken); /// <summary> diff --git a/MediaBrowser.Controller/Subtitles/SubtitleSearchRequest.cs b/MediaBrowser.Controller/Subtitles/SubtitleSearchRequest.cs index 767d87d46..ef052237a 100644 --- a/MediaBrowser.Controller/Subtitles/SubtitleSearchRequest.cs +++ b/MediaBrowser.Controller/Subtitles/SubtitleSearchRequest.cs @@ -51,5 +51,7 @@ namespace MediaBrowser.Controller.Subtitles public string[] DisabledSubtitleFetchers { get; set; } public string[] SubtitleFetcherOrder { get; set; } + + public bool IsAutomated { get; set; } } } diff --git a/MediaBrowser.Controller/SyncPlay/GroupMember.cs b/MediaBrowser.Controller/SyncPlay/GroupMember.cs index 7e7e759a5..b973672c4 100644 --- a/MediaBrowser.Controller/SyncPlay/GroupMember.cs +++ b/MediaBrowser.Controller/SyncPlay/GroupMember.cs @@ -1,5 +1,6 @@ #nullable disable +using System; using MediaBrowser.Controller.Session; namespace MediaBrowser.Controller.SyncPlay @@ -15,14 +16,28 @@ namespace MediaBrowser.Controller.SyncPlay /// <param name="session">The session.</param> public GroupMember(SessionInfo session) { - Session = session; + SessionId = session.Id; + UserId = session.UserId; + UserName = session.UserName; } /// <summary> - /// Gets the session. + /// Gets the identifier of the session. /// </summary> - /// <value>The session.</value> - public SessionInfo Session { get; } + /// <value>The session identifier.</value> + public string SessionId { get; } + + /// <summary> + /// Gets the identifier of the user. + /// </summary> + /// <value>The user identifier.</value> + public Guid UserId { get; } + + /// <summary> + /// Gets the username. + /// </summary> + /// <value>The username.</value> + public string UserName { get; } /// <summary> /// Gets or sets the ping, in milliseconds. diff --git a/MediaBrowser.Controller/SyncPlay/GroupStates/PausedGroupState.cs b/MediaBrowser.Controller/SyncPlay/GroupStates/PausedGroupState.cs index b9786ddb0..2523ec709 100644 --- a/MediaBrowser.Controller/SyncPlay/GroupStates/PausedGroupState.cs +++ b/MediaBrowser.Controller/SyncPlay/GroupStates/PausedGroupState.cs @@ -17,11 +17,6 @@ namespace MediaBrowser.Controller.SyncPlay.GroupStates /// </remarks> public class PausedGroupState : AbstractGroupState { - /// <summary> - /// The logger. - /// </summary> - private readonly ILogger<PausedGroupState> _logger; - /// <summary> /// Initializes a new instance of the <see cref="PausedGroupState"/> class. /// </summary> @@ -29,7 +24,6 @@ namespace MediaBrowser.Controller.SyncPlay.GroupStates public PausedGroupState(ILoggerFactory loggerFactory) : base(loggerFactory) { - _logger = LoggerFactory.CreateLogger<PausedGroupState>(); } /// <inheritdoc /> diff --git a/MediaBrowser.Controller/SyncPlay/GroupStates/PlayingGroupState.cs b/MediaBrowser.Controller/SyncPlay/GroupStates/PlayingGroupState.cs index cb1cadf0b..4f29ca1c6 100644 --- a/MediaBrowser.Controller/SyncPlay/GroupStates/PlayingGroupState.cs +++ b/MediaBrowser.Controller/SyncPlay/GroupStates/PlayingGroupState.cs @@ -17,11 +17,6 @@ namespace MediaBrowser.Controller.SyncPlay.GroupStates /// </remarks> public class PlayingGroupState : AbstractGroupState { - /// <summary> - /// The logger. - /// </summary> - private readonly ILogger<PlayingGroupState> _logger; - /// <summary> /// Initializes a new instance of the <see cref="PlayingGroupState"/> class. /// </summary> @@ -29,7 +24,6 @@ namespace MediaBrowser.Controller.SyncPlay.GroupStates public PlayingGroupState(ILoggerFactory loggerFactory) : base(loggerFactory) { - _logger = LoggerFactory.CreateLogger<PlayingGroupState>(); } /// <inheritdoc /> diff --git a/MediaBrowser.Controller/SyncPlay/PlaybackRequests/RemoveFromPlaylistGroupRequest.cs b/MediaBrowser.Controller/SyncPlay/PlaybackRequests/RemoveFromPlaylistGroupRequest.cs index 856f175df..2f38d6adc 100644 --- a/MediaBrowser.Controller/SyncPlay/PlaybackRequests/RemoveFromPlaylistGroupRequest.cs +++ b/MediaBrowser.Controller/SyncPlay/PlaybackRequests/RemoveFromPlaylistGroupRequest.cs @@ -19,7 +19,6 @@ namespace MediaBrowser.Controller.SyncPlay.PlaybackRequests /// <param name="items">The playlist ids of the items to remove.</param> /// <param name="clearPlaylist">Whether to clear the entire playlist. The items list will be ignored.</param> /// <param name="clearPlayingItem">Whether to remove the playing item as well. Used only when clearing the playlist.</param> - public RemoveFromPlaylistGroupRequest(IReadOnlyList<Guid> items, bool clearPlaylist = false, bool clearPlayingItem = false) { PlaylistItemIds = items ?? Array.Empty<Guid>(); diff --git a/MediaBrowser.Controller/SyncPlay/Queue/PlayQueueManager.cs b/MediaBrowser.Controller/SyncPlay/Queue/PlayQueueManager.cs index b8ae9f3ff..f49876cca 100644 --- a/MediaBrowser.Controller/SyncPlay/Queue/PlayQueueManager.cs +++ b/MediaBrowser.Controller/SyncPlay/Queue/PlayQueueManager.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; using System.Linq; +using Jellyfin.Extensions; using MediaBrowser.Model.SyncPlay; namespace MediaBrowser.Controller.SyncPlay.Queue @@ -19,10 +20,16 @@ namespace MediaBrowser.Controller.SyncPlay.Queue private const int NoPlayingItemIndex = -1; /// <summary> - /// Random number generator used to shuffle lists. + /// The sorted playlist. /// </summary> - /// <value>The random number generator.</value> - private readonly Random _randomNumberGenerator = new Random(); + /// <value>The sorted playlist, or play queue of the group.</value> + private List<QueueItem> _sortedPlaylist = new List<QueueItem>(); + + /// <summary> + /// The shuffled playlist. + /// </summary> + /// <value>The shuffled playlist, or play queue of the group.</value> + private List<QueueItem> _shuffledPlaylist = new List<QueueItem>(); /// <summary> /// Initializes a new instance of the <see cref="PlayQueueManager" /> class. @@ -56,18 +63,6 @@ namespace MediaBrowser.Controller.SyncPlay.Queue /// <value>The repeat mode.</value> public GroupRepeatMode RepeatMode { get; private set; } = GroupRepeatMode.RepeatNone; - /// <summary> - /// Gets or sets the sorted playlist. - /// </summary> - /// <value>The sorted playlist, or play queue of the group.</value> - private List<QueueItem> SortedPlaylist { get; set; } = new List<QueueItem>(); - - /// <summary> - /// Gets or sets the shuffled playlist. - /// </summary> - /// <value>The shuffled playlist, or play queue of the group.</value> - private List<QueueItem> ShuffledPlaylist { get; set; } = new List<QueueItem>(); - /// <summary> /// Checks if an item is playing. /// </summary> @@ -92,14 +87,14 @@ namespace MediaBrowser.Controller.SyncPlay.Queue /// <param name="items">The new items of the playlist.</param> public void SetPlaylist(IReadOnlyList<Guid> items) { - SortedPlaylist.Clear(); - ShuffledPlaylist.Clear(); + _sortedPlaylist.Clear(); + _shuffledPlaylist.Clear(); - SortedPlaylist = CreateQueueItemsFromArray(items); + _sortedPlaylist = CreateQueueItemsFromArray(items); if (ShuffleMode.Equals(GroupShuffleMode.Shuffle)) { - ShuffledPlaylist = new List<QueueItem>(SortedPlaylist); - Shuffle(ShuffledPlaylist); + _shuffledPlaylist = new List<QueueItem>(_sortedPlaylist); + _shuffledPlaylist.Shuffle(); } PlayingItemIndex = NoPlayingItemIndex; @@ -114,10 +109,10 @@ namespace MediaBrowser.Controller.SyncPlay.Queue { var newItems = CreateQueueItemsFromArray(items); - SortedPlaylist.AddRange(newItems); + _sortedPlaylist.AddRange(newItems); if (ShuffleMode.Equals(GroupShuffleMode.Shuffle)) { - ShuffledPlaylist.AddRange(newItems); + _shuffledPlaylist.AddRange(newItems); } LastChange = DateTime.UtcNow; @@ -130,26 +125,26 @@ namespace MediaBrowser.Controller.SyncPlay.Queue { if (PlayingItemIndex == NoPlayingItemIndex) { - ShuffledPlaylist = new List<QueueItem>(SortedPlaylist); - Shuffle(ShuffledPlaylist); + _shuffledPlaylist = new List<QueueItem>(_sortedPlaylist); + _shuffledPlaylist.Shuffle(); } else if (ShuffleMode.Equals(GroupShuffleMode.Sorted)) { // First time shuffle. - var playingItem = SortedPlaylist[PlayingItemIndex]; - ShuffledPlaylist = new List<QueueItem>(SortedPlaylist); - ShuffledPlaylist.RemoveAt(PlayingItemIndex); - Shuffle(ShuffledPlaylist); - ShuffledPlaylist.Insert(0, playingItem); + var playingItem = _sortedPlaylist[PlayingItemIndex]; + _shuffledPlaylist = new List<QueueItem>(_sortedPlaylist); + _shuffledPlaylist.RemoveAt(PlayingItemIndex); + _shuffledPlaylist.Shuffle(); + _shuffledPlaylist.Insert(0, playingItem); PlayingItemIndex = 0; } else { // Re-shuffle playlist. - var playingItem = ShuffledPlaylist[PlayingItemIndex]; - ShuffledPlaylist.RemoveAt(PlayingItemIndex); - Shuffle(ShuffledPlaylist); - ShuffledPlaylist.Insert(0, playingItem); + var playingItem = _shuffledPlaylist[PlayingItemIndex]; + _shuffledPlaylist.RemoveAt(PlayingItemIndex); + _shuffledPlaylist.Shuffle(); + _shuffledPlaylist.Insert(0, playingItem); PlayingItemIndex = 0; } @@ -164,11 +159,11 @@ namespace MediaBrowser.Controller.SyncPlay.Queue { if (PlayingItemIndex != NoPlayingItemIndex) { - var playingItem = ShuffledPlaylist[PlayingItemIndex]; - PlayingItemIndex = SortedPlaylist.IndexOf(playingItem); + var playingItem = _shuffledPlaylist[PlayingItemIndex]; + PlayingItemIndex = _sortedPlaylist.IndexOf(playingItem); } - ShuffledPlaylist.Clear(); + _shuffledPlaylist.Clear(); ShuffleMode = GroupShuffleMode.Sorted; LastChange = DateTime.UtcNow; @@ -181,16 +176,16 @@ namespace MediaBrowser.Controller.SyncPlay.Queue public void ClearPlaylist(bool clearPlayingItem) { var playingItem = GetPlayingItem(); - SortedPlaylist.Clear(); - ShuffledPlaylist.Clear(); + _sortedPlaylist.Clear(); + _shuffledPlaylist.Clear(); LastChange = DateTime.UtcNow; if (!clearPlayingItem && playingItem != null) { - SortedPlaylist.Add(playingItem); + _sortedPlaylist.Add(playingItem); if (ShuffleMode.Equals(GroupShuffleMode.Shuffle)) { - ShuffledPlaylist.Add(playingItem); + _shuffledPlaylist.Add(playingItem); } PlayingItemIndex = 0; @@ -212,14 +207,14 @@ namespace MediaBrowser.Controller.SyncPlay.Queue if (ShuffleMode.Equals(GroupShuffleMode.Shuffle)) { var playingItem = GetPlayingItem(); - var sortedPlayingItemIndex = SortedPlaylist.IndexOf(playingItem); + var sortedPlayingItemIndex = _sortedPlaylist.IndexOf(playingItem); // Append items to sorted and shuffled playlist as they are. - SortedPlaylist.InsertRange(sortedPlayingItemIndex + 1, newItems); - ShuffledPlaylist.InsertRange(PlayingItemIndex + 1, newItems); + _sortedPlaylist.InsertRange(sortedPlayingItemIndex + 1, newItems); + _shuffledPlaylist.InsertRange(PlayingItemIndex + 1, newItems); } else { - SortedPlaylist.InsertRange(PlayingItemIndex + 1, newItems); + _sortedPlaylist.InsertRange(PlayingItemIndex + 1, newItems); } LastChange = DateTime.UtcNow; @@ -298,8 +293,8 @@ namespace MediaBrowser.Controller.SyncPlay.Queue { var playingItem = GetPlayingItem(); - SortedPlaylist.RemoveAll(item => playlistItemIds.Contains(item.PlaylistItemId)); - ShuffledPlaylist.RemoveAll(item => playlistItemIds.Contains(item.PlaylistItemId)); + _sortedPlaylist.RemoveAll(item => playlistItemIds.Contains(item.PlaylistItemId)); + _shuffledPlaylist.RemoveAll(item => playlistItemIds.Contains(item.PlaylistItemId)); LastChange = DateTime.UtcNow; @@ -313,7 +308,7 @@ namespace MediaBrowser.Controller.SyncPlay.Queue { // Was first element, picking next if available. // Default to no playing item otherwise. - PlayingItemIndex = SortedPlaylist.Count > 0 ? 0 : NoPlayingItemIndex; + PlayingItemIndex = _sortedPlaylist.Count > 0 ? 0 : NoPlayingItemIndex; } return true; @@ -363,8 +358,8 @@ namespace MediaBrowser.Controller.SyncPlay.Queue /// </summary> public void Reset() { - SortedPlaylist.Clear(); - ShuffledPlaylist.Clear(); + _sortedPlaylist.Clear(); + _shuffledPlaylist.Clear(); PlayingItemIndex = NoPlayingItemIndex; ShuffleMode = GroupShuffleMode.Sorted; RepeatMode = GroupRepeatMode.RepeatNone; @@ -460,7 +455,7 @@ namespace MediaBrowser.Controller.SyncPlay.Queue } PlayingItemIndex++; - if (PlayingItemIndex >= SortedPlaylist.Count) + if (PlayingItemIndex >= _sortedPlaylist.Count) { if (RepeatMode.Equals(GroupRepeatMode.RepeatAll)) { @@ -468,7 +463,7 @@ namespace MediaBrowser.Controller.SyncPlay.Queue } else { - PlayingItemIndex = SortedPlaylist.Count - 1; + PlayingItemIndex = _sortedPlaylist.Count - 1; return false; } } @@ -494,7 +489,7 @@ namespace MediaBrowser.Controller.SyncPlay.Queue { if (RepeatMode.Equals(GroupRepeatMode.RepeatAll)) { - PlayingItemIndex = SortedPlaylist.Count - 1; + PlayingItemIndex = _sortedPlaylist.Count - 1; } else { @@ -507,23 +502,6 @@ namespace MediaBrowser.Controller.SyncPlay.Queue return true; } - /// <summary> - /// Shuffles a given list. - /// </summary> - /// <param name="list">The list to shuffle.</param> - private void Shuffle<T>(IList<T> list) - { - int n = list.Count; - while (n > 1) - { - n--; - int k = _randomNumberGenerator.Next(n + 1); - T value = list[k]; - list[k] = list[n]; - list[n] = value; - } - } - /// <summary> /// Creates a list from the array of items. Each item is given an unique playlist identifier. /// </summary> @@ -548,11 +526,11 @@ namespace MediaBrowser.Controller.SyncPlay.Queue { if (ShuffleMode.Equals(GroupShuffleMode.Shuffle)) { - return ShuffledPlaylist; + return _shuffledPlaylist; } else { - return SortedPlaylist; + return _sortedPlaylist; } } @@ -568,11 +546,11 @@ namespace MediaBrowser.Controller.SyncPlay.Queue } else if (ShuffleMode.Equals(GroupShuffleMode.Shuffle)) { - return ShuffledPlaylist[PlayingItemIndex]; + return _shuffledPlaylist[PlayingItemIndex]; } else { - return SortedPlaylist[PlayingItemIndex]; + return _sortedPlaylist[PlayingItemIndex]; } } } diff --git a/MediaBrowser.LocalMetadata/Images/InternalMetadataFolderImageProvider.cs b/MediaBrowser.LocalMetadata/Images/InternalMetadataFolderImageProvider.cs index 10d691b3e..d3fa41bcd 100644 --- a/MediaBrowser.LocalMetadata/Images/InternalMetadataFolderImageProvider.cs +++ b/MediaBrowser.LocalMetadata/Images/InternalMetadataFolderImageProvider.cs @@ -1,7 +1,6 @@ using System.Collections.Generic; using System.IO; using System.Linq; -using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.Audio; using MediaBrowser.Controller.Providers; @@ -15,22 +14,18 @@ namespace MediaBrowser.LocalMetadata.Images /// </summary> public class InternalMetadataFolderImageProvider : ILocalImageProvider, IHasOrder { - private readonly IServerConfigurationManager _config; private readonly IFileSystem _fileSystem; private readonly ILogger<InternalMetadataFolderImageProvider> _logger; /// <summary> /// Initializes a new instance of the <see cref="InternalMetadataFolderImageProvider"/> class. /// </summary> - /// <param name="config">Instance of the <see cref="IServerConfigurationManager"/> interface.</param> /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param> /// <param name="logger">Instance of the <see cref="ILogger{InternalMetadataFolderImageProvider}"/> interface.</param> public InternalMetadataFolderImageProvider( - IServerConfigurationManager config, IFileSystem fileSystem, ILogger<InternalMetadataFolderImageProvider> logger) { - _config = config; _fileSystem = fileSystem; _logger = logger; } diff --git a/MediaBrowser.LocalMetadata/Images/LocalImageProvider.cs b/MediaBrowser.LocalMetadata/Images/LocalImageProvider.cs index b7398880e..7dc6149f4 100644 --- a/MediaBrowser.LocalMetadata/Images/LocalImageProvider.cs +++ b/MediaBrowser.LocalMetadata/Images/LocalImageProvider.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Globalization; using System.IO; using System.Linq; +using Jellyfin.Extensions; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.Audio; using MediaBrowser.Controller.Entities.Movies; @@ -60,8 +61,6 @@ namespace MediaBrowser.LocalMetadata.Images private readonly IFileSystem _fileSystem; - private readonly CultureInfo _usCulture = new CultureInfo("en-US"); - /// <summary> /// Initializes a new instance of the <see cref="LocalImageProvider"/> class. /// </summary> @@ -119,16 +118,10 @@ namespace MediaBrowser.LocalMetadata.Images return Enumerable.Empty<FileSystemMetadata>(); } - if (includeDirectories) - { - return directoryService.GetFileSystemEntries(path) - .Where(i => BaseItem.SupportedImageExtensions.Contains(i.Extension, StringComparer.OrdinalIgnoreCase) || i.IsDirectory) - - .OrderBy(i => Array.IndexOf(BaseItem.SupportedImageExtensions, i.Extension ?? string.Empty)); - } - - return directoryService.GetFiles(path) - .Where(i => BaseItem.SupportedImageExtensions.Contains(i.Extension, StringComparer.OrdinalIgnoreCase)) + return directoryService.GetFileSystemEntries(path) + .Where(i => + (includeDirectories && i.IsDirectory) + || BaseItem.SupportedImageExtensions.Contains(i.Extension, StringComparison.OrdinalIgnoreCase)) .OrderBy(i => Array.IndexOf(BaseItem.SupportedImageExtensions, i.Extension ?? string.Empty)); } @@ -203,7 +196,7 @@ namespace MediaBrowser.LocalMetadata.Images added = AddImage(files, images, "logo", imagePrefix, isInMixedFolder, ImageType.Logo); if (!added) { - added = AddImage(files, images, "clearlogo", imagePrefix, isInMixedFolder, ImageType.Logo); + AddImage(files, images, "clearlogo", imagePrefix, isInMixedFolder, ImageType.Logo); } } @@ -220,7 +213,7 @@ namespace MediaBrowser.LocalMetadata.Images if (!added) { - added = AddImage(files, images, "disc", imagePrefix, isInMixedFolder, ImageType.Disc); + AddImage(files, images, "disc", imagePrefix, isInMixedFolder, ImageType.Disc); } } else if (item is Video || item is BoxSet) @@ -234,7 +227,7 @@ namespace MediaBrowser.LocalMetadata.Images if (!added) { - added = AddImage(files, images, "discart", imagePrefix, isInMixedFolder, ImageType.Disc); + AddImage(files, images, "discart", imagePrefix, isInMixedFolder, ImageType.Disc); } } @@ -250,7 +243,7 @@ namespace MediaBrowser.LocalMetadata.Images added = AddImage(files, images, "landscape", imagePrefix, isInMixedFolder, ImageType.Thumb); if (!added) { - added = AddImage(files, images, "thumb", imagePrefix, isInMixedFolder, ImageType.Thumb); + AddImage(files, images, "thumb", imagePrefix, isInMixedFolder, ImageType.Thumb); } } @@ -258,11 +251,6 @@ namespace MediaBrowser.LocalMetadata.Images { PopulateBackdrops(item, images, files, imagePrefix, isInMixedFolder); } - - if (item is IHasScreenshots) - { - PopulateScreenshots(images, files, imagePrefix, isInMixedFolder); - } } private void PopulatePrimaryImages(BaseItem item, List<LocalImageInfo> images, List<FileSystemMetadata> files, string imagePrefix, bool isInMixedFolder) @@ -365,11 +353,6 @@ namespace MediaBrowser.LocalMetadata.Images })); } - private void PopulateScreenshots(List<LocalImageInfo> images, List<FileSystemMetadata> files, string imagePrefix, bool isInMixedFolder) - { - PopulateBackdrops(images, files, imagePrefix, "screenshot", "screenshot", isInMixedFolder, ImageType.Screenshot); - } - private void PopulateBackdrops(List<LocalImageInfo> images, List<FileSystemMetadata> files, string imagePrefix, string firstFileName, string subsequentFileNamePrefix, bool isInMixedFolder, ImageType type) { AddImage(files, images, imagePrefix + firstFileName, type); @@ -434,7 +417,7 @@ namespace MediaBrowser.LocalMetadata.Images var seasonMarker = seasonNumber.Value == 0 ? "-specials" - : seasonNumber.Value.ToString("00", _usCulture); + : seasonNumber.Value.ToString("00", CultureInfo.InvariantCulture); // Get this one directly from the file system since we have to go up a level if (!string.Equals(prefix, seasonMarker, StringComparison.OrdinalIgnoreCase)) diff --git a/MediaBrowser.LocalMetadata/MediaBrowser.LocalMetadata.csproj b/MediaBrowser.LocalMetadata/MediaBrowser.LocalMetadata.csproj index 1cf8fcd1b..41c79651d 100644 --- a/MediaBrowser.LocalMetadata/MediaBrowser.LocalMetadata.csproj +++ b/MediaBrowser.LocalMetadata/MediaBrowser.LocalMetadata.csproj @@ -11,7 +11,7 @@ </ItemGroup> <PropertyGroup> - <TargetFramework>net5.0</TargetFramework> + <TargetFramework>net6.0</TargetFramework> <GenerateAssemblyInfo>false</GenerateAssemblyInfo> <GenerateDocumentationFile>true</GenerateDocumentationFile> </PropertyGroup> @@ -23,7 +23,7 @@ <!-- Code Analyzers--> <ItemGroup Condition=" '$(Configuration)' == 'Debug' "> <PackageReference Include="SerilogAnalyzer" Version="0.15.0" PrivateAssets="All" /> - <PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="All" /> + <PackageReference Include="StyleCop.Analyzers" Version="1.2.0-beta.376" PrivateAssets="All" /> <PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" PrivateAssets="All" /> </ItemGroup> diff --git a/MediaBrowser.LocalMetadata/Parsers/BaseItemXmlParser.cs b/MediaBrowser.LocalMetadata/Parsers/BaseItemXmlParser.cs index ef130ee74..777fe6774 100644 --- a/MediaBrowser.LocalMetadata/Parsers/BaseItemXmlParser.cs +++ b/MediaBrowser.LocalMetadata/Parsers/BaseItemXmlParser.cs @@ -6,6 +6,7 @@ using System.Linq; using System.Text; using System.Threading; using System.Xml; +using Jellyfin.Extensions; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Entities; @@ -20,8 +21,6 @@ namespace MediaBrowser.LocalMetadata.Parsers public class BaseItemXmlParser<T> where T : BaseItem { - private readonly CultureInfo _usCulture = new CultureInfo("en-US"); - private Dictionary<string, string>? _validProviderIds; /// <summary> @@ -144,13 +143,13 @@ namespace MediaBrowser.LocalMetadata.Parsers if (!string.IsNullOrWhiteSpace(val)) { - if (DateTime.TryParse(val, out var added)) + if (DateTime.TryParse(val, CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal, out var added)) { - item.DateCreated = added.ToUniversalTime(); + item.DateCreated = added; } else { - Logger.LogWarning("Invalid Added value found: " + val); + Logger.LogWarning("Invalid Added value found: {Value}", val); } } @@ -179,7 +178,7 @@ namespace MediaBrowser.LocalMetadata.Parsers if (!string.IsNullOrEmpty(text)) { - if (float.TryParse(text, NumberStyles.Any, _usCulture, out var value)) + if (float.TryParse(text, NumberStyles.Any, CultureInfo.InvariantCulture, out var value)) { item.CriticRating = value; } @@ -331,7 +330,7 @@ namespace MediaBrowser.LocalMetadata.Parsers if (!string.IsNullOrWhiteSpace(text)) { - if (int.TryParse(text.Split(' ')[0], NumberStyles.Integer, _usCulture, out var runtime)) + if (int.TryParse(text.AsSpan().LeftPart(' '), NumberStyles.Integer, CultureInfo.InvariantCulture, out var runtime)) { item.RunTimeTicks = TimeSpan.FromMinutes(runtime).Ticks; } @@ -413,7 +412,7 @@ namespace MediaBrowser.LocalMetadata.Parsers { var actors = reader.ReadInnerXml(); - if (actors.Contains("<", StringComparison.Ordinal)) + if (actors.Contains('<', StringComparison.Ordinal)) { // This is one of the mis-named "Actors" full nodes created by MB2 // Create a reader and pass it to the persons node processor @@ -534,9 +533,9 @@ namespace MediaBrowser.LocalMetadata.Parsers if (!string.IsNullOrWhiteSpace(firstAired)) { - if (DateTime.TryParseExact(firstAired, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.AssumeLocal, out var airDate) && airDate.Year > 1850) + if (DateTime.TryParseExact(firstAired, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.AssumeLocal | DateTimeStyles.AdjustToUniversal, out var airDate) && airDate.Year > 1850) { - item.PremiereDate = airDate.ToUniversalTime(); + item.PremiereDate = airDate; item.ProductionYear = airDate.Year; } } @@ -551,9 +550,9 @@ namespace MediaBrowser.LocalMetadata.Parsers if (!string.IsNullOrWhiteSpace(firstAired)) { - if (DateTime.TryParseExact(firstAired, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.AssumeLocal, out var airDate) && airDate.Year > 1850) + if (DateTime.TryParseExact(firstAired, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.AssumeLocal | DateTimeStyles.AdjustToUniversal, out var airDate) && airDate.Year > 1850) { - item.EndDate = airDate.ToUniversalTime(); + item.EndDate = airDate; } } @@ -1094,7 +1093,7 @@ namespace MediaBrowser.LocalMetadata.Parsers if (!string.IsNullOrWhiteSpace(val)) { - if (int.TryParse(val, NumberStyles.Integer, _usCulture, out var intVal)) + if (int.TryParse(val, NumberStyles.Integer, CultureInfo.InvariantCulture, out var intVal)) { sortOrder = intVal; } diff --git a/MediaBrowser.LocalMetadata/Savers/BaseXmlSaver.cs b/MediaBrowser.LocalMetadata/Savers/BaseXmlSaver.cs index 6a3896eb6..1a8b5bb4e 100644 --- a/MediaBrowser.LocalMetadata/Savers/BaseXmlSaver.cs +++ b/MediaBrowser.LocalMetadata/Savers/BaseXmlSaver.cs @@ -25,8 +25,6 @@ namespace MediaBrowser.LocalMetadata.Savers /// </summary> public const string DateAddedFormat = "yyyy-MM-dd HH:mm:ss"; - private static readonly CultureInfo _usCulture = new CultureInfo("en-US"); - /// <summary> /// Initializes a new instance of the <see cref="BaseXmlSaver"/> class. /// </summary> @@ -115,11 +113,19 @@ namespace MediaBrowser.LocalMetadata.Savers { var directory = Path.GetDirectoryName(path) ?? throw new ArgumentException($"Provided path ({path}) is not valid.", nameof(path)); Directory.CreateDirectory(directory); + // On Windows, savint the file will fail if the file is hidden or readonly FileSystem.SetAttributes(path, false, false); - // use FileShare.None as this bypasses dotnet bug dotnet/runtime#42790 . - using (var filestream = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.None)) + var fileStreamOptions = new FileStreamOptions() + { + Mode = FileMode.Create, + Access = FileAccess.Write, + Share = FileShare.None, + PreallocationSize = stream.Length + }; + + using (var filestream = new FileStream(path, fileStreamOptions)) { stream.CopyTo(filestream); } @@ -138,7 +144,7 @@ namespace MediaBrowser.LocalMetadata.Savers } catch (Exception ex) { - Logger.LogError(ex, "Error setting hidden attribute on {path}", path); + Logger.LogError(ex, "Error setting hidden attribute on {Path}", path); } } @@ -205,7 +211,7 @@ namespace MediaBrowser.LocalMetadata.Savers if (item.CriticRating.HasValue) { - writer.WriteElementString("CriticRating", item.CriticRating.Value.ToString(_usCulture)); + writer.WriteElementString("CriticRating", item.CriticRating.Value.ToString(CultureInfo.InvariantCulture)); } if (!string.IsNullOrEmpty(item.Overview)) @@ -289,12 +295,12 @@ namespace MediaBrowser.LocalMetadata.Savers if (item.CommunityRating.HasValue) { - writer.WriteElementString("Rating", item.CommunityRating.Value.ToString(_usCulture)); + writer.WriteElementString("Rating", item.CommunityRating.Value.ToString(CultureInfo.InvariantCulture)); } if (item.ProductionYear.HasValue && item is not Person) { - writer.WriteElementString("ProductionYear", item.ProductionYear.Value.ToString(_usCulture)); + writer.WriteElementString("ProductionYear", item.ProductionYear.Value.ToString(CultureInfo.InvariantCulture)); } if (item is IHasAspectRatio hasAspectRatio) @@ -322,7 +328,7 @@ namespace MediaBrowser.LocalMetadata.Savers { var timespan = TimeSpan.FromTicks(runTimeTicks.Value); - writer.WriteElementString("RunningTime", Math.Floor(timespan.TotalMinutes).ToString(_usCulture)); + writer.WriteElementString("RunningTime", Math.Floor(timespan.TotalMinutes).ToString(CultureInfo.InvariantCulture)); } if (item.ProviderIds != null) @@ -395,7 +401,7 @@ namespace MediaBrowser.LocalMetadata.Savers if (person.SortOrder.HasValue) { - writer.WriteElementString("SortOrder", person.SortOrder.Value.ToString(_usCulture)); + writer.WriteElementString("SortOrder", person.SortOrder.Value.ToString(CultureInfo.InvariantCulture)); } writer.WriteEndElement(); diff --git a/MediaBrowser.MediaEncoding/Attachments/AttachmentExtractor.cs b/MediaBrowser.MediaEncoding/Attachments/AttachmentExtractor.cs index a524aeaa9..3fd4cd731 100644 --- a/MediaBrowser.MediaEncoding/Attachments/AttachmentExtractor.cs +++ b/MediaBrowser.MediaEncoding/Attachments/AttachmentExtractor.cs @@ -50,7 +50,7 @@ namespace MediaBrowser.MediaEncoding.Attachments } /// <inheritdoc /> - public async Task<(MediaAttachment attachment, Stream stream)> GetAttachment(BaseItem item, string mediaSourceId, int attachmentStreamIndex, CancellationToken cancellationToken) + public async Task<(MediaAttachment Attachment, Stream Stream)> GetAttachment(BaseItem item, string mediaSourceId, int attachmentStreamIndex, CancellationToken cancellationToken) { if (item == null) { @@ -223,11 +223,10 @@ namespace MediaBrowser.MediaEncoding.Attachments if (failed) { - var msg = $"ffmpeg attachment extraction failed for {inputPath} to {outputPath}"; + _logger.LogError("ffmpeg attachment extraction failed for {InputPath} to {OutputPath}", inputPath, outputPath); - _logger.LogError(msg); - - throw new InvalidOperationException(msg); + throw new InvalidOperationException( + string.Format(CultureInfo.InvariantCulture, "ffmpeg attachment extraction failed for {0} to {1}", inputPath, outputPath)); } else { diff --git a/MediaBrowser.MediaEncoding/BdInfo/BdInfoDirectoryInfo.cs b/MediaBrowser.MediaEncoding/BdInfo/BdInfoDirectoryInfo.cs index e86e518be..409379c35 100644 --- a/MediaBrowser.MediaEncoding/BdInfo/BdInfoDirectoryInfo.cs +++ b/MediaBrowser.MediaEncoding/BdInfo/BdInfoDirectoryInfo.cs @@ -75,7 +75,7 @@ namespace MediaBrowser.MediaEncoding.BdInfo x => new BdInfoFileInfo(x)); } - public static IDirectoryInfo FromFileSystemPath(Model.IO.IFileSystem fs, string path) + public static IDirectoryInfo FromFileSystemPath(IFileSystem fs, string path) { return new BdInfoDirectoryInfo(fs, path); } diff --git a/MediaBrowser.MediaEncoding/Encoder/EncoderValidator.cs b/MediaBrowser.MediaEncoding/Encoder/EncoderValidator.cs index 60a2d39e5..fe3069934 100644 --- a/MediaBrowser.MediaEncoding/Encoder/EncoderValidator.cs +++ b/MediaBrowser.MediaEncoding/Encoder/EncoderValidator.cs @@ -16,6 +16,12 @@ namespace MediaBrowser.MediaEncoding.Encoder { "h264", "hevc", + "vp8", + "libvpx", + "vp9", + "libvpx-vp9", + "av1", + "libdav1d", "mpeg2video", "mpeg4", "msmpeg4", @@ -30,6 +36,7 @@ namespace MediaBrowser.MediaEncoding.Encoder "vc1_qsv", "vp8_qsv", "vp9_qsv", + "av1_qsv", "h264_cuvid", "hevc_cuvid", "mpeg2_cuvid", @@ -37,16 +44,11 @@ namespace MediaBrowser.MediaEncoding.Encoder "mpeg4_cuvid", "vp8_cuvid", "vp9_cuvid", + "av1_cuvid", "h264_mmal", "mpeg2_mmal", "mpeg4_mmal", "vc1_mmal", - "h264_mediacodec", - "hevc_mediacodec", - "mpeg2_mediacodec", - "mpeg4_mediacodec", - "vp8_mediacodec", - "vp9_mediacodec", "h264_opencl", "hevc_opencl", "mpeg2_opencl", @@ -89,20 +91,39 @@ namespace MediaBrowser.MediaEncoding.Encoder private static readonly string[] _requiredFilters = new[] { + // sw + "alphasrc", + "zscale", + // qsv + "scale_qsv", + "vpp_qsv", + "deinterlace_qsv", + "overlay_qsv", + // cuda "scale_cuda", "yadif_cuda", - "hwupload_cuda", - "overlay_cuda", "tonemap_cuda", + "overlay_cuda", + "hwupload_cuda", + // opencl + "scale_opencl", "tonemap_opencl", + "overlay_opencl", + // vaapi + "scale_vaapi", + "deinterlace_vaapi", "tonemap_vaapi", + "overlay_vaapi", + "hwupload_vaapi" }; private static readonly IReadOnlyDictionary<int, string[]> _filterOptionsDict = new Dictionary<int, string[]> { { 0, new string[] { "scale_cuda", "Output format (default \"same\")" } }, { 1, new string[] { "tonemap_cuda", "GPU accelerated HDR to SDR tonemapping" } }, - { 2, new string[] { "tonemap_opencl", "bt2390" } } + { 2, new string[] { "tonemap_opencl", "bt2390" } }, + { 3, new string[] { "overlay_opencl", "Action to take when encountering EOF from secondary input" } }, + { 4, new string[] { "overlay_vaapi", "Action to take when encountering EOF from secondary input" } } }; // These are the library versions that corresponds to our minimum ffmpeg version 4.x according to the version table below @@ -144,7 +165,7 @@ namespace MediaBrowser.MediaEncoding.Encoder string output; try { - output = GetProcessOutput(_encoderPath, "-version"); + output = GetProcessOutput(_encoderPath, "-version", false); } catch (Exception ex) { @@ -225,7 +246,7 @@ namespace MediaBrowser.MediaEncoding.Encoder string output; try { - output = GetProcessOutput(_encoderPath, "-version"); + output = GetProcessOutput(_encoderPath, "-version", false); } catch (Exception ex) { @@ -318,12 +339,36 @@ namespace MediaBrowser.MediaEncoding.Encoder return map; } + public bool CheckVaapiDeviceByDriverName(string driverName, string renderNodePath) + { + if (!OperatingSystem.IsLinux()) + { + return false; + } + + if (string.IsNullOrEmpty(driverName) || string.IsNullOrEmpty(renderNodePath)) + { + return false; + } + + try + { + var output = GetProcessOutput(_encoderPath, "-v verbose -hide_banner -init_hw_device vaapi=va:" + renderNodePath, true); + return output.Contains(driverName, StringComparison.Ordinal); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error detecting the given vaapi render node path"); + return false; + } + } + private IEnumerable<string> GetHwaccelTypes() { string? output = null; try { - output = GetProcessOutput(_encoderPath, "-hwaccels"); + output = GetProcessOutput(_encoderPath, "-hwaccels", false); } catch (Exception ex) { @@ -351,7 +396,7 @@ namespace MediaBrowser.MediaEncoding.Encoder string output; try { - output = GetProcessOutput(_encoderPath, "-h filter=" + filter); + output = GetProcessOutput(_encoderPath, "-h filter=" + filter, false); } catch (Exception ex) { @@ -375,7 +420,7 @@ namespace MediaBrowser.MediaEncoding.Encoder string output; try { - output = GetProcessOutput(_encoderPath, "-" + codecstr); + output = GetProcessOutput(_encoderPath, "-" + codecstr, false); } catch (Exception ex) { @@ -406,7 +451,7 @@ namespace MediaBrowser.MediaEncoding.Encoder string output; try { - output = GetProcessOutput(_encoderPath, "-filters"); + output = GetProcessOutput(_encoderPath, "-filters", false); } catch (Exception ex) { @@ -444,7 +489,7 @@ namespace MediaBrowser.MediaEncoding.Encoder return dict; } - private string GetProcessOutput(string path, string arguments) + private string GetProcessOutput(string path, string arguments, bool readStdErr) { using (var process = new Process() { @@ -455,7 +500,6 @@ namespace MediaBrowser.MediaEncoding.Encoder WindowStyle = ProcessWindowStyle.Hidden, ErrorDialog = false, RedirectStandardOutput = true, - // ffmpeg uses stderr to log info, don't show this RedirectStandardError = true } }) @@ -464,7 +508,7 @@ namespace MediaBrowser.MediaEncoding.Encoder process.Start(); - return process.StandardOutput.ReadToEnd(); + return readStdErr ? process.StandardError.ReadToEnd() : process.StandardOutput.ReadToEnd(); } } } diff --git a/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs b/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs index a7bcaf544..e1643ea43 100644 --- a/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs +++ b/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs @@ -12,12 +12,14 @@ using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; using Jellyfin.Extensions.Json; +using MediaBrowser.Common; using MediaBrowser.Common.Configuration; using MediaBrowser.Common.Extensions; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.MediaEncoding; using MediaBrowser.MediaEncoding.Probing; using MediaBrowser.Model.Dlna; +using MediaBrowser.Model.Drawing; using MediaBrowser.Model.Dto; using MediaBrowser.Model.Entities; using MediaBrowser.Model.Globalization; @@ -43,11 +45,6 @@ namespace MediaBrowser.MediaEncoding.Encoder /// </summary> internal const int DefaultHdrImageExtractionTimeout = 20000; - /// <summary> - /// The us culture. - /// </summary> - private readonly CultureInfo _usCulture = new CultureInfo("en-US"); - private readonly ILogger<MediaEncoder> _logger; private readonly IServerConfigurationManager _configurationManager; private readonly IFileSystem _fileSystem; @@ -68,6 +65,10 @@ namespace MediaBrowser.MediaEncoding.Encoder private List<string> _filters = new List<string>(); private IDictionary<int, bool> _filtersWithOption = new Dictionary<int, bool>(); + private bool _isVaapiDeviceAmd = false; + private bool _isVaapiDeviceInteliHD = false; + private bool _isVaapiDeviceInteli965 = false; + private Version _ffmpegVersion = null; private string _ffmpegPath = string.Empty; private string _ffprobePath; @@ -91,6 +92,14 @@ namespace MediaBrowser.MediaEncoding.Encoder /// <inheritdoc /> public string EncoderPath => _ffmpegPath; + public Version EncoderVersion => _ffmpegVersion; + + public bool IsVaapiDeviceAmd => _isVaapiDeviceAmd; + + public bool IsVaapiDeviceInteliHD => _isVaapiDeviceInteliHD; + + public bool IsVaapiDeviceInteli965 => _isVaapiDeviceInteli965; + /// <summary> /// Run at startup or if the user removes a Custom path from transcode page. /// Sets global variables FFmpegPath. @@ -117,9 +126,9 @@ namespace MediaBrowser.MediaEncoding.Encoder } // Write the FFmpeg path to the config/encoding.xml file as <EncoderAppPathDisplay> so it appears in UI - var config = _configurationManager.GetEncodingOptions(); - config.EncoderAppPathDisplay = _ffmpegPath ?? string.Empty; - _configurationManager.SaveConfiguration("encoding", config); + var options = _configurationManager.GetEncodingOptions(); + options.EncoderAppPathDisplay = _ffmpegPath ?? string.Empty; + _configurationManager.SaveConfiguration("encoding", options); // Only if mpeg path is set, try and set path to probe if (_ffmpegPath != null) @@ -137,7 +146,30 @@ namespace MediaBrowser.MediaEncoding.Encoder SetAvailableHwaccels(validator.GetHwaccels()); SetMediaEncoderVersion(validator); - _threads = EncodingHelper.GetNumberOfThreads(null, _configurationManager.GetEncodingOptions(), null); + _threads = EncodingHelper.GetNumberOfThreads(null, options, null); + + // Check the Vaapi device vendor + if (OperatingSystem.IsLinux() + && SupportsHwaccel("vaapi") + && !string.IsNullOrEmpty(options.VaapiDevice) + && string.Equals(options.HardwareAccelerationType, "vaapi", StringComparison.OrdinalIgnoreCase)) + { + _isVaapiDeviceAmd = validator.CheckVaapiDeviceByDriverName("Mesa Gallium driver", options.VaapiDevice); + _isVaapiDeviceInteliHD = validator.CheckVaapiDeviceByDriverName("Intel iHD driver", options.VaapiDevice); + _isVaapiDeviceInteli965 = validator.CheckVaapiDeviceByDriverName("Intel i965 driver", options.VaapiDevice); + if (_isVaapiDeviceAmd) + { + _logger.LogInformation("VAAPI device {RenderNodePath} is AMD GPU", options.VaapiDevice); + } + else if (_isVaapiDeviceInteliHD) + { + _logger.LogInformation("VAAPI device {RenderNodePath} is Intel GPU (iHD)", options.VaapiDevice); + } + else if (_isVaapiDeviceInteli965) + { + _logger.LogInformation("VAAPI device {RenderNodePath} is Intel GPU (i965)", options.VaapiDevice); + } + } } _logger.LogInformation("FFmpeg: {FfmpegPath}", _ffmpegPath ?? string.Empty); @@ -151,6 +183,16 @@ namespace MediaBrowser.MediaEncoding.Encoder /// <param name="pathType">The path type.</param> public void UpdateEncoderPath(string path, string pathType) { + var config = _configurationManager.GetEncodingOptions(); + + // Filesystem may not be case insensitive, but EncoderAppPathDisplay should always point to a valid file? + if (string.IsNullOrEmpty(config.EncoderAppPath) + && string.Equals(config.EncoderAppPathDisplay, path, StringComparison.OrdinalIgnoreCase)) + { + _logger.LogDebug("Existing ffmpeg path is empty and the new path is the same as {EncoderAppPathDisplay}. Skipping", nameof(config.EncoderAppPathDisplay)); + return; + } + string newPath; _logger.LogInformation("Attempting to update encoder path to {Path}. pathType: {PathType}", path ?? string.Empty, pathType ?? string.Empty); @@ -165,19 +207,26 @@ namespace MediaBrowser.MediaEncoding.Encoder // User had cleared the custom path in UI newPath = string.Empty; } - else if (Directory.Exists(path)) - { - // Given path is directory, so resolve down to filename - newPath = GetEncoderPathFromDirectory(path, "ffmpeg"); - } else { - newPath = path; + if (Directory.Exists(path)) + { + // Given path is directory, so resolve down to filename + newPath = GetEncoderPathFromDirectory(path, "ffmpeg"); + } + else + { + newPath = path; + } + + if (!new EncoderValidator(_logger, newPath).ValidateVersion()) + { + throw new ResourceNotFoundException(); + } } // Write the new ffmpeg path to the xml as <EncoderAppPath> // This ensures its not lost on next startup - var config = _configurationManager.GetEncodingOptions(); config.EncoderAppPath = newPath; _configurationManager.SaveConfiguration("encoding", config); @@ -287,11 +336,6 @@ namespace MediaBrowser.MediaEncoding.Encoder return false; } - public Version GetMediaEncoderVersion() - { - return _ffmpegVersion; - } - public bool CanEncodeToAudioCodec(string codec) { if (string.Equals(codec, "opus", StringComparison.OrdinalIgnoreCase)) @@ -465,17 +509,17 @@ namespace MediaBrowser.MediaEncoding.Encoder Protocol = MediaProtocol.File }; - return ExtractImage(path, null, null, imageStreamIndex, mediaSource, true, null, null, cancellationToken); + return ExtractImage(path, null, null, imageStreamIndex, mediaSource, true, null, null, ImageFormat.Jpg, cancellationToken); } public Task<string> ExtractVideoImage(string inputFile, string container, MediaSourceInfo mediaSource, MediaStream videoStream, Video3DFormat? threedFormat, TimeSpan? offset, CancellationToken cancellationToken) { - return ExtractImage(inputFile, container, videoStream, null, mediaSource, false, threedFormat, offset, cancellationToken); + return ExtractImage(inputFile, container, videoStream, null, mediaSource, false, threedFormat, offset, ImageFormat.Jpg, cancellationToken); } - public Task<string> ExtractVideoImage(string inputFile, string container, MediaSourceInfo mediaSource, MediaStream imageStream, int? imageStreamIndex, CancellationToken cancellationToken) + public Task<string> ExtractVideoImage(string inputFile, string container, MediaSourceInfo mediaSource, MediaStream imageStream, int? imageStreamIndex, ImageFormat? targetFormat, CancellationToken cancellationToken) { - return ExtractImage(inputFile, container, imageStream, imageStreamIndex, mediaSource, false, null, null, cancellationToken); + return ExtractImage(inputFile, container, imageStream, imageStreamIndex, mediaSource, false, null, null, targetFormat, cancellationToken); } private async Task<string> ExtractImage( @@ -487,42 +531,16 @@ namespace MediaBrowser.MediaEncoding.Encoder bool isAudio, Video3DFormat? threedFormat, TimeSpan? offset, + ImageFormat? targetFormat, CancellationToken cancellationToken) { var inputArgument = GetInputArgument(inputFile, mediaSource); if (!isAudio) { - // The failure of HDR extraction usually occurs when using custom ffmpeg that does not contain the zscale filter. try { - return await ExtractImageInternal(inputArgument, container, videoStream, imageStreamIndex, threedFormat, offset, true, true, cancellationToken).ConfigureAwait(false); - } - catch (ArgumentException) - { - throw; - } - catch (Exception ex) - { - _logger.LogError(ex, "I-frame or HDR image extraction failed, will attempt with I-frame extraction disabled. Input: {Arguments}", inputArgument); - } - - try - { - return await ExtractImageInternal(inputArgument, container, videoStream, imageStreamIndex, threedFormat, offset, false, true, cancellationToken).ConfigureAwait(false); - } - catch (ArgumentException) - { - throw; - } - catch (Exception ex) - { - _logger.LogError(ex, "HDR image extraction failed, will fallback to SDR image extraction. Input: {Arguments}", inputArgument); - } - - try - { - return await ExtractImageInternal(inputArgument, container, videoStream, imageStreamIndex, threedFormat, offset, true, false, cancellationToken).ConfigureAwait(false); + return await ExtractImageInternal(inputArgument, container, videoStream, imageStreamIndex, threedFormat, offset, true, targetFormat, cancellationToken).ConfigureAwait(false); } catch (ArgumentException) { @@ -534,49 +552,55 @@ namespace MediaBrowser.MediaEncoding.Encoder } } - return await ExtractImageInternal(inputArgument, container, videoStream, imageStreamIndex, threedFormat, offset, false, false, cancellationToken).ConfigureAwait(false); + return await ExtractImageInternal(inputArgument, container, videoStream, imageStreamIndex, threedFormat, offset, false, targetFormat, cancellationToken).ConfigureAwait(false); } - private async Task<string> ExtractImageInternal(string inputPath, string container, MediaStream videoStream, int? imageStreamIndex, Video3DFormat? threedFormat, TimeSpan? offset, bool useIFrame, bool allowTonemap, CancellationToken cancellationToken) + private async Task<string> ExtractImageInternal(string inputPath, string container, MediaStream videoStream, int? imageStreamIndex, Video3DFormat? threedFormat, TimeSpan? offset, bool useIFrame, ImageFormat? targetFormat, CancellationToken cancellationToken) { if (string.IsNullOrEmpty(inputPath)) { throw new ArgumentNullException(nameof(inputPath)); } - var tempExtractPath = Path.Combine(_configurationManager.ApplicationPaths.TempDirectory, Guid.NewGuid() + ".jpg"); + var outputExtension = targetFormat switch + { + ImageFormat.Bmp => ".bmp", + ImageFormat.Gif => ".gif", + ImageFormat.Jpg => ".jpg", + ImageFormat.Png => ".png", + ImageFormat.Webp => ".webp", + _ => ".jpg" + }; + + var tempExtractPath = Path.Combine(_configurationManager.ApplicationPaths.TempDirectory, Guid.NewGuid() + outputExtension); Directory.CreateDirectory(Path.GetDirectoryName(tempExtractPath)); + // deint -> scale -> thumbnail -> tonemap. + // put the SW tonemap right after the thumbnail to do it only once to reduce cpu usage. + var filters = new List<string>(); + + // deinterlace using bwdif algorithm for video stream. + if (videoStream != null && videoStream.IsInterlaced) + { + filters.Add("bwdif=0:-1:0"); + } + // apply some filters to thumbnail extracted below (below) crop any black lines that we made and get the correct ar. // This filter chain may have adverse effects on recorded tv thumbnails if ar changes during presentation ex. commercials @ diff ar - var vf = threedFormat switch + var scaler = threedFormat switch { // hsbs crop width in half,scale to correct size, set the display aspect,crop out any black bars we may have made. Work out the correct height based on the display aspect it will maintain the aspect where -1 in this case (3d) may not. - Video3DFormat.HalfSideBySide => "-vf crop=iw/2:ih:0:0,scale=(iw*2):ih,setdar=dar=a,crop=min(iw\\,ih*dar):min(ih\\,iw/dar):(iw-min(iw\\,iw*sar))/2:(ih - min (ih\\,ih/sar))/2,setsar=sar=1", + Video3DFormat.HalfSideBySide => "crop=iw/2:ih:0:0,scale=(iw*2):ih,setdar=dar=a,crop=min(iw\\,ih*dar):min(ih\\,iw/dar):(iw-min(iw\\,iw*sar))/2:(ih - min (ih\\,ih/sar))/2,setsar=sar=1", // fsbs crop width in half,set the display aspect,crop out any black bars we may have made - Video3DFormat.FullSideBySide => "-vf crop=iw/2:ih:0:0,setdar=dar=a,crop=min(iw\\,ih*dar):min(ih\\,iw/dar):(iw-min(iw\\,iw*sar))/2:(ih - min (ih\\,ih/sar))/2,setsar=sar=1", + Video3DFormat.FullSideBySide => "crop=iw/2:ih:0:0,setdar=dar=a,crop=min(iw\\,ih*dar):min(ih\\,iw/dar):(iw-min(iw\\,iw*sar))/2:(ih - min (ih\\,ih/sar))/2,setsar=sar=1", // htab crop heigh in half,scale to correct size, set the display aspect,crop out any black bars we may have made - Video3DFormat.HalfTopAndBottom => "-vf crop=iw:ih/2:0:0,scale=(iw*2):ih),setdar=dar=a,crop=min(iw\\,ih*dar):min(ih\\,iw/dar):(iw-min(iw\\,iw*sar))/2:(ih - min (ih\\,ih/sar))/2,setsar=sar=1", + Video3DFormat.HalfTopAndBottom => "crop=iw:ih/2:0:0,scale=(iw*2):ih),setdar=dar=a,crop=min(iw\\,ih*dar):min(ih\\,iw/dar):(iw-min(iw\\,iw*sar))/2:(ih - min (ih\\,ih/sar))/2,setsar=sar=1", // ftab crop heigt in half, set the display aspect,crop out any black bars we may have made - Video3DFormat.FullTopAndBottom => "-vf crop=iw:ih/2:0:0,setdar=dar=a,crop=min(iw\\,ih*dar):min(ih\\,iw/dar):(iw-min(iw\\,iw*sar))/2:(ih - min (ih\\,ih/sar))/2,setsar=sar=1", - _ => string.Empty + Video3DFormat.FullTopAndBottom => "crop=iw:ih/2:0:0,setdar=dar=a,crop=min(iw\\,ih*dar):min(ih\\,iw/dar):(iw-min(iw\\,iw*sar))/2:(ih - min (ih\\,ih/sar))/2,setsar=sar=1", + _ => "scale=trunc(iw*sar):ih" }; - var mapArg = imageStreamIndex.HasValue ? (" -map 0:" + imageStreamIndex.Value.ToString(CultureInfo.InvariantCulture)) : string.Empty; - - var enableHdrExtraction = allowTonemap && string.Equals(videoStream?.VideoRange, "HDR", StringComparison.OrdinalIgnoreCase); - if (enableHdrExtraction) - { - string tonemapFilters = "zscale=t=linear:npl=100,format=gbrpf32le,zscale=p=bt709,tonemap=tonemap=hable:desat=0:peak=100,zscale=t=bt709:m=bt709,format=yuv420p"; - if (vf.Length == 0) - { - vf = "-vf " + tonemapFilters; - } - else - { - vf += "," + tonemapFilters; - } - } + filters.Add(scaler); // Use ffmpeg to sample 100 (we can drop this if required using thumbnail=50 for 50 frames) frames and pick the best thumbnail. Have a fall back just in case. // mpegts need larger batch size otherwise the corrupted thumbnail will be created. Larger batch size will lower the processing speed. @@ -584,18 +608,19 @@ namespace MediaBrowser.MediaEncoding.Encoder if (enableThumbnail) { var useLargerBatchSize = string.Equals("mpegts", container, StringComparison.OrdinalIgnoreCase); - var batchSize = useLargerBatchSize ? "50" : "24"; - if (string.IsNullOrEmpty(vf)) - { - vf = "-vf thumbnail=" + batchSize; - } - else - { - vf += ",thumbnail=" + batchSize; - } + filters.Add("thumbnail=n=" + (useLargerBatchSize ? "50" : "24")); } - var args = string.Format(CultureInfo.InvariantCulture, "-i {0}{3} -threads {4} -v quiet -vframes 1 {2} -f image2 \"{1}\"", inputPath, tempExtractPath, vf, mapArg, _threads); + // Use SW tonemap on HDR video stream only when the zscale filter is available. + var enableHdrExtraction = string.Equals(videoStream?.VideoRange, "HDR", StringComparison.OrdinalIgnoreCase) && SupportsFilter("zscale"); + if (enableHdrExtraction) + { + filters.Add("zscale=t=linear:npl=100,format=gbrpf32le,zscale=p=bt709,tonemap=tonemap=hable:desat=0:peak=100,zscale=t=bt709:m=bt709,format=yuv420p"); + } + + var vf = string.Join(',', filters); + var mapArg = imageStreamIndex.HasValue ? (" -map 0:" + imageStreamIndex.Value.ToString(CultureInfo.InvariantCulture)) : string.Empty; + var args = string.Format(CultureInfo.InvariantCulture, "-i {0}{3} -threads {4} -v quiet -vframes 1 -vf {2} -f image2 \"{1}\"", inputPath, tempExtractPath, vf, mapArg, _threads); if (offset.HasValue) { @@ -659,11 +684,9 @@ namespace MediaBrowser.MediaEncoding.Encoder if (exitCode == -1 || !file.Exists || file.Length == 0) { - var msg = string.Format(CultureInfo.InvariantCulture, "ffmpeg image extraction failed for {0}", inputPath); + _logger.LogError("ffmpeg image extraction failed for {Path}", inputPath); - _logger.LogError(msg); - - throw new FfmpegException(msg); + throw new FfmpegException(string.Format(CultureInfo.InvariantCulture, "ffmpeg image extraction failed for {0}", inputPath)); } return tempExtractPath; @@ -679,118 +702,7 @@ namespace MediaBrowser.MediaEncoding.Encoder public string GetTimeParameter(TimeSpan time) { - return time.ToString(@"hh\:mm\:ss\.fff", _usCulture); - } - - public async Task ExtractVideoImagesOnInterval( - string inputFile, - string container, - MediaStream videoStream, - MediaSourceInfo mediaSource, - Video3DFormat? threedFormat, - TimeSpan interval, - string targetDirectory, - string filenamePrefix, - int? maxWidth, - CancellationToken cancellationToken) - { - var inputArgument = GetInputArgument(inputFile, mediaSource); - - var vf = "fps=fps=1/" + interval.TotalSeconds.ToString(_usCulture); - - if (maxWidth.HasValue) - { - var maxWidthParam = maxWidth.Value.ToString(_usCulture); - - vf += string.Format(CultureInfo.InvariantCulture, ",scale=min(iw\\,{0}):trunc(ow/dar/2)*2", maxWidthParam); - } - - Directory.CreateDirectory(targetDirectory); - var outputPath = Path.Combine(targetDirectory, filenamePrefix + "%05d.jpg"); - - var args = string.Format(CultureInfo.InvariantCulture, "-i {0} -threads {3} -v quiet {2} -f image2 \"{1}\"", inputArgument, outputPath, vf, _threads); - - if (!string.IsNullOrWhiteSpace(container)) - { - var inputFormat = EncodingHelper.GetInputFormat(container); - if (!string.IsNullOrWhiteSpace(inputFormat)) - { - args = "-f " + inputFormat + " " + args; - } - } - - var processStartInfo = new ProcessStartInfo - { - CreateNoWindow = true, - UseShellExecute = false, - FileName = _ffmpegPath, - Arguments = args, - WindowStyle = ProcessWindowStyle.Hidden, - ErrorDialog = false - }; - - _logger.LogInformation(processStartInfo.FileName + " " + processStartInfo.Arguments); - - await _thumbnailResourcePool.WaitAsync(cancellationToken).ConfigureAwait(false); - - bool ranToCompletion = false; - - var process = new Process - { - StartInfo = processStartInfo, - EnableRaisingEvents = true - }; - using (var processWrapper = new ProcessWrapper(process, this)) - { - try - { - StartProcess(processWrapper); - - // Need to give ffmpeg enough time to make all the thumbnails, which could be a while, - // but we still need to detect if the process hangs. - // Making the assumption that as long as new jpegs are showing up, everything is good. - - bool isResponsive = true; - int lastCount = 0; - - while (isResponsive) - { - if (await process.WaitForExitAsync(TimeSpan.FromSeconds(30)).ConfigureAwait(false)) - { - ranToCompletion = true; - break; - } - - cancellationToken.ThrowIfCancellationRequested(); - - var jpegCount = _fileSystem.GetFilePaths(targetDirectory) - .Count(i => string.Equals(Path.GetExtension(i), ".jpg", StringComparison.OrdinalIgnoreCase)); - - isResponsive = jpegCount > lastCount; - lastCount = jpegCount; - } - - if (!ranToCompletion) - { - StopProcess(processWrapper, 1000); - } - } - finally - { - _thumbnailResourcePool.Release(); - } - - var exitCode = ranToCompletion ? processWrapper.ExitCode ?? 0 : -1; - - if (exitCode == -1) - { - var msg = string.Format(CultureInfo.InvariantCulture, "ffmpeg image extraction failed for {0}", inputArgument); - - _logger.LogError(msg); - - throw new FfmpegException(msg); - } - } + return time.ToString(@"hh\:mm\:ss\.fff", CultureInfo.InvariantCulture); } private void StartProcess(ProcessWrapper process) diff --git a/MediaBrowser.MediaEncoding/MediaBrowser.MediaEncoding.csproj b/MediaBrowser.MediaEncoding/MediaBrowser.MediaEncoding.csproj index 6da9886a4..b60ccd2ca 100644 --- a/MediaBrowser.MediaEncoding/MediaBrowser.MediaEncoding.csproj +++ b/MediaBrowser.MediaEncoding/MediaBrowser.MediaEncoding.csproj @@ -6,11 +6,15 @@ </PropertyGroup> <PropertyGroup> - <TargetFramework>net5.0</TargetFramework> + <TargetFramework>net6.0</TargetFramework> <GenerateAssemblyInfo>false</GenerateAssemblyInfo> <GenerateDocumentationFile>true</GenerateDocumentationFile> </PropertyGroup> + <PropertyGroup Condition=" '$(Configuration)' == 'Debug' "> + <TreatWarningsAsErrors>false</TreatWarningsAsErrors> + </PropertyGroup> + <ItemGroup> <Compile Include="..\SharedVersion.cs" /> </ItemGroup> @@ -22,17 +26,17 @@ </ItemGroup> <ItemGroup> - <PackageReference Include="BDInfo" Version="0.7.6.1" /> - <PackageReference Include="libse" Version="3.6.0" /> - <PackageReference Include="Microsoft.Extensions.Http" Version="5.0.0" /> - <PackageReference Include="System.Text.Encoding.CodePages" Version="5.0.0" /> - <PackageReference Include="UTF.Unknown" Version="2.4.0" /> + <PackageReference Include="BDInfo" Version="0.7.6.2" /> + <PackageReference Include="libse" Version="3.6.4" /> + <PackageReference Include="Microsoft.Extensions.Http" Version="6.0.0" /> + <PackageReference Include="System.Text.Encoding.CodePages" Version="6.0.0" /> + <PackageReference Include="UTF.Unknown" Version="2.5.0" /> </ItemGroup> <!-- Code Analyzers--> <ItemGroup Condition=" '$(Configuration)' == 'Debug' "> <PackageReference Include="SerilogAnalyzer" Version="0.15.0" PrivateAssets="All" /> - <PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="All" /> + <PackageReference Include="StyleCop.Analyzers" Version="1.2.0-beta.376" PrivateAssets="All" /> <PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" PrivateAssets="All" /> </ItemGroup> diff --git a/MediaBrowser.MediaEncoding/Probing/FFProbeHelpers.cs b/MediaBrowser.MediaEncoding/Probing/FFProbeHelpers.cs index 9196fe139..a9e753726 100644 --- a/MediaBrowser.MediaEncoding/Probing/FFProbeHelpers.cs +++ b/MediaBrowser.MediaEncoding/Probing/FFProbeHelpers.cs @@ -63,10 +63,10 @@ namespace MediaBrowser.MediaEncoding.Probing public static DateTime? GetDictionaryDateTime(IReadOnlyDictionary<string, string> tags, string key) { if (tags.TryGetValue(key, out var val) - && (DateTime.TryParse(val, DateTimeFormatInfo.CurrentInfo, DateTimeStyles.AssumeUniversal, out var dateTime) - || DateTime.TryParseExact(val, "yyyy", DateTimeFormatInfo.CurrentInfo, DateTimeStyles.AssumeUniversal, out dateTime))) + && (DateTime.TryParse(val, DateTimeFormatInfo.CurrentInfo, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var dateTime) + || DateTime.TryParseExact(val, "yyyy", DateTimeFormatInfo.CurrentInfo, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out dateTime))) { - return dateTime.ToUniversalTime(); + return dateTime; } return null; diff --git a/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs b/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs index 2516aad1c..750fd44eb 100644 --- a/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs +++ b/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs @@ -28,9 +28,8 @@ namespace MediaBrowser.MediaEncoding.Probing private readonly char[] _nameDelimiters = { '/', '|', ';', '\\' }; - private static readonly Regex _performerPattern = new (@"(?<name>.*) \((?<instrument>.*)\)"); + private static readonly Regex _performerPattern = new(@"(?<name>.*) \((?<instrument>.*)\)"); - private readonly CultureInfo _usCulture = new ("en-US"); private readonly ILogger _logger; private readonly ILocalizationManager _localization; @@ -46,11 +45,13 @@ namespace MediaBrowser.MediaEncoding.Probing { "AC/DC", "Au/Ra", + "Bremer/McCoy", "이달의 소녀 1/3", "LOONA 1/3", "LOONA / yyxy", "LOONA / ODD EYE CIRCLE", - "K/DA" + "K/DA", + "22/7" }; public MediaInfo GetMediaInfo(InternalMediaInfoResult data, VideoType? videoType, bool isAudio, string path, MediaProtocol protocol) @@ -83,7 +84,7 @@ namespace MediaBrowser.MediaEncoding.Probing if (!string.IsNullOrEmpty(data.Format.BitRate)) { - if (int.TryParse(data.Format.BitRate, NumberStyles.Any, _usCulture, out var value)) + if (int.TryParse(data.Format.BitRate, NumberStyles.Any, CultureInfo.InvariantCulture, out var value)) { info.Bitrate = value; } @@ -191,7 +192,7 @@ namespace MediaBrowser.MediaEncoding.Probing if (data.Format != null && !string.IsNullOrEmpty(data.Format.Duration)) { - info.RunTimeTicks = TimeSpan.FromSeconds(double.Parse(data.Format.Duration, _usCulture)).Ticks; + info.RunTimeTicks = TimeSpan.FromSeconds(double.Parse(data.Format.Duration, CultureInfo.InvariantCulture)).Ticks; } FetchWtvInfo(info, data); @@ -582,7 +583,8 @@ namespace MediaBrowser.MediaEncoding.Probing /// <returns>MediaAttachments.</returns> private MediaAttachment GetMediaAttachment(MediaStreamInfo streamInfo) { - if (!string.Equals(streamInfo.CodecType, "attachment", StringComparison.OrdinalIgnoreCase)) + if (!string.Equals(streamInfo.CodecType, "attachment", StringComparison.OrdinalIgnoreCase) + && streamInfo.Disposition?.GetValueOrDefault("attached_pic") != 1) { return null; } @@ -647,11 +649,6 @@ namespace MediaBrowser.MediaEncoding.Probing stream.IsAVC = false; } - if (!string.IsNullOrWhiteSpace(streamInfo.FieldOrder) && !string.Equals(streamInfo.FieldOrder, "progressive", StringComparison.OrdinalIgnoreCase)) - { - stream.IsInterlaced = true; - } - // Filter out junk if (!string.IsNullOrWhiteSpace(streamInfo.CodecTagString) && !streamInfo.CodecTagString.Contains("[0]", StringComparison.OrdinalIgnoreCase)) { @@ -673,7 +670,7 @@ namespace MediaBrowser.MediaEncoding.Probing if (!string.IsNullOrEmpty(streamInfo.SampleRate)) { - if (int.TryParse(streamInfo.SampleRate, NumberStyles.Any, _usCulture, out var value)) + if (int.TryParse(streamInfo.SampleRate, NumberStyles.Any, CultureInfo.InvariantCulture, out var value)) { stream.SampleRate = value; } @@ -689,6 +686,16 @@ namespace MediaBrowser.MediaEncoding.Probing { stream.BitDepth = streamInfo.BitsPerRawSample; } + + if (string.IsNullOrEmpty(stream.Title)) + { + // mp4 missing track title workaround: fall back to handler_name if populated + string handlerName = GetDictionaryValue(streamInfo.Tags, "handler_name"); + if (!string.IsNullOrEmpty(handlerName)) + { + stream.Title = handlerName; + } + } } else if (string.Equals(streamInfo.CodecType, "subtitle", StringComparison.OrdinalIgnoreCase)) { @@ -697,18 +704,43 @@ namespace MediaBrowser.MediaEncoding.Probing stream.LocalizedUndefined = _localization.GetLocalizedString("Undefined"); stream.LocalizedDefault = _localization.GetLocalizedString("Default"); stream.LocalizedForced = _localization.GetLocalizedString("Forced"); + + if (string.IsNullOrEmpty(stream.Title)) + { + // mp4 missing track title workaround: fall back to handler_name if populated and not the default "SubtitleHandler" + string handlerName = GetDictionaryValue(streamInfo.Tags, "handler_name"); + if (!string.IsNullOrEmpty(handlerName) && !string.Equals(handlerName, "SubtitleHandler", StringComparison.OrdinalIgnoreCase)) + { + stream.Title = handlerName; + } + } } else if (string.Equals(streamInfo.CodecType, "video", StringComparison.OrdinalIgnoreCase)) { - stream.Type = isAudio || string.Equals(stream.Codec, "mjpeg", StringComparison.OrdinalIgnoreCase) || string.Equals(stream.Codec, "gif", StringComparison.OrdinalIgnoreCase) || string.Equals(stream.Codec, "png", StringComparison.OrdinalIgnoreCase) - ? MediaStreamType.EmbeddedImage - : MediaStreamType.Video; - stream.AverageFrameRate = GetFrameRate(streamInfo.AverageFrameRate); stream.RealFrameRate = GetFrameRate(streamInfo.RFrameRate); - if (isAudio || string.Equals(stream.Codec, "gif", StringComparison.OrdinalIgnoreCase) || - string.Equals(stream.Codec, "png", StringComparison.OrdinalIgnoreCase)) + // Some interlaced H.264 files in mp4 containers using MBAFF coding aren't flagged as being interlaced by FFprobe, + // so for H.264 files we also calculate the frame rate from the codec time base and check if it is double the reported + // frame rate (both rounded to the nearest integer) to determine if the file is interlaced + int roundedTimeBaseFPS = Convert.ToInt32(1 / GetFrameRate(stream.CodecTimeBase) ?? 0); + int roundedDoubleFrameRate = Convert.ToInt32(stream.AverageFrameRate * 2 ?? 0); + + bool videoInterlaced = !string.IsNullOrWhiteSpace(streamInfo.FieldOrder) + && !string.Equals(streamInfo.FieldOrder, "progressive", StringComparison.OrdinalIgnoreCase); + bool h264MbaffCoded = string.Equals(stream.Codec, "h264", StringComparison.OrdinalIgnoreCase) + && string.IsNullOrWhiteSpace(streamInfo.FieldOrder) + && roundedTimeBaseFPS == roundedDoubleFrameRate; + + if (videoInterlaced || h264MbaffCoded) + { + stream.IsInterlaced = true; + } + + if (isAudio + || string.Equals(stream.Codec, "mjpeg", StringComparison.OrdinalIgnoreCase) + || string.Equals(stream.Codec, "gif", StringComparison.OrdinalIgnoreCase) + || string.Equals(stream.Codec, "png", StringComparison.OrdinalIgnoreCase)) { stream.Type = MediaStreamType.EmbeddedImage; } @@ -745,18 +777,23 @@ namespace MediaBrowser.MediaEncoding.Probing if (!stream.BitDepth.HasValue) { - if (!string.IsNullOrEmpty(streamInfo.PixelFormat) - && streamInfo.PixelFormat.Contains("p10", StringComparison.OrdinalIgnoreCase)) + if (!string.IsNullOrEmpty(streamInfo.PixelFormat)) { - stream.BitDepth = 10; - } - - if (!string.IsNullOrEmpty(streamInfo.Profile) - && (streamInfo.Profile.Contains("Main 10", StringComparison.OrdinalIgnoreCase) - || streamInfo.Profile.Contains("High 10", StringComparison.OrdinalIgnoreCase) - || streamInfo.Profile.Contains("Profile 2", StringComparison.OrdinalIgnoreCase))) - { - stream.BitDepth = 10; + if (string.Equals(streamInfo.PixelFormat, "yuv420p", StringComparison.OrdinalIgnoreCase) + || string.Equals(streamInfo.PixelFormat, "yuv444p", StringComparison.OrdinalIgnoreCase)) + { + stream.BitDepth = 8; + } + else if (string.Equals(streamInfo.PixelFormat, "yuv420p10le", StringComparison.OrdinalIgnoreCase) + || string.Equals(streamInfo.PixelFormat, "yuv444p10le", StringComparison.OrdinalIgnoreCase)) + { + stream.BitDepth = 10; + } + else if (string.Equals(streamInfo.PixelFormat, "yuv420p12le", StringComparison.OrdinalIgnoreCase) + || string.Equals(streamInfo.PixelFormat, "yuv444p12le", StringComparison.OrdinalIgnoreCase)) + { + stream.BitDepth = 12; + } } } @@ -802,7 +839,7 @@ namespace MediaBrowser.MediaEncoding.Probing if (!string.IsNullOrEmpty(streamInfo.BitRate)) { - if (int.TryParse(streamInfo.BitRate, NumberStyles.Any, _usCulture, out var value)) + if (int.TryParse(streamInfo.BitRate, NumberStyles.Any, CultureInfo.InvariantCulture, out var value)) { bitrate = value; } @@ -815,7 +852,7 @@ namespace MediaBrowser.MediaEncoding.Probing && (stream.Type == MediaStreamType.Video || (isAudio && stream.Type == MediaStreamType.Audio))) { // If the stream info doesn't have a bitrate get the value from the media format info - if (int.TryParse(formatInfo.BitRate, NumberStyles.Any, _usCulture, out var value)) + if (int.TryParse(formatInfo.BitRate, NumberStyles.Any, CultureInfo.InvariantCulture, out var value)) { bitrate = value; } @@ -921,8 +958,8 @@ namespace MediaBrowser.MediaEncoding.Probing var parts = (original ?? string.Empty).Split(':'); if (!(parts.Length == 2 && - int.TryParse(parts[0], NumberStyles.Any, _usCulture, out var width) && - int.TryParse(parts[1], NumberStyles.Any, _usCulture, out var height) && + int.TryParse(parts[0], NumberStyles.Any, CultureInfo.InvariantCulture, out var width) && + int.TryParse(parts[1], NumberStyles.Any, CultureInfo.InvariantCulture, out var height) && width > 0 && height > 0)) { @@ -995,27 +1032,32 @@ namespace MediaBrowser.MediaEncoding.Probing /// </summary> /// <param name="value">The value.</param> /// <returns>System.Nullable{System.Single}.</returns> - private float? GetFrameRate(string value) + internal static float? GetFrameRate(ReadOnlySpan<char> value) { - if (string.IsNullOrEmpty(value)) + if (value.IsEmpty) { return null; } - var parts = value.Split('/'); - - float result; - - if (parts.Length == 2) + int index = value.IndexOf('/'); + if (index == -1) { - result = float.Parse(parts[0], _usCulture) / float.Parse(parts[1], _usCulture); - } - else - { - result = float.Parse(parts[0], _usCulture); + // REVIEW: is this branch actually required? (i.e. does ffprobe ever output something other than a fraction?) + if (float.TryParse(value, NumberStyles.AllowThousands | NumberStyles.Float, CultureInfo.InvariantCulture, out var result)) + { + return result; + } + + return null; } - return float.IsNaN(result) ? null : result; + if (!float.TryParse(value[..index], NumberStyles.Integer, CultureInfo.InvariantCulture, out var dividend) + || !float.TryParse(value[(index + 1)..], NumberStyles.Integer, CultureInfo.InvariantCulture, out var divisor)) + { + return null; + } + + return divisor == 0f ? null : dividend / divisor; } private void SetAudioRuntimeTicks(InternalMediaInfoResult result, MediaInfo data) @@ -1039,7 +1081,7 @@ namespace MediaBrowser.MediaEncoding.Probing // If we got something, parse it if (!string.IsNullOrEmpty(duration)) { - data.RunTimeTicks = TimeSpan.FromSeconds(double.Parse(duration, _usCulture)).Ticks; + data.RunTimeTicks = TimeSpan.FromSeconds(double.Parse(duration, CultureInfo.InvariantCulture)).Ticks; } } @@ -1101,7 +1143,7 @@ namespace MediaBrowser.MediaEncoding.Probing return; } - info.Size = string.IsNullOrEmpty(data.Format.Size) ? null : long.Parse(data.Format.Size, _usCulture); + info.Size = string.IsNullOrEmpty(data.Format.Size) ? null : long.Parse(data.Format.Size, CultureInfo.InvariantCulture); } private void SetAudioInfoFromTags(MediaInfo audio, IReadOnlyDictionary<string, string> tags) @@ -1144,7 +1186,7 @@ namespace MediaBrowser.MediaEncoding.Probing { Name = match.Groups["name"].Value, Type = PersonType.Actor, - Role = CultureInfo.CurrentCulture.TextInfo.ToTitleCase(match.Groups["instrument"].Value) + Role = CultureInfo.InvariantCulture.TextInfo.ToTitleCase(match.Groups["instrument"].Value) }); } } @@ -1325,8 +1367,8 @@ namespace MediaBrowser.MediaEncoding.Probing } // Don't add artist/album artist name to studios, even if it's listed there - if (info.Artists.Contains(studio, StringComparer.OrdinalIgnoreCase) - || info.AlbumArtists.Contains(studio, StringComparer.OrdinalIgnoreCase)) + if (info.Artists.Contains(studio, StringComparison.OrdinalIgnoreCase) + || info.AlbumArtists.Contains(studio, StringComparison.OrdinalIgnoreCase)) { continue; } @@ -1378,7 +1420,7 @@ namespace MediaBrowser.MediaEncoding.Probing { var disc = tags.GetValueOrDefault(tagName); - if (!string.IsNullOrEmpty(disc) && int.TryParse(disc.Split('/')[0], out var discNum)) + if (!string.IsNullOrEmpty(disc) && int.TryParse(disc.AsSpan().LeftPart('/'), out var discNum)) { return discNum; } @@ -1443,16 +1485,16 @@ namespace MediaBrowser.MediaEncoding.Probing .ToArray(); } - if (tags.TryGetValue("WM/OriginalReleaseTime", out var year) && int.TryParse(year, NumberStyles.Integer, _usCulture, out var parsedYear)) + if (tags.TryGetValue("WM/OriginalReleaseTime", out var year) && int.TryParse(year, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsedYear)) { video.ProductionYear = parsedYear; } // Credit to MCEBuddy: https://mcebuddy2x.codeplex.com/ // DateTime is reported along with timezone info (typically Z i.e. UTC hence assume None) - if (tags.TryGetValue("WM/MediaOriginalBroadcastDateTime", out var premiereDateString) && DateTime.TryParse(year, null, DateTimeStyles.None, out var parsedDate)) + if (tags.TryGetValue("WM/MediaOriginalBroadcastDateTime", out var premiereDateString) && DateTime.TryParse(year, null, DateTimeStyles.AdjustToUniversal, out var parsedDate)) { - video.PremiereDate = parsedDate.ToUniversalTime(); + video.PremiereDate = parsedDate; } var description = tags.GetValueOrDefault("WM/SubTitleDescription"); @@ -1468,7 +1510,7 @@ namespace MediaBrowser.MediaEncoding.Probing // e.g. -> CBeebies Bedtime Hour. The Mystery: Animated adventures of two friends who live on an island in the middle of the big city. Some of Abney and Teal's favourite objects are missing. [S] if (string.IsNullOrWhiteSpace(subTitle) && !string.IsNullOrWhiteSpace(description) - && description.AsSpan()[0..Math.Min(description.Length, MaxSubtitleDescriptionExtractionLength)].IndexOf(':') != -1) // Check within the Subtitle size limit, otherwise from description it can get too long creating an invalid filename + && description.AsSpan()[..Math.Min(description.Length, MaxSubtitleDescriptionExtractionLength)].Contains(':')) // Check within the Subtitle size limit, otherwise from description it can get too long creating an invalid filename { string[] descriptionParts = description.Split(':'); if (descriptionParts.Length > 0) diff --git a/MediaBrowser.MediaEncoding/Subtitles/SubtitleEditParser.cs b/MediaBrowser.MediaEncoding/Subtitles/SubtitleEditParser.cs index 24ceb1b57..52c1b6467 100644 --- a/MediaBrowser.MediaEncoding/Subtitles/SubtitleEditParser.cs +++ b/MediaBrowser.MediaEncoding/Subtitles/SubtitleEditParser.cs @@ -5,7 +5,7 @@ using System.Threading; using Jellyfin.Extensions; using MediaBrowser.Model.MediaInfo; using Microsoft.Extensions.Logging; -using Nikse.SubtitleEdit.Core; +using Nikse.SubtitleEdit.Core.Common; using ILogger = Microsoft.Extensions.Logging.ILogger; using SubtitleFormat = Nikse.SubtitleEdit.Core.SubtitleFormats.SubtitleFormat; @@ -38,7 +38,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles subRip.LoadSubtitle(subtitle, lines, "untitled"); if (subRip.ErrorCount > 0) { - _logger.LogError("{ErrorCount} errors encountered while parsing subtitle."); + _logger.LogError("{ErrorCount} errors encountered while parsing subtitle", subRip.ErrorCount); } var trackInfo = new SubtitleTrackInfo(); diff --git a/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs b/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs index 6f6178af2..5b1ec8041 100644 --- a/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs +++ b/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs @@ -11,6 +11,7 @@ using System.Net.Http; using System.Text; using System.Threading; using System.Threading.Tasks; +using MediaBrowser.Common; using MediaBrowser.Common.Configuration; using MediaBrowser.Common.Extensions; using MediaBrowser.Common.Net; @@ -138,28 +139,28 @@ namespace MediaBrowser.MediaEncoding.Subtitles var subtitle = await GetSubtitleStream(mediaSource, subtitleStream, cancellationToken) .ConfigureAwait(false); - var inputFormat = subtitle.format; + var inputFormat = subtitle.Format; // Return the original if we don't have any way of converting it if (!TryGetWriter(outputFormat, out var writer)) { - return subtitle.stream; + return subtitle.Stream; } // Return the original if the same format is being requested // Character encoding was already handled in GetSubtitleStream if (string.Equals(inputFormat, outputFormat, StringComparison.OrdinalIgnoreCase)) { - return subtitle.stream; + return subtitle.Stream; } - using (var stream = subtitle.stream) + using (var stream = subtitle.Stream) { return ConvertSubtitles(stream, inputFormat, outputFormat, startTimeTicks, endTimeTicks, preserveOriginalTimestamps, cancellationToken); } } - private async Task<(Stream stream, string format)> GetSubtitleStream( + private async Task<(Stream Stream, string Format)> GetSubtitleStream( MediaSourceInfo mediaSource, MediaStream subtitleStream, CancellationToken cancellationToken) @@ -195,7 +196,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles return AsyncFile.OpenRead(fileInfo.Path); } - private async Task<SubtitleInfo> GetReadableFile( + internal async Task<SubtitleInfo> GetReadableFile( MediaSourceInfo mediaSource, MediaStream subtitleStream, CancellationToken cancellationToken) @@ -205,9 +206,9 @@ namespace MediaBrowser.MediaEncoding.Subtitles string outputFormat; string outputCodec; - if (string.Equals(subtitleStream.Codec, "ass", StringComparison.OrdinalIgnoreCase) || - string.Equals(subtitleStream.Codec, "ssa", StringComparison.OrdinalIgnoreCase) || - string.Equals(subtitleStream.Codec, "srt", StringComparison.OrdinalIgnoreCase)) + if (string.Equals(subtitleStream.Codec, "ass", StringComparison.OrdinalIgnoreCase) + || string.Equals(subtitleStream.Codec, "ssa", StringComparison.OrdinalIgnoreCase) + || string.Equals(subtitleStream.Codec, "srt", StringComparison.OrdinalIgnoreCase)) { // Extract outputCodec = "copy"; @@ -238,7 +239,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles var currentFormat = (Path.GetExtension(subtitleStream.Path) ?? subtitleStream.Codec) .TrimStart('.'); - if (TryGetReader(currentFormat, out _)) + if (!TryGetReader(currentFormat, out _)) { // Convert var outputPath = GetSubtitleCachePath(mediaSource, subtitleStream.Index, ".srt"); @@ -248,12 +249,8 @@ namespace MediaBrowser.MediaEncoding.Subtitles return new SubtitleInfo(outputPath, MediaProtocol.File, "srt", true); } - if (subtitleStream.IsExternal) - { - return new SubtitleInfo(subtitleStream.Path, _mediaSourceManager.GetPathProtocol(subtitleStream.Path), currentFormat, true); - } - - return new SubtitleInfo(subtitleStream.Path, mediaSource.Protocol, currentFormat, true); + // It's possbile that the subtitleStream and mediaSource don't share the same protocol (e.g. .STRM file with local subs) + return new SubtitleInfo(subtitleStream.Path, _mediaSourceManager.GetPathProtocol(subtitleStream.Path), currentFormat, true); } private bool TryGetReader(string format, [NotNullWhen(true)] out ISubtitleParser? value) @@ -639,17 +636,14 @@ namespace MediaBrowser.MediaEncoding.Subtitles if (failed) { - var msg = $"ffmpeg subtitle extraction failed for {inputPath} to {outputPath}"; + _logger.LogError("ffmpeg subtitle extraction failed for {InputPath} to {OutputPath}", inputPath, outputPath); - _logger.LogError(msg); - - throw new FfmpegException(msg); + throw new FfmpegException( + string.Format(CultureInfo.InvariantCulture, "ffmpeg subtitle extraction failed for {0} to {1}", inputPath, outputPath)); } else { - var msg = $"ffmpeg subtitle extraction completed for {inputPath} to {outputPath}"; - - _logger.LogInformation(msg); + _logger.LogInformation("ffmpeg subtitle extraction completed for {InputPath} to {OutputPath}", inputPath, outputPath); } if (string.Equals(outputCodec, "ass", StringComparison.OrdinalIgnoreCase)) @@ -683,8 +677,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles if (!string.Equals(text, newText, StringComparison.Ordinal)) { - // use FileShare.None as this bypasses dotnet bug dotnet/runtime#42790 . - using (var fileStream = new FileStream(file, FileMode.Create, FileAccess.Write, FileShare.None, IODefaults.FileStreamBufferSize, AsyncFile.UseAsyncIO)) + using (var fileStream = new FileStream(file, FileMode.Create, FileAccess.Write, FileShare.None, IODefaults.FileStreamBufferSize, FileOptions.Asynchronous)) using (var writer = new StreamWriter(fileStream, encoding)) { await writer.WriteAsync(newText.AsMemory(), cancellationToken).ConfigureAwait(false); @@ -756,7 +749,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles } } - private struct SubtitleInfo + internal readonly struct SubtitleInfo { public SubtitleInfo(string path, MediaProtocol protocol, string format, bool isExternal) { @@ -766,13 +759,13 @@ namespace MediaBrowser.MediaEncoding.Subtitles IsExternal = isExternal; } - public string Path { get; set; } + public string Path { get; } - public MediaProtocol Protocol { get; set; } + public MediaProtocol Protocol { get; } - public string Format { get; set; } + public string Format { get; } - public bool IsExternal { get; set; } + public bool IsExternal { get; } } } } diff --git a/MediaBrowser.MediaEncoding/Subtitles/VttWriter.cs b/MediaBrowser.MediaEncoding/Subtitles/VttWriter.cs index ad32cb794..38ef57dee 100644 --- a/MediaBrowser.MediaEncoding/Subtitles/VttWriter.cs +++ b/MediaBrowser.MediaEncoding/Subtitles/VttWriter.cs @@ -18,14 +18,9 @@ namespace MediaBrowser.MediaEncoding.Subtitles using (var writer = new StreamWriter(stream, Encoding.UTF8, 1024, true)) { writer.WriteLine("WEBVTT"); - writer.WriteLine(string.Empty); - writer.WriteLine("REGION"); - writer.WriteLine("id:subtitle"); - writer.WriteLine("width:80%"); - writer.WriteLine("lines:3"); - writer.WriteLine("regionanchor:50%,100%"); - writer.WriteLine("viewportanchor:50%,90%"); - writer.WriteLine(string.Empty); + writer.WriteLine(); + writer.WriteLine("Region: id:subtitle width:80% lines:3 regionanchor:50%,100% viewportanchor:50%,90%"); + writer.WriteLine(); foreach (var trackEvent in info.TrackEvents) { cancellationToken.ThrowIfCancellationRequested(); @@ -39,7 +34,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles endTime = startTime.Add(TimeSpan.FromMilliseconds(1)); } - writer.WriteLine(@"{0:hh\:mm\:ss\.fff} --> {1:hh\:mm\:ss\.fff} region:subtitle", startTime, endTime); + writer.WriteLine(@"{0:hh\:mm\:ss\.fff} --> {1:hh\:mm\:ss\.fff} region:subtitle line:90%", startTime, endTime); var text = trackEvent.Text; diff --git a/MediaBrowser.Model/Channels/ChannelFeatures.cs b/MediaBrowser.Model/Channels/ChannelFeatures.cs index d925b78b6..1ca8e80a6 100644 --- a/MediaBrowser.Model/Channels/ChannelFeatures.cs +++ b/MediaBrowser.Model/Channels/ChannelFeatures.cs @@ -1,4 +1,3 @@ -#nullable disable #pragma warning disable CS1591 using System; @@ -7,11 +6,14 @@ namespace MediaBrowser.Model.Channels { public class ChannelFeatures { - public ChannelFeatures() + public ChannelFeatures(string name, Guid id) { MediaTypes = Array.Empty<ChannelMediaType>(); ContentTypes = Array.Empty<ChannelMediaContentType>(); DefaultSortFields = Array.Empty<ChannelItemSortField>(); + + Name = name; + Id = id; } /// <summary> @@ -24,7 +26,7 @@ namespace MediaBrowser.Model.Channels /// Gets or sets the identifier. /// </summary> /// <value>The identifier.</value> - public string Id { get; set; } + public Guid Id { get; set; } /// <summary> /// Gets or sets a value indicating whether this instance can search. diff --git a/MediaBrowser.Model/Channels/ChannelQuery.cs b/MediaBrowser.Model/Channels/ChannelQuery.cs index 59966127f..f9380ce3a 100644 --- a/MediaBrowser.Model/Channels/ChannelQuery.cs +++ b/MediaBrowser.Model/Channels/ChannelQuery.cs @@ -1,4 +1,3 @@ -#nullable disable #pragma warning disable CS1591 using System; @@ -13,13 +12,13 @@ namespace MediaBrowser.Model.Channels /// Gets or sets the fields to return within the items, in addition to basic information. /// </summary> /// <value>The fields.</value> - public ItemFields[] Fields { get; set; } + public ItemFields[]? Fields { get; set; } public bool? EnableImages { get; set; } public int? ImageTypeLimit { get; set; } - public ImageType[] EnableImageTypes { get; set; } + public ImageType[]? EnableImageTypes { get; set; } /// <summary> /// Gets or sets the user identifier. diff --git a/MediaBrowser.Model/ClientLog/ClientLogEvent.cs b/MediaBrowser.Model/ClientLog/ClientLogEvent.cs new file mode 100644 index 000000000..21087b564 --- /dev/null +++ b/MediaBrowser.Model/ClientLog/ClientLogEvent.cs @@ -0,0 +1,75 @@ +using System; +using Microsoft.Extensions.Logging; + +namespace MediaBrowser.Model.ClientLog +{ + /// <summary> + /// The client log event. + /// </summary> + public class ClientLogEvent + { + /// <summary> + /// Initializes a new instance of the <see cref="ClientLogEvent"/> class. + /// </summary> + /// <param name="timestamp">The log timestamp.</param> + /// <param name="level">The log level.</param> + /// <param name="userId">The user id.</param> + /// <param name="clientName">The client name.</param> + /// <param name="clientVersion">The client version.</param> + /// <param name="deviceId">The device id.</param> + /// <param name="message">The message.</param> + public ClientLogEvent( + DateTime timestamp, + LogLevel level, + Guid? userId, + string clientName, + string clientVersion, + string deviceId, + string message) + { + Timestamp = timestamp; + UserId = userId; + ClientName = clientName; + ClientVersion = clientVersion; + DeviceId = deviceId; + Message = message; + Level = level; + } + + /// <summary> + /// Gets the event timestamp. + /// </summary> + public DateTime Timestamp { get; } + + /// <summary> + /// Gets the log level. + /// </summary> + public LogLevel Level { get; } + + /// <summary> + /// Gets the user id. + /// </summary> + public Guid? UserId { get; } + + /// <summary> + /// Gets the client name. + /// </summary> + public string ClientName { get; } + + /// <summary> + /// Gets the client version. + /// </summary> + public string ClientVersion { get; } + + /// + /// <summary> + /// Gets the device id. + /// </summary> + public string DeviceId { get; } + + /// <summary> + /// Gets the log message. + /// </summary> + public string Message { get; } + } +} diff --git a/MediaBrowser.Model/Configuration/BaseApplicationConfiguration.cs b/MediaBrowser.Model/Configuration/BaseApplicationConfiguration.cs index b00d2fffb..57759a7d3 100644 --- a/MediaBrowser.Model/Configuration/BaseApplicationConfiguration.cs +++ b/MediaBrowser.Model/Configuration/BaseApplicationConfiguration.cs @@ -1,4 +1,3 @@ -#nullable disable using System; using System.Xml.Serialization; @@ -35,21 +34,21 @@ namespace MediaBrowser.Model.Configuration /// Gets or sets the cache path. /// </summary> /// <value>The cache path.</value> - public string CachePath { get; set; } + public string? CachePath { get; set; } /// <summary> /// Gets or sets the last known version that was ran using the configuration. /// </summary> /// <value>The version from previous run.</value> [XmlIgnore] - public Version PreviousVersion { get; set; } + public Version? PreviousVersion { get; set; } /// <summary> /// Gets or sets the stringified PreviousVersion to be stored/loaded, /// because System.Version itself isn't xml-serializable. /// </summary> /// <value>String value of PreviousVersion.</value> - public string PreviousVersionStr + public string? PreviousVersionStr { get => PreviousVersion?.ToString(); set diff --git a/MediaBrowser.Model/Configuration/EncodingOptions.cs b/MediaBrowser.Model/Configuration/EncodingOptions.cs index 365bbeef6..d0ded99ea 100644 --- a/MediaBrowser.Model/Configuration/EncodingOptions.cs +++ b/MediaBrowser.Model/Configuration/EncodingOptions.cs @@ -16,12 +16,9 @@ namespace MediaBrowser.Model.Configuration // This is a DRM device that is almost guaranteed to be there on every intel platform, // plus it's the default one in ffmpeg if you don't specify anything VaapiDevice = "/dev/dri/renderD128"; - // This is the OpenCL device that is used for tonemapping. - // The left side of the dot is the platform number, and the right side is the device number on the platform. - OpenclDevice = "0.0"; EnableTonemapping = false; EnableVppTonemapping = false; - TonemappingAlgorithm = "hable"; + TonemappingAlgorithm = "bt2390"; TonemappingRange = "auto"; TonemappingDesat = 0; TonemappingThreshold = 0.8; @@ -34,6 +31,9 @@ namespace MediaBrowser.Model.Configuration EnableDecodingColorDepth10Hevc = true; EnableDecodingColorDepth10Vp9 = true; EnableEnhancedNvdecDecoder = true; + PreferSystemNativeHwDecoder = true; + EnableIntelLowPowerH264HwEncoder = false; + EnableIntelLowPowerHevcHwEncoder = false; EnableHardwareEncoding = true; AllowHevcEncoding = false; EnableSubtitleExtraction = true; @@ -70,8 +70,6 @@ namespace MediaBrowser.Model.Configuration public string VaapiDevice { get; set; } - public string OpenclDevice { get; set; } - public bool EnableTonemapping { get; set; } public bool EnableVppTonemapping { get; set; } @@ -104,6 +102,12 @@ namespace MediaBrowser.Model.Configuration public bool EnableEnhancedNvdecDecoder { get; set; } + public bool PreferSystemNativeHwDecoder { get; set; } + + public bool EnableIntelLowPowerH264HwEncoder { get; set; } + + public bool EnableIntelLowPowerHevcHwEncoder { get; set; } + public bool EnableHardwareEncoding { get; set; } public bool AllowHevcEncoding { get; set; } diff --git a/MediaBrowser.Model/Configuration/LibraryOptions.cs b/MediaBrowser.Model/Configuration/LibraryOptions.cs index 24698360e..d3ce6aa7f 100644 --- a/MediaBrowser.Model/Configuration/LibraryOptions.cs +++ b/MediaBrowser.Model/Configuration/LibraryOptions.cs @@ -1,4 +1,3 @@ -#nullable disable #pragma warning disable CS1591 using System; @@ -17,11 +16,11 @@ namespace MediaBrowser.Model.Configuration SkipSubtitlesIfAudioTrackMatches = true; RequirePerfectSubtitleMatch = true; + AutomaticallyAddToCollection = true; EnablePhotos = true; SaveSubtitlesWithMedia = true; EnableRealtimeMonitor = true; PathInfos = Array.Empty<MediaPathInfo>(); - EnableInternetProviders = true; EnableAutomaticSeriesGrouping = true; SeasonZeroDisplayName = "Specials"; } @@ -38,6 +37,7 @@ namespace MediaBrowser.Model.Configuration public bool SaveLocalMetadata { get; set; } + [Obsolete("Disable remote providers in TypeOptions instead")] public bool EnableInternetProviders { get; set; } public bool EnableAutomaticSeriesGrouping { get; set; } @@ -52,21 +52,21 @@ namespace MediaBrowser.Model.Configuration /// Gets or sets the preferred metadata language. /// </summary> /// <value>The preferred metadata language.</value> - public string PreferredMetadataLanguage { get; set; } + public string? PreferredMetadataLanguage { get; set; } /// <summary> /// Gets or sets the metadata country code. /// </summary> /// <value>The metadata country code.</value> - public string MetadataCountryCode { get; set; } + public string? MetadataCountryCode { get; set; } public string SeasonZeroDisplayName { get; set; } - public string[] MetadataSavers { get; set; } + public string[]? MetadataSavers { get; set; } public string[] DisabledLocalMetadataReaders { get; set; } - public string[] LocalMetadataReaderOrder { get; set; } + public string[]? LocalMetadataReaderOrder { get; set; } public string[] DisabledSubtitleFetchers { get; set; } @@ -76,15 +76,17 @@ namespace MediaBrowser.Model.Configuration public bool SkipSubtitlesIfAudioTrackMatches { get; set; } - public string[] SubtitleDownloadLanguages { get; set; } + public string[]? SubtitleDownloadLanguages { get; set; } public bool RequirePerfectSubtitleMatch { get; set; } public bool SaveSubtitlesWithMedia { get; set; } + public bool AutomaticallyAddToCollection { get; set; } + public TypeOptions[] TypeOptions { get; set; } - public TypeOptions GetTypeOptions(string type) + public TypeOptions? GetTypeOptions(string type) { foreach (var options in TypeOptions) { diff --git a/MediaBrowser.Model/Configuration/MetadataOptions.cs b/MediaBrowser.Model/Configuration/MetadataOptions.cs index 76b72bd08..384a7997f 100644 --- a/MediaBrowser.Model/Configuration/MetadataOptions.cs +++ b/MediaBrowser.Model/Configuration/MetadataOptions.cs @@ -1,5 +1,5 @@ #nullable disable -#pragma warning disable CS1591 +#pragma warning disable CS1591, CA1819 using System; diff --git a/MediaBrowser.Model/Configuration/ServerConfiguration.cs b/MediaBrowser.Model/Configuration/ServerConfiguration.cs index d1e999666..46e61ee1a 100644 --- a/MediaBrowser.Model/Configuration/ServerConfiguration.cs +++ b/MediaBrowser.Model/Configuration/ServerConfiguration.cs @@ -13,18 +13,6 @@ namespace MediaBrowser.Model.Configuration /// </summary> public class ServerConfiguration : BaseApplicationConfiguration { - /// <summary> - /// The default value for <see cref="HttpServerPortNumber"/>. - /// </summary> - public const int DefaultHttpPort = 8096; - - /// <summary> - /// The default value for <see cref="PublicHttpsPort"/> and <see cref="HttpsPortNumber"/>. - /// </summary> - public const int DefaultHttpsPort = 8920; - - private string _baseUrl = string.Empty; - /// <summary> /// Initializes a new instance of the <see cref="ServerConfiguration" /> class. /// </summary> @@ -75,149 +63,13 @@ namespace MediaBrowser.Model.Configuration }; } - /// <summary> - /// Gets or sets a value indicating whether to enable automatic port forwarding. - /// </summary> - public bool EnableUPnP { get; set; } = false; - /// <summary> /// Gets or sets a value indicating whether to enable prometheus metrics exporting. /// </summary> public bool EnableMetrics { get; set; } = false; - /// <summary> - /// Gets or sets the public mapped port. - /// </summary> - /// <value>The public mapped port.</value> - public int PublicPort { get; set; } = DefaultHttpPort; - - /// <summary> - /// Gets or sets a value indicating whether the http port should be mapped as part of UPnP automatic port forwarding. - /// </summary> - public bool UPnPCreateHttpPortMap { get; set; } = false; - - /// <summary> - /// Gets or sets client udp port range. - /// </summary> - public string UDPPortRange { get; set; } = string.Empty; - - /// <summary> - /// Gets or sets a value indicating whether IPV6 capability is enabled. - /// </summary> - public bool EnableIPV6 { get; set; } = false; - - /// <summary> - /// Gets or sets a value indicating whether IPV4 capability is enabled. - /// </summary> - public bool EnableIPV4 { get; set; } = true; - - /// <summary> - /// Gets or sets a value indicating whether detailed ssdp logs are sent to the console/log. - /// "Emby.Dlna": "Debug" must be set in logging.default.json for this property to work. - /// </summary> - public bool EnableSSDPTracing { get; set; } = false; - - /// <summary> - /// Gets or sets a value indicating whether an IP address is to be used to filter the detailed ssdp logs that are being sent to the console/log. - /// If the setting "Emby.Dlna": "Debug" msut be set in logging.default.json for this property to work. - /// </summary> - public string SSDPTracingFilter { get; set; } = string.Empty; - - /// <summary> - /// Gets or sets the number of times SSDP UDP messages are sent. - /// </summary> - public int UDPSendCount { get; set; } = 2; - - /// <summary> - /// Gets or sets the delay between each groups of SSDP messages (in ms). - /// </summary> - public int UDPSendDelay { get; set; } = 100; - - /// <summary> - /// Gets or sets a value indicating whether address names that match <see cref="VirtualInterfaceNames"/> should be Ignore for the purposes of binding. - /// </summary> - public bool IgnoreVirtualInterfaces { get; set; } = true; - - /// <summary> - /// Gets or sets a value indicating the interfaces that should be ignored. The list can be comma separated. <seealso cref="IgnoreVirtualInterfaces"/>. - /// </summary> - public string VirtualInterfaceNames { get; set; } = "vEthernet*"; - - /// <summary> - /// Gets or sets the time (in seconds) between the pings of SSDP gateway monitor. - /// </summary> - public int GatewayMonitorPeriod { get; set; } = 60; - - /// <summary> - /// Gets a value indicating whether multi-socket binding is available. - /// </summary> - public bool EnableMultiSocketBinding { get; } = true; - - /// <summary> - /// Gets or sets a value indicating whether all IPv6 interfaces should be treated as on the internal network. - /// Depending on the address range implemented ULA ranges might not be used. - /// </summary> - public bool TrustAllIP6Interfaces { get; set; } = false; - - /// <summary> - /// Gets or sets the ports that HDHomerun uses. - /// </summary> - public string HDHomerunPortRange { get; set; } = string.Empty; - - /// <summary> - /// Gets or sets PublishedServerUri to advertise for specific subnets. - /// </summary> - public string[] PublishedServerUriBySubnet { get; set; } = Array.Empty<string>(); - - /// <summary> - /// Gets or sets a value indicating whether Autodiscovery tracing is enabled. - /// </summary> - public bool AutoDiscoveryTracing { get; set; } = false; - - /// <summary> - /// Gets or sets a value indicating whether Autodiscovery is enabled. - /// </summary> - public bool AutoDiscovery { get; set; } = true; - - /// <summary> - /// Gets or sets the public HTTPS port. - /// </summary> - /// <value>The public HTTPS port.</value> - public int PublicHttpsPort { get; set; } = DefaultHttpsPort; - - /// <summary> - /// Gets or sets the HTTP server port number. - /// </summary> - /// <value>The HTTP server port number.</value> - public int HttpServerPortNumber { get; set; } = DefaultHttpPort; - - /// <summary> - /// Gets or sets the HTTPS server port number. - /// </summary> - /// <value>The HTTPS server port number.</value> - public int HttpsPortNumber { get; set; } = DefaultHttpsPort; - - /// <summary> - /// Gets or sets a value indicating whether to use HTTPS. - /// </summary> - /// <remarks> - /// In order for HTTPS to be used, in addition to setting this to true, valid values must also be - /// provided for <see cref="CertificatePath"/> and <see cref="CertificatePassword"/>. - /// </remarks> - public bool EnableHttps { get; set; } = false; - public bool EnableNormalizedItemByNameIds { get; set; } = true; - /// <summary> - /// Gets or sets the filesystem path of an X.509 certificate to use for SSL. - /// </summary> - public string CertificatePath { get; set; } = string.Empty; - - /// <summary> - /// Gets or sets the password required to access the X.509 certificate data in the file specified by <see cref="CertificatePath"/>. - /// </summary> - public string CertificatePassword { get; set; } = string.Empty; - /// <summary> /// Gets or sets a value indicating whether this instance is port authorized. /// </summary> @@ -229,11 +81,6 @@ namespace MediaBrowser.Model.Configuration /// </summary> public bool QuickConnectAvailable { get; set; } = false; - /// <summary> - /// Gets or sets a value indicating whether access outside of the LAN is permitted. - /// </summary> - public bool EnableRemoteAccess { get; set; } = true; - /// <summary> /// Gets or sets a value indicating whether [enable case sensitive item ids]. /// </summary> @@ -318,13 +165,6 @@ namespace MediaBrowser.Model.Configuration /// <value>The file watcher delay.</value> public int LibraryMonitorDelay { get; set; } = 60; - /// <summary> - /// Gets or sets a value indicating whether [enable dashboard response caching]. - /// Allows potential contributors without visual studio to modify production dashboard code and test changes. - /// </summary> - /// <value><c>true</c> if [enable dashboard response caching]; otherwise, <c>false</c>.</value> - public bool EnableDashboardResponseCaching { get; set; } = true; - /// <summary> /// Gets or sets the image saving convention. /// </summary> @@ -337,36 +177,6 @@ namespace MediaBrowser.Model.Configuration public string ServerName { get; set; } = string.Empty; - public string BaseUrl - { - get => _baseUrl; - set - { - // Normalize the start of the string - if (string.IsNullOrWhiteSpace(value)) - { - // If baseUrl is empty, set an empty prefix string - _baseUrl = string.Empty; - return; - } - - if (value[0] != '/') - { - // If baseUrl was not configured with a leading slash, append one for consistency - value = "/" + value; - } - - // Normalize the end of the string - if (value[value.Length - 1] == '/') - { - // If baseUrl was configured with a trailing slash, remove it for consistency - value = value.Remove(value.Length - 1); - } - - _baseUrl = value; - } - } - public string UICulture { get; set; } = "en-US"; public bool SaveMetadataHidden { get; set; } = false; @@ -381,45 +191,16 @@ namespace MediaBrowser.Model.Configuration public bool DisplaySpecialsWithinSeasons { get; set; } = true; - /// <summary> - /// Gets or sets the subnets that are deemed to make up the LAN. - /// </summary> - public string[] LocalNetworkSubnets { get; set; } = Array.Empty<string>(); - - /// <summary> - /// Gets or sets the interface addresses which Jellyfin will bind to. If empty, all interfaces will be used. - /// </summary> - public string[] LocalNetworkAddresses { get; set; } = Array.Empty<string>(); - public string[] CodecsUsed { get; set; } = Array.Empty<string>(); public List<RepositoryInfo> PluginRepositories { get; set; } = new List<RepositoryInfo>(); public bool EnableExternalContentInSuggestions { get; set; } = true; - /// <summary> - /// Gets or sets a value indicating whether the server should force connections over HTTPS. - /// </summary> - public bool RequireHttps { get; set; } = false; - - public bool EnableNewOmdbSupport { get; set; } = true; - - /// <summary> - /// Gets or sets the filter for remote IP connectivity. Used in conjuntion with <seealso cref="IsRemoteIPFilterBlacklist"/>. - /// </summary> - public string[] RemoteIPFilter { get; set; } = Array.Empty<string>(); - - /// <summary> - /// Gets or sets a value indicating whether <seealso cref="RemoteIPFilter"/> contains a blacklist or a whitelist. Default is a whitelist. - /// </summary> - public bool IsRemoteIPFilterBlacklist { get; set; } = false; - public int ImageExtractionTimeoutMs { get; set; } = 0; public PathSubstitution[] PathSubstitutions { get; set; } = Array.Empty<PathSubstitution>(); - public string[] UninstalledPlugins { get; set; } = Array.Empty<string>(); - /// <summary> /// Gets or sets a value indicating whether slow server responses should be logged as a warning. /// </summary> @@ -435,11 +216,6 @@ namespace MediaBrowser.Model.Configuration /// </summary> public string[] CorsHosts { get; set; } = new[] { "*" }; - /// <summary> - /// Gets or sets the known proxies. - /// </summary> - public string[] KnownProxies { get; set; } = Array.Empty<string>(); - /// <summary> /// Gets or sets the number of days we should retain activity logs. /// </summary> @@ -459,5 +235,10 @@ namespace MediaBrowser.Model.Configuration /// Gets or sets a value indicating whether older plugins should automatically be deleted from the plugin folder. /// </summary> public bool RemoveOldPlugins { get; set; } + + /// <summary> + /// Gets or sets a value indicating whether clients should be allowed to upload logs. + /// </summary> + public bool AllowClientLogUpload { get; set; } = true; } } diff --git a/MediaBrowser.Model/Configuration/UserConfiguration.cs b/MediaBrowser.Model/Configuration/UserConfiguration.cs index 935e6cbe1..81359462c 100644 --- a/MediaBrowser.Model/Configuration/UserConfiguration.cs +++ b/MediaBrowser.Model/Configuration/UserConfiguration.cs @@ -1,4 +1,3 @@ -#nullable disable #pragma warning disable CS1591 using System; @@ -33,7 +32,7 @@ namespace MediaBrowser.Model.Configuration /// Gets or sets the audio language preference. /// </summary> /// <value>The audio language preference.</value> - public string AudioLanguagePreference { get; set; } + public string? AudioLanguagePreference { get; set; } /// <summary> /// Gets or sets a value indicating whether [play default audio track]. @@ -45,7 +44,7 @@ namespace MediaBrowser.Model.Configuration /// Gets or sets the subtitle language preference. /// </summary> /// <value>The subtitle language preference.</value> - public string SubtitleLanguagePreference { get; set; } + public string? SubtitleLanguagePreference { get; set; } public bool DisplayMissingEpisodes { get; set; } diff --git a/MediaBrowser.Model/Configuration/XbmcMetadataOptions.cs b/MediaBrowser.Model/Configuration/XbmcMetadataOptions.cs index 8ad070dcb..07129d715 100644 --- a/MediaBrowser.Model/Configuration/XbmcMetadataOptions.cs +++ b/MediaBrowser.Model/Configuration/XbmcMetadataOptions.cs @@ -1,4 +1,3 @@ -#nullable disable #pragma warning disable CS1591 namespace MediaBrowser.Model.Configuration @@ -13,7 +12,7 @@ namespace MediaBrowser.Model.Configuration EnablePathSubstitution = true; } - public string UserId { get; set; } + public string? UserId { get; set; } public string ReleaseDateFormat { get; set; } diff --git a/MediaBrowser.Common/Cryptography/Constants.cs b/MediaBrowser.Model/Cryptography/Constants.cs similarity index 55% rename from MediaBrowser.Common/Cryptography/Constants.cs rename to MediaBrowser.Model/Cryptography/Constants.cs index 354114232..f2ebb5d3d 100644 --- a/MediaBrowser.Common/Cryptography/Constants.cs +++ b/MediaBrowser.Model/Cryptography/Constants.cs @@ -1,4 +1,4 @@ -namespace MediaBrowser.Common.Cryptography +namespace MediaBrowser.Model.Cryptography { /// <summary> /// Class containing global constants for Jellyfin Cryptography. @@ -8,11 +8,16 @@ namespace MediaBrowser.Common.Cryptography /// <summary> /// The default length for new salts. /// </summary> - public const int DefaultSaltLength = 64; + public const int DefaultSaltLength = 128 / 8; + + /// <summary> + /// The default output length. + /// </summary> + public const int DefaultOutputLength = 512 / 8; /// <summary> /// The default amount of iterations for hashing passwords. /// </summary> - public const int DefaultIterations = 1000; + public const int DefaultIterations = 120000; } } diff --git a/MediaBrowser.Model/Cryptography/ICryptoProvider.cs b/MediaBrowser.Model/Cryptography/ICryptoProvider.cs index d8b7d848a..6c521578c 100644 --- a/MediaBrowser.Model/Cryptography/ICryptoProvider.cs +++ b/MediaBrowser.Model/Cryptography/ICryptoProvider.cs @@ -1,6 +1,6 @@ #pragma warning disable CS1591 -using System.Collections.Generic; +using System; namespace MediaBrowser.Model.Cryptography { @@ -8,11 +8,14 @@ namespace MediaBrowser.Model.Cryptography { string DefaultHashMethod { get; } - IEnumerable<string> GetSupportedHashMethods(); + /// <summary> + /// Creates a new <see cref="PasswordHash" /> instance. + /// </summary> + /// <param name="password">The password that will be hashed.</param> + /// <returns>A <see cref="PasswordHash" /> instance with the hash method, hash, salt and number of iterations.</returns> + PasswordHash CreatePasswordHash(ReadOnlySpan<char> password); - byte[] ComputeHash(string hashMethod, byte[] bytes, byte[] salt); - - byte[] ComputeHashWithDefaultMethod(byte[] bytes, byte[] salt); + bool Verify(PasswordHash hash, ReadOnlySpan<char> password); byte[] GenerateSalt(); diff --git a/MediaBrowser.Common/Cryptography/PasswordHash.cs b/MediaBrowser.Model/Cryptography/PasswordHash.cs similarity index 99% rename from MediaBrowser.Common/Cryptography/PasswordHash.cs rename to MediaBrowser.Model/Cryptography/PasswordHash.cs index 0e2065302..eec541041 100644 --- a/MediaBrowser.Common/Cryptography/PasswordHash.cs +++ b/MediaBrowser.Model/Cryptography/PasswordHash.cs @@ -4,7 +4,7 @@ using System; using System.Collections.Generic; using System.Text; -namespace MediaBrowser.Common.Cryptography +namespace MediaBrowser.Model.Cryptography { // Defined from this hash storage spec // https://github.com/P-H-C/phc-string-format/blob/master/phc-sf-spec.md diff --git a/MediaBrowser.Model/Dlna/CodecProfile.cs b/MediaBrowser.Model/Dlna/CodecProfile.cs index 8343cf028..f857bf3a8 100644 --- a/MediaBrowser.Model/Dlna/CodecProfile.cs +++ b/MediaBrowser.Model/Dlna/CodecProfile.cs @@ -2,8 +2,8 @@ #pragma warning disable CS1591 using System; -using System.Linq; using System.Xml.Serialization; +using Jellyfin.Extensions; namespace MediaBrowser.Model.Dlna { @@ -58,7 +58,7 @@ namespace MediaBrowser.Model.Dlna foreach (var val in codec) { - if (codecs.Contains(val, StringComparer.OrdinalIgnoreCase)) + if (codecs.Contains(val, StringComparison.OrdinalIgnoreCase)) { return true; } diff --git a/MediaBrowser.Model/Dlna/ConditionProcessor.cs b/MediaBrowser.Model/Dlna/ConditionProcessor.cs index 55c4dd074..8d03b4c0b 100644 --- a/MediaBrowser.Model/Dlna/ConditionProcessor.cs +++ b/MediaBrowser.Model/Dlna/ConditionProcessor.cs @@ -2,7 +2,7 @@ using System; using System.Globalization; -using System.Linq; +using Jellyfin.Extensions; using MediaBrowser.Model.MediaInfo; namespace MediaBrowser.Model.Dlna @@ -167,7 +167,7 @@ namespace MediaBrowser.Model.Dlna switch (condition.Condition) { case ProfileConditionType.EqualsAny: - return expected.Split('|').Contains(currentValue, StringComparer.OrdinalIgnoreCase); + return expected.Split('|').Contains(currentValue, StringComparison.OrdinalIgnoreCase); case ProfileConditionType.Equals: return string.Equals(currentValue, expected, StringComparison.OrdinalIgnoreCase); case ProfileConditionType.NotEquals: diff --git a/MediaBrowser.Model/Dlna/ContainerProfile.cs b/MediaBrowser.Model/Dlna/ContainerProfile.cs index 740966088..c6befdd85 100644 --- a/MediaBrowser.Model/Dlna/ContainerProfile.cs +++ b/MediaBrowser.Model/Dlna/ContainerProfile.cs @@ -1,8 +1,8 @@ #pragma warning disable CS1591 using System; -using System.Linq; using System.Xml.Serialization; +using Jellyfin.Extensions; namespace MediaBrowser.Model.Dlna { @@ -62,7 +62,7 @@ namespace MediaBrowser.Model.Dlna foreach (var container in allInputContainers) { - if (profileContainers.Contains(container, StringComparer.OrdinalIgnoreCase)) + if (profileContainers.Contains(container, StringComparison.OrdinalIgnoreCase)) { return !isNegativeList; } diff --git a/MediaBrowser.Model/Dlna/ContentFeatureBuilder.cs b/MediaBrowser.Model/Dlna/ContentFeatureBuilder.cs index 600a44157..58b06ca1d 100644 --- a/MediaBrowser.Model/Dlna/ContentFeatureBuilder.cs +++ b/MediaBrowser.Model/Dlna/ContentFeatureBuilder.cs @@ -151,10 +151,12 @@ namespace MediaBrowser.Model.Dlna DlnaFlags.InteractiveTransferMode | DlnaFlags.DlnaV15; - // if (isDirectStream) - // { - // flagValue = flagValue | DlnaFlags.ByteBasedSeek; - // } + if (isDirectStream) + { + flagValue |= DlnaFlags.ByteBasedSeek; + } + + // Time based seek is curently disabled when streaming. On LG CX3 adding DlnaFlags.TimeBasedSeek and orgPn causes the DLNA playback to fail (format not supported). Further investigations are needed before enabling the remaining code paths. // else if (runtimeTicks.HasValue) // { // flagValue = flagValue | DlnaFlags.TimeBasedSeek; @@ -208,7 +210,11 @@ namespace MediaBrowser.Model.Dlna if (string.IsNullOrEmpty(orgPn)) { contentFeatureList.Add(orgOp.TrimStart(';') + orgCi + dlnaflags); - continue; + } + else if (isDirectStream) + { + // orgOp should be added all the time once the time based seek is resolved for transcoded streams + contentFeatureList.Add("DLNA.ORG_PN=" + orgPn + orgOp + orgCi + dlnaflags); } else { diff --git a/MediaBrowser.Model/Dlna/DeviceIdentification.cs b/MediaBrowser.Model/Dlna/DeviceIdentification.cs index c511801f4..6625b7981 100644 --- a/MediaBrowser.Model/Dlna/DeviceIdentification.cs +++ b/MediaBrowser.Model/Dlna/DeviceIdentification.cs @@ -1,4 +1,3 @@ -#nullable disable #pragma warning disable CS1591 using System; diff --git a/MediaBrowser.Model/Dlna/DeviceProfile.cs b/MediaBrowser.Model/Dlna/DeviceProfile.cs index feb3d880e..6170ff5bd 100644 --- a/MediaBrowser.Model/Dlna/DeviceProfile.cs +++ b/MediaBrowser.Model/Dlna/DeviceProfile.cs @@ -1,8 +1,8 @@ #pragma warning disable CA1819 // Properties should not return arrays using System; using System.ComponentModel; -using System.Linq; using System.Xml.Serialization; +using Jellyfin.Extensions; using MediaBrowser.Model.MediaInfo; namespace MediaBrowser.Model.Dlna @@ -253,7 +253,7 @@ namespace MediaBrowser.Model.Dlna continue; } - if (!i.GetAudioCodecs().Contains(audioCodec ?? string.Empty, StringComparer.OrdinalIgnoreCase)) + if (!i.GetAudioCodecs().Contains(audioCodec ?? string.Empty, StringComparison.OrdinalIgnoreCase)) { continue; } @@ -287,7 +287,7 @@ namespace MediaBrowser.Model.Dlna continue; } - if (!i.GetAudioCodecs().Contains(audioCodec ?? string.Empty, StringComparer.OrdinalIgnoreCase)) + if (!i.GetAudioCodecs().Contains(audioCodec ?? string.Empty, StringComparison.OrdinalIgnoreCase)) { continue; } @@ -328,7 +328,7 @@ namespace MediaBrowser.Model.Dlna } var audioCodecs = i.GetAudioCodecs(); - if (audioCodecs.Length > 0 && !audioCodecs.Contains(audioCodec ?? string.Empty, StringComparer.OrdinalIgnoreCase)) + if (audioCodecs.Length > 0 && !audioCodecs.Contains(audioCodec ?? string.Empty, StringComparison.OrdinalIgnoreCase)) { continue; } @@ -469,13 +469,13 @@ namespace MediaBrowser.Model.Dlna } var audioCodecs = i.GetAudioCodecs(); - if (audioCodecs.Length > 0 && !audioCodecs.Contains(audioCodec ?? string.Empty, StringComparer.OrdinalIgnoreCase)) + if (audioCodecs.Length > 0 && !audioCodecs.Contains(audioCodec ?? string.Empty, StringComparison.OrdinalIgnoreCase)) { continue; } var videoCodecs = i.GetVideoCodecs(); - if (videoCodecs.Length > 0 && !videoCodecs.Contains(videoCodec ?? string.Empty, StringComparer.OrdinalIgnoreCase)) + if (videoCodecs.Length > 0 && !videoCodecs.Contains(videoCodec ?? string.Empty, StringComparison.OrdinalIgnoreCase)) { continue; } diff --git a/MediaBrowser.Model/Dlna/DlnaMaps.cs b/MediaBrowser.Model/Dlna/DlnaMaps.cs index 95cd0ac27..4613bc542 100644 --- a/MediaBrowser.Model/Dlna/DlnaMaps.cs +++ b/MediaBrowser.Model/Dlna/DlnaMaps.cs @@ -6,20 +6,6 @@ namespace MediaBrowser.Model.Dlna { public static class DlnaMaps { - private static readonly string DefaultStreaming = - FlagsToString(DlnaFlags.StreamingTransferMode | - DlnaFlags.BackgroundTransferMode | - DlnaFlags.ConnectionStall | - DlnaFlags.ByteBasedSeek | - DlnaFlags.DlnaV15); - - private static readonly string DefaultInteractive = - FlagsToString(DlnaFlags.InteractiveTransferMode | - DlnaFlags.BackgroundTransferMode | - DlnaFlags.ConnectionStall | - DlnaFlags.ByteBasedSeek | - DlnaFlags.DlnaV15); - public static string FlagsToString(DlnaFlags flags) { return string.Format(CultureInfo.InvariantCulture, "{0:X8}{1:D24}", (ulong)flags, 0); diff --git a/MediaBrowser.Model/Dlna/ITranscoderSupport.cs b/MediaBrowser.Model/Dlna/ITranscoderSupport.cs index d9bd094d9..a70ce44cc 100644 --- a/MediaBrowser.Model/Dlna/ITranscoderSupport.cs +++ b/MediaBrowser.Model/Dlna/ITranscoderSupport.cs @@ -1,4 +1,3 @@ -#nullable disable #pragma warning disable CS1591 namespace MediaBrowser.Model.Dlna diff --git a/MediaBrowser.Model/Dlna/ResolutionNormalizer.cs b/MediaBrowser.Model/Dlna/ResolutionNormalizer.cs index 806877ff0..94071b419 100644 --- a/MediaBrowser.Model/Dlna/ResolutionNormalizer.cs +++ b/MediaBrowser.Model/Dlna/ResolutionNormalizer.cs @@ -5,7 +5,7 @@ using System; namespace MediaBrowser.Model.Dlna { - public class ResolutionNormalizer + public static class ResolutionNormalizer { private static readonly ResolutionConfiguration[] Configurations = new[] diff --git a/MediaBrowser.Model/Dlna/SortCriteria.cs b/MediaBrowser.Model/Dlna/SortCriteria.cs index 7769d0bd3..7fef16e53 100644 --- a/MediaBrowser.Model/Dlna/SortCriteria.cs +++ b/MediaBrowser.Model/Dlna/SortCriteria.cs @@ -1,15 +1,24 @@ #pragma warning disable CS1591 +using System; using Jellyfin.Data.Enums; namespace MediaBrowser.Model.Dlna { public class SortCriteria { - public SortCriteria(string value) + public SortCriteria(string sortOrder) { + if (!string.IsNullOrEmpty(sortOrder) && Enum.TryParse<SortOrder>(sortOrder, true, out var sortOrderValue)) + { + SortOrder = sortOrderValue; + } + else + { + SortOrder = SortOrder.Ascending; + } } - public SortOrder SortOrder => SortOrder.Ascending; + public SortOrder SortOrder { get; } } } diff --git a/MediaBrowser.Model/Dlna/StreamBuilder.cs b/MediaBrowser.Model/Dlna/StreamBuilder.cs index 635420a76..d2ca21150 100644 --- a/MediaBrowser.Model/Dlna/StreamBuilder.cs +++ b/MediaBrowser.Model/Dlna/StreamBuilder.cs @@ -289,8 +289,8 @@ namespace MediaBrowser.Model.Dlna var directPlayInfo = GetAudioDirectPlayMethods(item, audioStream, options); - var directPlayMethods = directPlayInfo.Item1; - var transcodeReasons = directPlayInfo.Item2.ToList(); + var directPlayMethods = directPlayInfo.PlayMethods; + var transcodeReasons = directPlayInfo.TranscodeReasons.ToList(); int? inputAudioChannels = audioStream?.Channels; int? inputAudioBitrate = audioStream?.BitDepth; @@ -448,14 +448,14 @@ namespace MediaBrowser.Model.Dlna return options.GetMaxBitrate(isAudio); } - private (IEnumerable<PlayMethod>, IEnumerable<TranscodeReason>) GetAudioDirectPlayMethods(MediaSourceInfo item, MediaStream audioStream, AudioOptions options) + private (IEnumerable<PlayMethod> PlayMethods, IEnumerable<TranscodeReason> TranscodeReasons) GetAudioDirectPlayMethods(MediaSourceInfo item, MediaStream audioStream, AudioOptions options) { DirectPlayProfile directPlayProfile = options.Profile.DirectPlayProfiles .FirstOrDefault(x => x.Type == DlnaProfileType.Audio && IsAudioDirectPlaySupported(x, item, audioStream)); if (directPlayProfile == null) { - _logger.LogInformation( + _logger.LogDebug( "Profile: {0}, No audio direct play profiles found for {1} with codec {2}", options.Profile.Name ?? "Unknown Profile", item.Path ?? "Unknown path", @@ -677,12 +677,12 @@ namespace MediaBrowser.Model.Dlna var videoStream = item.VideoStream; // TODO: This doesn't account for situations where the device is able to handle the media's bitrate, but the connection isn't fast enough - var directPlayEligibilityResult = IsEligibleForDirectPlay(item, GetBitrateForDirectPlayCheck(item, options, true) ?? 0, subtitleStream, options, PlayMethod.DirectPlay); - var directStreamEligibilityResult = IsEligibleForDirectPlay(item, options.GetMaxBitrate(false) ?? 0, subtitleStream, options, PlayMethod.DirectStream); - bool isEligibleForDirectPlay = options.EnableDirectPlay && (options.ForceDirectPlay || directPlayEligibilityResult.Item1); - bool isEligibleForDirectStream = options.EnableDirectStream && (options.ForceDirectStream || directStreamEligibilityResult.Item1); + var directPlayEligibilityResult = IsEligibleForDirectPlay(item, GetBitrateForDirectPlayCheck(item, options, true) ?? 0, subtitleStream, audioStream, options, PlayMethod.DirectPlay); + var directStreamEligibilityResult = IsEligibleForDirectPlay(item, options.GetMaxBitrate(false) ?? 0, subtitleStream, audioStream, options, PlayMethod.DirectStream); + bool isEligibleForDirectPlay = options.EnableDirectPlay && (options.ForceDirectPlay || directPlayEligibilityResult.DirectPlay); + bool isEligibleForDirectStream = options.EnableDirectStream && (options.ForceDirectStream || directStreamEligibilityResult.DirectPlay); - _logger.LogInformation( + _logger.LogDebug( "Profile: {0}, Path: {1}, isEligibleForDirectPlay: {2}, isEligibleForDirectStream: {3}", options.Profile.Name ?? "Unknown Profile", item.Path ?? "Unknown path", @@ -695,7 +695,7 @@ namespace MediaBrowser.Model.Dlna { // See if it can be direct played var directPlayInfo = GetVideoDirectPlayProfile(options, item, videoStream, audioStream, isEligibleForDirectStream); - var directPlay = directPlayInfo.Item1; + var directPlay = directPlayInfo.PlayMethod; if (directPlay != null) { @@ -713,17 +713,17 @@ namespace MediaBrowser.Model.Dlna return playlistItem; } - transcodeReasons.AddRange(directPlayInfo.Item2); + transcodeReasons.AddRange(directPlayInfo.TranscodeReasons); } - if (directPlayEligibilityResult.Item2.HasValue) + if (directPlayEligibilityResult.Reason.HasValue) { - transcodeReasons.Add(directPlayEligibilityResult.Item2.Value); + transcodeReasons.Add(directPlayEligibilityResult.Reason.Value); } - if (directStreamEligibilityResult.Item2.HasValue) + if (directStreamEligibilityResult.Reason.HasValue) { - transcodeReasons.Add(directStreamEligibilityResult.Item2.Value); + transcodeReasons.Add(directStreamEligibilityResult.Reason.Value); } // Can't direct play, find the transcoding profile @@ -1000,7 +1000,7 @@ namespace MediaBrowser.Model.Dlna return 7168000; } - private (PlayMethod?, List<TranscodeReason>) GetVideoDirectPlayProfile( + private (PlayMethod? PlayMethod, List<TranscodeReason> TranscodeReasons) GetVideoDirectPlayProfile( VideoOptions options, MediaSourceInfo mediaSource, MediaStream videoStream, @@ -1033,7 +1033,7 @@ namespace MediaBrowser.Model.Dlna if (directPlay == null) { - _logger.LogInformation( + _logger.LogDebug( "Container: {Container}, Video: {Video}, Audio: {Audio} cannot be direct played by profile: {Profile} for path: {Path}", container, videoStream?.Codec ?? "no video", @@ -1198,7 +1198,7 @@ namespace MediaBrowser.Model.Dlna private void LogConditionFailure(DeviceProfile profile, string type, ProfileCondition condition, MediaSourceInfo mediaSource) { - _logger.LogInformation( + _logger.LogDebug( "Profile: {0}, DirectPlay=false. Reason={1}.{2} Condition: {3}. ConditionValue: {4}. IsRequired: {5}. Path: {6}", type, profile.Name ?? "Unknown Profile", @@ -1209,10 +1209,11 @@ namespace MediaBrowser.Model.Dlna mediaSource.Path ?? "Unknown path"); } - private (bool directPlay, TranscodeReason? reason) IsEligibleForDirectPlay( + private (bool DirectPlay, TranscodeReason? Reason) IsEligibleForDirectPlay( MediaSourceInfo item, long maxBitrate, MediaStream subtitleStream, + MediaStream audioStream, VideoOptions options, PlayMethod playMethod) { @@ -1220,16 +1221,27 @@ namespace MediaBrowser.Model.Dlna { var subtitleProfile = GetSubtitleProfile(item, subtitleStream, options.Profile.SubtitleProfiles, playMethod, _transcoderSupport, item.Container, null); - if (subtitleProfile.Method != SubtitleDeliveryMethod.External && subtitleProfile.Method != SubtitleDeliveryMethod.Embed) + if (subtitleProfile.Method != SubtitleDeliveryMethod.Drop + && subtitleProfile.Method != SubtitleDeliveryMethod.External + && subtitleProfile.Method != SubtitleDeliveryMethod.Embed) { - _logger.LogInformation("Not eligible for {0} due to unsupported subtitles", playMethod); + _logger.LogDebug("Not eligible for {0} due to unsupported subtitles", playMethod); return (false, TranscodeReason.SubtitleCodecNotSupported); } } bool result = IsAudioEligibleForDirectPlay(item, maxBitrate, playMethod); + if (!result) + { + return (false, TranscodeReason.ContainerBitrateExceedsLimit); + } - return (result, result ? (TranscodeReason?)null : TranscodeReason.ContainerBitrateExceedsLimit); + if (audioStream?.IsExternal == true) + { + return (false, TranscodeReason.AudioIsExternal); + } + + return (true, null); } public static SubtitleProfile GetSubtitleProfile( @@ -1404,7 +1416,7 @@ namespace MediaBrowser.Model.Dlna if (itemBitrate > requestedMaxBitrate) { - _logger.LogInformation( + _logger.LogDebug( "Bitrate exceeds {PlayBackMethod} limit: media bitrate: {MediaBitrate}, max bitrate: {MaxBitrate}", playMethod, itemBitrate, diff --git a/MediaBrowser.Model/Dlna/StreamInfo.cs b/MediaBrowser.Model/Dlna/StreamInfo.cs index 4414415a2..cf8465067 100644 --- a/MediaBrowser.Model/Dlna/StreamInfo.cs +++ b/MediaBrowser.Model/Dlna/StreamInfo.cs @@ -794,7 +794,7 @@ namespace MediaBrowser.Model.Dlna } // strip spaces to avoid having to encode h264 profile names - list.Add(new NameValuePair(pair.Key, pair.Value.Replace(" ", string.Empty))); + list.Add(new NameValuePair(pair.Key, pair.Value.Replace(" ", string.Empty, StringComparison.Ordinal))); } if (!item.IsDirectStream) diff --git a/MediaBrowser.Model/Dlna/SubtitleDeliveryMethod.cs b/MediaBrowser.Model/Dlna/SubtitleDeliveryMethod.cs index 9b39f9e11..69bda2d91 100644 --- a/MediaBrowser.Model/Dlna/SubtitleDeliveryMethod.cs +++ b/MediaBrowser.Model/Dlna/SubtitleDeliveryMethod.cs @@ -25,6 +25,11 @@ namespace MediaBrowser.Model.Dlna /// <summary> /// Serve the subtitles as a separate HLS stream. /// </summary> - Hls = 3 + Hls = 3, + + /// <summary> + /// Drop the subtitle. + /// </summary> + Drop = 4 } } diff --git a/MediaBrowser.Model/Dlna/SubtitleProfile.cs b/MediaBrowser.Model/Dlna/SubtitleProfile.cs index 01e3c696b..9ebde25ff 100644 --- a/MediaBrowser.Model/Dlna/SubtitleProfile.cs +++ b/MediaBrowser.Model/Dlna/SubtitleProfile.cs @@ -2,8 +2,8 @@ #pragma warning disable CS1591 using System; -using System.Linq; using System.Xml.Serialization; +using Jellyfin.Extensions; namespace MediaBrowser.Model.Dlna { @@ -42,7 +42,7 @@ namespace MediaBrowser.Model.Dlna } var languages = GetLanguages(); - return languages.Length == 0 || languages.Contains(subLanguage, StringComparer.OrdinalIgnoreCase); + return languages.Length == 0 || languages.Contains(subLanguage, StringComparison.OrdinalIgnoreCase); } } } diff --git a/MediaBrowser.Model/Drawing/ImageFormatExtensions.cs b/MediaBrowser.Model/Drawing/ImageFormatExtensions.cs new file mode 100644 index 000000000..68a5c2534 --- /dev/null +++ b/MediaBrowser.Model/Drawing/ImageFormatExtensions.cs @@ -0,0 +1,27 @@ +using System.ComponentModel; +using System.Net.Mime; + +namespace MediaBrowser.Model.Drawing; + +/// <summary> +/// Extension class for the <see cref="ImageFormat" /> enum. +/// </summary> +public static class ImageFormatExtensions +{ + /// <summary> + /// Returns the correct mime type for this <see cref="ImageFormat" />. + /// </summary> + /// <param name="format">This <see cref="ImageFormat" />.</param> + /// <exception cref="InvalidEnumArgumentException">The <paramref name="format"/> is an invalid enumeration value.</exception> + /// <returns>The correct mime type for this <see cref="ImageFormat" />.</returns> + public static string GetMimeType(this ImageFormat format) + => format switch + { + ImageFormat.Bmp => "image/bmp", + ImageFormat.Gif => MediaTypeNames.Image.Gif, + ImageFormat.Jpg => MediaTypeNames.Image.Jpeg, + ImageFormat.Png => "image/png", + ImageFormat.Webp => "image/webp", + _ => throw new InvalidEnumArgumentException(nameof(format), (int)format, typeof(ImageFormat)) + }; +} diff --git a/Jellyfin.Api/Models/DisplayPreferencesDtos/DisplayPreferencesDto.cs b/MediaBrowser.Model/Dto/DisplayPreferencesDto.cs similarity index 92% rename from Jellyfin.Api/Models/DisplayPreferencesDtos/DisplayPreferencesDto.cs rename to MediaBrowser.Model/Dto/DisplayPreferencesDto.cs index 249d828d3..90163ae91 100644 --- a/Jellyfin.Api/Models/DisplayPreferencesDtos/DisplayPreferencesDto.cs +++ b/MediaBrowser.Model/Dto/DisplayPreferencesDto.cs @@ -1,7 +1,7 @@ -using System.Collections.Generic; +using System.Collections.Generic; using Jellyfin.Data.Enums; -namespace Jellyfin.Api.Models.DisplayPreferencesDtos +namespace MediaBrowser.Model.Dto { /// <summary> /// Defines the display preferences for any item that supports them (usually Folders). @@ -17,7 +17,7 @@ namespace Jellyfin.Api.Models.DisplayPreferencesDtos PrimaryImageHeight = 250; PrimaryImageWidth = 250; ShowBackdrop = true; - CustomPrefs = new Dictionary<string, string>(); + CustomPrefs = new Dictionary<string, string?>(); } /// <summary> @@ -63,10 +63,10 @@ namespace Jellyfin.Api.Models.DisplayPreferencesDtos public int PrimaryImageWidth { get; set; } /// <summary> - /// Gets the custom prefs. + /// Gets or sets the custom prefs. /// </summary> /// <value>The custom prefs.</value> - public Dictionary<string, string> CustomPrefs { get; } + public Dictionary<string, string?> CustomPrefs { get; set; } /// <summary> /// Gets or sets the scroll direction. diff --git a/MediaBrowser.Model/Entities/DisplayPreferencesDto.cs b/MediaBrowser.Model/Entities/DisplayPreferencesDto.cs deleted file mode 100644 index 1f7fe3030..000000000 --- a/MediaBrowser.Model/Entities/DisplayPreferencesDto.cs +++ /dev/null @@ -1,107 +0,0 @@ -#nullable disable -using System.Collections.Generic; -using Jellyfin.Data.Enums; - -namespace MediaBrowser.Model.Entities -{ - /// <summary> - /// Defines the display preferences for any item that supports them (usually Folders). - /// </summary> - public class DisplayPreferencesDto - { - /// <summary> - /// Initializes a new instance of the <see cref="DisplayPreferencesDto" /> class. - /// </summary> - public DisplayPreferencesDto() - { - RememberIndexing = false; - PrimaryImageHeight = 250; - PrimaryImageWidth = 250; - ShowBackdrop = true; - CustomPrefs = new Dictionary<string, string>(); - } - - /// <summary> - /// Gets or sets the user id. - /// </summary> - /// <value>The user id.</value> - public string Id { get; set; } - - /// <summary> - /// Gets or sets the type of the view. - /// </summary> - /// <value>The type of the view.</value> - public string ViewType { get; set; } - - /// <summary> - /// Gets or sets the sort by. - /// </summary> - /// <value>The sort by.</value> - public string SortBy { get; set; } - - /// <summary> - /// Gets or sets the index by. - /// </summary> - /// <value>The index by.</value> - public string IndexBy { get; set; } - - /// <summary> - /// Gets or sets a value indicating whether [remember indexing]. - /// </summary> - /// <value><c>true</c> if [remember indexing]; otherwise, <c>false</c>.</value> - public bool RememberIndexing { get; set; } - - /// <summary> - /// Gets or sets the height of the primary image. - /// </summary> - /// <value>The height of the primary image.</value> - public int PrimaryImageHeight { get; set; } - - /// <summary> - /// Gets or sets the width of the primary image. - /// </summary> - /// <value>The width of the primary image.</value> - public int PrimaryImageWidth { get; set; } - - /// <summary> - /// Gets or sets the custom prefs. - /// </summary> - /// <value>The custom prefs.</value> - public Dictionary<string, string> CustomPrefs { get; set; } - - /// <summary> - /// Gets or sets the scroll direction. - /// </summary> - /// <value>The scroll direction.</value> - public ScrollDirection ScrollDirection { get; set; } - - /// <summary> - /// Gets or sets a value indicating whether to show backdrops on this item. - /// </summary> - /// <value><c>true</c> if showing backdrops; otherwise, <c>false</c>.</value> - public bool ShowBackdrop { get; set; } - - /// <summary> - /// Gets or sets a value indicating whether [remember sorting]. - /// </summary> - /// <value><c>true</c> if [remember sorting]; otherwise, <c>false</c>.</value> - public bool RememberSorting { get; set; } - - /// <summary> - /// Gets or sets the sort order. - /// </summary> - /// <value>The sort order.</value> - public SortOrder SortOrder { get; set; } - - /// <summary> - /// Gets or sets a value indicating whether [show sidebar]. - /// </summary> - /// <value><c>true</c> if [show sidebar]; otherwise, <c>false</c>.</value> - public bool ShowSidebar { get; set; } - - /// <summary> - /// Gets or sets the client. - /// </summary> - public string Client { get; set; } - } -} diff --git a/MediaBrowser.Model/Entities/ImageType.cs b/MediaBrowser.Model/Entities/ImageType.cs index 6ea9ee419..1f7e03718 100644 --- a/MediaBrowser.Model/Entities/ImageType.cs +++ b/MediaBrowser.Model/Entities/ImageType.cs @@ -48,6 +48,10 @@ namespace MediaBrowser.Model.Entities /// <summary> /// The screenshot. /// </summary> + /// <remarks> + /// This enum value is obsolete. + /// XmlSerializer does not serialize/deserialize objects that are marked as [Obsolete]. + /// </remarks> Screenshot = 8, /// <summary> diff --git a/MediaBrowser.Model/Entities/MetadataFields.cs b/MediaBrowser.Model/Entities/MetadataField.cs similarity index 100% rename from MediaBrowser.Model/Entities/MetadataFields.cs rename to MediaBrowser.Model/Entities/MetadataField.cs diff --git a/MediaBrowser.Model/Extensions/EnumerableExtensions.cs b/MediaBrowser.Model/Extensions/EnumerableExtensions.cs index 712fa381e..a5a6b18aa 100644 --- a/MediaBrowser.Model/Extensions/EnumerableExtensions.cs +++ b/MediaBrowser.Model/Extensions/EnumerableExtensions.cs @@ -18,6 +18,12 @@ namespace MediaBrowser.Model.Extensions /// <returns>The ordered remote image infos.</returns> public static IEnumerable<RemoteImageInfo> OrderByLanguageDescending(this IEnumerable<RemoteImageInfo> remoteImageInfos, string requestedLanguage) { + if (string.IsNullOrWhiteSpace(requestedLanguage)) + { + // Default to English if no requested language is specified. + requestedLanguage = "en"; + } + var isRequestedLanguageEn = string.Equals(requestedLanguage, "en", StringComparison.OrdinalIgnoreCase); return remoteImageInfos.OrderByDescending(i => @@ -27,16 +33,18 @@ namespace MediaBrowser.Model.Extensions return 3; } - if (!isRequestedLanguageEn && string.Equals("en", i.Language, StringComparison.OrdinalIgnoreCase)) - { - return 2; - } - if (string.IsNullOrEmpty(i.Language)) { + // Assume empty image language is likely to be English. return isRequestedLanguageEn ? 3 : 2; } + if (!isRequestedLanguageEn && string.Equals(i.Language, "en", StringComparison.OrdinalIgnoreCase)) + { + // Prioritize English over non-requested languages. + return 2; + } + return 0; }) .ThenByDescending(i => i.CommunityRating ?? 0) diff --git a/MediaBrowser.Model/Globalization/ILocalizationManager.cs b/MediaBrowser.Model/Globalization/ILocalizationManager.cs index b213e7aa0..e00157dce 100644 --- a/MediaBrowser.Model/Globalization/ILocalizationManager.cs +++ b/MediaBrowser.Model/Globalization/ILocalizationManager.cs @@ -1,5 +1,4 @@ using System.Collections.Generic; -using System.Globalization; using MediaBrowser.Model.Entities; namespace MediaBrowser.Model.Globalization @@ -56,10 +55,10 @@ namespace MediaBrowser.Model.Globalization IEnumerable<LocalizationOption> GetLocalizationOptions(); /// <summary> - /// Returns the correct <see cref="CultureInfo" /> for the given language. + /// Returns the correct <see cref="CultureDto" /> for the given language. /// </summary> /// <param name="language">The language.</param> - /// <returns>The correct <see cref="CultureInfo" /> for the given language.</returns> + /// <returns>The correct <see cref="CultureDto" /> for the given language.</returns> CultureDto? FindLanguageInfo(string language); } } diff --git a/MediaBrowser.Model/IO/AsyncFile.cs b/MediaBrowser.Model/IO/AsyncFile.cs index b888a4163..3c8007d1c 100644 --- a/MediaBrowser.Model/IO/AsyncFile.cs +++ b/MediaBrowser.Model/IO/AsyncFile.cs @@ -1,4 +1,3 @@ -using System; using System.IO; namespace MediaBrowser.Model.IO @@ -9,11 +8,23 @@ namespace MediaBrowser.Model.IO public static class AsyncFile { /// <summary> - /// Gets a value indicating whether we should use async IO on this platform. - /// <see href="https://github.com/dotnet/runtime/issues/16354" />. + /// Gets the default <see cref="FileStreamOptions"/> for reading files async. /// </summary> - /// <returns>Returns <c>false</c> on Windows; otherwise <c>true</c>.</returns> - public static bool UseAsyncIO => !OperatingSystem.IsWindows(); + public static FileStreamOptions ReadOptions => new FileStreamOptions() + { + Options = FileOptions.Asynchronous + }; + + /// <summary> + /// Gets the default <see cref="FileStreamOptions"/> for writing files async. + /// </summary> + public static FileStreamOptions WriteOptions => new FileStreamOptions() + { + Mode = FileMode.OpenOrCreate, + Access = FileAccess.Write, + Share = FileShare.None, + Options = FileOptions.Asynchronous + }; /// <summary> /// Opens an existing file for reading. @@ -21,7 +32,7 @@ namespace MediaBrowser.Model.IO /// <param name="path">The file to be opened for reading.</param> /// <returns>A read-only <see cref="FileStream" /> on the specified path.</returns> public static FileStream OpenRead(string path) - => new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read, IODefaults.FileStreamBufferSize, UseAsyncIO); + => new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read, IODefaults.FileStreamBufferSize, FileOptions.Asynchronous); /// <summary> /// Opens an existing file for writing. @@ -29,6 +40,6 @@ namespace MediaBrowser.Model.IO /// <param name="path">The file to be opened for writing.</param> /// <returns>An unshared <see cref="FileStream" /> object on the specified path with Write access.</returns> public static FileStream OpenWrite(string path) - => new FileStream(path, FileMode.OpenOrCreate, FileAccess.Write, FileShare.None, IODefaults.FileStreamBufferSize, UseAsyncIO); + => new FileStream(path, FileMode.OpenOrCreate, FileAccess.Write, FileShare.None, IODefaults.FileStreamBufferSize, FileOptions.Asynchronous); } } diff --git a/MediaBrowser.Model/IO/IZipClient.cs b/MediaBrowser.Model/IO/IZipClient.cs index fca52ebae..2448575d1 100644 --- a/MediaBrowser.Model/IO/IZipClient.cs +++ b/MediaBrowser.Model/IO/IZipClient.cs @@ -9,64 +9,8 @@ namespace MediaBrowser.Model.IO /// </summary> public interface IZipClient { - /// <summary> - /// Extracts all. - /// </summary> - /// <param name="sourceFile">The source file.</param> - /// <param name="targetPath">The target path.</param> - /// <param name="overwriteExistingFiles">if set to <c>true</c> [overwrite existing files].</param> - void ExtractAll(string sourceFile, string targetPath, bool overwriteExistingFiles); - - /// <summary> - /// Extracts all. - /// </summary> - /// <param name="source">The source.</param> - /// <param name="targetPath">The target path.</param> - /// <param name="overwriteExistingFiles">if set to <c>true</c> [overwrite existing files].</param> - void ExtractAll(Stream source, string targetPath, bool overwriteExistingFiles); - void ExtractAllFromGz(Stream source, string targetPath, bool overwriteExistingFiles); void ExtractFirstFileFromGz(Stream source, string targetPath, string defaultFileName); - - /// <summary> - /// Extracts all from zip. - /// </summary> - /// <param name="source">The source.</param> - /// <param name="targetPath">The target path.</param> - /// <param name="overwriteExistingFiles">if set to <c>true</c> [overwrite existing files].</param> - void ExtractAllFromZip(Stream source, string targetPath, bool overwriteExistingFiles); - - /// <summary> - /// Extracts all from7z. - /// </summary> - /// <param name="sourceFile">The source file.</param> - /// <param name="targetPath">The target path.</param> - /// <param name="overwriteExistingFiles">if set to <c>true</c> [overwrite existing files].</param> - void ExtractAllFrom7z(string sourceFile, string targetPath, bool overwriteExistingFiles); - - /// <summary> - /// Extracts all from7z. - /// </summary> - /// <param name="source">The source.</param> - /// <param name="targetPath">The target path.</param> - /// <param name="overwriteExistingFiles">if set to <c>true</c> [overwrite existing files].</param> - void ExtractAllFrom7z(Stream source, string targetPath, bool overwriteExistingFiles); - - /// <summary> - /// Extracts all from tar. - /// </summary> - /// <param name="sourceFile">The source file.</param> - /// <param name="targetPath">The target path.</param> - /// <param name="overwriteExistingFiles">if set to <c>true</c> [overwrite existing files].</param> - void ExtractAllFromTar(string sourceFile, string targetPath, bool overwriteExistingFiles); - - /// <summary> - /// Extracts all from tar. - /// </summary> - /// <param name="source">The source.</param> - /// <param name="targetPath">The target path.</param> - /// <param name="overwriteExistingFiles">if set to <c>true</c> [overwrite existing files].</param> - void ExtractAllFromTar(Stream source, string targetPath, bool overwriteExistingFiles); } } diff --git a/MediaBrowser.Model/MediaBrowser.Model.csproj b/MediaBrowser.Model/MediaBrowser.Model.csproj index a371afc2c..63f7ada5c 100644 --- a/MediaBrowser.Model/MediaBrowser.Model.csproj +++ b/MediaBrowser.Model/MediaBrowser.Model.csproj @@ -14,18 +14,17 @@ </PropertyGroup> <PropertyGroup> - <TargetFramework>net5.0</TargetFramework> + <TargetFramework>net6.0</TargetFramework> <GenerateAssemblyInfo>false</GenerateAssemblyInfo> <GenerateDocumentationFile>true</GenerateDocumentationFile> <PublishRepositoryUrl>true</PublishRepositoryUrl> <EmbedUntrackedSources>true</EmbedUntrackedSources> <IncludeSymbols>true</IncludeSymbols> <SymbolPackageFormat>snupkg</SymbolPackageFormat> - <TreatWarningsAsErrors>false</TreatWarningsAsErrors> </PropertyGroup> - <PropertyGroup Condition=" '$(Configuration)' == 'Release'"> - <TreatWarningsAsErrors>true</TreatWarningsAsErrors> + <PropertyGroup Condition=" '$(Configuration)' == 'Debug' "> + <TreatWarningsAsErrors>false</TreatWarningsAsErrors> </PropertyGroup> <PropertyGroup Condition=" '$(Stability)'=='Unstable'"> @@ -34,10 +33,14 @@ </PropertyGroup> <ItemGroup> - <PackageReference Include="Microsoft.SourceLink.GitHub" Version="1.0.0" PrivateAssets="All" /> - <PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="5.0.0" /> + <PackageReference Include="Microsoft.SourceLink.GitHub" Version="1.1.1" PrivateAssets="All" /> + <PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="6.0.0" /> + <PackageReference Include="MimeTypes" Version="2.2.1"> + <PrivateAssets>all</PrivateAssets> + <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> + </PackageReference> <PackageReference Include="System.Globalization" Version="4.3.0" /> - <PackageReference Include="System.Text.Json" Version="5.0.2" /> + <PackageReference Include="System.Text.Json" Version="6.0.1" /> </ItemGroup> <ItemGroup> @@ -47,7 +50,7 @@ <!-- Code Analyzers--> <ItemGroup Condition=" '$(Configuration)' == 'Debug' "> <PackageReference Include="SerilogAnalyzer" Version="0.15.0" PrivateAssets="All" /> - <PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="All" /> + <PackageReference Include="StyleCop.Analyzers" Version="1.2.0-beta.376" PrivateAssets="All" /> <PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" PrivateAssets="All" /> </ItemGroup> <ItemGroup> diff --git a/MediaBrowser.Model/MediaInfo/AudioCodec.cs b/MediaBrowser.Model/MediaInfo/AudioCodec.cs index 8b17757b8..7b83b1b9d 100644 --- a/MediaBrowser.Model/MediaInfo/AudioCodec.cs +++ b/MediaBrowser.Model/MediaInfo/AudioCodec.cs @@ -1,13 +1,11 @@ #pragma warning disable CS1591 +using System; + namespace MediaBrowser.Model.MediaInfo { public static class AudioCodec { - public const string AAC = "aac"; - public const string MP3 = "mp3"; - public const string AC3 = "ac3"; - public static string GetFriendlyName(string codec) { if (codec.Length == 0) @@ -15,17 +13,20 @@ namespace MediaBrowser.Model.MediaInfo return codec; } - switch (codec.ToLowerInvariant()) + if (string.Equals(codec, "ac3", StringComparison.OrdinalIgnoreCase)) { - case "ac3": - return "Dolby Digital"; - case "eac3": - return "Dolby Digital+"; - case "dca": - return "DTS"; - default: - return codec.ToUpperInvariant(); + return "Dolby Digital"; } + else if (string.Equals(codec, "eac3", StringComparison.OrdinalIgnoreCase)) + { + return "Dolby Digital+"; + } + else if (string.Equals(codec, "dca", StringComparison.OrdinalIgnoreCase)) + { + return "DTS"; + } + + return codec.ToUpperInvariant(); } } } diff --git a/MediaBrowser.Model/MediaInfo/LiveStreamRequest.cs b/MediaBrowser.Model/MediaInfo/LiveStreamRequest.cs index 36a240706..24eab1a74 100644 --- a/MediaBrowser.Model/MediaInfo/LiveStreamRequest.cs +++ b/MediaBrowser.Model/MediaInfo/LiveStreamRequest.cs @@ -16,22 +16,6 @@ namespace MediaBrowser.Model.MediaInfo DirectPlayProtocols = new MediaProtocol[] { MediaProtocol.Http }; } - public LiveStreamRequest(AudioOptions options) - { - MaxStreamingBitrate = options.MaxBitrate; - ItemId = options.ItemId; - DeviceProfile = options.Profile; - MaxAudioChannels = options.MaxAudioChannels; - - DirectPlayProtocols = new MediaProtocol[] { MediaProtocol.Http }; - - if (options is VideoOptions videoOptions) - { - AudioStreamIndex = videoOptions.AudioStreamIndex; - SubtitleStreamIndex = videoOptions.SubtitleStreamIndex; - } - } - public string OpenToken { get; set; } public Guid UserId { get; set; } diff --git a/MediaBrowser.Model/MediaInfo/PlaybackInfoRequest.cs b/MediaBrowser.Model/MediaInfo/PlaybackInfoRequest.cs deleted file mode 100644 index ecd9b8834..000000000 --- a/MediaBrowser.Model/MediaInfo/PlaybackInfoRequest.cs +++ /dev/null @@ -1,58 +0,0 @@ -#nullable disable -#pragma warning disable CS1591 - -using System; -using MediaBrowser.Model.Dlna; - -namespace MediaBrowser.Model.MediaInfo -{ - public class PlaybackInfoRequest - { - public PlaybackInfoRequest() - { - EnableDirectPlay = true; - EnableDirectStream = true; - EnableTranscoding = true; - AllowVideoStreamCopy = true; - AllowAudioStreamCopy = true; - IsPlayback = true; - DirectPlayProtocols = new MediaProtocol[] { MediaProtocol.Http }; - } - - public Guid Id { get; set; } - - public Guid UserId { get; set; } - - public long? MaxStreamingBitrate { get; set; } - - public long? StartTimeTicks { get; set; } - - public int? AudioStreamIndex { get; set; } - - public int? SubtitleStreamIndex { get; set; } - - public int? MaxAudioChannels { get; set; } - - public string MediaSourceId { get; set; } - - public string LiveStreamId { get; set; } - - public DeviceProfile DeviceProfile { get; set; } - - public bool EnableDirectPlay { get; set; } - - public bool EnableDirectStream { get; set; } - - public bool EnableTranscoding { get; set; } - - public bool AllowVideoStreamCopy { get; set; } - - public bool AllowAudioStreamCopy { get; set; } - - public bool IsPlayback { get; set; } - - public bool AutoOpenLiveStream { get; set; } - - public MediaProtocol[] DirectPlayProtocols { get; set; } - } -} diff --git a/MediaBrowser.Model/MediaInfo/SubtitleFormat.cs b/MediaBrowser.Model/MediaInfo/SubtitleFormat.cs index 2bd45695a..9bc5c31f6 100644 --- a/MediaBrowser.Model/MediaInfo/SubtitleFormat.cs +++ b/MediaBrowser.Model/MediaInfo/SubtitleFormat.cs @@ -8,8 +8,6 @@ namespace MediaBrowser.Model.MediaInfo public const string SSA = "ssa"; public const string ASS = "ass"; public const string VTT = "vtt"; - public const string SUB = "sub"; - public const string SMI = "smi"; public const string TTML = "ttml"; } } diff --git a/MediaBrowser.Model/Net/MimeTypes.cs b/MediaBrowser.Model/Net/MimeTypes.cs index 96f5ab51a..3b03466e9 100644 --- a/MediaBrowser.Model/Net/MimeTypes.cs +++ b/MediaBrowser.Model/Net/MimeTypes.cs @@ -2,14 +2,25 @@ using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.IO; using System.Linq; +using Jellyfin.Extensions; namespace MediaBrowser.Model.Net { /// <summary> /// Class MimeTypes. /// </summary> + /// + /// <remarks> + /// For more information on MIME types: + /// <list type="bullet"> + /// <item>http://en.wikipedia.org/wiki/Internet_media_type</item> + /// <item>https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types/Common_types</item> + /// <item>http://www.iana.org/assignments/media-types/media-types.xhtml</item> + /// </list> + /// </remarks> public static class MimeTypes { /// <summary> @@ -48,81 +59,26 @@ namespace MediaBrowser.Model.Net ".wtv", }; - // http://en.wikipedia.org/wiki/Internet_media_type - // https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types/Common_types - // http://www.iana.org/assignments/media-types/media-types.xhtml - // Add more as needed + /// <summary> + /// Used for extensions not in <see cref="Model.MimeTypes"/> or to override them. + /// </summary> private static readonly Dictionary<string, string> _mimeTypeLookup = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase) { // Type application - { ".7z", "application/x-7z-compressed" }, - { ".azw", "application/vnd.amazon.ebook" }, { ".azw3", "application/vnd.amazon.ebook" }, - { ".cbz", "application/x-cbz" }, - { ".cbr", "application/epub+zip" }, - { ".eot", "application/vnd.ms-fontobject" }, - { ".epub", "application/epub+zip" }, - { ".js", "application/x-javascript" }, - { ".json", "application/json" }, - { ".m3u8", "application/x-mpegURL" }, - { ".map", "application/x-javascript" }, - { ".mobi", "application/x-mobipocket-ebook" }, - { ".opf", "application/oebps-package+xml" }, - { ".pdf", "application/pdf" }, - { ".rar", "application/vnd.rar" }, - { ".srt", "application/x-subrip" }, - { ".ttml", "application/ttml+xml" }, - { ".wasm", "application/wasm" }, - { ".xml", "application/xml" }, - { ".zip", "application/zip" }, // Type image - { ".bmp", "image/bmp" }, - { ".gif", "image/gif" }, - { ".ico", "image/vnd.microsoft.icon" }, - { ".jpg", "image/jpeg" }, - { ".jpeg", "image/jpeg" }, - { ".png", "image/png" }, - { ".svg", "image/svg+xml" }, - { ".svgz", "image/svg+xml" }, { ".tbn", "image/jpeg" }, - { ".tif", "image/tiff" }, - { ".tiff", "image/tiff" }, - { ".webp", "image/webp" }, - - // Type font - { ".ttf", "font/ttf" }, - { ".woff", "font/woff" }, - { ".woff2", "font/woff2" }, // Type text { ".ass", "text/x-ssa" }, { ".ssa", "text/x-ssa" }, - { ".css", "text/css" }, - { ".csv", "text/csv" }, { ".edl", "text/plain" }, - { ".rtf", "text/rtf" }, - { ".txt", "text/plain" }, - { ".vtt", "text/vtt" }, + { ".html", "text/html; charset=UTF-8" }, + { ".htm", "text/html; charset=UTF-8" }, // Type video - { ".3gp", "video/3gpp" }, - { ".3g2", "video/3gpp2" }, - { ".asf", "video/x-ms-asf" }, - { ".avi", "video/x-msvideo" }, - { ".flv", "video/x-flv" }, - { ".mp4", "video/mp4" }, - { ".m4s", "video/mp4" }, - { ".m4v", "video/x-m4v" }, { ".mpegts", "video/mp2t" }, - { ".mpg", "video/mpeg" }, - { ".mkv", "video/x-matroska" }, - { ".mov", "video/quicktime" }, - { ".mpd", "video/vnd.mpeg.dash.mpd" }, - { ".ogv", "video/ogg" }, - { ".ts", "video/mp2t" }, - { ".webm", "video/webm" }, - { ".wmv", "video/x-ms-wmv" }, // Type audio { ".aac", "audio/aac" }, @@ -131,47 +87,58 @@ namespace MediaBrowser.Model.Net { ".dsf", "audio/dsf" }, { ".dsp", "audio/dsp" }, { ".flac", "audio/flac" }, - { ".m4a", "audio/mp4" }, { ".m4b", "audio/m4b" }, - { ".mid", "audio/midi" }, - { ".midi", "audio/midi" }, { ".mp3", "audio/mpeg" }, - { ".oga", "audio/ogg" }, - { ".ogg", "audio/ogg" }, - { ".opus", "audio/ogg" }, { ".vorbis", "audio/vorbis" }, - { ".wav", "audio/wav" }, { ".webma", "audio/webm" }, - { ".wma", "audio/x-ms-wma" }, { ".wv", "audio/x-wavpack" }, { ".xsp", "audio/xsp" }, }; - private static readonly Dictionary<string, string> _extensionLookup = CreateExtensionLookup(); - - private static Dictionary<string, string> CreateExtensionLookup() + private static readonly Dictionary<string, string> _extensionLookup = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase) { - var dict = _mimeTypeLookup - .GroupBy(i => i.Value) - .ToDictionary(x => x.Key, x => x.First().Key, StringComparer.OrdinalIgnoreCase); + // Type application + { "application/x-cbz", ".cbz" }, + { "application/x-javascript", ".js" }, + { "application/xml", ".xml" }, + { "application/x-mpegURL", ".m3u8" }, - dict["image/jpg"] = ".jpg"; - dict["image/x-png"] = ".png"; + // Type audio + { "audio/aac", ".aac" }, + { "audio/ac3", ".ac3" }, + { "audio/dsf", ".dsf" }, + { "audio/dsp", ".dsp" }, + { "audio/flac", ".flac" }, + { "audio/m4b", ".m4b" }, + { "audio/vorbis", ".vorbis" }, + { "audio/x-ape", ".ape" }, + { "audio/xsp", ".xsp" }, + { "audio/x-wavpack", ".wv" }, - dict["audio/x-aac"] = ".aac"; + // Type image + { "image/jpeg", ".jpg" }, + { "image/x-png", ".png" }, - return dict; - } + // Type text + { "text/plain", ".txt" }, + { "text/rtf", ".rtf" }, + { "text/x-ssa", ".ssa" }, - public static string? GetMimeType(string path) => GetMimeType(path, true); + // Type video + { "video/vnd.mpeg.dash.mpd", ".mpd" }, + { "video/x-matroska", ".mkv" }, + }; + + public static string GetMimeType(string path) => GetMimeType(path, "application/octet-stream"); /// <summary> /// Gets the type of the MIME. /// </summary> /// <param name="filename">The filename to find the MIME type of.</param> - /// <param name="enableStreamDefault">Whether of not to return a default value if no fitting MIME type is found.</param> - /// <returns>The worrect MIME type for the given filename, or `null` if it wasn't found and <paramref name="enableStreamDefault"/> is false.</returns> - public static string? GetMimeType(string filename, bool enableStreamDefault) + /// <param name="defaultValue">The default value to return if no fitting MIME type is found.</param> + /// <returns>The correct MIME type for the given filename, or <paramref name="defaultValue"/> if it wasn't found.</returns> + [return: NotNullIfNotNull("defaultValue")] + public static string? GetMimeType(string filename, string? defaultValue = null) { if (filename.Length == 0) { @@ -185,32 +152,18 @@ namespace MediaBrowser.Model.Net return result; } + if (Model.MimeTypes.TryGetMimeType(filename, out var mimeType)) + { + return mimeType; + } + // Catch-all for all video types that don't require specific mime types if (_videoFileExtensions.Contains(ext)) { - return "video/" + ext.Substring(1); + return string.Concat("video/", ext.AsSpan(1)); } - // Type text - if (string.Equals(ext, ".html", StringComparison.OrdinalIgnoreCase) - || string.Equals(ext, ".htm", StringComparison.OrdinalIgnoreCase)) - { - return "text/html; charset=UTF-8"; - } - - if (string.Equals(ext, ".log", StringComparison.OrdinalIgnoreCase) - || string.Equals(ext, ".srt", StringComparison.OrdinalIgnoreCase)) - { - return "text/plain"; - } - - // Misc - if (string.Equals(ext, ".dll", StringComparison.OrdinalIgnoreCase)) - { - return "application/octet-stream"; - } - - return enableStreamDefault ? "application/octet-stream" : null; + return defaultValue; } public static string? ToExtension(string mimeType) @@ -221,14 +174,15 @@ namespace MediaBrowser.Model.Net } // handle text/html; charset=UTF-8 - mimeType = mimeType.Split(';')[0]; + mimeType = mimeType.AsSpan().LeftPart(';').ToString(); if (_extensionLookup.TryGetValue(mimeType, out string? result)) { return result; } - return null; + var extension = Model.MimeTypes.GetMimeTypeExtensions(mimeType).FirstOrDefault(); + return string.IsNullOrEmpty(extension) ? null : "." + extension; } } } diff --git a/MediaBrowser.Model/Net/NetworkShareType.cs b/MediaBrowser.Model/Net/NetworkShareType.cs deleted file mode 100644 index 5d985f85d..000000000 --- a/MediaBrowser.Model/Net/NetworkShareType.cs +++ /dev/null @@ -1,33 +0,0 @@ -namespace MediaBrowser.Model.Net -{ - /// <summary> - /// Enum NetworkShareType. - /// </summary> - public enum NetworkShareType - { - /// <summary> - /// Disk share. - /// </summary> - Disk, - - /// <summary> - /// Printer share. - /// </summary> - Printer, - - /// <summary> - /// Device share. - /// </summary> - Device, - - /// <summary> - /// IPC share. - /// </summary> - Ipc, - - /// <summary> - /// Special share. - /// </summary> - Special - } -} diff --git a/MediaBrowser.Model/Notifications/NotificationOptions.cs b/MediaBrowser.Model/Notifications/NotificationOptions.cs index 09beb2ef7..d1b5491bd 100644 --- a/MediaBrowser.Model/Notifications/NotificationOptions.cs +++ b/MediaBrowser.Model/Notifications/NotificationOptions.cs @@ -2,9 +2,9 @@ #pragma warning disable CS1591 using System; -using System.Linq; using Jellyfin.Data.Entities; using Jellyfin.Data.Enums; +using Jellyfin.Extensions; namespace MediaBrowser.Model.Notifications { @@ -94,7 +94,7 @@ namespace MediaBrowser.Model.Notifications NotificationOption opt = GetOptions(notificationType); return opt == null - || !opt.DisabledServices.Contains(service, StringComparer.OrdinalIgnoreCase); + || !opt.DisabledServices.Contains(service, StringComparison.OrdinalIgnoreCase); } public bool IsEnabledToMonitorUser(string type, Guid userId) @@ -103,7 +103,7 @@ namespace MediaBrowser.Model.Notifications return opt != null && opt.Enabled - && !opt.DisabledMonitorUsers.Contains(userId.ToString("N"), StringComparer.OrdinalIgnoreCase); + && !opt.DisabledMonitorUsers.Contains(userId.ToString("N"), StringComparison.OrdinalIgnoreCase); } public bool IsEnabledToSendToUser(string type, string userId, User user) @@ -122,7 +122,7 @@ namespace MediaBrowser.Model.Notifications return true; } - return opt.SendToUsers.Contains(userId, StringComparer.OrdinalIgnoreCase); + return opt.SendToUsers.Contains(userId, StringComparison.OrdinalIgnoreCase); } return false; diff --git a/MediaBrowser.Model/Plugins/PluginStatus.cs b/MediaBrowser.Model/Plugins/PluginStatus.cs index 4b9b9bbee..bd420d7b4 100644 --- a/MediaBrowser.Model/Plugins/PluginStatus.cs +++ b/MediaBrowser.Model/Plugins/PluginStatus.cs @@ -29,7 +29,7 @@ namespace MediaBrowser.Model.Plugins NotSupported = -2, /// <summary> - /// This plugin caused an error when instantiated. (Either DI loop, or exception) + /// This plugin caused an error when instantiated (either DI loop, or exception). /// </summary> Malfunctioned = -3, diff --git a/MediaBrowser.Model/Providers/ExternalIdInfo.cs b/MediaBrowser.Model/Providers/ExternalIdInfo.cs index 0ea3e96ca..d026d574f 100644 --- a/MediaBrowser.Model/Providers/ExternalIdInfo.cs +++ b/MediaBrowser.Model/Providers/ExternalIdInfo.cs @@ -12,7 +12,7 @@ namespace MediaBrowser.Model.Providers /// <param name="key">Key for this id. This key should be unique across all providers.</param> /// <param name="type">Specific media type for this id.</param> /// <param name="urlFormatString">URL format string.</param> - public ExternalIdInfo(string name, string key, ExternalIdMediaType? type, string urlFormatString) + public ExternalIdInfo(string name, string key, ExternalIdMediaType? type, string? urlFormatString) { Name = name; Key = key; @@ -46,6 +46,6 @@ namespace MediaBrowser.Model.Providers /// <summary> /// Gets or sets the URL format string. /// </summary> - public string UrlFormatString { get; set; } + public string? UrlFormatString { get; set; } } } diff --git a/MediaBrowser.Model/Querying/ItemFields.cs b/MediaBrowser.Model/Querying/ItemFields.cs index ef4698f3f..e6c3a6c26 100644 --- a/MediaBrowser.Model/Querying/ItemFields.cs +++ b/MediaBrowser.Model/Querying/ItemFields.cs @@ -1,5 +1,7 @@ #pragma warning disable CS1591 +using System; + namespace MediaBrowser.Model.Querying { /// <summary> @@ -143,6 +145,7 @@ namespace MediaBrowser.Model.Querying /// <summary> /// The screenshot image tags. /// </summary> + [Obsolete("Screenshot image type is no longer used.")] ScreenshotImageTags, SeriesPrimaryImage, diff --git a/MediaBrowser.Model/Querying/LatestItemsQuery.cs b/MediaBrowser.Model/Querying/LatestItemsQuery.cs index f555ffb36..d2d9f1f9a 100644 --- a/MediaBrowser.Model/Querying/LatestItemsQuery.cs +++ b/MediaBrowser.Model/Querying/LatestItemsQuery.cs @@ -2,6 +2,7 @@ #pragma warning disable CS1591 using System; +using Jellyfin.Data.Enums; using MediaBrowser.Model.Entities; namespace MediaBrowser.Model.Querying @@ -48,7 +49,7 @@ namespace MediaBrowser.Model.Querying /// Gets or sets the include item types. /// </summary> /// <value>The include item types.</value> - public string[] IncludeItemTypes { get; set; } + public BaseItemKind[] IncludeItemTypes { get; set; } /// <summary> /// Gets or sets a value indicating whether this instance is played. diff --git a/MediaBrowser.Model/Search/SearchQuery.cs b/MediaBrowser.Model/Search/SearchQuery.cs index aedfa4d36..1caed827f 100644 --- a/MediaBrowser.Model/Search/SearchQuery.cs +++ b/MediaBrowser.Model/Search/SearchQuery.cs @@ -2,6 +2,7 @@ #pragma warning disable CS1591 using System; +using Jellyfin.Data.Enums; namespace MediaBrowser.Model.Search { @@ -16,8 +17,8 @@ namespace MediaBrowser.Model.Search IncludeStudios = true; MediaTypes = Array.Empty<string>(); - IncludeItemTypes = Array.Empty<string>(); - ExcludeItemTypes = Array.Empty<string>(); + IncludeItemTypes = Array.Empty<BaseItemKind>(); + ExcludeItemTypes = Array.Empty<BaseItemKind>(); } /// <summary> @@ -56,9 +57,9 @@ namespace MediaBrowser.Model.Search public string[] MediaTypes { get; set; } - public string[] IncludeItemTypes { get; set; } + public BaseItemKind[] IncludeItemTypes { get; set; } - public string[] ExcludeItemTypes { get; set; } + public BaseItemKind[] ExcludeItemTypes { get; set; } public Guid? ParentId { get; set; } diff --git a/MediaBrowser.Model/Session/BrowseRequest.cs b/MediaBrowser.Model/Session/BrowseRequest.cs index 65afe5cf3..5ad7d783a 100644 --- a/MediaBrowser.Model/Session/BrowseRequest.cs +++ b/MediaBrowser.Model/Session/BrowseRequest.cs @@ -1,3 +1,5 @@ +using Jellyfin.Data.Enums; + #nullable disable namespace MediaBrowser.Model.Session { @@ -8,10 +10,9 @@ namespace MediaBrowser.Model.Session { /// <summary> /// Gets or sets the item type. - /// Artist, Genre, Studio, Person, or any kind of BaseItem. /// </summary> /// <value>The type of the item.</value> - public string ItemType { get; set; } + public BaseItemKind ItemType { get; set; } /// <summary> /// Gets or sets the item id. diff --git a/MediaBrowser.Model/Session/GeneralCommandType.cs b/MediaBrowser.Model/Session/GeneralCommandType.cs index c58fa9a6b..166a6b441 100644 --- a/MediaBrowser.Model/Session/GeneralCommandType.cs +++ b/MediaBrowser.Model/Session/GeneralCommandType.cs @@ -39,7 +39,6 @@ namespace MediaBrowser.Model.Session SetRepeatMode = 29, ChannelUp = 30, ChannelDown = 31, - SetMaxStreamingBitrate = 31, Guide = 32, ToggleStats = 33, PlayMediaSource = 34, @@ -48,6 +47,7 @@ namespace MediaBrowser.Model.Session PlayState = 37, PlayNext = 38, ToggleOsdMenu = 39, - Play = 40 + Play = 40, + SetMaxStreamingBitrate = 41 } } diff --git a/MediaBrowser.Model/Session/HardwareEncodingType.cs b/MediaBrowser.Model/Session/HardwareEncodingType.cs index 0e172f35f..0db5697d3 100644 --- a/MediaBrowser.Model/Session/HardwareEncodingType.cs +++ b/MediaBrowser.Model/Session/HardwareEncodingType.cs @@ -6,42 +6,42 @@ public enum HardwareEncodingType { /// <summary> - /// AMD AMF + /// AMD AMF. /// </summary> AMF = 0, /// <summary> - /// Intel Quick Sync Video + /// Intel Quick Sync Video. /// </summary> QSV = 1, /// <summary> - /// NVIDIA NVENC + /// NVIDIA NVENC. /// </summary> NVENC = 2, /// <summary> - /// OpenMax OMX + /// OpenMax OMX. /// </summary> OMX = 3, /// <summary> - /// Exynos V4L2 MFC + /// Exynos V4L2 MFC. /// </summary> V4L2M2M = 4, /// <summary> - /// MediaCodec Android + /// MediaCodec Android. /// </summary> MediaCodec = 5, /// <summary> - /// Video Acceleration API (VAAPI) + /// Video Acceleration API (VAAPI). /// </summary> VAAPI = 6, /// <summary> - /// Video ToolBox + /// Video ToolBox. /// </summary> VideoToolBox = 7 } diff --git a/MediaBrowser.Model/Session/TranscodeReason.cs b/MediaBrowser.Model/Session/TranscodeReason.cs index e93b5d288..3c95df66d 100644 --- a/MediaBrowser.Model/Session/TranscodeReason.cs +++ b/MediaBrowser.Model/Session/TranscodeReason.cs @@ -26,6 +26,7 @@ namespace MediaBrowser.Model.Session VideoProfileNotSupported = 19, AudioBitDepthNotSupported = 20, SubtitleCodecNotSupported = 21, - DirectPlayError = 22 + DirectPlayError = 22, + AudioIsExternal = 23 } } diff --git a/MediaBrowser.Model/SyncPlay/PlayQueueUpdate.cs b/MediaBrowser.Model/SyncPlay/PlayQueueUpdate.cs index a851229f7..cce99c77d 100644 --- a/MediaBrowser.Model/SyncPlay/PlayQueueUpdate.cs +++ b/MediaBrowser.Model/SyncPlay/PlayQueueUpdate.cs @@ -16,15 +16,17 @@ namespace MediaBrowser.Model.SyncPlay /// <param name="playlist">The playlist.</param> /// <param name="playingItemIndex">The playing item index in the playlist.</param> /// <param name="startPositionTicks">The start position ticks.</param> + /// <param name="isPlaying">The playing item status.</param> /// <param name="shuffleMode">The shuffle mode.</param> /// <param name="repeatMode">The repeat mode.</param> - public PlayQueueUpdate(PlayQueueUpdateReason reason, DateTime lastUpdate, IReadOnlyList<QueueItem> playlist, int playingItemIndex, long startPositionTicks, GroupShuffleMode shuffleMode, GroupRepeatMode repeatMode) + public PlayQueueUpdate(PlayQueueUpdateReason reason, DateTime lastUpdate, IReadOnlyList<QueueItem> playlist, int playingItemIndex, long startPositionTicks, bool isPlaying, GroupShuffleMode shuffleMode, GroupRepeatMode repeatMode) { Reason = reason; LastUpdate = lastUpdate; Playlist = playlist; PlayingItemIndex = playingItemIndex; StartPositionTicks = startPositionTicks; + IsPlaying = isPlaying; ShuffleMode = shuffleMode; RepeatMode = repeatMode; } @@ -59,6 +61,12 @@ namespace MediaBrowser.Model.SyncPlay /// <value>The start position ticks.</value> public long StartPositionTicks { get; } + /// <summary> + /// Gets a value indicating whether the current item is playing. + /// </summary> + /// <value>The playing item status.</value> + public bool IsPlaying { get; } + /// <summary> /// Gets the shuffle mode. /// </summary> diff --git a/MediaBrowser.Model/Tasks/ITaskTrigger.cs b/MediaBrowser.Model/Tasks/ITaskTrigger.cs index 999db9605..8c3ec6626 100644 --- a/MediaBrowser.Model/Tasks/ITaskTrigger.cs +++ b/MediaBrowser.Model/Tasks/ITaskTrigger.cs @@ -25,7 +25,7 @@ namespace MediaBrowser.Model.Tasks /// <param name="logger">The <see cref="ILogger"/>.</param> /// <param name="taskName">The name of the task.</param> /// <param name="isApplicationStartup">Wheter or not this is is fired during startup.</param> - void Start(TaskResult lastResult, ILogger logger, string taskName, bool isApplicationStartup); + void Start(TaskResult? lastResult, ILogger logger, string taskName, bool isApplicationStartup); /// <summary> /// Stops waiting for the trigger action. diff --git a/MediaBrowser.Model/Users/PinRedeemResult.cs b/MediaBrowser.Model/Users/PinRedeemResult.cs index 7e4553bac..23fa631e8 100644 --- a/MediaBrowser.Model/Users/PinRedeemResult.cs +++ b/MediaBrowser.Model/Users/PinRedeemResult.cs @@ -1,6 +1,7 @@ -#nullable disable #pragma warning disable CS1591 +using System; + namespace MediaBrowser.Model.Users { public class PinRedeemResult @@ -15,6 +16,6 @@ namespace MediaBrowser.Model.Users /// Gets or sets the users reset. /// </summary> /// <value>The users reset.</value> - public string[] UsersReset { get; set; } + public string[] UsersReset { get; set; } = Array.Empty<string>(); } } diff --git a/MediaBrowser.Model/Users/UserPolicy.cs b/MediaBrowser.Model/Users/UserPolicy.cs index 111070d81..3634d0705 100644 --- a/MediaBrowser.Model/Users/UserPolicy.cs +++ b/MediaBrowser.Model/Users/UserPolicy.cs @@ -1,5 +1,5 @@ #nullable disable -#pragma warning disable CS1591 +#pragma warning disable CS1591, CA1819 using System; using System.Xml.Serialization; diff --git a/MediaBrowser.Providers/Books/AudioBookMetadataService.cs b/MediaBrowser.Providers/Books/AudioBookMetadataService.cs index eabc66c6b..96e1165b6 100644 --- a/MediaBrowser.Providers/Books/AudioBookMetadataService.cs +++ b/MediaBrowser.Providers/Books/AudioBookMetadataService.cs @@ -31,7 +31,7 @@ namespace MediaBrowser.Providers.Books bool replaceData, bool mergeMetadataSettings) { - ProviderUtils.MergeBaseItemData(source, target, lockedFields, replaceData, mergeMetadataSettings); + base.MergeData(source, target, lockedFields, replaceData, mergeMetadataSettings); var sourceItem = source.Item; var targetItem = target.Item; diff --git a/MediaBrowser.Providers/Books/BookMetadataService.cs b/MediaBrowser.Providers/Books/BookMetadataService.cs index 3f3782dfb..50b9922c6 100644 --- a/MediaBrowser.Providers/Books/BookMetadataService.cs +++ b/MediaBrowser.Providers/Books/BookMetadataService.cs @@ -26,7 +26,7 @@ namespace MediaBrowser.Providers.Books /// <inheritdoc /> protected override void MergeData(MetadataResult<Book> source, MetadataResult<Book> target, MetadataField[] lockedFields, bool replaceData, bool mergeMetadataSettings) { - ProviderUtils.MergeBaseItemData(source, target, lockedFields, replaceData, mergeMetadataSettings); + base.MergeData(source, target, lockedFields, replaceData, mergeMetadataSettings); if (replaceData || string.IsNullOrEmpty(target.Item.SeriesName)) { diff --git a/MediaBrowser.Providers/BoxSets/BoxSetMetadataService.cs b/MediaBrowser.Providers/BoxSets/BoxSetMetadataService.cs index 88ce8d087..cbbb343e5 100644 --- a/MediaBrowser.Providers/BoxSets/BoxSetMetadataService.cs +++ b/MediaBrowser.Providers/BoxSets/BoxSetMetadataService.cs @@ -47,7 +47,7 @@ namespace MediaBrowser.Providers.BoxSets /// <inheritdoc /> protected override void MergeData(MetadataResult<BoxSet> source, MetadataResult<BoxSet> target, MetadataField[] lockedFields, bool replaceData, bool mergeMetadataSettings) { - ProviderUtils.MergeBaseItemData(source, target, lockedFields, replaceData, mergeMetadataSettings); + base.MergeData(source, target, lockedFields, replaceData, mergeMetadataSettings); var sourceItem = source.Item; var targetItem = target.Item; diff --git a/MediaBrowser.Providers/Channels/ChannelMetadataService.cs b/MediaBrowser.Providers/Channels/ChannelMetadataService.cs index db2213bad..0267fa13f 100644 --- a/MediaBrowser.Providers/Channels/ChannelMetadataService.cs +++ b/MediaBrowser.Providers/Channels/ChannelMetadataService.cs @@ -4,7 +4,6 @@ using MediaBrowser.Controller.Channels; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Providers; -using MediaBrowser.Model.Entities; using MediaBrowser.Model.IO; using MediaBrowser.Providers.Manager; using Microsoft.Extensions.Logging; @@ -22,11 +21,5 @@ namespace MediaBrowser.Providers.Channels : base(serverConfigurationManager, logger, providerManager, fileSystem, libraryManager) { } - - /// <inheritdoc /> - protected override void MergeData(MetadataResult<Channel> source, MetadataResult<Channel> target, MetadataField[] lockedFields, bool replaceData, bool mergeMetadataSettings) - { - ProviderUtils.MergeBaseItemData(source, target, lockedFields, replaceData, mergeMetadataSettings); - } } } diff --git a/MediaBrowser.Providers/Folders/CollectionFolderMetadataService.cs b/MediaBrowser.Providers/Folders/CollectionFolderMetadataService.cs index e0f3131fd..0629824d3 100644 --- a/MediaBrowser.Providers/Folders/CollectionFolderMetadataService.cs +++ b/MediaBrowser.Providers/Folders/CollectionFolderMetadataService.cs @@ -4,7 +4,6 @@ using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Providers; -using MediaBrowser.Model.Entities; using MediaBrowser.Model.IO; using MediaBrowser.Providers.Manager; using Microsoft.Extensions.Logging; @@ -22,11 +21,5 @@ namespace MediaBrowser.Providers.Folders : base(serverConfigurationManager, logger, providerManager, fileSystem, libraryManager) { } - - /// <inheritdoc /> - protected override void MergeData(MetadataResult<CollectionFolder> source, MetadataResult<CollectionFolder> target, MetadataField[] lockedFields, bool replaceData, bool mergeMetadataSettings) - { - ProviderUtils.MergeBaseItemData(source, target, lockedFields, replaceData, mergeMetadataSettings); - } } } diff --git a/MediaBrowser.Providers/Folders/FolderMetadataService.cs b/MediaBrowser.Providers/Folders/FolderMetadataService.cs index 998bf4c6a..79d52991a 100644 --- a/MediaBrowser.Providers/Folders/FolderMetadataService.cs +++ b/MediaBrowser.Providers/Folders/FolderMetadataService.cs @@ -4,7 +4,6 @@ using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Providers; -using MediaBrowser.Model.Entities; using MediaBrowser.Model.IO; using MediaBrowser.Providers.Manager; using Microsoft.Extensions.Logging; @@ -26,11 +25,5 @@ namespace MediaBrowser.Providers.Folders /// <inheritdoc /> // Make sure the type-specific services get picked first public override int Order => 10; - - /// <inheritdoc /> - protected override void MergeData(MetadataResult<Folder> source, MetadataResult<Folder> target, MetadataField[] lockedFields, bool replaceData, bool mergeMetadataSettings) - { - ProviderUtils.MergeBaseItemData(source, target, lockedFields, replaceData, mergeMetadataSettings); - } } } diff --git a/MediaBrowser.Providers/Folders/UserViewMetadataService.cs b/MediaBrowser.Providers/Folders/UserViewMetadataService.cs index 2d536f12e..79c5597e5 100644 --- a/MediaBrowser.Providers/Folders/UserViewMetadataService.cs +++ b/MediaBrowser.Providers/Folders/UserViewMetadataService.cs @@ -4,7 +4,6 @@ using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Providers; -using MediaBrowser.Model.Entities; using MediaBrowser.Model.IO; using MediaBrowser.Providers.Manager; using Microsoft.Extensions.Logging; @@ -22,11 +21,5 @@ namespace MediaBrowser.Providers.Folders : base(serverConfigurationManager, logger, providerManager, fileSystem, libraryManager) { } - - /// <inheritdoc /> - protected override void MergeData(MetadataResult<UserView> source, MetadataResult<UserView> target, MetadataField[] lockedFields, bool replaceData, bool mergeMetadataSettings) - { - ProviderUtils.MergeBaseItemData(source, target, lockedFields, replaceData, mergeMetadataSettings); - } } } diff --git a/MediaBrowser.Providers/Genres/GenreMetadataService.cs b/MediaBrowser.Providers/Genres/GenreMetadataService.cs index f7ea767e7..4d10d8987 100644 --- a/MediaBrowser.Providers/Genres/GenreMetadataService.cs +++ b/MediaBrowser.Providers/Genres/GenreMetadataService.cs @@ -4,7 +4,6 @@ using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Providers; -using MediaBrowser.Model.Entities; using MediaBrowser.Model.IO; using MediaBrowser.Providers.Manager; using Microsoft.Extensions.Logging; @@ -22,11 +21,5 @@ namespace MediaBrowser.Providers.Genres : base(serverConfigurationManager, logger, providerManager, fileSystem, libraryManager) { } - - /// <inheritdoc /> - protected override void MergeData(MetadataResult<Genre> source, MetadataResult<Genre> target, MetadataField[] lockedFields, bool replaceData, bool mergeMetadataSettings) - { - ProviderUtils.MergeBaseItemData(source, target, lockedFields, replaceData, mergeMetadataSettings); - } } } diff --git a/MediaBrowser.Providers/LiveTv/LiveTvMetadataService.cs b/MediaBrowser.Providers/LiveTv/LiveTvMetadataService.cs index 2e6cf4530..c94d36530 100644 --- a/MediaBrowser.Providers/LiveTv/LiveTvMetadataService.cs +++ b/MediaBrowser.Providers/LiveTv/LiveTvMetadataService.cs @@ -4,7 +4,6 @@ using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.LiveTv; using MediaBrowser.Controller.Providers; -using MediaBrowser.Model.Entities; using MediaBrowser.Model.IO; using MediaBrowser.Providers.Manager; using Microsoft.Extensions.Logging; @@ -22,11 +21,5 @@ namespace MediaBrowser.Providers.LiveTv : base(serverConfigurationManager, logger, providerManager, fileSystem, libraryManager) { } - - /// <inheritdoc /> - protected override void MergeData(MetadataResult<LiveTvChannel> source, MetadataResult<LiveTvChannel> target, MetadataField[] lockedFields, bool replaceData, bool mergeMetadataSettings) - { - ProviderUtils.MergeBaseItemData(source, target, lockedFields, replaceData, mergeMetadataSettings); - } } } diff --git a/MediaBrowser.Providers/Manager/ImageSaver.cs b/MediaBrowser.Providers/Manager/ImageSaver.cs index 6c14c8de1..4632e1d51 100644 --- a/MediaBrowser.Providers/Manager/ImageSaver.cs +++ b/MediaBrowser.Providers/Manager/ImageSaver.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System; @@ -7,6 +9,7 @@ using System.IO; using System.Linq; using System.Threading; using System.Threading.Tasks; +using Jellyfin.Extensions; using MediaBrowser.Common.Configuration; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Entities; @@ -29,8 +32,6 @@ namespace MediaBrowser.Providers.Manager /// </summary> public class ImageSaver { - private static readonly CultureInfo UsCulture = new CultureInfo("en-US"); - /// <summary> /// The _config. /// </summary> @@ -173,7 +174,7 @@ namespace MediaBrowser.Providers.Manager // Delete the current path if (currentImageIsLocalFile - && !savedPaths.Contains(currentImagePath, StringComparer.OrdinalIgnoreCase) + && !savedPaths.Contains(currentImagePath, StringComparison.OrdinalIgnoreCase) && (saveLocally || currentImagePath.Contains(_config.ApplicationPaths.InternalMetadataPath, StringComparison.OrdinalIgnoreCase))) { var currentPath = currentImagePath; @@ -263,8 +264,10 @@ namespace MediaBrowser.Providers.Manager _fileSystem.SetAttributes(path, false, false); - // use FileShare.None as this bypasses dotnet bug dotnet/runtime#42790 . - await using (var fs = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.None, IODefaults.FileStreamBufferSize, AsyncFile.UseAsyncIO)) + var fileStreamOptions = AsyncFile.WriteOptions; + fileStreamOptions.Mode = FileMode.Create; + fileStreamOptions.PreallocationSize = source.Length; + await using (var fs = new FileStream(path, fileStreamOptions)) { await source.CopyToAsync(fs, cancellationToken).ConfigureAwait(false); } @@ -377,7 +380,7 @@ namespace MediaBrowser.Providers.Manager var seasonMarker = season.IndexNumber.Value == 0 ? "-specials" - : season.IndexNumber.Value.ToString("00", UsCulture); + : season.IndexNumber.Value.ToString("00", CultureInfo.InvariantCulture); var imageFilename = "season" + seasonMarker + "-landscape" + extension; @@ -400,7 +403,7 @@ namespace MediaBrowser.Providers.Manager var seasonMarker = season.IndexNumber.Value == 0 ? "-specials" - : season.IndexNumber.Value.ToString("00", UsCulture); + : season.IndexNumber.Value.ToString("00", CultureInfo.InvariantCulture); var imageFilename = "season" + seasonMarker + "-banner" + extension; @@ -437,9 +440,6 @@ namespace MediaBrowser.Providers.Manager case ImageType.Backdrop: filename = GetBackdropSaveFilename(item.GetImages(type), "backdrop", "backdrop", imageIndex); break; - case ImageType.Screenshot: - filename = GetBackdropSaveFilename(item.GetImages(type), "screenshot", "screenshot", imageIndex); - break; default: filename = type.ToString().ToLowerInvariant(); break; @@ -495,12 +495,12 @@ namespace MediaBrowser.Providers.Manager var filenames = images.Select(i => Path.GetFileNameWithoutExtension(i.Path)).ToList(); var current = 1; - while (filenames.Contains(numberedIndexPrefix + current.ToString(UsCulture), StringComparer.OrdinalIgnoreCase)) + while (filenames.Contains(numberedIndexPrefix + current.ToString(CultureInfo.InvariantCulture), StringComparison.OrdinalIgnoreCase)) { current++; } - return numberedIndexPrefix + current.ToString(UsCulture); + return numberedIndexPrefix + current.ToString(CultureInfo.InvariantCulture); } /// <summary> @@ -539,7 +539,7 @@ namespace MediaBrowser.Providers.Manager var seasonMarker = season.IndexNumber.Value == 0 ? "-specials" - : season.IndexNumber.Value.ToString("00", UsCulture); + : season.IndexNumber.Value.ToString("00", CultureInfo.InvariantCulture); var imageFilename = "season" + seasonMarker + "-fanart" + extension; @@ -556,7 +556,7 @@ namespace MediaBrowser.Providers.Manager if (item.IsInMixedFolder) { - return new[] { GetSavePathForItemInMixedFolder(item, type, "fanart" + outputIndex.ToString(UsCulture), extension) }; + return new[] { GetSavePathForItemInMixedFolder(item, type, "fanart" + outputIndex.ToString(CultureInfo.InvariantCulture), extension) }; } var extraFanartFilename = GetBackdropSaveFilename(item.GetImages(ImageType.Backdrop), "fanart", "fanart", outputIndex); @@ -568,7 +568,7 @@ namespace MediaBrowser.Providers.Manager if (EnableExtraThumbsDuplication) { - list.Add(Path.Combine(item.ContainingFolderPath, "extrathumbs", "thumb" + outputIndex.ToString(UsCulture) + extension)); + list.Add(Path.Combine(item.ContainingFolderPath, "extrathumbs", "thumb" + outputIndex.ToString(CultureInfo.InvariantCulture) + extension)); } return list.ToArray(); @@ -582,7 +582,7 @@ namespace MediaBrowser.Providers.Manager var seasonMarker = season.IndexNumber.Value == 0 ? "-specials" - : season.IndexNumber.Value.ToString("00", UsCulture); + : season.IndexNumber.Value.ToString("00", CultureInfo.InvariantCulture); var imageFilename = "season" + seasonMarker + "-poster" + extension; diff --git a/MediaBrowser.Providers/Manager/ItemImageProvider.cs b/MediaBrowser.Providers/Manager/ItemImageProvider.cs index 4b325f2cf..0f21ec7b2 100644 --- a/MediaBrowser.Providers/Manager/ItemImageProvider.cs +++ b/MediaBrowser.Providers/Manager/ItemImageProvider.cs @@ -1,4 +1,4 @@ -#pragma warning disable CA1002, CS1591 +#nullable disable using System; using System.Collections.Generic; @@ -14,6 +14,7 @@ using MediaBrowser.Controller.Library; using MediaBrowser.Controller.LiveTv; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Configuration; +using MediaBrowser.Model.Drawing; using MediaBrowser.Model.Entities; using MediaBrowser.Model.IO; using MediaBrowser.Model.MediaInfo; @@ -23,6 +24,9 @@ using Microsoft.Extensions.Logging; namespace MediaBrowser.Providers.Manager { + /// <summary> + /// Utilities for managing images attached to items. + /// </summary> public class ItemImageProvider { private readonly ILogger _logger; @@ -45,6 +49,12 @@ namespace MediaBrowser.Providers.Manager ImageType.Thumb }; + /// <summary> + /// Initializes a new instance of the <see cref="ItemImageProvider"/> class. + /// </summary> + /// <param name="logger">The logger.</param> + /// <param name="providerManager">The provider manager for interacting with provider image references.</param> + /// <param name="fileSystem">The filesystem.</param> public ItemImageProvider(ILogger logger, IProviderManager providerManager, IFileSystem fileSystem) { _logger = logger; @@ -52,6 +62,36 @@ namespace MediaBrowser.Providers.Manager _fileSystem = fileSystem; } + /// <summary> + /// Removes all existing images from the provided item. + /// </summary> + /// <param name="item">The <see cref="BaseItem"/> to remove images from.</param> + /// <returns><c>true</c> if changes were made to the item; otherwise <c>false</c>.</returns> + public bool RemoveImages(BaseItem item) + { + var singular = new List<ItemImageInfo>(); + for (var i = 0; i < _singularImages.Length; i++) + { + var currentImage = item.GetImageInfo(_singularImages[i], 0); + if (currentImage != null) + { + singular.Add(currentImage); + } + } + + singular.AddRange(item.GetImages(ImageType.Backdrop)); + PruneImages(item, singular); + + return singular.Count > 0; + } + + /// <summary> + /// Verifies existing images have valid paths and adds any new local images provided. + /// </summary> + /// <param name="item">The <see cref="BaseItem"/> to validate images for.</param> + /// <param name="providers">The providers to use, must include <see cref="ILocalImageProvider"/>(s) for local scanning.</param> + /// <param name="directoryService">The directory service for <see cref="ILocalImageProvider"/>s to use.</param> + /// <returns><c>true</c> if changes were made to the item; otherwise <c>false</c>.</returns> public bool ValidateImages(BaseItem item, IEnumerable<IImageProvider> providers, IDirectoryService directoryService) { var hasChanges = false; @@ -71,21 +111,26 @@ namespace MediaBrowser.Providers.Manager return hasChanges; } + /// <summary> + /// Refreshes from the providers according to the given options. + /// </summary> + /// <param name="item">The <see cref="BaseItem"/> to gather images for.</param> + /// <param name="libraryOptions">The library options.</param> + /// <param name="providers">The providers to query for images.</param> + /// <param name="refreshOptions">The refresh options.</param> + /// <param name="cancellationToken">The cancellation token.</param> + /// <returns>The refresh result.</returns> public async Task<RefreshResult> RefreshImages( BaseItem item, LibraryOptions libraryOptions, - List<IImageProvider> providers, + IEnumerable<IImageProvider> providers, ImageRefreshOptions refreshOptions, CancellationToken cancellationToken) { + var oldBackdropImages = Array.Empty<ItemImageInfo>(); if (refreshOptions.IsReplacingImage(ImageType.Backdrop)) { - ClearImages(item, ImageType.Backdrop); - } - - if (refreshOptions.IsReplacingImage(ImageType.Screenshot)) - { - ClearImages(item, ImageType.Screenshot); + oldBackdropImages = item.GetImages(ImageType.Backdrop).ToArray(); } var result = new RefreshResult { UpdateType = ItemUpdateType.None }; @@ -93,16 +138,15 @@ namespace MediaBrowser.Providers.Manager var typeName = item.GetType().Name; var typeOptions = libraryOptions.GetTypeOptions(typeName) ?? new TypeOptions { Type = typeName }; - // In order to avoid duplicates, only download these if there are none already - var backdropLimit = typeOptions.GetLimit(ImageType.Backdrop); - var screenshotLimit = typeOptions.GetLimit(ImageType.Screenshot); + // track library limits, adding buffer to allow lazy replacing of current images + var backdropLimit = typeOptions.GetLimit(ImageType.Backdrop) + oldBackdropImages.Length; var downloadedImages = new List<ImageType>(); foreach (var provider in providers) { if (provider is IRemoteImageProvider remoteProvider) { - await RefreshFromProvider(item, remoteProvider, refreshOptions, typeOptions, backdropLimit, screenshotLimit, downloadedImages, result, cancellationToken).ConfigureAwait(false); + await RefreshFromProvider(item, remoteProvider, refreshOptions, typeOptions, backdropLimit, downloadedImages, result, cancellationToken).ConfigureAwait(false); continue; } @@ -112,11 +156,17 @@ namespace MediaBrowser.Providers.Manager } } + // only delete existing multi-images if new ones were added + if (oldBackdropImages.Length > 0 && oldBackdropImages.Length < item.GetImages(ImageType.Backdrop).Count()) + { + PruneImages(item, oldBackdropImages); + } + return result; } /// <summary> - /// Refreshes from provider. + /// Refreshes from a dynamic provider. /// </summary> private async Task RefreshFromProvider( BaseItem item, @@ -133,47 +183,48 @@ namespace MediaBrowser.Providers.Manager foreach (var imageType in images) { - if (!IsEnabled(savedOptions, imageType)) + if (!savedOptions.IsEnabled(imageType)) { continue; } - if (!HasImage(item, imageType) || (refreshOptions.IsReplacingImage(imageType) && !downloadedImages.Contains(imageType))) + if (!item.HasImage(imageType) || (refreshOptions.IsReplacingImage(imageType) && !downloadedImages.Contains(imageType))) { - _logger.LogDebug("Running {0} for {1}", provider.GetType().Name, item.Path ?? item.Name); + _logger.LogDebug("Running {Provider} for {Item}", provider.GetType().Name, item.Path ?? item.Name); var response = await provider.GetImage(item, imageType, cancellationToken).ConfigureAwait(false); if (response.HasImage) { - if (!string.IsNullOrEmpty(response.Path)) + if (string.IsNullOrEmpty(response.Path)) + { + var mimeType = response.Format.GetMimeType(); + + await _providerManager.SaveImage(item, response.Stream, mimeType, imageType, null, cancellationToken).ConfigureAwait(false); + } + else { if (response.Protocol == MediaProtocol.Http) { - _logger.LogDebug("Setting image url into item {0}", item.Id); + _logger.LogDebug("Setting image url into item {Item}", item.Id); + var index = item.AllowsMultipleImages(imageType) ? item.GetImages(imageType).Count() : 0; item.SetImage( new ItemImageInfo { Path = response.Path, Type = imageType }, - 0); + index); } else { var mimeType = MimeTypes.GetMimeType(response.Path); - var stream = new FileStream(response.Path, FileMode.Open, FileAccess.Read, FileShare.Read, IODefaults.FileStreamBufferSize, AsyncFile.UseAsyncIO); + var stream = AsyncFile.OpenRead(response.Path); await _providerManager.SaveImage(item, stream, mimeType, imageType, null, cancellationToken).ConfigureAwait(false); } } - else - { - var mimeType = "image/" + response.Format.ToString().ToLowerInvariant(); - - await _providerManager.SaveImage(item, response.Stream, mimeType, imageType, null, cancellationToken).ConfigureAwait(false); - } downloadedImages.Add(imageType); result.UpdateType |= ItemUpdateType.ImageUpdate; @@ -188,58 +239,18 @@ namespace MediaBrowser.Providers.Manager catch (Exception ex) { result.ErrorMessage = ex.Message; - _logger.LogError(ex, "Error in {provider}", provider.Name); + _logger.LogError(ex, "Error in {Provider}", provider.Name); } } - private bool HasImage(BaseItem item, ImageType type) - { - return item.HasImage(type); - } - /// <summary> - /// Determines if an item already contains the given images. - /// </summary> - /// <param name="item">The item.</param> - /// <param name="images">The images.</param> - /// <param name="savedOptions">The saved options.</param> - /// <param name="backdropLimit">The backdrop limit.</param> - /// <param name="screenshotLimit">The screenshot limit.</param> - /// <returns><c>true</c> if the specified item contains images; otherwise, <c>false</c>.</returns> - private bool ContainsImages(BaseItem item, List<ImageType> images, TypeOptions savedOptions, int backdropLimit, int screenshotLimit) - { - // Using .Any causes the creation of a DisplayClass aka. variable capture - for (var i = 0; i < _singularImages.Length; i++) - { - var type = _singularImages[i]; - if (images.Contains(type) && !HasImage(item, type) && savedOptions.GetLimit(type) > 0) - { - return false; - } - } - - if (images.Contains(ImageType.Backdrop) && item.GetImages(ImageType.Backdrop).Count() < backdropLimit) - { - return false; - } - - if (images.Contains(ImageType.Screenshot) && item.GetImages(ImageType.Screenshot).Count() < screenshotLimit) - { - return false; - } - - return true; - } - - /// <summary> - /// Refreshes from provider. + /// Refreshes from a remote provider. /// </summary> /// <param name="item">The item.</param> /// <param name="provider">The provider.</param> /// <param name="refreshOptions">The refresh options.</param> /// <param name="savedOptions">The saved options.</param> /// <param name="backdropLimit">The backdrop limit.</param> - /// <param name="screenshotLimit">The screenshot limit.</param> /// <param name="downloadedImages">The downloaded images.</param> /// <param name="result">The result.</param> /// <param name="cancellationToken">The cancellation token.</param> @@ -250,7 +261,6 @@ namespace MediaBrowser.Providers.Manager ImageRefreshOptions refreshOptions, TypeOptions savedOptions, int backdropLimit, - int screenshotLimit, ICollection<ImageType> downloadedImages, RefreshResult result, CancellationToken cancellationToken) @@ -264,18 +274,18 @@ namespace MediaBrowser.Providers.Manager if (!refreshOptions.ReplaceAllImages && refreshOptions.ReplaceImages.Length == 0 && - ContainsImages(item, provider.GetSupportedImages(item).ToList(), savedOptions, backdropLimit, screenshotLimit)) + ContainsImages(item, provider.GetSupportedImages(item).ToList(), savedOptions, backdropLimit)) { return; } - _logger.LogDebug("Running {0} for {1}", provider.GetType().Name, item.Path ?? item.Name); + _logger.LogDebug("Running {Provider} for {Item}", provider.GetType().Name, item.Path ?? item.Name); var images = await _providerManager.GetAvailableRemoteImages( item, new RemoteImageQuery(provider.Name) { - IncludeAllLanguages = false, + IncludeAllLanguages = true, IncludeDisabledProviders = false, }, cancellationToken).ConfigureAwait(false); @@ -285,12 +295,12 @@ namespace MediaBrowser.Providers.Manager foreach (var imageType in _singularImages) { - if (!IsEnabled(savedOptions, imageType)) + if (!savedOptions.IsEnabled(imageType)) { continue; } - if (!HasImage(item, imageType) || (refreshOptions.IsReplacingImage(imageType) && !downloadedImages.Contains(imageType))) + if (!item.HasImage(imageType) || (refreshOptions.IsReplacingImage(imageType) && !downloadedImages.Contains(imageType))) { minWidth = savedOptions.GetMinWidth(imageType); var downloaded = await DownloadImage(item, provider, result, list, minWidth, imageType, cancellationToken).ConfigureAwait(false); @@ -303,13 +313,7 @@ namespace MediaBrowser.Providers.Manager } minWidth = savedOptions.GetMinWidth(ImageType.Backdrop); - await DownloadBackdrops(item, ImageType.Backdrop, backdropLimit, provider, result, list, minWidth, cancellationToken).ConfigureAwait(false); - - if (item is IHasScreenshots hasScreenshots) - { - minWidth = savedOptions.GetMinWidth(ImageType.Screenshot); - await DownloadBackdrops(item, ImageType.Screenshot, screenshotLimit, provider, result, list, minWidth, cancellationToken).ConfigureAwait(false); - } + await DownloadMultiImages(item, ImageType.Backdrop, refreshOptions, backdropLimit, provider, result, list, minWidth, cancellationToken).ConfigureAwait(false); } catch (OperationCanceledException) { @@ -318,49 +322,73 @@ namespace MediaBrowser.Providers.Manager catch (Exception ex) { result.ErrorMessage = ex.Message; - _logger.LogError(ex, "Error in {provider}", provider.Name); + _logger.LogError(ex, "Error in {Provider}", provider.Name); } } - private bool IsEnabled(TypeOptions options, ImageType type) + /// <summary> + /// Determines if an item already contains the given images. + /// </summary> + /// <param name="item">The item.</param> + /// <param name="images">The images.</param> + /// <param name="savedOptions">The saved options.</param> + /// <param name="backdropLimit">The backdrop limit.</param> + /// <returns><c>true</c> if the specified item contains images; otherwise, <c>false</c>.</returns> + private bool ContainsImages(BaseItem item, List<ImageType> images, TypeOptions savedOptions, int backdropLimit) { - return options.IsEnabled(type); + // Using .Any causes the creation of a DisplayClass aka. variable capture + for (var i = 0; i < _singularImages.Length; i++) + { + var type = _singularImages[i]; + if (images.Contains(type) && !item.HasImage(type) && savedOptions.GetLimit(type) > 0) + { + return false; + } + } + + if (images.Contains(ImageType.Backdrop) && item.GetImages(ImageType.Backdrop).Count() < backdropLimit) + { + return false; + } + + return true; } - private void ClearImages(BaseItem item, ImageType type) + private void PruneImages(BaseItem item, IReadOnlyList<ItemImageInfo> images) { - var deleted = false; - var deletedImages = new List<ItemImageInfo>(); - - foreach (var image in item.GetImages(type)) + for (var i = 0; i < images.Count; i++) { - if (!image.IsLocalFile) - { - deletedImages.Add(image); - continue; - } + var image = images[i]; - try - { - _fileSystem.DeleteFile(image.Path); - deleted = true; - } - catch (FileNotFoundException) + if (image.IsLocalFile) { + try + { + _fileSystem.DeleteFile(image.Path); + } + catch (FileNotFoundException) + { + // nothing to do, already gone + } + catch (UnauthorizedAccessException ex) + { + _logger.LogWarning(ex, "Unable to delete {Image}", image.Path); + } } } - item.RemoveImages(deletedImages); - - if (deleted) - { - item.ValidateImages(new DirectoryService(_fileSystem)); - } + item.RemoveImages(images); } + /// <summary> + /// Merges a list of images into the provided item, validating existing images and replacing them or adding new images as necessary. + /// </summary> + /// <param name="item">The <see cref="BaseItem"/> to modify.</param> + /// <param name="images">The new images to place in <c>item</c>.</param> + /// <returns><c>true</c> if changes were made to the item; otherwise <c>false</c>.</returns> public bool MergeImages(BaseItem item, IReadOnlyList<LocalImageInfo> images) { - var changed = false; + var changed = item.ValidateImages(new DirectoryService(_fileSystem)); for (var i = 0; i < _singularImages.Length; i++) { @@ -371,12 +399,7 @@ namespace MediaBrowser.Providers.Manager { var currentImage = item.GetImageInfo(type, 0); - if (currentImage == null) - { - item.SetImagePath(type, image.FileInfo); - changed = true; - } - else if (!string.Equals(currentImage.Path, image.FileInfo.FullName, StringComparison.OrdinalIgnoreCase)) + if (currentImage == null || !string.Equals(currentImage.Path, image.FileInfo.FullName, StringComparison.OrdinalIgnoreCase)) { item.SetImagePath(type, image.FileInfo); changed = true; @@ -396,18 +419,6 @@ namespace MediaBrowser.Providers.Manager currentImage.DateModified = newDateModified; } } - else - { - var existing = item.GetImageInfo(type, 0); - if (existing != null) - { - if (existing.IsLocalFile && !File.Exists(existing.Path)) - { - item.RemoveImage(existing); - changed = true; - } - } - } } if (UpdateMultiImages(item, images, ImageType.Backdrop)) @@ -415,15 +426,6 @@ namespace MediaBrowser.Providers.Manager changed = true; } - var hasScreenshots = item as IHasScreenshots; - if (hasScreenshots != null) - { - if (UpdateMultiImages(item, images, ImageType.Screenshot)) - { - changed = true; - } - } - return changed; } @@ -469,7 +471,7 @@ namespace MediaBrowser.Providers.Manager CancellationToken cancellationToken) { var eligibleImages = images - .Where(i => i.Type == type && !(i.Width.HasValue && i.Width.Value < minWidth)) + .Where(i => i.Type == type && (i.Width == null || i.Width >= minWidth)) .ToList(); if (EnableImageStub(item) && eligibleImages.Count > 0) @@ -486,13 +488,26 @@ namespace MediaBrowser.Providers.Manager try { using var response = await provider.GetImageResponse(url, cancellationToken).ConfigureAwait(false); - response.EnsureSuccessStatusCode(); + + // Sometimes providers send back bad urls. Just move to the next image + if (response.StatusCode == HttpStatusCode.NotFound || response.StatusCode == HttpStatusCode.Forbidden) + { + _logger.LogDebug("{Url} returned {StatusCode}, ignoring", url, response.StatusCode); + continue; + } + + if (!response.IsSuccessStatusCode) + { + _logger.LogWarning("{Url} returned {StatusCode}, skipping all remaining requests", url, response.StatusCode); + break; + } + await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); await _providerManager.SaveImage( item, stream, - response.Content.Headers.ContentType.MediaType, + response.Content.Headers.ContentType?.MediaType, type, null, cancellationToken).ConfigureAwait(false); @@ -500,15 +515,8 @@ namespace MediaBrowser.Providers.Manager result.UpdateType |= ItemUpdateType.ImageUpdate; return true; } - catch (HttpRequestException ex) + catch (HttpRequestException) { - // Sometimes providers send back bad url's. Just move to the next image - if (ex.StatusCode.HasValue - && (ex.StatusCode.Value == HttpStatusCode.NotFound || ex.StatusCode.Value == HttpStatusCode.Forbidden)) - { - continue; - } - break; } } @@ -528,7 +536,7 @@ namespace MediaBrowser.Providers.Manager return true; } - if (item is IItemByName && item is not MusicArtist) + if (item is IItemByName and not MusicArtist) { var hasDualAccess = item as IHasDualAccess; if (hasDualAccess == null || hasDualAccess.IsAccessedByName) @@ -561,7 +569,7 @@ namespace MediaBrowser.Providers.Manager newIndex); } - private async Task DownloadBackdrops(BaseItem item, ImageType imageType, int limit, IRemoteImageProvider provider, RefreshResult result, IEnumerable<RemoteImageInfo> images, int minWidth, CancellationToken cancellationToken) + private async Task DownloadMultiImages(BaseItem item, ImageType imageType, ImageRefreshOptions refreshOptions, int limit, IRemoteImageProvider provider, RefreshResult result, IEnumerable<RemoteImageInfo> images, int minWidth, CancellationToken cancellationToken) { foreach (var image in images.Where(i => i.Type == imageType)) { @@ -588,8 +596,21 @@ namespace MediaBrowser.Providers.Manager { using var response = await provider.GetImageResponse(url, cancellationToken).ConfigureAwait(false); - // If there's already an image of the same size, skip it - if (response.Content.Headers.ContentLength.HasValue) + // Sometimes providers send back bad urls. Just move to the next image + if (response.StatusCode == HttpStatusCode.NotFound || response.StatusCode == HttpStatusCode.Forbidden) + { + _logger.LogDebug("{Url} returned {StatusCode}, ignoring", url, response.StatusCode); + continue; + } + + if (!response.IsSuccessStatusCode) + { + _logger.LogWarning("{Url} returned {StatusCode}, skipping all remaining requests", url, response.StatusCode); + break; + } + + // If there's already an image of the same file size, skip it unless doing a full refresh + if (response.Content.Headers.ContentLength.HasValue && !refreshOptions.IsReplacingImage(imageType)) { try { @@ -609,21 +630,14 @@ namespace MediaBrowser.Providers.Manager await _providerManager.SaveImage( item, stream, - response.Content.Headers.ContentType.MediaType, + response.Content.Headers.ContentType?.MediaType, imageType, null, cancellationToken).ConfigureAwait(false); - result.UpdateType = result.UpdateType | ItemUpdateType.ImageUpdate; + result.UpdateType |= ItemUpdateType.ImageUpdate; } - catch (HttpRequestException ex) + catch (HttpRequestException) { - // Sometimes providers send back bad urls. Just move onto the next image - if (ex.StatusCode.HasValue - && (ex.StatusCode.Value == HttpStatusCode.NotFound || ex.StatusCode.Value == HttpStatusCode.Forbidden)) - { - continue; - } - break; } } diff --git a/MediaBrowser.Providers/Manager/MetadataService.cs b/MediaBrowser.Providers/Manager/MetadataService.cs index ab8d3a2a6..0c52d2673 100644 --- a/MediaBrowser.Providers/Manager/MetadataService.cs +++ b/MediaBrowser.Providers/Manager/MetadataService.cs @@ -1,12 +1,17 @@ +#nullable disable + #pragma warning disable CS1591 using System; using System.Collections.Generic; using System.Linq; +using System.Net.Http; using System.Threading; using System.Threading.Tasks; +using Diacritics.Extensions; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.Audio; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Configuration; @@ -71,14 +76,10 @@ namespace MediaBrowser.Providers.Manager var itemOfType = (TItemType)item; var updateType = ItemUpdateType.None; - var requiresRefresh = false; var libraryOptions = LibraryManager.GetLibraryOptions(item); - if (!requiresRefresh && libraryOptions.AutomaticRefreshIntervalDays > 0 && (DateTime.UtcNow - item.DateLastRefreshed).TotalDays >= libraryOptions.AutomaticRefreshIntervalDays) - { - requiresRefresh = true; - } + var requiresRefresh = libraryOptions.AutomaticRefreshIntervalDays > 0 && (DateTime.UtcNow - item.DateLastRefreshed).TotalDays >= libraryOptions.AutomaticRefreshIntervalDays; if (!requiresRefresh && refreshOptions.MetadataRefreshMode != MetadataRefreshMode.None) { @@ -87,7 +88,7 @@ namespace MediaBrowser.Providers.Manager if (requiresRefresh) { - Logger.LogDebug("Refreshing {0} {1} because item.RequiresRefresh() returned true", typeof(TItemType).Name, item.Path ?? item.Name); + Logger.LogDebug("Refreshing {Type} {Item} because item.RequiresRefresh() returned true", typeof(TItemType).Name, item.Path ?? item.Name); } } @@ -95,6 +96,14 @@ namespace MediaBrowser.Providers.Manager var allImageProviders = ((ProviderManager)ProviderManager).GetImageProviders(item, refreshOptions).ToList(); + if (refreshOptions.RemoveOldMetadata && refreshOptions.ReplaceAllImages) + { + if (ImageProvider.RemoveImages(item)) + { + updateType |= ItemUpdateType.ImageUpdate; + } + } + // Start by validating images try { @@ -107,7 +116,7 @@ namespace MediaBrowser.Providers.Manager catch (Exception ex) { localImagesFailed = true; - Logger.LogError(ex, "Error validating images for {0}", item.Path ?? item.Name ?? "Unknown name"); + Logger.LogError(ex, "Error validating images for {Item}", item.Path ?? item.Name ?? "Unknown name"); } var metadataResult = new MetadataResult<TItemType> @@ -377,8 +386,7 @@ namespace MediaBrowser.Providers.Manager { var updateType = ItemUpdateType.None; - var folder = item as Folder; - if (folder != null && folder.SupportsDateLastMediaAdded) + if (item is Folder folder && folder.SupportsDateLastMediaAdded) { var dateLastMediaAdded = DateTime.MinValue; var any = false; @@ -665,7 +673,7 @@ namespace MediaBrowser.Providers.Manager foreach (var provider in providers.OfType<ILocalMetadataProvider<TItemType>>().ToList()) { var providerName = provider.GetType().Name; - Logger.LogDebug("Running {0} for {1}", providerName, logName); + Logger.LogDebug("Running {Provider} for {Item}", providerName, logName); var itemInfo = new ItemInfo(item); @@ -677,8 +685,15 @@ namespace MediaBrowser.Providers.Manager { foreach (var remoteImage in localItem.RemoteImages) { - await ProviderManager.SaveImage(item, remoteImage.url, remoteImage.type, null, cancellationToken).ConfigureAwait(false); - refreshResult.UpdateType |= ItemUpdateType.ImageUpdate; + try + { + await ProviderManager.SaveImage(item, remoteImage.Url, remoteImage.Type, null, cancellationToken).ConfigureAwait(false); + refreshResult.UpdateType |= ItemUpdateType.ImageUpdate; + } + catch (HttpRequestException ex) + { + Logger.LogError(ex, "Could not save {ImageType} image: {Url}", Enum.GetName(remoteImage.Type), remoteImage.Url); + } } if (imageService.MergeImages(item, localItem.Images)) @@ -703,7 +718,7 @@ namespace MediaBrowser.Providers.Manager break; } - Logger.LogDebug("{0} returned no metadata for {1}", providerName, logName); + Logger.LogDebug("{Provider} returned no metadata for {Item}", providerName, logName); } catch (OperationCanceledException) { @@ -711,7 +726,7 @@ namespace MediaBrowser.Providers.Manager } catch (Exception ex) { - Logger.LogError(ex, "Error in {provider}", provider.Name); + Logger.LogError(ex, "Error in {Provider}", provider.Name); // If a local provider fails, consider that a failure refreshResult.ErrorMessage = ex.Message; @@ -739,8 +754,11 @@ namespace MediaBrowser.Providers.Manager } else { - // TODO: If the new metadata from above has some blank data, this can cause old data to get filled into those empty fields - MergeData(metadata, temp, Array.Empty<MetadataField>(), false, false); + if (!options.RemoveOldMetadata) + { + MergeData(metadata, temp, Array.Empty<MetadataField>(), false, false); + } + MergeData(temp, metadata, item.LockedFields, true, false); } } @@ -770,7 +788,7 @@ namespace MediaBrowser.Providers.Manager private async Task RunCustomProvider(ICustomMetadataProvider<TItemType> provider, TItemType item, string logName, MetadataRefreshOptions options, RefreshResult refreshResult, CancellationToken cancellationToken) { - Logger.LogDebug("Running {0} for {1}", provider.GetType().Name, logName); + Logger.LogDebug("Running {Provider} for {Item}", provider.GetType().Name, logName); try { @@ -783,7 +801,7 @@ namespace MediaBrowser.Providers.Manager catch (Exception ex) { refreshResult.ErrorMessage = ex.Message; - Logger.LogError(ex, "Error in {provider}", provider.Name); + Logger.LogError(ex, "Error in {Provider}", provider.Name); } } @@ -801,7 +819,7 @@ namespace MediaBrowser.Providers.Manager foreach (var provider in providers) { var providerName = provider.GetType().Name; - Logger.LogDebug("Running {0} for {1}", providerName, logName); + Logger.LogDebug("Running {Provider} for {Item}", providerName, logName); if (id != null && !tmpDataMerged) { @@ -824,7 +842,7 @@ namespace MediaBrowser.Providers.Manager } else { - Logger.LogDebug("{0} returned no metadata for {1}", providerName, logName); + Logger.LogDebug("{Provider} returned no metadata for {Item}", providerName, logName); } } catch (OperationCanceledException) @@ -835,7 +853,7 @@ namespace MediaBrowser.Providers.Manager { refreshResult.Failures++; refreshResult.ErrorMessage = ex.Message; - Logger.LogError(ex, "Error in {provider}", provider.Name); + Logger.LogError(ex, "Error in {Provider}", provider.Name); } } @@ -857,13 +875,6 @@ namespace MediaBrowser.Providers.Manager } } - protected abstract void MergeData( - MetadataResult<TItemType> source, - MetadataResult<TItemType> target, - MetadataField[] lockedFields, - bool replaceData, - bool mergeMetadataSettings); - private bool HasChanged(BaseItem item, IHasItemChangeMonitor changeMonitor, IDirectoryService directoryService) { try @@ -872,16 +883,312 @@ namespace MediaBrowser.Providers.Manager if (hasChanged) { - Logger.LogDebug("{0} reports change to {1}", changeMonitor.GetType().Name, item.Path ?? item.Name); + Logger.LogDebug("{Monitor} reports change to {Item}", changeMonitor.GetType().Name, item.Path ?? item.Name); } return hasChanged; } catch (Exception ex) { - Logger.LogError(ex, "Error in {0}.HasChanged", changeMonitor.GetType().Name); + Logger.LogError(ex, "Error in {Monitor}.HasChanged", changeMonitor.GetType().Name); return false; } } + + /// <summary> + /// Merges metadata from source into target. + /// </summary> + /// <param name="source">The source for new metadata.</param> + /// <param name="target">The target to insert new metadata into.</param> + /// <param name="lockedFields">The fields that are locked and should not be updated.</param> + /// <param name="replaceData"><c>true</c> if existing data should be replaced.</param> + /// <param name="mergeMetadataSettings"><c>true</c> if the metadata settings in target should be updated to match source.</param> + /// <exception cref="ArgumentException">Thrown if source or target are null.</exception> + protected virtual void MergeData( + MetadataResult<TItemType> source, + MetadataResult<TItemType> target, + MetadataField[] lockedFields, + bool replaceData, + bool mergeMetadataSettings) + { + MergeBaseItemData(source, target, lockedFields, replaceData, mergeMetadataSettings); + } + + internal static void MergeBaseItemData( + MetadataResult<TItemType> sourceResult, + MetadataResult<TItemType> targetResult, + MetadataField[] lockedFields, + bool replaceData, + bool mergeMetadataSettings) + { + var source = sourceResult.Item; + var target = targetResult.Item; + + if (source == null) + { + throw new ArgumentException("Item cannot be null.", nameof(sourceResult)); + } + + if (target == null) + { + throw new ArgumentException("Item cannot be null.", nameof(targetResult)); + } + + if (!lockedFields.Contains(MetadataField.Name)) + { + if (replaceData || string.IsNullOrEmpty(target.Name)) + { + // Safeguard against incoming data having an empty name + if (!string.IsNullOrWhiteSpace(source.Name)) + { + target.Name = source.Name; + } + } + } + + if (replaceData || string.IsNullOrEmpty(target.OriginalTitle)) + { + // Safeguard against incoming data having an empty name + if (!string.IsNullOrWhiteSpace(source.OriginalTitle)) + { + target.OriginalTitle = source.OriginalTitle; + } + } + + if (replaceData || !target.CommunityRating.HasValue) + { + target.CommunityRating = source.CommunityRating; + } + + if (replaceData || !target.EndDate.HasValue) + { + target.EndDate = source.EndDate; + } + + if (!lockedFields.Contains(MetadataField.Genres)) + { + if (replaceData || target.Genres.Length == 0) + { + target.Genres = source.Genres; + } + } + + if (replaceData || !target.IndexNumber.HasValue) + { + target.IndexNumber = source.IndexNumber; + } + + if (!lockedFields.Contains(MetadataField.OfficialRating)) + { + if (replaceData || string.IsNullOrEmpty(target.OfficialRating)) + { + target.OfficialRating = source.OfficialRating; + } + } + + if (replaceData || string.IsNullOrEmpty(target.CustomRating)) + { + target.CustomRating = source.CustomRating; + } + + if (replaceData || string.IsNullOrEmpty(target.Tagline)) + { + target.Tagline = source.Tagline; + } + + if (!lockedFields.Contains(MetadataField.Overview)) + { + if (replaceData || string.IsNullOrEmpty(target.Overview)) + { + target.Overview = source.Overview; + } + } + + if (replaceData || !target.ParentIndexNumber.HasValue) + { + target.ParentIndexNumber = source.ParentIndexNumber; + } + + if (!lockedFields.Contains(MetadataField.Cast)) + { + if (replaceData || targetResult.People == null || targetResult.People.Count == 0) + { + targetResult.People = sourceResult.People; + } + else if (targetResult.People != null && sourceResult.People != null) + { + MergePeople(sourceResult.People, targetResult.People); + } + } + + if (replaceData || !target.PremiereDate.HasValue) + { + target.PremiereDate = source.PremiereDate; + } + + if (replaceData || !target.ProductionYear.HasValue) + { + target.ProductionYear = source.ProductionYear; + } + + if (!lockedFields.Contains(MetadataField.Runtime)) + { + if (replaceData || !target.RunTimeTicks.HasValue) + { + if (target is not Audio && target is not Video) + { + target.RunTimeTicks = source.RunTimeTicks; + } + } + } + + if (!lockedFields.Contains(MetadataField.Studios)) + { + if (replaceData || target.Studios.Length == 0) + { + target.Studios = source.Studios; + } + } + + if (!lockedFields.Contains(MetadataField.Tags)) + { + if (replaceData || target.Tags.Length == 0) + { + target.Tags = source.Tags; + } + } + + if (!lockedFields.Contains(MetadataField.ProductionLocations)) + { + if (replaceData || target.ProductionLocations.Length == 0) + { + target.ProductionLocations = source.ProductionLocations; + } + } + + foreach (var id in source.ProviderIds) + { + var key = id.Key; + + // Don't replace existing Id's. + if (replaceData) + { + target.ProviderIds[key] = id.Value; + } + else + { + target.ProviderIds.TryAdd(key, id.Value); + } + } + + MergeAlbumArtist(source, target, replaceData); + MergeCriticRating(source, target, replaceData); + MergeTrailers(source, target, replaceData); + MergeVideoInfo(source, target, replaceData); + MergeDisplayOrder(source, target, replaceData); + + if (replaceData || string.IsNullOrEmpty(target.ForcedSortName)) + { + var forcedSortName = source.ForcedSortName; + + if (!string.IsNullOrWhiteSpace(forcedSortName)) + { + target.ForcedSortName = forcedSortName; + } + } + + if (mergeMetadataSettings) + { + target.LockedFields = source.LockedFields; + target.IsLocked = source.IsLocked; + + // Grab the value if it's there, but if not then don't overwrite with the default + if (source.DateCreated != default) + { + target.DateCreated = source.DateCreated; + } + + target.PreferredMetadataCountryCode = source.PreferredMetadataCountryCode; + target.PreferredMetadataLanguage = source.PreferredMetadataLanguage; + } + } + + private static void MergePeople(List<PersonInfo> source, List<PersonInfo> target) + { + foreach (var person in target) + { + var normalizedName = person.Name.RemoveDiacritics(); + var personInSource = source.FirstOrDefault(i => string.Equals(i.Name.RemoveDiacritics(), normalizedName, StringComparison.OrdinalIgnoreCase)); + + if (personInSource != null) + { + foreach (var providerId in personInSource.ProviderIds) + { + person.ProviderIds.TryAdd(providerId.Key, providerId.Value); + } + + if (string.IsNullOrWhiteSpace(person.ImageUrl)) + { + person.ImageUrl = personInSource.ImageUrl; + } + } + } + } + + private static void MergeDisplayOrder(BaseItem source, BaseItem target, bool replaceData) + { + if (source is IHasDisplayOrder sourceHasDisplayOrder + && target is IHasDisplayOrder targetHasDisplayOrder) + { + if (replaceData || string.IsNullOrEmpty(targetHasDisplayOrder.DisplayOrder)) + { + var displayOrder = sourceHasDisplayOrder.DisplayOrder; + + if (!string.IsNullOrWhiteSpace(displayOrder)) + { + targetHasDisplayOrder.DisplayOrder = displayOrder; + } + } + } + } + + private static void MergeAlbumArtist(BaseItem source, BaseItem target, bool replaceData) + { + if (source is IHasAlbumArtist sourceHasAlbumArtist + && target is IHasAlbumArtist targetHasAlbumArtist) + { + if (replaceData || targetHasAlbumArtist.AlbumArtists.Count == 0) + { + targetHasAlbumArtist.AlbumArtists = sourceHasAlbumArtist.AlbumArtists; + } + } + } + + private static void MergeCriticRating(BaseItem source, BaseItem target, bool replaceData) + { + if (replaceData || !target.CriticRating.HasValue) + { + target.CriticRating = source.CriticRating; + } + } + + private static void MergeTrailers(BaseItem source, BaseItem target, bool replaceData) + { + if (replaceData || target.RemoteTrailers.Count == 0) + { + target.RemoteTrailers = source.RemoteTrailers; + } + } + + private static void MergeVideoInfo(BaseItem source, BaseItem target, bool replaceData) + { + if (source is Video sourceCast && target is Video targetCast) + { + if (replaceData || targetCast.Video3DFormat == null) + { + targetCast.Video3DFormat = sourceCast.Video3DFormat; + } + } + } } } diff --git a/MediaBrowser.Providers/Manager/ProviderManager.cs b/MediaBrowser.Providers/Manager/ProviderManager.cs index 812f5cf06..0385ce6a7 100644 --- a/MediaBrowser.Providers/Manager/ProviderManager.cs +++ b/MediaBrowser.Providers/Manager/ProviderManager.cs @@ -1,3 +1,5 @@ +#nullable disable + using System; using System.Collections.Concurrent; using System.Collections.Generic; @@ -9,7 +11,9 @@ using System.Net.Http; using System.Net.Mime; using System.Threading; using System.Threading.Tasks; +using Jellyfin.Data.Enums; using Jellyfin.Data.Events; +using Jellyfin.Extensions; using MediaBrowser.Common.Net; using MediaBrowser.Common.Progress; using MediaBrowser.Controller; @@ -24,6 +28,7 @@ using MediaBrowser.Controller.Providers; using MediaBrowser.Controller.Subtitles; using MediaBrowser.Model.Configuration; using MediaBrowser.Model.Entities; +using MediaBrowser.Model.Extensions; using MediaBrowser.Model.IO; using MediaBrowser.Model.Providers; using Microsoft.Extensions.Logging; @@ -209,8 +214,7 @@ namespace MediaBrowser.Providers.Manager throw new ArgumentNullException(nameof(source)); } - var fileStream = new FileStream(source, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); - + var fileStream = AsyncFile.OpenRead(source); return new ImageSaver(_configurationManager, _libraryMonitor, _fileSystem, _logger).SaveImage(item, fileStream, mimeType, type, imageIndex, saveLocallyWithMedia, cancellationToken); } @@ -235,14 +239,7 @@ namespace MediaBrowser.Providers.Manager var preferredLanguage = item.GetPreferredMetadataLanguage(); - var languages = new List<string>(); - if (!query.IncludeAllLanguages && !string.IsNullOrWhiteSpace(preferredLanguage)) - { - languages.Add(preferredLanguage); - } - - // TODO include [query.IncludeAllLanguages] as an argument to the providers - var tasks = providers.Select(i => GetImages(item, i, languages, cancellationToken, query.ImageType)); + var tasks = providers.Select(i => GetImages(item, i, preferredLanguage, query.IncludeAllLanguages, cancellationToken, query.ImageType)); var results = await Task.WhenAll(tasks).ConfigureAwait(false); @@ -254,17 +251,21 @@ namespace MediaBrowser.Providers.Manager /// </summary> /// <param name="item">The item.</param> /// <param name="provider">The provider.</param> - /// <param name="preferredLanguages">The preferred languages.</param> + /// <param name="preferredLanguage">The preferred language.</param> + /// <param name="includeAllLanguages">Whether to include all languages in results.</param> /// <param name="cancellationToken">The cancellation token.</param> /// <param name="type">The type.</param> /// <returns>Task{IEnumerable{RemoteImageInfo}}.</returns> private async Task<IEnumerable<RemoteImageInfo>> GetImages( BaseItem item, IRemoteImageProvider provider, - IReadOnlyCollection<string> preferredLanguages, + string preferredLanguage, + bool includeAllLanguages, CancellationToken cancellationToken, ImageType? type = null) { + bool hasPreferredLanguage = !string.IsNullOrWhiteSpace(preferredLanguage); + try { var result = await provider.GetImages(item, cancellationToken).ConfigureAwait(false); @@ -274,14 +275,17 @@ namespace MediaBrowser.Providers.Manager result = result.Where(i => i.Type == type.Value); } - if (preferredLanguages.Count > 0) + if (!includeAllLanguages && hasPreferredLanguage) { - result = result.Where(i => string.IsNullOrEmpty(i.Language) || - preferredLanguages.Contains(i.Language, StringComparer.OrdinalIgnoreCase) || + // Filter out languages that do not match the preferred languages. + // + // TODO: should exception case of "en" (English) eventually be removed? + result = result.Where(i => string.IsNullOrWhiteSpace(i.Language) || + string.Equals(preferredLanguage, i.Language, StringComparison.OrdinalIgnoreCase) || string.Equals(i.Language, "en", StringComparison.OrdinalIgnoreCase)); } - return result; + return result.OrderByLanguageDescending(preferredLanguage); } catch (OperationCanceledException) { @@ -657,7 +661,7 @@ namespace MediaBrowser.Providers.Manager /// <inheritdoc/> public void SaveMetadata(BaseItem item, ItemUpdateType updateType, IEnumerable<string> savers) { - SaveMetadata(item, updateType, _savers.Where(i => savers.Contains(i.Name, StringComparer.OrdinalIgnoreCase))); + SaveMetadata(item, updateType, _savers.Where(i => savers.Contains(i.Name, StringComparison.OrdinalIgnoreCase))); } /// <summary> @@ -734,7 +738,7 @@ namespace MediaBrowser.Providers.Manager { if (libraryOptions.MetadataSavers == null) { - if (options.DisabledMetadataSavers.Contains(saver.Name, StringComparer.OrdinalIgnoreCase)) + if (options.DisabledMetadataSavers.Contains(saver.Name, StringComparison.OrdinalIgnoreCase)) { return false; } @@ -760,7 +764,7 @@ namespace MediaBrowser.Providers.Manager } else { - if (!libraryOptions.MetadataSavers.Contains(saver.Name, StringComparer.OrdinalIgnoreCase)) + if (!libraryOptions.MetadataSavers.Contains(saver.Name, StringComparison.OrdinalIgnoreCase)) { return false; } @@ -1131,7 +1135,7 @@ namespace MediaBrowser.Providers.Manager var albums = _libraryManager .GetItemList(new InternalItemsQuery { - IncludeItemTypes = new[] { nameof(MusicAlbum) }, + IncludeItemTypes = new[] { BaseItemKind.MusicAlbum }, ArtistIds = new[] { item.Id }, DtoOptions = new DtoOptions(false) { diff --git a/MediaBrowser.Providers/Manager/ProviderUtils.cs b/MediaBrowser.Providers/Manager/ProviderUtils.cs deleted file mode 100644 index 6d088e6e7..000000000 --- a/MediaBrowser.Providers/Manager/ProviderUtils.cs +++ /dev/null @@ -1,293 +0,0 @@ -#pragma warning disable CS1591 - -using System; -using System.Collections.Generic; -using System.Linq; -using Diacritics.Extensions; -using MediaBrowser.Controller.Entities; -using MediaBrowser.Controller.Entities.Audio; -using MediaBrowser.Controller.Providers; -using MediaBrowser.Model.Entities; - -namespace MediaBrowser.Providers.Manager -{ - public static class ProviderUtils - { - public static void MergeBaseItemData<T>( - MetadataResult<T> sourceResult, - MetadataResult<T> targetResult, - MetadataField[] lockedFields, - bool replaceData, - bool mergeMetadataSettings) - where T : BaseItem - { - var source = sourceResult.Item; - var target = targetResult.Item; - - if (source == null) - { - throw new ArgumentException("Item cannot be null.", nameof(sourceResult)); - } - - if (target == null) - { - throw new ArgumentException("Item cannot be null.", nameof(targetResult)); - } - - if (!lockedFields.Contains(MetadataField.Name)) - { - if (replaceData || string.IsNullOrEmpty(target.Name)) - { - // Safeguard against incoming data having an empty name - if (!string.IsNullOrWhiteSpace(source.Name)) - { - target.Name = source.Name; - } - } - } - - if (replaceData || string.IsNullOrEmpty(target.OriginalTitle)) - { - // Safeguard against incoming data having an empty name - if (!string.IsNullOrWhiteSpace(source.OriginalTitle)) - { - target.OriginalTitle = source.OriginalTitle; - } - } - - if (replaceData || !target.CommunityRating.HasValue) - { - target.CommunityRating = source.CommunityRating; - } - - if (replaceData || !target.EndDate.HasValue) - { - target.EndDate = source.EndDate; - } - - if (!lockedFields.Contains(MetadataField.Genres)) - { - if (replaceData || target.Genres.Length == 0) - { - target.Genres = source.Genres; - } - } - - if (replaceData || !target.IndexNumber.HasValue) - { - target.IndexNumber = source.IndexNumber; - } - - if (!lockedFields.Contains(MetadataField.OfficialRating)) - { - if (replaceData || string.IsNullOrEmpty(target.OfficialRating)) - { - target.OfficialRating = source.OfficialRating; - } - } - - if (replaceData || string.IsNullOrEmpty(target.CustomRating)) - { - target.CustomRating = source.CustomRating; - } - - if (replaceData || string.IsNullOrEmpty(target.Tagline)) - { - target.Tagline = source.Tagline; - } - - if (!lockedFields.Contains(MetadataField.Overview)) - { - if (replaceData || string.IsNullOrEmpty(target.Overview)) - { - target.Overview = source.Overview; - } - } - - if (replaceData || !target.ParentIndexNumber.HasValue) - { - target.ParentIndexNumber = source.ParentIndexNumber; - } - - if (!lockedFields.Contains(MetadataField.Cast)) - { - if (replaceData || targetResult.People == null || targetResult.People.Count == 0) - { - targetResult.People = sourceResult.People; - } - else if (targetResult.People != null && sourceResult.People != null) - { - MergePeople(sourceResult.People, targetResult.People); - } - } - - if (replaceData || !target.PremiereDate.HasValue) - { - target.PremiereDate = source.PremiereDate; - } - - if (replaceData || !target.ProductionYear.HasValue) - { - target.ProductionYear = source.ProductionYear; - } - - if (!lockedFields.Contains(MetadataField.Runtime)) - { - if (replaceData || !target.RunTimeTicks.HasValue) - { - if (target is not Audio && target is not Video) - { - target.RunTimeTicks = source.RunTimeTicks; - } - } - } - - if (!lockedFields.Contains(MetadataField.Studios)) - { - if (replaceData || target.Studios.Length == 0) - { - target.Studios = source.Studios; - } - } - - if (!lockedFields.Contains(MetadataField.Tags)) - { - if (replaceData || target.Tags.Length == 0) - { - target.Tags = source.Tags; - } - } - - if (!lockedFields.Contains(MetadataField.ProductionLocations)) - { - if (replaceData || target.ProductionLocations.Length == 0) - { - target.ProductionLocations = source.ProductionLocations; - } - } - - foreach (var id in source.ProviderIds) - { - var key = id.Key; - - // Don't replace existing Id's. - if (replaceData || !target.ProviderIds.ContainsKey(key)) - { - target.ProviderIds[key] = id.Value; - } - } - - MergeAlbumArtist(source, target, replaceData); - MergeCriticRating(source, target, replaceData); - MergeTrailers(source, target, replaceData); - MergeVideoInfo(source, target, replaceData); - MergeDisplayOrder(source, target, replaceData); - - if (replaceData || string.IsNullOrEmpty(target.ForcedSortName)) - { - var forcedSortName = source.ForcedSortName; - - if (!string.IsNullOrWhiteSpace(forcedSortName)) - { - target.ForcedSortName = forcedSortName; - } - } - - if (mergeMetadataSettings) - { - target.LockedFields = source.LockedFields; - target.IsLocked = source.IsLocked; - - // Grab the value if it's there, but if not then don't overwrite the default - if (source.DateCreated != default) - { - target.DateCreated = source.DateCreated; - } - - target.PreferredMetadataCountryCode = source.PreferredMetadataCountryCode; - target.PreferredMetadataLanguage = source.PreferredMetadataLanguage; - } - } - - private static void MergePeople(List<PersonInfo> source, List<PersonInfo> target) - { - foreach (var person in target) - { - var normalizedName = person.Name.RemoveDiacritics(); - var personInSource = source.FirstOrDefault(i => string.Equals(i.Name.RemoveDiacritics(), normalizedName, StringComparison.OrdinalIgnoreCase)); - - if (personInSource != null) - { - foreach (var providerId in personInSource.ProviderIds) - { - if (!person.ProviderIds.ContainsKey(providerId.Key)) - { - person.ProviderIds[providerId.Key] = providerId.Value; - } - } - - if (string.IsNullOrWhiteSpace(person.ImageUrl)) - { - person.ImageUrl = personInSource.ImageUrl; - } - } - } - } - - private static void MergeDisplayOrder(BaseItem source, BaseItem target, bool replaceData) - { - if (source is IHasDisplayOrder sourceHasDisplayOrder - && target is IHasDisplayOrder targetHasDisplayOrder) - { - if (replaceData || string.IsNullOrEmpty(targetHasDisplayOrder.DisplayOrder)) - { - var displayOrder = sourceHasDisplayOrder.DisplayOrder; - - if (!string.IsNullOrWhiteSpace(displayOrder)) - { - targetHasDisplayOrder.DisplayOrder = displayOrder; - } - } - } - } - - private static void MergeAlbumArtist(BaseItem source, BaseItem target, bool replaceData) - { - if (source is IHasAlbumArtist sourceHasAlbumArtist - && target is IHasAlbumArtist targetHasAlbumArtist) - { - if (replaceData || targetHasAlbumArtist.AlbumArtists.Count == 0) - { - targetHasAlbumArtist.AlbumArtists = sourceHasAlbumArtist.AlbumArtists; - } - } - } - - private static void MergeCriticRating(BaseItem source, BaseItem target, bool replaceData) - { - if (replaceData || !target.CriticRating.HasValue) - { - target.CriticRating = source.CriticRating; - } - } - - private static void MergeTrailers(BaseItem source, BaseItem target, bool replaceData) - { - if (replaceData || target.RemoteTrailers.Count == 0) - { - target.RemoteTrailers = source.RemoteTrailers; - } - } - - private static void MergeVideoInfo(BaseItem source, BaseItem target, bool replaceData) - { - if (source is Video sourceCast && target is Video targetCast) - { - if (replaceData || targetCast.Video3DFormat == null) - { - targetCast.Video3DFormat = sourceCast.Video3DFormat; - } - } - } - } -} diff --git a/MediaBrowser.Providers/Manager/RefreshResult.cs b/MediaBrowser.Providers/Manager/RefreshResult.cs index 72fc61e42..663ffc524 100644 --- a/MediaBrowser.Providers/Manager/RefreshResult.cs +++ b/MediaBrowser.Providers/Manager/RefreshResult.cs @@ -8,7 +8,7 @@ namespace MediaBrowser.Providers.Manager { public ItemUpdateType UpdateType { get; set; } - public string ErrorMessage { get; set; } + public string? ErrorMessage { get; set; } public int Failures { get; set; } } diff --git a/MediaBrowser.Providers/MediaBrowser.Providers.csproj b/MediaBrowser.Providers/MediaBrowser.Providers.csproj index 3d866cdc2..43cf621cd 100644 --- a/MediaBrowser.Providers/MediaBrowser.Providers.csproj +++ b/MediaBrowser.Providers/MediaBrowser.Providers.csproj @@ -16,9 +16,9 @@ </ItemGroup> <ItemGroup> - <PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="5.0.0" /> - <PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="5.0.0" /> - <PackageReference Include="Microsoft.Extensions.Http" Version="5.0.0" /> + <PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="6.0.0" /> + <PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="6.0.0" /> + <PackageReference Include="Microsoft.Extensions.Http" Version="6.0.0" /> <PackageReference Include="Newtonsoft.Json" Version="13.0.1" /> <PackageReference Include="OptimizedPriorityQueue" Version="5.0.0" /> <PackageReference Include="PlaylistsNET" Version="1.1.3" /> @@ -26,19 +26,20 @@ </ItemGroup> <PropertyGroup> - <TargetFramework>net5.0</TargetFramework> + <TargetFramework>net6.0</TargetFramework> <GenerateAssemblyInfo>false</GenerateAssemblyInfo> <GenerateDocumentationFile>true</GenerateDocumentationFile> - <TreatWarningsAsErrors>true</TreatWarningsAsErrors> - <AnalysisMode Condition=" '$(Configuration)' == 'Debug'">AllEnabledByDefault</AnalysisMode> <CodeAnalysisRuleSet>../jellyfin.ruleset</CodeAnalysisRuleSet> - <Nullable>disable</Nullable> + </PropertyGroup> + + <PropertyGroup Condition=" '$(Configuration)' == 'Debug' "> + <TreatWarningsAsErrors>false</TreatWarningsAsErrors> </PropertyGroup> <!-- Code Analyzers--> <ItemGroup Condition=" '$(Configuration)' == 'Debug' "> <PackageReference Include="SerilogAnalyzer" Version="0.15.0" PrivateAssets="All" /> - <PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="All" /> + <PackageReference Include="StyleCop.Analyzers" Version="1.2.0-beta.376" PrivateAssets="All" /> <PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" PrivateAssets="All" /> </ItemGroup> @@ -49,5 +50,9 @@ <EmbeddedResource Include="Plugins\Omdb\Configuration\config.html" /> <None Remove="Plugins\MusicBrainz\Configuration\config.html" /> <EmbeddedResource Include="Plugins\MusicBrainz\Configuration\config.html" /> + <None Remove="Plugins\StudioImages\Configuration\config.html" /> + <EmbeddedResource Include="Plugins\StudioImages\Configuration\config.html" /> + <None Remove="Plugins\Tmdb\Configuration\config.html" /> + <EmbeddedResource Include="Plugins\Tmdb\Configuration\config.html" /> </ItemGroup> </Project> diff --git a/MediaBrowser.Providers/MediaInfo/AudioImageProvider.cs b/MediaBrowser.Providers/MediaInfo/AudioImageProvider.cs index 12125cbb9..b4b1895f5 100644 --- a/MediaBrowser.Providers/MediaInfo/AudioImageProvider.cs +++ b/MediaBrowser.Providers/MediaInfo/AudioImageProvider.cs @@ -1,4 +1,4 @@ -#pragma warning disable CA1002, CS1591 +#nullable disable using System; using System.Collections.Generic; @@ -11,7 +11,9 @@ using MediaBrowser.Common.Extensions; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.Audio; +using MediaBrowser.Controller.Library; using MediaBrowser.Controller.MediaEncoding; +using MediaBrowser.Controller.Persistence; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Entities; using MediaBrowser.Model.IO; @@ -19,38 +21,49 @@ using MediaBrowser.Model.IO; namespace MediaBrowser.Providers.MediaInfo { /// <summary> - /// Uses ffmpeg to create video images. + /// Uses <see cref="IMediaEncoder"/> to extract embedded images. /// </summary> public class AudioImageProvider : IDynamicImageProvider { + private readonly IMediaSourceManager _mediaSourceManager; private readonly IMediaEncoder _mediaEncoder; private readonly IServerConfigurationManager _config; private readonly IFileSystem _fileSystem; - public AudioImageProvider(IMediaEncoder mediaEncoder, IServerConfigurationManager config, IFileSystem fileSystem) + /// <summary> + /// Initializes a new instance of the <see cref="AudioImageProvider"/> class. + /// </summary> + /// <param name="mediaSourceManager">The media source manager for fetching item streams.</param> + /// <param name="mediaEncoder">The media encoder for extracting embedded images.</param> + /// <param name="config">The server configuration manager for getting image paths.</param> + /// <param name="fileSystem">The filesystem.</param> + public AudioImageProvider(IMediaSourceManager mediaSourceManager, IMediaEncoder mediaEncoder, IServerConfigurationManager config, IFileSystem fileSystem) { + _mediaSourceManager = mediaSourceManager; _mediaEncoder = mediaEncoder; _config = config; _fileSystem = fileSystem; } - public string AudioImagesPath => Path.Combine(_config.ApplicationPaths.CachePath, "extracted-audio-images"); + private string AudioImagesPath => Path.Combine(_config.ApplicationPaths.CachePath, "extracted-audio-images"); + /// <inheritdoc /> public string Name => "Image Extractor"; + /// <inheritdoc /> public IEnumerable<ImageType> GetSupportedImages(BaseItem item) { - return new List<ImageType> { ImageType.Primary }; + return new[] { ImageType.Primary }; } + /// <inheritdoc /> public Task<DynamicImageResponse> GetImage(BaseItem item, ImageType type, CancellationToken cancellationToken) { - var audio = (Audio)item; - - var imageStreams = - audio.GetMediaStreams(MediaStreamType.EmbeddedImage) - .Where(i => i.Type == MediaStreamType.EmbeddedImage) - .ToList(); + var imageStreams = _mediaSourceManager.GetMediaStreams(new MediaStreamQuery + { + ItemId = item.Id, + Type = MediaStreamType.EmbeddedImage + }); // Can't extract if we didn't find a video stream in the file if (imageStreams.Count == 0) @@ -61,7 +74,7 @@ namespace MediaBrowser.Providers.MediaInfo return GetImage((Audio)item, imageStreams, cancellationToken); } - public async Task<DynamicImageResponse> GetImage(Audio item, List<MediaStream> imageStreams, CancellationToken cancellationToken) + private async Task<DynamicImageResponse> GetImage(Audio item, List<MediaStream> imageStreams, CancellationToken cancellationToken) { var path = GetAudioImagePath(item); @@ -73,7 +86,7 @@ namespace MediaBrowser.Providers.MediaInfo imageStreams.FirstOrDefault(i => (i.Comment ?? string.Empty).IndexOf("cover", StringComparison.OrdinalIgnoreCase) != -1) ?? imageStreams.FirstOrDefault(); - var imageStreamIndex = imageStream == null ? (int?)null : imageStream.Index; + var imageStreamIndex = imageStream?.Index; var tempFile = await _mediaEncoder.ExtractAudioImage(item.Path, imageStreamIndex, cancellationToken).ConfigureAwait(false); @@ -125,6 +138,7 @@ namespace MediaBrowser.Providers.MediaInfo return Path.Join(AudioImagesPath, prefix, filename); } + /// <inheritdoc /> public bool Supports(BaseItem item) { if (item.IsShortcut) diff --git a/MediaBrowser.Providers/MediaInfo/AudioResolver.cs b/MediaBrowser.Providers/MediaInfo/AudioResolver.cs new file mode 100644 index 000000000..425913501 --- /dev/null +++ b/MediaBrowser.Providers/MediaInfo/AudioResolver.cs @@ -0,0 +1,176 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; +using Emby.Naming.Audio; +using Emby.Naming.Common; +using Jellyfin.Extensions; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.MediaEncoding; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.Dlna; +using MediaBrowser.Model.Dto; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.Globalization; +using MediaBrowser.Model.MediaInfo; + +namespace MediaBrowser.Providers.MediaInfo +{ + /// <summary> + /// Resolves external audios for videos. + /// </summary> + public class AudioResolver + { + private readonly ILocalizationManager _localizationManager; + private readonly IMediaEncoder _mediaEncoder; + private readonly NamingOptions _namingOptions; + + /// <summary> + /// Initializes a new instance of the <see cref="AudioResolver"/> class. + /// </summary> + /// <param name="localizationManager">The localization manager.</param> + /// <param name="mediaEncoder">The media encoder.</param> + /// <param name="namingOptions">The naming options.</param> + public AudioResolver( + ILocalizationManager localizationManager, + IMediaEncoder mediaEncoder, + NamingOptions namingOptions) + { + _localizationManager = localizationManager; + _mediaEncoder = mediaEncoder; + _namingOptions = namingOptions; + } + + /// <summary> + /// Returns the audio streams found in the external audio files for the given video. + /// </summary> + /// <param name="video">The video to get the external audio streams from.</param> + /// <param name="startIndex">The stream index to start adding audio streams at.</param> + /// <param name="directoryService">The directory service to search for files.</param> + /// <param name="clearCache">True if the directory service cache should be cleared before searching.</param> + /// <param name="cancellationToken">The cancellation token to cancel operation.</param> + /// <returns>A list of external audio streams.</returns> + public async IAsyncEnumerable<MediaStream> GetExternalAudioStreams( + Video video, + int startIndex, + IDirectoryService directoryService, + bool clearCache, + [EnumeratorCancellation] CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (!video.IsFileProtocol) + { + yield break; + } + + IEnumerable<string> paths = GetExternalAudioFiles(video, directoryService, clearCache); + foreach (string path in paths) + { + string fileNameWithoutExtension = Path.GetFileNameWithoutExtension(path); + Model.MediaInfo.MediaInfo mediaInfo = await GetMediaInfo(path, cancellationToken).ConfigureAwait(false); + + foreach (MediaStream mediaStream in mediaInfo.MediaStreams) + { + mediaStream.Index = startIndex++; + mediaStream.Type = MediaStreamType.Audio; + mediaStream.IsExternal = true; + mediaStream.Path = path; + mediaStream.IsDefault = false; + mediaStream.Title = null; + + if (string.IsNullOrEmpty(mediaStream.Language)) + { + // Try to translate to three character code + // Be flexible and check against both the full and three character versions + var language = StringExtensions.RightPart(fileNameWithoutExtension, '.').ToString(); + + if (language != fileNameWithoutExtension) + { + var culture = _localizationManager.FindLanguageInfo(language); + + language = culture == null ? language : culture.ThreeLetterISOLanguageName; + mediaStream.Language = language; + } + } + + yield return mediaStream; + } + } + } + + /// <summary> + /// Returns the external audio file paths for the given video. + /// </summary> + /// <param name="video">The video to get the external audio file paths from.</param> + /// <param name="directoryService">The directory service to search for files.</param> + /// <param name="clearCache">True if the directory service cache should be cleared before searching.</param> + /// <returns>A list of external audio file paths.</returns> + public IEnumerable<string> GetExternalAudioFiles( + Video video, + IDirectoryService directoryService, + bool clearCache) + { + if (!video.IsFileProtocol) + { + yield break; + } + + // Check if video folder exists + string folder = video.ContainingFolderPath; + if (!Directory.Exists(folder)) + { + yield break; + } + + string videoFileNameWithoutExtension = Path.GetFileNameWithoutExtension(video.Path); + + var files = directoryService.GetFilePaths(folder, clearCache, true); + for (int i = 0; i < files.Count; i++) + { + string file = files[i]; + if (string.Equals(video.Path, file, StringComparison.OrdinalIgnoreCase) + || !AudioFileParser.IsAudioFile(file, _namingOptions) + || Path.GetExtension(file.AsSpan()).Equals(".strm", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + string fileNameWithoutExtension = Path.GetFileNameWithoutExtension(file); + // The audio filename must either be equal to the video filename or start with the video filename followed by a dot + if (videoFileNameWithoutExtension.Equals(fileNameWithoutExtension, StringComparison.OrdinalIgnoreCase) + || (fileNameWithoutExtension.Length > videoFileNameWithoutExtension.Length + && fileNameWithoutExtension[videoFileNameWithoutExtension.Length] == '.' + && fileNameWithoutExtension.StartsWith(videoFileNameWithoutExtension, StringComparison.OrdinalIgnoreCase))) + { + yield return file; + } + } + } + + /// <summary> + /// Returns the media info of the given audio file. + /// </summary> + /// <param name="path">The path to the audio file.</param> + /// <param name="cancellationToken">The cancellation token to cancel operation.</param> + /// <returns>The media info for the given audio file.</returns> + private Task<Model.MediaInfo.MediaInfo> GetMediaInfo(string path, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + return _mediaEncoder.GetMediaInfo( + new MediaInfoRequest + { + MediaType = DlnaProfileType.Audio, + MediaSource = new MediaSourceInfo + { + Path = path, + Protocol = MediaProtocol.File + } + }, + cancellationToken); + } + } +} diff --git a/MediaBrowser.Providers/MediaInfo/EmbeddedImageProvider.cs b/MediaBrowser.Providers/MediaInfo/EmbeddedImageProvider.cs new file mode 100644 index 000000000..96d7d139a --- /dev/null +++ b/MediaBrowser.Providers/MediaInfo/EmbeddedImageProvider.cs @@ -0,0 +1,248 @@ +#nullable disable + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.TV; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.MediaEncoding; +using MediaBrowser.Controller.Persistence; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.Drawing; +using MediaBrowser.Model.Dto; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.MediaInfo; +using MediaBrowser.Model.Net; +using Microsoft.Extensions.Logging; + +namespace MediaBrowser.Providers.MediaInfo +{ + /// <summary> + /// Uses <see cref="IMediaEncoder"/> to extract embedded images. + /// </summary> + public class EmbeddedImageProvider : IDynamicImageProvider, IHasOrder + { + private static readonly string[] _primaryImageFileNames = + { + "poster", + "folder", + "cover", + "default" + }; + + private static readonly string[] _backdropImageFileNames = + { + "backdrop", + "fanart", + "background", + "art" + }; + + private static readonly string[] _logoImageFileNames = + { + "logo", + }; + + private readonly IMediaSourceManager _mediaSourceManager; + private readonly IMediaEncoder _mediaEncoder; + private readonly ILogger<EmbeddedImageProvider> _logger; + + /// <summary> + /// Initializes a new instance of the <see cref="EmbeddedImageProvider"/> class. + /// </summary> + /// <param name="mediaSourceManager">The media source manager for fetching item streams and attachments.</param> + /// <param name="mediaEncoder">The media encoder for extracting attached/embedded images.</param> + /// <param name="logger">The logger.</param> + public EmbeddedImageProvider(IMediaSourceManager mediaSourceManager, IMediaEncoder mediaEncoder, ILogger<EmbeddedImageProvider> logger) + { + _mediaSourceManager = mediaSourceManager; + _mediaEncoder = mediaEncoder; + _logger = logger; + } + + /// <inheritdoc /> + public string Name => "Embedded Image Extractor"; + + /// <inheritdoc /> + // Default to after internet image providers but before Screen Grabber + public int Order => 99; + + /// <inheritdoc /> + public IEnumerable<ImageType> GetSupportedImages(BaseItem item) + { + if (item is Video) + { + if (item is Episode) + { + return new[] + { + ImageType.Primary, + }; + } + + return new[] + { + ImageType.Primary, + ImageType.Backdrop, + ImageType.Logo, + }; + } + + return Array.Empty<ImageType>(); + } + + /// <inheritdoc /> + public Task<DynamicImageResponse> GetImage(BaseItem item, ImageType type, CancellationToken cancellationToken) + { + var video = (Video)item; + + // No support for these + if (video.IsPlaceHolder || video.VideoType == VideoType.Dvd) + { + return Task.FromResult(new DynamicImageResponse { HasImage = false }); + } + + return GetEmbeddedImage(video, type, cancellationToken); + } + + private async Task<DynamicImageResponse> GetEmbeddedImage(Video item, ImageType type, CancellationToken cancellationToken) + { + MediaSourceInfo mediaSource = new MediaSourceInfo + { + VideoType = item.VideoType, + IsoType = item.IsoType, + Protocol = item.PathProtocol ?? MediaProtocol.File, + }; + + string[] imageFileNames = type switch + { + ImageType.Primary => _primaryImageFileNames, + ImageType.Backdrop => _backdropImageFileNames, + ImageType.Logo => _logoImageFileNames, + _ => Array.Empty<string>() + }; + + if (imageFileNames.Length == 0) + { + _logger.LogWarning("Attempted to load unexpected image type: {Type}", type); + return new DynamicImageResponse { HasImage = false }; + } + + // Try attachments first + var attachmentStream = _mediaSourceManager.GetMediaAttachments(item.Id) + .FirstOrDefault(attachment => !string.IsNullOrEmpty(attachment.FileName) + && imageFileNames.Any(name => attachment.FileName.Contains(name, StringComparison.OrdinalIgnoreCase))); + + if (attachmentStream != null) + { + return await ExtractAttachment(item, attachmentStream, mediaSource, cancellationToken); + } + + // Fall back to EmbeddedImage streams + var imageStreams = _mediaSourceManager.GetMediaStreams(new MediaStreamQuery + { + ItemId = item.Id, + Type = MediaStreamType.EmbeddedImage + }); + + if (imageStreams.Count == 0) + { + // Can't extract if we don't have any EmbeddedImage streams + return new DynamicImageResponse { HasImage = false }; + } + + // Extract first stream containing an element of imageFileNames + var imageStream = imageStreams + .FirstOrDefault(stream => !string.IsNullOrEmpty(stream.Comment) + && imageFileNames.Any(name => stream.Comment.Contains(name, StringComparison.OrdinalIgnoreCase))); + + // Primary type only: default to first image if none found by label + if (imageStream == null) + { + if (type == ImageType.Primary) + { + imageStream = imageStreams[0]; + } + else + { + // No streams matched, abort + return new DynamicImageResponse { HasImage = false }; + } + } + + var format = imageStream.Codec switch + { + "mjpeg" => ImageFormat.Jpg, + "png" => ImageFormat.Png, + "gif" => ImageFormat.Gif, + _ => ImageFormat.Jpg + }; + + string extractedImagePath = + await _mediaEncoder.ExtractVideoImage(item.Path, item.Container, mediaSource, imageStream, imageStream.Index, format, cancellationToken) + .ConfigureAwait(false); + + return new DynamicImageResponse + { + Format = format, + HasImage = true, + Path = extractedImagePath, + Protocol = MediaProtocol.File + }; + } + + private async Task<DynamicImageResponse> ExtractAttachment(Video item, MediaAttachment attachmentStream, MediaSourceInfo mediaSource, CancellationToken cancellationToken) + { + var extension = string.IsNullOrEmpty(attachmentStream.MimeType) + ? Path.GetExtension(attachmentStream.FileName) + : MimeTypes.ToExtension(attachmentStream.MimeType); + + if (string.IsNullOrEmpty(extension)) + { + extension = ".jpg"; + } + + ImageFormat format = extension switch + { + ".bmp" => ImageFormat.Bmp, + ".gif" => ImageFormat.Gif, + ".jpg" => ImageFormat.Jpg, + ".png" => ImageFormat.Png, + ".webp" => ImageFormat.Webp, + _ => ImageFormat.Jpg + }; + + string extractedAttachmentPath = + await _mediaEncoder.ExtractVideoImage(item.Path, item.Container, mediaSource, null, attachmentStream.Index, format, cancellationToken) + .ConfigureAwait(false); + + return new DynamicImageResponse + { + Format = format, + HasImage = true, + Path = extractedAttachmentPath, + Protocol = MediaProtocol.File + }; + } + + /// <inheritdoc /> + public bool Supports(BaseItem item) + { + if (item.IsShortcut) + { + return false; + } + + if (!item.IsFileProtocol) + { + return false; + } + + return item is Video video && !video.IsPlaceHolder && video.IsCompleteMedia; + } + } +} diff --git a/MediaBrowser.Providers/MediaInfo/FFProbeAudioInfo.cs b/MediaBrowser.Providers/MediaInfo/FFProbeAudioInfo.cs index cf271e7db..9eb79c39d 100644 --- a/MediaBrowser.Providers/MediaInfo/FFProbeAudioInfo.cs +++ b/MediaBrowser.Providers/MediaInfo/FFProbeAudioInfo.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System; diff --git a/MediaBrowser.Providers/MediaInfo/FFProbeProvider.cs b/MediaBrowser.Providers/MediaInfo/FFProbeProvider.cs index 4fff57273..19a435196 100644 --- a/MediaBrowser.Providers/MediaInfo/FFProbeProvider.cs +++ b/MediaBrowser.Providers/MediaInfo/FFProbeProvider.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System; @@ -5,6 +7,7 @@ using System.IO; using System.Linq; using System.Threading; using System.Threading.Tasks; +using Emby.Naming.Common; using MediaBrowser.Controller.Chapters; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Entities; @@ -36,17 +39,10 @@ namespace MediaBrowser.Providers.MediaInfo IHasItemChangeMonitor { private readonly ILogger<FFProbeProvider> _logger; - private readonly IMediaEncoder _mediaEncoder; - private readonly IItemRepository _itemRepo; - private readonly IBlurayExaminer _blurayExaminer; - private readonly ILocalizationManager _localization; - private readonly IEncodingManager _encodingManager; - private readonly IServerConfigurationManager _config; - private readonly ISubtitleManager _subtitleManager; - private readonly IChapterManager _chapterManager; - private readonly ILibraryManager _libraryManager; - private readonly IMediaSourceManager _mediaSourceManager; private readonly SubtitleResolver _subtitleResolver; + private readonly AudioResolver _audioResolver; + private readonly FFProbeVideoInfo _videoProber; + private readonly FFProbeAudioInfo _audioProber; private readonly Task<ItemUpdateType> _cachedTask = Task.FromResult(ItemUpdateType.None); @@ -61,21 +57,26 @@ namespace MediaBrowser.Providers.MediaInfo IServerConfigurationManager config, ISubtitleManager subtitleManager, IChapterManager chapterManager, - ILibraryManager libraryManager) + ILibraryManager libraryManager, + NamingOptions namingOptions) { _logger = logger; - _mediaEncoder = mediaEncoder; - _itemRepo = itemRepo; - _blurayExaminer = blurayExaminer; - _localization = localization; - _encodingManager = encodingManager; - _config = config; - _subtitleManager = subtitleManager; - _chapterManager = chapterManager; - _libraryManager = libraryManager; - _mediaSourceManager = mediaSourceManager; - + _audioResolver = new AudioResolver(localization, mediaEncoder, namingOptions); _subtitleResolver = new SubtitleResolver(BaseItem.LocalizationManager); + _videoProber = new FFProbeVideoInfo( + _logger, + mediaSourceManager, + mediaEncoder, + itemRepo, + blurayExaminer, + localization, + encodingManager, + config, + subtitleManager, + chapterManager, + libraryManager, + _audioResolver); + _audioProber = new FFProbeAudioInfo(mediaSourceManager, mediaEncoder, itemRepo, libraryManager); } public string Name => "ffprobe"; @@ -95,7 +96,7 @@ namespace MediaBrowser.Providers.MediaInfo var file = directoryService.GetFile(path); if (file != null && file.LastWriteTimeUtc != item.DateModified) { - _logger.LogDebug("Refreshing {0} due to date modified timestamp change.", path); + _logger.LogDebug("Refreshing {ItemPath} due to date modified timestamp change.", path); return true; } } @@ -105,7 +106,15 @@ namespace MediaBrowser.Providers.MediaInfo && !video.SubtitleFiles.SequenceEqual( _subtitleResolver.GetExternalSubtitleFiles(video, directoryService, false), StringComparer.Ordinal)) { - _logger.LogDebug("Refreshing {0} due to external subtitles change.", item.Path); + _logger.LogDebug("Refreshing {ItemPath} due to external subtitles change.", item.Path); + return true; + } + + if (item.SupportsLocalMetadata && video != null && !video.IsPlaceHolder + && !video.AudioFiles.SequenceEqual( + _audioResolver.GetExternalAudioFiles(video, directoryService, false), StringComparer.Ordinal)) + { + _logger.LogDebug("Refreshing {ItemPath} due to external audio change.", item.Path); return true; } @@ -175,20 +184,7 @@ namespace MediaBrowser.Providers.MediaInfo FetchShortcutInfo(item); } - var prober = new FFProbeVideoInfo( - _logger, - _mediaSourceManager, - _mediaEncoder, - _itemRepo, - _blurayExaminer, - _localization, - _encodingManager, - _config, - _subtitleManager, - _chapterManager, - _libraryManager); - - return prober.ProbeVideo(item, options, cancellationToken); + return _videoProber.ProbeVideo(item, options, cancellationToken); } private string NormalizeStrmLine(string line) @@ -224,9 +220,7 @@ namespace MediaBrowser.Providers.MediaInfo FetchShortcutInfo(item); } - var prober = new FFProbeAudioInfo(_mediaSourceManager, _mediaEncoder, _itemRepo, _libraryManager); - - return prober.Probe(item, options, cancellationToken); + return _audioProber.Probe(item, options, cancellationToken); } } } diff --git a/MediaBrowser.Providers/MediaInfo/FFProbeVideoInfo.cs b/MediaBrowser.Providers/MediaInfo/FFProbeVideoInfo.cs index 1f17d8cd4..77372e063 100644 --- a/MediaBrowser.Providers/MediaInfo/FFProbeVideoInfo.cs +++ b/MediaBrowser.Providers/MediaInfo/FFProbeVideoInfo.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CA1068, CS1591 using System; @@ -42,6 +44,7 @@ namespace MediaBrowser.Providers.MediaInfo private readonly ISubtitleManager _subtitleManager; private readonly IChapterManager _chapterManager; private readonly ILibraryManager _libraryManager; + private readonly AudioResolver _audioResolver; private readonly IMediaSourceManager _mediaSourceManager; private readonly long _dummyChapterDuration = TimeSpan.FromMinutes(5).Ticks; @@ -57,7 +60,8 @@ namespace MediaBrowser.Providers.MediaInfo IServerConfigurationManager config, ISubtitleManager subtitleManager, IChapterManager chapterManager, - ILibraryManager libraryManager) + ILibraryManager libraryManager, + AudioResolver audioResolver) { _logger = logger; _mediaEncoder = mediaEncoder; @@ -69,6 +73,7 @@ namespace MediaBrowser.Providers.MediaInfo _subtitleManager = subtitleManager; _chapterManager = chapterManager; _libraryManager = libraryManager; + _audioResolver = audioResolver; _mediaSourceManager = mediaSourceManager; } @@ -212,6 +217,8 @@ namespace MediaBrowser.Providers.MediaInfo await AddExternalSubtitles(video, mediaStreams, options, cancellationToken).ConfigureAwait(false); + await AddExternalAudioAsync(video, mediaStreams, options, cancellationToken).ConfigureAwait(false); + var libraryOptions = _libraryManager.GetLibraryOptions(video); if (mediaInfo != null) @@ -557,6 +564,7 @@ namespace MediaBrowser.Providers.MediaInfo subtitleDownloadLanguages, libraryOptions.DisabledSubtitleFetchers, libraryOptions.SubtitleFetcherOrder, + true, cancellationToken).ConfigureAwait(false); // Rescan @@ -571,6 +579,31 @@ namespace MediaBrowser.Providers.MediaInfo currentStreams.AddRange(externalSubtitleStreams); } + /// <summary> + /// Adds the external audio. + /// </summary> + /// <param name="video">The video.</param> + /// <param name="currentStreams">The current streams.</param> + /// <param name="options">The refreshOptions.</param> + /// <param name="cancellationToken">The cancellation token.</param> + private async Task AddExternalAudioAsync( + Video video, + List<MediaStream> currentStreams, + MetadataRefreshOptions options, + CancellationToken cancellationToken) + { + var startIndex = currentStreams.Count == 0 ? 0 : currentStreams.Max(i => i.Index) + 1; + var externalAudioStreams = _audioResolver.GetExternalAudioStreams(video, startIndex, options.DirectoryService, false, cancellationToken); + + await foreach (MediaStream externalAudioStream in externalAudioStreams) + { + currentStreams.Add(externalAudioStream); + } + + // Select all external audio file paths + video.AudioFiles = currentStreams.Where(i => i.Type == MediaStreamType.Audio && i.IsExternal).Select(i => i.Path).Distinct().ToArray(); + } + /// <summary> /// Creates dummy chapters. /// </summary> diff --git a/MediaBrowser.Providers/MediaInfo/SubtitleDownloader.cs b/MediaBrowser.Providers/MediaInfo/SubtitleDownloader.cs index 449f0d259..b2b93940a 100644 --- a/MediaBrowser.Providers/MediaInfo/SubtitleDownloader.cs +++ b/MediaBrowser.Providers/MediaInfo/SubtitleDownloader.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CA1002, CS1591 using System; @@ -36,6 +38,7 @@ namespace MediaBrowser.Providers.MediaInfo IEnumerable<string> languages, string[] disabledSubtitleFetchers, string[] subtitleFetcherOrder, + bool isAutomated, CancellationToken cancellationToken) { var downloadedLanguages = new List<string>(); @@ -51,6 +54,7 @@ namespace MediaBrowser.Providers.MediaInfo lang, disabledSubtitleFetchers, subtitleFetcherOrder, + isAutomated, cancellationToken).ConfigureAwait(false); if (downloaded) @@ -71,6 +75,7 @@ namespace MediaBrowser.Providers.MediaInfo string lang, string[] disabledSubtitleFetchers, string[] subtitleFetcherOrder, + bool isAutomated, CancellationToken cancellationToken) { if (video.VideoType != VideoType.VideoFile) @@ -109,6 +114,7 @@ namespace MediaBrowser.Providers.MediaInfo disabledSubtitleFetchers, subtitleFetcherOrder, mediaType, + isAutomated, cancellationToken); } @@ -122,6 +128,7 @@ namespace MediaBrowser.Providers.MediaInfo string[] disabledSubtitleFetchers, string[] subtitleFetcherOrder, VideoContentType mediaType, + bool isAutomated, CancellationToken cancellationToken) { // There's already subtitles for this language @@ -169,7 +176,8 @@ namespace MediaBrowser.Providers.MediaInfo IsPerfectMatch = requirePerfectMatch, DisabledSubtitleFetchers = disabledSubtitleFetchers, - SubtitleFetcherOrder = subtitleFetcherOrder + SubtitleFetcherOrder = subtitleFetcherOrder, + IsAutomated = isAutomated }; if (video is Episode episode) diff --git a/MediaBrowser.Providers/MediaInfo/SubtitleResolver.cs b/MediaBrowser.Providers/MediaInfo/SubtitleResolver.cs index d7f6a5fac..ba284187e 100644 --- a/MediaBrowser.Providers/MediaInfo/SubtitleResolver.cs +++ b/MediaBrowser.Providers/MediaInfo/SubtitleResolver.cs @@ -1,5 +1,3 @@ -#pragma warning disable CA1002, CS1591 - using System; using System.Collections.Generic; using System.IO; @@ -10,15 +8,30 @@ using MediaBrowser.Model.Globalization; namespace MediaBrowser.Providers.MediaInfo { + /// <summary> + /// Resolves external subtitles for videos. + /// </summary> public class SubtitleResolver { private readonly ILocalizationManager _localization; + /// <summary> + /// Initializes a new instance of the <see cref="SubtitleResolver"/> class. + /// </summary> + /// <param name="localization">The localization manager.</param> public SubtitleResolver(ILocalizationManager localization) { _localization = localization; } + /// <summary> + /// Retrieves the external subtitle streams for the provided video. + /// </summary> + /// <param name="video">The video to search from.</param> + /// <param name="startIndex">The stream index to start adding subtitle streams at.</param> + /// <param name="directoryService">The directory service to search for files.</param> + /// <param name="clearCache">True if the directory service cache should be cleared before searching.</param> + /// <returns>The external subtitle streams located.</returns> public List<MediaStream> GetExternalSubtitleStreams( Video video, int startIndex, @@ -54,6 +67,13 @@ namespace MediaBrowser.Providers.MediaInfo return streams; } + /// <summary> + /// Locates the external subtitle files for the provided video. + /// </summary> + /// <param name="video">The video to search from.</param> + /// <param name="directoryService">The directory service to search for files.</param> + /// <param name="clearCache">True if the directory service cache should be cleared before searching.</param> + /// <returns>The external subtitle file paths located.</returns> public IEnumerable<string> GetExternalSubtitleFiles( Video video, IDirectoryService directoryService, @@ -72,6 +92,13 @@ namespace MediaBrowser.Providers.MediaInfo } } + /// <summary> + /// Extracts the subtitle files from the provided list and adds them to the list of streams. + /// </summary> + /// <param name="streams">The list of streams to add external subtitles to.</param> + /// <param name="videoPath">The path to the video file.</param> + /// <param name="startIndex">The stream index to start adding subtitle streams at.</param> + /// <param name="files">The files to add if they are subtitles.</param> public void AddExternalSubtitleStreams( List<MediaStream> streams, string videoPath, @@ -118,6 +145,12 @@ namespace MediaBrowser.Providers.MediaInfo while (languageSpan.Length > 0) { var lastDot = languageSpan.LastIndexOf('.'); + if (lastDot < videoFileNameWithoutExtension.Length) + { + languageSpan = ReadOnlySpan<char>.Empty; + break; + } + var currentSlice = languageSpan[lastDot..]; if (currentSlice.Equals(".default", StringComparison.OrdinalIgnoreCase) || currentSlice.Equals(".forced", StringComparison.OrdinalIgnoreCase) @@ -131,12 +164,19 @@ namespace MediaBrowser.Providers.MediaInfo break; } - // Try to translate to three character code - // Be flexible and check against both the full and three character versions var language = languageSpan.ToString(); - var culture = _localization.FindLanguageInfo(language); + if (string.IsNullOrWhiteSpace(language)) + { + language = null; + } + else + { + // Try to translate to three character code + // Be flexible and check against both the full and three character versions + var culture = _localization.FindLanguageInfo(language); - language = culture == null ? language : culture.ThreeLetterISOLanguageName; + language = culture == null ? language : culture.ThreeLetterISOLanguageName; + } mediaStream = new MediaStream { diff --git a/MediaBrowser.Providers/MediaInfo/SubtitleScheduledTask.cs b/MediaBrowser.Providers/MediaInfo/SubtitleScheduledTask.cs index 9804ec3bb..58651d42a 100644 --- a/MediaBrowser.Providers/MediaInfo/SubtitleScheduledTask.cs +++ b/MediaBrowser.Providers/MediaInfo/SubtitleScheduledTask.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System; @@ -5,6 +7,7 @@ using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; +using Jellyfin.Data.Enums; using MediaBrowser.Common.Configuration; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Dto; @@ -64,7 +67,7 @@ namespace MediaBrowser.Providers.MediaInfo { var options = GetOptions(); - var types = new[] { "Episode", "Movie" }; + var types = new[] { BaseItemKind.Episode, BaseItemKind.Movie }; var dict = new Dictionary<Guid, BaseItem>(); @@ -75,21 +78,18 @@ namespace MediaBrowser.Providers.MediaInfo string[] subtitleDownloadLanguages; bool skipIfEmbeddedSubtitlesPresent; bool skipIfAudioTrackMatches; - bool requirePerfectMatch; if (libraryOptions.SubtitleDownloadLanguages == null) { subtitleDownloadLanguages = options.DownloadLanguages; skipIfEmbeddedSubtitlesPresent = options.SkipIfEmbeddedSubtitlesPresent; skipIfAudioTrackMatches = options.SkipIfAudioTrackMatches; - requirePerfectMatch = options.RequirePerfectMatch; } else { subtitleDownloadLanguages = libraryOptions.SubtitleDownloadLanguages; skipIfEmbeddedSubtitlesPresent = libraryOptions.SkipSubtitlesIfEmbeddedSubtitlesPresent; skipIfAudioTrackMatches = libraryOptions.SkipSubtitlesIfAudioTrackMatches; - requirePerfectMatch = libraryOptions.RequirePerfectSubtitleMatch; } foreach (var lang in subtitleDownloadLanguages) @@ -197,6 +197,7 @@ namespace MediaBrowser.Providers.MediaInfo subtitleDownloadLanguages, libraryOptions.DisabledSubtitleFetchers, libraryOptions.SubtitleFetcherOrder, + true, cancellationToken).ConfigureAwait(false); // Rescan diff --git a/MediaBrowser.Providers/MediaInfo/VideoImageProvider.cs b/MediaBrowser.Providers/MediaInfo/VideoImageProvider.cs index 8b96205c2..d4bf62970 100644 --- a/MediaBrowser.Providers/MediaInfo/VideoImageProvider.cs +++ b/MediaBrowser.Providers/MediaInfo/VideoImageProvider.cs @@ -1,13 +1,12 @@ -#nullable enable -#pragma warning disable CS1591 - using System; using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Library; using MediaBrowser.Controller.MediaEncoding; +using MediaBrowser.Controller.Persistence; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Drawing; using MediaBrowser.Model.Dto; @@ -17,13 +16,24 @@ using Microsoft.Extensions.Logging; namespace MediaBrowser.Providers.MediaInfo { + /// <summary> + /// Uses <see cref="IMediaEncoder"/> to create still images from the main video. + /// </summary> public class VideoImageProvider : IDynamicImageProvider, IHasOrder { + private readonly IMediaSourceManager _mediaSourceManager; private readonly IMediaEncoder _mediaEncoder; private readonly ILogger<VideoImageProvider> _logger; - public VideoImageProvider(IMediaEncoder mediaEncoder, ILogger<VideoImageProvider> logger) + /// <summary> + /// Initializes a new instance of the <see cref="VideoImageProvider"/> class. + /// </summary> + /// <param name="mediaSourceManager">The media source manager for fetching item streams.</param> + /// <param name="mediaEncoder">The media encoder for capturing images.</param> + /// <param name="logger">The logger.</param> + public VideoImageProvider(IMediaSourceManager mediaSourceManager, IMediaEncoder mediaEncoder, ILogger<VideoImageProvider> logger) { + _mediaSourceManager = mediaSourceManager; _mediaEncoder = mediaEncoder; _logger = logger; } @@ -71,37 +81,29 @@ namespace MediaBrowser.Providers.MediaInfo Protocol = item.PathProtocol ?? MediaProtocol.File, }; - var mediaStreams = - item.GetMediaStreams(); + // If we know the duration, grab it from 10% into the video. Otherwise just 10 seconds in. + // Always use 10 seconds for dvd because our duration could be out of whack + var imageOffset = item.VideoType != VideoType.Dvd && item.RunTimeTicks > 0 + ? TimeSpan.FromTicks(item.RunTimeTicks.Value / 10) + : TimeSpan.FromSeconds(10); - var imageStreams = - mediaStreams - .Where(i => i.Type == MediaStreamType.EmbeddedImage) - .ToList(); - - string extractedImagePath; - - if (imageStreams.Count == 0) + var query = new MediaStreamQuery { ItemId = item.Id, Index = item.DefaultVideoStreamIndex }; + var videoStream = _mediaSourceManager.GetMediaStreams(query).FirstOrDefault(); + if (videoStream == null) { - // If we know the duration, grab it from 10% into the video. Otherwise just 10 seconds in. - // Always use 10 seconds for dvd because our duration could be out of whack - var imageOffset = item.VideoType != VideoType.Dvd && item.RunTimeTicks.HasValue && - item.RunTimeTicks.Value > 0 - ? TimeSpan.FromTicks(item.RunTimeTicks.Value / 10) - : TimeSpan.FromSeconds(10); - - var videoStream = mediaStreams.FirstOrDefault(i => i.Type == MediaStreamType.Video); - extractedImagePath = await _mediaEncoder.ExtractVideoImage(item.Path, item.Container, mediaSource, videoStream, item.Video3DFormat, imageOffset, cancellationToken).ConfigureAwait(false); + query.Type = MediaStreamType.Video; + query.Index = null; + videoStream = _mediaSourceManager.GetMediaStreams(query).FirstOrDefault(); } - else + + if (videoStream == null) { - var imageStream = imageStreams.Find(i => (i.Comment ?? string.Empty).Contains("front", StringComparison.OrdinalIgnoreCase)) - ?? imageStreams.Find(i => (i.Comment ?? string.Empty).Contains("cover", StringComparison.OrdinalIgnoreCase)) - ?? imageStreams[0]; - - extractedImagePath = await _mediaEncoder.ExtractVideoImage(item.Path, item.Container, mediaSource, imageStream, imageStream.Index, cancellationToken).ConfigureAwait(false); + _logger.LogInformation("Skipping image extraction: no video stream found for {Path}.", item.Path ?? string.Empty); + return new DynamicImageResponse { HasImage = false }; } + string extractedImagePath = await _mediaEncoder.ExtractVideoImage(item.Path, item.Container, mediaSource, videoStream, item.Video3DFormat, imageOffset, cancellationToken).ConfigureAwait(false); + return new DynamicImageResponse { Format = ImageFormat.Jpg, diff --git a/MediaBrowser.Providers/Movies/ImdbExternalId.cs b/MediaBrowser.Providers/Movies/ImdbExternalId.cs index a8d74aa0b..d00f37db5 100644 --- a/MediaBrowser.Providers/Movies/ImdbExternalId.cs +++ b/MediaBrowser.Providers/Movies/ImdbExternalId.cs @@ -22,7 +22,7 @@ namespace MediaBrowser.Providers.Movies public ExternalIdMediaType? Type => null; /// <inheritdoc /> - public string UrlFormatString => "https://www.imdb.com/title/{0}"; + public string? UrlFormatString => "https://www.imdb.com/title/{0}"; /// <inheritdoc /> public bool Supports(IHasProviderIds item) diff --git a/MediaBrowser.Providers/Movies/ImdbPersonExternalId.cs b/MediaBrowser.Providers/Movies/ImdbPersonExternalId.cs index 8151ab471..1bb5e1ea8 100644 --- a/MediaBrowser.Providers/Movies/ImdbPersonExternalId.cs +++ b/MediaBrowser.Providers/Movies/ImdbPersonExternalId.cs @@ -19,7 +19,7 @@ namespace MediaBrowser.Providers.Movies public ExternalIdMediaType? Type => ExternalIdMediaType.Person; /// <inheritdoc /> - public string UrlFormatString => "https://www.imdb.com/name/{0}"; + public string? UrlFormatString => "https://www.imdb.com/name/{0}"; /// <inheritdoc /> public bool Supports(IHasProviderIds item) => item is Person; diff --git a/MediaBrowser.Providers/Movies/MovieMetadataService.cs b/MediaBrowser.Providers/Movies/MovieMetadataService.cs index c477fb70f..984a3c122 100644 --- a/MediaBrowser.Providers/Movies/MovieMetadataService.cs +++ b/MediaBrowser.Providers/Movies/MovieMetadataService.cs @@ -42,7 +42,7 @@ namespace MediaBrowser.Providers.Movies /// <inheritdoc /> protected override void MergeData(MetadataResult<Movie> source, MetadataResult<Movie> target, MetadataField[] lockedFields, bool replaceData, bool mergeMetadataSettings) { - ProviderUtils.MergeBaseItemData(source, target, lockedFields, replaceData, mergeMetadataSettings); + base.MergeData(source, target, lockedFields, replaceData, mergeMetadataSettings); var sourceItem = source.Item; var targetItem = target.Item; diff --git a/MediaBrowser.Providers/Movies/TrailerMetadataService.cs b/MediaBrowser.Providers/Movies/TrailerMetadataService.cs index f32d9ec0a..ad0c5aaa7 100644 --- a/MediaBrowser.Providers/Movies/TrailerMetadataService.cs +++ b/MediaBrowser.Providers/Movies/TrailerMetadataService.cs @@ -42,7 +42,7 @@ namespace MediaBrowser.Providers.Movies /// <inheritdoc /> protected override void MergeData(MetadataResult<Trailer> source, MetadataResult<Trailer> target, MetadataField[] lockedFields, bool replaceData, bool mergeMetadataSettings) { - ProviderUtils.MergeBaseItemData(source, target, lockedFields, replaceData, mergeMetadataSettings); + base.MergeData(source, target, lockedFields, replaceData, mergeMetadataSettings); if (replaceData || target.Item.TrailerTypes.Length == 0) { diff --git a/MediaBrowser.Providers/Music/AlbumInfoExtensions.cs b/MediaBrowser.Providers/Music/AlbumInfoExtensions.cs index dddfd02e4..d3fce37c7 100644 --- a/MediaBrowser.Providers/Music/AlbumInfoExtensions.cs +++ b/MediaBrowser.Providers/Music/AlbumInfoExtensions.cs @@ -8,7 +8,7 @@ namespace MediaBrowser.Providers.Music { public static class AlbumInfoExtensions { - public static string GetAlbumArtist(this AlbumInfo info) + public static string? GetAlbumArtist(this AlbumInfo info) { var id = info.SongInfos.SelectMany(i => i.AlbumArtists) .FirstOrDefault(i => !string.IsNullOrEmpty(i)); @@ -21,7 +21,7 @@ namespace MediaBrowser.Providers.Music return info.AlbumArtists.Count > 0 ? info.AlbumArtists[0] : default; } - public static string GetReleaseGroupId(this AlbumInfo info) + public static string? GetReleaseGroupId(this AlbumInfo info) { var id = info.GetProviderId(MetadataProvider.MusicBrainzReleaseGroup); @@ -34,7 +34,7 @@ namespace MediaBrowser.Providers.Music return id; } - public static string GetReleaseId(this AlbumInfo info) + public static string? GetReleaseId(this AlbumInfo info) { var id = info.GetProviderId(MetadataProvider.MusicBrainzAlbum); @@ -47,9 +47,9 @@ namespace MediaBrowser.Providers.Music return id; } - public static string GetMusicBrainzArtistId(this AlbumInfo info) + public static string? GetMusicBrainzArtistId(this AlbumInfo info) { - info.ProviderIds.TryGetValue(MetadataProvider.MusicBrainzAlbumArtist.ToString(), out string id); + info.ProviderIds.TryGetValue(MetadataProvider.MusicBrainzAlbumArtist.ToString(), out string? id); if (string.IsNullOrEmpty(id)) { @@ -65,7 +65,7 @@ namespace MediaBrowser.Providers.Music return id; } - public static string GetMusicBrainzArtistId(this ArtistInfo info) + public static string? GetMusicBrainzArtistId(this ArtistInfo info) { info.ProviderIds.TryGetValue(MetadataProvider.MusicBrainzArtist.ToString(), out var id); diff --git a/MediaBrowser.Providers/Music/AlbumMetadataService.cs b/MediaBrowser.Providers/Music/AlbumMetadataService.cs index 8c9a1f59b..7743d3b27 100644 --- a/MediaBrowser.Providers/Music/AlbumMetadataService.cs +++ b/MediaBrowser.Providers/Music/AlbumMetadataService.cs @@ -81,7 +81,7 @@ namespace MediaBrowser.Providers.Music if (!item.AlbumArtists.SequenceEqual(artists, StringComparer.OrdinalIgnoreCase)) { item.AlbumArtists = artists; - updateType = updateType | ItemUpdateType.MetadataEdit; + updateType |= ItemUpdateType.MetadataEdit; } return updateType; @@ -100,7 +100,7 @@ namespace MediaBrowser.Providers.Music if (!item.Artists.SequenceEqual(artists, StringComparer.OrdinalIgnoreCase)) { item.Artists = artists; - updateType = updateType | ItemUpdateType.MetadataEdit; + updateType |= ItemUpdateType.MetadataEdit; } return updateType; @@ -114,7 +114,7 @@ namespace MediaBrowser.Providers.Music bool replaceData, bool mergeMetadataSettings) { - ProviderUtils.MergeBaseItemData(source, target, lockedFields, replaceData, mergeMetadataSettings); + base.MergeData(source, target, lockedFields, replaceData, mergeMetadataSettings); var sourceItem = source.Item; var targetItem = target.Item; diff --git a/MediaBrowser.Providers/Music/ArtistMetadataService.cs b/MediaBrowser.Providers/Music/ArtistMetadataService.cs index e29475dd7..1f342c0db 100644 --- a/MediaBrowser.Providers/Music/ArtistMetadataService.cs +++ b/MediaBrowser.Providers/Music/ArtistMetadataService.cs @@ -6,7 +6,6 @@ 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.Providers.Manager; using Microsoft.Extensions.Logging; @@ -39,11 +38,5 @@ namespace MediaBrowser.Providers.Music }) : item.GetRecursiveChildren(i => i is IHasArtist && !i.IsFolder); } - - /// <inheritdoc /> - protected override void MergeData(MetadataResult<MusicArtist> source, MetadataResult<MusicArtist> target, MetadataField[] lockedFields, bool replaceData, bool mergeMetadataSettings) - { - ProviderUtils.MergeBaseItemData(source, target, lockedFields, replaceData, mergeMetadataSettings); - } } } diff --git a/MediaBrowser.Providers/Music/AudioMetadataService.cs b/MediaBrowser.Providers/Music/AudioMetadataService.cs index 8b9fc8a08..4577f7745 100644 --- a/MediaBrowser.Providers/Music/AudioMetadataService.cs +++ b/MediaBrowser.Providers/Music/AudioMetadataService.cs @@ -26,7 +26,7 @@ namespace MediaBrowser.Providers.Music /// <inheritdoc /> protected override void MergeData(MetadataResult<Audio> source, MetadataResult<Audio> target, MetadataField[] lockedFields, bool replaceData, bool mergeMetadataSettings) { - ProviderUtils.MergeBaseItemData(source, target, lockedFields, replaceData, mergeMetadataSettings); + base.MergeData(source, target, lockedFields, replaceData, mergeMetadataSettings); var sourceItem = source.Item; var targetItem = target.Item; diff --git a/MediaBrowser.Providers/Music/ImvdbId.cs b/MediaBrowser.Providers/Music/ImvdbId.cs index a1726b996..ed69f369c 100644 --- a/MediaBrowser.Providers/Music/ImvdbId.cs +++ b/MediaBrowser.Providers/Music/ImvdbId.cs @@ -19,7 +19,7 @@ namespace MediaBrowser.Providers.Music public ExternalIdMediaType? Type => null; /// <inheritdoc /> - public string UrlFormatString => null; + public string? UrlFormatString => null; /// <inheritdoc /> public bool Supports(IHasProviderIds item) diff --git a/MediaBrowser.Providers/Music/MusicVideoMetadataService.cs b/MediaBrowser.Providers/Music/MusicVideoMetadataService.cs index 1d611a746..b97b76630 100644 --- a/MediaBrowser.Providers/Music/MusicVideoMetadataService.cs +++ b/MediaBrowser.Providers/Music/MusicVideoMetadataService.cs @@ -31,7 +31,7 @@ namespace MediaBrowser.Providers.Music bool replaceData, bool mergeMetadataSettings) { - ProviderUtils.MergeBaseItemData(source, target, lockedFields, replaceData, mergeMetadataSettings); + base.MergeData(source, target, lockedFields, replaceData, mergeMetadataSettings); var sourceItem = source.Item; var targetItem = target.Item; diff --git a/MediaBrowser.Providers/MusicGenres/MusicGenreMetadataService.cs b/MediaBrowser.Providers/MusicGenres/MusicGenreMetadataService.cs index 7dda7e9bf..46eb546c2 100644 --- a/MediaBrowser.Providers/MusicGenres/MusicGenreMetadataService.cs +++ b/MediaBrowser.Providers/MusicGenres/MusicGenreMetadataService.cs @@ -4,7 +4,6 @@ using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Entities.Audio; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Providers; -using MediaBrowser.Model.Entities; using MediaBrowser.Model.IO; using MediaBrowser.Providers.Manager; using Microsoft.Extensions.Logging; @@ -22,11 +21,5 @@ namespace MediaBrowser.Providers.MusicGenres : base(serverConfigurationManager, logger, providerManager, fileSystem, libraryManager) { } - - /// <inheritdoc /> - protected override void MergeData(MetadataResult<MusicGenre> source, MetadataResult<MusicGenre> target, MetadataField[] lockedFields, bool replaceData, bool mergeMetadataSettings) - { - ProviderUtils.MergeBaseItemData(source, target, lockedFields, replaceData, mergeMetadataSettings); - } } } diff --git a/MediaBrowser.Providers/People/PersonMetadataService.cs b/MediaBrowser.Providers/People/PersonMetadataService.cs index fe6d1d4d3..59bf7e4e6 100644 --- a/MediaBrowser.Providers/People/PersonMetadataService.cs +++ b/MediaBrowser.Providers/People/PersonMetadataService.cs @@ -4,7 +4,6 @@ using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Providers; -using MediaBrowser.Model.Entities; using MediaBrowser.Model.IO; using MediaBrowser.Providers.Manager; using Microsoft.Extensions.Logging; @@ -22,11 +21,5 @@ namespace MediaBrowser.Providers.People : base(serverConfigurationManager, logger, providerManager, fileSystem, libraryManager) { } - - /// <inheritdoc /> - protected override void MergeData(MetadataResult<Person> source, MetadataResult<Person> target, MetadataField[] lockedFields, bool replaceData, bool mergeMetadataSettings) - { - ProviderUtils.MergeBaseItemData(source, target, lockedFields, replaceData, mergeMetadataSettings); - } } } diff --git a/MediaBrowser.Providers/Photos/PhotoAlbumMetadataService.cs b/MediaBrowser.Providers/Photos/PhotoAlbumMetadataService.cs index 60ed96452..f2cccb90f 100644 --- a/MediaBrowser.Providers/Photos/PhotoAlbumMetadataService.cs +++ b/MediaBrowser.Providers/Photos/PhotoAlbumMetadataService.cs @@ -4,7 +4,6 @@ using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Providers; -using MediaBrowser.Model.Entities; using MediaBrowser.Model.IO; using MediaBrowser.Providers.Manager; using Microsoft.Extensions.Logging; @@ -22,11 +21,5 @@ namespace MediaBrowser.Providers.Photos : base(serverConfigurationManager, logger, providerManager, fileSystem, libraryManager) { } - - /// <inheritdoc /> - protected override void MergeData(MetadataResult<PhotoAlbum> source, MetadataResult<PhotoAlbum> target, MetadataField[] lockedFields, bool replaceData, bool mergeMetadataSettings) - { - ProviderUtils.MergeBaseItemData(source, target, lockedFields, replaceData, mergeMetadataSettings); - } } } diff --git a/MediaBrowser.Providers/Photos/PhotoMetadataService.cs b/MediaBrowser.Providers/Photos/PhotoMetadataService.cs index cbbb433c0..6941401e0 100644 --- a/MediaBrowser.Providers/Photos/PhotoMetadataService.cs +++ b/MediaBrowser.Providers/Photos/PhotoMetadataService.cs @@ -4,7 +4,6 @@ using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Providers; -using MediaBrowser.Model.Entities; using MediaBrowser.Model.IO; using MediaBrowser.Providers.Manager; using Microsoft.Extensions.Logging; @@ -22,11 +21,5 @@ namespace MediaBrowser.Providers.Photos : base(serverConfigurationManager, logger, providerManager, fileSystem, libraryManager) { } - - /// <inheritdoc /> - protected override void MergeData(MetadataResult<Photo> source, MetadataResult<Photo> target, MetadataField[] lockedFields, bool replaceData, bool mergeMetadataSettings) - { - ProviderUtils.MergeBaseItemData(source, target, lockedFields, replaceData, mergeMetadataSettings); - } } } diff --git a/MediaBrowser.Providers/Playlists/PlaylistItemsProvider.cs b/MediaBrowser.Providers/Playlists/PlaylistItemsProvider.cs index 067d585cb..fe9986d42 100644 --- a/MediaBrowser.Providers/Playlists/PlaylistItemsProvider.cs +++ b/MediaBrowser.Providers/Playlists/PlaylistItemsProvider.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System; @@ -6,6 +8,7 @@ using System.IO; using System.Linq; using System.Threading; using System.Threading.Tasks; +using Jellyfin.Extensions; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Playlists; @@ -42,7 +45,7 @@ namespace MediaBrowser.Providers.Playlists } var extension = Path.GetExtension(path); - if (!Playlist.SupportedExtensions.Contains(extension ?? string.Empty, StringComparer.OrdinalIgnoreCase)) + if (!Playlist.SupportedExtensions.Contains(extension ?? string.Empty, StringComparison.OrdinalIgnoreCase)) { return Task.FromResult(ItemUpdateType.None); } diff --git a/MediaBrowser.Providers/Playlists/PlaylistMetadataService.cs b/MediaBrowser.Providers/Playlists/PlaylistMetadataService.cs index 5262919d5..1bd000a48 100644 --- a/MediaBrowser.Providers/Playlists/PlaylistMetadataService.cs +++ b/MediaBrowser.Providers/Playlists/PlaylistMetadataService.cs @@ -41,7 +41,7 @@ namespace MediaBrowser.Providers.Playlists /// <inheritdoc /> protected override void MergeData(MetadataResult<Playlist> source, MetadataResult<Playlist> target, MetadataField[] lockedFields, bool replaceData, bool mergeMetadataSettings) { - ProviderUtils.MergeBaseItemData(source, target, lockedFields, replaceData, mergeMetadataSettings); + base.MergeData(source, target, lockedFields, replaceData, mergeMetadataSettings); var sourceItem = source.Item; var targetItem = target.Item; diff --git a/MediaBrowser.Providers/Plugins/AudioDb/AudioDbAlbumExternalId.cs b/MediaBrowser.Providers/Plugins/AudioDb/AudioDbAlbumExternalId.cs index 138cfef19..3a400575b 100644 --- a/MediaBrowser.Providers/Plugins/AudioDb/AudioDbAlbumExternalId.cs +++ b/MediaBrowser.Providers/Plugins/AudioDb/AudioDbAlbumExternalId.cs @@ -19,7 +19,7 @@ namespace MediaBrowser.Providers.Plugins.AudioDb public ExternalIdMediaType? Type => null; /// <inheritdoc /> - public string UrlFormatString => "https://www.theaudiodb.com/album/{0}"; + public string? UrlFormatString => "https://www.theaudiodb.com/album/{0}"; /// <inheritdoc /> public bool Supports(IHasProviderIds item) => item is MusicAlbum; diff --git a/MediaBrowser.Providers/Plugins/AudioDb/AudioDbAlbumImageProvider.cs b/MediaBrowser.Providers/Plugins/AudioDb/AudioDbAlbumImageProvider.cs index 81bbc26b8..ad0247fb2 100644 --- a/MediaBrowser.Providers/Plugins/AudioDb/AudioDbAlbumImageProvider.cs +++ b/MediaBrowser.Providers/Plugins/AudioDb/AudioDbAlbumImageProvider.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System.Collections.Generic; diff --git a/MediaBrowser.Providers/Plugins/AudioDb/AudioDbAlbumProvider.cs b/MediaBrowser.Providers/Plugins/AudioDb/AudioDbAlbumProvider.cs index c1226febf..43f30824b 100644 --- a/MediaBrowser.Providers/Plugins/AudioDb/AudioDbAlbumProvider.cs +++ b/MediaBrowser.Providers/Plugins/AudioDb/AudioDbAlbumProvider.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CA1002, CS1591, SA1300 using System; @@ -172,8 +174,11 @@ namespace MediaBrowser.Providers.Plugins.AudioDb using var response = await _httpClientFactory.CreateClient(NamedClient.Default).GetAsync(url, cancellationToken).ConfigureAwait(false); await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); - // use FileShare.None as this bypasses dotnet bug dotnet/runtime#42790 . - await using var xmlFileStream = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.None, IODefaults.FileStreamBufferSize, AsyncFile.UseAsyncIO); + + var fileStreamOptions = AsyncFile.WriteOptions; + fileStreamOptions.Mode = FileMode.Create; + fileStreamOptions.PreallocationSize = stream.Length; + await using var xmlFileStream = new FileStream(path, fileStreamOptions); await stream.CopyToAsync(xmlFileStream, cancellationToken).ConfigureAwait(false); } diff --git a/MediaBrowser.Providers/Plugins/AudioDb/AudioDbArtistExternalId.cs b/MediaBrowser.Providers/Plugins/AudioDb/AudioDbArtistExternalId.cs index 8aceb48c0..b9e57eb26 100644 --- a/MediaBrowser.Providers/Plugins/AudioDb/AudioDbArtistExternalId.cs +++ b/MediaBrowser.Providers/Plugins/AudioDb/AudioDbArtistExternalId.cs @@ -19,7 +19,7 @@ namespace MediaBrowser.Providers.Plugins.AudioDb public ExternalIdMediaType? Type => ExternalIdMediaType.Artist; /// <inheritdoc /> - public string UrlFormatString => "https://www.theaudiodb.com/artist/{0}"; + public string? UrlFormatString => "https://www.theaudiodb.com/artist/{0}"; /// <inheritdoc /> public bool Supports(IHasProviderIds item) => item is MusicArtist; diff --git a/MediaBrowser.Providers/Plugins/AudioDb/AudioDbArtistImageProvider.cs b/MediaBrowser.Providers/Plugins/AudioDb/AudioDbArtistImageProvider.cs index 3ffdcdbeb..9c2447660 100644 --- a/MediaBrowser.Providers/Plugins/AudioDb/AudioDbArtistImageProvider.cs +++ b/MediaBrowser.Providers/Plugins/AudioDb/AudioDbArtistImageProvider.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System.Collections.Generic; diff --git a/MediaBrowser.Providers/Plugins/AudioDb/AudioDbArtistProvider.cs b/MediaBrowser.Providers/Plugins/AudioDb/AudioDbArtistProvider.cs index 8572b3413..538dc67c4 100644 --- a/MediaBrowser.Providers/Plugins/AudioDb/AudioDbArtistProvider.cs +++ b/MediaBrowser.Providers/Plugins/AudioDb/AudioDbArtistProvider.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CA1034, CS1591, CA1002, SA1028, SA1300 using System; @@ -154,8 +156,10 @@ namespace MediaBrowser.Providers.Plugins.AudioDb Directory.CreateDirectory(Path.GetDirectoryName(path)); - // use FileShare.None as this bypasses dotnet bug dotnet/runtime#42790 . - await using var xmlFileStream = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.None, IODefaults.FileStreamBufferSize, AsyncFile.UseAsyncIO); + var fileStreamOptions = AsyncFile.WriteOptions; + fileStreamOptions.Mode = FileMode.Create; + fileStreamOptions.PreallocationSize = stream.Length; + await using var xmlFileStream = new FileStream(path, fileStreamOptions); await stream.CopyToAsync(xmlFileStream, cancellationToken).ConfigureAwait(false); } diff --git a/MediaBrowser.Providers/Plugins/AudioDb/AudioDbOtherAlbumExternalId.cs b/MediaBrowser.Providers/Plugins/AudioDb/AudioDbOtherAlbumExternalId.cs index 014481da2..f8f6253ff 100644 --- a/MediaBrowser.Providers/Plugins/AudioDb/AudioDbOtherAlbumExternalId.cs +++ b/MediaBrowser.Providers/Plugins/AudioDb/AudioDbOtherAlbumExternalId.cs @@ -19,7 +19,7 @@ namespace MediaBrowser.Providers.Plugins.AudioDb public ExternalIdMediaType? Type => ExternalIdMediaType.Album; /// <inheritdoc /> - public string UrlFormatString => "https://www.theaudiodb.com/album/{0}"; + public string? UrlFormatString => "https://www.theaudiodb.com/album/{0}"; /// <inheritdoc /> public bool Supports(IHasProviderIds item) => item is Audio; diff --git a/MediaBrowser.Providers/Plugins/AudioDb/AudioDbOtherArtistExternalId.cs b/MediaBrowser.Providers/Plugins/AudioDb/AudioDbOtherArtistExternalId.cs index 787539104..fd598c918 100644 --- a/MediaBrowser.Providers/Plugins/AudioDb/AudioDbOtherArtistExternalId.cs +++ b/MediaBrowser.Providers/Plugins/AudioDb/AudioDbOtherArtistExternalId.cs @@ -19,7 +19,7 @@ namespace MediaBrowser.Providers.Plugins.AudioDb public ExternalIdMediaType? Type => ExternalIdMediaType.OtherArtist; /// <inheritdoc /> - public string UrlFormatString => "https://www.theaudiodb.com/artist/{0}"; + public string? UrlFormatString => "https://www.theaudiodb.com/artist/{0}"; /// <inheritdoc /> public bool Supports(IHasProviderIds item) => item is Audio || item is MusicAlbum; diff --git a/MediaBrowser.Providers/Plugins/AudioDb/Configuration/PluginConfiguration.cs b/MediaBrowser.Providers/Plugins/AudioDb/Configuration/PluginConfiguration.cs index 664474dcd..d61ec6cb1 100644 --- a/MediaBrowser.Providers/Plugins/AudioDb/Configuration/PluginConfiguration.cs +++ b/MediaBrowser.Providers/Plugins/AudioDb/Configuration/PluginConfiguration.cs @@ -1,4 +1,4 @@ -#pragma warning disable CS1591 +#pragma warning disable CS1591 using MediaBrowser.Model.Plugins; diff --git a/MediaBrowser.Providers/Plugins/AudioDb/Plugin.cs b/MediaBrowser.Providers/Plugins/AudioDb/Plugin.cs index ba0d7b569..6c2ad0573 100644 --- a/MediaBrowser.Providers/Plugins/AudioDb/Plugin.cs +++ b/MediaBrowser.Providers/Plugins/AudioDb/Plugin.cs @@ -1,3 +1,4 @@ +#nullable disable #pragma warning disable CS1591 using System; diff --git a/MediaBrowser.Providers/Plugins/MusicBrainz/Configuration/PluginConfiguration.cs b/MediaBrowser.Providers/Plugins/MusicBrainz/Configuration/PluginConfiguration.cs index 0cec9e359..9c27bd7d3 100644 --- a/MediaBrowser.Providers/Plugins/MusicBrainz/Configuration/PluginConfiguration.cs +++ b/MediaBrowser.Providers/Plugins/MusicBrainz/Configuration/PluginConfiguration.cs @@ -1,4 +1,4 @@ -#pragma warning disable CS1591 +#pragma warning disable CS1591 using MediaBrowser.Model.Plugins; @@ -12,24 +12,13 @@ namespace MediaBrowser.Providers.Plugins.MusicBrainz public string Server { - get - { - return _server; - } - - set - { - _server = value.TrimEnd('/'); - } + get => _server; + set => _server = value.TrimEnd('/'); } public long RateLimit { - get - { - return _rateLimit; - } - + get => _rateLimit; set { if (value < Plugin.DefaultRateLimit && _server == Plugin.DefaultServer) diff --git a/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzAlbumArtistExternalId.cs b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzAlbumArtistExternalId.cs index 1b37e2a60..c54cdda3d 100644 --- a/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzAlbumArtistExternalId.cs +++ b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzAlbumArtistExternalId.cs @@ -20,7 +20,7 @@ namespace MediaBrowser.Providers.Music public ExternalIdMediaType? Type => ExternalIdMediaType.AlbumArtist; /// <inheritdoc /> - public string UrlFormatString => Plugin.Instance.Configuration.Server + "/artist/{0}"; + public string? UrlFormatString => Plugin.Instance.Configuration.Server + "/artist/{0}"; /// <inheritdoc /> public bool Supports(IHasProviderIds item) => item is Audio; diff --git a/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzAlbumExternalId.cs b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzAlbumExternalId.cs index ef095111a..8f7fadd06 100644 --- a/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzAlbumExternalId.cs +++ b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzAlbumExternalId.cs @@ -20,7 +20,7 @@ namespace MediaBrowser.Providers.Music public ExternalIdMediaType? Type => ExternalIdMediaType.Album; /// <inheritdoc /> - public string UrlFormatString => Plugin.Instance.Configuration.Server + "/release/{0}"; + public string? UrlFormatString => Plugin.Instance.Configuration.Server + "/release/{0}"; /// <inheritdoc /> public bool Supports(IHasProviderIds item) => item is Audio || item is MusicAlbum; diff --git a/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzAlbumProvider.cs b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzAlbumProvider.cs index 93f8902de..5ae5ff3be 100644 --- a/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzAlbumProvider.cs +++ b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzAlbumProvider.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591, SA1401 using System; @@ -301,7 +303,7 @@ namespace MediaBrowser.Providers.Music return ReleaseResult.Parse(reader).FirstOrDefault(); } - private static (string, string) ParseArtistCredit(XmlReader reader) + private static (string Name, string ArtistId) ParseArtistCredit(XmlReader reader) { reader.MoveToContent(); reader.Read(); @@ -317,6 +319,12 @@ namespace MediaBrowser.Providers.Music { case "name-credit": { + if (reader.IsEmptyElement) + { + reader.Read(); + break; + } + using var subReader = reader.ReadSubtree(); return ParseArtistNameCredit(subReader); } @@ -337,7 +345,7 @@ namespace MediaBrowser.Providers.Music return default; } - private static (string, string) ParseArtistNameCredit(XmlReader reader) + private static (string Name, string ArtistId) ParseArtistNameCredit(XmlReader reader) { reader.MoveToContent(); reader.Read(); @@ -353,6 +361,12 @@ namespace MediaBrowser.Providers.Music { case "artist": { + if (reader.IsEmptyElement) + { + reader.Read(); + break; + } + var id = reader.GetAttribute("id"); using var subReader = reader.ReadSubtree(); return ParseArtistArtistCredit(subReader, id); @@ -374,7 +388,7 @@ namespace MediaBrowser.Providers.Music return (null, null); } - private static (string name, string id) ParseArtistArtistCredit(XmlReader reader, string artistId) + private static (string Name, string ArtistId) ParseArtistArtistCredit(XmlReader reader, string artistId) { reader.MoveToContent(); reader.Read(); @@ -455,8 +469,8 @@ namespace MediaBrowser.Providers.Music }; using var reader = XmlReader.Create(oReader, settings); - reader.MoveToContent(); - reader.Read(); + await reader.MoveToContentAsync().ConfigureAwait(false); + await reader.ReadAsync().ConfigureAwait(false); // Loop through each element while (!reader.EOF && reader.ReadState == ReadState.Interactive) @@ -469,7 +483,7 @@ namespace MediaBrowser.Providers.Music { if (reader.IsEmptyElement) { - reader.Read(); + await reader.ReadAsync().ConfigureAwait(false); continue; } @@ -479,14 +493,14 @@ namespace MediaBrowser.Providers.Music default: { - reader.Skip(); + await reader.SkipAsync().ConfigureAwait(false); break; } } } else { - reader.Read(); + await reader.ReadAsync().ConfigureAwait(false); } } @@ -614,7 +628,7 @@ namespace MediaBrowser.Providers.Music public string Overview; public int? Year; - public List<ValueTuple<string, string>> Artists = new List<ValueTuple<string, string>>(); + public List<(string, string)> Artists = new(); public static IEnumerable<ReleaseResult> Parse(XmlReader reader) { @@ -753,10 +767,16 @@ namespace MediaBrowser.Providers.Music case "artist-credit": { + if (reader.IsEmptyElement) + { + reader.Read(); + break; + } + using var subReader = reader.ReadSubtree(); var artist = ParseArtistCredit(subReader); - if (!string.IsNullOrEmpty(artist.Item1)) + if (!string.IsNullOrEmpty(artist.Name)) { result.Artists.Add(artist); } diff --git a/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzArtistExternalId.cs b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzArtistExternalId.cs index d654e1372..941ffea72 100644 --- a/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzArtistExternalId.cs +++ b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzArtistExternalId.cs @@ -20,7 +20,7 @@ namespace MediaBrowser.Providers.Music public ExternalIdMediaType? Type => ExternalIdMediaType.Artist; /// <inheritdoc /> - public string UrlFormatString => Plugin.Instance.Configuration.Server + "/artist/{0}"; + public string? UrlFormatString => Plugin.Instance.Configuration.Server + "/artist/{0}"; /// <inheritdoc /> public bool Supports(IHasProviderIds item) => item is MusicArtist; diff --git a/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzArtistProvider.cs b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzArtistProvider.cs index 7cff5f595..1feb7f4ea 100644 --- a/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzArtistProvider.cs +++ b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzArtistProvider.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System; diff --git a/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzOtherArtistExternalId.cs b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzOtherArtistExternalId.cs index f889a34b5..05db2d98f 100644 --- a/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzOtherArtistExternalId.cs +++ b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzOtherArtistExternalId.cs @@ -20,7 +20,7 @@ namespace MediaBrowser.Providers.Music public ExternalIdMediaType? Type => ExternalIdMediaType.OtherArtist; /// <inheritdoc /> - public string UrlFormatString => Plugin.Instance.Configuration.Server + "/artist/{0}"; + public string? UrlFormatString => Plugin.Instance.Configuration.Server + "/artist/{0}"; /// <inheritdoc /> public bool Supports(IHasProviderIds item) => item is Audio || item is MusicAlbum; diff --git a/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzReleaseGroupExternalId.cs b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzReleaseGroupExternalId.cs index 53783d2c0..acb652fe0 100644 --- a/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzReleaseGroupExternalId.cs +++ b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzReleaseGroupExternalId.cs @@ -20,7 +20,7 @@ namespace MediaBrowser.Providers.Music public ExternalIdMediaType? Type => ExternalIdMediaType.ReleaseGroup; /// <inheritdoc /> - public string UrlFormatString => Plugin.Instance.Configuration.Server + "/release-group/{0}"; + public string? UrlFormatString => Plugin.Instance.Configuration.Server + "/release-group/{0}"; /// <inheritdoc /> public bool Supports(IHasProviderIds item) => item is Audio || item is MusicAlbum; diff --git a/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzTrackId.cs b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzTrackId.cs index 627f8f098..14805b9b7 100644 --- a/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzTrackId.cs +++ b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzTrackId.cs @@ -20,7 +20,7 @@ namespace MediaBrowser.Providers.Music public ExternalIdMediaType? Type => ExternalIdMediaType.Track; /// <inheritdoc /> - public string UrlFormatString => Plugin.Instance.Configuration.Server + "/track/{0}"; + public string? UrlFormatString => Plugin.Instance.Configuration.Server + "/track/{0}"; /// <inheritdoc /> public bool Supports(IHasProviderIds item) => item is Audio; diff --git a/MediaBrowser.Providers/Plugins/MusicBrainz/Plugin.cs b/MediaBrowser.Providers/Plugins/MusicBrainz/Plugin.cs index 69b69be42..cfa10dd64 100644 --- a/MediaBrowser.Providers/Plugins/MusicBrainz/Plugin.cs +++ b/MediaBrowser.Providers/Plugins/MusicBrainz/Plugin.cs @@ -1,3 +1,4 @@ +#nullable disable #pragma warning disable CS1591 using System; diff --git a/MediaBrowser.Providers/Plugins/Omdb/Configuration/PluginConfiguration.cs b/MediaBrowser.Providers/Plugins/Omdb/Configuration/PluginConfiguration.cs index 196f14e7c..099547005 100644 --- a/MediaBrowser.Providers/Plugins/Omdb/Configuration/PluginConfiguration.cs +++ b/MediaBrowser.Providers/Plugins/Omdb/Configuration/PluginConfiguration.cs @@ -1,4 +1,4 @@ -#pragma warning disable CS1591 +#pragma warning disable CS1591 using MediaBrowser.Model.Plugins; diff --git a/MediaBrowser.Providers/Plugins/Omdb/JsonOmdbNotAvailableInt32Converter.cs b/MediaBrowser.Providers/Plugins/Omdb/JsonOmdbNotAvailableInt32Converter.cs index 268538815..8bfdc461e 100644 --- a/MediaBrowser.Providers/Plugins/Omdb/JsonOmdbNotAvailableInt32Converter.cs +++ b/MediaBrowser.Providers/Plugins/Omdb/JsonOmdbNotAvailableInt32Converter.cs @@ -1,5 +1,3 @@ -#nullable enable - using System; using System.ComponentModel; using System.Text.Json; @@ -18,7 +16,7 @@ namespace MediaBrowser.Providers.Plugins.Omdb if (reader.TokenType == JsonTokenType.String) { var str = reader.GetString(); - if (str != null && str.Equals("N/A", StringComparison.OrdinalIgnoreCase)) + if (str == null || str.Equals("N/A", StringComparison.OrdinalIgnoreCase)) { return null; } diff --git a/MediaBrowser.Providers/Plugins/Omdb/JsonOmdbNotAvailableStringConverter.cs b/MediaBrowser.Providers/Plugins/Omdb/JsonOmdbNotAvailableStringConverter.cs index c19589d45..f35880a04 100644 --- a/MediaBrowser.Providers/Plugins/Omdb/JsonOmdbNotAvailableStringConverter.cs +++ b/MediaBrowser.Providers/Plugins/Omdb/JsonOmdbNotAvailableStringConverter.cs @@ -1,5 +1,3 @@ -#nullable enable - using System; using System.Text.Json; using System.Text.Json.Serialization; diff --git a/MediaBrowser.Providers/Plugins/Omdb/OmdbEpisodeProvider.cs b/MediaBrowser.Providers/Plugins/Omdb/OmdbEpisodeProvider.cs index 24ef80a35..d8b33a799 100644 --- a/MediaBrowser.Providers/Plugins/Omdb/OmdbEpisodeProvider.cs +++ b/MediaBrowser.Providers/Plugins/Omdb/OmdbEpisodeProvider.cs @@ -4,7 +4,6 @@ using System.Collections.Generic; using System.Net.Http; using System.Threading; using System.Threading.Tasks; -using MediaBrowser.Common; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Entities.TV; using MediaBrowser.Controller.Library; @@ -17,24 +16,17 @@ namespace MediaBrowser.Providers.Plugins.Omdb { public class OmdbEpisodeProvider : IRemoteMetadataProvider<Episode, EpisodeInfo>, IHasOrder { - private readonly IHttpClientFactory _httpClientFactory; private readonly OmdbItemProvider _itemProvider; - private readonly IFileSystem _fileSystem; - private readonly IServerConfigurationManager _configurationManager; - private readonly IApplicationHost _appHost; + private readonly OmdbProvider _omdbProvider; public OmdbEpisodeProvider( - IApplicationHost appHost, IHttpClientFactory httpClientFactory, ILibraryManager libraryManager, IFileSystem fileSystem, IServerConfigurationManager configurationManager) { - _httpClientFactory = httpClientFactory; - _fileSystem = fileSystem; - _configurationManager = configurationManager; - _appHost = appHost; - _itemProvider = new OmdbItemProvider(_appHost, httpClientFactory, libraryManager, fileSystem, configurationManager); + _itemProvider = new OmdbItemProvider(httpClientFactory, libraryManager, fileSystem, configurationManager); + _omdbProvider = new OmdbProvider(httpClientFactory, fileSystem, configurationManager); } // After TheTvDb @@ -44,12 +36,12 @@ namespace MediaBrowser.Providers.Plugins.Omdb public Task<IEnumerable<RemoteSearchResult>> GetSearchResults(EpisodeInfo searchInfo, CancellationToken cancellationToken) { - return _itemProvider.GetSearchResults(searchInfo, "episode", cancellationToken); + return _itemProvider.GetSearchResults(searchInfo, cancellationToken); } public async Task<MetadataResult<Episode>> GetMetadata(EpisodeInfo info, CancellationToken cancellationToken) { - var result = new MetadataResult<Episode>() + var result = new MetadataResult<Episode> { Item = new Episode(), QueriedById = true @@ -61,13 +53,20 @@ namespace MediaBrowser.Providers.Plugins.Omdb return result; } - if (info.SeriesProviderIds.TryGetValue(MetadataProvider.Imdb.ToString(), out string seriesImdbId) && !string.IsNullOrEmpty(seriesImdbId)) + if (info.SeriesProviderIds.TryGetValue(MetadataProvider.Imdb.ToString(), out string? seriesImdbId) + && !string.IsNullOrEmpty(seriesImdbId) + && info.IndexNumber.HasValue + && info.ParentIndexNumber.HasValue) { - if (info.IndexNumber.HasValue && info.ParentIndexNumber.HasValue) - { - result.HasMetadata = await new OmdbProvider(_httpClientFactory, _fileSystem, _appHost, _configurationManager) - .FetchEpisodeData(result, info.IndexNumber.Value, info.ParentIndexNumber.Value, info.GetProviderId(MetadataProvider.Imdb), seriesImdbId, info.MetadataLanguage, info.MetadataCountryCode, cancellationToken).ConfigureAwait(false); - } + result.HasMetadata = await _omdbProvider.FetchEpisodeData( + result, + info.IndexNumber.Value, + info.ParentIndexNumber.Value, + info.GetProviderId(MetadataProvider.Imdb), + seriesImdbId, + info.MetadataLanguage, + info.MetadataCountryCode, + cancellationToken).ConfigureAwait(false); } return result; diff --git a/MediaBrowser.Providers/Plugins/Omdb/OmdbImageProvider.cs b/MediaBrowser.Providers/Plugins/Omdb/OmdbImageProvider.cs index df67aff31..60b373483 100644 --- a/MediaBrowser.Providers/Plugins/Omdb/OmdbImageProvider.cs +++ b/MediaBrowser.Providers/Plugins/Omdb/OmdbImageProvider.cs @@ -1,11 +1,12 @@ +#nullable disable + #pragma warning disable CS1591 using System.Collections.Generic; -using System.Globalization; +using System.Linq; using System.Net.Http; using System.Threading; using System.Threading.Tasks; -using MediaBrowser.Common; using MediaBrowser.Common.Net; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Entities; @@ -21,16 +22,12 @@ namespace MediaBrowser.Providers.Plugins.Omdb public class OmdbImageProvider : IRemoteImageProvider, IHasOrder { private readonly IHttpClientFactory _httpClientFactory; - private readonly IFileSystem _fileSystem; - private readonly IServerConfigurationManager _configurationManager; - private readonly IApplicationHost _appHost; + private readonly OmdbProvider _omdbProvider; - public OmdbImageProvider(IApplicationHost appHost, IHttpClientFactory httpClientFactory, IFileSystem fileSystem, IServerConfigurationManager configurationManager) + public OmdbImageProvider(IHttpClientFactory httpClientFactory, IFileSystem fileSystem, IServerConfigurationManager configurationManager) { _httpClientFactory = httpClientFactory; - _fileSystem = fileSystem; - _configurationManager = configurationManager; - _appHost = appHost; + _omdbProvider = new OmdbProvider(_httpClientFactory, fileSystem, configurationManager); } public string Name => "The Open Movie Database"; @@ -50,38 +47,27 @@ namespace MediaBrowser.Providers.Plugins.Omdb public async Task<IEnumerable<RemoteImageInfo>> GetImages(BaseItem item, CancellationToken cancellationToken) { var imdbId = item.GetProviderId(MetadataProvider.Imdb); - - var list = new List<RemoteImageInfo>(); - - var provider = new OmdbProvider(_httpClientFactory, _fileSystem, _appHost, _configurationManager); - - if (!string.IsNullOrWhiteSpace(imdbId)) + if (string.IsNullOrWhiteSpace(imdbId)) { - var rootObject = await provider.GetRootObject(imdbId, cancellationToken).ConfigureAwait(false); - - if (!string.IsNullOrEmpty(rootObject.Poster)) - { - if (item is Episode) - { - // img.omdbapi.com is returning 404's - list.Add(new RemoteImageInfo - { - ProviderName = Name, - Url = rootObject.Poster - }); - } - else - { - list.Add(new RemoteImageInfo - { - ProviderName = Name, - Url = string.Format(CultureInfo.InvariantCulture, "https://img.omdbapi.com/?i={0}&apikey=2c9d9507", imdbId) - }); - } - } + return Enumerable.Empty<RemoteImageInfo>(); } - return list; + var rootObject = await _omdbProvider.GetRootObject(imdbId, cancellationToken).ConfigureAwait(false); + + if (string.IsNullOrEmpty(rootObject.Poster)) + { + return Enumerable.Empty<RemoteImageInfo>(); + } + + // the poster url is sometimes higher quality than the poster api + return new[] + { + new RemoteImageInfo + { + ProviderName = Name, + Url = rootObject.Poster + } + }; } public Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken cancellationToken) diff --git a/MediaBrowser.Providers/Plugins/Omdb/OmdbItemProvider.cs b/MediaBrowser.Providers/Plugins/Omdb/OmdbItemProvider.cs index 02e696de5..e5753b2b5 100644 --- a/MediaBrowser.Providers/Plugins/Omdb/OmdbItemProvider.cs +++ b/MediaBrowser.Providers/Plugins/Omdb/OmdbItemProvider.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591, SA1300 using System; @@ -6,11 +8,11 @@ using System.Globalization; using System.Linq; using System.Net; using System.Net.Http; +using System.Text; using System.Text.Json; using System.Threading; using System.Threading.Tasks; using Jellyfin.Extensions.Json; -using MediaBrowser.Common; using MediaBrowser.Common.Net; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Entities; @@ -29,13 +31,10 @@ namespace MediaBrowser.Providers.Plugins.Omdb { private readonly IHttpClientFactory _httpClientFactory; private readonly ILibraryManager _libraryManager; - private readonly IFileSystem _fileSystem; - private readonly IServerConfigurationManager _configurationManager; - private readonly IApplicationHost _appHost; private readonly JsonSerializerOptions _jsonOptions; + private readonly OmdbProvider _omdbProvider; public OmdbItemProvider( - IApplicationHost appHost, IHttpClientFactory httpClientFactory, ILibraryManager libraryManager, IFileSystem fileSystem, @@ -43,9 +42,7 @@ namespace MediaBrowser.Providers.Plugins.Omdb { _httpClientFactory = httpClientFactory; _libraryManager = libraryManager; - _fileSystem = fileSystem; - _configurationManager = configurationManager; - _appHost = appHost; + _omdbProvider = new OmdbProvider(_httpClientFactory, fileSystem, configurationManager); _jsonOptions = new JsonSerializerOptions(JsonDefaults.Options); _jsonOptions.Converters.Add(new JsonOmdbNotAvailableStringConverter()); @@ -57,185 +54,166 @@ namespace MediaBrowser.Providers.Plugins.Omdb // After primary option public int Order => 2; + public Task<IEnumerable<RemoteSearchResult>> GetSearchResults(TrailerInfo searchInfo, CancellationToken cancellationToken) + { + return GetSearchResultsInternal(searchInfo, true, cancellationToken); + } + public Task<IEnumerable<RemoteSearchResult>> GetSearchResults(SeriesInfo searchInfo, CancellationToken cancellationToken) { - return GetSearchResults(searchInfo, "series", cancellationToken); + return GetSearchResultsInternal(searchInfo, true, cancellationToken); } public Task<IEnumerable<RemoteSearchResult>> GetSearchResults(MovieInfo searchInfo, CancellationToken cancellationToken) { - return GetSearchResults(searchInfo, "movie", cancellationToken); + return GetSearchResultsInternal(searchInfo, true, cancellationToken); } - public Task<IEnumerable<RemoteSearchResult>> GetSearchResults(ItemLookupInfo searchInfo, string type, CancellationToken cancellationToken) + public Task<IEnumerable<RemoteSearchResult>> GetSearchResults(EpisodeInfo searchInfo, CancellationToken cancellationToken) { - return GetSearchResultsInternal(searchInfo, type, true, cancellationToken); + return GetSearchResultsInternal(searchInfo, true, cancellationToken); } - private async Task<IEnumerable<RemoteSearchResult>> GetSearchResultsInternal(ItemLookupInfo searchInfo, string type, bool isSearch, CancellationToken cancellationToken) + private async Task<IEnumerable<RemoteSearchResult>> GetSearchResultsInternal(ItemLookupInfo searchInfo, bool isSearch, CancellationToken cancellationToken) { + var type = searchInfo switch + { + EpisodeInfo => "episode", + SeriesInfo => "series", + _ => "movie" + }; + + // This is a bit hacky? var episodeSearchInfo = searchInfo as EpisodeInfo; + var indexNumberEnd = episodeSearchInfo?.IndexNumberEnd; var imdbId = searchInfo.GetProviderId(MetadataProvider.Imdb); - var urlQuery = "plot=full&r=json"; - if (type == "episode" && episodeSearchInfo != null) + var urlQuery = new StringBuilder("plot=full&r=json"); + if (episodeSearchInfo != null) { episodeSearchInfo.SeriesProviderIds.TryGetValue(MetadataProvider.Imdb.ToString(), out imdbId); - } - - var name = searchInfo.Name; - var year = searchInfo.Year; - - if (!string.IsNullOrWhiteSpace(name)) - { - var parsedName = _libraryManager.ParseName(name); - var yearInName = parsedName.Year; - name = parsedName.Name; - year ??= yearInName; - } - - if (string.IsNullOrWhiteSpace(imdbId)) - { - if (year.HasValue) - { - urlQuery += "&y=" + year.Value.ToString(CultureInfo.InvariantCulture); - } - - // &s means search and returns a list of results as opposed to t - if (isSearch) - { - urlQuery += "&s=" + WebUtility.UrlEncode(name); - } - else - { - urlQuery += "&t=" + WebUtility.UrlEncode(name); - } - - urlQuery += "&type=" + type; - } - else - { - urlQuery += "&i=" + imdbId; - isSearch = false; - } - - if (type == "episode") - { if (searchInfo.IndexNumber.HasValue) { - urlQuery += string.Format(CultureInfo.InvariantCulture, "&Episode={0}", searchInfo.IndexNumber); + urlQuery.Append("&Episode=").Append(searchInfo.IndexNumber.Value); } if (searchInfo.ParentIndexNumber.HasValue) { - urlQuery += string.Format(CultureInfo.InvariantCulture, "&Season={0}", searchInfo.ParentIndexNumber); + urlQuery.Append("&Season=").Append(searchInfo.ParentIndexNumber.Value); } } - var url = OmdbProvider.GetOmdbUrl(urlQuery); + if (string.IsNullOrWhiteSpace(imdbId)) + { + var name = searchInfo.Name; + var year = searchInfo.Year; + if (!string.IsNullOrWhiteSpace(name)) + { + var parsedName = _libraryManager.ParseName(name); + var yearInName = parsedName.Year; + name = parsedName.Name; + year ??= yearInName; + } - using var response = await OmdbProvider.GetOmdbResponse(_httpClientFactory.CreateClient(NamedClient.Default), url, cancellationToken).ConfigureAwait(false); + if (year.HasValue) + { + urlQuery.Append("&y=").Append(year); + } + + // &s means search and returns a list of results as opposed to t + urlQuery.Append(isSearch ? "&s=" : "&t="); + urlQuery.Append(WebUtility.UrlEncode(name)); + urlQuery.Append("&type=") + .Append(type); + } + else + { + urlQuery.Append("&i=") + .Append(imdbId); + isSearch = false; + } + + var url = OmdbProvider.GetOmdbUrl(urlQuery.ToString()); + + using var response = await _httpClientFactory.CreateClient(NamedClient.Default).GetAsync(url, cancellationToken).ConfigureAwait(false); await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); - var resultList = new List<SearchResult>(); if (isSearch) { var searchResultList = await JsonSerializer.DeserializeAsync<SearchResultList>(stream, _jsonOptions, cancellationToken).ConfigureAwait(false); - if (searchResultList != null && searchResultList.Search != null) + if (searchResultList?.Search != null) { - resultList.AddRange(searchResultList.Search); + var resultCount = searchResultList.Search.Count; + var result = new RemoteSearchResult[resultCount]; + for (var i = 0; i < resultCount; i++) + { + result[i] = ResultToMetadataResult(searchResultList.Search[i], searchInfo, indexNumberEnd); + } + + return result; } } else { var result = await JsonSerializer.DeserializeAsync<SearchResult>(stream, _jsonOptions, cancellationToken).ConfigureAwait(false); - if (string.Equals(result.Response, "true", StringComparison.OrdinalIgnoreCase)) + if (string.Equals(result?.Response, "true", StringComparison.OrdinalIgnoreCase)) { - resultList.Add(result); + return new[] { ResultToMetadataResult(result, searchInfo, indexNumberEnd) }; } } - return resultList.Select(result => - { - var item = new RemoteSearchResult - { - IndexNumber = searchInfo.IndexNumber, - Name = result.Title, - ParentIndexNumber = searchInfo.ParentIndexNumber, - SearchProviderName = Name - }; - - if (episodeSearchInfo != null && episodeSearchInfo.IndexNumberEnd.HasValue) - { - item.IndexNumberEnd = episodeSearchInfo.IndexNumberEnd.Value; - } - - item.SetProviderId(MetadataProvider.Imdb, result.imdbID); - - if (result.Year.Length > 0 - && int.TryParse(result.Year.AsSpan().Slice(0, Math.Min(result.Year.Length, 4)), NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsedYear)) - { - item.ProductionYear = parsedYear; - } - - if (!string.IsNullOrEmpty(result.Released) - && DateTime.TryParse(result.Released, CultureInfo.InvariantCulture, DateTimeStyles.AllowWhiteSpaces, out var released)) - { - item.PremiereDate = released; - } - - if (!string.IsNullOrWhiteSpace(result.Poster) && !string.Equals(result.Poster, "N/A", StringComparison.OrdinalIgnoreCase)) - { - item.ImageUrl = result.Poster; - } - - return item; - }); + return Enumerable.Empty<RemoteSearchResult>(); } public Task<MetadataResult<Trailer>> GetMetadata(TrailerInfo info, CancellationToken cancellationToken) { - return GetMovieResult<Trailer>(info, cancellationToken); + return GetResult<Trailer>(info, cancellationToken); } - public Task<IEnumerable<RemoteSearchResult>> GetSearchResults(TrailerInfo searchInfo, CancellationToken cancellationToken) + public Task<MetadataResult<Series>> GetMetadata(SeriesInfo info, CancellationToken cancellationToken) { - return GetSearchResults(searchInfo, "movie", cancellationToken); - } - - public async Task<MetadataResult<Series>> GetMetadata(SeriesInfo info, CancellationToken cancellationToken) - { - var result = new MetadataResult<Series> - { - Item = new Series(), - QueriedById = true - }; - - var imdbId = info.GetProviderId(MetadataProvider.Imdb); - if (string.IsNullOrWhiteSpace(imdbId)) - { - imdbId = await GetSeriesImdbId(info, cancellationToken).ConfigureAwait(false); - result.QueriedById = false; - } - - if (!string.IsNullOrEmpty(imdbId)) - { - result.Item.SetProviderId(MetadataProvider.Imdb, imdbId); - result.HasMetadata = true; - - await new OmdbProvider(_httpClientFactory, _fileSystem, _appHost, _configurationManager).Fetch(result, imdbId, info.MetadataLanguage, info.MetadataCountryCode, cancellationToken).ConfigureAwait(false); - } - - return result; + return GetResult<Series>(info, cancellationToken); } public Task<MetadataResult<Movie>> GetMetadata(MovieInfo info, CancellationToken cancellationToken) { - return GetMovieResult<Movie>(info, cancellationToken); + return GetResult<Movie>(info, cancellationToken); } - private async Task<MetadataResult<T>> GetMovieResult<T>(ItemLookupInfo info, CancellationToken cancellationToken) + private RemoteSearchResult ResultToMetadataResult(SearchResult result, ItemLookupInfo searchInfo, int? indexNumberEnd) + { + var item = new RemoteSearchResult + { + IndexNumber = searchInfo.IndexNumber, + Name = result.Title, + ParentIndexNumber = searchInfo.ParentIndexNumber, + SearchProviderName = Name, + IndexNumberEnd = indexNumberEnd + }; + + item.SetProviderId(MetadataProvider.Imdb, result.imdbID); + + if (OmdbProvider.TryParseYear(result.Year, out var parsedYear)) + { + item.ProductionYear = parsedYear; + } + + if (!string.IsNullOrEmpty(result.Released) + && DateTime.TryParse(result.Released, CultureInfo.InvariantCulture, DateTimeStyles.AllowWhiteSpaces, out var released)) + { + item.PremiereDate = released; + } + + if (!string.IsNullOrWhiteSpace(result.Poster)) + { + item.ImageUrl = result.Poster; + } + + return item; + } + + private async Task<MetadataResult<T>> GetResult<T>(ItemLookupInfo info, CancellationToken cancellationToken) where T : BaseItem, new() { var result = new MetadataResult<T> @@ -247,7 +225,7 @@ namespace MediaBrowser.Providers.Plugins.Omdb var imdbId = info.GetProviderId(MetadataProvider.Imdb); if (string.IsNullOrWhiteSpace(imdbId)) { - imdbId = await GetMovieImdbId(info, cancellationToken).ConfigureAwait(false); + imdbId = await GetImdbId(info, cancellationToken).ConfigureAwait(false); result.QueriedById = false; } @@ -256,22 +234,15 @@ namespace MediaBrowser.Providers.Plugins.Omdb result.Item.SetProviderId(MetadataProvider.Imdb, imdbId); result.HasMetadata = true; - await new OmdbProvider(_httpClientFactory, _fileSystem, _appHost, _configurationManager).Fetch(result, imdbId, info.MetadataLanguage, info.MetadataCountryCode, cancellationToken).ConfigureAwait(false); + await _omdbProvider.Fetch(result, imdbId, info.MetadataLanguage, info.MetadataCountryCode, cancellationToken).ConfigureAwait(false); } return result; } - private async Task<string> GetMovieImdbId(ItemLookupInfo info, CancellationToken cancellationToken) + private async Task<string> GetImdbId(ItemLookupInfo info, CancellationToken cancellationToken) { - var results = await GetSearchResultsInternal(info, "movie", false, cancellationToken).ConfigureAwait(false); - var first = results.FirstOrDefault(); - return first?.GetProviderId(MetadataProvider.Imdb); - } - - private async Task<string> GetSeriesImdbId(SeriesInfo info, CancellationToken cancellationToken) - { - var results = await GetSearchResultsInternal(info, "series", false, cancellationToken).ConfigureAwait(false); + var results = await GetSearchResultsInternal(info, false, cancellationToken).ConfigureAwait(false); var first = results.FirstOrDefault(); return first?.GetProviderId(MetadataProvider.Imdb); } diff --git a/MediaBrowser.Providers/Plugins/Omdb/OmdbProvider.cs b/MediaBrowser.Providers/Plugins/Omdb/OmdbProvider.cs index 1dea3dece..12ea2d55b 100644 --- a/MediaBrowser.Providers/Plugins/Omdb/OmdbProvider.cs +++ b/MediaBrowser.Providers/Plugins/Omdb/OmdbProvider.cs @@ -1,16 +1,19 @@ +#nullable disable + #pragma warning disable CS159, SA1300 using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Globalization; using System.IO; using System.Linq; using System.Net.Http; +using System.Net.Http.Json; using System.Text.Json; using System.Threading; using System.Threading.Tasks; using Jellyfin.Extensions.Json; -using MediaBrowser.Common; using MediaBrowser.Common.Net; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Entities; @@ -26,25 +29,22 @@ namespace MediaBrowser.Providers.Plugins.Omdb private readonly IFileSystem _fileSystem; private readonly IServerConfigurationManager _configurationManager; private readonly IHttpClientFactory _httpClientFactory; - private readonly CultureInfo _usCulture = new CultureInfo("en-US"); - private readonly IApplicationHost _appHost; private readonly JsonSerializerOptions _jsonOptions; /// <summary>Initializes a new instance of the <see cref="OmdbProvider"/> class.</summary> /// <param name="httpClientFactory">HttpClientFactory to use for calls to OMDB service.</param> /// <param name="fileSystem">IFileSystem to use for store OMDB data.</param> - /// <param name="appHost">IApplicationHost to use.</param> /// <param name="configurationManager">IServerConfigurationManager to use.</param> - public OmdbProvider(IHttpClientFactory httpClientFactory, IFileSystem fileSystem, IApplicationHost appHost, IServerConfigurationManager configurationManager) + public OmdbProvider(IHttpClientFactory httpClientFactory, IFileSystem fileSystem, IServerConfigurationManager configurationManager) { _httpClientFactory = httpClientFactory; _fileSystem = fileSystem; _configurationManager = configurationManager; - _appHost = appHost; _jsonOptions = new JsonSerializerOptions(JsonDefaults.Options); - _jsonOptions.Converters.Add(new JsonOmdbNotAvailableStringConverter()); - _jsonOptions.Converters.Add(new JsonOmdbNotAvailableInt32Converter()); + // These converters need to take priority + _jsonOptions.Converters.Insert(0, new JsonOmdbNotAvailableStringConverter()); + _jsonOptions.Converters.Insert(0, new JsonOmdbNotAvailableInt32Converter()); } /// <summary>Fetches data from OMDB service.</summary> @@ -67,8 +67,9 @@ namespace MediaBrowser.Providers.Plugins.Omdb var result = await GetRootObject(imdbId, cancellationToken).ConfigureAwait(false); + var isEnglishRequested = IsConfiguredForEnglish(item, language); // Only take the name and rating if the user's language is set to English, since Omdb has no localization - if (string.Equals(language, "en", StringComparison.OrdinalIgnoreCase) || _configurationManager.Configuration.EnableNewOmdbSupport) + if (isEnglishRequested) { item.Name = result.Title; @@ -78,9 +79,7 @@ namespace MediaBrowser.Providers.Plugins.Omdb } } - if (!string.IsNullOrEmpty(result.Year) && result.Year.Length >= 4 - && int.TryParse(result.Year.AsSpan().Slice(0, 4), NumberStyles.Number, _usCulture, out var year) - && year >= 0) + if (TryParseYear(result.Year, out var year)) { item.ProductionYear = year; } @@ -93,14 +92,14 @@ namespace MediaBrowser.Providers.Plugins.Omdb } if (!string.IsNullOrEmpty(result.imdbVotes) - && int.TryParse(result.imdbVotes, NumberStyles.Number, _usCulture, out var voteCount) + && int.TryParse(result.imdbVotes, NumberStyles.Number, CultureInfo.InvariantCulture, out var voteCount) && voteCount >= 0) { // item.VoteCount = voteCount; } if (!string.IsNullOrEmpty(result.imdbRating) - && float.TryParse(result.imdbRating, NumberStyles.Any, _usCulture, out var imdbRating) + && float.TryParse(result.imdbRating, NumberStyles.Any, CultureInfo.InvariantCulture, out var imdbRating) && imdbRating >= 0) { item.CommunityRating = imdbRating; @@ -116,7 +115,7 @@ namespace MediaBrowser.Providers.Plugins.Omdb item.SetProviderId(MetadataProvider.Imdb, result.imdbID); } - ParseAdditionalMetadata(itemResult, result); + ParseAdditionalMetadata(itemResult, result, isEnglishRequested); } /// <summary>Gets data about an episode.</summary> @@ -179,8 +178,9 @@ namespace MediaBrowser.Providers.Plugins.Omdb return false; } + var isEnglishRequested = IsConfiguredForEnglish(item, language); // Only take the name and rating if the user's language is set to English, since Omdb has no localization - if (string.Equals(language, "en", StringComparison.OrdinalIgnoreCase) || _configurationManager.Configuration.EnableNewOmdbSupport) + if (isEnglishRequested) { item.Name = result.Title; @@ -190,9 +190,7 @@ namespace MediaBrowser.Providers.Plugins.Omdb } } - if (!string.IsNullOrEmpty(result.Year) && result.Year.Length >= 4 - && int.TryParse(result.Year.AsSpan().Slice(0, 4), NumberStyles.Number, _usCulture, out var year) - && year >= 0) + if (TryParseYear(result.Year, out var year)) { item.ProductionYear = year; } @@ -205,14 +203,14 @@ namespace MediaBrowser.Providers.Plugins.Omdb } if (!string.IsNullOrEmpty(result.imdbVotes) - && int.TryParse(result.imdbVotes, NumberStyles.Number, _usCulture, out var voteCount) + && int.TryParse(result.imdbVotes, NumberStyles.Number, CultureInfo.InvariantCulture, out var voteCount) && voteCount >= 0) { // item.VoteCount = voteCount; } if (!string.IsNullOrEmpty(result.imdbRating) - && float.TryParse(result.imdbRating, NumberStyles.Any, _usCulture, out var imdbRating) + && float.TryParse(result.imdbRating, NumberStyles.Any, CultureInfo.InvariantCulture, out var imdbRating) && imdbRating >= 0) { item.CommunityRating = imdbRating; @@ -228,7 +226,7 @@ namespace MediaBrowser.Providers.Plugins.Omdb item.SetProviderId(MetadataProvider.Imdb, result.imdbID); } - ParseAdditionalMetadata(itemResult, result); + ParseAdditionalMetadata(itemResult, result, isEnglishRequested); return true; } @@ -262,6 +260,30 @@ namespace MediaBrowser.Providers.Plugins.Omdb return Url + "&" + query; } + /// <summary> + /// Extract the year from a string. + /// </summary> + /// <param name="input">The input string.</param> + /// <param name="year">The year.</param> + /// <returns>A value indicating whether the input could successfully be parsed as a year.</returns> + public static bool TryParseYear(string input, [NotNullWhen(true)] out int? year) + { + if (string.IsNullOrEmpty(input)) + { + year = 0; + return false; + } + + if (int.TryParse(input.AsSpan(0, 4), NumberStyles.Number, CultureInfo.InvariantCulture, out var result)) + { + year = result; + return true; + } + + year = 0; + return false; + } + private async Task<string> EnsureItemInfo(string imdbId, CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(imdbId)) @@ -294,8 +316,8 @@ namespace MediaBrowser.Providers.Plugins.Omdb "i={0}&plot=short&tomatoes=true&r=json", imdbParam)); - var rootObject = await GetDeserializedOmdbResponse<RootObject>(_httpClientFactory.CreateClient(NamedClient.Default), url, cancellationToken).ConfigureAwait(false); - await using FileStream jsonFileStream = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.None, IODefaults.FileStreamBufferSize, AsyncFile.UseAsyncIO); + var rootObject = await _httpClientFactory.CreateClient(NamedClient.Default).GetFromJsonAsync<RootObject>(url, _jsonOptions, cancellationToken).ConfigureAwait(false); + await using FileStream jsonFileStream = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.None, IODefaults.FileStreamBufferSize, FileOptions.Asynchronous); await JsonSerializer.SerializeAsync(jsonFileStream, rootObject, _jsonOptions, cancellationToken).ConfigureAwait(false); return path; @@ -334,37 +356,13 @@ namespace MediaBrowser.Providers.Plugins.Omdb imdbParam, seasonId)); - var rootObject = await GetDeserializedOmdbResponse<SeasonRootObject>(_httpClientFactory.CreateClient(NamedClient.Default), url, cancellationToken).ConfigureAwait(false); - await using FileStream jsonFileStream = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.None, IODefaults.FileStreamBufferSize, AsyncFile.UseAsyncIO); + var rootObject = await _httpClientFactory.CreateClient(NamedClient.Default).GetFromJsonAsync<SeasonRootObject>(url, _jsonOptions, cancellationToken).ConfigureAwait(false); + await using FileStream jsonFileStream = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.None, IODefaults.FileStreamBufferSize, FileOptions.Asynchronous); await JsonSerializer.SerializeAsync(jsonFileStream, rootObject, _jsonOptions, cancellationToken).ConfigureAwait(false); return path; } - /// <summary>Gets response from OMDB service as type T.</summary> - /// <param name="httpClient">HttpClient instance to use for service call.</param> - /// <param name="url">Http URL to use for service call.</param> - /// <param name="cancellationToken">CancellationToken to use for service call.</param> - /// <typeparam name="T">The first generic type parameter.</typeparam> - /// <returns>OMDB service response as type T.</returns> - public async Task<T> GetDeserializedOmdbResponse<T>(HttpClient httpClient, string url, CancellationToken cancellationToken) - { - using var response = await GetOmdbResponse(httpClient, url, cancellationToken).ConfigureAwait(false); - await using Stream content = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); - - return await JsonSerializer.DeserializeAsync<T>(content, _jsonOptions, cancellationToken).ConfigureAwait(false); - } - - /// <summary>Gets response from OMDB service.</summary> - /// <param name="httpClient">HttpClient instance to use for service call.</param> - /// <param name="url">Http URL to use for service call.</param> - /// <param name="cancellationToken">CancellationToken to use for service call.</param> - /// <returns>OMDB service response as HttpResponseMessage.</returns> - public static Task<HttpResponseMessage> GetOmdbResponse(HttpClient httpClient, string url, CancellationToken cancellationToken) - { - return httpClient.GetAsync(url, cancellationToken); - } - internal string GetDataFilePath(string imdbId) { if (string.IsNullOrEmpty(imdbId)) @@ -393,31 +391,25 @@ namespace MediaBrowser.Providers.Plugins.Omdb return Path.Combine(dataPath, filename); } - private void ParseAdditionalMetadata<T>(MetadataResult<T> itemResult, RootObject result) + private static void ParseAdditionalMetadata<T>(MetadataResult<T> itemResult, RootObject result, bool isEnglishRequested) where T : BaseItem { var item = itemResult.Item; - var isConfiguredForEnglish = IsConfiguredForEnglish(item) || _configurationManager.Configuration.EnableNewOmdbSupport; - // Grab series genres because IMDb data is better than TVDB. Leave movies alone // But only do it if English is the preferred language because this data will not be localized - if (isConfiguredForEnglish && !string.IsNullOrWhiteSpace(result.Genre)) + if (isEnglishRequested && !string.IsNullOrWhiteSpace(result.Genre)) { item.Genres = Array.Empty<string>(); - foreach (var genre in result.Genre - .Split(',', StringSplitOptions.RemoveEmptyEntries) - .Select(i => i.Trim()) - .Where(i => !string.IsNullOrWhiteSpace(i))) + foreach (var genre in result.Genre.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) { item.AddGenre(genre); } } - if (isConfiguredForEnglish) + if (isEnglishRequested) { - // Omdb is currently English only, so for other languages skip this and let secondary providers fill it in item.Overview = result.Plot; } @@ -430,7 +422,7 @@ namespace MediaBrowser.Providers.Plugins.Omdb { var person = new PersonInfo { - Name = result.Director.Trim(), + Name = result.Director, Type = PersonType.Director }; @@ -441,7 +433,7 @@ namespace MediaBrowser.Providers.Plugins.Omdb { var person = new PersonInfo { - Name = result.Writer.Trim(), + Name = result.Writer, Type = PersonType.Writer }; @@ -450,29 +442,34 @@ namespace MediaBrowser.Providers.Plugins.Omdb if (!string.IsNullOrWhiteSpace(result.Actors)) { - var actorList = result.Actors.Split(','); + var actorList = result.Actors.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); foreach (var actor in actorList) { - if (!string.IsNullOrWhiteSpace(actor)) + if (string.IsNullOrWhiteSpace(actor)) { - var person = new PersonInfo - { - Name = actor.Trim(), - Type = PersonType.Actor - }; - - itemResult.AddPerson(person); + continue; } + + var person = new PersonInfo + { + Name = actor, + Type = PersonType.Actor + }; + + itemResult.AddPerson(person); } } } - private bool IsConfiguredForEnglish(BaseItem item) + private static bool IsConfiguredForEnglish(BaseItem item, string language) { - var lang = item.GetPreferredMetadataLanguage(); + if (string.IsNullOrEmpty(language)) + { + language = item.GetPreferredMetadataLanguage(); + } // The data isn't localized and so can only be used for English users - return string.Equals(lang, "en", StringComparison.OrdinalIgnoreCase); + return string.Equals(language, "en", StringComparison.OrdinalIgnoreCase); } internal class SeasonRootObject @@ -549,7 +546,7 @@ namespace MediaBrowser.Providers.Plugins.Omdb if (Ratings != null) { var rating = Ratings.FirstOrDefault(i => string.Equals(i.Source, "Rotten Tomatoes", StringComparison.OrdinalIgnoreCase)); - if (rating != null && rating.Value != null) + if (rating?.Value != null) { var value = rating.Value.TrimEnd('%'); if (float.TryParse(value, NumberStyles.Any, CultureInfo.InvariantCulture, out var score)) diff --git a/MediaBrowser.Providers/Plugins/Omdb/Plugin.cs b/MediaBrowser.Providers/Plugins/Omdb/Plugin.cs index 047df4f33..a0fba48f0 100644 --- a/MediaBrowser.Providers/Plugins/Omdb/Plugin.cs +++ b/MediaBrowser.Providers/Plugins/Omdb/Plugin.cs @@ -1,3 +1,4 @@ +#nullable disable #pragma warning disable CS1591 using System; diff --git a/MediaBrowser.Providers/Plugins/StudioImages/Configuration/PluginConfiguration.cs b/MediaBrowser.Providers/Plugins/StudioImages/Configuration/PluginConfiguration.cs new file mode 100644 index 000000000..fad989ab4 --- /dev/null +++ b/MediaBrowser.Providers/Plugins/StudioImages/Configuration/PluginConfiguration.cs @@ -0,0 +1,24 @@ +#pragma warning disable CS1591 + +using MediaBrowser.Model.Plugins; + +namespace MediaBrowser.Providers.Plugins.StudioImages +{ + public class PluginConfiguration : BasePluginConfiguration + { + private string _repository = Plugin.DefaultServer; + + public string RepositoryUrl + { + get + { + return _repository; + } + + set + { + _repository = value.TrimEnd('/'); + } + } + } +} diff --git a/MediaBrowser.Providers/Plugins/StudioImages/Configuration/config.html b/MediaBrowser.Providers/Plugins/StudioImages/Configuration/config.html new file mode 100644 index 000000000..f9fe3dc2e --- /dev/null +++ b/MediaBrowser.Providers/Plugins/StudioImages/Configuration/config.html @@ -0,0 +1,58 @@ +<!DOCTYPE html> +<html> +<head> + <title>Studio Images + + +
+
+
+
+
+ +
This can be any Jellyfin-compatible artwork repository.
+
+
+
+ +
+
+
+
+ +
+ + diff --git a/MediaBrowser.Providers/Plugins/StudioImages/Plugin.cs b/MediaBrowser.Providers/Plugins/StudioImages/Plugin.cs new file mode 100644 index 000000000..e0ab31b56 --- /dev/null +++ b/MediaBrowser.Providers/Plugins/StudioImages/Plugin.cs @@ -0,0 +1,44 @@ +#nullable disable +#pragma warning disable CS1591 + +using System; +using System.Collections.Generic; +using MediaBrowser.Common.Configuration; +using MediaBrowser.Common.Plugins; +using MediaBrowser.Model.Plugins; +using MediaBrowser.Model.Serialization; + +namespace MediaBrowser.Providers.Plugins.StudioImages +{ + public class Plugin : BasePlugin, IHasWebPages + { + // TODO change this for a Jellyfin-hosted repository. + public const string DefaultServer = "https://raw.github.com/MediaBrowser/MediaBrowser.Resources/master/images/imagesbyname"; + + public Plugin(IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer) + : base(applicationPaths, xmlSerializer) + { + Instance = this; + } + + public static Plugin Instance { get; private set; } + + public override Guid Id => new Guid("872a7849-1171-458d-a6fb-3de3d442ad30"); + + public override string Name => "Studio Images"; + + public override string Description => "Get artwork for studios from any Jellyfin-compatible repository."; + + // TODO remove when plugin removed from server. + public override string ConfigurationFileName => "Jellyfin.Plugin.StudioImages.xml"; + + public IEnumerable GetPages() + { + yield return new PluginPageInfo + { + Name = Name, + EmbeddedResourcePath = GetType().Namespace + ".Configuration.config.html" + }; + } + } +} diff --git a/MediaBrowser.Providers/Studios/StudiosImageProvider.cs b/MediaBrowser.Providers/Plugins/StudioImages/StudiosImageProvider.cs similarity index 90% rename from MediaBrowser.Providers/Studios/StudiosImageProvider.cs rename to MediaBrowser.Providers/Plugins/StudioImages/StudiosImageProvider.cs index 7a057c065..3a3048cec 100644 --- a/MediaBrowser.Providers/Studios/StudiosImageProvider.cs +++ b/MediaBrowser.Providers/Plugins/StudioImages/StudiosImageProvider.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System; @@ -16,6 +18,7 @@ using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Entities; using MediaBrowser.Model.IO; using MediaBrowser.Model.Providers; +using MediaBrowser.Providers.Plugins.StudioImages; namespace MediaBrowser.Providers.Studios { @@ -24,15 +27,17 @@ namespace MediaBrowser.Providers.Studios private readonly IServerConfigurationManager _config; private readonly IHttpClientFactory _httpClientFactory; private readonly IFileSystem _fileSystem; + private readonly string repositoryUrl; public StudiosImageProvider(IServerConfigurationManager config, IHttpClientFactory httpClientFactory, IFileSystem fileSystem) { _config = config; _httpClientFactory = httpClientFactory; _fileSystem = fileSystem; + repositoryUrl = Plugin.Instance.Configuration.RepositoryUrl; } - public string Name => "Emby Designs"; + public string Name => "Artwork Repository"; public int Order => 0; @@ -105,19 +110,19 @@ namespace MediaBrowser.Providers.Studios private string GetUrl(string image, string filename) { - return string.Format(CultureInfo.InvariantCulture, "https://raw.github.com/MediaBrowser/MediaBrowser.Resources/master/images/imagesbyname/studios/{0}/{1}.jpg", image, filename); + return string.Format(CultureInfo.InvariantCulture, "{0}/{1}/{2}.jpg", repositoryUrl, image, filename); } private Task EnsureThumbsList(string file, CancellationToken cancellationToken) { - const string url = "https://raw.github.com/MediaBrowser/MediaBrowser.Resources/master/images/imagesbyname/studiothumbs.txt"; + string url = string.Format(CultureInfo.InvariantCulture, "{0}/studiothumbs.txt", repositoryUrl); return EnsureList(url, file, _fileSystem, cancellationToken); } private Task EnsurePosterList(string file, CancellationToken cancellationToken) { - const string url = "https://raw.github.com/MediaBrowser/MediaBrowser.Resources/master/images/imagesbyname/studioposters.txt"; + string url = string.Format(CultureInfo.InvariantCulture, "{0}/studioposters.txt", repositoryUrl); return EnsureList(url, file, _fileSystem, cancellationToken); } @@ -146,7 +151,7 @@ namespace MediaBrowser.Providers.Studios Directory.CreateDirectory(Path.GetDirectoryName(file)); await using var response = await httpClient.GetStreamAsync(url, cancellationToken).ConfigureAwait(false); - await using var fileStream = new FileStream(file, FileMode.Create, FileAccess.Write, FileShare.None, IODefaults.FileStreamBufferSize, AsyncFile.UseAsyncIO); + await using var fileStream = new FileStream(file, FileMode.Create, FileAccess.Write, FileShare.None, IODefaults.FileStreamBufferSize, FileOptions.Asynchronous); await response.CopyToAsync(fileStream, cancellationToken).ConfigureAwait(false); } @@ -172,19 +177,16 @@ namespace MediaBrowser.Providers.Studios public IEnumerable GetAvailableImages(string file) { - using var fileStream = new FileStream(file, FileMode.Open, FileAccess.Read, FileShare.Read); + using var fileStream = File.OpenRead(file); using var reader = new StreamReader(fileStream); - var lines = new List(); foreach (var line in reader.ReadAllLines()) { if (!string.IsNullOrWhiteSpace(line)) { - lines.Add(line); + yield return line; } } - - return lines; } } } diff --git a/MediaBrowser.Providers/Plugins/Tmdb/Api/TmdbController.cs b/MediaBrowser.Providers/Plugins/Tmdb/Api/TmdbController.cs new file mode 100644 index 000000000..0bab7c3ca --- /dev/null +++ b/MediaBrowser.Providers/Plugins/Tmdb/Api/TmdbController.cs @@ -0,0 +1,41 @@ +using System.Net.Mime; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using TMDbLib.Objects.General; + +namespace MediaBrowser.Providers.Plugins.Tmdb.Api +{ + /// + /// The TMDb api controller. + /// + [ApiController] + [Authorize(Policy = "DefaultAuthorization")] + [Route("[controller]")] + [Produces(MediaTypeNames.Application.Json)] + public class TmdbController : ControllerBase + { + private readonly TmdbClientManager _tmdbClientManager; + + /// + /// Initializes a new instance of the class. + /// + /// The TMDb client manager. + public TmdbController(TmdbClientManager tmdbClientManager) + { + _tmdbClientManager = tmdbClientManager; + } + + /// + /// Gets the TMDb image configuration options. + /// + /// The image portion of the TMDb client configuration. + [HttpGet("ClientConfiguration")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task TmdbClientConfiguration() + { + return (await _tmdbClientManager.GetClientConfiguration().ConfigureAwait(false)).Images; + } + } +} diff --git a/MediaBrowser.Providers/Plugins/Tmdb/BoxSets/TmdbBoxSetExternalId.cs b/MediaBrowser.Providers/Plugins/Tmdb/BoxSets/TmdbBoxSetExternalId.cs index 1f7ec6433..3217ac2f1 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/BoxSets/TmdbBoxSetExternalId.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/BoxSets/TmdbBoxSetExternalId.cs @@ -21,7 +21,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.BoxSets public ExternalIdMediaType? Type => ExternalIdMediaType.BoxSet; /// - public string UrlFormatString => TmdbUtils.BaseTmdbUrl + "collection/{0}"; + public string? UrlFormatString => TmdbUtils.BaseTmdbUrl + "collection/{0}"; /// public bool Supports(IHasProviderIds item) diff --git a/MediaBrowser.Providers/Plugins/Tmdb/BoxSets/TmdbBoxSetImageProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/BoxSets/TmdbBoxSetImageProvider.cs index 5ad61c567..29a557c31 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/BoxSets/TmdbBoxSetImageProvider.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/BoxSets/TmdbBoxSetImageProvider.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System; @@ -11,9 +13,7 @@ using MediaBrowser.Common.Net; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.Movies; using MediaBrowser.Controller.Providers; -using MediaBrowser.Model.Dto; using MediaBrowser.Model.Entities; -using MediaBrowser.Model.Extensions; using MediaBrowser.Model.Providers; namespace MediaBrowser.Providers.Plugins.Tmdb.BoxSets @@ -66,42 +66,14 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.BoxSets return Enumerable.Empty(); } - var remoteImages = new List(); + var posters = collection.Images.Posters; + var backdrops = collection.Images.Backdrops; + var remoteImages = new List(posters.Count + backdrops.Count); - for (var i = 0; i < collection.Images.Posters.Count; i++) - { - var poster = collection.Images.Posters[i]; - remoteImages.Add(new RemoteImageInfo - { - Url = _tmdbClientManager.GetPosterUrl(poster.FilePath), - CommunityRating = poster.VoteAverage, - VoteCount = poster.VoteCount, - Width = poster.Width, - Height = poster.Height, - Language = TmdbUtils.AdjustImageLanguage(poster.Iso_639_1, language), - ProviderName = Name, - Type = ImageType.Primary, - RatingType = RatingType.Score - }); - } + _tmdbClientManager.ConvertPostersToRemoteImageInfo(posters, language, remoteImages); + _tmdbClientManager.ConvertBackdropsToRemoteImageInfo(backdrops, language, remoteImages); - for (var i = 0; i < collection.Images.Backdrops.Count; i++) - { - var backdrop = collection.Images.Backdrops[i]; - remoteImages.Add(new RemoteImageInfo - { - Url = _tmdbClientManager.GetBackdropUrl(backdrop.FilePath), - CommunityRating = backdrop.VoteAverage, - VoteCount = backdrop.VoteCount, - Width = backdrop.Width, - Height = backdrop.Height, - ProviderName = Name, - Type = ImageType.Backdrop, - RatingType = RatingType.Score - }); - } - - return remoteImages.OrderByLanguageDescending(language); + return remoteImages; } public Task GetImageResponse(string url, CancellationToken cancellationToken) diff --git a/MediaBrowser.Providers/Plugins/Tmdb/BoxSets/TmdbBoxSetProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/BoxSets/TmdbBoxSetProvider.cs index 5dd1f0b73..62bc9c65f 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/BoxSets/TmdbBoxSetProvider.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/BoxSets/TmdbBoxSetProvider.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System; diff --git a/MediaBrowser.Providers/Plugins/Tmdb/Configuration/PluginConfiguration.cs b/MediaBrowser.Providers/Plugins/Tmdb/Configuration/PluginConfiguration.cs new file mode 100644 index 000000000..92f5306e5 --- /dev/null +++ b/MediaBrowser.Providers/Plugins/Tmdb/Configuration/PluginConfiguration.cs @@ -0,0 +1,55 @@ +using MediaBrowser.Model.Plugins; + +namespace MediaBrowser.Providers.Plugins.Tmdb +{ + /// + /// Plugin configuration class for TMDb library. + /// + public class PluginConfiguration : BasePluginConfiguration + { + /// + /// Gets or sets a value indicating whether include adult content when searching with TMDb. + /// + public bool IncludeAdult { get; set; } + + /// + /// Gets or sets a value indicating whether tags should be imported for series from TMDb. + /// + public bool ExcludeTagsSeries { get; set; } + + /// + /// Gets or sets a value indicating whether tags should be imported for movies from TMDb. + /// + public bool ExcludeTagsMovies { get; set; } + + /// + /// Gets or sets a value indicating whether season name should be imported from TMDb. + /// + public bool ImportSeasonName { get; set; } + + /// + /// Gets or sets a value indicating the maximum number of cast members to fetch for an item. + /// + public int MaxCastMembers { get; set; } = 15; + + /// + /// Gets or sets a value indicating the poster image size to fetch. + /// + public string? PosterSize { get; set; } + + /// + /// Gets or sets a value indicating the backdrop image size to fetch. + /// + public string? BackdropSize { get; set; } + + /// + /// Gets or sets a value indicating the profile image size to fetch. + /// + public string? ProfileSize { get; set; } + + /// + /// Gets or sets a value indicating the still image size to fetch. + /// + public string? StillSize { get; set; } + } +} diff --git a/MediaBrowser.Providers/Plugins/Tmdb/Configuration/config.html b/MediaBrowser.Providers/Plugins/Tmdb/Configuration/config.html new file mode 100644 index 000000000..72bd38ffa --- /dev/null +++ b/MediaBrowser.Providers/Plugins/Tmdb/Configuration/config.html @@ -0,0 +1,143 @@ + + + + TMDb + + +
+
+
+
+ + + + +
+ +
The maximum number of cast members to fetch for an item.
+
+
+

Image Scaling

+
+ +
+
+ +
+
+ +
+
+ +
+
+
+ +
+
+
+
+ +
+ + diff --git a/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieExternalId.cs b/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieExternalId.cs index f1a1b65d8..31310a8d4 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieExternalId.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieExternalId.cs @@ -21,7 +21,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.Movies public ExternalIdMediaType? Type => ExternalIdMediaType.Movie; /// - public string UrlFormatString => TmdbUtils.BaseTmdbUrl + "movie/{0}"; + public string? UrlFormatString => TmdbUtils.BaseTmdbUrl + "movie/{0}"; /// public bool Supports(IHasProviderIds item) diff --git a/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieImageProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieImageProvider.cs index f34d689c1..f71f7bd10 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieImageProvider.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieImageProvider.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System; @@ -11,9 +13,7 @@ using MediaBrowser.Common.Net; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.Movies; using MediaBrowser.Controller.Providers; -using MediaBrowser.Model.Dto; using MediaBrowser.Model.Entities; -using MediaBrowser.Model.Extensions; using MediaBrowser.Model.Providers; using TMDbLib.Objects.Find; @@ -83,42 +83,14 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.Movies return Enumerable.Empty(); } - var remoteImages = new List(); + var posters = movie.Images.Posters; + var backdrops = movie.Images.Backdrops; + var remoteImages = new List(posters.Count + backdrops.Count); - for (var i = 0; i < movie.Images.Posters.Count; i++) - { - var poster = movie.Images.Posters[i]; - remoteImages.Add(new RemoteImageInfo - { - Url = _tmdbClientManager.GetPosterUrl(poster.FilePath), - CommunityRating = poster.VoteAverage, - VoteCount = poster.VoteCount, - Width = poster.Width, - Height = poster.Height, - Language = TmdbUtils.AdjustImageLanguage(poster.Iso_639_1, language), - ProviderName = Name, - Type = ImageType.Primary, - RatingType = RatingType.Score - }); - } + _tmdbClientManager.ConvertPostersToRemoteImageInfo(posters, language, remoteImages); + _tmdbClientManager.ConvertBackdropsToRemoteImageInfo(backdrops, language, remoteImages); - for (var i = 0; i < movie.Images.Backdrops.Count; i++) - { - var backdrop = movie.Images.Backdrops[i]; - remoteImages.Add(new RemoteImageInfo - { - Url = _tmdbClientManager.GetPosterUrl(backdrop.FilePath), - CommunityRating = backdrop.VoteAverage, - VoteCount = backdrop.VoteCount, - Width = backdrop.Width, - Height = backdrop.Height, - ProviderName = Name, - Type = ImageType.Backdrop, - RatingType = RatingType.Score - }); - } - - return remoteImages.OrderByLanguageDescending(language); + return remoteImages; } public Task GetImageResponse(string url, CancellationToken cancellationToken) diff --git a/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieProvider.cs index 54f8d450a..e4a56fde9 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieProvider.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieProvider.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System; @@ -7,6 +9,7 @@ using System.Linq; using System.Net.Http; using System.Threading; using System.Threading.Tasks; +using Jellyfin.Extensions; using MediaBrowser.Common.Net; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.Movies; @@ -239,8 +242,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.Movies if (movieResult.Credits?.Cast != null) { - // TODO configurable - foreach (var actor in movieResult.Credits.Cast.OrderBy(a => a.Order).Take(TmdbUtils.MaxCastMembers)) + foreach (var actor in movieResult.Credits.Cast.OrderBy(a => a.Order).Take(Plugin.Instance.Configuration.MaxCastMembers)) { var personInfo = new PersonInfo { @@ -278,8 +280,8 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.Movies // Normalize this var type = TmdbUtils.MapCrewToPersonType(person); - if (!keepTypes.Contains(type, StringComparer.OrdinalIgnoreCase) && - !keepTypes.Contains(person.Job ?? string.Empty, StringComparer.OrdinalIgnoreCase)) + if (!keepTypes.Contains(type, StringComparison.OrdinalIgnoreCase) && + !keepTypes.Contains(person.Job ?? string.Empty, StringComparison.OrdinalIgnoreCase)) { continue; } diff --git a/MediaBrowser.Providers/Plugins/Tmdb/People/TmdbPersonExternalId.cs b/MediaBrowser.Providers/Plugins/Tmdb/People/TmdbPersonExternalId.cs index de74a7a4c..9804d60bd 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/People/TmdbPersonExternalId.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/People/TmdbPersonExternalId.cs @@ -20,7 +20,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.People public ExternalIdMediaType? Type => ExternalIdMediaType.Person; /// - public string UrlFormatString => TmdbUtils.BaseTmdbUrl + "person/{0}"; + public string? UrlFormatString => TmdbUtils.BaseTmdbUrl + "person/{0}"; /// public bool Supports(IHasProviderIds item) diff --git a/MediaBrowser.Providers/Plugins/Tmdb/People/TmdbPersonImageProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/People/TmdbPersonImageProvider.cs index e4c908a62..7ce4cfe67 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/People/TmdbPersonImageProvider.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/People/TmdbPersonImageProvider.cs @@ -10,7 +10,6 @@ using MediaBrowser.Common.Net; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Entities; -using MediaBrowser.Model.Extensions; using MediaBrowser.Model.Providers; namespace MediaBrowser.Providers.Plugins.Tmdb.People @@ -61,23 +60,12 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.People return Enumerable.Empty(); } - var remoteImages = new RemoteImageInfo[personResult.Images.Profiles.Count]; + var profiles = personResult.Images.Profiles; + var remoteImages = new List(profiles.Count); - for (var i = 0; i < personResult.Images.Profiles.Count; i++) - { - var image = personResult.Images.Profiles[i]; - remoteImages[i] = new RemoteImageInfo - { - ProviderName = Name, - Type = ImageType.Primary, - Width = image.Width, - Height = image.Height, - Language = TmdbUtils.AdjustImageLanguage(image.Iso_639_1, language), - Url = _tmdbClientManager.GetProfileUrl(image.FilePath) - }; - } + _tmdbClientManager.ConvertProfilesToRemoteImageInfo(profiles, language, remoteImages); - return remoteImages.OrderByLanguageDescending(language); + return remoteImages; } public Task GetImageResponse(string url, CancellationToken cancellationToken) diff --git a/MediaBrowser.Providers/Plugins/Tmdb/People/TmdbPersonProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/People/TmdbPersonProvider.cs index dac118388..8790e3759 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/People/TmdbPersonProvider.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/People/TmdbPersonProvider.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System; diff --git a/MediaBrowser.Providers/Plugins/Tmdb/Plugin.cs b/MediaBrowser.Providers/Plugins/Tmdb/Plugin.cs new file mode 100644 index 000000000..4adde8366 --- /dev/null +++ b/MediaBrowser.Providers/Plugins/Tmdb/Plugin.cs @@ -0,0 +1,60 @@ +#nullable disable + +using System; +using System.Collections.Generic; +using MediaBrowser.Common.Configuration; +using MediaBrowser.Common.Plugins; +using MediaBrowser.Model.Plugins; +using MediaBrowser.Model.Serialization; + +namespace MediaBrowser.Providers.Plugins.Tmdb +{ + /// + /// Plugin class for the TMDb library. + /// + public class Plugin : BasePlugin, IHasWebPages + { + /// + /// Initializes a new instance of the class. + /// + /// application paths. + /// xml serializer. + public Plugin(IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer) + : base(applicationPaths, xmlSerializer) + { + Instance = this; + } + + /// + /// Gets the instance of TMDb plugin. + /// + public static Plugin Instance { get; private set; } + + /// + public override Guid Id => new Guid("b8715ed1-6c47-4528-9ad3-f72deb539cd4"); + + /// + public override string Name => "TMDb"; + + /// + public override string Description => "Get metadata for movies and other video content from TheMovieDb."; + + // TODO remove when plugin removed from server. + + /// + public override string ConfigurationFileName => "Jellyfin.Plugin.Tmdb.xml"; + + /// + /// Return the plugin configuration page. + /// + /// PluginPageInfo. + public IEnumerable GetPages() + { + yield return new PluginPageInfo + { + Name = Name, + EmbeddedResourcePath = GetType().Namespace + ".Configuration.config.html" + }; + } + } +} diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbEpisodeImageProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbEpisodeImageProvider.cs index ba18c542f..5eec776b5 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbEpisodeImageProvider.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbEpisodeImageProvider.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System; @@ -10,9 +12,7 @@ using System.Threading.Tasks; using MediaBrowser.Common.Net; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Providers; -using MediaBrowser.Model.Dto; using MediaBrowser.Model.Entities; -using MediaBrowser.Model.Extensions; using MediaBrowser.Model.Providers; namespace MediaBrowser.Providers.Plugins.Tmdb.TV @@ -74,25 +74,11 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV return Enumerable.Empty(); } - var remoteImages = new RemoteImageInfo[stills.Count]; - for (var i = 0; i < stills.Count; i++) - { - var image = stills[i]; - remoteImages[i] = new RemoteImageInfo - { - Url = _tmdbClientManager.GetStillUrl(image.FilePath), - CommunityRating = image.VoteAverage, - VoteCount = image.VoteCount, - Width = image.Width, - Height = image.Height, - Language = TmdbUtils.AdjustImageLanguage(image.Iso_639_1, language), - ProviderName = Name, - Type = ImageType.Primary, - RatingType = RatingType.Score - }; - } + var remoteImages = new List(stills.Count); - return remoteImages.OrderByLanguageDescending(language); + _tmdbClientManager.ConvertStillsToRemoteImageInfo(stills, language, remoteImages); + + return remoteImages; } public Task GetImageResponse(string url, CancellationToken cancellationToken) diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbEpisodeProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbEpisodeProvider.cs index 8ec8f6464..f50f15877 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbEpisodeProvider.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbEpisodeProvider.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System; @@ -7,6 +9,7 @@ using System.Linq; using System.Net.Http; using System.Threading; using System.Threading.Tasks; +using Jellyfin.Extensions; using MediaBrowser.Common.Net; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.TV; @@ -152,7 +155,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV if (credits?.Cast != null) { - foreach (var actor in credits.Cast.OrderBy(a => a.Order).Take(TmdbUtils.MaxCastMembers)) + foreach (var actor in credits.Cast.OrderBy(a => a.Order).Take(Plugin.Instance.Configuration.MaxCastMembers)) { metadataResult.AddPerson(new PersonInfo { @@ -166,7 +169,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV if (credits?.GuestStars != null) { - foreach (var guest in credits.GuestStars.OrderBy(a => a.Order).Take(TmdbUtils.MaxCastMembers)) + foreach (var guest in credits.GuestStars.OrderBy(a => a.Order).Take(Plugin.Instance.Configuration.MaxCastMembers)) { metadataResult.AddPerson(new PersonInfo { @@ -186,8 +189,8 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV // Normalize this var type = TmdbUtils.MapCrewToPersonType(person); - if (!TmdbUtils.WantedCrewTypes.Contains(type, StringComparer.OrdinalIgnoreCase) - && !TmdbUtils.WantedCrewTypes.Contains(person.Job ?? string.Empty, StringComparer.OrdinalIgnoreCase)) + if (!TmdbUtils.WantedCrewTypes.Contains(type, StringComparison.OrdinalIgnoreCase) + && !TmdbUtils.WantedCrewTypes.Contains(person.Job ?? string.Empty, StringComparison.OrdinalIgnoreCase)) { continue; } diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeasonImageProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeasonImageProvider.cs index 0d23c7872..4446fa966 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeasonImageProvider.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeasonImageProvider.cs @@ -11,9 +11,7 @@ using MediaBrowser.Common.Net; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.TV; using MediaBrowser.Controller.Providers; -using MediaBrowser.Model.Dto; using MediaBrowser.Model.Entities; -using MediaBrowser.Model.Extensions; using MediaBrowser.Model.Providers; namespace MediaBrowser.Providers.Plugins.Tmdb.TV @@ -63,25 +61,11 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV return Enumerable.Empty(); } - var remoteImages = new RemoteImageInfo[posters.Count]; - for (var i = 0; i < posters.Count; i++) - { - var image = posters[i]; - remoteImages[i] = new RemoteImageInfo - { - Url = _tmdbClientManager.GetPosterUrl(image.FilePath), - CommunityRating = image.VoteAverage, - VoteCount = image.VoteCount, - Width = image.Width, - Height = image.Height, - Language = TmdbUtils.AdjustImageLanguage(image.Iso_639_1, language), - ProviderName = Name, - Type = ImageType.Primary, - RatingType = RatingType.Score - }; - } + var remoteImages = new List(posters.Count); - return remoteImages.OrderByLanguageDescending(language); + _tmdbClientManager.ConvertPostersToRemoteImageInfo(posters, language, remoteImages); + + return remoteImages; } public IEnumerable GetSupportedImages(BaseItem item) diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeasonProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeasonProvider.cs index 66e30115d..64ed3f408 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeasonProvider.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeasonProvider.cs @@ -7,6 +7,7 @@ using System.Linq; using System.Net.Http; using System.Threading; using System.Threading.Tasks; +using Jellyfin.Extensions; using MediaBrowser.Common.Net; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.TV; @@ -33,7 +34,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV { var result = new MetadataResult(); - info.SeriesProviderIds.TryGetValue(MetadataProvider.Tmdb.ToString(), out string seriesTmdbId); + info.SeriesProviderIds.TryGetValue(MetadataProvider.Tmdb.ToString(), out string? seriesTmdbId); var seasonNumber = info.IndexNumber; @@ -58,6 +59,11 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV Overview = seasonResult.Overview }; + if (Plugin.Instance.Configuration.ImportSeasonName) + { + result.Item.Name = seasonResult.Name; + } + if (!string.IsNullOrEmpty(seasonResult.ExternalIds?.TvdbId)) { result.Item.SetProviderId(MetadataProvider.Tvdb, seasonResult.ExternalIds.TvdbId); @@ -67,7 +73,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV var credits = seasonResult.Credits; if (credits?.Cast != null) { - var cast = credits.Cast.OrderBy(c => c.Order).Take(TmdbUtils.MaxCastMembers).ToList(); + var cast = credits.Cast.OrderBy(c => c.Order).Take(Plugin.Instance.Configuration.MaxCastMembers).ToList(); for (var i = 0; i < cast.Count; i++) { result.AddPerson(new PersonInfo @@ -87,8 +93,8 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV // Normalize this var type = TmdbUtils.MapCrewToPersonType(person); - if (!TmdbUtils.WantedCrewTypes.Contains(type, StringComparer.OrdinalIgnoreCase) - && !TmdbUtils.WantedCrewTypes.Contains(person.Job ?? string.Empty, StringComparer.OrdinalIgnoreCase)) + if (!TmdbUtils.WantedCrewTypes.Contains(type, StringComparison.OrdinalIgnoreCase) + && !TmdbUtils.WantedCrewTypes.Contains(person.Job ?? string.Empty, StringComparison.OrdinalIgnoreCase)) { continue; } diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesExternalId.cs b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesExternalId.cs index 6ecc055d7..8a2be80cd 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesExternalId.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesExternalId.cs @@ -20,7 +20,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV public ExternalIdMediaType? Type => ExternalIdMediaType.Series; /// - public string UrlFormatString => TmdbUtils.BaseTmdbUrl + "tv/{0}"; + public string? UrlFormatString => TmdbUtils.BaseTmdbUrl + "tv/{0}"; /// public bool Supports(IHasProviderIds item) diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesImageProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesImageProvider.cs index 326c116b3..5ef3736c4 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesImageProvider.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesImageProvider.cs @@ -11,9 +11,7 @@ using MediaBrowser.Common.Net; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.TV; using MediaBrowser.Controller.Providers; -using MediaBrowser.Model.Dto; using MediaBrowser.Model.Entities; -using MediaBrowser.Model.Extensions; using MediaBrowser.Model.Providers; namespace MediaBrowser.Providers.Plugins.Tmdb.TV @@ -71,43 +69,12 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV var posters = series.Images.Posters; var backdrops = series.Images.Backdrops; + var remoteImages = new List(posters.Count + backdrops.Count); - var remoteImages = new RemoteImageInfo[posters.Count + backdrops.Count]; + _tmdbClientManager.ConvertPostersToRemoteImageInfo(posters, language, remoteImages); + _tmdbClientManager.ConvertBackdropsToRemoteImageInfo(backdrops, language, remoteImages); - for (var i = 0; i < posters.Count; i++) - { - var poster = posters[i]; - remoteImages[i] = new RemoteImageInfo - { - Url = _tmdbClientManager.GetPosterUrl(poster.FilePath), - CommunityRating = poster.VoteAverage, - VoteCount = poster.VoteCount, - Width = poster.Width, - Height = poster.Height, - Language = TmdbUtils.AdjustImageLanguage(poster.Iso_639_1, language), - ProviderName = Name, - Type = ImageType.Primary, - RatingType = RatingType.Score - }; - } - - for (var i = 0; i < backdrops.Count; i++) - { - var backdrop = series.Images.Backdrops[i]; - remoteImages[posters.Count + i] = new RemoteImageInfo - { - Url = _tmdbClientManager.GetBackdropUrl(backdrop.FilePath), - CommunityRating = backdrop.VoteAverage, - VoteCount = backdrop.VoteCount, - Width = backdrop.Width, - Height = backdrop.Height, - ProviderName = Name, - Type = ImageType.Backdrop, - RatingType = RatingType.Score - }; - } - - return remoteImages.OrderByLanguageDescending(language); + return remoteImages; } public Task GetImageResponse(string url, CancellationToken cancellationToken) diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesProvider.cs index da76345b5..f565b6569 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesProvider.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesProvider.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System; @@ -7,6 +9,7 @@ using System.Linq; using System.Net.Http; using System.Threading; using System.Threading.Tasks; +using Jellyfin.Extensions; using MediaBrowser.Common.Net; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.TV; @@ -329,7 +332,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV { if (seriesResult.Credits?.Cast != null) { - foreach (var actor in seriesResult.Credits.Cast.OrderBy(a => a.Order).Take(TmdbUtils.MaxCastMembers)) + foreach (var actor in seriesResult.Credits.Cast.OrderBy(a => a.Order).Take(Plugin.Instance.Configuration.MaxCastMembers)) { var personInfo = new PersonInfo { @@ -363,8 +366,8 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV // Normalize this var type = TmdbUtils.MapCrewToPersonType(person); - if (!keepTypes.Contains(type, StringComparer.OrdinalIgnoreCase) - && !keepTypes.Contains(person.Job ?? string.Empty, StringComparer.OrdinalIgnoreCase)) + if (!keepTypes.Contains(type, StringComparison.OrdinalIgnoreCase) + && !keepTypes.Contains(person.Job ?? string.Empty, StringComparison.OrdinalIgnoreCase)) { continue; } diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TmdbClientManager.cs b/MediaBrowser.Providers/Plugins/Tmdb/TmdbClientManager.cs index e72af4a40..b00075872 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/TmdbClientManager.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/TmdbClientManager.cs @@ -1,8 +1,13 @@ -using System; +#nullable disable + +using System; using System.Collections.Generic; using System.Globalization; using System.Threading; using System.Threading.Tasks; +using MediaBrowser.Model.Dto; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.Providers; using Microsoft.Extensions.Caching.Memory; using TMDbLib.Client; using TMDbLib.Objects.Collections; @@ -55,11 +60,17 @@ namespace MediaBrowser.Providers.Plugins.Tmdb await EnsureClientConfigAsync().ConfigureAwait(false); + var extraMethods = MovieMethods.Credits | MovieMethods.Releases | MovieMethods.Images | MovieMethods.Videos; + if (!(Plugin.Instance?.Configuration.ExcludeTagsMovies).GetValueOrDefault()) + { + extraMethods |= MovieMethods.Keywords; + } + movie = await _tmDbClient.GetMovieAsync( tmdbId, TmdbUtils.NormalizeLanguage(language), imageLanguages, - MovieMethods.Credits | MovieMethods.Releases | MovieMethods.Images | MovieMethods.Keywords | MovieMethods.Videos, + extraMethods, cancellationToken).ConfigureAwait(false); if (movie != null) @@ -121,11 +132,17 @@ namespace MediaBrowser.Providers.Plugins.Tmdb await EnsureClientConfigAsync().ConfigureAwait(false); + var extraMethods = TvShowMethods.Credits | TvShowMethods.Images | TvShowMethods.ExternalIds | TvShowMethods.Videos | TvShowMethods.ContentRatings | TvShowMethods.EpisodeGroups; + if (!(Plugin.Instance?.Configuration.ExcludeTagsSeries).GetValueOrDefault()) + { + extraMethods |= TvShowMethods.Keywords; + } + series = await _tmDbClient.GetTvShowAsync( tmdbId, language: TmdbUtils.NormalizeLanguage(language), includeImageLanguage: imageLanguages, - extraMethods: TvShowMethods.Credits | TvShowMethods.Images | TvShowMethods.Keywords | TvShowMethods.ExternalIds | TvShowMethods.Videos | TvShowMethods.ContentRatings | TvShowMethods.EpisodeGroups, + extraMethods: extraMethods, cancellationToken: cancellationToken).ConfigureAwait(false); if (series != null) @@ -360,7 +377,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb await EnsureClientConfigAsync().ConfigureAwait(false); var searchResults = await _tmDbClient - .SearchTvShowAsync(name, TmdbUtils.NormalizeLanguage(language), firstAirDateYear: year, cancellationToken: cancellationToken) + .SearchTvShowAsync(name, TmdbUtils.NormalizeLanguage(language), includeAdult: Plugin.Instance.Configuration.IncludeAdult, firstAirDateYear: year, cancellationToken: cancellationToken) .ConfigureAwait(false); if (searchResults.Results.Count > 0) @@ -388,7 +405,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb await EnsureClientConfigAsync().ConfigureAwait(false); var searchResults = await _tmDbClient - .SearchPersonAsync(name, cancellationToken: cancellationToken) + .SearchPersonAsync(name, includeAdult: Plugin.Instance.Configuration.IncludeAdult, cancellationToken: cancellationToken) .ConfigureAwait(false); if (searchResults.Results.Count > 0) @@ -430,7 +447,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb await EnsureClientConfigAsync().ConfigureAwait(false); var searchResults = await _tmDbClient - .SearchMovieAsync(name, TmdbUtils.NormalizeLanguage(language), year: year, cancellationToken: cancellationToken) + .SearchMovieAsync(name, TmdbUtils.NormalizeLanguage(language), includeAdult: Plugin.Instance.Configuration.IncludeAdult, year: year, cancellationToken: cancellationToken) .ConfigureAwait(false); if (searchResults.Results.Count > 0) @@ -470,6 +487,22 @@ namespace MediaBrowser.Providers.Plugins.Tmdb return searchResults.Results; } + /// + /// Handles bad path checking and builds the absolute url. + /// + /// The image size to fetch. + /// The relative URL of the image. + /// The absolute URL. + private string GetUrl(string size, string path) + { + if (string.IsNullOrEmpty(path)) + { + return null; + } + + return _tmDbClient.GetImageUrl(size, path, true).ToString(); + } + /// /// Gets the absolute URL of the poster. /// @@ -477,27 +510,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb /// The absolute URL. public string GetPosterUrl(string posterPath) { - if (string.IsNullOrEmpty(posterPath)) - { - return null; - } - - return _tmDbClient.GetImageUrl(_tmDbClient.Config.Images.PosterSizes[^1], posterPath).ToString(); - } - - /// - /// Gets the absolute URL of the backdrop image. - /// - /// The relative URL of the backdrop image. - /// The absolute URL. - public string GetBackdropUrl(string posterPath) - { - if (string.IsNullOrEmpty(posterPath)) - { - return null; - } - - return _tmDbClient.GetImageUrl(_tmDbClient.Config.Images.BackdropSizes[^1], posterPath).ToString(); + return GetUrl(Plugin.Instance.Configuration.PosterSize, posterPath); } /// @@ -507,32 +520,130 @@ namespace MediaBrowser.Providers.Plugins.Tmdb /// The absolute URL. public string GetProfileUrl(string actorProfilePath) { - if (string.IsNullOrEmpty(actorProfilePath)) - { - return null; - } - - return _tmDbClient.GetImageUrl(_tmDbClient.Config.Images.ProfileSizes[^1], actorProfilePath).ToString(); + return GetUrl(Plugin.Instance.Configuration.ProfileSize, actorProfilePath); } /// - /// Gets the absolute URL of the still image. + /// Converts poster s into s. /// - /// The relative URL of the still image. - /// The absolute URL. - public string GetStillUrl(string filePath) + /// The input images. + /// The requested language. + /// The collection to add the remote images into. + public void ConvertPostersToRemoteImageInfo(List images, string requestLanguage, List results) { - if (string.IsNullOrEmpty(filePath)) - { - return null; - } - - return _tmDbClient.GetImageUrl(_tmDbClient.Config.Images.StillSizes[^1], filePath).ToString(); + ConvertToRemoteImageInfo(images, Plugin.Instance.Configuration.PosterSize, ImageType.Primary, requestLanguage, results); } - private Task EnsureClientConfigAsync() + /// + /// Converts backdrop s into s. + /// + /// The input images. + /// The requested language. + /// The collection to add the remote images into. + public void ConvertBackdropsToRemoteImageInfo(List images, string requestLanguage, List results) { - return !_tmDbClient.HasConfig ? _tmDbClient.GetConfigAsync() : Task.CompletedTask; + ConvertToRemoteImageInfo(images, Plugin.Instance.Configuration.BackdropSize, ImageType.Backdrop, requestLanguage, results); + } + + /// + /// Converts profile s into s. + /// + /// The input images. + /// The requested language. + /// The collection to add the remote images into. + public void ConvertProfilesToRemoteImageInfo(List images, string requestLanguage, List results) + { + ConvertToRemoteImageInfo(images, Plugin.Instance.Configuration.ProfileSize, ImageType.Primary, requestLanguage, results); + } + + /// + /// Converts still s into s. + /// + /// The input images. + /// The requested language. + /// The collection to add the remote images into. + public void ConvertStillsToRemoteImageInfo(List images, string requestLanguage, List results) + { + ConvertToRemoteImageInfo(images, Plugin.Instance.Configuration.StillSize, ImageType.Primary, requestLanguage, results); + } + + /// + /// Converts s into s. + /// + /// The input images. + /// The size of the image to fetch. + /// The type of the image. + /// The requested language. + /// The collection to add the remote images into. + private void ConvertToRemoteImageInfo(List images, string size, ImageType type, string requestLanguage, List results) + { + // sizes provided are for original resolution, don't store them when downloading scaled images + var scaleImage = !string.Equals(size, "original", StringComparison.OrdinalIgnoreCase); + + for (var i = 0; i < images.Count; i++) + { + var image = images[i]; + + results.Add(new RemoteImageInfo + { + Url = GetUrl(size, image.FilePath), + CommunityRating = image.VoteAverage, + VoteCount = image.VoteCount, + Width = scaleImage ? null : image.Width, + Height = scaleImage ? null : image.Height, + Language = TmdbUtils.AdjustImageLanguage(image.Iso_639_1, requestLanguage), + ProviderName = TmdbUtils.ProviderName, + Type = type, + RatingType = RatingType.Score + }); + } + } + + private async Task EnsureClientConfigAsync() + { + if (!_tmDbClient.HasConfig) + { + var config = await _tmDbClient.GetConfigAsync().ConfigureAwait(false); + ValidatePreferences(config); + } + } + + private static void ValidatePreferences(TMDbConfig config) + { + var imageConfig = config.Images; + + var pluginConfig = Plugin.Instance.Configuration; + + if (!imageConfig.PosterSizes.Contains(pluginConfig.PosterSize)) + { + pluginConfig.PosterSize = imageConfig.PosterSizes[^1]; + } + + if (!imageConfig.BackdropSizes.Contains(pluginConfig.BackdropSize)) + { + pluginConfig.BackdropSize = imageConfig.BackdropSizes[^1]; + } + + if (!imageConfig.ProfileSizes.Contains(pluginConfig.ProfileSize)) + { + pluginConfig.ProfileSize = imageConfig.ProfileSizes[^1]; + } + + if (!imageConfig.StillSizes.Contains(pluginConfig.StillSize)) + { + pluginConfig.StillSize = imageConfig.StillSizes[^1]; + } + } + + /// + /// Gets the configuration. + /// + /// The configuration. + public async Task GetClientConfiguration() + { + await EnsureClientConfigAsync().ConfigureAwait(false); + + return _tmDbClient.Config; } /// @@ -542,7 +653,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb GC.SuppressFinalize(this); } -/// + /// /// Releases unmanaged and - optionally - managed resources. /// /// true to release both managed and unmanaged resources; false to release only unmanaged resources. diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TmdbUtils.cs b/MediaBrowser.Providers/Plugins/Tmdb/TmdbUtils.cs index b713736a0..234d717bf 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/TmdbUtils.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/TmdbUtils.cs @@ -1,5 +1,3 @@ -#nullable enable - using System; using System.Collections.Generic; using System.Text.RegularExpressions; @@ -13,7 +11,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb /// public static class TmdbUtils { - private static readonly Regex _nonWords = new (@"[\W_]+", RegexOptions.Compiled); + private static readonly Regex _nonWords = new(@"[\W_]+", RegexOptions.Compiled); /// /// URL of the TMDB instance to use. @@ -30,11 +28,6 @@ namespace MediaBrowser.Providers.Plugins.Tmdb /// public const string ApiKey = "4219e299c89411838049ab0dab19ebd5"; - /// - /// Maximum number of cast members to pull. - /// - public const int MaxCastMembers = 15; - /// /// The crew types to keep. /// diff --git a/MediaBrowser.Providers/Studios/StudioMetadataService.cs b/MediaBrowser.Providers/Studios/StudioMetadataService.cs index 091b33ce0..df938325f 100644 --- a/MediaBrowser.Providers/Studios/StudioMetadataService.cs +++ b/MediaBrowser.Providers/Studios/StudioMetadataService.cs @@ -4,7 +4,6 @@ using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Providers; -using MediaBrowser.Model.Entities; using MediaBrowser.Model.IO; using MediaBrowser.Providers.Manager; using Microsoft.Extensions.Logging; @@ -22,11 +21,5 @@ namespace MediaBrowser.Providers.Studios : base(serverConfigurationManager, logger, providerManager, fileSystem, libraryManager) { } - - /// - protected override void MergeData(MetadataResult source, MetadataResult target, MetadataField[] lockedFields, bool replaceData, bool mergeMetadataSettings) - { - ProviderUtils.MergeBaseItemData(source, target, lockedFields, replaceData, mergeMetadataSettings); - } } } diff --git a/MediaBrowser.Providers/Subtitles/SubtitleManager.cs b/MediaBrowser.Providers/Subtitles/SubtitleManager.cs index d6c346ba1..34019e582 100644 --- a/MediaBrowser.Providers/Subtitles/SubtitleManager.cs +++ b/MediaBrowser.Providers/Subtitles/SubtitleManager.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System; @@ -7,6 +9,7 @@ using System.IO; using System.Linq; using System.Threading; using System.Threading.Tasks; +using Jellyfin.Extensions; using MediaBrowser.Common.Extensions; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.Movies; @@ -21,7 +24,6 @@ using MediaBrowser.Model.Globalization; using MediaBrowser.Model.IO; using MediaBrowser.Model.Providers; using Microsoft.Extensions.Logging; -using static MediaBrowser.Model.IO.IODefaults; namespace MediaBrowser.Providers.Subtitles { @@ -75,8 +77,7 @@ namespace MediaBrowser.Providers.Subtitles var contentType = request.ContentType; var providers = _subtitleProviders - .Where(i => i.SupportedMediaTypes.Contains(contentType)) - .Where(i => !request.DisabledSubtitleFetchers.Contains(i.Name, StringComparer.OrdinalIgnoreCase)) + .Where(i => i.SupportedMediaTypes.Contains(contentType) && !request.DisabledSubtitleFetchers.Contains(i.Name, StringComparison.OrdinalIgnoreCase)) .OrderBy(i => { var index = request.SubtitleFetcherOrder.ToList().IndexOf(i.Name); @@ -187,8 +188,8 @@ namespace MediaBrowser.Providers.Subtitles { var saveInMediaFolder = libraryOptions.SaveSubtitlesWithMedia; - using var stream = response.Stream; - using var memoryStream = new MemoryStream(); + await using var stream = response.Stream; + await using var memoryStream = new MemoryStream(); await stream.CopyToAsync(memoryStream).ConfigureAwait(false); memoryStream.Position = 0; @@ -236,7 +237,7 @@ namespace MediaBrowser.Providers.Subtitles foreach (var savePath in savePaths) { - _logger.LogInformation("Saving subtitles to {0}", savePath); + _logger.LogInformation("Saving subtitles to {SavePath}", savePath); _monitor.ReportFileSystemChangeBeginning(savePath); @@ -244,8 +245,10 @@ namespace MediaBrowser.Providers.Subtitles { Directory.CreateDirectory(Path.GetDirectoryName(savePath)); - // use FileShare.None as this bypasses dotnet bug dotnet/runtime#42790 . - using var fs = new FileStream(savePath, FileMode.Create, FileAccess.Write, FileShare.None, FileStreamBufferSize, AsyncFile.UseAsyncIO); + var fileOptions = AsyncFile.WriteOptions; + fileOptions.Mode = FileMode.CreateNew; + fileOptions.PreallocationSize = stream.Length; + using var fs = new FileStream(savePath, fileOptions); await stream.CopyToAsync(fs).ConfigureAwait(false); return; @@ -254,13 +257,9 @@ namespace MediaBrowser.Providers.Subtitles { // Bug in analyzer -- https://github.com/dotnet/roslyn-analyzers/issues/5160 #pragma warning disable CA1508 - exs ??= new List() - { - ex - }; + (exs ??= new List()).Add(ex); #pragma warning restore CA1508 - - } + } finally { _monitor.ReportFileSystemChangeComplete(savePath, false); @@ -276,7 +275,7 @@ namespace MediaBrowser.Providers.Subtitles } /// - public Task SearchSubtitles(Video video, string language, bool? isPerfectMatch, CancellationToken cancellationToken) + public Task SearchSubtitles(Video video, string language, bool? isPerfectMatch, bool isAutomated, CancellationToken cancellationToken) { if (video.VideoType != VideoType.VideoFile) { @@ -310,7 +309,8 @@ namespace MediaBrowser.Providers.Subtitles ProductionYear = video.ProductionYear, ProviderIds = video.ProviderIds, RuntimeTicks = video.RunTimeTicks, - IsPerfectMatch = isPerfectMatch ?? false + IsPerfectMatch = isPerfectMatch ?? false, + IsAutomated = isAutomated }; if (video is Episode episode) diff --git a/MediaBrowser.Providers/TV/EpisodeMetadataService.cs b/MediaBrowser.Providers/TV/EpisodeMetadataService.cs index 08cb6ced9..d8855ec93 100644 --- a/MediaBrowser.Providers/TV/EpisodeMetadataService.cs +++ b/MediaBrowser.Providers/TV/EpisodeMetadataService.cs @@ -70,7 +70,7 @@ namespace MediaBrowser.Providers.TV /// protected override void MergeData(MetadataResult source, MetadataResult target, MetadataField[] lockedFields, bool replaceData, bool mergeMetadataSettings) { - ProviderUtils.MergeBaseItemData(source, target, lockedFields, replaceData, mergeMetadataSettings); + base.MergeData(source, target, lockedFields, replaceData, mergeMetadataSettings); var sourceItem = source.Item; var targetItem = target.Item; diff --git a/MediaBrowser.Providers/TV/SeasonMetadataService.cs b/MediaBrowser.Providers/TV/SeasonMetadataService.cs index 0f22f8a9b..54dcee41e 100644 --- a/MediaBrowser.Providers/TV/SeasonMetadataService.cs +++ b/MediaBrowser.Providers/TV/SeasonMetadataService.cs @@ -35,14 +35,14 @@ namespace MediaBrowser.Providers.TV { var updatedType = base.BeforeSaveInternal(item, isFullRefresh, updateType); - if (item.IndexNumber.HasValue && item.IndexNumber.Value == 0) + if (item.IndexNumber == 0 && !item.IsLocked && !item.LockedFields.Contains(MetadataField.Name)) { var seasonZeroDisplayName = LibraryManager.GetLibraryOptions(item).SeasonZeroDisplayName; if (!string.Equals(item.Name, seasonZeroDisplayName, StringComparison.OrdinalIgnoreCase)) { item.Name = seasonZeroDisplayName; - updatedType = updatedType | ItemUpdateType.MetadataEdit; + updatedType |= ItemUpdateType.MetadataEdit; } } @@ -87,12 +87,6 @@ namespace MediaBrowser.Providers.TV return updateType; } - /// - protected override void MergeData(MetadataResult source, MetadataResult target, MetadataField[] lockedFields, bool replaceData, bool mergeMetadataSettings) - { - ProviderUtils.MergeBaseItemData(source, target, lockedFields, replaceData, mergeMetadataSettings); - } - private ItemUpdateType SaveIsVirtualItem(Season item, IList episodes) { var isVirtualItem = item.LocationType == LocationType.Virtual && (episodes.Count == 0 || episodes.All(i => i.LocationType == LocationType.Virtual)); diff --git a/MediaBrowser.Providers/TV/SeriesMetadataService.cs b/MediaBrowser.Providers/TV/SeriesMetadataService.cs index dcb693408..f49492f33 100644 --- a/MediaBrowser.Providers/TV/SeriesMetadataService.cs +++ b/MediaBrowser.Providers/TV/SeriesMetadataService.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System.Collections.Generic; @@ -61,7 +63,7 @@ namespace MediaBrowser.Providers.TV /// protected override void MergeData(MetadataResult source, MetadataResult target, MetadataField[] lockedFields, bool replaceData, bool mergeMetadataSettings) { - ProviderUtils.MergeBaseItemData(source, target, lockedFields, replaceData, mergeMetadataSettings); + base.MergeData(source, target, lockedFields, replaceData, mergeMetadataSettings); var sourceItem = source.Item; var targetItem = target.Item; @@ -128,11 +130,12 @@ namespace MediaBrowser.Providers.TV /// The async task. private async Task FillInMissingSeasonsAsync(Series series, CancellationToken cancellationToken) { - var episodesInSeriesFolder = series.GetRecursiveChildren(i => i is Episode) - .Cast() + var seriesChildren = series.GetRecursiveChildren(i => i is Episode || i is Season); + var episodesInSeriesFolder = seriesChildren + .OfType() .Where(i => !i.IsInSeasonFolder); - List seasons = series.Children.OfType().ToList(); + List seasons = seriesChildren.OfType().ToList(); // Loop through the unique season numbers foreach (var episode in episodesInSeriesFolder) diff --git a/MediaBrowser.Providers/TV/Zap2ItExternalId.cs b/MediaBrowser.Providers/TV/Zap2ItExternalId.cs index 3cb18e424..087e4036a 100644 --- a/MediaBrowser.Providers/TV/Zap2ItExternalId.cs +++ b/MediaBrowser.Providers/TV/Zap2ItExternalId.cs @@ -19,7 +19,7 @@ namespace MediaBrowser.Providers.TV public ExternalIdMediaType? Type => null; /// - public string UrlFormatString => "http://tvlistings.zap2it.com/overview.html?programSeriesId={0}"; + public string? UrlFormatString => "http://tvlistings.zap2it.com/overview.html?programSeriesId={0}"; /// public bool Supports(IHasProviderIds item) => item is Series; diff --git a/MediaBrowser.Providers/Videos/VideoMetadataService.cs b/MediaBrowser.Providers/Videos/VideoMetadataService.cs index 31c7eaac4..caa6d6e1f 100644 --- a/MediaBrowser.Providers/Videos/VideoMetadataService.cs +++ b/MediaBrowser.Providers/Videos/VideoMetadataService.cs @@ -4,7 +4,6 @@ using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Providers; -using MediaBrowser.Model.Entities; using MediaBrowser.Model.IO; using MediaBrowser.Providers.Manager; using Microsoft.Extensions.Logging; @@ -26,11 +25,5 @@ namespace MediaBrowser.Providers.Videos /// // Make sure the type-specific services get picked first public override int Order => 10; - - /// - protected override void MergeData(MetadataResult 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/README.md b/README.md index 3aef84b99..7f6daca68 100644 --- a/README.md +++ b/README.md @@ -83,9 +83,9 @@ 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). +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 6 development will work, but two options are recent versions of [Visual Studio](https://visualstudio.microsoft.com/downloads/) (at least 2022) and [Visual Studio Code](https://code.visualstudio.com/Download). [ffmpeg](https://github.com/jellyfin/jellyfin-ffmpeg) will also need to be installed. @@ -138,10 +138,10 @@ A second option is to build the project and then run the resulting executable fi 1. Build the project - ```bash - dotnet build # Build the project - cd bin/Debug/net5.0 # Change into the build output directory - ``` +```bash +dotnet build # Build the project +cd Jellyfin.Server/bin/Debug/net6.0 # Change into the build output directory +``` 2. Execute the build output. On Linux, Mac, etc. use `./jellyfin` and on Windows use `jellyfin.exe`. diff --git a/RSSDP/DisposableManagedObjectBase.cs b/RSSDP/DisposableManagedObjectBase.cs index 7d6a471f9..5d7da4124 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 c56249523..6b6c13d99 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/HttpRequestParser.cs b/RSSDP/HttpRequestParser.cs index 4114195a6..a3e100796 100644 --- a/RSSDP/HttpRequestParser.cs +++ b/RSSDP/HttpRequestParser.cs @@ -1,6 +1,6 @@ using System; -using System.Linq; using System.Net.Http; +using Jellyfin.Extensions; namespace Rssdp.Infrastructure { @@ -86,7 +86,7 @@ namespace Rssdp.Infrastructure /// A string containing the name of the header to return the type of. protected override bool IsContentHeader(string headerName) { - return ContentHeaderNames.Contains(headerName, StringComparer.OrdinalIgnoreCase); + return ContentHeaderNames.Contains(headerName, StringComparison.OrdinalIgnoreCase); } } } diff --git a/RSSDP/HttpResponseParser.cs b/RSSDP/HttpResponseParser.cs index 0dd4bb45a..3e361465d 100644 --- a/RSSDP/HttpResponseParser.cs +++ b/RSSDP/HttpResponseParser.cs @@ -1,7 +1,7 @@ using System; -using System.Linq; using System.Net; using System.Net.Http; +using Jellyfin.Extensions; namespace Rssdp.Infrastructure { @@ -49,7 +49,7 @@ namespace Rssdp.Infrastructure /// A boolean, true if th specified header relates to HTTP content, otherwise false. protected override bool IsContentHeader(string headerName) { - return ContentHeaderNames.Contains(headerName, StringComparer.OrdinalIgnoreCase); + return ContentHeaderNames.Contains(headerName, StringComparison.OrdinalIgnoreCase); } /// diff --git a/RSSDP/RSSDP.csproj b/RSSDP/RSSDP.csproj index 54113d464..77130983b 100644 --- a/RSSDP/RSSDP.csproj +++ b/RSSDP/RSSDP.csproj @@ -11,7 +11,7 @@ - net5.0 + net6.0 false AllDisabledByDefault disable diff --git a/RSSDP/SsdpCommunicationsServer.cs b/RSSDP/SsdpCommunicationsServer.cs index e0116c068..58dabc628 100644 --- a/RSSDP/SsdpCommunicationsServer.cs +++ b/RSSDP/SsdpCommunicationsServer.cs @@ -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) diff --git a/RSSDP/SsdpDevice.cs b/RSSDP/SsdpDevice.cs index 4005d836d..c826830f1 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 188e298e2..3a52b2a3e 100644 --- a/RSSDP/SsdpDeviceLocator.cs +++ b/RSSDP/SsdpDeviceLocator.cs @@ -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() diff --git a/RSSDP/SsdpDevicePublisher.cs b/RSSDP/SsdpDevicePublisher.cs index c9e795d56..a7767b3c0 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/bump_version b/bump_version index 8012ec07f..41d27f5c8 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" @@ -45,7 +52,8 @@ echo $old_version # Set the build.yaml version to the specified new_version old_version_sed="$( sed 's/\./\\./g' <<<"${old_version}" )" # Escape the '.' chars -sed -i "s/${old_version_sed}/${new_version}/g" ${build_file} +new_version_sed="$( cut -f1 -d'-' <<<"${new_version}" )" +sed -i "s/${old_version_sed}/${new_version_sed}/g" ${build_file} # update nuget package version for subproject in ${jellyfin_subprojects[@]}; do @@ -57,26 +65,29 @@ for subproject in ${jellyfin_subprojects[@]}; do | sed -E 's/([0-9\.]+[-a-z0-9]*)<\/VersionPrefix>/\1/' )" echo old nuget version: $old_version + new_version_sed="$( cut -f1 -d'-' <<<"${new_version}" )" # Set the nuget version to the specified new_version - sed -i "s|${old_version}|${new_version}|g" ${subproject} + sed -i "s|${old_version}|${new_version_sed}|g" ${subproject} done if [[ ${new_version} == *"-"* ]]; then - new_version_deb="$( sed 's/-/~/g' <<<"${new_version}" )" + new_version_pkg="$( sed 's/-/~/g' <<<"${new_version}" )" + new_version_deb_sup="" else - new_version_deb="${new_version}-1" + new_version_pkg="${new_version}" + new_version_deb_sup="-1" fi # Update the metapackage equivs file debian_equivs_file="debian/metapackage/jellyfin" -sed -i "s/${old_version_sed}/${new_version}/g" ${debian_equivs_file} +sed -i "s/${old_version_sed}/${new_version_pkg}/g" ${debian_equivs_file} # Write out a temporary Debian changelog with our new stuff appended and some templated formatting debian_changelog_file="debian/changelog" debian_changelog_temp="$( mktemp )" # Create new temp file with our changelog -echo -e "jellyfin-server (${new_version_deb}) unstable; urgency=medium +echo -e "jellyfin-server (${new_version_pkg}${new_version_deb_sup}) unstable; urgency=medium * New upstream version ${new_version}; release changelog at https://github.com/jellyfin/jellyfin/releases/tag/v${new_version} @@ -97,7 +108,7 @@ pushd ${fedora_spec_temp_dir} # Split out the stuff before and after changelog csplit jellyfin.spec "/^%changelog/" # produces xx00 xx01 # Update the version in xx00 -sed -i "s/${old_version_sed}/${new_version_sed}/g" xx00 +sed -i "s/${old_version_sed}/${new_version_pkg}/g" xx00 # Remove the header from xx01 sed -i '/^%changelog/d' xx01 # Create new temp file with our changelog @@ -114,5 +125,5 @@ mv ${fedora_spec_temp} ${fedora_spec_file} rm -rf ${fedora_spec_temp_dir} # Stage the changed files for commit -git add ${shared_version_file} ${build_file} ${debian_equivs_file} ${debian_changelog_file} ${fedora_spec_file} -git status +git add . +git status -v diff --git a/debian/control b/debian/control index 51b20c670..da9aa94d4 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, diff --git a/debian/jellyfin.service b/debian/jellyfin.service index b79cd47c7..b86f40473 100644 --- a/debian/jellyfin.service +++ b/debian/jellyfin.service @@ -10,5 +10,39 @@ ExecStart = /usr/bin/jellyfin ${JELLYFIN_WEB_OPT} ${JELLYFIN_RESTART_OPT} ${JELL Restart = on-failure TimeoutSec = 15 +NoNewPrivileges=true +SystemCallArchitectures=native +RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 AF_NETLINK +RestrictNamespaces=true +RestrictRealtime=true +RestrictSUIDSGID=true +ProtectControlGroups=true +ProtectHostname=true +ProtectKernelLogs=true +ProtectKernelModules=true +ProtectKernelTunables=true +LockPersonality=true +PrivateTmp=true +PrivateDevices=false +PrivateUsers=true +RemoveIPC=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/deployment/Dockerfile.centos.amd64 b/deployment/Dockerfile.centos.amd64 index 326e995be..708c706b5 100644 --- a/deployment/Dockerfile.centos.amd64 +++ b/deployment/Dockerfile.centos.amd64 @@ -2,7 +2,6 @@ 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 @@ -11,12 +10,13 @@ ENV IS_DOCKER=YES # Prepare CentOS environment 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 + && 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 -yq dotnet-sdk-${SDK_VERSION} +RUN wget -q https://download.visualstudio.microsoft.com/download/pr/ede8a287-3d61-4988-a356-32ff9129079e/bdb47b6b510ed0c4f0b132f7f4ad9d5a/dotnet-sdk-6.0.101-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 \ diff --git a/deployment/Dockerfile.debian.amd64 b/deployment/Dockerfile.debian.amd64 index 23b662526..daba0eb7d 100644 --- a/deployment/Dockerfile.debian.amd64 +++ b/deployment/Dockerfile.debian.amd64 @@ -1,4 +1,4 @@ -FROM mcr.microsoft.com/dotnet/sdk:5.0-buster-slim +FROM mcr.microsoft.com/dotnet/sdk:6.0-bullseye-slim # Docker build arguments ARG SOURCE_DIR=/jellyfin ARG ARTIFACT_DIR=/dist diff --git a/deployment/Dockerfile.debian.arm64 b/deployment/Dockerfile.debian.arm64 index a33099031..db4e7f817 100644 --- a/deployment/Dockerfile.debian.arm64 +++ b/deployment/Dockerfile.debian.arm64 @@ -1,4 +1,4 @@ -FROM mcr.microsoft.com/dotnet/sdk:5.0-buster-slim +FROM mcr.microsoft.com/dotnet/sdk:6.0-bullseye-slim # Docker build arguments ARG SOURCE_DIR=/jellyfin ARG ARTIFACT_DIR=/dist @@ -18,16 +18,16 @@ RUN apt-get update -yqq \ RUN dpkg --add-architecture arm64 \ && apt-get update -yqq \ && apt-get install -yqq --no-install-recommends cross-gcc-dev \ - && TARGET_LIST="arm64" cross-gcc-gensource 8 \ - && cd cross-gcc-packages-amd64/cross-gcc-8-arm64 \ + && TARGET_LIST="arm64" cross-gcc-gensource 9 \ + && cd cross-gcc-packages-amd64/cross-gcc-9-arm64 \ && apt-get install -yqq --no-install-recommends \ - gcc-8-source libstdc++-8-dev-arm64-cross \ + 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++-8-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 bc5e3543f..9b008e7fb 100644 --- a/deployment/Dockerfile.debian.armhf +++ b/deployment/Dockerfile.debian.armhf @@ -1,4 +1,4 @@ -FROM mcr.microsoft.com/dotnet/sdk:5.0-buster-slim +FROM mcr.microsoft.com/dotnet/sdk:6.0-bullseye-slim # Docker build arguments ARG SOURCE_DIR=/jellyfin ARG ARTIFACT_DIR=/dist @@ -18,17 +18,17 @@ RUN apt-get update -yqq \ RUN dpkg --add-architecture armhf \ && apt-get update -yqq \ && apt-get install -yqq --no-install-recommends cross-gcc-dev \ - && TARGET_LIST="armhf" cross-gcc-gensource 8 \ - && cd cross-gcc-packages-amd64/cross-gcc-8-armhf \ + && TARGET_LIST="armhf" cross-gcc-gensource 9 \ + && cd cross-gcc-packages-amd64/cross-gcc-9-armhf \ && apt-get install -yqq --no-install-recommends\ - gcc-8-source libstdc++-8-dev-armhf-cross \ + 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++-8-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 0b1a57014..b2bd40713 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 583f53ca0..fc60f1624 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 177c11713..f5cc47d83 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 590cde167..30615cd42 100644 --- a/deployment/Dockerfile.fedora.amd64 +++ b/deployment/Dockerfile.fedora.amd64 @@ -2,7 +2,6 @@ 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 @@ -10,10 +9,14 @@ ENV IS_DOCKER=YES # Prepare Fedora environment 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 + && 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 -yq dotnet-sdk-${SDK_VERSION} dotnet-runtime-${SDK_VERSION} +RUN wget -q https://download.visualstudio.microsoft.com/download/pr/ede8a287-3d61-4988-a356-32ff9129079e/bdb47b6b510ed0c4f0b132f7f4ad9d5a/dotnet-sdk-6.0.101-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 \ diff --git a/deployment/Dockerfile.linux.amd64 b/deployment/Dockerfile.linux.amd64 index 3c7e2b87f..2c7e41cac 100644 --- a/deployment/Dockerfile.linux.amd64 +++ b/deployment/Dockerfile.linux.amd64 @@ -1,4 +1,4 @@ -FROM mcr.microsoft.com/dotnet/sdk:5.0-buster-slim +FROM mcr.microsoft.com/dotnet/sdk:6.0-bullseye-slim # Docker build arguments ARG SOURCE_DIR=/jellyfin ARG ARTIFACT_DIR=/dist diff --git a/deployment/Dockerfile.linux.amd64-musl b/deployment/Dockerfile.linux.amd64-musl index 3cda9ad23..e903cf1d3 100644 --- a/deployment/Dockerfile.linux.amd64-musl +++ b/deployment/Dockerfile.linux.amd64-musl @@ -1,4 +1,4 @@ -FROM mcr.microsoft.com/dotnet/sdk:5.0-buster-slim +FROM mcr.microsoft.com/dotnet/sdk:6.0-bullseye-slim # Docker build arguments ARG SOURCE_DIR=/jellyfin ARG ARTIFACT_DIR=/dist diff --git a/deployment/Dockerfile.linux.arm64 b/deployment/Dockerfile.linux.arm64 index ddf97cbd1..0dd3c5e4e 100644 --- a/deployment/Dockerfile.linux.arm64 +++ b/deployment/Dockerfile.linux.arm64 @@ -1,4 +1,4 @@ -FROM mcr.microsoft.com/dotnet/sdk:5.0-buster-slim +FROM mcr.microsoft.com/dotnet/sdk:6.0-bullseye-slim # Docker build arguments ARG SOURCE_DIR=/jellyfin ARG ARTIFACT_DIR=/dist diff --git a/deployment/Dockerfile.linux.armhf b/deployment/Dockerfile.linux.armhf index 49e1c7bbf..16a8218e1 100644 --- a/deployment/Dockerfile.linux.armhf +++ b/deployment/Dockerfile.linux.armhf @@ -1,4 +1,4 @@ -FROM mcr.microsoft.com/dotnet/sdk:5.0-buster-slim +FROM mcr.microsoft.com/dotnet/sdk:6.0-bullseye-slim # Docker build arguments ARG SOURCE_DIR=/jellyfin ARG ARTIFACT_DIR=/dist diff --git a/deployment/Dockerfile.macos b/deployment/Dockerfile.macos index fad44ef83..699ab2d40 100644 --- a/deployment/Dockerfile.macos +++ b/deployment/Dockerfile.macos @@ -1,4 +1,4 @@ -FROM mcr.microsoft.com/dotnet/sdk:5.0-buster-slim +FROM mcr.microsoft.com/dotnet/sdk:6.0-bullseye-slim # Docker build arguments ARG SOURCE_DIR=/jellyfin ARG ARTIFACT_DIR=/dist diff --git a/deployment/Dockerfile.portable b/deployment/Dockerfile.portable index 90cc0717b..b567d7bce 100644 --- a/deployment/Dockerfile.portable +++ b/deployment/Dockerfile.portable @@ -1,4 +1,4 @@ -FROM mcr.microsoft.com/dotnet/sdk:5.0-buster-slim +FROM mcr.microsoft.com/dotnet/sdk:6.0-bullseye-slim # Docker build arguments ARG SOURCE_DIR=/jellyfin ARG ARTIFACT_DIR=/dist diff --git a/deployment/Dockerfile.ubuntu.amd64 b/deployment/Dockerfile.ubuntu.amd64 index d88efcdc9..ccfaaa5f0 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 @@ -18,8 +17,7 @@ RUN apt-get update -yqq \ libfreetype6-dev libssl-dev libssl1.1 liblttng-ust0 # Install dotnet repository -# https://dotnet.microsoft.com/download/linux-package-manager/debian9/sdk-current -RUN wget -q https://download.visualstudio.microsoft.com/download/pr/13b9d84c-a35b-4ffe-8f62-447a01403d64/1f9ae31daa0f7d98513e7551246899f2/dotnet-sdk-5.0.400-linux-x64.tar.gz -O dotnet-sdk.tar.gz \ +RUN wget -q https://download.visualstudio.microsoft.com/download/pr/ede8a287-3d61-4988-a356-32ff9129079e/bdb47b6b510ed0c4f0b132f7f4ad9d5a/dotnet-sdk-6.0.101-linux-x64.tar.gz -O dotnet-sdk.tar.gz \ && mkdir -p dotnet-sdk \ && tar -xzf dotnet-sdk.tar.gz -C dotnet-sdk \ && ln -s $( pwd )/dotnet-sdk/dotnet /usr/bin/dotnet diff --git a/deployment/Dockerfile.ubuntu.arm64 b/deployment/Dockerfile.ubuntu.arm64 index 4f41bba2d..988c8f16d 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 @@ -17,8 +16,7 @@ RUN apt-get update -yqq \ mmv build-essential lsb-release # Install dotnet repository -# https://dotnet.microsoft.com/download/linux-package-manager/debian9/sdk-current -RUN wget -q https://download.visualstudio.microsoft.com/download/pr/13b9d84c-a35b-4ffe-8f62-447a01403d64/1f9ae31daa0f7d98513e7551246899f2/dotnet-sdk-5.0.400-linux-x64.tar.gz -O dotnet-sdk.tar.gz \ +RUN wget -q https://download.visualstudio.microsoft.com/download/pr/ede8a287-3d61-4988-a356-32ff9129079e/bdb47b6b510ed0c4f0b132f7f4ad9d5a/dotnet-sdk-6.0.101-linux-x64.tar.gz -O dotnet-sdk.tar.gz \ && mkdir -p dotnet-sdk \ && tar -xzf dotnet-sdk.tar.gz -C dotnet-sdk \ && ln -s $( pwd )/dotnet-sdk/dotnet /usr/bin/dotnet diff --git a/deployment/Dockerfile.ubuntu.armhf b/deployment/Dockerfile.ubuntu.armhf index 01752d536..61a008d6a 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 @@ -17,8 +16,7 @@ RUN apt-get update -yqq \ mmv build-essential lsb-release # Install dotnet repository -# https://dotnet.microsoft.com/download/linux-package-manager/debian9/sdk-current -RUN wget -q https://download.visualstudio.microsoft.com/download/pr/13b9d84c-a35b-4ffe-8f62-447a01403d64/1f9ae31daa0f7d98513e7551246899f2/dotnet-sdk-5.0.400-linux-x64.tar.gz -O dotnet-sdk.tar.gz \ +RUN wget -q https://download.visualstudio.microsoft.com/download/pr/ede8a287-3d61-4988-a356-32ff9129079e/bdb47b6b510ed0c4f0b132f7f4ad9d5a/dotnet-sdk-6.0.101-linux-x64.tar.gz -O dotnet-sdk.tar.gz \ && mkdir -p dotnet-sdk \ && tar -xzf dotnet-sdk.tar.gz -C dotnet-sdk \ && ln -s $( pwd )/dotnet-sdk/dotnet /usr/bin/dotnet diff --git a/deployment/Dockerfile.windows.amd64 b/deployment/Dockerfile.windows.amd64 index acd0e1854..b9543a7c9 100644 --- a/deployment/Dockerfile.windows.amd64 +++ b/deployment/Dockerfile.windows.amd64 @@ -1,4 +1,4 @@ -FROM mcr.microsoft.com/dotnet/sdk:5.0-buster-slim +FROM mcr.microsoft.com/dotnet/sdk:6.0-bullseye-slim # Docker build arguments ARG SOURCE_DIR=/jellyfin ARG ARTIFACT_DIR=/dist diff --git a/deployment/build.centos.amd64 b/deployment/build.centos.amd64 index 69f0cadcf..bfdc6e591 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 145e28d87..b2bbf9c29 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 5699133a0..02f84471e 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 20af2ddfb..92779cb59 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 2c7bff506..23c5ed86a 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.ubuntu.amd64 b/deployment/build.ubuntu.amd64 index 0c29286c0..c36978c9e 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 65d67f80f..76d51e321 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 370370abc..0ff5ab066 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 2c268e8b3..8e401e009 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/Makefile b/fedora/Makefile index 97904ddd3..261fd262d 100644 --- a/fedora/Makefile +++ b/fedora/Makefile @@ -1,26 +1,53 @@ -VERSION := $(shell sed -ne '/^Version:/s/.* *//p' fedora/jellyfin.spec) +DIR := $(dir $(lastword $(MAKEFILE_LIST))) +INSTGIT := $(shell if [ "$$(id -u)" = "0" ]; then dnf -y install git; fi) +NAME := jellyfin-server +VERSION := $(shell sed -ne '/^Version:/s/.* *//p' $(DIR)/jellyfin.spec) +RELEASE := $(shell sed -ne '/^Release:/s/.* *\(.*\)%{.*}.*/\1/p' $(DIR)/jellyfin.spec) +SRPM := jellyfin-$(subst -,~,$(VERSION))-$(RELEASE)$(shell rpm --eval %dist).src.rpm +TARBALL :=$(NAME)-$(subst -,~,$(VERSION)).tar.gz -srpm: - cd fedora/; \ - SOURCE_DIR=.. \ - WORKDIR="$${PWD}"; \ - tar \ - --transform "s,^\.,jellyfin-server-$(VERSION)," \ - --exclude='.git*' \ - --exclude='**/.git' \ - --exclude='**/.hg' \ - --exclude='**/.vs' \ - --exclude='**/.vscode' \ - --exclude='deployment' \ - --exclude='**/bin' \ - --exclude='**/obj' \ - --exclude='**/.nuget' \ - --exclude='*.deb' \ - --exclude='*.rpm' \ - --exclude='jellyfin-server-$(VERSION).tar.gz' \ - -czf "jellyfin-server-$(VERSION).tar.gz" \ +epel-7-x86_64_repos := https://packages.microsoft.com/rhel/7/prod/ +epel-8-x86_64_repos := https://download.copr.fedorainfracloud.org/results/@dotnet-sig/dotnet-preview/$(TARGET)/ +fedora_repos := https://download.copr.fedorainfracloud.org/results/@dotnet-sig/dotnet-preview/$(TARGET)/ +fedora-34-x86_64_repos := $(fedora_repos) +fedora-35-x86_64_repos := $(fedora_repos) +fedora-34-x86_64_repos := $(fedora_repos) + +outdir ?= $(PWD)/$(DIR)/ +TARGET ?= fedora-35-x86_64 + +srpm: $(DIR)/$(SRPM) +tarball: $(DIR)/$(TARBALL) + +$(DIR)/$(TARBALL): + cd $(DIR)/; \ + SOURCE_DIR=.. \ + WORKDIR="$${PWD}"; \ + version=$(VERSION); \ + tar \ + --transform "s,^\.,$(NAME)-$(subst -,~,$(VERSION))," \ + --exclude='.git*' \ + --exclude='**/.git' \ + --exclude='**/.hg' \ + --exclude='**/.vs' \ + --exclude='**/.vscode' \ + --exclude=deployment \ + --exclude='**/bin' \ + --exclude='**/obj' \ + --exclude='**/.nuget' \ + --exclude='*.deb' \ + --exclude='*.rpm' \ + --exclude=$(notdir $@) \ + -czf $(notdir $@) \ -C $${SOURCE_DIR} ./ - cd fedora/; \ - rpmbuild -bs jellyfin.spec \ - --define "_sourcedir $$PWD/" \ + +$(DIR)/$(SRPM): $(DIR)/$(TARBALL) $(DIR)/jellyfin.spec + cd $(DIR)/; \ + rpmbuild -bs jellyfin.spec \ + --define "_sourcedir $$PWD/" \ --define "_srcrpmdir $(outdir)" + +rpms: $(DIR)/$(SRPM) + mock $(addprefix --addrepo=, $($(TARGET)_repos)) \ + --enable-network \ + -r $(TARGET) $< diff --git a/fedora/jellyfin-server-lowports.conf b/fedora/jellyfin-server-lowports.conf new file mode 100644 index 000000000..eeb48a4e4 --- /dev/null +++ b/fedora/jellyfin-server-lowports.conf @@ -0,0 +1,4 @@ +# This allows Jellyfin to bind to low ports such as 80 and/or 443 + +[Service] +AmbientCapabilities=CAP_NET_BIND_SERVICE \ No newline at end of file diff --git a/fedora/jellyfin.spec b/fedora/jellyfin.spec index 0d606f9f7..e93944a20 100644 --- a/fedora/jellyfin.spec +++ b/fedora/jellyfin.spec @@ -12,7 +12,7 @@ Release: 1%{?dist} Summary: The Free Software Media System License: GPLv3 URL: https://jellyfin.org -# Jellyfin Server tarball created by `make -f .copr/Makefile srpm`, real URL ends with `v%{version}.tar.gz` +# Jellyfin Server tarball created by `make -f .copr/Makefile srpm`, real URL ends with `v%%{version}.tar.gz` Source0: jellyfin-server-%{version}.tar.gz Source11: jellyfin.service Source12: jellyfin.env @@ -20,6 +20,7 @@ Source13: jellyfin.sudoers Source14: restart.sh Source15: jellyfin.override.conf Source16: jellyfin-firewalld.xml +Source17: jellyfin-server-lowports.conf %{?systemd_requires} BuildRequires: systemd @@ -27,7 +28,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 @@ -45,6 +46,16 @@ Requires: libcurl, fontconfig, freetype, openssl, glibc, libicu, at, sudo %description server The Jellyfin media server backend. +%package server-lowports +# RPMfusion free +Summary: The Free Software Media System Server backend. Low-port binding. +Requires: jellyfin-server + +%description server-lowports +The Jellyfin media server backend low port binding package. This package +enables binding to ports < 1024. You would install this if you want +the Jellyfin server to bind to ports 80 and/or 443 for example. + %prep %autosetup -n jellyfin-server-%{version} -b 0 @@ -53,10 +64,12 @@ The Jellyfin media server backend. %install export DOTNET_CLI_TELEMETRY_OPTOUT=1 export DOTNET_SKIP_FIRST_TIME_EXPERIENCE=1 +export PATH=$PATH:/usr/local/bin dotnet publish --configuration Release --output='%{buildroot}%{_libdir}/jellyfin' --self-contained --runtime %{dotnet_runtime} \ "-p:DebugSymbols=false;DebugType=none" Jellyfin.Server %{__install} -D -m 0644 LICENSE %{buildroot}%{_datadir}/licenses/jellyfin/LICENSE %{__install} -D -m 0644 %{SOURCE15} %{buildroot}%{_sysconfdir}/systemd/system/jellyfin.service.d/override.conf +%{__install} -D -m 0644 %{SOURCE17} %{buildroot}%{_unitdir}/jellyfin.service.d/jellyfin-server-lowports.conf %{__install} -D -m 0644 Jellyfin.Server/Resources/Configuration/logging.json %{buildroot}%{_sysconfdir}/jellyfin/logging.json %{__mkdir} -p %{buildroot}%{_bindir} tee %{buildroot}%{_bindir}/jellyfin << EOF @@ -95,6 +108,9 @@ EOF %attr(750,jellyfin,jellyfin) %dir %{_var}/cache/jellyfin %{_datadir}/licenses/jellyfin/LICENSE +%files server-lowports +%{_unitdir}/jellyfin.service.d/jellyfin-server-lowports.conf + %pre server getent group jellyfin >/dev/null || groupadd -r jellyfin getent passwd jellyfin >/dev/null || \ @@ -137,6 +153,9 @@ fi %systemd_postun_with_restart jellyfin.service %changelog +* Mon Nov 29 2021 Brian J. Murrell +- Add jellyfin-server-lowports.service drop-in in a server-lowports + subpackage to allow binding to low ports * Fri Dec 04 2020 Jellyfin Packaging Team - Forthcoming stable release * Mon Jul 27 2020 Jellyfin Packaging Team diff --git a/jellyfin.ruleset b/jellyfin.ruleset index 68fb9064e..dea1a748b 100644 --- a/jellyfin.ruleset +++ b/jellyfin.ruleset @@ -1,8 +1,24 @@ - - + + + + + + + + + + + + + + + + + + @@ -41,13 +57,31 @@ + + + + + + + + + + + + + + + + + + @@ -57,12 +91,19 @@ + + + + + + + + + @@ -86,6 +129,8 @@ + + diff --git a/src/Jellyfin.Extensions/Jellyfin.Extensions.csproj b/src/Jellyfin.Extensions/Jellyfin.Extensions.csproj index 981b796e0..90d2a0da6 100644 --- a/src/Jellyfin.Extensions/Jellyfin.Extensions.csproj +++ b/src/Jellyfin.Extensions/Jellyfin.Extensions.csproj @@ -1,17 +1,28 @@ - net5.0 + 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 + + @@ -19,7 +30,7 @@ - + diff --git a/src/Jellyfin.Extensions/Json/Converters/JsonDelimitedArrayConverter.cs b/src/Jellyfin.Extensions/Json/Converters/JsonDelimitedArrayConverter.cs index c39805aa3..321cfa502 100644 --- a/src/Jellyfin.Extensions/Json/Converters/JsonDelimitedArrayConverter.cs +++ b/src/Jellyfin.Extensions/Json/Converters/JsonDelimitedArrayConverter.cs @@ -9,7 +9,7 @@ namespace Jellyfin.Extensions.Json.Converters /// Convert delimited string to array of type. ///
/// Type to convert to. - public abstract class JsonDelimitedArrayConverter : JsonConverter + public abstract class JsonDelimitedArrayConverter : JsonConverter { private readonly TypeConverter _typeConverter; @@ -31,9 +31,9 @@ namespace Jellyfin.Extensions.Json.Converters { if (reader.TokenType == JsonTokenType.String) { - // GetString can't return null here because we already handled it above - var stringEntries = reader.GetString()?.Split(Delimiter, StringSplitOptions.RemoveEmptyEntries); - if (stringEntries == null || stringEntries.Length == 0) + // null got handled higher up the call stack + var stringEntries = reader.GetString()!.Split(Delimiter, StringSplitOptions.RemoveEmptyEntries); + if (stringEntries.Length == 0) { return Array.Empty(); } @@ -44,7 +44,7 @@ namespace Jellyfin.Extensions.Json.Converters { try { - parsedValues[i] = _typeConverter.ConvertFrom(stringEntries[i].Trim()); + parsedValues[i] = _typeConverter.ConvertFromInvariantString(stringEntries[i].Trim()) ?? throw new FormatException(); convertedCount++; } catch (FormatException) diff --git a/src/Jellyfin.Extensions/Json/Converters/JsonGuidConverter.cs b/src/Jellyfin.Extensions/Json/Converters/JsonGuidConverter.cs index be94dd519..ea6d141cb 100644 --- a/src/Jellyfin.Extensions/Json/Converters/JsonGuidConverter.cs +++ b/src/Jellyfin.Extensions/Json/Converters/JsonGuidConverter.cs @@ -12,15 +12,19 @@ namespace Jellyfin.Extensions.Json.Converters { /// public override Guid Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) - { - var guidStr = reader.GetString(); - return guidStr == null ? Guid.Empty : new Guid(guidStr); - } + => reader.TokenType == JsonTokenType.Null + ? Guid.Empty + : ReadInternal(ref reader); + + // TODO: optimize by parsing the UTF8 bytes instead of converting to string first + internal static Guid ReadInternal(ref Utf8JsonReader reader) + => Guid.Parse(reader.GetString()!); // null got handled higher up the call stack /// public override void Write(Utf8JsonWriter writer, Guid value, JsonSerializerOptions options) - { - writer.WriteStringValue(value.ToString("N", CultureInfo.InvariantCulture)); - } + => WriteInternal(writer, value); + + internal static void WriteInternal(Utf8JsonWriter writer, Guid value) + => writer.WriteStringValue(value.ToString("N", CultureInfo.InvariantCulture)); } } diff --git a/src/Jellyfin.Extensions/Json/Converters/JsonNullableGuidConverter.cs b/src/Jellyfin.Extensions/Json/Converters/JsonNullableGuidConverter.cs index 6192d1598..b477bcb66 100644 --- a/src/Jellyfin.Extensions/Json/Converters/JsonNullableGuidConverter.cs +++ b/src/Jellyfin.Extensions/Json/Converters/JsonNullableGuidConverter.cs @@ -1,5 +1,4 @@ using System; -using System.Globalization; using System.Text.Json; using System.Text.Json.Serialization; @@ -12,21 +11,19 @@ namespace Jellyfin.Extensions.Json.Converters { /// public override Guid? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) - { - var guidStr = reader.GetString(); - return guidStr == null ? null : new Guid(guidStr); - } + => JsonGuidConverter.ReadInternal(ref reader); /// public override void Write(Utf8JsonWriter writer, Guid? value, JsonSerializerOptions options) { - if (value == null || value == Guid.Empty) + if (value == Guid.Empty) { writer.WriteNullValue(); } else { - writer.WriteStringValue(value.Value.ToString("N", CultureInfo.InvariantCulture)); + // null got handled higher up the call stack + JsonGuidConverter.WriteInternal(writer, value!.Value); } } } diff --git a/src/Jellyfin.Extensions/Json/Converters/JsonNullableStructConverter.cs b/src/Jellyfin.Extensions/Json/Converters/JsonNullableStructConverter.cs index 6de238b39..28437023f 100644 --- a/src/Jellyfin.Extensions/Json/Converters/JsonNullableStructConverter.cs +++ b/src/Jellyfin.Extensions/Json/Converters/JsonNullableStructConverter.cs @@ -15,13 +15,10 @@ namespace Jellyfin.Extensions.Json.Converters /// public override TStruct? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { - if (reader.TokenType == JsonTokenType.Null) - { - return null; - } - // Token is empty string. - if (reader.TokenType == JsonTokenType.String && ((reader.HasValueSequence && reader.ValueSequence.IsEmpty) || reader.ValueSpan.IsEmpty)) + if (reader.TokenType == JsonTokenType.String + && ((reader.HasValueSequence && reader.ValueSequence.IsEmpty) + || (!reader.HasValueSequence && reader.ValueSpan.IsEmpty))) { return null; } @@ -31,15 +28,6 @@ namespace Jellyfin.Extensions.Json.Converters /// public override void Write(Utf8JsonWriter writer, TStruct? value, JsonSerializerOptions options) - { - if (value.HasValue) - { - JsonSerializer.Serialize(writer, value.Value, options); - } - else - { - writer.WriteNullValue(); - } - } + => JsonSerializer.Serialize(writer, value!.Value, options); // null got handled higher up the call stack } } diff --git a/src/Jellyfin.Extensions/Json/Converters/JsonStringConverter.cs b/src/Jellyfin.Extensions/Json/Converters/JsonStringConverter.cs index 1a7a8c4f5..36b36c9b4 100644 --- a/src/Jellyfin.Extensions/Json/Converters/JsonStringConverter.cs +++ b/src/Jellyfin.Extensions/Json/Converters/JsonStringConverter.cs @@ -13,20 +13,11 @@ namespace Jellyfin.Extensions.Json.Converters { /// public override string? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) - { - return reader.TokenType switch - { - JsonTokenType.Null => null, - JsonTokenType.String => reader.GetString(), - _ => GetRawValue(reader) - }; - } + => reader.TokenType == JsonTokenType.String ? reader.GetString() : GetRawValue(reader); /// public override void Write(Utf8JsonWriter writer, string? value, JsonSerializerOptions options) - { - writer.WriteStringValue(value); - } + => writer.WriteStringValue(value); private static string GetRawValue(Utf8JsonReader reader) { diff --git a/src/Jellyfin.Extensions/Json/JsonDefaults.cs b/src/Jellyfin.Extensions/Json/JsonDefaults.cs index f4ec91123..2cd89dc3b 100644 --- a/src/Jellyfin.Extensions/Json/JsonDefaults.cs +++ b/src/Jellyfin.Extensions/Json/JsonDefaults.cs @@ -25,7 +25,7 @@ namespace Jellyfin.Extensions.Json /// -> AddJellyfinApi /// -> AddJsonOptions. ///
- private static readonly JsonSerializerOptions _jsonSerializerOptions = new () + private static readonly JsonSerializerOptions _jsonSerializerOptions = new() { ReadCommentHandling = JsonCommentHandling.Disallow, WriteIndented = false, @@ -44,12 +44,12 @@ namespace Jellyfin.Extensions.Json } }; - private static readonly JsonSerializerOptions _pascalCaseJsonSerializerOptions = new (_jsonSerializerOptions) + private static readonly JsonSerializerOptions _pascalCaseJsonSerializerOptions = new(_jsonSerializerOptions) { PropertyNamingPolicy = null }; - private static readonly JsonSerializerOptions _camelCaseJsonSerializerOptions = new (_jsonSerializerOptions) + private static readonly JsonSerializerOptions _camelCaseJsonSerializerOptions = new(_jsonSerializerOptions) { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; diff --git a/src/Jellyfin.Extensions/ReadOnlyListExtension.cs b/src/Jellyfin.Extensions/ReadOnlyListExtension.cs new file mode 100644 index 000000000..7785cfb49 --- /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/src/Jellyfin.Extensions/ShuffleExtensions.cs b/src/Jellyfin.Extensions/ShuffleExtensions.cs index 4e481983f..33c492053 100644 --- a/src/Jellyfin.Extensions/ShuffleExtensions.cs +++ b/src/Jellyfin.Extensions/ShuffleExtensions.cs @@ -8,8 +8,6 @@ namespace Jellyfin.Extensions ///
public static class ShuffleExtensions { - private static readonly Random _rng = new Random(); - /// /// Shuffles the items in a list. /// @@ -17,7 +15,7 @@ namespace Jellyfin.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 index 5fa5c0123..1d1c377f5 100644 --- a/src/Jellyfin.Extensions/SplitStringExtensions.cs +++ b/src/Jellyfin.Extensions/SplitStringExtensions.cs @@ -43,7 +43,7 @@ namespace Jellyfin.Extensions /// The separator to split on. /// The enumerator struct. [Pure] - public static Enumerator SpanSplit(this string str, char separator) => new (str.AsSpan(), separator); + public static Enumerator SpanSplit(this string str, char separator) => new(str.AsSpan(), separator); /// /// Creates a new span split enumerator. @@ -52,7 +52,7 @@ namespace Jellyfin.Extensions /// The separator to split on. /// The enumerator struct. [Pure] - public static Enumerator Split(this ReadOnlySpan str, char separator) => new (str, separator); + public static Enumerator Split(this ReadOnlySpan str, char separator) => new(str, separator); /// /// Provides an enumerator for the substrings seperated by the separator. diff --git a/src/Jellyfin.Extensions/StringExtensions.cs b/src/Jellyfin.Extensions/StringExtensions.cs index acc695ed2..3a7707253 100644 --- a/src/Jellyfin.Extensions/StringExtensions.cs +++ b/src/Jellyfin.Extensions/StringExtensions.cs @@ -27,5 +27,39 @@ namespace Jellyfin.Extensions 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 cd03958b6..6f5c0ed0c 100644 --- a/tests/Jellyfin.Api.Tests/Auth/CustomAuthenticationHandlerTests.cs +++ b/tests/Jellyfin.Api.Tests/Auth/CustomAuthenticationHandlerTests.cs @@ -132,6 +132,8 @@ 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( diff --git a/tests/Jellyfin.Api.Tests/Controllers/DynamicHlsControllerTests.cs b/tests/Jellyfin.Api.Tests/Controllers/DynamicHlsControllerTests.cs index 59a6b52d1..1f06e8fde 100644 --- a/tests/Jellyfin.Api.Tests/Controllers/DynamicHlsControllerTests.cs +++ b/tests/Jellyfin.Api.Tests/Controllers/DynamicHlsControllerTests.cs @@ -1,5 +1,4 @@ using System; -using System.Collections.Generic; using Jellyfin.Api.Controllers; using Xunit; @@ -19,33 +18,28 @@ namespace Jellyfin.Api.Tests.Controllers } } - public static IEnumerable GetSegmentLengths_Success_TestData() + public static TheoryData GetSegmentLengths_Success_TestData() { - yield return new object[] { 0, 6, Array.Empty() }; - yield return new object[] - { + var data = new TheoryData(); + data.Add(0, 6, Array.Empty()); + data.Add( TimeSpan.FromSeconds(3).Ticks, 6, - new double[] { 3 } - }; - yield return new object[] - { + new double[] { 3 }); + data.Add( TimeSpan.FromSeconds(6).Ticks, 6, - new double[] { 6 } - }; - yield return new object[] - { + new double[] { 6 }); + data.Add( TimeSpan.FromSeconds(3.3333333).Ticks, 6, - new double[] { 3.3333333 } - }; - yield return new object[] - { + new double[] { 3.3333333 }); + data.Add( TimeSpan.FromSeconds(9.3333333).Ticks, 6, - new double[] { 6, 3.3333333 } - }; + 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 97e441b1d..c4640bd22 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,38 +51,9 @@ namespace Jellyfin.Api.Tests.Helpers { ("SortName", SortOrder.Descending), ("ProductionYear", SortOrder.Descending), - } - }; - } + }); - [Fact] - public static void GetItemTypeStrings_Empty_Empty() - { - Assert.Empty(RequestHelpers.GetItemTypeStrings(Array.Empty())); - } - - [Fact] - public static void GetItemTypeStrings_Valid_Success() - { - BaseItemKind[] input = - { - BaseItemKind.AggregateFolder, - BaseItemKind.Audio, - BaseItemKind.BasePluginFolder, - BaseItemKind.CollectionFolder - }; - - string[] expected = - { - "AggregateFolder", - "Audio", - "BasePluginFolder", - "CollectionFolder" - }; - - var res = RequestHelpers.GetItemTypeStrings(input); - - Assert.Equal(expected, res); + return data; } } } diff --git a/tests/Jellyfin.Api.Tests/Jellyfin.Api.Tests.csproj b/tests/Jellyfin.Api.Tests/Jellyfin.Api.Tests.csproj index 0c36e81cc..6e0474dbf 100644 --- a/tests/Jellyfin.Api.Tests/Jellyfin.Api.Tests.csproj +++ b/tests/Jellyfin.Api.Tests/Jellyfin.Api.Tests.csproj @@ -6,7 +6,7 @@ - net5.0 + net6.0 false ../jellyfin-tests.ruleset @@ -15,9 +15,9 @@ - - - + + + @@ -27,7 +27,7 @@ - + diff --git a/tests/Jellyfin.Common.Tests/Jellyfin.Common.Tests.csproj b/tests/Jellyfin.Common.Tests/Jellyfin.Common.Tests.csproj index 1619fa89c..aaa6b5d90 100644 --- a/tests/Jellyfin.Common.Tests/Jellyfin.Common.Tests.csproj +++ b/tests/Jellyfin.Common.Tests/Jellyfin.Common.Tests.csproj @@ -6,23 +6,23 @@ - net5.0 + net6.0 false ../jellyfin-tests.ruleset - + - + - + diff --git a/tests/Jellyfin.Controller.Tests/BaseItemManagerTests.cs b/tests/Jellyfin.Controller.Tests/BaseItemManagerTests.cs new file mode 100644 index 000000000..463e17ad3 --- /dev/null +++ b/tests/Jellyfin.Controller.Tests/BaseItemManagerTests.cs @@ -0,0 +1,88 @@ +using System; +using MediaBrowser.Controller.BaseItemManager; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.Audio; +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/DirectoryServiceTests.cs b/tests/Jellyfin.Controller.Tests/DirectoryServiceTests.cs index feffb50e8..46439aecb 100644 --- a/tests/Jellyfin.Controller.Tests/DirectoryServiceTests.cs +++ b/tests/Jellyfin.Controller.Tests/DirectoryServiceTests.cs @@ -13,22 +13,22 @@ namespace Jellyfin.Controller.Tests private static readonly FileSystemMetadata[] _lowerCaseFileSystemMetadata = { - new () + new() { FullName = LowerCasePath + "/Artwork", IsDirectory = true }, - new () + new() { FullName = LowerCasePath + "/Some Other Folder", IsDirectory = true }, - new () + new() { FullName = LowerCasePath + "/Song 2.mp3", IsDirectory = false }, - new () + new() { FullName = LowerCasePath + "/Song 3.mp3", IsDirectory = false @@ -37,12 +37,12 @@ namespace Jellyfin.Controller.Tests private static readonly FileSystemMetadata[] _upperCaseFileSystemMetadata = { - new () + new() { FullName = UpperCasePath + "/Lyrics", IsDirectory = true }, - new () + new() { FullName = UpperCasePath + "/Song 1.mp3", IsDirectory = false diff --git a/tests/Jellyfin.Controller.Tests/Jellyfin.Controller.Tests.csproj b/tests/Jellyfin.Controller.Tests/Jellyfin.Controller.Tests.csproj index a5778b59c..981c7e9c9 100644 --- a/tests/Jellyfin.Controller.Tests/Jellyfin.Controller.Tests.csproj +++ b/tests/Jellyfin.Controller.Tests/Jellyfin.Controller.Tests.csproj @@ -6,13 +6,13 @@ - net5.0 + net6.0 false ../jellyfin-tests.ruleset - + @@ -22,7 +22,7 @@ - + diff --git a/tests/Jellyfin.Dlna.Tests/DlnaManagerTests.cs b/tests/Jellyfin.Dlna.Tests/DlnaManagerTests.cs index 668bd8f87..78a956f5f 100644 --- a/tests/Jellyfin.Dlna.Tests/DlnaManagerTests.cs +++ b/tests/Jellyfin.Dlna.Tests/DlnaManagerTests.cs @@ -46,7 +46,7 @@ namespace Jellyfin.Dlna.Tests ModelDescription = "LG WebOSTV DMRplus", ModelName = "LG TV", ModelNumber = "1.0", - Identification = new () + Identification = new() { FriendlyName = "My Device", Manufacturer = "LG Electronics", @@ -92,7 +92,7 @@ namespace Jellyfin.Dlna.Tests ModelDescription = "LG WebOSTV DMRplus", ModelName = "LG TV", ModelNumber = "1.0", - Identification = new () + Identification = new() { FriendlyName = "My Device", Manufacturer = "LG Electronics", @@ -120,7 +120,7 @@ namespace Jellyfin.Dlna.Tests { Name = "Test Profile", FriendlyName = "My .*", - Identification = new () + Identification = new() }; var deviceMatch = GetManager().IsMatch(device.ToDeviceIdentification(), profile.Identification); diff --git a/tests/Jellyfin.Dlna.Tests/Jellyfin.Dlna.Tests.csproj b/tests/Jellyfin.Dlna.Tests/Jellyfin.Dlna.Tests.csproj index 5a48631c2..6200a148b 100644 --- a/tests/Jellyfin.Dlna.Tests/Jellyfin.Dlna.Tests.csproj +++ b/tests/Jellyfin.Dlna.Tests/Jellyfin.Dlna.Tests.csproj @@ -1,13 +1,13 @@ - net5.0 + net6.0 false ../jellyfin-tests.ruleset - + @@ -17,7 +17,7 @@ - + diff --git a/tests/Jellyfin.Extensions.Tests/CopyToExtensionsTests.cs b/tests/Jellyfin.Extensions.Tests/CopyToExtensionsTests.cs index 6fdca4694..d46beedd9 100644 --- a/tests/Jellyfin.Extensions.Tests/CopyToExtensionsTests.cs +++ b/tests/Jellyfin.Extensions.Tests/CopyToExtensionsTests.cs @@ -6,10 +6,17 @@ namespace Jellyfin.Extensions.Tests { public static class CopyToExtensionsTests { - public static IEnumerable CopyTo_Valid_Correct_TestData() + public static TheoryData, IList, int, IList> 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 } }; + 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] @@ -20,13 +27,26 @@ namespace Jellyfin.Extensions.Tests Assert.Equal(expected, destination); } - public static IEnumerable CopyTo_Invalid_ThrowsArgumentOutOfRangeException_TestData() + public static TheoryData, IList, int> 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 }; + 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] diff --git a/tests/Jellyfin.Extensions.Tests/Jellyfin.Extensions.Tests.csproj b/tests/Jellyfin.Extensions.Tests/Jellyfin.Extensions.Tests.csproj index 20680157f..2a3918469 100644 --- a/tests/Jellyfin.Extensions.Tests/Jellyfin.Extensions.Tests.csproj +++ b/tests/Jellyfin.Extensions.Tests/Jellyfin.Extensions.Tests.csproj @@ -1,13 +1,13 @@ - net5.0 + net6.0 false ../jellyfin-tests.ruleset - + runtime; build; native; contentfiles; analyzers; buildtransitive @@ -17,13 +17,13 @@ runtime; build; native; contentfiles; analyzers; buildtransitive all - + - + diff --git a/tests/Jellyfin.Extensions.Tests/Json/Converters/JsonStringConverterTests.cs b/tests/Jellyfin.Extensions.Tests/Json/Converters/JsonStringConverterTests.cs index 655e07074..345f37cbe 100644 --- a/tests/Jellyfin.Extensions.Tests/Json/Converters/JsonStringConverterTests.cs +++ b/tests/Jellyfin.Extensions.Tests/Json/Converters/JsonStringConverterTests.cs @@ -6,7 +6,7 @@ namespace Jellyfin.Extensions.Tests.Json.Converters { public class JsonStringConverterTests { - private readonly JsonSerializerOptions _jsonSerializerOptions = new () + private readonly JsonSerializerOptions _jsonSerializerOptions = new() { Converters = { diff --git a/tests/Jellyfin.Extensions.Tests/ShuffleExtensionsTests.cs b/tests/Jellyfin.Extensions.Tests/ShuffleExtensionsTests.cs index c72216d94..a73cfb078 100644 --- a/tests/Jellyfin.Extensions.Tests/ShuffleExtensionsTests.cs +++ b/tests/Jellyfin.Extensions.Tests/ShuffleExtensionsTests.cs @@ -5,13 +5,11 @@ 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 index d1aa2e476..7186cc023 100644 --- a/tests/Jellyfin.Extensions.Tests/StringExtensionsTests.cs +++ b/tests/Jellyfin.Extensions.Tests/StringExtensionsTests.cs @@ -14,5 +14,28 @@ namespace Jellyfin.Extensions.Tests { 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 d1854a3c8..c0c363d3d 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; @@ -34,23 +32,21 @@ namespace Jellyfin.MediaEncoding.Tests Assert.Equal(valid, _encoderValidator.ValidateVersionInternal(versionOutput)); } - private class GetFFmpegVersionTestData : IEnumerable + private class GetFFmpegVersionTestData : TheoryData { - public IEnumerator GetEnumerator() + public GetFFmpegVersionTestData() { - yield return new object?[] { EncoderValidatorTestsData.FFmpegV44Output, new Version(4, 4) }; - yield return new object?[] { EncoderValidatorTestsData.FFmpegV432Output, new Version(4, 3, 2) }; - 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/Jellyfin.MediaEncoding.Tests.csproj b/tests/Jellyfin.MediaEncoding.Tests/Jellyfin.MediaEncoding.Tests.csproj index 7ea503913..f366f553a 100644 --- a/tests/Jellyfin.MediaEncoding.Tests/Jellyfin.MediaEncoding.Tests.csproj +++ b/tests/Jellyfin.MediaEncoding.Tests/Jellyfin.MediaEncoding.Tests.csproj @@ -6,7 +6,7 @@ - net5.0 + net6.0 false ../jellyfin-tests.ruleset @@ -18,16 +18,20 @@ - + + + + + + - - + diff --git a/tests/Jellyfin.MediaEncoding.Tests/Probing/ProbeResultNormalizerTests.cs b/tests/Jellyfin.MediaEncoding.Tests/Probing/ProbeResultNormalizerTests.cs index d002d5a34..0fc8724b6 100644 --- a/tests/Jellyfin.MediaEncoding.Tests/Probing/ProbeResultNormalizerTests.cs +++ b/tests/Jellyfin.MediaEncoding.Tests/Probing/ProbeResultNormalizerTests.cs @@ -5,8 +5,10 @@ using System.Text.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 @@ -16,6 +18,19 @@ namespace Jellyfin.MediaEncoding.Tests.Probing private readonly JsonSerializerOptions _jsonOptions = JsonDefaults.Options; private readonly ProbeResultNormalizer _probeResultNormalizer = new ProbeResultNormalizer(new NullLogger(), null); + [Theory] + [InlineData("2997/125", 23.976f)] + [InlineData("1/50", 0.02f)] + [InlineData("25/1", 25f)] + [InlineData("120/1", 120f)] + [InlineData("1704753000/71073479", 23.98578237601117f)] + [InlineData("0/0", null)] + [InlineData("1/1000", 0.001f)] + [InlineData("1/90000", 1.1111111E-05f)] + [InlineData("1/48000", 2.0833333E-05f)] + public void GetFrameRate_Success(string value, float? expected) + => Assert.Equal(expected, ProbeResultNormalizer.GetFrameRate(value)); + [Fact] public void GetMediaInfo_MetaData_Success() { @@ -55,6 +70,72 @@ namespace Jellyfin.MediaEncoding.Tests.Probing 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() { @@ -69,7 +150,7 @@ namespace Jellyfin.MediaEncoding.Tests.Probing Assert.Equal("Album", res.Album); Assert.Equal(2021, res.ProductionYear); Assert.True(res.PremiereDate.HasValue); - Assert.Equal(DateTime.Parse("2021-01-01T00:00Z", DateTimeFormatInfo.CurrentInfo).ToUniversalTime(), res.PremiereDate); + Assert.Equal(DateTime.Parse("2021-01-01T00:00Z", DateTimeFormatInfo.CurrentInfo, DateTimeStyles.AdjustToUniversal), res.PremiereDate); } [Fact] @@ -85,7 +166,7 @@ namespace Jellyfin.MediaEncoding.Tests.Probing Assert.Equal("City to City", res.Album); Assert.Equal(1978, res.ProductionYear); Assert.True(res.PremiereDate.HasValue); - Assert.Equal(DateTime.Parse("1978-01-01T00:00Z", DateTimeFormatInfo.CurrentInfo).ToUniversalTime(), res.PremiereDate); + Assert.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); @@ -105,7 +186,7 @@ namespace Jellyfin.MediaEncoding.Tests.Probing 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).ToUniversalTime(), res.PremiereDate); + 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); diff --git a/tests/Jellyfin.MediaEncoding.Tests/Subtitles/SrtParserTests.cs b/tests/Jellyfin.MediaEncoding.Tests/Subtitles/SrtParserTests.cs index 537a944b0..c07c9ea7d 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 5db80c300..56649db8f 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 000000000..639c364df --- /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/video_mp4_metadata.json b/tests/Jellyfin.MediaEncoding.Tests/Test Data/Probing/video_mp4_metadata.json new file mode 100644 index 000000000..77e3def76 --- /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 000000000..b14aa8ea3 --- /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 88% rename from tests/Jellyfin.Common.Tests/Cryptography/PasswordHashTests.cs rename to tests/Jellyfin.Model.Tests/Cryptography/PasswordHashTests.cs index 18d3f9763..6948280a3 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] diff --git a/tests/Jellyfin.Model.Tests/Drawing/ImageFormatExtensionsTests.cs b/tests/Jellyfin.Model.Tests/Drawing/ImageFormatExtensionsTests.cs new file mode 100644 index 000000000..7c3a7ff6c --- /dev/null +++ b/tests/Jellyfin.Model.Tests/Drawing/ImageFormatExtensionsTests.cs @@ -0,0 +1,33 @@ +using System; +using System.ComponentModel; +using MediaBrowser.Model.Drawing; +using Xunit; + +namespace Jellyfin.Model.Drawing; + +public static class ImageFormatExtensionsTests +{ + private static TheoryData GetAllImageFormats() + { + var theoryTypes = new TheoryData(); + foreach (var x in Enum.GetValues()) + { + theoryTypes.Add(x); + } + + return theoryTypes; + } + + [Theory] + [MemberData(nameof(GetAllImageFormats))] + public static void GetMimeType_Valid_Valid(ImageFormat format) + => Assert.Null(Record.Exception(() => format.GetMimeType())); + + [Theory] + [InlineData((ImageFormat)int.MinValue)] + [InlineData((ImageFormat)int.MaxValue)] + [InlineData((ImageFormat)(-1))] + [InlineData((ImageFormat)5)] + public static void GetMimeType_Valid_ThrowsInvalidEnumArgumentException(ImageFormat format) + => Assert.Throws(() => format.GetMimeType()); +} diff --git a/tests/Jellyfin.Model.Tests/Entities/MediaStreamTests.cs b/tests/Jellyfin.Model.Tests/Entities/MediaStreamTests.cs index ce9ecea6a..0c97a90b4 100644 --- a/tests/Jellyfin.Model.Tests/Entities/MediaStreamTests.cs +++ b/tests/Jellyfin.Model.Tests/Entities/MediaStreamTests.cs @@ -1,4 +1,3 @@ -using System.Collections.Generic; using MediaBrowser.Model.Entities; using Xunit; @@ -6,12 +5,11 @@ namespace Jellyfin.Model.Tests.Entities { public class MediaStreamTests { - public static IEnumerable Get_DisplayTitle_TestData() + public static TheoryData Get_DisplayTitle_TestData() { - return new List - { - new object[] - { + var data = new TheoryData(); + + data.Add( new MediaStream { Type = MediaStreamType.Subtitle, @@ -21,61 +19,57 @@ namespace Jellyfin.Model.Tests.Entities IsDefault = false, Codec = "ASS" }, - "English - Und - ASS" - }, - new object[] + "English - Und - ASS"); + + data.Add( + new MediaStream { - new MediaStream - { - Type = MediaStreamType.Subtitle, - Title = "English", - Language = string.Empty, - IsForced = false, - IsDefault = false, - Codec = string.Empty - }, - "English - Und" + Type = MediaStreamType.Subtitle, + Title = "English", + Language = string.Empty, + IsForced = false, + IsDefault = false, + Codec = string.Empty }, - new object[] + "English - Und"); + + data.Add( + new MediaStream { - new MediaStream - { - Type = MediaStreamType.Subtitle, - Title = "English", - Language = "EN", - IsForced = false, - IsDefault = false, - Codec = string.Empty - }, - "English" + Type = MediaStreamType.Subtitle, + Title = "English", + Language = "EN", + IsForced = false, + IsDefault = false, + Codec = string.Empty }, - new object[] + "English"); + + data.Add( + new MediaStream { - new MediaStream - { - Type = MediaStreamType.Subtitle, - Title = "English", - Language = "EN", - IsForced = true, - IsDefault = true, - Codec = "SRT" - }, - "English - Default - Forced - SRT" + Type = MediaStreamType.Subtitle, + Title = "English", + Language = "EN", + IsForced = true, + IsDefault = true, + Codec = "SRT" }, - new object[] + "English - Default - Forced - SRT"); + + data.Add( + new MediaStream { - new MediaStream - { - Type = MediaStreamType.Subtitle, - Title = null, - Language = null, - IsForced = false, - IsDefault = false, - Codec = null - }, - "Und" - } - }; + Type = MediaStreamType.Subtitle, + Title = null, + Language = null, + IsForced = false, + IsDefault = false, + Codec = null + }, + "Und"); + + return data; } [Theory] diff --git a/tests/Jellyfin.Model.Tests/Jellyfin.Model.Tests.csproj b/tests/Jellyfin.Model.Tests/Jellyfin.Model.Tests.csproj index 09b8a7a94..3b6259abd 100644 --- a/tests/Jellyfin.Model.Tests/Jellyfin.Model.Tests.csproj +++ b/tests/Jellyfin.Model.Tests/Jellyfin.Model.Tests.csproj @@ -1,23 +1,23 @@ - net5.0 + net6.0 false ../jellyfin-tests.ruleset - + - + - + diff --git a/tests/Jellyfin.Model.Tests/Net/MimeTypesTests.cs b/tests/Jellyfin.Model.Tests/Net/MimeTypesTests.cs new file mode 100644 index 000000000..cbab455f0 --- /dev/null +++ b/tests/Jellyfin.Model.Tests/Net/MimeTypesTests.cs @@ -0,0 +1,159 @@ +using MediaBrowser.Model.Net; +using Xunit; + +namespace Jellyfin.Model.Tests.Net +{ + public class MimeTypesTests + { + [Theory] + [InlineData(".dll", "application/octet-stream")] + [InlineData(".log", "text/plain")] + [InlineData(".srt", "application/x-subrip")] + [InlineData(".html", "text/html; charset=UTF-8")] + [InlineData(".htm", "text/html; charset=UTF-8")] + [InlineData(".7z", "application/x-7z-compressed")] + [InlineData(".azw", "application/vnd.amazon.ebook")] + [InlineData(".azw3", "application/vnd.amazon.ebook")] + [InlineData(".eot", "application/vnd.ms-fontobject")] + [InlineData(".epub", "application/epub+zip")] + [InlineData(".json", "application/json")] + [InlineData(".mobi", "application/x-mobipocket-ebook")] + [InlineData(".opf", "application/oebps-package+xml")] + [InlineData(".pdf", "application/pdf")] + [InlineData(".rar", "application/vnd.rar")] + [InlineData(".ttml", "application/ttml+xml")] + [InlineData(".wasm", "application/wasm")] + [InlineData(".xml", "application/xml")] + [InlineData(".zip", "application/zip")] + [InlineData(".bmp", "image/bmp")] + [InlineData(".gif", "image/gif")] + [InlineData(".ico", "image/vnd.microsoft.icon")] + [InlineData(".jpg", "image/jpeg")] + [InlineData(".jpeg", "image/jpeg")] + [InlineData(".png", "image/png")] + [InlineData(".svg", "image/svg+xml")] + [InlineData(".svgz", "image/svg+xml")] + [InlineData(".tbn", "image/jpeg")] + [InlineData(".tif", "image/tiff")] + [InlineData(".tiff", "image/tiff")] + [InlineData(".webp", "image/webp")] + [InlineData(".ttf", "font/ttf")] + [InlineData(".woff", "font/woff")] + [InlineData(".woff2", "font/woff2")] + [InlineData(".ass", "text/x-ssa")] + [InlineData(".ssa", "text/x-ssa")] + [InlineData(".css", "text/css")] + [InlineData(".csv", "text/csv")] + [InlineData(".edl", "text/plain")] + [InlineData(".txt", "text/plain")] + [InlineData(".vtt", "text/vtt")] + [InlineData(".3gp", "video/3gpp")] + [InlineData(".3g2", "video/3gpp2")] + [InlineData(".asf", "video/x-ms-asf")] + [InlineData(".avi", "video/x-msvideo")] + [InlineData(".flv", "video/x-flv")] + [InlineData(".mp4", "video/mp4")] + [InlineData(".m4v", "video/x-m4v")] + [InlineData(".mpegts", "video/mp2t")] + [InlineData(".mpg", "video/mpeg")] + [InlineData(".mkv", "video/x-matroska")] + [InlineData(".mov", "video/quicktime")] + [InlineData(".ogv", "video/ogg")] + [InlineData(".ts", "video/mp2t")] + [InlineData(".webm", "video/webm")] + [InlineData(".wmv", "video/x-ms-wmv")] + [InlineData(".aac", "audio/aac")] + [InlineData(".ac3", "audio/ac3")] + [InlineData(".ape", "audio/x-ape")] + [InlineData(".dsf", "audio/dsf")] + [InlineData(".dsp", "audio/dsp")] + [InlineData(".flac", "audio/flac")] + [InlineData(".m4a", "audio/mp4")] + [InlineData(".m4b", "audio/m4b")] + [InlineData(".mid", "audio/midi")] + [InlineData(".midi", "audio/midi")] + [InlineData(".mp3", "audio/mpeg")] + [InlineData(".oga", "audio/ogg")] + [InlineData(".ogg", "audio/ogg")] + [InlineData(".opus", "audio/ogg")] + [InlineData(".vorbis", "audio/vorbis")] + [InlineData(".wav", "audio/wav")] + [InlineData(".webma", "audio/webm")] + [InlineData(".wma", "audio/x-ms-wma")] + [InlineData(".wv", "audio/x-wavpack")] + [InlineData(".xsp", "audio/xsp")] + public void GetMimeType_Valid_ReturnsCorrectResult(string input, string expectedResult) + { + Assert.Equal(expectedResult, MimeTypes.GetMimeType(input, null)); + } + + [Theory] + [InlineData("application/epub+zip", ".epub")] + [InlineData("application/json", ".json")] + [InlineData("application/oebps-package+xml", ".opf")] + [InlineData("application/pdf", ".pdf")] + [InlineData("application/ttml+xml", ".ttml")] + [InlineData("application/vnd.amazon.ebook", ".azw")] + [InlineData("application/vnd.ms-fontobject", ".eot")] + [InlineData("application/vnd.rar", ".rar")] + [InlineData("application/wasm", ".wasm")] + [InlineData("application/x-7z-compressed", ".7z")] + [InlineData("application/x-cbz", ".cbz")] + [InlineData("application/x-javascript", ".js")] + [InlineData("application/x-mobipocket-ebook", ".mobi")] + [InlineData("application/x-mpegURL", ".m3u8")] + [InlineData("application/x-subrip", ".srt")] + [InlineData("application/xml", ".xml")] + [InlineData("application/zip", ".zip")] + [InlineData("audio/aac", ".aac")] + [InlineData("audio/ac3", ".ac3")] + [InlineData("audio/dsf", ".dsf")] + [InlineData("audio/dsp", ".dsp")] + [InlineData("audio/flac", ".flac")] + [InlineData("audio/m4b", ".m4b")] + [InlineData("audio/mp4", ".m4a")] + [InlineData("audio/vorbis", ".vorbis")] + [InlineData("audio/wav", ".wav")] + [InlineData("audio/x-aac", ".aac")] + [InlineData("audio/x-ape", ".ape")] + [InlineData("audio/x-ms-wma", ".wma")] + [InlineData("audio/x-wavpack", ".wv")] + [InlineData("audio/xsp", ".xsp")] + [InlineData("font/ttf", ".ttf")] + [InlineData("font/woff", ".woff")] + [InlineData("font/woff2", ".woff2")] + [InlineData("image/bmp", ".bmp")] + [InlineData("image/gif", ".gif")] + [InlineData("image/jpeg", ".jpg")] + [InlineData("image/png", ".png")] + [InlineData("image/svg+xml", ".svg")] + [InlineData("image/tiff", ".tif")] + [InlineData("image/vnd.microsoft.icon", ".ico")] + [InlineData("image/webp", ".webp")] + [InlineData("image/x-png", ".png")] + [InlineData("text/css", ".css")] + [InlineData("text/csv", ".csv")] + [InlineData("text/plain", ".txt")] + [InlineData("text/rtf", ".rtf")] + [InlineData("text/vtt", ".vtt")] + [InlineData("text/x-ssa", ".ssa")] + [InlineData("video/3gpp", ".3gp")] + [InlineData("video/3gpp2", ".3g2")] + [InlineData("video/mp2t", ".ts")] + [InlineData("video/mp4", ".mp4")] + [InlineData("video/ogg", ".ogv")] + [InlineData("video/quicktime", ".mov")] + [InlineData("video/vnd.mpeg.dash.mpd", ".mpd")] + [InlineData("video/webm", ".webm")] + [InlineData("video/x-flv", ".flv")] + [InlineData("video/x-m4v", ".m4v")] + [InlineData("video/x-matroska", ".mkv")] + [InlineData("video/x-ms-asf", ".asf")] + [InlineData("video/x-ms-wmv", ".wmv")] + [InlineData("video/x-msvideo", ".avi")] + public void ToExtension_Valid_ReturnsCorrectResult(string input, string expectedResult) + { + Assert.Equal(expectedResult, MimeTypes.ToExtension(input)); + } + } +} diff --git a/tests/Jellyfin.Naming.Tests/AudioBook/AudioBookResolverTests.cs b/tests/Jellyfin.Naming.Tests/AudioBook/AudioBookResolverTests.cs index 53b35c2d6..c72a3315e 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/Common/NamingOptionsTest.cs b/tests/Jellyfin.Naming.Tests/Common/NamingOptionsTest.cs index 3892d00f6..58aaed023 100644 --- a/tests/Jellyfin.Naming.Tests/Common/NamingOptionsTest.cs +++ b/tests/Jellyfin.Naming.Tests/Common/NamingOptionsTest.cs @@ -10,7 +10,6 @@ namespace Jellyfin.Naming.Tests.Common { var options = new NamingOptions(); - Assert.NotEmpty(options.VideoFileStackingRegexes); Assert.NotEmpty(options.CleanDateTimeRegexes); Assert.NotEmpty(options.CleanStringRegexes); Assert.NotEmpty(options.EpisodeWithoutSeasonRegexes); diff --git a/tests/Jellyfin.Naming.Tests/Jellyfin.Naming.Tests.csproj b/tests/Jellyfin.Naming.Tests/Jellyfin.Naming.Tests.csproj index a4ebab141..4c95e78b1 100644 --- a/tests/Jellyfin.Naming.Tests/Jellyfin.Naming.Tests.csproj +++ b/tests/Jellyfin.Naming.Tests/Jellyfin.Naming.Tests.csproj @@ -6,13 +6,13 @@ - net5.0 + net6.0 false ../jellyfin-tests.ruleset - + @@ -25,7 +25,7 @@ - + diff --git a/tests/Jellyfin.Naming.Tests/TV/AbsoluteEpisodeNumberTests.cs b/tests/Jellyfin.Naming.Tests/TV/AbsoluteEpisodeNumberTests.cs index 356ba216d..e81c5152e 100644 --- a/tests/Jellyfin.Naming.Tests/TV/AbsoluteEpisodeNumberTests.cs +++ b/tests/Jellyfin.Naming.Tests/TV/AbsoluteEpisodeNumberTests.cs @@ -6,6 +6,8 @@ namespace Jellyfin.Naming.Tests.TV { public class AbsoluteEpisodeNumberTests { + private readonly EpisodeResolver _resolver = new EpisodeResolver(new NamingOptions()); + [Theory] [InlineData("The Simpsons/12.avi", 12)] [InlineData("The Simpsons/The Simpsons 12.avi", 12)] @@ -16,10 +18,7 @@ namespace Jellyfin.Naming.Tests.TV [InlineData("The Simpsons/The Simpsons 101.avi", 101)] public void GetEpisodeNumberFromFileTest(string path, int episodeNumber) { - var options = new NamingOptions(); - - var result = new EpisodeResolver(options) - .Resolve(path, false, null, null, true); + var result = _resolver.Resolve(path, false, null, null, true); Assert.Equal(episodeNumber, result?.EpisodeNumber); } diff --git a/tests/Jellyfin.Naming.Tests/TV/DailyEpisodeTests.cs b/tests/Jellyfin.Naming.Tests/TV/DailyEpisodeTests.cs index 2937914b9..72052a23c 100644 --- a/tests/Jellyfin.Naming.Tests/TV/DailyEpisodeTests.cs +++ b/tests/Jellyfin.Naming.Tests/TV/DailyEpisodeTests.cs @@ -6,6 +6,8 @@ namespace Jellyfin.Naming.Tests.TV { public class DailyEpisodeTests { + private readonly EpisodeResolver _resolver = new EpisodeResolver(new NamingOptions()); + [Theory] [InlineData(@"/server/anything_1996.11.14.mp4", "anything", 1996, 11, 14)] [InlineData(@"/server/anything_1996-11-14.mp4", "anything", 1996, 11, 14)] @@ -16,10 +18,7 @@ namespace Jellyfin.Naming.Tests.TV // TODO: [InlineData(@"/server/Last Man Standing_KTLADT_2018_05_25_01_28_00.wtv", "Last Man Standing", 2018, 05, 25)] public void Test(string path, string seriesName, int? year, int? month, int? day) { - var options = new NamingOptions(); - - var result = new EpisodeResolver(options) - .Resolve(path, false); + var result = _resolver.Resolve(path, false); Assert.Null(result?.SeasonNumber); Assert.Null(result?.EpisodeNumber); diff --git a/tests/Jellyfin.Naming.Tests/TV/EpisodeNumberTests.cs b/tests/Jellyfin.Naming.Tests/TV/EpisodeNumberTests.cs index 2873f6161..1e7fedb36 100644 --- a/tests/Jellyfin.Naming.Tests/TV/EpisodeNumberTests.cs +++ b/tests/Jellyfin.Naming.Tests/TV/EpisodeNumberTests.cs @@ -71,9 +71,9 @@ namespace Jellyfin.Naming.Tests.TV [InlineData("Season 1/seriesname 05.mkv", 5)] // no hyphen between series name and episode number [InlineData("[BBT-RMX] Ranma ½ - 154 [50AC421A].mkv", 154)] // hyphens in the pre-name info, triple digit episode number [InlineData("Season 2/Episode 21 - 94 Meetings.mp4", 21)] // Title starts with a number + [InlineData("/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/EpisodeNumberWithoutSeasonTests.cs b/tests/Jellyfin.Naming.Tests/TV/EpisodeNumberWithoutSeasonTests.cs index 8bd1a43d6..1da5a30a8 100644 --- a/tests/Jellyfin.Naming.Tests/TV/EpisodeNumberWithoutSeasonTests.cs +++ b/tests/Jellyfin.Naming.Tests/TV/EpisodeNumberWithoutSeasonTests.cs @@ -6,6 +6,8 @@ namespace Jellyfin.Naming.Tests.TV { public class EpisodeNumberWithoutSeasonTests { + private readonly EpisodeResolver _resolver = new EpisodeResolver(new NamingOptions()); + [Theory] [InlineData(8, @"The Simpsons/The Simpsons.S25E08.Steal this episode.mp4")] [InlineData(2, @"The Simpsons/The Simpsons - 02 - Ep Name.avi")] @@ -24,10 +26,7 @@ namespace Jellyfin.Naming.Tests.TV // TODO: [InlineData(13, @"Case Closed (1996-2007)/Case Closed - 13.mkv")] public void GetEpisodeNumberFromFileTest(int episodeNumber, string path) { - var options = new NamingOptions(); - - var result = new EpisodeResolver(options) - .Resolve(path, false); + var result = _resolver.Resolve(path, false); Assert.Equal(episodeNumber, result?.EpisodeNumber); } diff --git a/tests/Jellyfin.Naming.Tests/TV/EpisodePathParserTest.cs b/tests/Jellyfin.Naming.Tests/TV/EpisodePathParserTest.cs index 12fc19bc4..af219b118 100644 --- a/tests/Jellyfin.Naming.Tests/TV/EpisodePathParserTest.cs +++ b/tests/Jellyfin.Naming.Tests/TV/EpisodePathParserTest.cs @@ -6,6 +6,8 @@ namespace Jellyfin.Naming.Tests.TV { public class EpisodePathParserTest { + private readonly NamingOptions _namingOptions = new NamingOptions(); + [Theory] [InlineData("/media/Foo/Foo-S01E01", true, "Foo", 1, 1)] [InlineData("/media/Foo - S04E011", true, "Foo", 4, 11)] @@ -36,8 +38,7 @@ namespace Jellyfin.Naming.Tests.TV // TODO: [InlineData("/The.Legend.of.Condor.Heroes.2017.V2.web-dl.1080p.h264.aac-hdctv/The.Legend.of.Condor.Heroes.2017.E07.V2.web-dl.1080p.h264.aac-hdctv.mkv", "The Legend of Condor Heroes 2017", 1, 7)] public void ParseEpisodesCorrectly(string path, bool isDirectory, string name, int season, int episode) { - NamingOptions o = new NamingOptions(); - EpisodePathParser p = new EpisodePathParser(o); + EpisodePathParser p = new EpisodePathParser(_namingOptions); var res = p.Parse(path, isDirectory); Assert.True(res.Success); @@ -50,8 +51,7 @@ namespace Jellyfin.Naming.Tests.TV [InlineData("/test/01-03.avi", true, true)] public void EpisodePathParserTest_DifferentExpressionsParameters(string path, bool? isNamed, bool? isOptimistic) { - NamingOptions o = new NamingOptions(); - EpisodePathParser p = new EpisodePathParser(o); + EpisodePathParser p = new EpisodePathParser(_namingOptions); var res = p.Parse(path, false, isNamed, isOptimistic); Assert.True(res.Success); @@ -60,8 +60,7 @@ namespace Jellyfin.Naming.Tests.TV [Fact] public void EpisodePathParserTest_FalsePositivePixelRate() { - NamingOptions o = new NamingOptions(); - EpisodePathParser p = new EpisodePathParser(o); + EpisodePathParser p = new EpisodePathParser(_namingOptions); var res = p.Parse("Series Special (1920x1080).mkv", false); Assert.False(res.Success); @@ -70,14 +69,14 @@ namespace Jellyfin.Naming.Tests.TV [Fact] public void EpisodeResolverTest_WrongExtension() { - var res = new EpisodeResolver(new NamingOptions()).Resolve("test.mp3", false); + var res = new EpisodeResolver(_namingOptions).Resolve("test.mp3", false); Assert.Null(res); } [Fact] public void EpisodeResolverTest_WrongExtensionStub() { - var res = new EpisodeResolver(new NamingOptions()).Resolve("dvd.disc", false); + var res = new EpisodeResolver(_namingOptions).Resolve("dvd.disc", false); Assert.NotNull(res); Assert.True(res!.IsStub); } diff --git a/tests/Jellyfin.Naming.Tests/TV/EpisodeWithoutSeasonTests.cs b/tests/Jellyfin.Naming.Tests/TV/EpisodeWithoutSeasonTests.cs deleted file mode 100644 index d0418a49e..000000000 --- a/tests/Jellyfin.Naming.Tests/TV/EpisodeWithoutSeasonTests.cs +++ /dev/null @@ -1,27 +0,0 @@ -using Emby.Naming.Common; -using Emby.Naming.TV; -using Xunit; - -namespace Jellyfin.Naming.Tests.TV -{ - public class EpisodeWithoutSeasonTests - { - // TODO: [Theory] - // TODO: [InlineData(@"/server/anything_ep02.mp4", "anything", null, 2)] - // TODO: [InlineData(@"/server/anything_ep_02.mp4", "anything", null, 2)] - // TODO: [InlineData(@"/server/anything_part.II.mp4", "anything", null, null)] - // TODO: [InlineData(@"/server/anything_pt.II.mp4", "anything", null, null)] - // TODO: [InlineData(@"/server/anything_pt_II.mp4", "anything", null, null)] - public void Test(string path, string seriesName, int? seasonNumber, int? episodeNumber) - { - var options = new NamingOptions(); - - var result = new EpisodeResolver(options) - .Resolve(path, false); - - Assert.Equal(seasonNumber, result?.SeasonNumber); - Assert.Equal(episodeNumber, result?.EpisodeNumber); - Assert.Equal(seriesName, result?.SeriesName, ignoreCase: true); - } - } -} diff --git a/tests/Jellyfin.Naming.Tests/TV/MultiEpisodeTests.cs b/tests/Jellyfin.Naming.Tests/TV/MultiEpisodeTests.cs index 58ea0bec5..ffaa64c3f 100644 --- a/tests/Jellyfin.Naming.Tests/TV/MultiEpisodeTests.cs +++ b/tests/Jellyfin.Naming.Tests/TV/MultiEpisodeTests.cs @@ -6,6 +6,8 @@ namespace Jellyfin.Naming.Tests.TV { public class MultiEpisodeTests { + private readonly EpisodePathParser _episodePathParser = new EpisodePathParser(new NamingOptions()); + [Theory] [InlineData(@"Season 1/4x01 – 20 Hours in America (1).mkv", null)] [InlineData(@"Season 1/01x02 blah.avi", null)] @@ -69,10 +71,7 @@ namespace Jellyfin.Naming.Tests.TV [InlineData(@"Season 1/MOONLIGHTING_s01e01-e04", 4)] public void TestGetEndingEpisodeNumberFromFile(string filename, int? endingEpisodeNumber) { - var options = new NamingOptions(); - - var result = new EpisodePathParser(options) - .Parse(filename, false); + var result = _episodePathParser.Parse(filename, false); Assert.Equal(result.EndingEpisodeNumber, endingEpisodeNumber); } diff --git a/tests/Jellyfin.Naming.Tests/TV/SeasonNumberTests.cs b/tests/Jellyfin.Naming.Tests/TV/SeasonNumberTests.cs index 4837e3a3b..58ec1b5d2 100644 --- a/tests/Jellyfin.Naming.Tests/TV/SeasonNumberTests.cs +++ b/tests/Jellyfin.Naming.Tests/TV/SeasonNumberTests.cs @@ -6,7 +6,7 @@ namespace Jellyfin.Naming.Tests.TV { public class SeasonNumberTests { - private readonly NamingOptions _namingOptions = new NamingOptions(); + private readonly EpisodeResolver _resolver = new EpisodeResolver(new NamingOptions()); [Theory] [InlineData("The Daily Show/The Daily Show 25x22 - [WEBDL-720p][AAC 2.0][x264] Noah Baumbach-TBS.mkv", 25)] @@ -56,8 +56,7 @@ namespace Jellyfin.Naming.Tests.TV // TODO: [InlineData(@"Seinfeld/Seinfeld 0807 The Checks.avi", 8)] public void GetSeasonNumberFromEpisodeFileTest(string path, int? expected) { - var result = new EpisodeResolver(_namingOptions) - .Resolve(path, false); + var result = _resolver.Resolve(path, false); Assert.Equal(expected, result?.SeasonNumber); } diff --git a/tests/Jellyfin.Naming.Tests/TV/SeriesPathParserTest.cs b/tests/Jellyfin.Naming.Tests/TV/SeriesPathParserTest.cs new file mode 100644 index 000000000..e6b0409db --- /dev/null +++ b/tests/Jellyfin.Naming.Tests/TV/SeriesPathParserTest.cs @@ -0,0 +1,29 @@ +using Emby.Naming.Common; +using Emby.Naming.TV; +using Xunit; + +namespace Jellyfin.Naming.Tests.TV +{ + public class SeriesPathParserTest + { + private readonly NamingOptions _namingOptions = new NamingOptions(); + + [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) + { + var res = SeriesPathParser.Parse(_namingOptions, 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 000000000..84758c9c3 --- /dev/null +++ b/tests/Jellyfin.Naming.Tests/TV/SeriesResolverTests.cs @@ -0,0 +1,29 @@ +using Emby.Naming.Common; +using Emby.Naming.TV; +using Xunit; + +namespace Jellyfin.Naming.Tests.TV +{ + public class SeriesResolverTests + { + private readonly NamingOptions _namingOptions = new NamingOptions(); + + [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) + { + var res = SeriesResolver.Resolve(_namingOptions, 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 6d49ac832..fa46ecc3a 100644 --- a/tests/Jellyfin.Naming.Tests/TV/SimpleEpisodeTests.cs +++ b/tests/Jellyfin.Naming.Tests/TV/SimpleEpisodeTests.cs @@ -7,6 +7,8 @@ namespace Jellyfin.Naming.Tests.TV { public class SimpleEpisodeTests { + private readonly EpisodeResolver _resolver = new EpisodeResolver(new NamingOptions()); + [Theory] [InlineData("/server/anything_s01e02.mp4", "anything", 1, 2)] [InlineData("/server/anything_s1e2.mp4", "anything", 1, 2)] @@ -23,39 +25,25 @@ namespace Jellyfin.Naming.Tests.TV [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)] [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)] + [InlineData("Series/4-12 - The Woman.mp4", "", 4, 12, 12)] // 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) + public void TestSimple(string path, string seriesName, int? seasonNumber, int? episodeNumber, int? episodeEndNumber = null) { - Test(path, seriesName, seasonNumber, episodeNumber, null); - } - - [Theory] - [InlineData("Series/4-12 - The Woman.mp4", "", 4, 12, 12)] - public void TestWithPossibleEpisodeEnd(string path, string seriesName, int? seasonNumber, int? episodeNumber, int? episodeEndNumber) - { - Test(path, seriesName, seasonNumber, episodeNumber, episodeEndNumber); - } - - private void Test(string path, string seriesName, int? seasonNumber, int? episodeNumber, int? episodeEndNumber) - { - var options = new NamingOptions(); - - var result = new EpisodeResolver(options) - .Resolve(path, false); + var result = _resolver.Resolve(path, false); Assert.NotNull(result); - Assert.Equal(seasonNumber, result?.SeasonNumber); - Assert.Equal(episodeNumber, result?.EpisodeNumber); - Assert.Equal(seriesName, result?.SeriesName, true); - Assert.Equal(path, result?.Path); + Assert.Equal(seasonNumber, result!.SeasonNumber); + Assert.Equal(episodeNumber, result!.EpisodeNumber); + Assert.Equal(seriesName, result!.SeriesName, true); + Assert.Equal(path, result!.Path); Assert.Equal(Path.GetExtension(path).Substring(1), result?.Container); - Assert.Null(result?.Format3D); - Assert.False(result?.Is3D); - Assert.False(result?.IsStub); - Assert.Null(result?.StubType); - Assert.Equal(episodeEndNumber, result?.EndingEpisodeNumber); - Assert.False(result?.IsByDate); + Assert.Null(result!.Format3D); + Assert.False(result!.Is3D); + Assert.False(result!.IsStub); + Assert.Null(result!.StubType); + Assert.Equal(episodeEndNumber, result!.EndingEpisodeNumber); + Assert.False(result!.IsByDate); } } } diff --git a/tests/Jellyfin.Naming.Tests/Video/CleanStringTests.cs b/tests/Jellyfin.Naming.Tests/Video/CleanStringTests.cs index fb050cf5a..1574bce58 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; @@ -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, _namingOptions, 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, _namingOptions, 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 f872f94f8..731580e0c 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("trailer.mp4", ExtraType.Trailer); + Test("trailer.mp3", null); + Test("300-trailer.mp4", ExtraType.Trailer); + Test("stuff trailerthings.mkv", null); - Test("theme.mp3", ExtraType.ThemeSong, _videoOptions); - Test("theme.mkv", null, _videoOptions); + Test("theme.mp3", ExtraType.ThemeSong); + Test("theme.mkv", null); - Test("300-scene.mp4", ExtraType.Scene, _videoOptions); - Test("300-scene2.mp4", ExtraType.Scene, _videoOptions); - Test("300-clip.mp4", ExtraType.Clip, _videoOptions); + Test("300-scene.mp4", ExtraType.Scene); + Test("300-scene2.mp4", ExtraType.Scene); + Test("300-clip.mp4", ExtraType.Clip); - 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("300-deleted.mp4", ExtraType.DeletedScene); + Test("300-deletedscene.mp4", ExtraType.DeletedScene); + Test("300-interview.mp4", ExtraType.Interview); + Test("300-behindthescenes.mp4", ExtraType.BehindTheScenes); } [Theory] @@ -52,12 +53,13 @@ namespace Jellyfin.Naming.Tests.Video [InlineData(ExtraType.Sample, "samples")] [InlineData(ExtraType.Clip, "shorts")] [InlineData(ExtraType.Clip, "featurettes")] + [InlineData(ExtraType.ThemeVideo, "backdrops")] [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 +68,23 @@ 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 extraType = ExtraRuleResolver.GetExtraInfo(input, _videoOptions).ExtraType; - var extraType = parser.GetExtraInfo(input).ExtraType; - - if (expectedType == null) - { - Assert.Null(extraType); - } - else - { - Assert.Equal(expectedType, extraType); - } + Assert.Equal(expectedType, extraType); } [Fact] @@ -99,14 +92,9 @@ namespace Jellyfin.Naming.Tests.Video { var rule = new ExtraRule(ExtraType.Unknown, ExtraRuleType.Regex, @"([eE]x(tra)?\.\w+)", MediaType.Video); var options = new NamingOptions { VideoExtraRules = new[] { rule } }; - var res = GetExtraTypeParser(options).GetExtraInfo("extra.mp4"); + var res = ExtraRuleResolver.GetExtraInfo("extra.mp4", options); Assert.Equal(rule, res.Rule); } - - private ExtraResolver GetExtraTypeParser(NamingOptions videoOptions) - { - return new ExtraResolver(videoOptions); - } } } diff --git a/tests/Jellyfin.Naming.Tests/Video/MultiVersionTests.cs b/tests/Jellyfin.Naming.Tests/Video/MultiVersionTests.cs index d02f8ae92..9a9a57be4 100644 --- a/tests/Jellyfin.Naming.Tests/Video/MultiVersionTests.cs +++ b/tests/Jellyfin.Naming.Tests/Video/MultiVersionTests.cs @@ -23,15 +23,11 @@ namespace Jellyfin.Naming.Tests.Video }; var result = VideoListResolver.Resolve( - files.Select(i => new FileSystemMetadata - { - IsDirectory = false, - FullName = i - }).ToList(), + files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType().ToList(), _namingOptions).ToList(); - Assert.Single(result); - Assert.Single(result[0].Extras); + Assert.Single(result.Where(v => v.ExtraType == null)); + Assert.Single(result.Where(v => v.ExtraType != null)); } [Fact] @@ -46,15 +42,11 @@ namespace Jellyfin.Naming.Tests.Video }; var result = VideoListResolver.Resolve( - files.Select(i => new FileSystemMetadata - { - IsDirectory = false, - FullName = i - }).ToList(), + files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType().ToList(), _namingOptions).ToList(); - Assert.Single(result); - Assert.Single(result[0].Extras); + Assert.Single(result.Where(v => v.ExtraType == null)); + Assert.Single(result.Where(v => v.ExtraType != null)); Assert.Equal(2, result[0].AlternateVersions.Count); } @@ -68,11 +60,7 @@ namespace Jellyfin.Naming.Tests.Video }; var result = VideoListResolver.Resolve( - files.Select(i => new FileSystemMetadata - { - IsDirectory = false, - FullName = i - }).ToList(), + files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType().ToList(), _namingOptions).ToList(); Assert.Single(result); @@ -94,15 +82,10 @@ namespace Jellyfin.Naming.Tests.Video }; var result = VideoListResolver.Resolve( - files.Select(i => new FileSystemMetadata - { - IsDirectory = false, - FullName = i - }).ToList(), + files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType().ToList(), _namingOptions).ToList(); Assert.Equal(7, result.Count); - Assert.Empty(result[0].Extras); Assert.Empty(result[0].AlternateVersions); } @@ -122,15 +105,10 @@ namespace Jellyfin.Naming.Tests.Video }; var result = VideoListResolver.Resolve( - files.Select(i => new FileSystemMetadata - { - IsDirectory = false, - FullName = i - }).ToList(), + files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType().ToList(), _namingOptions).ToList(); Assert.Single(result); - Assert.Empty(result[0].Extras); Assert.Equal(7, result[0].AlternateVersions.Count); } @@ -151,15 +129,10 @@ namespace Jellyfin.Naming.Tests.Video }; var result = VideoListResolver.Resolve( - files.Select(i => new FileSystemMetadata - { - IsDirectory = false, - FullName = i - }).ToList(), + files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType().ToList(), _namingOptions).ToList(); Assert.Equal(9, result.Count); - Assert.Empty(result[0].Extras); Assert.Empty(result[0].AlternateVersions); } @@ -176,15 +149,10 @@ namespace Jellyfin.Naming.Tests.Video }; var result = VideoListResolver.Resolve( - files.Select(i => new FileSystemMetadata - { - IsDirectory = false, - FullName = i - }).ToList(), + files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType().ToList(), _namingOptions).ToList(); Assert.Equal(5, result.Count); - Assert.Empty(result[0].Extras); Assert.Empty(result[0].AlternateVersions); } @@ -203,15 +171,10 @@ namespace Jellyfin.Naming.Tests.Video }; var result = VideoListResolver.Resolve( - files.Select(i => new FileSystemMetadata - { - IsDirectory = false, - FullName = i - }).ToList(), + files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType().ToList(), _namingOptions).ToList(); Assert.Equal(5, result.Count); - Assert.Empty(result[0].Extras); Assert.Empty(result[0].AlternateVersions); } @@ -231,15 +194,10 @@ namespace Jellyfin.Naming.Tests.Video }; var result = VideoListResolver.Resolve( - files.Select(i => new FileSystemMetadata - { - IsDirectory = false, - FullName = i - }).ToList(), + files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType().ToList(), _namingOptions).ToList(); Assert.Single(result); - Assert.Empty(result[0].Extras); Assert.Equal(7, result[0].AlternateVersions.Count); Assert.False(result[0].AlternateVersions[2].Is3D); Assert.True(result[0].AlternateVersions[3].Is3D); @@ -262,15 +220,10 @@ namespace Jellyfin.Naming.Tests.Video }; var result = VideoListResolver.Resolve( - files.Select(i => new FileSystemMetadata - { - IsDirectory = false, - FullName = i - }).ToList(), + files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType().ToList(), _namingOptions).ToList(); Assert.Single(result); - Assert.Empty(result[0].Extras); Assert.Equal(7, result[0].AlternateVersions.Count); Assert.False(result[0].AlternateVersions[3].Is3D); Assert.True(result[0].AlternateVersions[4].Is3D); @@ -287,11 +240,7 @@ namespace Jellyfin.Naming.Tests.Video }; var result = VideoListResolver.Resolve( - files.Select(i => new FileSystemMetadata - { - IsDirectory = false, - FullName = i - }).ToList(), + files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType().ToList(), _namingOptions).ToList(); Assert.Equal(2, result.Count); @@ -312,15 +261,10 @@ namespace Jellyfin.Naming.Tests.Video }; var result = VideoListResolver.Resolve( - files.Select(i => new FileSystemMetadata - { - IsDirectory = false, - FullName = i - }).ToList(), + files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType().ToList(), _namingOptions).ToList(); Assert.Equal(7, result.Count); - Assert.Empty(result[0].Extras); Assert.Empty(result[0].AlternateVersions); } @@ -339,15 +283,10 @@ namespace Jellyfin.Naming.Tests.Video }; var result = VideoListResolver.Resolve( - files.Select(i => new FileSystemMetadata - { - IsDirectory = false, - FullName = i - }).ToList(), + files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType().ToList(), _namingOptions).ToList(); Assert.Equal(5, result.Count); - Assert.Empty(result[0].Extras); Assert.Empty(result[0].AlternateVersions); } @@ -361,15 +300,10 @@ namespace Jellyfin.Naming.Tests.Video }; var result = VideoListResolver.Resolve( - files.Select(i => new FileSystemMetadata - { - IsDirectory = false, - FullName = i - }).ToList(), + files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType().ToList(), _namingOptions).ToList(); Assert.Single(result); - Assert.Empty(result[0].Extras); Assert.Single(result[0].AlternateVersions); } @@ -383,15 +317,10 @@ namespace Jellyfin.Naming.Tests.Video }; var result = VideoListResolver.Resolve( - files.Select(i => new FileSystemMetadata - { - IsDirectory = false, - FullName = i - }).ToList(), + files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType().ToList(), _namingOptions).ToList(); Assert.Single(result); - Assert.Empty(result[0].Extras); Assert.Single(result[0].AlternateVersions); } @@ -405,15 +334,10 @@ namespace Jellyfin.Naming.Tests.Video }; var result = VideoListResolver.Resolve( - files.Select(i => new FileSystemMetadata - { - IsDirectory = false, - FullName = i - }).ToList(), + files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType().ToList(), _namingOptions).ToList(); Assert.Single(result); - Assert.Empty(result[0].Extras); Assert.Single(result[0].AlternateVersions); } @@ -427,11 +351,7 @@ namespace Jellyfin.Naming.Tests.Video }; var result = VideoListResolver.Resolve( - files.Select(i => new FileSystemMetadata - { - IsDirectory = false, - FullName = i - }).ToList(), + files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType().ToList(), _namingOptions).ToList(); Assert.Equal(2, result.Count); @@ -440,7 +360,7 @@ namespace Jellyfin.Naming.Tests.Video [Fact] public void TestEmptyList() { - var result = VideoListResolver.Resolve(new List(), _namingOptions).ToList(); + var result = VideoListResolver.Resolve(new List(), _namingOptions).ToList(); Assert.Empty(result); } diff --git a/tests/Jellyfin.Naming.Tests/Video/StackTests.cs b/tests/Jellyfin.Naming.Tests/Video/StackTests.cs index 8794d3ebe..368c3592e 100644 --- a/tests/Jellyfin.Naming.Tests/Video/StackTests.cs +++ b/tests/Jellyfin.Naming.Tests/Video/StackTests.cs @@ -22,9 +22,7 @@ namespace Jellyfin.Naming.Tests.Video "Bad Boys (2006)-trailer.mkv" }; - var resolver = GetResolver(); - - var result = resolver.ResolveFiles(files).ToList(); + var result = StackResolver.ResolveFiles(files, _namingOptions).ToList(); Assert.Single(result); TestStackInfo(result[0], "Bad Boys (2006)", 4); @@ -39,9 +37,7 @@ namespace Jellyfin.Naming.Tests.Video "Bad Boys (2007).mkv" }; - var resolver = GetResolver(); - - var result = resolver.ResolveFiles(files).ToList(); + var result = StackResolver.ResolveFiles(files, _namingOptions).ToList(); Assert.Empty(result); } @@ -55,9 +51,7 @@ namespace Jellyfin.Naming.Tests.Video "Bad Boys 2007.mkv" }; - var resolver = GetResolver(); - - var result = resolver.ResolveFiles(files).ToList(); + var result = StackResolver.ResolveFiles(files, _namingOptions).ToList(); Assert.Empty(result); } @@ -71,9 +65,7 @@ namespace Jellyfin.Naming.Tests.Video "300 (2007).mkv" }; - var resolver = GetResolver(); - - var result = resolver.ResolveFiles(files).ToList(); + var result = StackResolver.ResolveFiles(files, _namingOptions).ToList(); Assert.Empty(result); } @@ -87,9 +79,7 @@ namespace Jellyfin.Naming.Tests.Video "300 2007.mkv" }; - var resolver = GetResolver(); - - var result = resolver.ResolveFiles(files).ToList(); + var result = StackResolver.ResolveFiles(files, _namingOptions).ToList(); Assert.Empty(result); } @@ -103,9 +93,7 @@ namespace Jellyfin.Naming.Tests.Video "Star Trek 2- The wrath of khan.mkv" }; - var resolver = GetResolver(); - - var result = resolver.ResolveFiles(files).ToList(); + var result = StackResolver.ResolveFiles(files, _namingOptions).ToList(); Assert.Empty(result); } @@ -119,9 +107,7 @@ namespace Jellyfin.Naming.Tests.Video "Red Riding in the Year of Our Lord 1974 (2009).mkv" }; - var resolver = GetResolver(); - - var result = resolver.ResolveFiles(files).ToList(); + var result = StackResolver.ResolveFiles(files, _namingOptions).ToList(); Assert.Empty(result); } @@ -135,16 +121,14 @@ namespace Jellyfin.Naming.Tests.Video "d:/movies/300 2006 part2.mkv" }; - var resolver = GetResolver(); - - var result = resolver.ResolveFiles(files).ToList(); + var result = StackResolver.ResolveFiles(files, _namingOptions).ToList(); Assert.Single(result); TestStackInfo(result[0], "300 2006", 2); } [Fact] - public void TestDirtyNames() + public void ResolveFiles_GivenPartInMiddleOfName_ReturnsNoStack() { var files = new[] { @@ -155,16 +139,13 @@ namespace Jellyfin.Naming.Tests.Video "Bad Boys (2006)-trailer.mkv" }; - var resolver = GetResolver(); + var result = StackResolver.ResolveFiles(files, _namingOptions).ToList(); - var result = resolver.ResolveFiles(files).ToList(); - - Assert.Single(result); - TestStackInfo(result[0], "Bad Boys (2006).stv.unrated.multi.1080p.bluray.x264-rough", 4); + Assert.Empty(result); } [Fact] - public void TestNumberedFiles() + public void ResolveFiles_FileNamesWithMissingPartType_ReturnsNoStack() { var files = new[] { @@ -175,9 +156,7 @@ namespace Jellyfin.Naming.Tests.Video "Bad Boys (2006)-trailer.mkv" }; - var resolver = GetResolver(); - - var result = resolver.ResolveFiles(files).ToList(); + var result = StackResolver.ResolveFiles(files, _namingOptions).ToList(); Assert.Empty(result); } @@ -194,9 +173,7 @@ namespace Jellyfin.Naming.Tests.Video "300 (2006)-trailer.mkv" }; - var resolver = GetResolver(); - - var result = resolver.ResolveFiles(files).ToList(); + var result = StackResolver.ResolveFiles(files, _namingOptions).ToList(); Assert.Single(result); TestStackInfo(result[0], "300 (2006)", 4); @@ -214,9 +191,7 @@ namespace Jellyfin.Naming.Tests.Video "Bad Boys (2006)-trailer.mkv" }; - var resolver = GetResolver(); - - var result = resolver.ResolveFiles(files).ToList(); + var result = StackResolver.ResolveFiles(files, _namingOptions).ToList(); Assert.Single(result); TestStackInfo(result[0], "Bad Boys (2006)", 3); @@ -238,9 +213,7 @@ namespace Jellyfin.Naming.Tests.Video "300 (2006)-trailer.mkv" }; - var resolver = GetResolver(); - - var result = resolver.ResolveFiles(files).ToList(); + var result = StackResolver.ResolveFiles(files, _namingOptions).ToList(); Assert.Equal(2, result.Count); TestStackInfo(result[1], "Bad Boys (2006)", 4); @@ -256,9 +229,7 @@ namespace Jellyfin.Naming.Tests.Video "blah blah - cd 2" }; - var resolver = GetResolver(); - - var result = resolver.ResolveDirectories(files).ToList(); + var result = StackResolver.ResolveDirectories(files, _namingOptions).ToList(); Assert.Single(result); TestStackInfo(result[0], "blah blah", 2); @@ -275,9 +246,7 @@ namespace Jellyfin.Naming.Tests.Video "300-trailer.mkv" }; - var resolver = GetResolver(); - - var result = resolver.ResolveFiles(files).ToList(); + var result = StackResolver.ResolveFiles(files, _namingOptions).ToList(); Assert.Single(result); @@ -297,9 +266,7 @@ namespace Jellyfin.Naming.Tests.Video "Avengers part3.mkv" }; - var resolver = GetResolver(); - - var result = resolver.ResolveFiles(files).ToList(); + var result = StackResolver.ResolveFiles(files, _namingOptions).ToList(); Assert.Equal(2, result.Count); @@ -328,9 +295,7 @@ namespace Jellyfin.Naming.Tests.Video "300-trailer.mkv" }; - var resolver = GetResolver(); - - var result = resolver.ResolveFiles(files).ToList(); + var result = StackResolver.ResolveFiles(files, _namingOptions).ToList(); Assert.Equal(3, result.Count); @@ -354,9 +319,7 @@ namespace Jellyfin.Naming.Tests.Video "300 (2006)-trailer.mkv" }; - var resolver = GetResolver(); - - var result = resolver.ResolveFiles(files).ToList(); + var result = StackResolver.ResolveFiles(files, _namingOptions).ToList(); Assert.Single(result); @@ -375,9 +338,7 @@ namespace Jellyfin.Naming.Tests.Video new FileSystemMetadata { FullName = "300 (2006) part1", IsDirectory = true } }; - var resolver = GetResolver(); - - var result = resolver.Resolve(files).ToList(); + var result = StackResolver.Resolve(files, _namingOptions).ToList(); Assert.Equal(2, result.Count); TestStackInfo(result[0], "300 (2006)", 3); @@ -397,9 +358,7 @@ namespace Jellyfin.Naming.Tests.Video "Harry Potter and the Deathly Hallows 4.mkv" }; - var resolver = GetResolver(); - - var result = resolver.ResolveFiles(files).ToList(); + var result = StackResolver.ResolveFiles(files, _namingOptions).ToList(); Assert.Empty(result); } @@ -414,9 +373,7 @@ namespace Jellyfin.Naming.Tests.Video "Neverland (2011)[720p][PG][Voted 6.5][Family-Fantasy]part2.mkv" }; - var resolver = GetResolver(); - - var result = resolver.ResolveFiles(files).ToList(); + var result = StackResolver.ResolveFiles(files, _namingOptions).ToList(); Assert.Single(result); Assert.Equal(2, result[0].Files.Count); @@ -432,9 +389,7 @@ namespace Jellyfin.Naming.Tests.Video @"M:/Movies (DVD)/Movies (Musical)/The Sound of Music/The Sound of Music (1965) (Disc 02)" }; - var resolver = GetResolver(); - - var result = resolver.ResolveDirectories(files).ToList(); + var result = StackResolver.ResolveDirectories(files, _namingOptions).ToList(); Assert.Single(result); Assert.Equal(2, result[0].Files.Count); @@ -445,10 +400,5 @@ namespace Jellyfin.Naming.Tests.Video Assert.Equal(fileCount, stack.Files.Count); Assert.Equal(name, stack.Name); } - - private StackResolver GetResolver() - { - return new StackResolver(_namingOptions); - } } } diff --git a/tests/Jellyfin.Naming.Tests/Video/VideoListResolverTests.cs b/tests/Jellyfin.Naming.Tests/Video/VideoListResolverTests.cs index 9e0776c3c..b76187842 100644 --- a/tests/Jellyfin.Naming.Tests/Video/VideoListResolverTests.cs +++ b/tests/Jellyfin.Naming.Tests/Video/VideoListResolverTests.cs @@ -2,6 +2,7 @@ using System; using System.Linq; using Emby.Naming.Common; using Emby.Naming.Video; +using MediaBrowser.Model.Entities; using MediaBrowser.Model.IO; using Xunit; @@ -41,23 +42,28 @@ namespace Jellyfin.Naming.Tests.Video }; var result = VideoListResolver.Resolve( - files.Select(i => new FileSystemMetadata - { - IsDirectory = false, - FullName = i - }).ToList(), + files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType().ToList(), _namingOptions).ToList(); - Assert.Equal(5, result.Count); + Assert.Equal(11, result.Count); var batman = result.FirstOrDefault(x => string.Equals(x.Name, "Batman", StringComparison.Ordinal)); Assert.NotNull(batman); Assert.Equal(3, batman!.Files.Count); - Assert.Equal(3, batman!.Extras.Count); var harry = result.FirstOrDefault(x => string.Equals(x.Name, "Harry Potter and the Deathly Hallows", StringComparison.Ordinal)); Assert.NotNull(harry); Assert.Equal(4, harry!.Files.Count); - Assert.Equal(2, harry!.Extras.Count); + + Assert.False(result[2].ExtraType.HasValue); + + Assert.Equal(ExtraType.Trailer, result[3].ExtraType); + Assert.Equal(ExtraType.Trailer, result[4].ExtraType); + Assert.Equal(ExtraType.DeletedScene, result[5].ExtraType); + Assert.Equal(ExtraType.Sample, result[6].ExtraType); + Assert.Equal(ExtraType.Trailer, result[7].ExtraType); + Assert.Equal(ExtraType.Trailer, result[8].ExtraType); + Assert.Equal(ExtraType.Trailer, result[9].ExtraType); + Assert.Equal(ExtraType.Trailer, result[10].ExtraType); } [Fact] @@ -70,11 +76,7 @@ namespace Jellyfin.Naming.Tests.Video }; var result = VideoListResolver.Resolve( - files.Select(i => new FileSystemMetadata - { - IsDirectory = false, - FullName = i - }).ToList(), + files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType().ToList(), _namingOptions).ToList(); Assert.Single(result); @@ -90,14 +92,12 @@ namespace Jellyfin.Naming.Tests.Video }; var result = VideoListResolver.Resolve( - files.Select(i => new FileSystemMetadata - { - IsDirectory = false, - FullName = i - }).ToList(), + files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType().ToList(), _namingOptions).ToList(); - Assert.Single(result); + Assert.Equal(2, result.Count); + Assert.False(result[0].ExtraType.HasValue); + Assert.Equal(ExtraType.Trailer, result[1].ExtraType); } [Fact] @@ -110,14 +110,12 @@ namespace Jellyfin.Naming.Tests.Video }; var result = VideoListResolver.Resolve( - files.Select(i => new FileSystemMetadata - { - IsDirectory = false, - FullName = i - }).ToList(), + files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType().ToList(), _namingOptions).ToList(); - Assert.Single(result); + Assert.Equal(2, result.Count); + Assert.False(result[0].ExtraType.HasValue); + Assert.Equal(ExtraType.Trailer, result[1].ExtraType); } [Fact] @@ -131,34 +129,51 @@ namespace Jellyfin.Naming.Tests.Video }; var result = VideoListResolver.Resolve( - files.Select(i => new FileSystemMetadata - { - IsDirectory = false, - FullName = i - }).ToList(), + files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType().ToList(), _namingOptions).ToList(); - Assert.Single(result); + Assert.Equal(3, result.Count); + Assert.False(result[0].ExtraType.HasValue); + Assert.Equal(ExtraType.Trailer, result[1].ExtraType); + Assert.Equal(ExtraType.Trailer, result[2].ExtraType); } [Fact] - public void TestDifferentNames() + public void Resolve_SameNameAndYear_ReturnsSingleItem() { var files = new[] { "Looper (2012)-trailer.mkv", + "Looper 2012-trailer.mkv", "Looper.2012.bluray.720p.x264.mkv" }; var result = VideoListResolver.Resolve( - files.Select(i => new FileSystemMetadata - { - IsDirectory = false, - FullName = i - }).ToList(), + files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType().ToList(), _namingOptions).ToList(); - Assert.Single(result); + Assert.Equal(3, result.Count); + Assert.False(result[0].ExtraType.HasValue); + Assert.Equal(ExtraType.Trailer, result[1].ExtraType); + Assert.Equal(ExtraType.Trailer, result[2].ExtraType); + } + + [Fact] + public void Resolve_TrailerMatchesFolderName_ReturnsSingleItem() + { + var files = new[] + { + "/movies/Looper (2012)/Looper (2012)-trailer.mkv", + "/movies/Looper (2012)/Looper.bluray.720p.x264.mkv" + }; + + var result = VideoListResolver.Resolve( + files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType().ToList(), + _namingOptions).ToList(); + + Assert.Equal(2, result.Count); + Assert.False(result[0].ExtraType.HasValue); + Assert.Equal(ExtraType.Trailer, result[1].ExtraType); } [Fact] @@ -175,11 +190,7 @@ namespace Jellyfin.Naming.Tests.Video }; var result = VideoListResolver.Resolve( - files.Select(i => new FileSystemMetadata - { - IsDirectory = false, - FullName = i - }).ToList(), + files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType().ToList(), _namingOptions).ToList(); Assert.Equal(5, result.Count); @@ -195,11 +206,7 @@ namespace Jellyfin.Naming.Tests.Video }; var result = VideoListResolver.Resolve( - files.Select(i => new FileSystemMetadata - { - IsDirectory = true, - FullName = i - }).ToList(), + files.Select(i => VideoResolver.Resolve(i, true, _namingOptions)).OfType().ToList(), _namingOptions).ToList(); Assert.Single(result); @@ -216,11 +223,7 @@ namespace Jellyfin.Naming.Tests.Video }; var result = VideoListResolver.Resolve( - files.Select(i => new FileSystemMetadata - { - IsDirectory = true, - FullName = i - }).ToList(), + files.Select(i => VideoResolver.Resolve(i, true, _namingOptions)).OfType().ToList(), _namingOptions).ToList(); Assert.Equal(2, result.Count); @@ -233,39 +236,18 @@ namespace Jellyfin.Naming.Tests.Video { @"No (2012) part1.mp4", @"No (2012) part2.mp4", - @"No (2012) part1-trailer.mp4" - }; - - var result = VideoListResolver.Resolve( - files.Select(i => new FileSystemMetadata - { - IsDirectory = false, - FullName = i - }).ToList(), - _namingOptions).ToList(); - - Assert.Single(result); - } - - [Fact] - public void TestStackedWithTrailer2() - { - var files = new[] - { - @"No (2012) part1.mp4", - @"No (2012) part2.mp4", + @"No (2012) part1-trailer.mp4", @"No (2012)-trailer.mp4" }; var result = VideoListResolver.Resolve( - files.Select(i => new FileSystemMetadata - { - IsDirectory = false, - FullName = i - }).ToList(), + files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType().ToList(), _namingOptions).ToList(); - Assert.Single(result); + Assert.Equal(3, result.Count); + Assert.False(result[0].ExtraType.HasValue); + Assert.Equal(ExtraType.Trailer, result[1].ExtraType); + Assert.Equal(ExtraType.Trailer, result[2].ExtraType); } [Fact] @@ -276,18 +258,18 @@ namespace Jellyfin.Naming.Tests.Video @"/Movies/Top Gun (1984)/movie.mp4", @"/Movies/Top Gun (1984)/Top Gun (1984)-trailer.mp4", @"/Movies/Top Gun (1984)/Top Gun (1984)-trailer2.mp4", - @"trailer.mp4" + @"/Movies/trailer.mp4" }; var result = VideoListResolver.Resolve( - files.Select(i => new FileSystemMetadata - { - IsDirectory = false, - FullName = i - }).ToList(), + files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType().ToList(), _namingOptions).ToList(); - Assert.Single(result); + Assert.Equal(4, result.Count); + Assert.False(result[0].ExtraType.HasValue); + Assert.Equal(ExtraType.Trailer, result[1].ExtraType); + Assert.Equal(ExtraType.Trailer, result[2].ExtraType); + Assert.Equal(ExtraType.Trailer, result[3].ExtraType); } [Fact] @@ -302,11 +284,7 @@ namespace Jellyfin.Naming.Tests.Video }; var result = VideoListResolver.Resolve( - files.Select(i => new FileSystemMetadata - { - IsDirectory = false, - FullName = i - }).ToList(), + files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType().ToList(), _namingOptions).ToList(); Assert.Equal(2, result.Count); @@ -321,11 +299,7 @@ namespace Jellyfin.Naming.Tests.Video }; var result = VideoListResolver.Resolve( - files.Select(i => new FileSystemMetadata - { - IsDirectory = false, - FullName = i - }).ToList(), + files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType().ToList(), _namingOptions).ToList(); Assert.Single(result); @@ -340,11 +314,7 @@ namespace Jellyfin.Naming.Tests.Video }; var result = VideoListResolver.Resolve( - files.Select(i => new FileSystemMetadata - { - IsDirectory = false, - FullName = i - }).ToList(), + files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType().ToList(), _namingOptions).ToList(); Assert.Single(result); @@ -360,11 +330,7 @@ namespace Jellyfin.Naming.Tests.Video }; var result = VideoListResolver.Resolve( - files.Select(i => new FileSystemMetadata - { - IsDirectory = false, - FullName = i - }).ToList(), + files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType().ToList(), _namingOptions).ToList(); Assert.Single(result); @@ -380,11 +346,7 @@ namespace Jellyfin.Naming.Tests.Video }; var result = VideoListResolver.Resolve( - files.Select(i => new FileSystemMetadata - { - IsDirectory = false, - FullName = i - }).ToList(), + files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType().ToList(), _namingOptions).ToList(); Assert.Equal(2, result.Count); @@ -396,40 +358,34 @@ namespace Jellyfin.Naming.Tests.Video var files = new[] { @"/Server/Despicable Me/Despicable Me (2010).mkv", - @"/Server/Despicable Me/movie-trailer.mkv" + @"/Server/Despicable Me/trailer.mkv" }; var result = VideoListResolver.Resolve( - files.Select(i => new FileSystemMetadata - { - IsDirectory = false, - FullName = i - }).ToList(), + files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType().ToList(), _namingOptions).ToList(); - Assert.Single(result); + Assert.Equal(2, result.Count); + Assert.False(result[0].ExtraType.HasValue); + Assert.Equal(ExtraType.Trailer, result[1].ExtraType); } [Fact] - public void TestTrailerFalsePositives() + public void Resolve_TrailerInTrailersFolder_ReturnsCorrectExtraType() { var files = new[] { - @"/Server/Despicable Me/Skyscraper (2018) - Big Game Spot.mkv", - @"/Server/Despicable Me/Skyscraper (2018) - Trailer.mkv", - @"/Server/Despicable Me/Baywatch (2017) - Big Game Spot.mkv", - @"/Server/Despicable Me/Baywatch (2017) - Trailer.mkv" + @"/Server/Despicable Me/Despicable Me (2010).mkv", + @"/Server/Despicable Me/trailers/some title.mkv" }; var result = VideoListResolver.Resolve( - files.Select(i => new FileSystemMetadata - { - IsDirectory = false, - FullName = i - }).ToList(), + files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType().ToList(), _namingOptions).ToList(); - Assert.Equal(4, result.Count); + Assert.Equal(2, result.Count); + Assert.False(result[0].ExtraType.HasValue); + Assert.Equal(ExtraType.Trailer, result[1].ExtraType); } [Fact] @@ -442,20 +398,18 @@ namespace Jellyfin.Naming.Tests.Video }; var result = VideoListResolver.Resolve( - files.Select(i => new FileSystemMetadata - { - IsDirectory = false, - FullName = i - }).ToList(), + files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType().ToList(), _namingOptions).ToList(); - Assert.Single(result); + Assert.Equal(2, result.Count); + Assert.False(result[0].ExtraType.HasValue); + Assert.Equal(ExtraType.Trailer, result[1].ExtraType); } [Fact] public void TestDirectoryStack() { - var stack = new FileStack(); + var stack = new FileStack(string.Empty, false, Array.Empty()); Assert.False(stack.ContainsFile("XX", true)); } } diff --git a/tests/Jellyfin.Naming.Tests/Video/VideoResolverTests.cs b/tests/Jellyfin.Naming.Tests/Video/VideoResolverTests.cs index ac5a7a21e..33a99e107 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; @@ -11,148 +10,134 @@ namespace Jellyfin.Naming.Tests.Video { 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 - JEFF/Rain Man 1988 REMASTERED 1080p BluRay x264 AAC - JEFF.mp4", container: "mp4", name: "Rain Man", - year: 1988) - }; + year: 1988)); + + return data; } [Theory] diff --git a/tests/Jellyfin.Networking.Tests/Jellyfin.Networking.Tests.csproj b/tests/Jellyfin.Networking.Tests/Jellyfin.Networking.Tests.csproj index 5fa2ecfe9..7f9b60b9e 100644 --- a/tests/Jellyfin.Networking.Tests/Jellyfin.Networking.Tests.csproj +++ b/tests/Jellyfin.Networking.Tests/Jellyfin.Networking.Tests.csproj @@ -6,24 +6,24 @@ - net5.0 + net6.0 false ../jellyfin-tests.ruleset - + - + - + diff --git a/tests/Jellyfin.Networking.Tests/NetworkManagerTests.cs b/tests/Jellyfin.Networking.Tests/NetworkManagerTests.cs index 1cad625b7..61f913252 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 97c14d463..6b9397437 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) @@ -476,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 index d9e33617b..4338c812d 100644 --- a/tests/Jellyfin.Providers.Tests/Jellyfin.Providers.Tests.csproj +++ b/tests/Jellyfin.Providers.Tests/Jellyfin.Providers.Tests.csproj @@ -1,13 +1,19 @@ - net5.0 + net6.0 false ../jellyfin-tests.ruleset - + + PreserveNewest + + + + + @@ -23,7 +29,7 @@ - + diff --git a/tests/Jellyfin.Providers.Tests/Manager/ItemImageProviderTests.cs b/tests/Jellyfin.Providers.Tests/Manager/ItemImageProviderTests.cs new file mode 100644 index 000000000..c0931dbcf --- /dev/null +++ b/tests/Jellyfin.Providers.Tests/Manager/ItemImageProviderTests.cs @@ -0,0 +1,646 @@ +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() + { + ValidateImages_Test(ImageType.Primary, 0, true, 0, false, 0); + } + + private static TheoryData GetImageTypesWithCount() + { + var theoryTypes = new TheoryData + { + // minimal test cases that hit different handling + { ImageType.Primary, 1 }, + { ImageType.Backdrop, 2 } + }; + + return theoryTypes; + } + + [Theory] + [MemberData(nameof(GetImageTypesWithCount))] + public void ValidateImages_EmptyItemAndPopulatedProviders_AddsImages(ImageType imageType, int imageCount) + { + ValidateImages_Test(imageType, 0, true, imageCount, true, imageCount); + } + + [Theory] + [MemberData(nameof(GetImageTypesWithCount))] + public void ValidateImages_PopulatedItemWithGoodPathsAndEmptyProviders_NoChange(ImageType imageType, int imageCount) + { + ValidateImages_Test(imageType, imageCount, true, 0, false, imageCount); + } + + [Theory] + [MemberData(nameof(GetImageTypesWithCount))] + public void ValidateImages_PopulatedItemWithBadPathsAndEmptyProviders_RemovesImage(ImageType imageType, int imageCount) + { + ValidateImages_Test(imageType, imageCount, false, 0, true, 0); + } + + private void ValidateImages_Test(ImageType imageType, int initialImageCount, bool initialPathsValid, int providerImageCount, bool expectedChange, int expectedImageCount) + { + var item = GetItemWithImages(imageType, initialImageCount, initialPathsValid); + + var imageProvider = GetImageProvider(imageType, providerImageCount, true); + + var itemImageProvider = GetItemImageProvider(null, null); + var actualChange = itemImageProvider.ValidateImages(item, new[] { imageProvider }, null); + + Assert.Equal(expectedChange, actualChange); + Assert.Equal(expectedImageCount, item.GetImages(imageType).Count()); + } + + [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] + [InlineData(ImageType.Primary, 1, false)] + [InlineData(ImageType.Backdrop, 2, false)] + [InlineData(ImageType.Primary, 1, true)] + [InlineData(ImageType.Backdrop, 2, true)] + public void MergeImages_PopulatedItemWithGoodPathsAndSameNewImages_ResetIfTimeChanges(ImageType imageType, int imageCount, bool updateTime) + { + var oldTime = new DateTime(1970, 1, 1); + var updatedTime = updateTime ? new DateTime(2021, 1, 1) : oldTime; + + 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); + + if (updateTime) + { + Assert.True(changed); + // before and after paths are the same, verify updated by size reset to 0 + var typedImages = item.GetImages(imageType).ToArray(); + Assert.Equal(imageCount, typedImages.Length); + foreach (var image in typedImages) + { + Assert.Equal(updatedTime, image.DateModified); + Assert.Equal(0, image.Height); + Assert.Equal(0, image.Width); + } + } + else + { + Assert.False(changed); + } + } + + [Theory] + [InlineData(ImageType.Primary, 0)] + [InlineData(ImageType.Primary, 1)] + [InlineData(ImageType.Backdrop, 2)] + public void RemoveImages_DeletesImages_WhenFound(ImageType imageType, int imageCount) + { + var item = GetItemWithImages(imageType, imageCount, false); + + var mockFileSystem = new Mock(MockBehavior.Strict); + if (imageCount > 0) + { + mockFileSystem.Setup(fs => fs.DeleteFile("invalid path 0")) + .Verifiable(); + } + + if (imageCount > 1) + { + mockFileSystem.Setup(fs => fs.DeleteFile("invalid path 1")) + .Verifiable(); + } + + var itemImageProvider = GetItemImageProvider(Mock.Of(), mockFileSystem); + var result = itemImageProvider.RemoveImages(item); + + Assert.Equal(imageCount != 0, result); + Assert.Empty(item.GetImages(imageType)); + mockFileSystem.Verify(); + } + + [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 + }; + } + + 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 + }; + } + + 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 + }; + } + + 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()); + } + + [Theory] + [InlineData(9, false)] + [InlineData(10, true)] + [InlineData(null, true)] + public async void RefreshImages_ProviderRemote_FiltersByWidth(int? remoteImageWidth, bool expectedToUpdate) + { + var imageType = ImageType.Primary; + + var item = new Video(); + + var libraryOptions = new LibraryOptions + { + TypeOptions = new[] + { + new TypeOptions + { + Type = item.GetType().Name, + ImageOptions = new[] + { + new ImageOption + { + Type = imageType, + MinWidth = 10 + } + } + } + } + }; + + 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()); + + // set width on image from remote + var remoteInfo = new[] + { + new RemoteImageInfo() + { + Type = imageType, + Url = "image url", + Width = remoteImageWidth + } + }; + + 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.Equal(expectedToUpdate, result.UpdateType.HasFlag(ItemUpdateType.ImageUpdate)); + } + + 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, + } + } + } + } + }; + } + } +} diff --git a/tests/Jellyfin.Providers.Tests/Manager/MetadataServiceTests.cs b/tests/Jellyfin.Providers.Tests/Manager/MetadataServiceTests.cs new file mode 100644 index 000000000..b74b331b7 --- /dev/null +++ b/tests/Jellyfin.Providers.Tests/Manager/MetadataServiceTests.cs @@ -0,0 +1,378 @@ +using System; +using System.Collections.Generic; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.Audio; +using MediaBrowser.Controller.Entities.Movies; +using MediaBrowser.Controller.Entities.TV; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.Entities; +using MediaBrowser.Providers.Manager; +using Xunit; + +namespace Jellyfin.Providers.Tests.Manager +{ + public class MetadataServiceTests + { + [Theory] + [InlineData(false, false)] + [InlineData(true, false)] + [InlineData(true, true)] + public void MergeBaseItemData_MergeMetadataSettings_MergesWhenSet(bool mergeMetadataSettings, bool defaultDate) + { + var newLocked = new[] { MetadataField.Cast }; + var newString = "new"; + var newDate = DateTime.Now; + + var oldLocked = new[] { MetadataField.Genres }; + var oldString = "old"; + var oldDate = DateTime.UnixEpoch; + + var source = new MetadataResult + { + Item = new Movie + { + LockedFields = newLocked, + IsLocked = true, + PreferredMetadataCountryCode = newString, + PreferredMetadataLanguage = newString, + DateCreated = newDate + } + }; + if (defaultDate) + { + source.Item.DateCreated = default; + } + + var target = new MetadataResult + { + Item = new Movie + { + LockedFields = oldLocked, + IsLocked = false, + PreferredMetadataCountryCode = oldString, + PreferredMetadataLanguage = oldString, + DateCreated = oldDate + } + }; + + MetadataService.MergeBaseItemData(source, target, Array.Empty(), true, mergeMetadataSettings); + + if (mergeMetadataSettings) + { + Assert.Equal(newLocked, target.Item.LockedFields); + Assert.True(target.Item.IsLocked); + Assert.Equal(newString, target.Item.PreferredMetadataCountryCode); + Assert.Equal(newString, target.Item.PreferredMetadataLanguage); + Assert.Equal(defaultDate ? oldDate : newDate, target.Item.DateCreated); + } + else + { + Assert.Equal(oldLocked, target.Item.LockedFields); + Assert.False(target.Item.IsLocked); + Assert.Equal(oldString, target.Item.PreferredMetadataCountryCode); + Assert.Equal(oldString, target.Item.PreferredMetadataLanguage); + Assert.Equal(oldDate, target.Item.DateCreated); + } + } + + [Theory] + [InlineData("Name", MetadataField.Name, false)] + [InlineData("OriginalTitle", null, false)] + [InlineData("OfficialRating", MetadataField.OfficialRating)] + [InlineData("CustomRating")] + [InlineData("Tagline")] + [InlineData("Overview", MetadataField.Overview)] + [InlineData("DisplayOrder", null, false)] + [InlineData("ForcedSortName", null, false)] + public void MergeBaseItemData_StringField_ReplacesAppropriately(string propName, MetadataField? lockField = null, bool replacesWithEmpty = true) + { + var oldValue = "Old"; + var newValue = "New"; + + // Use type Series to hit DisplayOrder + Assert.False(TestMergeBaseItemData(propName, oldValue, newValue, null, false, out _)); + if (lockField != null) + { + Assert.False(TestMergeBaseItemData(propName, oldValue, newValue, lockField, true, out _)); + Assert.False(TestMergeBaseItemData(propName, null, newValue, lockField, false, out _)); + Assert.False(TestMergeBaseItemData(propName, string.Empty, newValue, lockField, false, out _)); + } + + Assert.True(TestMergeBaseItemData(propName, oldValue, newValue, null, true, out _)); + Assert.True(TestMergeBaseItemData(propName, null, newValue, null, false, out _)); + Assert.True(TestMergeBaseItemData(propName, string.Empty, newValue, null, false, out _)); + + var replacedWithEmpty = TestMergeBaseItemData(propName, oldValue, string.Empty, null, true, out _); + Assert.Equal(replacesWithEmpty, replacedWithEmpty); + } + + [Theory] + [InlineData("Genres", MetadataField.Genres)] + [InlineData("Studios", MetadataField.Studios)] + [InlineData("Tags", MetadataField.Tags)] + [InlineData("ProductionLocations", MetadataField.ProductionLocations)] + [InlineData("AlbumArtists")] + public void MergeBaseItemData_StringArrayField_ReplacesAppropriately(string propName, MetadataField? lockField = null) + { + // Note that arrays are replaced, not merged + var oldValue = new[] { "Old" }; + var newValue = new[] { "New" }; + + // Use type Audio to hit AlbumArtists + Assert.False(TestMergeBaseItemData(propName, oldValue, newValue, null, false, out _)); + if (lockField != null) + { + Assert.False(TestMergeBaseItemData(propName, oldValue, newValue, lockField, true, out _)); + Assert.False(TestMergeBaseItemData(propName, Array.Empty(), newValue, lockField, false, out _)); + } + + Assert.True(TestMergeBaseItemData(propName, oldValue, newValue, null, true, out _)); + Assert.True(TestMergeBaseItemData(propName, Array.Empty(), newValue, null, false, out _)); + + Assert.True(TestMergeBaseItemData(propName, oldValue, Array.Empty(), null, true, out _)); + } + + private static TheoryData MergeBaseItemData_SimpleField_ReplacesAppropriately_TestData() + => new() + { + { "IndexNumber", 1, 2 }, + { "ParentIndexNumber", 1, 2 }, + { "ProductionYear", 1, 2 }, + { "CommunityRating", 1.0f, 2.0f }, + { "CriticRating", 1.0f, 2.0f }, + { "EndDate", DateTime.UnixEpoch, DateTime.Now }, + { "PremiereDate", DateTime.UnixEpoch, DateTime.Now }, + { "Video3DFormat", Video3DFormat.HalfSideBySide, Video3DFormat.FullSideBySide } + }; + + [Theory] + [MemberData(nameof(MergeBaseItemData_SimpleField_ReplacesAppropriately_TestData))] + public void MergeBaseItemData_SimpleField_ReplacesAppropriately(string propName, object oldValue, object newValue) + { + // Use type Movie to allow testing of Video3DFormat + Assert.False(TestMergeBaseItemData(propName, oldValue, newValue, null, false, out _)); + + Assert.True(TestMergeBaseItemData(propName, oldValue, newValue, null, true, out _)); + Assert.True(TestMergeBaseItemData(propName, null, newValue, null, false, out _)); + + Assert.True(TestMergeBaseItemData(propName, oldValue, null, null, true, out _)); + } + + [Fact] + public void MergeBaseItemData_MergeTrailers_ReplacesAppropriately() + { + string propName = "RemoteTrailers"; + var oldValue = new[] + { + new MediaUrl + { + Name = "Name 1", + Url = "URL 1" + } + }; + var newValue = new[] + { + new MediaUrl + { + Name = "Name 2", + Url = "URL 2" + } + }; + + Assert.False(TestMergeBaseItemData(propName, oldValue, newValue, null, false, out _)); + + Assert.True(TestMergeBaseItemData(propName, oldValue, newValue, null, true, out _)); + Assert.True(TestMergeBaseItemData(propName, Array.Empty(), newValue, null, false, out _)); + + Assert.True(TestMergeBaseItemData(propName, oldValue, Array.Empty(), null, true, out _)); + } + + [Fact] + public void MergeBaseItemData_ProviderIds_MergesAppropriately() + { + var propName = "ProviderIds"; + var oldValue = new Dictionary + { + { "provider 1", "id 1" } + }; + + // overwrite provider id + var overwriteNewValue = new Dictionary + { + { "provider 1", "id 2" } + }; + Assert.False(TestMergeBaseItemData(propName, new Dictionary(oldValue), overwriteNewValue, null, false, out _)); + TestMergeBaseItemData(propName, new Dictionary(oldValue), overwriteNewValue, null, true, out var overwritten); + Assert.Equal(overwriteNewValue, overwritten); + + // merge without overwriting + var mergeNewValue = new Dictionary + { + { "provider 1", "id 2" }, + { "provider 2", "id 3" } + }; + TestMergeBaseItemData(propName, new Dictionary(oldValue), mergeNewValue, null, false, out var merged); + var actual = (Dictionary)merged!; + Assert.Equal("id 1", actual["provider 1"]); + Assert.Equal("id 3", actual["provider 2"]); + + // empty source results in no change + TestMergeBaseItemData(propName, new Dictionary(oldValue), new Dictionary(), null, true, out var notOverwritten); + Assert.Equal(oldValue, notOverwritten); + } + + [Fact] + public void MergeBaseItemData_MergePeople_MergesAppropriately() + { + // PersonInfo in list is changed by merge, create new for every call + List GetOldValue() + => new() + { + new PersonInfo + { + Name = "Name 1", + ProviderIds = new Dictionary + { + { "Provider 1", "1234" } + } + } + }; + + object? result; + List actual; + + // overwrite provider id + var overwriteNewValue = new List + { + new() + { + Name = "Name 2" + } + }; + Assert.False(TestMergeBaseItemDataPerson(GetOldValue(), overwriteNewValue, null, false, out result)); + // People not already in target are not merged into it from source + actual = (List)result!; + Assert.Single(actual); + Assert.Equal("Name 1", actual[0].Name); + + Assert.True(TestMergeBaseItemDataPerson(GetOldValue(), overwriteNewValue, null, true, out _)); + Assert.True(TestMergeBaseItemDataPerson(new List(), overwriteNewValue, null, false, out _)); + Assert.True(TestMergeBaseItemDataPerson(null, overwriteNewValue, null, false, out _)); + + Assert.False(TestMergeBaseItemDataPerson(GetOldValue(), overwriteNewValue, MetadataField.Cast, true, out _)); + + // providers merge but don't overwrite existing keys + var mergeNewValue = new List + { + new() + { + Name = "Name 1", + ProviderIds = new Dictionary + { + { "Provider 1", "5678" }, + { "Provider 2", "5678" } + } + } + }; + TestMergeBaseItemDataPerson(GetOldValue(), mergeNewValue, null, false, out result); + actual = (List)result!; + Assert.Single(actual); + Assert.Equal("Name 1", actual[0].Name); + Assert.Equal(2, actual[0].ProviderIds.Count); + Assert.Equal("1234", actual[0].ProviderIds["Provider 1"]); + Assert.Equal("5678", actual[0].ProviderIds["Provider 2"]); + + // picture adds if missing but won't overwrite (forcing overwrites entire list, not entries in merged PersonInfo) + var mergePicture1 = new List + { + new() + { + Name = "Name 1", + ImageUrl = "URL 1" + } + }; + TestMergeBaseItemDataPerson(GetOldValue(), mergePicture1, null, false, out result); + actual = (List)result!; + Assert.Single(actual); + Assert.Equal("Name 1", actual[0].Name); + Assert.Equal("URL 1", actual[0].ImageUrl); + var mergePicture2 = new List + { + new() + { + Name = "Name 1", + ImageUrl = "URL 2" + } + }; + TestMergeBaseItemDataPerson(mergePicture1, mergePicture2, null, false, out result); + actual = (List)result!; + Assert.Single(actual); + Assert.Equal("Name 1", actual[0].Name); + Assert.Equal("URL 1", actual[0].ImageUrl); + + // empty source can be forced to overwrite a target with data + Assert.True(TestMergeBaseItemDataPerson(GetOldValue(), new List(), null, true, out _)); + } + + private static bool TestMergeBaseItemDataPerson(List? oldValue, List? newValue, MetadataField? lockField, bool replaceData, out object? actualValue) + { + var source = new MetadataResult + { + Item = new Movie(), + People = newValue + }; + + var target = new MetadataResult + { + Item = new Movie(), + People = oldValue + }; + + var lockedFields = lockField == null ? Array.Empty() : new[] { (MetadataField)lockField }; + MetadataService.MergeBaseItemData(source, target, lockedFields, replaceData, false); + + actualValue = target.People; + return newValue?.Equals(actualValue) ?? actualValue == null; + } + + /// + /// Makes a call to with the provided parameters and returns whether the target changed or not. + /// + /// Reflection is used to allow testing of all fields using the same logic, rather than relying on copy/pasting test code for each field. + /// + /// The property to test. + /// The initial value in the target object. + /// The initial value in the source object. + /// The metadata field that locks this property if the field should be locked, or null to leave unlocked. + /// Passed through to . + /// The resulting value set to the target. + /// The type to test on. + /// The info type. + /// true if the property on the target updates to match the source value when is called. + private static bool TestMergeBaseItemData(string propName, object? oldValue, object? newValue, MetadataField? lockField, bool replaceData, out object? actualValue) + where TItemType : BaseItem, IHasLookupInfo, new() + where TIdType : ItemLookupInfo, new() + { + var property = typeof(TItemType).GetProperty(propName)!; + + var source = new MetadataResult + { + Item = new TItemType() + }; + property.SetValue(source.Item, newValue); + + var target = new MetadataResult + { + Item = new TItemType() + }; + property.SetValue(target.Item, oldValue); + + var lockedFields = lockField == null ? Array.Empty() : new[] { (MetadataField)lockField }; + // generic type doesn't actually matter to call the static method, just has to be filled in + MetadataService.MergeBaseItemData(source, target, lockedFields, replaceData, false); + + actualValue = property.GetValue(target.Item); + return newValue?.Equals(actualValue) ?? actualValue == null; + } + } +} diff --git a/tests/Jellyfin.Providers.Tests/MediaInfo/EmbeddedImageProviderTests.cs b/tests/Jellyfin.Providers.Tests/MediaInfo/EmbeddedImageProviderTests.cs new file mode 100644 index 000000000..98ac1dd64 --- /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 index b160e676e..040ea5d1d 100644 --- a/tests/Jellyfin.Providers.Tests/MediaInfo/SubtitleResolverTests.cs +++ b/tests/Jellyfin.Providers.Tests/MediaInfo/SubtitleResolverTests.cs @@ -1,4 +1,4 @@ -#pragma warning disable CA1002 // Do not expose generic lists +#pragma warning disable CA1002 // Do not expose generic lists using System.Collections.Generic; using MediaBrowser.Model.Entities; @@ -11,11 +11,12 @@ namespace Jellyfin.Providers.Tests.MediaInfo { public class SubtitleResolverTests { - public static IEnumerable AddExternalSubtitleStreams_GivenMixedFilenames_ReturnsValidSubtitles_TestData() + public static TheoryData, string, int, string[], MediaStream[]> AddExternalSubtitleStreams_GivenMixedFilenames_ReturnsValidSubtitles_TestData() { + var data = new TheoryData, string, int, string[], MediaStream[]>(); + var index = 0; - yield return new object[] - { + data.Add( new List(), "/video/My.Video.mkv", index, @@ -52,8 +53,9 @@ namespace Jellyfin.Providers.Tests.MediaInfo 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] @@ -78,9 +80,40 @@ namespace Jellyfin.Providers.Tests.MediaInfo } } + [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 () + return new() { Index = index, Codec = codec, diff --git a/tests/Jellyfin.Providers.Tests/MediaInfo/VideoImageProviderTests.cs b/tests/Jellyfin.Providers.Tests/MediaInfo/VideoImageProviderTests.cs new file mode 100644 index 000000000..1503a3392 --- /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 - + @@ -23,7 +23,7 @@ - + diff --git a/tests/Jellyfin.XbmcMetadata.Tests/Parsers/MovieNfoParserTests.cs b/tests/Jellyfin.XbmcMetadata.Tests/Parsers/MovieNfoParserTests.cs index 7ea45d14d..7c9952030 100644 --- a/tests/Jellyfin.XbmcMetadata.Tests/Parsers/MovieNfoParserTests.cs +++ b/tests/Jellyfin.XbmcMetadata.Tests/Parsers/MovieNfoParserTests.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Linq; using System.Threading; using Jellyfin.Data.Entities; @@ -157,33 +158,33 @@ namespace Jellyfin.XbmcMetadata.Tests.Parsers // Images Assert.Equal(7, result.RemoteImages.Count); - var posters = result.RemoteImages.Where(x => x.type == ImageType.Primary).ToList(); + var posters = result.RemoteImages.Where(x => x.Type == ImageType.Primary).ToList(); Assert.Single(posters); - Assert.Equal("http://image.tmdb.org/t/p/original/9rtrRGeRnL0JKtu9IMBWsmlmmZz.jpg", posters[0].url); + Assert.Equal("http://image.tmdb.org/t/p/original/9rtrRGeRnL0JKtu9IMBWsmlmmZz.jpg", posters[0].Url); - var logos = result.RemoteImages.Where(x => x.type == ImageType.Logo).ToList(); + var logos = result.RemoteImages.Where(x => x.Type == ImageType.Logo).ToList(); Assert.Single(logos); - Assert.Equal("https://assets.fanart.tv/fanart/movies/141052/hdmovielogo/justice-league-5865bf95cbadb.png", logos[0].url); + Assert.Equal("https://assets.fanart.tv/fanart/movies/141052/hdmovielogo/justice-league-5865bf95cbadb.png", logos[0].Url); - var banners = result.RemoteImages.Where(x => x.type == ImageType.Banner).ToList(); + var banners = result.RemoteImages.Where(x => x.Type == ImageType.Banner).ToList(); Assert.Single(banners); - Assert.Equal("https://assets.fanart.tv/fanart/movies/141052/moviebanner/justice-league-586017e95adbd.jpg", banners[0].url); + Assert.Equal("https://assets.fanart.tv/fanart/movies/141052/moviebanner/justice-league-586017e95adbd.jpg", banners[0].Url); - var thumbs = result.RemoteImages.Where(x => x.type == ImageType.Thumb).ToList(); + var thumbs = result.RemoteImages.Where(x => x.Type == ImageType.Thumb).ToList(); Assert.Single(thumbs); - Assert.Equal("https://assets.fanart.tv/fanart/movies/141052/moviethumb/justice-league-585fb155c3743.jpg", thumbs[0].url); + Assert.Equal("https://assets.fanart.tv/fanart/movies/141052/moviethumb/justice-league-585fb155c3743.jpg", thumbs[0].Url); - var art = result.RemoteImages.Where(x => x.type == ImageType.Art).ToList(); + var art = result.RemoteImages.Where(x => x.Type == ImageType.Art).ToList(); Assert.Single(art); - Assert.Equal("https://assets.fanart.tv/fanart/movies/141052/hdmovieclearart/justice-league-5865c23193041.png", art[0].url); + Assert.Equal("https://assets.fanart.tv/fanart/movies/141052/hdmovieclearart/justice-league-5865c23193041.png", art[0].Url); - var discArt = result.RemoteImages.Where(x => x.type == ImageType.Disc).ToList(); + var discArt = result.RemoteImages.Where(x => x.Type == ImageType.Disc).ToList(); Assert.Single(discArt); - Assert.Equal("https://assets.fanart.tv/fanart/movies/141052/moviedisc/justice-league-5a3af26360617.png", discArt[0].url); + Assert.Equal("https://assets.fanart.tv/fanart/movies/141052/moviedisc/justice-league-5a3af26360617.png", discArt[0].Url); - var backdrop = result.RemoteImages.Where(x => x.type == ImageType.Backdrop).ToList(); + var backdrop = result.RemoteImages.Where(x => x.Type == ImageType.Backdrop).ToList(); Assert.Single(backdrop); - Assert.Equal("https://assets.fanart.tv/fanart/movies/141052/moviebackground/justice-league-5793f518c6d6e.jpg", backdrop[0].url); + Assert.Equal("https://assets.fanart.tv/fanart/movies/141052/moviebackground/justice-league-5793f518c6d6e.jpg", backdrop[0].Url); // Local Image - contains only one item depending on operating system Assert.Single(result.Images); @@ -216,8 +217,8 @@ namespace Jellyfin.XbmcMetadata.Tests.Parsers _parser.Fetch(result, "Test Data/Fanart.nfo", CancellationToken.None); - Assert.Single(result.RemoteImages.Where(x => x.type == ImageType.Backdrop)); - Assert.Equal("https://assets.fanart.tv/fanart/movies/141052/moviebackground/justice-league-5a5332c7b5e77.jpg", result.RemoteImages.First(x => x.type == ImageType.Backdrop).url); + Assert.Single(result.RemoteImages.Where(x => x.Type == ImageType.Backdrop)); + Assert.Equal("https://assets.fanart.tv/fanart/movies/141052/moviebackground/justice-league-5a5332c7b5e77.jpg", result.RemoteImages.First(x => x.Type == ImageType.Backdrop).Url); } [Fact]