diff --git a/.ci/azure-pipelines-abi.yml b/.ci/azure-pipelines-abi.yml index 8d0737b66c..31f861f63f 100644 --- a/.ci/azure-pipelines-abi.yml +++ b/.ci/azure-pipelines-abi.yml @@ -7,7 +7,7 @@ parameters: default: "ubuntu-latest" - name: DotNetSdkVersion type: string - default: 5.0.103 + default: 6.0.x jobs: - job: CompatibilityCheck @@ -34,6 +34,7 @@ jobs: inputs: packageType: sdk version: ${{ parameters.DotNetSdkVersion }} + includePreviewVersions: true - task: DotNetCoreCLI@2 displayName: 'Install ABI CompatibilityChecker Tool' diff --git a/.ci/azure-pipelines-main.yml b/.ci/azure-pipelines-main.yml index 4bc72f9eb0..1086d51d27 100644 --- a/.ci/azure-pipelines-main.yml +++ b/.ci/azure-pipelines-main.yml @@ -1,7 +1,7 @@ parameters: LinuxImage: 'ubuntu-latest' RestoreBuildProjects: 'Jellyfin.Server/Jellyfin.Server.csproj' - DotNetSdkVersion: 5.0.103 + DotNetSdkVersion: 6.0.x jobs: - job: Build @@ -54,6 +54,7 @@ jobs: inputs: packageType: sdk version: ${{ parameters.DotNetSdkVersion }} + includePreviewVersions: true - task: DotNetCoreCLI@2 displayName: 'Publish Server' @@ -91,3 +92,10 @@ jobs: inputs: targetPath: '$(build.ArtifactStagingDirectory)/Jellyfin.Server/MediaBrowser.Common.dll' artifactName: 'Jellyfin.Common' + + - task: PublishPipelineArtifact@1 + displayName: 'Publish Artifact Extensions' + condition: and(succeeded(), eq(variables['BuildConfiguration'], 'Release')) + inputs: + targetPath: '$(build.ArtifactStagingDirectory)/Jellyfin.Server/Jellyfin.Extensions.dll' + artifactName: 'Jellyfin.Extensions' diff --git a/.ci/azure-pipelines-package.yml b/.ci/azure-pipelines-package.yml index 543fd7fc6d..4abe52b43b 100644 --- a/.ci/azure-pipelines-package.yml +++ b/.ci/azure-pipelines-package.yml @@ -195,10 +195,11 @@ 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' + includePreviewVersions: true - task: DotNetCoreCLI@2 displayName: 'Build Stable Nuget packages' @@ -211,6 +212,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 +227,7 @@ jobs: MediaBrowser.Controller/MediaBrowser.Controller.csproj MediaBrowser.Model/MediaBrowser.Model.csproj Emby.Naming/Emby.Naming.csproj + src/Jellyfin.Extensions/Jellyfin.Extensions.csproj custom: 'pack' arguments: '--version-suffix $(Build.BuildNumber) -o $(Build.ArtifactStagingDirectory) -p:Stability=Unstable' diff --git a/.ci/azure-pipelines-test.yml b/.ci/azure-pipelines-test.yml index 7838b3b026..80a5732eee 100644 --- a/.ci/azure-pipelines-test.yml +++ b/.ci/azure-pipelines-test.yml @@ -10,7 +10,7 @@ parameters: default: "tests/**/*Tests.csproj" - name: DotNetSdkVersion type: string - default: 5.0.103 + default: 6.0.x jobs: - job: Test @@ -41,6 +41,7 @@ jobs: inputs: packageType: sdk version: ${{ parameters.DotNetSdkVersion }} + includePreviewVersions: true - task: SonarCloudPrepare@1 displayName: 'Prepare analysis on SonarCloud' @@ -94,5 +95,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 c028b6e3e8..19c9caacbb 100644 --- a/.ci/azure-pipelines.yml +++ b/.ci/azure-pipelines.yml @@ -5,8 +5,6 @@ variables: value: 'tests/**/*Tests.csproj' - name: RestoreBuildProjects value: 'Jellyfin.Server/Jellyfin.Server.csproj' -- name: DotNetSdkVersion - value: 5.0.103 pr: autoCancel: true @@ -57,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/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 12f1f5ed53..c1d49778e3 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -14,9 +14,11 @@ assignees: '' - OS: [e.g. Debian, Windows] - Virtualization: [e.g. Docker, KVM, LXC] - Clients: [Browser, Android, Fire Stick, etc.] - - Browser: [e.g. Firefox 72, Chrome 80, Safari 13] - - Jellyfin Version: [e.g. 10.4.3, nightly 20191231] + - 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] diff --git a/.github/stale.yml b/.github/stale.yml index 05892c44dc..cba9c33b2a 100644 --- a/.github/stale.yml +++ b/.github/stale.yml @@ -17,9 +17,13 @@ staleLabel: stale # Comment to post when marking an issue as stale. Set to `false` to disable markComment: > This issue has gone 120 days without comment. To avoid abandoned issues, it will be closed in 21 days if there are no new comments. - + If you're the original submitter of this issue, please comment confirming if this issue still affects you in the latest release or nightlies, or close the issue if it has been fixed. If you're another user also affected by this bug, please comment confirming so. Either action will remove the stale label. This bot exists to prevent issues from becoming stale and forgotten. Jellyfin is always moving forward, and bugs are often fixed as side effects of other changes. We therefore ask that bug report authors remain vigilant about their issues to ensure they are closed if fixed, or re-confirmed - perhaps with fresh logs or reproduction examples - regularly. If you have any questions you can reach us on [Matrix or Social Media](https://docs.jellyfin.org/general/getting-help.html). # Comment to post when closing a stale issue. Set to `false` to disable closeComment: false + +# Disable automatic closing of pull requests +pulls: + daysUntilClose: false diff --git a/.github/workflows/automation.yml b/.github/workflows/automation.yml index 38415f1c60..20294843d5 100644 --- a/.github/workflows/automation.yml +++ b/.github/workflows/automation.yml @@ -26,7 +26,7 @@ jobs: if: ${{ github.repository == 'jellyfin/jellyfin' }} steps: - name: Remove from 'Current Release' project - uses: alex-page/github-project-automation-plus@v0.7.1 + uses: alex-page/github-project-automation-plus@v0.8.1 if: (github.event.pull_request || github.event.issue.pull_request) && !contains(github.event.*.labels.*.name, 'stable backport') continue-on-error: true with: @@ -35,7 +35,7 @@ jobs: repo-token: ${{ secrets.JF_BOT_TOKEN }} - name: Add to 'Release Next' project - uses: alex-page/github-project-automation-plus@v0.7.1 + uses: alex-page/github-project-automation-plus@v0.8.1 if: (github.event.pull_request || github.event.issue.pull_request) && github.event.action == 'opened' continue-on-error: true with: @@ -44,7 +44,7 @@ jobs: repo-token: ${{ secrets.JF_BOT_TOKEN }} - name: Add to 'Current Release' project - uses: alex-page/github-project-automation-plus@v0.7.1 + uses: alex-page/github-project-automation-plus@v0.8.1 if: (github.event.pull_request || github.event.issue.pull_request) && !contains(github.event.*.labels.*.name, 'stable backport') continue-on-error: true with: @@ -58,7 +58,7 @@ jobs: run: echo "::set-output name=number::$(curl -s ${{ github.event.issue.comments_url }} | jq '.[] | select(.author_association == "MEMBER") | .author_association' | wc -l)" - name: Move issue to needs triage - uses: alex-page/github-project-automation-plus@v0.7.1 + uses: alex-page/github-project-automation-plus@v0.8.1 if: github.event.issue.pull_request == '' && github.event.comment.author_association == 'MEMBER' && steps.member_comments.outputs.number <= 1 continue-on-error: true with: @@ -67,7 +67,7 @@ jobs: repo-token: ${{ secrets.JF_BOT_TOKEN }} - name: Add issue to triage project - uses: alex-page/github-project-automation-plus@v0.7.1 + uses: alex-page/github-project-automation-plus@v0.8.1 if: github.event.issue.pull_request == '' && github.event.action == 'opened' continue-on-error: true with: diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 3e456f9093..e07d913b5a 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -24,7 +24,9 @@ jobs: - name: Setup .NET Core uses: actions/setup-dotnet@v1 with: - dotnet-version: '5.0.x' + dotnet-version: '6.0.x' + include-prerelease: true + - name: Initialize CodeQL uses: github/codeql-action/init@v1 with: diff --git a/.github/workflows/commands.yml b/.github/workflows/commands.yml index e0b91ecee6..af4d8beb93 100644 --- a/.github/workflows/commands.yml +++ b/.github/workflows/commands.yml @@ -29,7 +29,7 @@ jobs: fetch-depth: 0 - name: Automatic Rebase - uses: cirrus-actions/rebase@1.4 + uses: cirrus-actions/rebase@1.5 env: GITHUB_TOKEN: ${{ secrets.JF_BOT_TOKEN }} diff --git a/.gitignore b/.gitignore index 252210e57c..c2ae76c1e3 100644 --- a/.gitignore +++ b/.gitignore @@ -278,3 +278,6 @@ web/ web-src.* MediaBrowser.WebDashboard/jellyfin-web apiclient/generated + +# Omnisharp crash logs +mono_crash.*.json diff --git a/.vscode/launch.json b/.vscode/launch.json index e55ea22485..b82956a721 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -6,7 +6,7 @@ "type": "coreclr", "request": "launch", "preLaunchTask": "build", - "program": "${workspaceFolder}/Jellyfin.Server/bin/Debug/net5.0/jellyfin.dll", + "program": "${workspaceFolder}/Jellyfin.Server/bin/Debug/net6.0/jellyfin.dll", "args": [], "cwd": "${workspaceFolder}/Jellyfin.Server", "console": "internalConsole", @@ -22,7 +22,7 @@ "type": "coreclr", "request": "launch", "preLaunchTask": "build", - "program": "${workspaceFolder}/Jellyfin.Server/bin/Debug/net5.0/jellyfin.dll", + "program": "${workspaceFolder}/Jellyfin.Server/bin/Debug/net6.0/jellyfin.dll", "args": ["--nowebclient"], "cwd": "${workspaceFolder}/Jellyfin.Server", "console": "internalConsole", diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index b44961bf8d..5c4a031bc1 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -46,6 +46,7 @@ - [fruhnow](https://github.com/fruhnow) - [geilername](https://github.com/geilername) - [gnattu](https://github.com/gnattu) + - [GodTamIt](https://github.com/GodTamIt) - [grafixeyehero](https://github.com/grafixeyehero) - [h1nk](https://github.com/h1nk) - [hawken93](https://github.com/hawken93) @@ -148,6 +149,7 @@ - [skyfrk](https://github.com/skyfrk) - [ianjazz246](https://github.com/ianjazz246) - [peterspenler](https://github.com/peterspenler) + - [MBR-0001](https://github.com/MBR-0001) # Emby Contributors @@ -212,3 +214,5 @@ - [Tim Hobbs](https://github.com/timhobbs) - [SvenVandenbrande](https://github.com/SvenVandenbrande) - [olsh](https://github.com/olsh) + - [lbenini](https://github.com/lbenini) + - [gnuyent](https://github.com/gnuyent) diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000000..b899999efb --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,14 @@ + + + + + enable + true + $(MSBuildThisFileDirectory)/jellyfin.ruleset + + + + AllEnabledByDefault + + + diff --git a/Dockerfile b/Dockerfile index 4e2d06b82a..e133c08193 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,8 @@ -ARG DOTNET_VERSION=5.0 +# DESIGNED FOR BUILDING ON AMD64 ONLY +##################################### +# Requires binfm_misc registration +# https://github.com/multiarch/qemu-user-static#binfmt_misc-register +ARG DOTNET_VERSION=6.0 FROM node:lts-alpine as web-builder ARG JELLYFIN_WEB_VERSION=master @@ -8,15 +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 mcr.microsoft.com/dotnet/sdk:${DOTNET_VERSION} as builder -WORKDIR /repo -COPY . . -ENV DOTNET_CLI_TELEMETRY_OPTOUT=1 -# because of changes in docker and systemd we need to not build in parallel at the moment -# see https://success.docker.com/article/how-to-reserve-resource-temporarily-unavailable-errors-due-to-tasksmax-setting -RUN dotnet publish Jellyfin.Server --disable-parallel --configuration Release --output="/jellyfin" --self-contained --runtime linux-x64 "-p:DebugSymbols=false;DebugType=none" - -FROM debian:buster-slim +FROM debian:stable-slim as app # https://askubuntu.com/questions/972516/debian-frontend-environment-variable ARG DEBIAN_FRONTEND="noninteractive" @@ -25,19 +21,17 @@ ARG APT_KEY_DONT_WARN_ON_DANGEROUS_USAGE=DontWarn # https://github.com/NVIDIA/nvidia-docker/wiki/Installation-(Native-GPU-Support) ENV NVIDIA_DRIVER_CAPABILITIES="compute,video,utility" -COPY --from=builder /jellyfin /jellyfin -COPY --from=web-builder /dist /jellyfin/jellyfin-web - # https://github.com/intel/compute-runtime/releases -ARG GMMLIB_VERSION=20.3.2 -ARG IGC_VERSION=1.0.5435 -ARG NEO_VERSION=20.46.18421 -ARG LEVEL_ZERO_VERSION=1.0.18421 +ARG GMMLIB_VERSION=21.2.1 +ARG IGC_VERSION=1.0.8517 +ARG NEO_VERSION=21.35.20826 +ARG LEVEL_ZERO_VERSION=1.2.20826 # Install dependencies: # mesa-va-drivers: needed for AMD VAAPI. Mesa >= 20.1 is required for HEVC transcoding. +# curl: healthcheck RUN apt-get update \ - && apt-get install --no-install-recommends --no-install-suggests -y ca-certificates gnupg wget apt-transport-https \ + && apt-get install --no-install-recommends --no-install-suggests -y ca-certificates gnupg wget apt-transport-https curl \ && wget -O - https://repo.jellyfin.org/jellyfin_team.gpg.key | apt-key add - \ && echo "deb [arch=$( dpkg --print-architecture )] https://repo.jellyfin.org/$( awk -F'=' '/^ID=/{ print $NF }' /etc/os-release ) $( awk -F'=' '/^VERSION_CODENAME=/{ print $NF }' /etc/os-release ) main" | tee /etc/apt/sources.list.d/jellyfin.list \ && apt-get update \ @@ -68,14 +62,32 @@ RUN apt-get update \ && chmod 777 /cache /config /media \ && sed -i -e 's/# en_US.UTF-8 UTF-8/en_US.UTF-8 UTF-8/' /etc/locale.gen && locale-gen -ENV DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1 +# ENV DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1 ENV LC_ALL en_US.UTF-8 ENV LANG en_US.UTF-8 ENV LANGUAGE en_US:en +FROM mcr.microsoft.com/dotnet/sdk:${DOTNET_VERSION} as builder +WORKDIR /repo +COPY . . +ENV DOTNET_CLI_TELEMETRY_OPTOUT=1 +# because of changes in docker and systemd we need to not build in parallel at the moment +# see https://success.docker.com/article/how-to-reserve-resource-temporarily-unavailable-errors-due-to-tasksmax-setting +RUN dotnet publish Jellyfin.Server --disable-parallel --configuration Release --output="/jellyfin" --self-contained --runtime linux-x64 "-p:DebugSymbols=false;DebugType=none" + +FROM app + +ENV HEALTHCHECK_URL=http://localhost:8096/health + +COPY --from=builder /jellyfin /jellyfin +COPY --from=web-builder /dist /jellyfin/jellyfin-web + EXPOSE 8096 VOLUME /cache /config /media ENTRYPOINT ["./jellyfin/jellyfin", \ "--datadir", "/config", \ "--cachedir", "/cache", \ "--ffmpeg", "/usr/lib/jellyfin-ffmpeg/ffmpeg"] + +HEALTHCHECK --interval=30s --timeout=30s --start-period=10s --retries=3 \ + CMD curl -Lk "${HEALTHCHECK_URL}" || exit 1 diff --git a/Dockerfile.arm b/Dockerfile.arm index 25a0de7db6..a46fa331df 100644 --- a/Dockerfile.arm +++ b/Dockerfile.arm @@ -1,8 +1,8 @@ -# DESIGNED FOR BUILDING ON AMD64 ONLY +# DESIGNED FOR BUILDING ON ARM ONLY ##################################### # Requires binfm_misc registration # https://github.com/multiarch/qemu-user-static#binfmt_misc-register -ARG DOTNET_VERSION=5.0 +ARG DOTNET_VERSION=6.0 FROM node:lts-alpine as web-builder @@ -13,19 +13,8 @@ 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 mcr.microsoft.com/dotnet/sdk:${DOTNET_VERSION} as builder -WORKDIR /repo -COPY . . -ENV DOTNET_CLI_TELEMETRY_OPTOUT=1 -# Discard objs - may cause failures if exists -RUN find . -type d -name obj | xargs -r rm -r -# Build -RUN dotnet publish Jellyfin.Server --configuration Release --output="/jellyfin" --self-contained --runtime linux-arm "-p:DebugSymbols=false;DebugType=none" - - FROM multiarch/qemu-user-static:x86_64-arm as qemu -FROM arm32v7/debian:buster-slim +FROM arm32v7/debian:stable-slim as app # https://askubuntu.com/questions/972516/debian-frontend-environment-variable ARG DEBIAN_FRONTEND="noninteractive" @@ -35,6 +24,8 @@ ARG APT_KEY_DONT_WARN_ON_DANGEROUS_USAGE=DontWarn ENV NVIDIA_DRIVER_CAPABILITIES="compute,video,utility" COPY --from=qemu /usr/bin/qemu-arm-static /usr/bin + +# curl: setup & healthcheck RUN apt-get update \ && apt-get install --no-install-recommends --no-install-suggests -y ca-certificates gnupg curl && \ curl -ks https://repo.jellyfin.org/debian/jellyfin_team.gpg.key | apt-key add - && \ @@ -53,7 +44,7 @@ RUN apt-get update \ vainfo \ libva2 \ locales \ - && apt-get remove curl gnupg -y \ + && apt-get remove gnupg -y \ && apt-get clean autoclean -y \ && apt-get autoremove -y \ && rm -rf /var/lib/apt/lists/* \ @@ -61,17 +52,33 @@ RUN apt-get update \ && chmod 777 /cache /config /media \ && sed -i -e 's/# en_US.UTF-8 UTF-8/en_US.UTF-8 UTF-8/' /etc/locale.gen && locale-gen -COPY --from=builder /jellyfin /jellyfin -COPY --from=web-builder /dist /jellyfin/jellyfin-web - -ENV DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1 +# ENV DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1 ENV LC_ALL en_US.UTF-8 ENV LANG en_US.UTF-8 ENV LANGUAGE en_US:en +FROM mcr.microsoft.com/dotnet/sdk:${DOTNET_VERSION} as builder +WORKDIR /repo +COPY . . +ENV DOTNET_CLI_TELEMETRY_OPTOUT=1 +# Discard objs - may cause failures if exists +RUN find . -type d -name obj | xargs -r rm -r +# Build +RUN dotnet publish Jellyfin.Server --configuration Release --output="/jellyfin" --self-contained --runtime linux-arm "-p:DebugSymbols=false;DebugType=none" + +FROM app + +ENV HEALTHCHECK_URL=http://localhost:8096/health + +COPY --from=builder /jellyfin /jellyfin +COPY --from=web-builder /dist /jellyfin/jellyfin-web + EXPOSE 8096 VOLUME /cache /config /media ENTRYPOINT ["./jellyfin/jellyfin", \ "--datadir", "/config", \ "--cachedir", "/cache", \ "--ffmpeg", "/usr/lib/jellyfin-ffmpeg/ffmpeg"] + +HEALTHCHECK --interval=30s --timeout=30s --start-period=10s --retries=3 \ + CMD curl -Lk "${HEALTHCHECK_URL}" || exit 1 diff --git a/Dockerfile.arm64 b/Dockerfile.arm64 index c9f19c5a39..1279c47f8e 100644 --- a/Dockerfile.arm64 +++ b/Dockerfile.arm64 @@ -1,8 +1,8 @@ -# DESIGNED FOR BUILDING ON AMD64 ONLY +# DESIGNED FOR BUILDING ON ARM64 ONLY ##################################### # Requires binfm_misc registration # https://github.com/multiarch/qemu-user-static#binfmt_misc-register -ARG DOTNET_VERSION=5.0 +ARG DOTNET_VERSION=6.0 FROM node:lts-alpine as web-builder @@ -13,18 +13,8 @@ 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 mcr.microsoft.com/dotnet/sdk:${DOTNET_VERSION} as builder -WORKDIR /repo -COPY . . -ENV DOTNET_CLI_TELEMETRY_OPTOUT=1 -# Discard objs - may cause failures if exists -RUN find . -type d -name obj | xargs -r rm -r -# Build -RUN dotnet publish Jellyfin.Server --configuration Release --output="/jellyfin" --self-contained --runtime linux-arm64 "-p:DebugSymbols=false;DebugType=none" - FROM multiarch/qemu-user-static:x86_64-aarch64 as qemu -FROM arm64v8/debian:buster-slim +FROM arm64v8/debian:stable-slim as app # https://askubuntu.com/questions/972516/debian-frontend-environment-variable ARG DEBIAN_FRONTEND="noninteractive" @@ -34,6 +24,8 @@ ARG APT_KEY_DONT_WARN_ON_DANGEROUS_USAGE=DontWarn ENV NVIDIA_DRIVER_CAPABILITIES="compute,video,utility" COPY --from=qemu /usr/bin/qemu-aarch64-static /usr/bin + +# curl: healcheck RUN apt-get update && apt-get install --no-install-recommends --no-install-suggests -y \ ffmpeg \ libssl-dev \ @@ -43,6 +35,7 @@ RUN apt-get update && apt-get install --no-install-recommends --no-install-sugge libomxil-bellagio0 \ libomxil-bellagio-bin \ locales \ + curl \ && apt-get clean autoclean -y \ && apt-get autoremove -y \ && rm -rf /var/lib/apt/lists/* \ @@ -50,17 +43,33 @@ RUN apt-get update && apt-get install --no-install-recommends --no-install-sugge && chmod 777 /cache /config /media \ && sed -i -e 's/# en_US.UTF-8 UTF-8/en_US.UTF-8 UTF-8/' /etc/locale.gen && locale-gen -COPY --from=builder /jellyfin /jellyfin -COPY --from=web-builder /dist /jellyfin/jellyfin-web - -ENV DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1 +# ENV DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1 ENV LC_ALL en_US.UTF-8 ENV LANG en_US.UTF-8 ENV LANGUAGE en_US:en +FROM mcr.microsoft.com/dotnet/sdk:${DOTNET_VERSION} as builder +WORKDIR /repo +COPY . . +ENV DOTNET_CLI_TELEMETRY_OPTOUT=1 +# Discard objs - may cause failures if exists +RUN find . -type d -name obj | xargs -r rm -r +# Build +RUN dotnet publish Jellyfin.Server --configuration Release --output="/jellyfin" --self-contained --runtime linux-arm64 "-p:DebugSymbols=false;DebugType=none" + +FROM app + +ENV HEALTHCHECK_URL=http://localhost:8096/health + +COPY --from=builder /jellyfin /jellyfin +COPY --from=web-builder /dist /jellyfin/jellyfin-web + EXPOSE 8096 VOLUME /cache /config /media ENTRYPOINT ["./jellyfin/jellyfin", \ "--datadir", "/config", \ "--cachedir", "/cache", \ "--ffmpeg", "/usr/bin/ffmpeg"] + +HEALTHCHECK --interval=30s --timeout=30s --start-period=10s --retries=3 \ + CMD curl -Lk "${HEALTHCHECK_URL}" || exit 1 diff --git a/DvdLib/DvdLib.csproj b/DvdLib/DvdLib.csproj index 7bbd9acf82..755d29160c 100644 --- a/DvdLib/DvdLib.csproj +++ b/DvdLib/DvdLib.csproj @@ -10,10 +10,11 @@ - net5.0 + net6.0 false true - true + AllDisabledByDefault + disable diff --git a/DvdLib/Ifo/Dvd.cs b/DvdLib/Ifo/Dvd.cs index b4a11ed5d6..7f8ece47dc 100644 --- a/DvdLib/Ifo/Dvd.cs +++ b/DvdLib/Ifo/Dvd.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; +using System.Globalization; using System.IO; using System.Linq; @@ -76,7 +77,7 @@ namespace DvdLib.Ifo private void ReadVTS(ushort vtsNum, IReadOnlyList allFiles) { - var filename = string.Format("VTS_{0:00}_0.IFO", vtsNum); + var filename = string.Format(CultureInfo.InvariantCulture, "VTS_{0:00}_0.IFO", vtsNum); var vtsPath = allFiles.FirstOrDefault(i => string.Equals(i.Name, filename, StringComparison.OrdinalIgnoreCase)) ?? allFiles.FirstOrDefault(i => string.Equals(i.Name, Path.ChangeExtension(filename, ".bup"), StringComparison.OrdinalIgnoreCase)); diff --git a/Emby.Dlna/Configuration/DlnaOptions.cs b/Emby.Dlna/Configuration/DlnaOptions.cs index 5ceeb55300..91fac4bef5 100644 --- a/Emby.Dlna/Configuration/DlnaOptions.cs +++ b/Emby.Dlna/Configuration/DlnaOptions.cs @@ -1,5 +1,3 @@ -#nullable disable - #pragma warning disable CS1591 namespace Emby.Dlna.Configuration @@ -74,7 +72,7 @@ namespace Emby.Dlna.Configuration /// /// Gets or sets the default user account that the dlna server uses. /// - public string DefaultUserId { get; set; } + public string? DefaultUserId { get; set; } /// /// Gets or sets a value indicating whether playTo device profiles should be created. diff --git a/Emby.Dlna/ContentDirectory/ContentDirectoryService.cs b/Emby.Dlna/ContentDirectory/ContentDirectoryService.cs index 7b8c504409..9020dea994 100644 --- a/Emby.Dlna/ContentDirectory/ContentDirectoryService.cs +++ b/Emby.Dlna/ContentDirectory/ContentDirectoryService.cs @@ -1,5 +1,3 @@ -#nullable disable - #pragma warning disable CS1591 using System; @@ -140,7 +138,7 @@ namespace Emby.Dlna.ContentDirectory /// /// The . /// The . - private User GetUser(DeviceProfile profile) + private User? GetUser(DeviceProfile profile) { if (!string.IsNullOrEmpty(profile.UserId)) { diff --git a/Emby.Dlna/ContentDirectory/ControlHandler.cs b/Emby.Dlna/ContentDirectory/ControlHandler.cs index 27c5b22680..ac336e5dcc 100644 --- a/Emby.Dlna/ContentDirectory/ControlHandler.cs +++ b/Emby.Dlna/ContentDirectory/ControlHandler.cs @@ -288,21 +288,14 @@ namespace Emby.Dlna.ContentDirectory /// The xml feature list. private static string WriteFeatureListXml() { - // TODO: clean this up - var builder = new StringBuilder(); - - builder.Append(""); - builder.Append(""); - - builder.Append(""); - builder.Append(""); - builder.Append(""); - builder.Append(""); - builder.Append(""); - - builder.Append(""); - - return builder.ToString(); + return "" + + "" + + "" + + "" + + "" + + "" + + "" + + ""; } /// diff --git a/Emby.Dlna/ContentDirectory/ServerItem.cs b/Emby.Dlna/ContentDirectory/ServerItem.cs index 34244000c1..ff30e6e4af 100644 --- a/Emby.Dlna/ContentDirectory/ServerItem.cs +++ b/Emby.Dlna/ContentDirectory/ServerItem.cs @@ -17,7 +17,7 @@ namespace Emby.Dlna.ContentDirectory { Item = item; - if (item is IItemByName && !(item is Folder)) + if (item is IItemByName && item is not Folder) { StubType = Dlna.ContentDirectory.StubType.Folder; } diff --git a/Emby.Dlna/ControlResponse.cs b/Emby.Dlna/ControlResponse.cs index a7f2d4a73b..8b09588424 100644 --- a/Emby.Dlna/ControlResponse.cs +++ b/Emby.Dlna/ControlResponse.cs @@ -1,5 +1,3 @@ -#nullable disable - #pragma warning disable CS1591 using System.Collections.Generic; @@ -8,9 +6,11 @@ namespace Emby.Dlna { public class ControlResponse { - public ControlResponse() + public ControlResponse(string xml, bool isSuccessful) { Headers = new Dictionary(); + Xml = xml; + IsSuccessful = isSuccessful; } public IDictionary Headers { get; } diff --git a/Emby.Dlna/Didl/DidlBuilder.cs b/Emby.Dlna/Didl/DidlBuilder.cs index 2982ce97e1..0a84f30c4c 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); @@ -748,7 +746,7 @@ namespace Emby.Dlna.Didl AddValue(writer, "upnp", "publisher", studio, NsUpnp); } - if (!(item is Folder)) + if (item is not Folder) { if (filter.Contains("dc:description")) { @@ -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); } } } diff --git a/Emby.Dlna/DlnaManager.cs b/Emby.Dlna/DlnaManager.cs index b08f7590d4..73e8a0008e 100644 --- a/Emby.Dlna/DlnaManager.cs +++ b/Emby.Dlna/DlnaManager.cs @@ -1,7 +1,4 @@ -#nullable disable - #pragma warning disable CS1591 - using System; using System.Collections.Generic; using System.Globalization; @@ -96,12 +93,14 @@ namespace Emby.Dlna } } + /// public DeviceProfile GetDefaultProfile() { return new DefaultProfile(); } - public DeviceProfile GetProfile(DeviceIdentification deviceInfo) + /// + public DeviceProfile? GetProfile(DeviceIdentification deviceInfo) { if (deviceInfo == null) { @@ -111,13 +110,13 @@ namespace Emby.Dlna var profile = GetProfiles() .FirstOrDefault(i => i.Identification != null && IsMatch(deviceInfo, i.Identification)); - if (profile != null) + if (profile == null) { - _logger.LogDebug("Found matching device profile: {ProfileName}", profile.Name); + LogUnmatchedProfile(deviceInfo); } else { - LogUnmatchedProfile(deviceInfo); + _logger.LogDebug("Found matching device profile: {ProfileName}", profile.Name); } return profile; @@ -187,7 +186,8 @@ namespace Emby.Dlna } } - public DeviceProfile GetProfile(IHeaderDictionary headers) + /// + public DeviceProfile? GetProfile(IHeaderDictionary headers) { if (headers == null) { @@ -195,15 +195,13 @@ namespace Emby.Dlna } var profile = GetProfiles().FirstOrDefault(i => i.Identification != null && IsMatch(headers, i.Identification)); - - if (profile != null) + if (profile == null) { - _logger.LogDebug("Found matching device profile: {0}", profile.Name); + _logger.LogDebug("No matching device profile found. {@Headers}", headers); } else { - var headerString = string.Join(", ", headers.Select(i => string.Format(CultureInfo.InvariantCulture, "{0}={1}", i.Key, i.Value))); - _logger.LogDebug("No matching device profile found. {0}", headerString); + _logger.LogDebug("Found matching device profile: {0}", profile.Name); } return profile; @@ -253,19 +251,19 @@ namespace Emby.Dlna return xmlFies .Select(i => ParseProfileFile(i, type)) .Where(i => i != null) - .ToList(); + .ToList()!; // We just filtered out all the nulls } catch (IOException) { - return new List(); + return Array.Empty(); } } - private DeviceProfile ParseProfileFile(string path, DeviceProfileType type) + private DeviceProfile? ParseProfileFile(string path, DeviceProfileType type) { lock (_profiles) { - if (_profiles.TryGetValue(path, out Tuple profileTuple)) + if (_profiles.TryGetValue(path, out Tuple? profileTuple)) { return profileTuple.Item2; } @@ -293,7 +291,8 @@ namespace Emby.Dlna } } - public DeviceProfile GetProfile(string id) + /// + public DeviceProfile? GetProfile(string id) { if (string.IsNullOrEmpty(id)) { @@ -322,6 +321,7 @@ namespace Emby.Dlna } } + /// public IEnumerable GetProfileInfos() { return GetProfileInfosInternal().Select(i => i.Info); @@ -329,17 +329,14 @@ namespace Emby.Dlna private InternalProfileInfo GetInternalProfileInfo(FileSystemMetadata file, DeviceProfileType type) { - return new InternalProfileInfo - { - Path = file.FullName, - - Info = new DeviceProfileInfo + return new InternalProfileInfo( + new DeviceProfileInfo { Id = file.FullName.ToLowerInvariant().GetMD5().ToString("N", CultureInfo.InvariantCulture), Name = _fileSystem.GetFileNameWithoutExtension(file), Type = type - } - }; + }, + file.FullName); } private async Task ExtractSystemProfilesAsync() @@ -359,16 +356,20 @@ namespace Emby.Dlna systemProfilesPath, Path.GetFileName(name.AsSpan()).Slice(namespaceName.Length)); - using (var stream = _assembly.GetManifestResourceStream(name)) + // The stream should exist as we just got its name from GetManifestResourceNames + using (var stream = _assembly.GetManifestResourceStream(name)!) { + var length = stream.Length; var fileInfo = _fileSystem.GetFileInfo(path); - if (!fileInfo.Exists || fileInfo.Length != stream.Length) + if (!fileInfo.Exists || fileInfo.Length != length) { Directory.CreateDirectory(systemProfilesPath); - // use FileShare.None as this bypasses dotnet bug dotnet/runtime#42790 . - using (var fileStream = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.None)) + var fileOptions = AsyncFile.WriteOptions; + fileOptions.Mode = FileMode.CreateNew; + fileOptions.PreallocationSize = length; + using (var fileStream = new FileStream(path, fileOptions)) { await stream.CopyToAsync(fileStream).ConfigureAwait(false); } @@ -380,6 +381,7 @@ namespace Emby.Dlna Directory.CreateDirectory(UserProfilesPath); } + /// public void DeleteProfile(string id) { var info = GetProfileInfosInternal().First(i => string.Equals(id, i.Info.Id, StringComparison.OrdinalIgnoreCase)); @@ -397,6 +399,7 @@ namespace Emby.Dlna } } + /// public void CreateProfile(DeviceProfile profile) { profile = ReserializeProfile(profile); @@ -412,6 +415,7 @@ namespace Emby.Dlna SaveProfile(profile, path, DeviceProfileType.User); } + /// public void UpdateProfile(DeviceProfile profile) { profile = ReserializeProfile(profile); @@ -470,9 +474,11 @@ namespace Emby.Dlna var json = JsonSerializer.Serialize(profile, _jsonOptions); - return JsonSerializer.Deserialize(json, _jsonOptions); + // Output can't be null if the input isn't null + return JsonSerializer.Deserialize(json, _jsonOptions)!; } + /// public string GetServerDescriptionXml(IHeaderDictionary headers, string serverUuId, string serverAddress) { var profile = GetDefaultProfile(); @@ -482,26 +488,37 @@ namespace Emby.Dlna return new DescriptionXmlBuilder(profile, serverUuId, serverAddress, _appHost.FriendlyName, serverId).GetXml(); } - public ImageStream GetIcon(string filename) + /// + public ImageStream? GetIcon(string filename) { var format = filename.EndsWith(".png", StringComparison.OrdinalIgnoreCase) ? ImageFormat.Png : ImageFormat.Jpg; var resource = GetType().Namespace + ".Images." + filename.ToLowerInvariant(); + var stream = _assembly.GetManifestResourceStream(resource); + if (stream == null) + { + return null; + } - return new ImageStream + return new ImageStream(stream) { - Format = format, - Stream = _assembly.GetManifestResourceStream(resource) + Format = format }; } private class InternalProfileInfo { - internal DeviceProfileInfo Info { get; set; } + internal InternalProfileInfo(DeviceProfileInfo info, string path) + { + Info = info; + Path = path; + } + + internal DeviceProfileInfo Info { get; } - internal string Path { get; set; } + internal string Path { get; } } } diff --git a/Emby.Dlna/Emby.Dlna.csproj b/Emby.Dlna/Emby.Dlna.csproj index a40578e403..c8332e44e4 100644 --- a/Emby.Dlna/Emby.Dlna.csproj +++ b/Emby.Dlna/Emby.Dlna.csproj @@ -17,11 +17,9 @@ - net5.0 + net6.0 false true - true - enable @@ -31,10 +29,6 @@ - - ../jellyfin.ruleset - - @@ -78,7 +72,7 @@ - + diff --git a/Emby.Dlna/EventSubscriptionResponse.cs b/Emby.Dlna/EventSubscriptionResponse.cs index 8c82dcbf68..635d2c47a1 100644 --- a/Emby.Dlna/EventSubscriptionResponse.cs +++ b/Emby.Dlna/EventSubscriptionResponse.cs @@ -1,5 +1,3 @@ -#nullable disable - #pragma warning disable CS1591 using System.Collections.Generic; @@ -8,8 +6,10 @@ namespace Emby.Dlna { public class EventSubscriptionResponse { - public EventSubscriptionResponse() + public EventSubscriptionResponse(string content, string contentType) { + Content = content; + ContentType = contentType; Headers = new Dictionary(); } diff --git a/Emby.Dlna/Eventing/DlnaEventManager.cs b/Emby.Dlna/Eventing/DlnaEventManager.cs index 2e672b886b..d17e238715 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; @@ -51,11 +50,7 @@ namespace Emby.Dlna.Eventing return GetEventSubscriptionResponse(subscriptionId, requestedTimeoutString, timeoutSeconds); } - return new EventSubscriptionResponse - { - Content = string.Empty, - ContentType = "text/plain" - }; + return new EventSubscriptionResponse(string.Empty, "text/plain"); } public EventSubscriptionResponse CreateEventSubscription(string notificationType, string requestedTimeoutString, string callbackUrl) @@ -86,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; } @@ -103,23 +96,15 @@ namespace Emby.Dlna.Eventing _subscriptions.TryRemove(subscriptionId, out _); - return new EventSubscriptionResponse - { - Content = string.Empty, - ContentType = "text/plain" - }; + return new EventSubscriptionResponse(string.Empty, "text/plain"); } private EventSubscriptionResponse GetEventSubscriptionResponse(string subscriptionId, string requestedTimeoutString, int timeoutSeconds) { - var response = new EventSubscriptionResponse - { - Content = string.Empty, - ContentType = "text/plain" - }; + var response = new EventSubscriptionResponse(string.Empty, "text/plain"); response.Headers["SID"] = subscriptionId; - response.Headers["TIMEOUT"] = string.IsNullOrEmpty(requestedTimeoutString) ? ("SECOND-" + timeoutSeconds.ToString(_usCulture)) : requestedTimeoutString; + response.Headers["TIMEOUT"] = string.IsNullOrEmpty(requestedTimeoutString) ? ("SECOND-" + timeoutSeconds.ToString(CultureInfo.InvariantCulture)) : requestedTimeoutString; return response; } @@ -176,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 0309926abb..5d252d8dc4 100644 --- a/Emby.Dlna/Main/DlnaEntryPoint.cs +++ b/Emby.Dlna/Main/DlnaEntryPoint.cs @@ -27,11 +27,9 @@ using MediaBrowser.Controller.TV; using MediaBrowser.Model.Dlna; using MediaBrowser.Model.Globalization; using MediaBrowser.Model.Net; -using MediaBrowser.Model.System; using Microsoft.Extensions.Logging; using Rssdp; using Rssdp.Infrastructure; -using OperatingSystem = MediaBrowser.Common.System.OperatingSystem; namespace Emby.Dlna.Main { @@ -204,8 +202,8 @@ namespace Emby.Dlna.Main { if (_communicationsServer == null) { - var enableMultiSocketBinding = OperatingSystem.Id == OperatingSystemId.Windows || - OperatingSystem.Id == OperatingSystemId.Linux; + var enableMultiSocketBinding = OperatingSystem.IsWindows() || + OperatingSystem.IsLinux(); _communicationsServer = new SsdpCommunicationsServer(_socketFactory, _networkManager, _logger, enableMultiSocketBinding) { @@ -268,7 +266,12 @@ namespace Emby.Dlna.Main try { - _publisher = new SsdpDevicePublisher(_communicationsServer, _networkManager, OperatingSystem.Name, Environment.OSVersion.VersionString, _config.GetDlnaConfiguration().SendOnlyMatchedHost) + _publisher = new SsdpDevicePublisher( + _communicationsServer, + _networkManager, + MediaBrowser.Common.System.OperatingSystem.Name, + Environment.OSVersion.VersionString, + _config.GetDlnaConfiguration().SendOnlyMatchedHost) { LogFunction = LogMessage, SupportPnpRootDevice = false diff --git a/Emby.Dlna/PlayTo/Device.cs b/Emby.Dlna/PlayTo/Device.cs index 6c580d15bd..0b22880003 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; @@ -640,7 +638,7 @@ namespace Emby.Dlna.PlayTo return; } - Volume = int.Parse(volumeValue, UsCulture); + Volume = int.Parse(volumeValue, CultureInfo.InvariantCulture); if (Volume > 0) { @@ -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(); @@ -1194,8 +1192,8 @@ namespace Emby.Dlna.PlayTo var depth = element.GetDescendantValue(UPnpNamespaces.Ud.GetName("depth")); var url = element.GetDescendantValue(UPnpNamespaces.Ud.GetName("url")); - var widthValue = int.Parse(width, NumberStyles.Integer, UsCulture); - var heightValue = int.Parse(height, NumberStyles.Integer, UsCulture); + var widthValue = int.Parse(width, NumberStyles.Integer, CultureInfo.InvariantCulture); + var heightValue = int.Parse(height, NumberStyles.Integer, CultureInfo.InvariantCulture); return new DeviceIcon { @@ -1260,10 +1258,7 @@ namespace Emby.Dlna.PlayTo return; } - PlaybackStart?.Invoke(this, new PlaybackStartEventArgs - { - MediaInfo = mediaInfo - }); + PlaybackStart?.Invoke(this, new PlaybackStartEventArgs(mediaInfo)); } private void OnPlaybackProgress(UBaseObject mediaInfo) @@ -1273,27 +1268,17 @@ namespace Emby.Dlna.PlayTo return; } - PlaybackProgress?.Invoke(this, new PlaybackProgressEventArgs - { - MediaInfo = mediaInfo - }); + PlaybackProgress?.Invoke(this, new PlaybackProgressEventArgs(mediaInfo)); } private void OnPlaybackStop(UBaseObject mediaInfo) { - PlaybackStopped?.Invoke(this, new PlaybackStoppedEventArgs - { - MediaInfo = mediaInfo - }); + PlaybackStopped?.Invoke(this, new PlaybackStoppedEventArgs(mediaInfo)); } private void OnMediaChanged(UBaseObject old, UBaseObject newMedia) { - MediaChanged?.Invoke(this, new MediaChangedEventArgs - { - OldMediaInfo = old, - NewMediaInfo = newMedia - }); + MediaChanged?.Invoke(this, new MediaChangedEventArgs(old, newMedia)); } /// diff --git a/Emby.Dlna/PlayTo/MediaChangedEventArgs.cs b/Emby.Dlna/PlayTo/MediaChangedEventArgs.cs index 2bc4d8cc24..0f7a524d62 100644 --- a/Emby.Dlna/PlayTo/MediaChangedEventArgs.cs +++ b/Emby.Dlna/PlayTo/MediaChangedEventArgs.cs @@ -1,6 +1,4 @@ -#nullable disable - -#pragma warning disable CS1591 +#pragma warning disable CS1591 using System; @@ -8,6 +6,12 @@ namespace Emby.Dlna.PlayTo { public class MediaChangedEventArgs : EventArgs { + public MediaChangedEventArgs(UBaseObject oldMediaInfo, UBaseObject newMediaInfo) + { + OldMediaInfo = oldMediaInfo; + NewMediaInfo = newMediaInfo; + } + public UBaseObject OldMediaInfo { get; set; } public UBaseObject NewMediaInfo { get; set; } diff --git a/Emby.Dlna/PlayTo/PlayToController.cs b/Emby.Dlna/PlayTo/PlayToController.cs index 0e49fd2c02..f25d8017ef 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; @@ -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 35bf5927c4..7927f5f8f9 100644 --- a/Emby.Dlna/PlayTo/PlayToManager.cs +++ b/Emby.Dlna/PlayTo/PlayToManager.cs @@ -173,7 +173,9 @@ namespace Emby.Dlna.PlayTo uuid = uri.ToString().GetMD5().ToString("N", CultureInfo.InvariantCulture); } - var sessionInfo = _sessionManager.LogSessionActivity("DLNA", _appHost.ApplicationVersionString, uuid, null, uri.OriginalString, null); + var sessionInfo = await _sessionManager + .LogSessionActivity("DLNA", _appHost.ApplicationVersionString, uuid, null, uri.OriginalString, null) + .ConfigureAwait(false); var controller = sessionInfo.SessionControllers.OfType().FirstOrDefault(); diff --git a/Emby.Dlna/PlayTo/PlaybackProgressEventArgs.cs b/Emby.Dlna/PlayTo/PlaybackProgressEventArgs.cs index c7d2b28df8..c95d8b1e84 100644 --- a/Emby.Dlna/PlayTo/PlaybackProgressEventArgs.cs +++ b/Emby.Dlna/PlayTo/PlaybackProgressEventArgs.cs @@ -1,5 +1,3 @@ -#nullable disable - #pragma warning disable CS1591 using System; @@ -8,6 +6,11 @@ namespace Emby.Dlna.PlayTo { public class PlaybackProgressEventArgs : EventArgs { + public PlaybackProgressEventArgs(UBaseObject mediaInfo) + { + MediaInfo = mediaInfo; + } + public UBaseObject MediaInfo { get; set; } } } diff --git a/Emby.Dlna/PlayTo/PlaybackStartEventArgs.cs b/Emby.Dlna/PlayTo/PlaybackStartEventArgs.cs index f8a14f411f..619c861ed9 100644 --- a/Emby.Dlna/PlayTo/PlaybackStartEventArgs.cs +++ b/Emby.Dlna/PlayTo/PlaybackStartEventArgs.cs @@ -1,5 +1,3 @@ -#nullable disable - #pragma warning disable CS1591 using System; @@ -8,6 +6,11 @@ namespace Emby.Dlna.PlayTo { public class PlaybackStartEventArgs : EventArgs { + public PlaybackStartEventArgs(UBaseObject mediaInfo) + { + MediaInfo = mediaInfo; + } + public UBaseObject MediaInfo { get; set; } } } diff --git a/Emby.Dlna/PlayTo/PlaybackStoppedEventArgs.cs b/Emby.Dlna/PlayTo/PlaybackStoppedEventArgs.cs index 6661f92ac7..d0ec250591 100644 --- a/Emby.Dlna/PlayTo/PlaybackStoppedEventArgs.cs +++ b/Emby.Dlna/PlayTo/PlaybackStoppedEventArgs.cs @@ -1,5 +1,3 @@ -#nullable disable - #pragma warning disable CS1591 using System; @@ -8,6 +6,11 @@ namespace Emby.Dlna.PlayTo { public class PlaybackStoppedEventArgs : EventArgs { + public PlaybackStoppedEventArgs(UBaseObject mediaInfo) + { + MediaInfo = mediaInfo; + } + public UBaseObject MediaInfo { get; set; } } } diff --git a/Emby.Dlna/PlayTo/SsdpHttpClient.cs b/Emby.Dlna/PlayTo/SsdpHttpClient.cs index f14f73bb6f..cade7b4c2c 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/Server/DescriptionXmlBuilder.cs b/Emby.Dlna/Server/DescriptionXmlBuilder.cs index 3f3dfccd3a..80a45f2b25 100644 --- a/Emby.Dlna/Server/DescriptionXmlBuilder.cs +++ b/Emby.Dlna/Server/DescriptionXmlBuilder.cs @@ -15,7 +15,6 @@ namespace Emby.Dlna.Server { private readonly DeviceProfile _profile; - private readonly CultureInfo _usCulture = new CultureInfo("en-US"); private readonly string _serverUdn; private readonly string _serverAddress; private readonly string _serverName; @@ -193,10 +192,10 @@ namespace Emby.Dlna.Server .Append(SecurityElement.Escape(icon.MimeType ?? string.Empty)) .Append(""); builder.Append("") - .Append(SecurityElement.Escape(icon.Width.ToString(_usCulture))) + .Append(SecurityElement.Escape(icon.Width.ToString(CultureInfo.InvariantCulture))) .Append(""); builder.Append("") - .Append(SecurityElement.Escape(icon.Height.ToString(_usCulture))) + .Append(SecurityElement.Escape(icon.Height.ToString(CultureInfo.InvariantCulture))) .Append(""); builder.Append("") .Append(SecurityElement.Escape(icon.Depth ?? string.Empty)) @@ -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 b3ee860f4b..581e4a2861 100644 --- a/Emby.Dlna/Service/BaseControlHandler.cs +++ b/Emby.Dlna/Service/BaseControlHandler.cs @@ -95,11 +95,7 @@ namespace Emby.Dlna.Service var xml = builder.ToString().Replace("xmlns:m=", "xmlns:u=", StringComparison.Ordinal); - var controlResponse = new ControlResponse - { - Xml = xml, - IsSuccessful = true - }; + var controlResponse = new ControlResponse(xml, true); controlResponse.Headers.Add("EXT", string.Empty); diff --git a/Emby.Dlna/Service/BaseService.cs b/Emby.Dlna/Service/BaseService.cs index a97c4d63a6..68fd987585 100644 --- a/Emby.Dlna/Service/BaseService.cs +++ b/Emby.Dlna/Service/BaseService.cs @@ -23,14 +23,14 @@ namespace Emby.Dlna.Service return EventManager.CancelEventSubscription(subscriptionId); } - public EventSubscriptionResponse RenewEventSubscription(string subscriptionId, string notificationType, string timeoutString, string callbackUrl) + public EventSubscriptionResponse RenewEventSubscription(string subscriptionId, string notificationType, string requestedTimeoutString, string callbackUrl) { - return EventManager.RenewEventSubscription(subscriptionId, notificationType, timeoutString, callbackUrl); + return EventManager.RenewEventSubscription(subscriptionId, notificationType, requestedTimeoutString, callbackUrl); } - public EventSubscriptionResponse CreateEventSubscription(string notificationType, string timeoutString, string callbackUrl) + public EventSubscriptionResponse CreateEventSubscription(string notificationType, string requestedTimeoutString, string callbackUrl) { - return EventManager.CreateEventSubscription(notificationType, timeoutString, callbackUrl); + return EventManager.CreateEventSubscription(notificationType, requestedTimeoutString, callbackUrl); } } } diff --git a/Emby.Dlna/Service/ControlErrorHandler.cs b/Emby.Dlna/Service/ControlErrorHandler.cs index f2b5dd9ca8..3e2cd6d2e4 100644 --- a/Emby.Dlna/Service/ControlErrorHandler.cs +++ b/Emby.Dlna/Service/ControlErrorHandler.cs @@ -46,11 +46,7 @@ namespace Emby.Dlna.Service writer.WriteEndDocument(); } - return new ControlResponse - { - Xml = builder.ToString(), - IsSuccessful = false - }; + return new ControlResponse(builder.ToString(), false); } } } diff --git a/Emby.Drawing/Emby.Drawing.csproj b/Emby.Drawing/Emby.Drawing.csproj index 5c5afe1c6e..149e4b5d91 100644 --- a/Emby.Drawing/Emby.Drawing.csproj +++ b/Emby.Drawing/Emby.Drawing.csproj @@ -6,11 +6,9 @@ - net5.0 + net6.0 false true - true - enable @@ -30,8 +28,4 @@ - - ../jellyfin.ruleset - - diff --git a/Emby.Drawing/ImageProcessor.cs b/Emby.Drawing/ImageProcessor.cs index 7d952aa23b..ac73cfa421 100644 --- a/Emby.Drawing/ImageProcessor.cs +++ b/Emby.Drawing/ImageProcessor.cs @@ -102,7 +102,7 @@ namespace Emby.Drawing { var file = await ProcessImage(options).ConfigureAwait(false); - using (var fileStream = new FileStream(file.Item1, FileMode.Open, FileAccess.Read, FileShare.Read, IODefaults.FileStreamBufferSize, true)) + using (var fileStream = AsyncFile.OpenRead(file.Item1)) { await fileStream.CopyToAsync(toStream).ConfigureAwait(false); } diff --git a/Emby.Naming/AudioBook/AudioBookInfo.cs b/Emby.Naming/AudioBook/AudioBookInfo.cs index 15702ff2ca..acd8905af6 100644 --- a/Emby.Naming/AudioBook/AudioBookInfo.cs +++ b/Emby.Naming/AudioBook/AudioBookInfo.cs @@ -15,7 +15,7 @@ namespace Emby.Naming.AudioBook /// List of files composing the actual audiobook. /// List of extra files. /// Alternative version of files. - public AudioBookInfo(string name, int? year, List files, List extras, List alternateVersions) + public AudioBookInfo(string name, int? year, IReadOnlyList files, IReadOnlyList extras, IReadOnlyList alternateVersions) { Name = name; Year = year; @@ -39,18 +39,18 @@ namespace Emby.Naming.AudioBook /// Gets or sets the files. /// /// The files. - public List Files { get; set; } + public IReadOnlyList Files { get; set; } /// /// Gets or sets the extras. /// /// The extras. - public List Extras { get; set; } + public IReadOnlyList Extras { get; set; } /// /// Gets or sets the alternate versions. /// /// The alternate versions. - public List AlternateVersions { get; set; } + public IReadOnlyList AlternateVersions { get; set; } } } diff --git a/Emby.Naming/AudioBook/AudioBookListResolver.cs b/Emby.Naming/AudioBook/AudioBookListResolver.cs index ca53228903..1e4a8d2edc 100644 --- a/Emby.Naming/AudioBook/AudioBookListResolver.cs +++ b/Emby.Naming/AudioBook/AudioBookListResolver.cs @@ -87,7 +87,7 @@ namespace Emby.Naming.AudioBook foreach (var audioFile in group) { var name = Path.GetFileNameWithoutExtension(audioFile.Path); - if (name.Equals("audiobook") || + if (name.Equals("audiobook", StringComparison.OrdinalIgnoreCase) || name.Contains(nameParserResult.Name, StringComparison.OrdinalIgnoreCase) || name.Contains(nameWithReplacedDots, StringComparison.OrdinalIgnoreCase)) { diff --git a/Emby.Naming/Common/NamingOptions.cs b/Emby.Naming/Common/NamingOptions.cs index 22a3e8bb49..6355f8b1be 100644 --- a/Emby.Naming/Common/NamingOptions.cs +++ b/Emby.Naming/Common/NamingOptions.cs @@ -137,8 +137,11 @@ namespace Emby.Naming.Common CleanStrings = new[] { - @"[ _\,\.\(\)\[\]\-](3d|sbs|tab|hsbs|htab|mvc|HDR|HDC|UHD|UltraHD|4k|ac3|dts|custom|dc|divx|divx5|dsr|dsrip|dutch|dvd|dvdrip|dvdscr|dvdscreener|screener|dvdivx|cam|fragment|fs|hdtv|hdrip|hdtvrip|internal|limited|multisubs|ntsc|ogg|ogm|pal|pdtv|proper|repack|rerip|retail|cd[1-9]|r3|r5|bd5|bd|se|svcd|swedish|german|read.nfo|nfofix|unrated|ws|telesync|ts|telecine|tc|brrip|bdrip|480p|480i|576p|576i|720p|720i|1080p|1080i|2160p|hrhd|hrhdtv|hddvd|bluray|blu-ray|x264|x265|h264|xvid|xvidvd|xxx|www.www|AAC|DTS|\[.*\])([ _\,\.\(\)\[\]\-]|$)", - @"(\[.*\])" + @"^\s*(?.+?)[ _\,\.\(\)\[\]\-](3d|sbs|tab|hsbs|htab|mvc|HDR|HDC|UHD|UltraHD|4k|ac3|dts|custom|dc|divx|divx5|dsr|dsrip|dutch|dvd|dvdrip|dvdscr|dvdscreener|screener|dvdivx|cam|fragment|fs|hdtv|hdrip|hdtvrip|internal|limited|multisubs|ntsc|ogg|ogm|pal|pdtv|proper|repack|rerip|retail|cd[1-9]|r3|r5|bd5|bd|se|svcd|swedish|german|read.nfo|nfofix|unrated|ws|telesync|ts|telecine|tc|brrip|bdrip|480p|480i|576p|576i|720p|720i|1080p|1080i|2160p|hrhd|hrhdtv|hddvd|bluray|blu-ray|x264|x265|h264|h265|xvid|xvidvd|xxx|www.www|AAC|DTS|\[.*\])([ _\,\.\(\)\[\]\-]|$)", + @"^(?.+?)(\[.*\])", + @"^\s*(?.+?)\WE[0-9]+(-|~)E?[0-9]+(\W|$)", + @"^\s*\[[^\]]+\](?!\.\w+$)\s*(?.+)", + @"^\s*(?.+?)\s+-\s+[0-9]+\s*$" }; SubtitleFileExtensions = new[] @@ -277,14 +280,14 @@ namespace Emby.Naming.Common IsNamed = true }, - new EpisodeExpression("[\\\\/\\._ \\[\\(-]([0-9]+)x([0-9]+(?:(?:[a-i]|\\.[1-9])(?![0-9]))?)([^\\\\/]*)$") + new EpisodeExpression(@"[\\\/\._ \[\(-]([0-9]+)x([0-9]+(?:(?:[a-i]|\.[1-9])(?![0-9]))?)([^\\\/]*)$") { SupportsAbsoluteEpisodeNumbers = true }, // Not a Kodi rule as well, but below rule also causes false positives for triple-digit episode names // [bar] Foo - 1 [baz] special case of below expression to prevent false positives with digits in the series name - new EpisodeExpression(@".*?(\[.*?\])+.*?(?[\w\s]+?)[\s_]*-[\s_]*(?[0-9]+).*$") + new EpisodeExpression(@".*[\\\/]?.*?(\[.*?\])+.*?(?[-\w\s]+?)[\s_]*-[\s_]*(?[0-9]+).*$") { IsNamed = true }, @@ -305,6 +308,12 @@ namespace Emby.Naming.Common // *** End Kodi Standard Naming + // "Episode 16", "Episode 16 - Title" + new EpisodeExpression(@"[Ee]pisode (?[0-9]+)(-(?[0-9]+))?[^\\\/]*$") + { + IsNamed = true + }, + new EpisodeExpression(@".*(\\|\/)[sS]?(?[0-9]+)[xX](?[0-9]+)[^\\\/]*$") { IsNamed = true @@ -362,12 +371,6 @@ namespace Emby.Naming.Common IsOptimistic = true, IsNamed = true }, - // "Episode 16", "Episode 16 - Title" - new EpisodeExpression(@".*[\\\/][^\\\/]* (?[0-9]{1,3})(-(?[0-9]{2,3}))*[^\\\/]*$") - { - IsOptimistic = true, - IsNamed = true - } }; EpisodeWithoutSeasonExpressions = new[] @@ -478,6 +481,12 @@ namespace Emby.Naming.Common "-deleted", MediaType.Video), + new ExtraRule( + ExtraType.DeletedScene, + ExtraRuleType.Suffix, + "-deletedscene", + MediaType.Video), + new ExtraRule( ExtraType.Clip, ExtraRuleType.Suffix, diff --git a/Emby.Naming/Emby.Naming.csproj b/Emby.Naming/Emby.Naming.csproj index 3224ff4129..e9e9edda65 100644 --- a/Emby.Naming/Emby.Naming.csproj +++ b/Emby.Naming/Emby.Naming.csproj @@ -1,4 +1,4 @@ - + @@ -6,15 +6,13 @@ - net5.0 + net6.0 false true - true true true true snupkg - enable @@ -50,8 +48,4 @@ - - ../jellyfin.ruleset - - diff --git a/Emby.Naming/Video/CleanStringParser.cs b/Emby.Naming/Video/CleanStringParser.cs index 4eef3ebc5e..b813335008 100644 --- a/Emby.Naming/Video/CleanStringParser.cs +++ b/Emby.Naming/Video/CleanStringParser.cs @@ -17,38 +17,39 @@ namespace Emby.Naming.Video /// List of regex to parse name and year from. /// Parsing result string. /// True if parsing was successful. - public static bool TryClean([NotNullWhen(true)] string? name, IReadOnlyList expressions, out ReadOnlySpan newName) + public static bool TryClean([NotNullWhen(true)] string? name, IReadOnlyList expressions, out string newName) { if (string.IsNullOrEmpty(name)) { - newName = ReadOnlySpan.Empty; + newName = string.Empty; return false; } - var len = expressions.Count; - for (int i = 0; i < len; i++) + // Iteratively apply the regexps to clean the string. + bool cleaned = false; + for (int i = 0; i < expressions.Count; i++) { if (TryClean(name, expressions[i], out newName)) { - return true; + cleaned = true; + name = newName; } } - newName = ReadOnlySpan.Empty; - return false; + newName = cleaned ? name : string.Empty; + return cleaned; } - private static bool TryClean(string name, Regex expression, out ReadOnlySpan newName) + private static bool TryClean(string name, Regex expression, out string newName) { var match = expression.Match(name); - int index = match.Index; - if (match.Success && index != 0) + if (match.Success && match.Groups.TryGetValue("cleaned", out var cleaned)) { - newName = name.AsSpan().Slice(0, match.Index); + newName = cleaned.Value; return true; } - newName = ReadOnlySpan.Empty; + newName = string.Empty; return false; } } diff --git a/Emby.Naming/Video/ExtraResolver.cs b/Emby.Naming/Video/ExtraResolver.cs index 1fade985be..7bc226614e 100644 --- a/Emby.Naming/Video/ExtraResolver.cs +++ b/Emby.Naming/Video/ExtraResolver.cs @@ -1,6 +1,5 @@ using System; using System.IO; -using System.Linq; using System.Text.RegularExpressions; using Emby.Naming.Audio; using Emby.Naming.Common; @@ -12,6 +11,7 @@ namespace Emby.Naming.Video /// public class ExtraResolver { + private static readonly char[] _digits = new[] { '0', '1', '2', '3', '4', '5', '6', '7', '8', '9' }; private readonly NamingOptions _options; /// @@ -63,9 +63,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; diff --git a/Emby.Naming/Video/VideoListResolver.cs b/Emby.Naming/Video/VideoListResolver.cs index 7da2dcd7a5..ed7d511a39 100644 --- a/Emby.Naming/Video/VideoListResolver.cs +++ b/Emby.Naming/Video/VideoListResolver.cs @@ -21,7 +21,7 @@ namespace Emby.Naming.Video /// The naming options. /// Indication we should consider multi-versions of content. /// Returns enumerable of which groups files together when related. - public static IEnumerable Resolve(List files, NamingOptions namingOptions, bool supportMultiVersion = true) + public static IEnumerable Resolve(IEnumerable files, NamingOptions namingOptions, bool supportMultiVersion = true) { var videoInfos = files .Select(i => VideoResolver.Resolve(i.FullName, i.IsDirectory, namingOptions)) diff --git a/Emby.Naming/Video/VideoResolver.cs b/Emby.Naming/Video/VideoResolver.cs index 3b1d906c64..4c9df27f50 100644 --- a/Emby.Naming/Video/VideoResolver.cs +++ b/Emby.Naming/Video/VideoResolver.cs @@ -87,9 +87,9 @@ namespace Emby.Naming.Video year = cleanDateTimeResult.Year; if (extraResult.ExtraType == null - && TryCleanString(name, namingOptions, out ReadOnlySpan newName)) + && TryCleanString(name, namingOptions, out var newName)) { - name = newName.ToString(); + name = newName; } } @@ -138,7 +138,7 @@ namespace Emby.Naming.Video /// The naming options. /// Clean name. /// True if cleaning of name was successful. - public static bool TryCleanString([NotNullWhen(true)] string? name, NamingOptions namingOptions, out ReadOnlySpan 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/Emby.Notifications.csproj b/Emby.Notifications/Emby.Notifications.csproj index 5a2aea6423..d200682e65 100644 --- a/Emby.Notifications/Emby.Notifications.csproj +++ b/Emby.Notifications/Emby.Notifications.csproj @@ -6,13 +6,9 @@ - net5.0 + net6.0 false true - true - enable - AllEnabledByDefault - ../jellyfin.ruleset diff --git a/Emby.Notifications/NotificationEntryPoint.cs b/Emby.Notifications/NotificationEntryPoint.cs index 7433d3c8ae..e8ae14ff22 100644 --- a/Emby.Notifications/NotificationEntryPoint.cs +++ b/Emby.Notifications/NotificationEntryPoint.cs @@ -77,7 +77,6 @@ namespace Emby.Notifications { _libraryManager.ItemAdded += OnLibraryManagerItemAdded; _appHost.HasPendingRestartChanged += OnAppHostHasPendingRestartChanged; - _appHost.HasUpdateAvailableChanged += OnAppHostHasUpdateAvailableChanged; _activityManager.EntryCreated += OnActivityManagerEntryCreated; return Task.CompletedTask; @@ -132,25 +131,6 @@ namespace Emby.Notifications return _config.GetConfiguration("notifications"); } - private async void OnAppHostHasUpdateAvailableChanged(object? sender, EventArgs e) - { - if (!_appHost.HasUpdateAvailable) - { - return; - } - - var type = NotificationType.ApplicationUpdateAvailable.ToString(); - - var notification = new NotificationRequest - { - Description = "Please see jellyfin.org for details.", - NotificationType = type, - Name = _localization.GetLocalizedString("NewVersionIsAvailable") - }; - - await SendNotification(notification, null).ConfigureAwait(false); - } - private void OnLibraryManagerItemAdded(object? sender, ItemChangeEventArgs e) { if (!FilterItem(e.Item)) @@ -325,7 +305,6 @@ namespace Emby.Notifications _libraryManager.ItemAdded -= OnLibraryManagerItemAdded; _appHost.HasPendingRestartChanged -= OnAppHostHasPendingRestartChanged; - _appHost.HasUpdateAvailableChanged -= OnAppHostHasUpdateAvailableChanged; _activityManager.EntryCreated -= OnActivityManagerEntryCreated; _disposed = true; diff --git a/Emby.Photos/Emby.Photos.csproj b/Emby.Photos/Emby.Photos.csproj index 2b66181599..bf6252c195 100644 --- a/Emby.Photos/Emby.Photos.csproj +++ b/Emby.Photos/Emby.Photos.csproj @@ -19,13 +19,9 @@ - net5.0 + net6.0 false true - true - enable - AllEnabledByDefault - ../jellyfin.ruleset diff --git a/Emby.Server.Implementations/AppBase/ConfigurationHelper.cs b/Emby.Server.Implementations/AppBase/ConfigurationHelper.cs index 0308a68e42..3a916e5c01 100644 --- a/Emby.Server.Implementations/AppBase/ConfigurationHelper.cs +++ b/Emby.Server.Implementations/AppBase/ConfigurationHelper.cs @@ -41,20 +41,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 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 82995deb30..6fd152a42d 100644 --- a/Emby.Server.Implementations/ApplicationHost.cs +++ b/Emby.Server.Implementations/ApplicationHost.cs @@ -38,7 +38,6 @@ using Emby.Server.Implementations.Playlists; using Emby.Server.Implementations.Plugins; using Emby.Server.Implementations.QuickConnect; using Emby.Server.Implementations.ScheduledTasks; -using Emby.Server.Implementations.Security; using Emby.Server.Implementations.Serialization; using Emby.Server.Implementations.Session; using Emby.Server.Implementations.SyncPlay; @@ -59,7 +58,6 @@ using MediaBrowser.Controller.Channels; using MediaBrowser.Controller.Chapters; using MediaBrowser.Controller.Collections; using MediaBrowser.Controller.Configuration; -using MediaBrowser.Controller.Devices; using MediaBrowser.Controller.Dlna; using MediaBrowser.Controller.Drawing; using MediaBrowser.Controller.Dto; @@ -75,7 +73,6 @@ using MediaBrowser.Controller.Plugins; using MediaBrowser.Controller.Providers; using MediaBrowser.Controller.QuickConnect; using MediaBrowser.Controller.Resolvers; -using MediaBrowser.Controller.Security; using MediaBrowser.Controller.Session; using MediaBrowser.Controller.Sorting; using MediaBrowser.Controller.Subtitles; @@ -103,7 +100,6 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Prometheus.DotNetRuntime; -using OperatingSystem = MediaBrowser.Common.System.OperatingSystem; using WebSocketManager = Emby.Server.Implementations.HttpServer.WebSocketManager; namespace Emby.Server.Implementations @@ -118,6 +114,11 @@ namespace Emby.Server.Implementations /// private static readonly string[] _relevantEnvVarPrefixes = { "JELLYFIN_", "DOTNET_", "ASPNETCORE_" }; + /// + /// The disposable parts. + /// + private readonly List _disposableParts = new List(); + private readonly IFileSystem _fileSystemManager; private readonly IConfiguration _startupConfig; private readonly IXmlSerializer _xmlSerializer; @@ -129,6 +130,62 @@ namespace Emby.Server.Implementations private ISessionManager _sessionManager; private string[] _urlPrefixes; + /// + /// Gets or sets all concrete types. + /// + /// All concrete types. + private Type[] _allConcreteTypes; + + private DeviceId _deviceId; + + private bool _disposed = false; + + /// + /// Initializes a new instance of the class. + /// + /// Instance of the interface. + /// Instance of the interface. + /// Instance of the interface. + /// The interface. + /// Instance of the interface. + /// Instance of the interface. + public ApplicationHost( + IServerApplicationPaths applicationPaths, + ILoggerFactory loggerFactory, + IStartupOptions options, + IConfiguration startupConfig, + IFileSystem fileSystem, + IServiceCollection serviceCollection) + { + ApplicationPaths = applicationPaths; + LoggerFactory = loggerFactory; + _startupOptions = options; + _startupConfig = startupConfig; + _fileSystemManager = fileSystem; + ServiceCollection = serviceCollection; + + Logger = LoggerFactory.CreateLogger(); + fileSystem.AddShortcutHandler(new MbLinkShortcutHandler(fileSystem)); + + ApplicationVersion = typeof(ApplicationHost).Assembly.GetName().Version; + ApplicationVersionString = ApplicationVersion.ToString(3); + ApplicationUserAgent = Name.Replace(' ', '-') + "/" + ApplicationVersionString; + + _xmlSerializer = new MyXmlSerializer(); + ConfigurationManager = new ServerConfigurationManager(ApplicationPaths, LoggerFactory, _xmlSerializer, _fileSystemManager); + _pluginManager = new PluginManager( + LoggerFactory.CreateLogger(), + this, + ConfigurationManager.Configuration, + ApplicationPaths.PluginsPath, + ApplicationVersion); + } + + /// + /// Occurs when [has pending restart changed]. + /// + public event EventHandler HasPendingRestartChanged; + /// /// Gets a value indicating whether this instance can self restart. /// @@ -150,13 +207,7 @@ namespace Emby.Server.Implementations return false; } - if (OperatingSystem.Id == OperatingSystemId.Windows - || OperatingSystem.Id == OperatingSystemId.Darwin) - { - return true; - } - - return false; + return OperatingSystem.IsWindows() || OperatingSystem.IsMacOS(); } } @@ -165,11 +216,6 @@ namespace Emby.Server.Implementations /// public INetworkManager NetManager { get; internal set; } - /// - /// Occurs when [has pending restart changed]. - /// - public event EventHandler HasPendingRestartChanged; - /// /// Gets a value indicating whether this instance has changes that require the entire application to restart. /// @@ -197,17 +243,6 @@ namespace Emby.Server.Implementations /// The application paths. protected IServerApplicationPaths ApplicationPaths { get; set; } - /// - /// Gets or sets all concrete types. - /// - /// All concrete types. - private Type[] _allConcreteTypes; - - /// - /// The disposable parts. - /// - private readonly List _disposableParts = new List(); - /// /// Gets or sets the configuration manager. /// @@ -234,47 +269,55 @@ namespace Emby.Server.Implementations /// public string PublishedServerUrl => _startupOptions.PublishedServerUrl ?? _startupConfig[UdpServer.AddressOverrideConfigKey]; + /// + public Version ApplicationVersion { get; } + + /// + public string ApplicationVersionString { get; } + /// - /// Initializes a new instance of the class. + /// Gets the current application user agent. /// - /// Instance of the interface. - /// Instance of the interface. - /// Instance of the interface. - /// The interface. - /// Instance of the interface. - /// Instance of the interface. - public ApplicationHost( - IServerApplicationPaths applicationPaths, - ILoggerFactory loggerFactory, - IStartupOptions options, - IConfiguration startupConfig, - IFileSystem fileSystem, - IServiceCollection serviceCollection) - { - ApplicationPaths = applicationPaths; - LoggerFactory = loggerFactory; - _startupOptions = options; - _startupConfig = startupConfig; - _fileSystemManager = fileSystem; - ServiceCollection = serviceCollection; + /// The application user agent. + public string ApplicationUserAgent { get; } - Logger = LoggerFactory.CreateLogger(); - fileSystem.AddShortcutHandler(new MbLinkShortcutHandler(fileSystem)); + /// + /// Gets the email address for use within a comment section of a user agent field. + /// Presently used to provide contact information to MusicBrainz service. + /// + public string ApplicationUserAgentAddress => "team@jellyfin.org"; - ApplicationVersion = typeof(ApplicationHost).Assembly.GetName().Version; - ApplicationVersionString = ApplicationVersion.ToString(3); - ApplicationUserAgent = Name.Replace(' ', '-') + "/" + ApplicationVersionString; + /// + /// Gets the current application name. + /// + /// The application name. + public string ApplicationProductName { get; } = FileVersionInfo.GetVersionInfo(Assembly.GetEntryAssembly().Location).ProductName; - _xmlSerializer = new MyXmlSerializer(); - ConfigurationManager = new ServerConfigurationManager(ApplicationPaths, LoggerFactory, _xmlSerializer, _fileSystemManager); - _pluginManager = new PluginManager( - LoggerFactory.CreateLogger(), - this, - ConfigurationManager.Configuration, - ApplicationPaths.PluginsPath, - ApplicationVersion); + public string SystemId + { + get + { + _deviceId ??= new DeviceId(ApplicationPaths, LoggerFactory); + + return _deviceId.Value; + } } + /// + public string Name => ApplicationProductName; + + private string CertificatePath { get; set; } + + public X509Certificate2 Certificate { get; private set; } + + /// + public bool ListenWithHttps => Certificate != null && ConfigurationManager.GetNetworkConfiguration().EnableHttps; + + public string FriendlyName => + string.IsNullOrEmpty(ConfigurationManager.Configuration.ServerName) + ? Environment.MachineName + : ConfigurationManager.Configuration.ServerName; + /// /// 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. @@ -307,45 +350,6 @@ namespace Emby.Server.Implementations .Replace(appPaths.InternalMetadataPath, appPaths.VirtualInternalMetadataPath, StringComparison.OrdinalIgnoreCase); } - /// - public Version ApplicationVersion { get; } - - /// - public string ApplicationVersionString { get; } - - /// - /// Gets the current application user agent. - /// - /// The application user agent. - public string ApplicationUserAgent { get; } - - /// - /// Gets the email address for use within a comment section of a user agent field. - /// Presently used to provide contact information to MusicBrainz service. - /// - public string ApplicationUserAgentAddress => "team@jellyfin.org"; - - /// - /// Gets the current application name. - /// - /// The application name. - public string ApplicationProductName { get; } = FileVersionInfo.GetVersionInfo(Assembly.GetEntryAssembly().Location).ProductName; - - private DeviceId _deviceId; - - public string SystemId - { - get - { - _deviceId ??= new DeviceId(ApplicationPaths, LoggerFactory); - - return _deviceId.Value; - } - } - - /// - public string Name => ApplicationProductName; - /// /// Creates an instance of type and resolves all constructor dependencies. /// @@ -463,6 +467,7 @@ namespace Emby.Server.Implementations /// /// Runs the startup tasks. /// + /// The cancellation token. /// . public async Task RunStartupTasksAsync(CancellationToken cancellationToken) { @@ -476,7 +481,7 @@ namespace Emby.Server.Implementations _mediaEncoder.SetFFmpegPath(); - Logger.LogInformation("ServerId: {0}", SystemId); + Logger.LogInformation("ServerId: {ServerId}", SystemId); var entryPoints = GetExports(); @@ -543,12 +548,8 @@ 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(); @@ -601,8 +602,6 @@ namespace Emby.Server.Implementations ServiceCollection.AddSingleton(); - ServiceCollection.AddSingleton(); - ServiceCollection.AddSingleton(); ServiceCollection.AddSingleton(); @@ -624,8 +623,6 @@ namespace Emby.Server.Implementations ServiceCollection.AddSingleton(); - ServiceCollection.AddSingleton(); - ServiceCollection.AddSingleton(); ServiceCollection.AddSingleton(); @@ -661,8 +658,7 @@ namespace Emby.Server.Implementations ServiceCollection.AddSingleton(); - ServiceCollection.AddSingleton(); - ServiceCollection.AddSingleton(); + ServiceCollection.AddScoped(); ServiceCollection.AddSingleton(); ServiceCollection.AddSingleton(); @@ -691,8 +687,6 @@ namespace Emby.Server.Implementations _mediaEncoder = Resolve(); _sessionManager = Resolve(); - ((AuthenticationRepository)Resolve()).Initialize(); - SetStaticProperties(); var userDataRepo = (SqliteUserDataRepository)Resolve(); @@ -721,7 +715,7 @@ namespace Emby.Server.Implementations logger.LogInformation("Environment Variables: {EnvVars}", relevantEnvVars); logger.LogInformation("Arguments: {Args}", commandLineArgs); - logger.LogInformation("Operating system: {OS}", OperatingSystem.Name); + logger.LogInformation("Operating system: {OS}", MediaBrowser.Common.System.OperatingSystem.Name); logger.LogInformation("Architecture: {Architecture}", RuntimeInformation.OSArchitecture); logger.LogInformation("64-Bit Process: {Is64Bit}", Environment.Is64BitProcess); logger.LogInformation("User Interactive: {IsUserInteractive}", Environment.UserInteractive); @@ -731,30 +725,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; } @@ -762,7 +753,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; } } @@ -873,10 +864,6 @@ namespace Emby.Server.Implementations } } - private CertificateInfo CertificateInfo { get; set; } - - public X509Certificate2 Certificate { get; private set; } - private IEnumerable GetUrlPrefixes() { var hosts = new[] { "+" }; @@ -888,7 +875,7 @@ namespace Emby.Server.Implementations "http://" + i + ":" + HttpPort + "/" }; - if (CertificateInfo != null) + if (Certificate != null) { prefixes.Add("https://" + i + ":" + HttpsPort + "/"); } @@ -952,7 +939,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)) { @@ -1080,9 +1067,9 @@ namespace Emby.Server.Implementations /// /// Gets the system status. /// - /// Where this request originated. + /// Where this request originated. /// SystemInfo. - public SystemInfo GetSystemInfo(IPAddress source) + public SystemInfo GetSystemInfo(HttpRequest request) { return new SystemInfo { @@ -1098,16 +1085,14 @@ namespace Emby.Server.Implementations ItemsByNamePath = ApplicationPaths.InternalMetadataPath, InternalMetadataPath = ApplicationPaths.InternalMetadataPath, CachePath = ApplicationPaths.CachePath, - OperatingSystem = OperatingSystem.Id.ToString(), - OperatingSystemDisplayName = OperatingSystem.Name, + OperatingSystem = MediaBrowser.Common.System.OperatingSystem.Id.ToString(), + OperatingSystemDisplayName = MediaBrowser.Common.System.OperatingSystem.Name, CanSelfRestart = CanSelfRestart, CanLaunchWebBrowser = CanLaunchWebBrowser, - HasUpdateAvailable = HasUpdateAvailable, TranscodingTempPath = ConfigurationManager.GetTranscodePath(), ServerName = FriendlyName, - LocalAddress = GetSmartApiUrl(source), + LocalAddress = GetSmartApiUrl(request), SupportsLibraryMonitor = true, - EncoderLocation = _mediaEncoder.EncoderLocation, SystemArchitecture = RuntimeInformation.OSArchitecture, PackageName = _startupOptions.PackageName }; @@ -1118,25 +1103,22 @@ namespace Emby.Server.Implementations .Select(i => new WakeOnLanInfo(i)) .ToList(); - public PublicSystemInfo GetPublicSystemInfo(IPAddress source) + public PublicSystemInfo GetPublicSystemInfo(HttpRequest request) { return new PublicSystemInfo { Version = ApplicationVersionString, ProductName = ApplicationProductName, Id = SystemId, - OperatingSystem = OperatingSystem.Id.ToString(), + OperatingSystem = MediaBrowser.Common.System.OperatingSystem.Id.ToString(), ServerName = FriendlyName, - LocalAddress = GetSmartApiUrl(source), + LocalAddress = GetSmartApiUrl(request), StartupWizardCompleted = ConfigurationManager.CommonConfiguration.IsStartupWizardCompleted }; } /// - public bool ListenWithHttps => Certificate != null && ConfigurationManager.GetNetworkConfiguration().EnableHttps; - - /// - public string GetSmartApiUrl(IPAddress ipAddress, int? port = null) + public string GetSmartApiUrl(IPAddress remoteAddr, int? port = null) { // Published server ends with a / if (!string.IsNullOrEmpty(PublishedServerUrl)) @@ -1145,7 +1127,7 @@ namespace Emby.Server.Implementations return PublishedServerUrl.Trim('/'); } - string smart = NetManager.GetBindInterface(ipAddress, out port); + 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)) { @@ -1158,6 +1140,18 @@ namespace Emby.Server.Implementations /// public string GetSmartApiUrl(HttpRequest request, int? port = null) { + // 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)) { @@ -1208,27 +1202,20 @@ namespace Emby.Server.Implementations } /// - public string GetLocalApiUrl(string host, string scheme = null, int? port = null) + public string GetLocalApiUrl(string hostname, string scheme = null, int? port = null) { // NOTE: If no BaseUrl is set then UriBuilder appends a trailing slash, but if there is no BaseUrl it does // not. For consistency, always trim the trailing slash. return new UriBuilder { Scheme = scheme ?? (ListenWithHttps ? Uri.UriSchemeHttps : Uri.UriSchemeHttp), - Host = host, + Host = hostname, Port = port ?? (ListenWithHttps ? HttpsPort : HttpPort), Path = ConfigurationManager.GetNetworkConfiguration().BaseUrl }.ToString().TrimEnd('/'); } - public string FriendlyName => - string.IsNullOrEmpty(ConfigurationManager.Configuration.ServerName) - ? Environment.MachineName - : ConfigurationManager.Configuration.ServerName; - - /// - /// Shuts down. - /// + /// public async Task Shutdown() { if (IsShuttingDown) @@ -1252,26 +1239,6 @@ namespace Emby.Server.Implementations protected abstract void ShutdownInternal(); - public event EventHandler HasUpdateAvailableChanged; - - private bool _hasUpdateAvailable; - - public bool HasUpdateAvailable - { - get => _hasUpdateAvailable; - set - { - var fireEvent = value && !_hasUpdateAvailable; - - _hasUpdateAvailable = value; - - if (fireEvent) - { - HasUpdateAvailableChanged?.Invoke(this, EventArgs.Empty); - } - } - } - public IEnumerable GetApiPluginAssemblies() { var assemblies = _allConcreteTypes @@ -1286,41 +1253,7 @@ namespace Emby.Server.Implementations } } - public virtual void LaunchUrl(string url) - { - if (!CanLaunchWebBrowser) - { - throw new NotSupportedException(); - } - - var process = new Process - { - StartInfo = new ProcessStartInfo - { - FileName = url, - UseShellExecute = true, - ErrorDialog = false - }, - EnableRaisingEvents = true - }; - process.Exited += (sender, args) => ((Process)sender).Dispose(); - - try - { - process.Start(); - } - catch (Exception ex) - { - Logger.LogError(ex, "Error launching url: {url}", url); - throw; - } - } - - private bool _disposed = false; - - /// - /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources. - /// + /// public void Dispose() { Dispose(true); @@ -1365,11 +1298,4 @@ namespace Emby.Server.Implementations _disposed = true; } } - - internal class CertificateInfo - { - public string Path { get; set; } - - public string Password { get; set; } - } } diff --git a/Emby.Server.Implementations/Archiving/ZipClient.cs b/Emby.Server.Implementations/Archiving/ZipClient.cs index 591ae547d6..9e1d550ebc 100644 --- a/Emby.Server.Implementations/Archiving/ZipClient.cs +++ b/Emby.Server.Implementations/Archiving/ZipClient.cs @@ -45,6 +45,7 @@ namespace Emby.Server.Implementations.Archiving options.Overwrite = true; } + Directory.CreateDirectory(targetPath); reader.WriteAllToDirectory(targetPath, options); } @@ -58,6 +59,7 @@ namespace Emby.Server.Implementations.Archiving Overwrite = overwriteExistingFiles }; + Directory.CreateDirectory(targetPath); reader.WriteAllToDirectory(targetPath, options); } @@ -71,6 +73,7 @@ namespace Emby.Server.Implementations.Archiving Overwrite = overwriteExistingFiles }; + Directory.CreateDirectory(targetPath); reader.WriteAllToDirectory(targetPath, options); } @@ -120,6 +123,7 @@ namespace Emby.Server.Implementations.Archiving Overwrite = overwriteExistingFiles }; + Directory.CreateDirectory(targetPath); reader.WriteAllToDirectory(targetPath, options); } @@ -151,6 +155,7 @@ namespace Emby.Server.Implementations.Archiving Overwrite = overwriteExistingFiles }; + Directory.CreateDirectory(targetPath); reader.WriteAllToDirectory(targetPath, options); } } diff --git a/Emby.Server.Implementations/Channels/ChannelManager.cs b/Emby.Server.Implementations/Channels/ChannelManager.cs index 093607dd5e..09aee602af 100644 --- a/Emby.Server.Implementations/Channels/ChannelManager.cs +++ b/Emby.Server.Implementations/Channels/ChannelManager.cs @@ -10,8 +10,8 @@ using System.Threading; using System.Threading.Tasks; using Jellyfin.Data.Entities; using Jellyfin.Data.Enums; -using MediaBrowser.Common.Extensions; using Jellyfin.Extensions.Json; +using MediaBrowser.Common.Extensions; using MediaBrowser.Common.Progress; using MediaBrowser.Controller.Channels; using MediaBrowser.Controller.Configuration; @@ -102,7 +102,7 @@ namespace Emby.Server.Implementations.Channels var internalChannel = _libraryManager.GetItemById(item.ChannelId); var channel = Channels.FirstOrDefault(i => GetInternalChannelId(i.Name).Equals(internalChannel.Id)); - return !(channel is IDisableMediaSourceDisplay); + return channel is not IDisableMediaSourceDisplay; } /// @@ -586,7 +586,7 @@ namespace Emby.Server.Implementations.Channels { var supportsLatest = provider is ISupportsLatestMedia; - return new ChannelFeatures + return new ChannelFeatures(channel.Name, channel.Id) { CanFilter = !features.MaxPageSize.HasValue, CanSearch = provider is ISearchableChannel, @@ -596,8 +596,6 @@ namespace Emby.Server.Implementations.Channels MediaTypes = features.MediaTypes.ToArray(), SupportsSortOrderToggle = features.SupportsSortOrderToggle, SupportsLatestMedia = supportsLatest, - Name = channel.Name, - Id = channel.Id.ToString("N", CultureInfo.InvariantCulture), SupportsContentDownloading = features.SupportsContentDownloading, AutoRefreshLevels = features.AutoRefreshLevels }; @@ -815,7 +813,7 @@ namespace Emby.Server.Implementations.Channels { if (_fileSystem.GetLastWriteTimeUtc(cachePath).Add(cacheLength) > DateTime.UtcNow) { - await using FileStream jsonStream = File.OpenRead(cachePath); + await using FileStream jsonStream = AsyncFile.OpenRead(cachePath); var cachedResult = await JsonSerializer.DeserializeAsync(jsonStream, _jsonOptions, cancellationToken).ConfigureAwait(false); if (cachedResult != null) { @@ -838,7 +836,7 @@ namespace Emby.Server.Implementations.Channels { if (_fileSystem.GetLastWriteTimeUtc(cachePath).Add(cacheLength) > DateTime.UtcNow) { - await using FileStream jsonStream = File.OpenRead(cachePath); + await using FileStream jsonStream = AsyncFile.OpenRead(cachePath); var cachedResult = await JsonSerializer.DeserializeAsync(jsonStream, _jsonOptions, cancellationToken).ConfigureAwait(false); if (cachedResult != null) { @@ -880,7 +878,7 @@ namespace Emby.Server.Implementations.Channels } } - private async Task CacheResponse(object result, string path) + private async Task CacheResponse(ChannelItemResult result, string path) { try { @@ -1079,11 +1077,11 @@ namespace Emby.Server.Implementations.Channels // 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)) { diff --git a/Emby.Server.Implementations/Collections/CollectionManager.cs b/Emby.Server.Implementations/Collections/CollectionManager.cs index 82d80fc83c..79ef70fffd 100644 --- a/Emby.Server.Implementations/Collections/CollectionManager.cs +++ b/Emby.Server.Implementations/Collections/CollectionManager.cs @@ -1,5 +1,3 @@ -#nullable disable - using System; using System.Collections.Generic; using System.IO; @@ -63,13 +61,13 @@ namespace Emby.Server.Implementations.Collections } /// - public event EventHandler CollectionCreated; + public event EventHandler? CollectionCreated; /// - public event EventHandler ItemsAddedToCollection; + public event EventHandler? ItemsAddedToCollection; /// - public event EventHandler ItemsRemovedFromCollection; + public event EventHandler? ItemsRemovedFromCollection; private IEnumerable FindFolders(string path) { @@ -80,14 +78,12 @@ namespace Emby.Server.Implementations.Collections .Where(i => _fileSystem.AreEqual(path, i.Path) || _fileSystem.ContainsSubPath(i.Path, path)); } - internal async Task EnsureLibraryFolder(string path, bool createIfNeeded) + internal async Task EnsureLibraryFolder(string path, bool createIfNeeded) { - var existingFolders = FindFolders(path) - .ToList(); - - if (existingFolders.Count > 0) + var existingFolder = FindFolders(path).FirstOrDefault(); + if (existingFolder != null) { - return existingFolders[0]; + return existingFolder; } if (!createIfNeeded) @@ -99,7 +95,7 @@ namespace Emby.Server.Implementations.Collections var libraryOptions = new LibraryOptions { - PathInfos = new[] { new MediaPathInfo { Path = path } }, + PathInfos = new[] { new MediaPathInfo(path) }, EnableRealtimeMonitor = false, SaveLocalMetadata = true }; @@ -116,7 +112,7 @@ namespace Emby.Server.Implementations.Collections return Path.Combine(_appPaths.DataPath, "collections"); } - private Task GetCollectionsFolder(bool createIfNeeded) + private Task GetCollectionsFolder(bool createIfNeeded) { return EnsureLibraryFolder(GetCollectionsFolderPath(), createIfNeeded); } @@ -164,7 +160,7 @@ namespace Emby.Server.Implementations.Collections DateCreated = DateTime.UtcNow }; - parentFolder.AddChild(collection, CancellationToken.None); + parentFolder.AddChild(collection); if (options.ItemIdList.Count > 0) { @@ -200,13 +196,12 @@ namespace Emby.Server.Implementations.Collections } /// - public Task AddToCollectionAsync(Guid collectionId, IEnumerable ids) - => AddToCollectionAsync(collectionId, ids, true, new MetadataRefreshOptions(new DirectoryService(_fileSystem))); + public Task AddToCollectionAsync(Guid collectionId, IEnumerable itemIds) + => AddToCollectionAsync(collectionId, itemIds, true, new MetadataRefreshOptions(new DirectoryService(_fileSystem))); private async Task AddToCollectionAsync(Guid collectionId, IEnumerable ids, bool fireEvent, MetadataRefreshOptions refreshOptions) { - var collection = _libraryManager.GetItemById(collectionId) as BoxSet; - if (collection == null) + if (_libraryManager.GetItemById(collectionId) is not BoxSet collection) { throw new ArgumentException("No collection exists with the supplied Id"); } @@ -258,9 +253,7 @@ namespace Emby.Server.Implementations.Collections /// public async Task RemoveFromCollectionAsync(Guid collectionId, IEnumerable itemIds) { - var collection = _libraryManager.GetItemById(collectionId) as BoxSet; - - if (collection == null) + if (_libraryManager.GetItemById(collectionId) is not BoxSet collection) { throw new ArgumentException("No collection exists with the supplied Id"); } @@ -314,11 +307,7 @@ namespace Emby.Server.Implementations.Collections foreach (var item in items) { - if (item is not ISupportsBoxSetGrouping) - { - results[item.Id] = item; - } - else + if (item is ISupportsBoxSetGrouping) { var itemId = item.Id; @@ -342,6 +331,7 @@ namespace Emby.Server.Implementations.Collections } var alreadyInResults = false; + // this is kind of a performance hack because only Video has alternate versions that should be in a box set? if (item is Video video) { @@ -357,11 +347,13 @@ namespace Emby.Server.Implementations.Collections } } - if (!alreadyInResults) + if (alreadyInResults) { - results[itemId] = item; + continue; } } + + results[item.Id] = item; } return results.Values; diff --git a/Emby.Server.Implementations/Cryptography/CryptographyProvider.cs b/Emby.Server.Implementations/Cryptography/CryptographyProvider.cs index 4a9b280852..673810c49d 100644 --- a/Emby.Server.Implementations/Cryptography/CryptographyProvider.cs +++ b/Emby.Server.Implementations/Cryptography/CryptographyProvider.cs @@ -10,8 +10,12 @@ namespace Emby.Server.Implementations.Cryptography /// /// Class providing abstractions over cryptographic functions. /// - public class CryptographyProvider : ICryptoProvider, IDisposable + public class CryptographyProvider : ICryptoProvider { + // 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 private static readonly HashSet _supportedHashMethods = new HashSet() { "MD5", @@ -30,22 +34,6 @@ namespace Emby.Server.Implementations.Cryptography "System.Security.Cryptography.SHA512" }; - private RandomNumberGenerator _randomNumberGenerator; - - private bool _disposed; - - /// - /// Initializes a new instance of the class. - /// - public CryptographyProvider() - { - // 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(); - } - /// public string DefaultHashMethod => "PBKDF2"; @@ -101,36 +89,6 @@ namespace Emby.Server.Implementations.Cryptography /// public byte[] GenerateSalt(int length) - { - byte[] salt = new byte[length]; - _randomNumberGenerator.GetBytes(salt); - return salt; - } - - /// - public void Dispose() - { - Dispose(true); - GC.SuppressFinalize(this); - } - - /// - /// Releases unmanaged and - optionally - managed resources. - /// - /// true to release both managed and unmanaged resources; false to release only unmanaged resources. - protected virtual void Dispose(bool disposing) - { - if (_disposed) - { - return; - } - - if (disposing) - { - _randomNumberGenerator.Dispose(); - } - - _disposed = true; - } + => RandomNumberGenerator.GetBytes(length); } } diff --git a/Emby.Server.Implementations/Data/BaseSqliteRepository.cs b/Emby.Server.Implementations/Data/BaseSqliteRepository.cs index 6f23a0888e..73c31f49d1 100644 --- a/Emby.Server.Implementations/Data/BaseSqliteRepository.cs +++ b/Emby.Server.Implementations/Data/BaseSqliteRepository.cs @@ -61,7 +61,7 @@ namespace Emby.Server.Implementations.Data protected virtual int? CacheSize => null; /// - /// Gets the journal mode. + /// Gets the journal mode. . /// /// The journal mode. protected virtual string JournalMode => "TRUNCATE"; @@ -98,7 +98,7 @@ namespace Emby.Server.Implementations.Data /// The write connection. protected SQLiteDatabaseConnection WriteConnection { get; set; } - protected ManagedConnection GetConnection(bool _ = false) + protected ManagedConnection GetConnection(bool readOnly = false) { WriteLock.Wait(); if (WriteConnection != null) @@ -249,55 +249,4 @@ namespace Emby.Server.Implementations.Data _disposed = true; } } - - /// - /// The disk synchronization mode, controls how aggressively SQLite will write data - /// all the way out to physical storage. - /// - public enum SynchronousMode - { - /// - /// SQLite continues without syncing as soon as it has handed data off to the operating system. - /// - Off = 0, - - /// - /// SQLite database engine will still sync at the most critical moments. - /// - Normal = 1, - - /// - /// SQLite database engine will use the xSync method of the VFS - /// to ensure that all content is safely written to the disk surface prior to continuing. - /// - Full = 2, - - /// - /// EXTRA synchronous is like FULL with the addition that the directory containing a rollback journal - /// is synced after that journal is unlinked to commit a transaction in DELETE mode. - /// - Extra = 3 - } - - /// - /// Storage mode used by temporary database files. - /// - public enum TempStoreMode - { - /// - /// The compile-time C preprocessor macro SQLITE_TEMP_STORE - /// is used to determine where temporary tables and indices are stored. - /// - Default = 0, - - /// - /// Temporary tables and indices are stored in a file. - /// - File = 1, - - /// - /// Temporary tables and indices are kept in as if they were pure in-memory databases memory. - /// - Memory = 2 - } } diff --git a/Emby.Server.Implementations/Data/ManagedConnection.cs b/Emby.Server.Implementations/Data/ManagedConnection.cs index afc8966f9c..44dad5b178 100644 --- a/Emby.Server.Implementations/Data/ManagedConnection.cs +++ b/Emby.Server.Implementations/Data/ManagedConnection.cs @@ -9,8 +9,10 @@ namespace Emby.Server.Implementations.Data { public 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 3289e76098..381eb92a88 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 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 2cb10765ff..13f1df7c86 100644 --- a/Emby.Server.Implementations/Data/SqliteItemRepository.cs +++ b/Emby.Server.Implementations/Data/SqliteItemRepository.cs @@ -16,7 +16,6 @@ using Emby.Server.Implementations.Playlists; using Jellyfin.Data.Enums; using Jellyfin.Extensions; using Jellyfin.Extensions.Json; -using MediaBrowser.Common.Extensions; using MediaBrowser.Controller; using MediaBrowser.Controller.Channels; using MediaBrowser.Controller.Configuration; @@ -25,7 +24,6 @@ using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.Audio; using MediaBrowser.Controller.Entities.Movies; using MediaBrowser.Controller.Entities.TV; -using MediaBrowser.Controller.Extensions; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.LiveTv; using MediaBrowser.Controller.Persistence; @@ -48,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; @@ -57,6 +60,231 @@ namespace Emby.Server.Implementations.Data private readonly TypeMapper _typeMapper; private readonly JsonSerializerOptions _jsonOptions; + private readonly ItemFields[] _allItemFields = Enum.GetValues(); + + private static readonly string[] _retriveItemColumns = + { + "type", + "data", + "StartDate", + "EndDate", + "ChannelId", + "IsMovie", + "IsSeries", + "EpisodeTitle", + "IsRepeat", + "CommunityRating", + "CustomRating", + "IndexNumber", + "IsLocked", + "PreferredMetadataLanguage", + "PreferredMetadataCountryCode", + "Width", + "Height", + "DateLastRefreshed", + "Name", + "Path", + "PremiereDate", + "Overview", + "ParentIndexNumber", + "ProductionYear", + "OfficialRating", + "ForcedSortName", + "RunTimeTicks", + "Size", + "DateCreated", + "DateModified", + "guid", + "Genres", + "ParentId", + "Audio", + "ExternalServiceId", + "IsInMixedFolder", + "DateLastSaved", + "LockedFields", + "Studios", + "Tags", + "TrailerTypes", + "OriginalTitle", + "PrimaryVersionId", + "DateLastMediaAdded", + "Album", + "CriticRating", + "IsVirtualItem", + "SeriesName", + "SeasonName", + "SeasonId", + "SeriesId", + "PresentationUniqueKey", + "InheritedParentalRatingValue", + "ExternalSeriesId", + "Tagline", + "ProviderIds", + "Images", + "ProductionLocations", + "ExtraIds", + "TotalBitrate", + "ExtraType", + "Artists", + "AlbumArtists", + "ExternalId", + "SeriesPresentationUniqueKey", + "ShowId", + "OwnerId" + }; + + private static readonly string _retriveItemColumnsSelectQuery = $"select {string.Join(',', _retriveItemColumns)} from TypedBaseItems where guid = @guid"; + + private static readonly string[] _mediaStreamSaveColumns = + { + "ItemId", + "StreamIndex", + "StreamType", + "Codec", + "Language", + "ChannelLayout", + "Profile", + "AspectRatio", + "Path", + "IsInterlaced", + "BitRate", + "Channels", + "SampleRate", + "IsDefault", + "IsForced", + "IsExternal", + "Height", + "Width", + "AverageFrameRate", + "RealFrameRate", + "Level", + "PixelFormat", + "BitDepth", + "IsAnamorphic", + "RefFrames", + "CodecTag", + "Comment", + "NalLengthSize", + "IsAvc", + "Title", + "TimeBase", + "CodecTimeBase", + "ColorPrimaries", + "ColorSpace", + "ColorTransfer" + }; + + private static readonly string _mediaStreamSaveColumnsInsertQuery = + $"insert into mediastreams ({string.Join(',', _mediaStreamSaveColumns)}) values "; + + private static readonly string _mediaStreamSaveColumnsSelectQuery = + $"select {string.Join(',', _mediaStreamSaveColumns)} from mediastreams where ItemId=@ItemId"; + + private static readonly string[] _mediaAttachmentSaveColumns = + { + "ItemId", + "AttachmentIndex", + "Codec", + "CodecTag", + "Comment", + "Filename", + "MIMEType" + }; + + private static readonly string _mediaAttachmentSaveColumnsSelectQuery = + $"select {string.Join(',', _mediaAttachmentSaveColumns)} from mediaattachments where ItemId=@ItemId"; + + private static readonly string _mediaAttachmentInsertPrefix; + + private static readonly HashSet _programTypes = new HashSet(StringComparer.OrdinalIgnoreCase) + { + "Program", + "TvChannel", + "LiveTvProgram", + "LiveTvTvChannel" + }; + + private static readonly HashSet _programExcludeParentTypes = new HashSet(StringComparer.OrdinalIgnoreCase) + { + "Series", + "Season", + "MusicAlbum", + "MusicArtist", + "PhotoAlbum" + }; + + private static readonly HashSet _serviceTypes = new HashSet(StringComparer.OrdinalIgnoreCase) + { + "TvChannel", + "LiveTvTvChannel" + }; + + private static readonly HashSet _startDateTypes = new HashSet(StringComparer.OrdinalIgnoreCase) + { + "Program", + "LiveTvProgram" + }; + + private static readonly HashSet _seriesTypes = new HashSet(StringComparer.OrdinalIgnoreCase) + { + "Book", + "AudioBook", + "Episode", + "Season" + }; + + private static readonly HashSet _artistExcludeParentTypes = new HashSet(StringComparer.OrdinalIgnoreCase) + { + "Series", + "Season", + "PhotoAlbum" + }; + + private static readonly HashSet _artistsTypes = new HashSet(StringComparer.OrdinalIgnoreCase) + { + "Audio", + "MusicAlbum", + "MusicVideo", + "AudioBook", + "AudioPodcast" + }; + + private static readonly Type[] _knownTypes = + { + typeof(LiveTvProgram), + typeof(LiveTvChannel), + typeof(Series), + typeof(Audio), + typeof(MusicAlbum), + typeof(MusicArtist), + typeof(MusicGenre), + typeof(MusicVideo), + typeof(Movie), + typeof(Playlist), + typeof(AudioBook), + typeof(Trailer), + typeof(BoxSet), + typeof(Episode), + typeof(Season), + typeof(Series), + typeof(Book), + typeof(CollectionFolder), + typeof(Folder), + typeof(Genre), + typeof(Person), + typeof(Photo), + typeof(PhotoAlbum), + typeof(Studio), + typeof(UserRootFolder), + typeof(UserView), + typeof(Video), + typeof(Year), + typeof(Channel), + typeof(AggregateFolder) + }; + + private readonly Dictionary _types = GetTypeMapDictionary(); + static SqliteItemRepository() { var queryPrefixText = new StringBuilder(); @@ -75,6 +303,12 @@ namespace Emby.Server.Implementations.Data /// /// Initializes a new instance of the class. /// + /// Instance of the interface. + /// Instance of the interface. + /// Instance of the interface. + /// Instance of the interface. + /// Instance of the interface. + /// config is null. public SqliteItemRepository( IServerConfigurationManager config, IServerApplicationHost appHost, @@ -111,6 +345,8 @@ namespace Emby.Server.Implementations.Data /// /// Opens the connection to the database. /// + /// The user data repository. + /// The user manager. public void Initialize(SqliteUserDataRepository userDataRepo, IUserManager userManager) { const string CreateMediaStreamsTableCommand @@ -150,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", @@ -336,151 +572,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)"; - /// /// Save a standard item in the repo. /// /// The item. /// The cancellation token. - /// item + /// is null. public void SaveItem(BaseItem item, CancellationToken cancellationToken) { if (item == null) @@ -505,7 +602,7 @@ namespace Emby.Server.Implementations.Data connection.RunInTransaction( db => { - using (var saveImagesStatement = base.PrepareStatement(db, "Update TypedBaseItems set Images=@Images where guid=@Id")) + using (var saveImagesStatement = PrepareStatement(db, "Update TypedBaseItems set Images=@Images where guid=@Id")) { saveImagesStatement.TryBind("@Id", item.Id.ToByteArray()); saveImagesStatement.TryBind("@Images", SerializeImages(item.ImageInfos)); @@ -522,9 +619,7 @@ namespace Emby.Server.Implementations.Data /// The items. /// The cancellation token. /// - /// items - /// or - /// cancellationToken + /// or is null. /// public void SaveItems(IEnumerable items, CancellationToken cancellationToken) { @@ -1135,15 +1230,25 @@ namespace Emby.Server.Implementations.Data Path = RestorePath(path.ToString()) }; - if (long.TryParse(dateModified, NumberStyles.Any, CultureInfo.InvariantCulture, out var ticks)) + if (long.TryParse(dateModified, NumberStyles.Any, CultureInfo.InvariantCulture, out var ticks) + && ticks >= DateTime.MinValue.Ticks + && ticks <= DateTime.MaxValue.Ticks) { image.DateModified = new DateTime(ticks, DateTimeKind.Utc); } + else + { + return null; + } - if (Enum.TryParse(imageType.ToString(), true, out ImageType type)) + if (Enum.TryParse(imageType, true, out ImageType type)) { image.Type = type; } + else + { + return null; + } // Optional parameters: width*height*blurhash if (nextSegment + 1 < value.Length - 1) @@ -1202,8 +1307,8 @@ namespace Emby.Server.Implementations.Data /// /// The id. /// BaseItem. - /// id - /// + /// is null. + /// is . public BaseItem RetrieveItem(Guid id) { if (id == Guid.Empty) @@ -1557,7 +1662,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; @@ -1596,18 +1700,16 @@ namespace Emby.Server.Implementations.Data { if (reader.TryGetString(index++, out var lockedFields)) { - IEnumerable GetLockedFields(string s) + List fields = null; + foreach (var i in lockedFields.AsSpan().Split('|')) { - foreach (var i in s.Split('|', StringSplitOptions.RemoveEmptyEntries)) + if (Enum.TryParse(i, true, out MetadataField parsedValue)) { - if (Enum.TryParse(i, true, out MetadataField parsedValue)) - { - yield return parsedValue; - } + (fields ??= new List()).Add(parsedValue); } } - item.LockedFields = GetLockedFields(lockedFields).ToArray(); + item.LockedFields = fields?.ToArray() ?? Array.Empty(); } } @@ -1633,18 +1735,16 @@ namespace Emby.Server.Implementations.Data { if (reader.TryGetString(index, out var trailerTypes)) { - IEnumerable GetTrailerTypes(string s) + List types = null; + foreach (var i in trailerTypes.AsSpan().Split('|')) { - foreach (var i in s.Split('|', StringSplitOptions.RemoveEmptyEntries)) + if (Enum.TryParse(i, true, out TrailerType parsedValue)) { - if (Enum.TryParse(i, true, out TrailerType parsedValue)) - { - yield return parsedValue; - } + (types ??= new List()).Add(parsedValue); } } - trailer.TrailerTypes = GetTrailerTypes(trailerTypes).ToArray(); + trailer.TrailerTypes = types?.ToArray() ?? Array.Empty(); } } @@ -1886,12 +1986,7 @@ namespace Emby.Server.Implementations.Data return result; } - /// - /// Gets chapters for an item. - /// - /// The item. - /// IEnumerable{ChapterInfo}. - /// id + /// public List GetChapters(BaseItem item) { CheckDisposed(); @@ -1914,13 +2009,7 @@ namespace Emby.Server.Implementations.Data } } - /// - /// Gets a single chapter for an item. - /// - /// The item. - /// The index. - /// ChapterInfo. - /// id + /// public ChapterInfo GetChapter(BaseItem item, int index) { CheckDisposed(); @@ -1988,6 +2077,8 @@ namespace Emby.Server.Implementations.Data /// /// Saves the chapters. /// + /// The item id. + /// The chapters. public void SaveChapters(Guid id, IReadOnlyList chapters) { CheckDisposed(); @@ -2032,7 +2123,7 @@ namespace Emby.Server.Implementations.Data for (var i = startIndex; i < endIndex; i++) { - insertText.AppendFormat("(@ItemId, @ChapterIndex{0}, @StartPositionTicks{0}, @Name{0}, @ImagePath{0}, @ImageDateModified{0}),", i.ToString(CultureInfo.InvariantCulture)); + insertText.AppendFormat(CultureInfo.InvariantCulture, "(@ItemId, @ChapterIndex{0}, @StartPositionTicks{0}, @Name{0}, @ImagePath{0}, @ImageDateModified{0}),", i.ToString(CultureInfo.InvariantCulture)); } insertText.Length -= 1; // Remove last , @@ -2087,8 +2178,6 @@ namespace Emby.Server.Implementations.Data || query.IsLiked.HasValue; } - private readonly ItemFields[] _allFields = Enum.GetValues(); - private bool HasField(InternalItemsQuery query, ItemFields name) { switch (name) @@ -2121,23 +2210,6 @@ namespace Emby.Server.Implementations.Data } } - private static readonly HashSet _programExcludeParentTypes = new HashSet(StringComparer.OrdinalIgnoreCase) - { - "Series", - "Season", - "MusicAlbum", - "MusicArtist", - "PhotoAlbum" - }; - - private static readonly HashSet _programTypes = new HashSet(StringComparer.OrdinalIgnoreCase) - { - "Program", - "TvChannel", - "LiveTvProgram", - "LiveTvTvChannel" - }; - private bool HasProgramAttributes(InternalItemsQuery query) { if (_programExcludeParentTypes.Contains(query.ParentType)) @@ -2153,12 +2225,6 @@ namespace Emby.Server.Implementations.Data return query.IncludeItemTypes.Any(x => _programTypes.Contains(x)); } - private static readonly HashSet _serviceTypes = new HashSet(StringComparer.OrdinalIgnoreCase) - { - "TvChannel", - "LiveTvTvChannel" - }; - private bool HasServiceName(InternalItemsQuery query) { if (_programExcludeParentTypes.Contains(query.ParentType)) @@ -2174,12 +2240,6 @@ namespace Emby.Server.Implementations.Data return query.IncludeItemTypes.Any(x => _serviceTypes.Contains(x)); } - private static readonly HashSet _startDateTypes = new HashSet(StringComparer.OrdinalIgnoreCase) - { - "Program", - "LiveTvProgram" - }; - private bool HasStartDate(InternalItemsQuery query) { if (_programExcludeParentTypes.Contains(query.ParentType)) @@ -2215,22 +2275,6 @@ namespace Emby.Server.Implementations.Data return query.IncludeItemTypes.Contains("Trailer", StringComparer.OrdinalIgnoreCase); } - private static readonly HashSet _artistExcludeParentTypes = new HashSet(StringComparer.OrdinalIgnoreCase) - { - "Series", - "Season", - "PhotoAlbum" - }; - - private static readonly HashSet _artistsTypes = new HashSet(StringComparer.OrdinalIgnoreCase) - { - "Audio", - "MusicAlbum", - "MusicVideo", - "AudioBook", - "AudioPodcast" - }; - private bool HasArtistFields(InternalItemsQuery query) { if (_artistExcludeParentTypes.Contains(query.ParentType)) @@ -2246,14 +2290,6 @@ namespace Emby.Server.Implementations.Data return query.IncludeItemTypes.Any(x => _artistsTypes.Contains(x)); } - private static readonly HashSet _seriesTypes = new HashSet(StringComparer.OrdinalIgnoreCase) - { - "Book", - "AudioBook", - "Episode", - "Season" - }; - private bool HasSeriesFields(InternalItemsQuery query) { if (string.Equals(query.ParentType, "PhotoAlbum", StringComparison.OrdinalIgnoreCase)) @@ -2271,7 +2307,7 @@ namespace Emby.Server.Implementations.Data private void SetFinalColumnsToSelect(InternalItemsQuery query, List columns) { - foreach (var field in _allFields) + foreach (var field in _allItemFields) { if (!HasField(query, field)) { @@ -4813,40 +4849,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( @@ -4879,7 +4881,7 @@ where AncestorIdText not null and ItemValues.Value not null and ItemValues.Type foreach (var t in _knownTypes) { - dict[t.Name] = t.FullName ; + dict[t.Name] = t.FullName; } dict["Program"] = typeof(LiveTvProgram).FullName; @@ -4888,9 +4890,6 @@ where AncestorIdText not null and ItemValues.Value not null and ItemValues.Type return dict; } - // Not crazy about having this all the way down here, but at least it's in one place - private readonly Dictionary _types = GetTypeMapDictionary(); - private string MapIncludeItemTypes(string value) { if (_types.TryGetValue(value, out string result)) diff --git a/Emby.Server.Implementations/Data/SqliteUserDataRepository.cs b/Emby.Server.Implementations/Data/SqliteUserDataRepository.cs index ef9af1dcd0..107096b5f2 100644 --- a/Emby.Server.Implementations/Data/SqliteUserDataRepository.cs +++ b/Emby.Server.Implementations/Data/SqliteUserDataRepository.cs @@ -32,6 +32,9 @@ namespace Emby.Server.Implementations.Data /// /// Opens the connection to the database. /// + /// The user manager. + /// The lock to use for database IO. + /// The connection to use for database IO. public void Initialize(IUserManager userManager, SemaphoreSlim dbLock, SQLiteDatabaseConnection dbConnection) { WriteLock.Dispose(); @@ -49,8 +52,8 @@ namespace Emby.Server.Implementations.Data connection.RunInTransaction( db => { - db.ExecuteAll(string.Join(';', new[] { - + db.ExecuteAll(string.Join(';', new[] + { "create table if not exists UserDatas (key nvarchar not null, userId INT not null, rating float null, played bit not null, playCount int not null, isFavorite bit not null, playbackPositionTicks bigint not null, lastPlayedDate datetime null, AudioStreamIndex INT, SubtitleStreamIndex INT)", "drop index if exists idx_userdata", @@ -129,19 +132,17 @@ namespace Emby.Server.Implementations.Data return list; } - /// - /// Saves the user data. - /// - public void SaveUserData(long internalUserId, string key, UserItemData userData, CancellationToken cancellationToken) + /// + public void SaveUserData(long userId, string key, UserItemData userData, CancellationToken cancellationToken) { if (userData == null) { throw new ArgumentNullException(nameof(userData)); } - if (internalUserId <= 0) + if (userId <= 0) { - throw new ArgumentNullException(nameof(internalUserId)); + throw new ArgumentNullException(nameof(userId)); } if (string.IsNullOrEmpty(key)) @@ -149,22 +150,23 @@ namespace Emby.Server.Implementations.Data throw new ArgumentNullException(nameof(key)); } - PersistUserData(internalUserId, key, userData, cancellationToken); + PersistUserData(userId, key, userData, cancellationToken); } - public void SaveAllUserData(long internalUserId, UserItemData[] userData, CancellationToken cancellationToken) + /// + public void SaveAllUserData(long userId, UserItemData[] userData, CancellationToken cancellationToken) { if (userData == null) { throw new ArgumentNullException(nameof(userData)); } - if (internalUserId <= 0) + if (userId <= 0) { - throw new ArgumentNullException(nameof(internalUserId)); + throw new ArgumentNullException(nameof(userId)); } - PersistAllUserData(internalUserId, userData, cancellationToken); + PersistAllUserData(userId, userData, cancellationToken); } /// @@ -174,7 +176,6 @@ namespace Emby.Server.Implementations.Data /// The key. /// The user data. /// The cancellation token. - /// Task. public void PersistUserData(long internalUserId, string key, UserItemData userData, CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); @@ -264,19 +265,19 @@ namespace Emby.Server.Implementations.Data /// /// Gets the user data. /// - /// The user id. + /// The user id. /// The key. /// Task{UserItemData}. /// /// userId /// or - /// key + /// key. /// - public UserItemData GetUserData(long internalUserId, string key) + public UserItemData GetUserData(long userId, string key) { - if (internalUserId <= 0) + if (userId <= 0) { - throw new ArgumentNullException(nameof(internalUserId)); + throw new ArgumentNullException(nameof(userId)); } if (string.IsNullOrEmpty(key)) @@ -288,7 +289,7 @@ namespace Emby.Server.Implementations.Data { using (var statement = connection.PrepareStatement("select key,userid,rating,played,playCount,isFavorite,playbackPositionTicks,lastPlayedDate,AudioStreamIndex,SubtitleStreamIndex from UserDatas where key =@Key and userId=@UserId")) { - statement.TryBind("@UserId", internalUserId); + statement.TryBind("@UserId", userId); statement.TryBind("@Key", key); foreach (var row in statement.ExecuteQuery()) @@ -301,7 +302,7 @@ namespace Emby.Server.Implementations.Data } } - public UserItemData GetUserData(long internalUserId, List keys) + public UserItemData GetUserData(long userId, List keys) { if (keys == null) { @@ -313,19 +314,19 @@ namespace Emby.Server.Implementations.Data return null; } - return GetUserData(internalUserId, keys[0]); + return GetUserData(userId, keys[0]); } /// /// Return all user-data associated with the given user. /// - /// - /// - public List GetAllUserData(long internalUserId) + /// The internal user id. + /// The list of user item data. + public List GetAllUserData(long userId) { - if (internalUserId <= 0) + if (userId <= 0) { - throw new ArgumentNullException(nameof(internalUserId)); + throw new ArgumentNullException(nameof(userId)); } var list = new List(); @@ -334,7 +335,7 @@ namespace Emby.Server.Implementations.Data { using (var statement = connection.PrepareStatement("select key,userid,rating,played,playCount,isFavorite,playbackPositionTicks,lastPlayedDate,AudioStreamIndex,SubtitleStreamIndex from UserDatas where userId=@UserId")) { - statement.TryBind("@UserId", internalUserId); + statement.TryBind("@UserId", userId); foreach (var row in statement.ExecuteQuery()) { @@ -349,7 +350,8 @@ namespace Emby.Server.Implementations.Data /// /// Read a row from the specified reader into the provided userData object. /// - /// + /// The list of result set values. + /// The user item data. private UserItemData ReadRow(IReadOnlyList reader) { var userData = new UserItemData(); diff --git a/Emby.Server.Implementations/Data/SynchronouseMode.cs b/Emby.Server.Implementations/Data/SynchronouseMode.cs new file mode 100644 index 0000000000..cde524e2e0 --- /dev/null +++ b/Emby.Server.Implementations/Data/SynchronouseMode.cs @@ -0,0 +1,30 @@ +namespace Emby.Server.Implementations.Data; + +/// +/// The disk synchronization mode, controls how aggressively SQLite will write data +/// all the way out to physical storage. +/// +public enum SynchronousMode +{ + /// + /// SQLite continues without syncing as soon as it has handed data off to the operating system. + /// + Off = 0, + + /// + /// SQLite database engine will still sync at the most critical moments. + /// + Normal = 1, + + /// + /// SQLite database engine will use the xSync method of the VFS + /// to ensure that all content is safely written to the disk surface prior to continuing. + /// + Full = 2, + + /// + /// EXTRA synchronous is like FULL with the addition that the directory containing a rollback journal + /// is synced after that journal is unlinked to commit a transaction in DELETE mode. + /// + Extra = 3 +} diff --git a/Emby.Server.Implementations/Data/TempStoreMode.cs b/Emby.Server.Implementations/Data/TempStoreMode.cs new file mode 100644 index 0000000000..d2427ce478 --- /dev/null +++ b/Emby.Server.Implementations/Data/TempStoreMode.cs @@ -0,0 +1,23 @@ +namespace Emby.Server.Implementations.Data; + +/// +/// Storage mode used by temporary database files. +/// +public enum TempStoreMode +{ + /// + /// The compile-time C preprocessor macro SQLITE_TEMP_STORE + /// is used to determine where temporary tables and indices are stored. + /// + Default = 0, + + /// + /// Temporary tables and indices are stored in a file. + /// + File = 1, + + /// + /// Temporary tables and indices are kept in as if they were pure in-memory databases memory. + /// + Memory = 2 +} diff --git a/Emby.Server.Implementations/Devices/DeviceId.cs b/Emby.Server.Implementations/Devices/DeviceId.cs index 3d15b3e768..0cfced8beb 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 _logger; - private readonly object _syncLock = new object(); + private string _id; + + public DeviceId(IApplicationPaths appPaths, ILoggerFactory loggerFactory) + { + _appPaths = appPaths; + _logger = loggerFactory.CreateLogger(); + } + + public string Value => _id ?? (_id = GetDeviceId()); + private string CachePath => Path.Combine(_appPaths.DataPath, "device.txt"); private string GetCachedId() @@ -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(); - } - - public string Value => _id ?? (_id = GetDeviceId()); } } diff --git a/Emby.Server.Implementations/Devices/DeviceManager.cs b/Emby.Server.Implementations/Devices/DeviceManager.cs deleted file mode 100644 index 2637addce7..0000000000 --- a/Emby.Server.Implementations/Devices/DeviceManager.cs +++ /dev/null @@ -1,146 +0,0 @@ -#nullable disable - -#pragma warning disable CS1591 - -using System; -using System.Collections.Concurrent; -using System.Collections.Generic; -using System.Linq; -using Jellyfin.Data.Entities; -using Jellyfin.Data.Enums; -using Jellyfin.Data.Events; -using MediaBrowser.Controller.Devices; -using MediaBrowser.Controller.Library; -using MediaBrowser.Controller.Security; -using MediaBrowser.Model.Devices; -using MediaBrowser.Model.Querying; -using MediaBrowser.Model.Session; - -namespace Emby.Server.Implementations.Devices -{ - public class DeviceManager : IDeviceManager - { - private readonly IUserManager _userManager; - private readonly IAuthenticationRepository _authRepo; - private readonly ConcurrentDictionary _capabilitiesMap = new (); - - public DeviceManager(IAuthenticationRepository authRepo, IUserManager userManager) - { - _userManager = userManager; - _authRepo = authRepo; - } - - public event EventHandler>> DeviceOptionsUpdated; - - public void SaveCapabilities(string deviceId, ClientCapabilities capabilities) - { - _capabilitiesMap[deviceId] = capabilities; - } - - public void UpdateDeviceOptions(string deviceId, DeviceOptions options) - { - _authRepo.UpdateDeviceOptions(deviceId, options); - - DeviceOptionsUpdated?.Invoke(this, new GenericEventArgs>(new Tuple(deviceId, options))); - } - - public DeviceOptions GetDeviceOptions(string deviceId) - { - return _authRepo.GetDeviceOptions(deviceId); - } - - public ClientCapabilities GetCapabilities(string id) - { - return _capabilitiesMap.TryGetValue(id, out ClientCapabilities result) - ? result - : new ClientCapabilities(); - } - - public DeviceInfo GetDevice(string id) - { - var session = _authRepo.Get(new AuthenticationInfoQuery - { - DeviceId = id - }).Items.FirstOrDefault(); - - var device = session == null ? null : ToDeviceInfo(session); - - return device; - } - - public QueryResult GetDevices(DeviceQuery query) - { - IEnumerable sessions = _authRepo.Get(new AuthenticationInfoQuery - { - // UserId = query.UserId - HasUser = true - }).Items; - - // TODO: DeviceQuery doesn't seem to be used from client. Not even Swagger. - if (query.SupportsSync.HasValue) - { - var val = query.SupportsSync.Value; - - sessions = sessions.Where(i => GetCapabilities(i.DeviceId).SupportsSync == val); - } - - if (!query.UserId.Equals(Guid.Empty)) - { - var user = _userManager.GetUserById(query.UserId); - - sessions = sessions.Where(i => CanAccessDevice(user, i.DeviceId)); - } - - var array = sessions.Select(ToDeviceInfo).ToArray(); - - return new QueryResult(array); - } - - private DeviceInfo ToDeviceInfo(AuthenticationInfo authInfo) - { - var caps = GetCapabilities(authInfo.DeviceId); - - return new DeviceInfo - { - AppName = authInfo.AppName, - AppVersion = authInfo.AppVersion, - Id = authInfo.DeviceId, - LastUserId = authInfo.UserId, - LastUserName = authInfo.UserName, - Name = authInfo.DeviceName, - DateLastActivity = authInfo.DateLastActivity, - IconUrl = caps?.IconUrl - }; - } - - public bool CanAccessDevice(User user, string deviceId) - { - if (user == null) - { - throw new ArgumentException("user not found"); - } - - if (string.IsNullOrEmpty(deviceId)) - { - throw new ArgumentNullException(nameof(deviceId)); - } - - if (user.HasPermission(PermissionKind.EnableAllDevices) || user.HasPermission(PermissionKind.IsAdministrator)) - { - return true; - } - - if (!user.GetPreference(PreferenceKind.EnabledDevices).Contains(deviceId, StringComparer.OrdinalIgnoreCase)) - { - var capabilities = GetCapabilities(deviceId); - - if (capabilities != null && capabilities.SupportsPersistentIdentifier) - { - return false; - } - } - - return true; - } - } -} diff --git a/Emby.Server.Implementations/Dto/DtoService.cs b/Emby.Server.Implementations/Dto/DtoService.cs index 7411239a1e..ad76f3d6d4 100644 --- a/Emby.Server.Implementations/Dto/DtoService.cs +++ b/Emby.Server.Implementations/Dto/DtoService.cs @@ -51,8 +51,6 @@ namespace Emby.Server.Implementations.Dto private readonly IMediaSourceManager _mediaSourceManager; private readonly Lazy _livetvManagerFactory; - private ILiveTvManager LivetvManager => _livetvManagerFactory.Value; - public DtoService( ILogger logger, ILibraryManager libraryManager, @@ -75,6 +73,8 @@ namespace Emby.Server.Implementations.Dto _livetvManagerFactory = livetvManagerFactory; } + private ILiveTvManager LivetvManager => _livetvManagerFactory.Value; + /// public IReadOnlyList GetBaseItemDtos(IReadOnlyList items, DtoOptions options, User user = null, BaseItem owner = null) { @@ -420,7 +420,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); @@ -507,7 +507,6 @@ namespace Emby.Server.Implementations.Dto /// /// The dto. /// The item. - /// Task. private void AttachPeople(BaseItemDto dto, BaseItem item) { // Ordering by person type to ensure actors and artists are at the front. @@ -616,7 +615,6 @@ namespace Emby.Server.Implementations.Dto /// /// The dto. /// The item. - /// Task. private void AttachStudios(BaseItemDto dto, BaseItem item) { dto.Studios = item.Studios @@ -807,7 +805,7 @@ namespace Emby.Server.Implementations.Dto dto.MediaType = item.MediaType; - if (!(item is LiveTvProgram)) + if (item is not LiveTvProgram) { dto.LocationType = item.LocationType; } @@ -928,9 +926,9 @@ namespace Emby.Server.Implementations.Dto } // if (options.ContainsField(ItemFields.MediaSourceCount)) - //{ + // { // Songs always have one - //} + // } } if (item is IHasArtist hasArtist) @@ -938,10 +936,10 @@ namespace Emby.Server.Implementations.Dto dto.Artists = hasArtist.Artists; // var artistItems = _libraryManager.GetArtists(new InternalItemsQuery - //{ + // { // EnableTotalRecordCount = false, // ItemIds = new[] { item.Id.ToString("N", CultureInfo.InvariantCulture) } - //}); + // }); // dto.ArtistItems = artistItems.Items // .Select(i => @@ -958,7 +956,7 @@ namespace Emby.Server.Implementations.Dto // Include artists that are not in the database yet, e.g., just added via metadata editor // var foundArtists = artistItems.Items.Select(i => i.Item1.Name).ToList(); dto.ArtistItems = hasArtist.Artists - //.Except(foundArtists, new DistinctNameComparer()) + // .Except(foundArtists, new DistinctNameComparer()) .Select(i => { // This should not be necessary but we're seeing some cases of it @@ -990,10 +988,10 @@ namespace Emby.Server.Implementations.Dto dto.AlbumArtist = hasAlbumArtist.AlbumArtists.FirstOrDefault(); // var artistItems = _libraryManager.GetAlbumArtists(new InternalItemsQuery - //{ + // { // EnableTotalRecordCount = false, // ItemIds = new[] { item.Id.ToString("N", CultureInfo.InvariantCulture) } - //}); + // }); // dto.AlbumArtists = artistItems.Items // .Select(i => @@ -1008,7 +1006,7 @@ namespace Emby.Server.Implementations.Dto // .ToList(); dto.AlbumArtists = hasAlbumArtist.AlbumArtists - //.Except(foundArtists, new DistinctNameComparer()) + // .Except(foundArtists, new DistinctNameComparer()) .Select(i => { // This should not be necessary but we're seeing some cases of it @@ -1035,8 +1033,7 @@ namespace Emby.Server.Implementations.Dto } // Add video info - var video = item as Video; - if (video != null) + if (item is Video video) { dto.VideoType = video.VideoType; dto.Video3DFormat = video.Video3DFormat; @@ -1075,9 +1072,7 @@ namespace Emby.Server.Implementations.Dto if (options.ContainsField(ItemFields.MediaStreams)) { // Add VideoInfo - var iHasMediaSources = item as IHasMediaSources; - - if (iHasMediaSources != null) + if (item is IHasMediaSources) { MediaStream[] mediaStreams; @@ -1146,7 +1141,7 @@ namespace Emby.Server.Implementations.Dto // TODO maybe remove the if statement entirely // if (options.ContainsField(ItemFields.SeriesPrimaryImage)) { - episodeSeries = episodeSeries ?? episode.Series; + episodeSeries ??= episode.Series; if (episodeSeries != null) { dto.SeriesPrimaryImageTag = GetTagAndFillBlurhash(dto, episodeSeries, ImageType.Primary); @@ -1159,7 +1154,7 @@ namespace Emby.Server.Implementations.Dto if (options.ContainsField(ItemFields.SeriesStudio)) { - episodeSeries = episodeSeries ?? episode.Series; + episodeSeries ??= episode.Series; if (episodeSeries != null) { dto.SeriesStudio = episodeSeries.Studios.FirstOrDefault(); @@ -1172,7 +1167,7 @@ namespace Emby.Server.Implementations.Dto { dto.AirDays = series.AirDays; dto.AirTime = series.AirTime; - dto.Status = series.Status.HasValue ? series.Status.Value.ToString() : null; + dto.Status = series.Status?.ToString(); } // Add SeasonInfo @@ -1185,7 +1180,7 @@ namespace Emby.Server.Implementations.Dto if (options.ContainsField(ItemFields.SeriesStudio)) { - series = series ?? season.Series; + series ??= season.Series; if (series != null) { dto.SeriesStudio = series.Studios.FirstOrDefault(); @@ -1196,7 +1191,7 @@ namespace Emby.Server.Implementations.Dto // TODO maybe remove the if statement entirely // if (options.ContainsField(ItemFields.SeriesPrimaryImage)) { - series = series ?? season.Series; + series ??= season.Series; if (series != null) { dto.SeriesPrimaryImageTag = GetTagAndFillBlurhash(dto, series, ImageType.Primary); @@ -1283,7 +1278,7 @@ namespace Emby.Server.Implementations.Dto var parent = currentItem.DisplayParent ?? currentItem.GetOwner() ?? currentItem.GetParent(); - if (parent == null && !(originalItem is UserRootFolder) && !(originalItem is UserView) && !(originalItem is AggregateFolder) && !(originalItem is ICollectionFolder) && !(originalItem is Channel)) + if (parent == null && originalItem is not UserRootFolder && originalItem is not UserView && originalItem is not AggregateFolder && originalItem is not ICollectionFolder && originalItem is not Channel) { parent = _libraryManager.GetCollectionFolders(originalItem).FirstOrDefault(); } @@ -1316,9 +1311,12 @@ namespace Emby.Server.Implementations.Dto var imageTags = dto.ImageTags; - while (((!(imageTags != null && imageTags.ContainsKey(ImageType.Logo)) && logoLimit > 0) || (!(imageTags != null && imageTags.ContainsKey(ImageType.Art)) && artLimit > 0) || (!(imageTags != null && imageTags.ContainsKey(ImageType.Thumb)) && thumbLimit > 0) || parent is Series) && - (parent = parent ?? (isFirst ? GetImageDisplayParent(item, item) ?? owner : parent)) != null) + while ((!(imageTags != null && imageTags.ContainsKey(ImageType.Logo)) && logoLimit > 0) + || (!(imageTags != null && imageTags.ContainsKey(ImageType.Art)) && artLimit > 0) + || (!(imageTags != null && imageTags.ContainsKey(ImageType.Thumb)) && thumbLimit > 0) + || parent is Series) { + parent ??= isFirst ? GetImageDisplayParent(item, item) ?? owner : parent; if (parent == null) { break; @@ -1348,7 +1346,7 @@ namespace Emby.Server.Implementations.Dto } } - if (thumbLimit > 0 && !(imageTags != null && imageTags.ContainsKey(ImageType.Thumb)) && (dto.ParentThumbItemId == null || parent is Series) && !(parent is ICollectionFolder) && !(parent is UserView)) + if (thumbLimit > 0 && !(imageTags != null && imageTags.ContainsKey(ImageType.Thumb)) && (dto.ParentThumbItemId == null || parent is Series) && parent is not ICollectionFolder && parent is not UserView) { var image = allImages.FirstOrDefault(i => i.Type == ImageType.Thumb); @@ -1398,7 +1396,6 @@ namespace Emby.Server.Implementations.Dto /// /// The dto. /// The item. - /// Task. public void AttachPrimaryImageAspectRatio(IItemDto dto, BaseItem item) { dto.PrimaryImageAspectRatio = GetPrimaryImageAspectRatio(item); diff --git a/Emby.Server.Implementations/Emby.Server.Implementations.csproj b/Emby.Server.Implementations/Emby.Server.Implementations.csproj index 9c90de1eda..c1ce4b5572 100644 --- a/Emby.Server.Implementations/Emby.Server.Implementations.csproj +++ b/Emby.Server.Implementations/Emby.Server.Implementations.csproj @@ -23,17 +23,18 @@ + - - - - - + + + + + - - + + - + @@ -41,15 +42,11 @@ - net5.0 + net6.0 false true - true - enable AD0001 - AllEnabledByDefault - ../jellyfin.ruleset diff --git a/Emby.Server.Implementations/EntryPoints/ExternalPortForwarding.cs b/Emby.Server.Implementations/EntryPoints/ExternalPortForwarding.cs index 0a4efd73c7..640754af40 100644 --- a/Emby.Server.Implementations/EntryPoints/ExternalPortForwarding.cs +++ b/Emby.Server.Implementations/EntryPoints/ExternalPortForwarding.cs @@ -9,7 +9,6 @@ using System.Net; using System.Text; using System.Threading; using System.Threading.Tasks; -using Jellyfin.Data.Events; using Jellyfin.Networking.Configuration; using MediaBrowser.Controller; using MediaBrowser.Controller.Configuration; diff --git a/Emby.Server.Implementations/EntryPoints/LibraryChangedNotifier.cs b/Emby.Server.Implementations/EntryPoints/LibraryChangedNotifier.cs index 5bb4100ba9..331de45c1e 100644 --- a/Emby.Server.Implementations/EntryPoints/LibraryChangedNotifier.cs +++ b/Emby.Server.Implementations/EntryPoints/LibraryChangedNotifier.cs @@ -149,7 +149,7 @@ namespace Emby.Server.Implementations.EntryPoints private static bool EnableRefreshMessage(BaseItem item) { - if (!(item is Folder folder)) + if (item is not Folder folder) { return false; } @@ -403,7 +403,7 @@ namespace Emby.Server.Implementations.EntryPoints return false; } - if (item is IItemByName && !(item is MusicArtist)) + if (item is IItemByName && item is not MusicArtist) { return false; } @@ -436,7 +436,7 @@ namespace Emby.Server.Implementations.EntryPoints /// /// Translates the physical item to user library. /// - /// + /// The type of item. /// The item. /// The user. /// if set to true [include if not found]. diff --git a/Emby.Server.Implementations/EntryPoints/UdpServerEntryPoint.cs b/Emby.Server.Implementations/EntryPoints/UdpServerEntryPoint.cs index 2e72b18f57..feaccf9fa1 100644 --- a/Emby.Server.Implementations/EntryPoints/UdpServerEntryPoint.cs +++ b/Emby.Server.Implementations/EntryPoints/UdpServerEntryPoint.cs @@ -37,6 +37,9 @@ namespace Emby.Server.Implementations.EntryPoints /// /// Initializes a new instance of the class. /// + /// Instance of the interface. + /// Instance of the interface. + /// Instance of the interface. public UdpServerEntryPoint( ILogger logger, IServerApplicationHost appHost, diff --git a/Emby.Server.Implementations/HttpServer/Security/AuthService.cs b/Emby.Server.Implementations/HttpServer/Security/AuthService.cs index 9afabf5272..e2ad07177e 100644 --- a/Emby.Server.Implementations/HttpServer/Security/AuthService.cs +++ b/Emby.Server.Implementations/HttpServer/Security/AuthService.cs @@ -1,5 +1,6 @@ #pragma warning disable CS1591 +using System.Threading.Tasks; using Jellyfin.Data.Enums; using MediaBrowser.Controller.Authentication; using MediaBrowser.Controller.Net; @@ -17,9 +18,9 @@ namespace Emby.Server.Implementations.HttpServer.Security _authorizationContext = authorizationContext; } - public AuthorizationInfo Authenticate(HttpRequest request) + public async Task Authenticate(HttpRequest request) { - var auth = _authorizationContext.GetAuthorizationInfo(request); + var auth = await _authorizationContext.GetAuthorizationInfo(request).ConfigureAwait(false); if (!auth.HasToken) { diff --git a/Emby.Server.Implementations/HttpServer/Security/SessionContext.cs b/Emby.Server.Implementations/HttpServer/Security/SessionContext.cs index c375f36ce4..a7647caf95 100644 --- a/Emby.Server.Implementations/HttpServer/Security/SessionContext.cs +++ b/Emby.Server.Implementations/HttpServer/Security/SessionContext.cs @@ -1,6 +1,7 @@ #pragma warning disable CS1591 using System; +using System.Threading.Tasks; using Jellyfin.Data.Entities; using MediaBrowser.Common.Extensions; using MediaBrowser.Controller.Library; @@ -23,27 +24,33 @@ namespace Emby.Server.Implementations.HttpServer.Security _sessionManager = sessionManager; } - public SessionInfo GetSession(HttpContext requestContext) + public async Task GetSession(HttpContext requestContext) { - var authorization = _authContext.GetAuthorizationInfo(requestContext); + var authorization = await _authContext.GetAuthorizationInfo(requestContext).ConfigureAwait(false); var user = authorization.User; - return _sessionManager.LogSessionActivity(authorization.Client, authorization.Version, authorization.DeviceId, authorization.Device, requestContext.GetNormalizedRemoteIp().ToString(), user); + return await _sessionManager.LogSessionActivity( + authorization.Client, + authorization.Version, + authorization.DeviceId, + authorization.Device, + requestContext.GetNormalizedRemoteIp().ToString(), + user).ConfigureAwait(false); } - public SessionInfo GetSession(object requestContext) + public Task GetSession(object requestContext) { return GetSession((HttpContext)requestContext); } - public User? GetUser(HttpContext requestContext) + public async Task GetUser(HttpContext requestContext) { - var session = GetSession(requestContext); + var session = await GetSession(requestContext).ConfigureAwait(false); return session == null || session.UserId.Equals(Guid.Empty) ? null : _userManager.GetUserById(session.UserId); } - public User? GetUser(object requestContext) + public Task GetUser(object requestContext) { return GetUser(((HttpRequest)requestContext).HttpContext); } diff --git a/Emby.Server.Implementations/HttpServer/WebSocketConnection.cs b/Emby.Server.Implementations/HttpServer/WebSocketConnection.cs index 5d38ea0ca6..5f25f69801 100644 --- a/Emby.Server.Implementations/HttpServer/WebSocketConnection.cs +++ b/Emby.Server.Implementations/HttpServer/WebSocketConnection.cs @@ -62,7 +62,7 @@ namespace Emby.Server.Implementations.HttpServer public event EventHandler? Closed; /// - /// Gets or sets the remote end point. + /// Gets the remote end point. /// public IPAddress? RemoteEndPoint { get; } @@ -82,7 +82,7 @@ namespace Emby.Server.Implementations.HttpServer public DateTime LastKeepAliveDate { get; set; } /// - /// Gets or sets the query string. + /// Gets the query string. /// /// The query string. public IQueryCollection QueryString { get; } @@ -96,7 +96,7 @@ namespace Emby.Server.Implementations.HttpServer /// /// Sends a message asynchronously. /// - /// + /// The type of the message. /// The message. /// The cancellation token. /// Task. @@ -150,8 +150,8 @@ namespace Emby.Server.Implementations.HttpServer { await ProcessInternal(pipe.Reader).ConfigureAwait(false); } - } while ( - (_socket.State == WebSocketState.Open || _socket.State == WebSocketState.Connecting) + } + while ((_socket.State == WebSocketState.Open || _socket.State == WebSocketState.Connecting) && receiveresult.MessageType != WebSocketMessageType.Close); Closed?.Invoke(this, EventArgs.Empty); diff --git a/Emby.Server.Implementations/HttpServer/WebSocketManager.cs b/Emby.Server.Implementations/HttpServer/WebSocketManager.cs index 861c0a95e3..f86bfd7550 100644 --- a/Emby.Server.Implementations/HttpServer/WebSocketManager.cs +++ b/Emby.Server.Implementations/HttpServer/WebSocketManager.cs @@ -35,7 +35,7 @@ namespace Emby.Server.Implementations.HttpServer /// public async Task WebSocketRequestHandler(HttpContext context) { - _ = _authService.Authenticate(context.Request); + _ = await _authService.Authenticate(context.Request).ConfigureAwait(false); try { _logger.LogInformation("WS {IP} request", context.Connection.RemoteIpAddress); diff --git a/Emby.Server.Implementations/IO/LibraryMonitor.cs b/Emby.Server.Implementations/IO/LibraryMonitor.cs index aa80bccd72..e9d069cd33 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; + /// + /// Initializes a new instance of the class. + /// + /// The logger. + /// The library manager. + /// The configuration manager. + /// The filesystem. + public LibraryMonitor( + ILogger logger, + ILibraryManager libraryManager, + IServerConfigurationManager configurationManager, + IFileSystem fileSystem) + { + _libraryManager = libraryManager; + _logger = logger; + _configurationManager = configurationManager; + _fileSystem = fileSystem; + } + /// /// Add the path to our temporary ignore list. Use when writing to a path within our listening scope. /// @@ -95,21 +114,6 @@ namespace Emby.Server.Implementations.IO } } - /// - /// Initializes a new instance of the class. - /// - public LibraryMonitor( - ILogger logger, - ILibraryManager libraryManager, - IServerConfigurationManager configurationManager, - IFileSystem fileSystem) - { - _libraryManager = libraryManager; - _logger = logger; - _configurationManager = configurationManager; - _fileSystem = fileSystem; - } - private bool IsLibraryMonitorEnabled(BaseItem item) { if (item is BasePluginFolder) @@ -199,7 +203,7 @@ namespace Emby.Server.Implementations.IO /// The LST. /// The path. /// true if [contains parent folder] [the specified LST]; otherwise, false. - /// path + /// is null. private static bool ContainsParentFolder(IEnumerable lst, string path) { if (string.IsNullOrEmpty(path)) diff --git a/Emby.Server.Implementations/IO/ManagedFileSystem.cs b/Emby.Server.Implementations/IO/ManagedFileSystem.cs index ca028a3ca2..eeee288425 100644 --- a/Emby.Server.Implementations/IO/ManagedFileSystem.cs +++ b/Emby.Server.Implementations/IO/ManagedFileSystem.cs @@ -5,13 +5,10 @@ using System.Collections.Generic; using System.Globalization; using System.IO; using System.Linq; -using System.Runtime.InteropServices; using Jellyfin.Extensions; using MediaBrowser.Common.Configuration; using MediaBrowser.Model.IO; -using MediaBrowser.Model.System; using Microsoft.Extensions.Logging; -using OperatingSystem = MediaBrowser.Common.System.OperatingSystem; namespace Emby.Server.Implementations.IO { @@ -20,17 +17,17 @@ namespace Emby.Server.Implementations.IO /// public class ManagedFileSystem : IFileSystem { - protected ILogger Logger; + private readonly ILogger _logger; private readonly List _shortcutHandlers = new List(); private readonly string _tempPath; - private static readonly bool _isEnvironmentCaseInsensitive = RuntimeInformation.IsOSPlatform(OSPlatform.Windows); + private static readonly bool _isEnvironmentCaseInsensitive = OperatingSystem.IsWindows(); public ManagedFileSystem( ILogger logger, IApplicationPaths applicationPaths) { - Logger = logger; + _logger = logger; _tempPath = applicationPaths.TempDirectory; } @@ -44,7 +41,7 @@ namespace Emby.Server.Implementations.IO /// /// The filename. /// true if the specified filename is shortcut; otherwise, false. - /// filename + /// is null. public virtual bool IsShortcut(string filename) { if (string.IsNullOrEmpty(filename)) @@ -61,7 +58,7 @@ namespace Emby.Server.Implementations.IO /// /// The filename. /// System.String. - /// filename + /// is null. public virtual string? ResolveShortcut(string filename) { if (string.IsNullOrEmpty(filename)) @@ -236,9 +233,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) { @@ -249,15 +246,15 @@ namespace Emby.Server.Implementations.IO { try { - using (Stream thisFileStream = File.OpenRead(fileInfo.FullName)) + using (var fileHandle = File.OpenHandle(fileInfo.FullName, FileMode.Open, FileAccess.Read, FileShare.ReadWrite)) { - result.Length = thisFileStream.Length; + result.Length = RandomAccess.GetLength(fileHandle); } } catch (FileNotFoundException ex) { // Dangling symlinks cannot be detected before opening the file unfortunately... - Logger.LogError(ex, "Reading the file size of the symlink at {Path} failed. Marking the file as not existing.", fileInfo.FullName); + _logger.LogError(ex, "Reading the file size of the symlink at {Path} failed. Marking the file as not existing.", fileInfo.FullName); result.Exists = false; } } @@ -346,7 +343,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; } } @@ -385,7 +382,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; } } @@ -402,7 +399,7 @@ namespace Emby.Server.Implementations.IO public virtual void SetHidden(string path, bool isHidden) { - if (OperatingSystem.Id != OperatingSystemId.Windows) + if (!OperatingSystem.IsWindows()) { return; } @@ -424,9 +421,9 @@ namespace Emby.Server.Implementations.IO } } - public virtual void SetAttributes(string path, bool isHidden, bool isReadOnly) + public virtual void SetAttributes(string path, bool isHidden, bool readOnly) { - if (OperatingSystem.Id != OperatingSystemId.Windows) + if (!OperatingSystem.IsWindows()) { return; } @@ -438,14 +435,14 @@ namespace Emby.Server.Implementations.IO return; } - if (info.IsReadOnly == isReadOnly && info.IsHidden == isHidden) + if (info.IsReadOnly == readOnly && info.IsHidden == isHidden) { return; } var attributes = File.GetAttributes(path); - if (isReadOnly) + if (readOnly) { attributes = attributes | FileAttributes.ReadOnly; } diff --git a/Emby.Server.Implementations/IStartupOptions.cs b/Emby.Server.Implementations/IStartupOptions.cs index a430b9e720..3769ae4dd8 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 { + /// + /// Specifies the contract for server startup options. + /// public interface IStartupOptions { /// @@ -10,7 +11,7 @@ namespace Emby.Server.Implementations string? FFmpegPath { get; } /// - /// Gets the value of the --service command line option. + /// Gets a value indicating whether to run as service by the --service command line option. /// bool IsService { get; } diff --git a/Emby.Server.Implementations/Images/BaseDynamicImageProvider.cs b/Emby.Server.Implementations/Images/BaseDynamicImageProvider.cs index 833fb0b7a1..7589869457 100644 --- a/Emby.Server.Implementations/Images/BaseDynamicImageProvider.cs +++ b/Emby.Server.Implementations/Images/BaseDynamicImageProvider.cs @@ -51,7 +51,7 @@ namespace Emby.Server.Implementations.Images public int Order => 0; - protected virtual bool Supports(BaseItem _) => true; + protected virtual bool Supports(BaseItem item) => true; public async Task FetchAsync(T item, MetadataRefreshOptions options, CancellationToken cancellationToken) { @@ -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 0000000000..1c69056d20 --- /dev/null +++ b/Emby.Server.Implementations/Images/BaseFolderImageProvider.cs @@ -0,0 +1,67 @@ +#nullable disable + +#pragma warning disable CS1591 + +using System.Collections.Generic; +using Jellyfin.Data.Enums; +using MediaBrowser.Common.Configuration; +using MediaBrowser.Controller.Drawing; +using MediaBrowser.Controller.Dto; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.Audio; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.IO; +using MediaBrowser.Model.Querying; + +namespace Emby.Server.Implementations.Images +{ + public abstract class BaseFolderImageProvider : BaseDynamicImageProvider + where T : Folder, new() + { + private readonly ILibraryManager _libraryManager; + + public BaseFolderImageProvider(IFileSystem fileSystem, IProviderManager providerManager, IApplicationPaths applicationPaths, IImageProcessor imageProcessor, ILibraryManager libraryManager) + : base(fileSystem, providerManager, applicationPaths, imageProcessor) + { + _libraryManager = libraryManager; + } + + protected override IReadOnlyList GetItemsWithImages(BaseItem item) + { + return _libraryManager.GetItemList(new InternalItemsQuery + { + Parent = item, + DtoOptions = new DtoOptions(true), + ImageTypes = new ImageType[] { ImageType.Primary }, + OrderBy = new (string, SortOrder)[] + { + (ItemSortBy.IsFolder, SortOrder.Ascending), + (ItemSortBy.SortName, SortOrder.Ascending) + }, + Limit = 1 + }); + } + + protected override string CreateImage(BaseItem item, IReadOnlyCollection itemsWithImages, string outputPathWithoutExtension, ImageType imageType, int imageIndex) + { + return CreateSingleImage(itemsWithImages, outputPathWithoutExtension, ImageType.Primary); + } + + protected override bool Supports(BaseItem item) + { + return item is T; + } + + protected override bool HasChangedByDate(BaseItem item, ItemImageInfo image) + { + if (item is MusicAlbum) + { + return false; + } + + return base.HasChangedByDate(item, image); + } + } +} diff --git a/Emby.Server.Implementations/Images/CollectionFolderImageProvider.cs b/Emby.Server.Implementations/Images/CollectionFolderImageProvider.cs index ff5f26ce09..0229fbae79 100644 --- a/Emby.Server.Implementations/Images/CollectionFolderImageProvider.cs +++ b/Emby.Server.Implementations/Images/CollectionFolderImageProvider.cs @@ -30,27 +30,27 @@ namespace Emby.Server.Implementations.Images string[] includeItemTypes; - if (string.Equals(viewType, CollectionType.Movies)) + if (string.Equals(viewType, CollectionType.Movies, StringComparison.Ordinal)) { includeItemTypes = new string[] { "Movie" }; } - else if (string.Equals(viewType, CollectionType.TvShows)) + else if (string.Equals(viewType, CollectionType.TvShows, StringComparison.Ordinal)) { includeItemTypes = new string[] { "Series" }; } - else if (string.Equals(viewType, CollectionType.Music)) + else if (string.Equals(viewType, CollectionType.Music, StringComparison.Ordinal)) { includeItemTypes = new string[] { "MusicAlbum" }; } - else if (string.Equals(viewType, CollectionType.Books)) + else if (string.Equals(viewType, CollectionType.Books, StringComparison.Ordinal)) { includeItemTypes = new string[] { "Book", "AudioBook" }; } - else if (string.Equals(viewType, CollectionType.BoxSets)) + else if (string.Equals(viewType, CollectionType.BoxSets, StringComparison.Ordinal)) { includeItemTypes = new string[] { "BoxSet" }; } - else if (string.Equals(viewType, CollectionType.HomeVideos) || string.Equals(viewType, CollectionType.Photos)) + else if (string.Equals(viewType, CollectionType.HomeVideos, StringComparison.Ordinal) || string.Equals(viewType, CollectionType.Photos, StringComparison.Ordinal)) { includeItemTypes = new string[] { "Video", "Photo" }; } diff --git a/Emby.Server.Implementations/Images/FolderImageProvider.cs b/Emby.Server.Implementations/Images/FolderImageProvider.cs index 859017f869..4376bd356c 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 : BaseDynamicImageProvider - where T : Folder, new() - { - protected ILibraryManager _libraryManager; - - public BaseFolderImageProvider(IFileSystem fileSystem, IProviderManager providerManager, IApplicationPaths applicationPaths, IImageProcessor imageProcessor, ILibraryManager libraryManager) - : base(fileSystem, providerManager, applicationPaths, imageProcessor) - { - _libraryManager = libraryManager; - } - - protected override IReadOnlyList GetItemsWithImages(BaseItem item) - { - return _libraryManager.GetItemList(new InternalItemsQuery - { - Parent = item, - DtoOptions = new DtoOptions(true), - ImageTypes = new ImageType[] { ImageType.Primary }, - OrderBy = new System.ValueTuple[] - { - new System.ValueTuple(ItemSortBy.IsFolder, SortOrder.Ascending), - new System.ValueTuple(ItemSortBy.SortName, SortOrder.Ascending) - }, - Limit = 1 - }); - } - - protected override string CreateImage(BaseItem item, IReadOnlyCollection itemsWithImages, string outputPathWithoutExtension, ImageType imageType, int imageIndex) - { - return CreateSingleImage(itemsWithImages, outputPathWithoutExtension, ImageType.Primary); - } - - protected override bool Supports(BaseItem item) - { - return item is T; - } - - protected override bool HasChangedByDate(BaseItem item, ItemImageInfo image) - { - if (item is MusicAlbum) - { - return false; - } - - return base.HasChangedByDate(item, image); - } - } - public class FolderImageProvider : BaseFolderImageProvider { public FolderImageProvider(IFileSystem fileSystem, IProviderManager providerManager, IApplicationPaths applicationPaths, IImageProcessor imageProcessor, ILibraryManager libraryManager) @@ -87,20 +34,4 @@ namespace Emby.Server.Implementations.Images return true; } } - - public class MusicAlbumImageProvider : BaseFolderImageProvider - { - public MusicAlbumImageProvider(IFileSystem fileSystem, IProviderManager providerManager, IApplicationPaths applicationPaths, IImageProcessor imageProcessor, ILibraryManager libraryManager) - : base(fileSystem, providerManager, applicationPaths, imageProcessor, libraryManager) - { - } - } - - public class PhotoAlbumImageProvider : BaseFolderImageProvider - { - public PhotoAlbumImageProvider(IFileSystem fileSystem, IProviderManager providerManager, IApplicationPaths applicationPaths, IImageProcessor imageProcessor, ILibraryManager libraryManager) - : base(fileSystem, providerManager, applicationPaths, imageProcessor, libraryManager) - { - } - } } diff --git a/Emby.Server.Implementations/Images/GenreImageProvider.cs b/Emby.Server.Implementations/Images/GenreImageProvider.cs index 6da431c68e..1f5090f7f5 100644 --- a/Emby.Server.Implementations/Images/GenreImageProvider.cs +++ b/Emby.Server.Implementations/Images/GenreImageProvider.cs @@ -8,7 +8,6 @@ using MediaBrowser.Common.Configuration; using MediaBrowser.Controller.Drawing; using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; -using MediaBrowser.Controller.Entities.Audio; using MediaBrowser.Controller.Entities.Movies; using MediaBrowser.Controller.Entities.TV; using MediaBrowser.Controller.Library; @@ -19,46 +18,6 @@ using MediaBrowser.Model.Querying; namespace Emby.Server.Implementations.Images { - /// - /// Class MusicGenreImageProvider. - /// - public class MusicGenreImageProvider : BaseDynamicImageProvider - { - /// - /// The library manager. - /// - private readonly ILibraryManager _libraryManager; - - public MusicGenreImageProvider(IFileSystem fileSystem, IProviderManager providerManager, IApplicationPaths applicationPaths, IImageProcessor imageProcessor, ILibraryManager libraryManager) : base(fileSystem, providerManager, applicationPaths, imageProcessor) - { - _libraryManager = libraryManager; - } - - /// - /// Get children objects used to create an music genre image. - /// - /// The music genre used to create the image. - /// Any relevant children objects. - protected override IReadOnlyList GetItemsWithImages(BaseItem item) - { - return _libraryManager.GetItemList(new InternalItemsQuery - { - Genres = new[] { item.Name }, - IncludeItemTypes = new[] - { - nameof(MusicAlbum), - nameof(MusicVideo), - nameof(Audio) - }, - OrderBy = new[] { (ItemSortBy.Random, SortOrder.Ascending) }, - Limit = 4, - Recursive = true, - ImageTypes = new[] { ImageType.Primary }, - DtoOptions = new DtoOptions(false) - }); - } - } - /// /// Class GenreImageProvider. /// diff --git a/Emby.Server.Implementations/Images/MusicAlbumImageProvider.cs b/Emby.Server.Implementations/Images/MusicAlbumImageProvider.cs new file mode 100644 index 0000000000..ce83673638 --- /dev/null +++ b/Emby.Server.Implementations/Images/MusicAlbumImageProvider.cs @@ -0,0 +1,19 @@ +#pragma warning disable CS1591 + +using MediaBrowser.Common.Configuration; +using MediaBrowser.Controller.Drawing; +using MediaBrowser.Controller.Entities.Audio; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.IO; + +namespace Emby.Server.Implementations.Images +{ + public class MusicAlbumImageProvider : BaseFolderImageProvider + { + public MusicAlbumImageProvider(IFileSystem fileSystem, IProviderManager providerManager, IApplicationPaths applicationPaths, IImageProcessor imageProcessor, ILibraryManager libraryManager) + : base(fileSystem, providerManager, applicationPaths, imageProcessor, libraryManager) + { + } + } +} diff --git a/Emby.Server.Implementations/Images/MusicGenreImageProvider.cs b/Emby.Server.Implementations/Images/MusicGenreImageProvider.cs new file mode 100644 index 0000000000..baf1c90517 --- /dev/null +++ b/Emby.Server.Implementations/Images/MusicGenreImageProvider.cs @@ -0,0 +1,59 @@ +#nullable disable + +#pragma warning disable CS1591 + +using System.Collections.Generic; +using Jellyfin.Data.Enums; +using MediaBrowser.Common.Configuration; +using MediaBrowser.Controller.Drawing; +using MediaBrowser.Controller.Dto; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.Audio; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.IO; +using MediaBrowser.Model.Querying; + +namespace Emby.Server.Implementations.Images +{ + /// + /// Class MusicGenreImageProvider. + /// + public class MusicGenreImageProvider : BaseDynamicImageProvider + { + /// + /// The library manager. + /// + private readonly ILibraryManager _libraryManager; + + public MusicGenreImageProvider(IFileSystem fileSystem, IProviderManager providerManager, IApplicationPaths applicationPaths, IImageProcessor imageProcessor, ILibraryManager libraryManager) : base(fileSystem, providerManager, applicationPaths, imageProcessor) + { + _libraryManager = libraryManager; + } + + /// + /// Get children objects used to create an music genre image. + /// + /// The music genre used to create the image. + /// Any relevant children objects. + protected override IReadOnlyList GetItemsWithImages(BaseItem item) + { + return _libraryManager.GetItemList(new InternalItemsQuery + { + Genres = new[] { item.Name }, + IncludeItemTypes = new[] + { + nameof(MusicAlbum), + nameof(MusicVideo), + nameof(Audio) + }, + OrderBy = new[] { (ItemSortBy.Random, SortOrder.Ascending) }, + Limit = 4, + Recursive = true, + ImageTypes = new[] { ImageType.Primary }, + DtoOptions = new DtoOptions(false) + }); + } + } +} diff --git a/Emby.Server.Implementations/Images/PhotoAlbumImageProvider.cs b/Emby.Server.Implementations/Images/PhotoAlbumImageProvider.cs new file mode 100644 index 0000000000..1ddb4c7579 --- /dev/null +++ b/Emby.Server.Implementations/Images/PhotoAlbumImageProvider.cs @@ -0,0 +1,19 @@ +#pragma warning disable CS1591 + +using MediaBrowser.Common.Configuration; +using MediaBrowser.Controller.Drawing; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.IO; + +namespace Emby.Server.Implementations.Images +{ + public class PhotoAlbumImageProvider : BaseFolderImageProvider + { + public PhotoAlbumImageProvider(IFileSystem fileSystem, IProviderManager providerManager, IApplicationPaths applicationPaths, IImageProcessor imageProcessor, ILibraryManager libraryManager) + : base(fileSystem, providerManager, applicationPaths, imageProcessor, libraryManager) + { + } + } +} diff --git a/Emby.Server.Implementations/Library/CoreResolutionIgnoreRule.cs b/Emby.Server.Implementations/Library/CoreResolutionIgnoreRule.cs index c7d1139639..bc5b4499fc 100644 --- a/Emby.Server.Implementations/Library/CoreResolutionIgnoreRule.cs +++ b/Emby.Server.Implementations/Library/CoreResolutionIgnoreRule.cs @@ -54,7 +54,7 @@ namespace Emby.Server.Implementations.Library if (parent != null) { // Ignore trailer folders but allow it at the collection level - if (string.Equals(filename, BaseItem.TrailerFolderName, StringComparison.OrdinalIgnoreCase) + if (string.Equals(filename, BaseItem.TrailersFolderName, StringComparison.OrdinalIgnoreCase) && !(parent is AggregateFolder) && !(parent is UserRootFolder)) { @@ -77,7 +77,7 @@ 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) + if (Path.GetFileNameWithoutExtension(filename.AsSpan()).Equals(BaseItem.ThemeSongFileName, StringComparison.Ordinal) && _libraryManager.IsAudioFile(filename)) { return true; diff --git a/Emby.Server.Implementations/Library/ExclusiveLiveStream.cs b/Emby.Server.Implementations/Library/ExclusiveLiveStream.cs index 6c65b58999..868071a992 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 d806373329..1326f60fe5 100644 --- a/Emby.Server.Implementations/Library/LibraryManager.cs +++ b/Emby.Server.Implementations/Library/LibraryManager.cs @@ -287,14 +287,14 @@ namespace Emby.Server.Implementations.Library if (item is IItemByName) { - if (!(item is MusicArtist)) + if (item is not MusicArtist) { return; } } else if (!item.IsFolder) { - if (!(item is Video) && !(item is LiveTvChannel)) + if (item is not Video && item is not LiveTvChannel) { return; } @@ -647,7 +647,7 @@ namespace Emby.Server.Implementations.Library /// Determines whether a path should be ignored based on its contents - called after the contents have been read. /// /// The args. - /// true if XXXX, false otherwise + /// true if XXXX, false otherwise. private static bool ShouldResolvePathContents(ItemResolveArgs args) { // Ignore any folders containing a file called .ignore @@ -866,7 +866,7 @@ namespace Emby.Server.Implementations.Library { var path = Person.GetPath(name); var id = GetItemByNameId(path); - if (!(GetItemById(id) is Person item)) + if (GetItemById(id) is not Person item) { item = new Person { @@ -1250,10 +1250,8 @@ namespace Emby.Server.Implementations.Library private CollectionTypeOptions? GetCollectionType(string path) { var files = _fileSystem.GetFilePaths(path, new[] { ".collection" }, true, false); - foreach (var file in files) + foreach (ReadOnlySpan file in files) { - // TODO: @bond use a ReadOnlySpan here when Enum.TryParse supports it - // https://github.com/dotnet/runtime/issues/20008 if (Enum.TryParse(Path.GetFileNameWithoutExtension(file), true, out var res)) { return res; @@ -1268,7 +1266,7 @@ namespace Emby.Server.Implementations.Library /// /// The id. /// BaseItem. - /// id + /// is null. public BaseItem GetItemById(Guid id) { if (id == Guid.Empty) @@ -1761,22 +1759,20 @@ namespace Emby.Server.Implementations.Library return orderedItems ?? items; } - public IEnumerable Sort(IEnumerable items, User user, IEnumerable> orderByList) + public IEnumerable Sort(IEnumerable items, User user, IEnumerable> orderBy) { var isFirst = true; IOrderedEnumerable orderedItems = null; - foreach (var orderBy in orderByList) + foreach (var (name, sortOrder) in orderBy) { - var comparer = GetComparer(orderBy.Item1, user); + var comparer = GetComparer(name, user); if (comparer == null) { continue; } - var sortOrder = orderBy.Item2; - if (isFirst) { orderedItems = sortOrder == SortOrder.Descending ? items.OrderByDescending(i => i, comparer) : items.OrderBy(i => i, comparer); @@ -2118,7 +2114,7 @@ namespace Emby.Server.Implementations.Library public LibraryOptions GetLibraryOptions(BaseItem item) { - if (!(item is CollectionFolder collectionFolder)) + if (item is not CollectionFolder collectionFolder) { // List.Find is more performant than FirstOrDefault due to enumerator allocation collectionFolder = GetCollectionFolders(item) @@ -2540,9 +2536,10 @@ namespace Emby.Server.Implementations.Library { episodeInfo = resolver.Resolve(episode.Path, isFolder, null, null, isAbsoluteNaming); // Resolve from parent folder if it's not the Season folder - if (episodeInfo == null && episode.Parent.GetType() == typeof(Folder)) + var parent = episode.GetParent(); + if (episodeInfo == null && parent.GetType() == typeof(Folder)) { - episodeInfo = resolver.Resolve(episode.Parent.Path, true, null, null, isAbsoluteNaming); + episodeInfo = resolver.Resolve(parent.Path, true, null, null, isAbsoluteNaming); if (episodeInfo != null) { // add the container @@ -2715,7 +2712,7 @@ namespace Emby.Server.Implementations.Library var namingOptions = GetNamingOptions(); var files = owner.IsInMixedFolder ? new List() : fileSystemChildren.Where(i => i.IsDirectory) - .Where(i => string.Equals(i.Name, BaseItem.TrailerFolderName, StringComparison.OrdinalIgnoreCase)) + .Where(i => string.Equals(i.Name, BaseItem.TrailersFolderName, StringComparison.OrdinalIgnoreCase)) .SelectMany(i => _fileSystem.GetFiles(i.FullName, _videoFileExtensions, false, false)) .ToList(); @@ -2759,7 +2756,7 @@ namespace Emby.Server.Implementations.Library var namingOptions = GetNamingOptions(); var files = owner.IsInMixedFolder ? new List() : fileSystemChildren.Where(i => i.IsDirectory) - .Where(i => BaseItem.AllExtrasTypesFolderNames.Contains(i.Name ?? string.Empty, StringComparer.OrdinalIgnoreCase)) + .Where(i => BaseItem.AllExtrasTypesFolderNames.ContainsKey(i.Name ?? string.Empty)) .SelectMany(i => _fileSystem.GetFiles(i.FullName, _videoFileExtensions, false, false)) .ToList(); @@ -3075,9 +3072,9 @@ namespace Emby.Server.Implementations.Library }); } - public void AddMediaPath(string virtualFolderName, MediaPathInfo pathInfo) + public void AddMediaPath(string virtualFolderName, MediaPathInfo mediaPath) { - AddMediaPathInternal(virtualFolderName, pathInfo, true); + AddMediaPathInternal(virtualFolderName, mediaPath, true); } private void AddMediaPathInternal(string virtualFolderName, MediaPathInfo pathInfo, bool saveLibraryOptions) @@ -3130,11 +3127,11 @@ namespace Emby.Server.Implementations.Library } } - public void UpdateMediaPath(string virtualFolderName, MediaPathInfo pathInfo) + public void UpdateMediaPath(string virtualFolderName, MediaPathInfo mediaPath) { - if (pathInfo == null) + if (mediaPath == null) { - throw new ArgumentNullException(nameof(pathInfo)); + throw new ArgumentNullException(nameof(mediaPath)); } var rootFolderPath = _configurationManager.ApplicationPaths.DefaultUserViewsPath; @@ -3147,9 +3144,9 @@ namespace Emby.Server.Implementations.Library var list = libraryOptions.PathInfos.ToList(); foreach (var originalPathInfo in list) { - if (string.Equals(pathInfo.Path, originalPathInfo.Path, StringComparison.Ordinal)) + if (string.Equals(mediaPath.Path, originalPathInfo.Path, StringComparison.Ordinal)) { - originalPathInfo.NetworkPath = pathInfo.NetworkPath; + originalPathInfo.NetworkPath = mediaPath.NetworkPath; break; } } @@ -3172,10 +3169,7 @@ namespace Emby.Server.Implementations.Library { if (!list.Any(i => string.Equals(i.Path, location, StringComparison.Ordinal))) { - list.Add(new MediaPathInfo - { - Path = location - }); + list.Add(new MediaPathInfo(location)); } } diff --git a/Emby.Server.Implementations/Library/LiveStreamHelper.cs b/Emby.Server.Implementations/Library/LiveStreamHelper.cs index 8062691827..83acd8e9f2 100644 --- a/Emby.Server.Implementations/Library/LiveStreamHelper.cs +++ b/Emby.Server.Implementations/Library/LiveStreamHelper.cs @@ -10,13 +10,14 @@ 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; using MediaBrowser.Model.Entities; +using MediaBrowser.Model.IO; using MediaBrowser.Model.MediaInfo; using Microsoft.Extensions.Logging; @@ -49,7 +50,7 @@ namespace Emby.Server.Implementations.Library { try { - await using FileStream jsonStream = File.OpenRead(cacheFilePath); + await using FileStream jsonStream = AsyncFile.OpenRead(cacheFilePath); mediaInfo = await JsonSerializer.DeserializeAsync(jsonStream, _jsonOptions, cancellationToken).ConfigureAwait(false); // _logger.LogDebug("Found cached media info"); @@ -86,7 +87,7 @@ namespace Emby.Server.Implementations.Library if (cacheFilePath != null) { Directory.CreateDirectory(Path.GetDirectoryName(cacheFilePath)); - await using FileStream createStream = File.OpenWrite(cacheFilePath); + await using FileStream createStream = AsyncFile.OpenWrite(cacheFilePath); await JsonSerializer.SerializeAsync(createStream, mediaInfo, _jsonOptions, cancellationToken).ConfigureAwait(false); // _logger.LogDebug("Saved media info to {0}", cacheFilePath); diff --git a/Emby.Server.Implementations/Library/MediaSourceManager.cs b/Emby.Server.Implementations/Library/MediaSourceManager.cs index 91c9e61cf3..351fced348 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; @@ -521,7 +521,7 @@ namespace Emby.Server.Implementations.Library // TODO: @bond Fix var json = JsonSerializer.SerializeToUtf8Bytes(mediaSource, _jsonOptions); - _logger.LogInformation("Live stream opened: " + json); + _logger.LogInformation("Live stream opened: {@MediaSource}", mediaSource); var clone = JsonSerializer.Deserialize(json, _jsonOptions); if (!request.UserId.Equals(Guid.Empty)) @@ -587,13 +587,6 @@ namespace Emby.Server.Implementations.Library mediaSource.InferTotalBitrate(); } - public Task 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 OpenLiveStream(LiveStreamRequest request, CancellationToken cancellationToken) { var result = await OpenLiveStreamInternal(request, cancellationToken).ConfigureAwait(false); @@ -602,7 +595,8 @@ namespace Emby.Server.Implementations.Library public async Task 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; @@ -638,7 +632,7 @@ namespace Emby.Server.Implementations.Library { try { - await using FileStream jsonStream = File.OpenRead(cacheFilePath); + await using FileStream jsonStream = AsyncFile.OpenRead(cacheFilePath); mediaInfo = await JsonSerializer.DeserializeAsync(jsonStream, _jsonOptions, cancellationToken).ConfigureAwait(false); // _logger.LogDebug("Found cached media info"); @@ -771,18 +765,19 @@ namespace Emby.Server.Implementations.Library mediaSource.InferTotalBitrate(true); } - public async Task> GetLiveStreamWithDirectStreamProvider(string id, CancellationToken cancellationToken) + public Task> 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(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(info.MediaSource, info as IDirectStreamProvider)); } - private Task GetLiveStreamInfo(string id, CancellationToken cancellationToken) + public ILiveStream GetLiveStreamInfo(string id) { if (string.IsNullOrEmpty(id)) { @@ -791,12 +786,16 @@ namespace Emby.Server.Implementations.Library if (_openStreams.TryGetValue(id, out ILiveStream info)) { - return Task.FromResult(info); - } - else - { - return Task.FromException(new ResourceNotFoundException()); + return info; } + + return null; + } + + /// + public ILiveStream GetLiveStreamInfoByUniqueId(string uniqueId) + { + return _openStreams.Values.FirstOrDefault(stream => string.Equals(uniqueId, stream?.UniqueId, StringComparison.OrdinalIgnoreCase)); } public async Task GetLiveStream(string id, CancellationToken cancellationToken) diff --git a/Emby.Server.Implementations/Library/MediaStreamSelector.cs b/Emby.Server.Implementations/Library/MediaStreamSelector.cs index b833122ea0..b3837fedb4 100644 --- a/Emby.Server.Implementations/Library/MediaStreamSelector.cs +++ b/Emby.Server.Implementations/Library/MediaStreamSelector.cs @@ -38,14 +38,11 @@ namespace Emby.Server.Implementations.Library } public static int? GetDefaultSubtitleStreamIndex( - List streams, + IEnumerable streams, string[] preferredLanguages, SubtitlePlaybackMode mode, string audioTrackLanguage) { - streams = GetSortedStreams(streams, MediaStreamType.Subtitle, preferredLanguages) - .ToList(); - MediaStream stream = null; if (mode == SubtitlePlaybackMode.None) @@ -53,52 +50,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)) { - stream = streams.Where(s => !s.IsForced).FirstOrDefault(s => preferredLanguages.Contains(s.Language, StringComparer.OrdinalIgnoreCase)); + stream = sortedStreams.FirstOrDefault(s => !s.IsForced && preferredLanguages.Contains(s.Language, StringComparer.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)) { - stream = streams.Where(s => !s.IsForced).FirstOrDefault(s => preferredLanguages.Contains(s.Language, StringComparer.OrdinalIgnoreCase)) ?? + stream = streams.FirstOrDefault(s => !s.IsForced && preferredLanguages.Contains(s.Language, StringComparer.OrdinalIgnoreCase)) ?? streams.FirstOrDefault(s => preferredLanguages.Contains(s.Language, StringComparer.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 GetSortedStreams(IEnumerable streams, MediaStreamType type, string[] languagePreferences) diff --git a/Emby.Server.Implementations/Library/MusicManager.cs b/Emby.Server.Implementations/Library/MusicManager.cs index 06300adebc..e2f1fb0ade 100644 --- a/Emby.Server.Implementations/Library/MusicManager.cs +++ b/Emby.Server.Implementations/Library/MusicManager.cs @@ -36,9 +36,10 @@ namespace Emby.Server.Implementations.Library return list.Concat(GetInstantMixFromGenres(item.Genres, user, dtoOptions)).ToList(); } - public List GetInstantMixFromArtist(MusicArtist item, User user, DtoOptions dtoOptions) + /// + public List GetInstantMixFromArtist(MusicArtist artist, User user, DtoOptions dtoOptions) { - return GetInstantMixFromGenres(item.Genres, user, dtoOptions); + return GetInstantMixFromGenres(artist.Genres, user, dtoOptions); } public List GetInstantMixFromAlbum(MusicAlbum item, User user, DtoOptions dtoOptions) diff --git a/Emby.Server.Implementations/Library/PathExtensions.cs b/Emby.Server.Implementations/Library/PathExtensions.cs index 86b8039fab..d5b855cdf2 100644 --- a/Emby.Server.Implementations/Library/PathExtensions.cs +++ b/Emby.Server.Implementations/Library/PathExtensions.cs @@ -53,7 +53,7 @@ namespace Emby.Server.Implementations.Library /// The original path. /// The original sub path. /// The new sub path. - /// The result of the sub path replacement + /// The result of the sub path replacement. /// The path after replacing the sub path. /// , or is empty. 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 e893d63350..fd9747b4b7 100644 --- a/Emby.Server.Implementations/Library/Resolvers/Audio/AudioResolver.cs +++ b/Emby.Server.Implementations/Library/Resolvers/Audio/AudioResolver.cs @@ -21,11 +21,11 @@ namespace Emby.Server.Implementations.Library.Resolvers.Audio /// public class AudioResolver : ItemResolver, IMultiItemResolver { - private readonly ILibraryManager LibraryManager; + private readonly ILibraryManager _libraryManager; public AudioResolver(ILibraryManager libraryManager) { - LibraryManager = libraryManager; + _libraryManager = libraryManager; } /// @@ -88,13 +88,13 @@ namespace Emby.Server.Implementations.Library.Resolvers.Audio } var files = args.FileSystemChildren - .Where(i => !LibraryManager.IgnoreFile(i, args.Parent)) + .Where(i => !_libraryManager.IgnoreFile(i, args.Parent)) .ToList(); return FindAudio(args, args.Path, args.Parent, files, args.DirectoryService, collectionType, false); } - if (LibraryManager.IsAudioFile(args.Path)) + if (_libraryManager.IsAudioFile(args.Path)) { var extension = Path.GetExtension(args.Path); @@ -107,7 +107,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 && _libraryManager.IsVideoFile(args.Path)) { return null; } @@ -182,7 +182,7 @@ namespace Emby.Server.Implementations.Library.Resolvers.Audio } } - var namingOptions = ((LibraryManager)LibraryManager).GetNamingOptions(); + var namingOptions = ((LibraryManager)_libraryManager).GetNamingOptions(); var resolver = new AudioBookListResolver(namingOptions); var resolverResult = resolver.Resolve(files).ToList(); diff --git a/Emby.Server.Implementations/Library/Resolvers/Audio/MusicAlbumResolver.cs b/Emby.Server.Implementations/Library/Resolvers/Audio/MusicAlbumResolver.cs index 8e1eccb10a..60720dd2f7 100644 --- a/Emby.Server.Implementations/Library/Resolvers/Audio/MusicAlbumResolver.cs +++ b/Emby.Server.Implementations/Library/Resolvers/Audio/MusicAlbumResolver.cs @@ -82,6 +82,9 @@ namespace Emby.Server.Implementations.Library.Resolvers.Audio /// /// Determine if the supplied file data points to a music album. /// + /// The path to check. + /// The directory service. + /// true if the provided path points to a music album, false otherwise. public bool IsMusicAlbum(string path, IDirectoryService directoryService) { return ContainsMusic(directoryService.GetFileSystemEntries(path), true, directoryService, _logger, _fileSystem, _libraryManager); diff --git a/Emby.Server.Implementations/Library/Resolvers/BaseVideoResolver.cs b/Emby.Server.Implementations/Library/Resolvers/BaseVideoResolver.cs index cdb492022b..9ff99fa431 100644 --- a/Emby.Server.Implementations/Library/Resolvers/BaseVideoResolver.cs +++ b/Emby.Server.Implementations/Library/Resolvers/BaseVideoResolver.cs @@ -5,6 +5,7 @@ using System; using System.IO; using System.Linq; +using DiscUtils.Udf; using Emby.Naming.Video; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; @@ -16,17 +17,17 @@ namespace Emby.Server.Implementations.Library.Resolvers /// /// Resolves a Path into a Video or Video subclass. /// - /// + /// The type of item to resolve. public abstract class BaseVideoResolver : MediaBrowser.Controller.Resolvers.ItemResolver where T : Video, new() { - protected readonly ILibraryManager LibraryManager; - protected BaseVideoResolver(ILibraryManager libraryManager) { LibraryManager = libraryManager; } + protected ILibraryManager LibraryManager { get; } + /// /// Resolves the specified args. /// @@ -80,7 +81,7 @@ namespace Emby.Server.Implementations.Library.Resolvers break; } - if (IsBluRayDirectory(child.FullName, filename, args.DirectoryService)) + if (IsBluRayDirectory(filename)) { videoInfo = VideoResolver.ResolveDirectory(args.Path, namingOptions); @@ -201,6 +202,22 @@ namespace Emby.Server.Implementations.Library.Resolvers { video.IsoType = IsoType.BluRay; } + else + { + // use disc-utils, both DVDs and BDs use UDF filesystem + using (var videoFileStream = File.Open(video.Path, FileMode.Open, FileAccess.Read)) + { + UdfReader udfReader = new UdfReader(videoFileStream); + if (udfReader.DirectoryExists("VIDEO_TS")) + { + video.IsoType = IsoType.Dvd; + } + else if (udfReader.DirectoryExists("BDMV")) + { + video.IsoType = IsoType.BluRay; + } + } + } } } @@ -258,6 +275,10 @@ namespace Emby.Server.Implementations.Library.Resolvers /// /// Determines whether [is DVD directory] [the specified directory name]. /// + /// The full path of the directory. + /// The name of the directory. + /// The directory service. + /// true if the provided directory is a DVD directory, false otherwise. protected bool IsDvdDirectory(string fullPath, string directoryName, IDirectoryService directoryService) { if (!string.Equals(directoryName, "video_ts", StringComparison.OrdinalIgnoreCase)) @@ -279,25 +300,13 @@ namespace Emby.Server.Implementations.Library.Resolvers } /// - /// Determines whether [is blu ray directory] [the specified directory name]. + /// Determines whether [is bluray directory] [the specified directory name]. /// - protected bool IsBluRayDirectory(string fullPath, string directoryName, IDirectoryService directoryService) + /// The directory name. + /// Whether the directory is a bluray directory. + protected bool IsBluRayDirectory(string directoryName) { - if (!string.Equals(directoryName, "bdmv", StringComparison.OrdinalIgnoreCase)) - { - return false; - } - - return true; - // var blurayExtensions = new[] - //{ - // ".mts", - // ".m2ts", - // ".bdmv", - // ".mpls" - //}; - - // return directoryService.GetFiles(fullPath).Any(i => blurayExtensions.Contains(i.Extension ?? string.Empty, StringComparer.OrdinalIgnoreCase)); + return string.Equals(directoryName, "bdmv", StringComparison.OrdinalIgnoreCase); } } } diff --git a/Emby.Server.Implementations/Library/Resolvers/Books/BookResolver.cs b/Emby.Server.Implementations/Library/Resolvers/Books/BookResolver.cs index 68076730b3..e685c87f1a 100644 --- a/Emby.Server.Implementations/Library/Resolvers/Books/BookResolver.cs +++ b/Emby.Server.Implementations/Library/Resolvers/Books/BookResolver.cs @@ -49,13 +49,12 @@ namespace Emby.Server.Implementations.Library.Resolvers.Books { var bookFiles = args.FileSystemChildren.Where(f => { - var fileExtension = Path.GetExtension(f.FullName) ?? - string.Empty; + var fileExtension = Path.GetExtension(f.FullName) + ?? string.Empty; return _validExtensions.Contains( fileExtension, - StringComparer - .OrdinalIgnoreCase); + StringComparer.OrdinalIgnoreCase); }).ToList(); // Don't return a Book if there is more (or less) than one document in the directory diff --git a/Emby.Server.Implementations/Library/Resolvers/FolderResolver.cs b/Emby.Server.Implementations/Library/Resolvers/FolderResolver.cs index 7aaee017dd..db7703cd60 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 /// /// Class FolderResolver. /// - public class FolderResolver : FolderResolver + public class FolderResolver : GenericFolderResolver { /// /// Gets the priority. @@ -32,24 +32,4 @@ namespace Emby.Server.Implementations.Library.Resolvers return null; } } - - /// - /// Class FolderResolver. - /// - /// The type of the T item type. - public abstract class FolderResolver : ItemResolver - where TItemType : Folder, new() - { - /// - /// Sets the initial item values. - /// - /// The item. - /// The args. - protected override void SetInitialItemValues(TItemType item, ItemResolveArgs args) - { - base.SetInitialItemValues(item, args); - - item.IsRoot = args.Parent == null; - } - } } diff --git a/Emby.Server.Implementations/Library/Resolvers/GenericFolderResolver.cs b/Emby.Server.Implementations/Library/Resolvers/GenericFolderResolver.cs new file mode 100644 index 0000000000..f109a5e9a2 --- /dev/null +++ b/Emby.Server.Implementations/Library/Resolvers/GenericFolderResolver.cs @@ -0,0 +1,27 @@ +#nullable disable + +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Library; + +namespace Emby.Server.Implementations.Library.Resolvers +{ + /// + /// Class FolderResolver. + /// + /// The type of the T item type. + public abstract class GenericFolderResolver : ItemResolver + where TItemType : Folder, new() + { + /// + /// Sets the initial item values. + /// + /// The item. + /// The args. + protected override void SetInitialItemValues(TItemType item, ItemResolveArgs args) + { + base.SetInitialItemValues(item, args); + + item.IsRoot = args.Parent == null; + } + } +} diff --git a/Emby.Server.Implementations/Library/Resolvers/VideoResolver.cs b/Emby.Server.Implementations/Library/Resolvers/GenericVideoResolver.cs similarity index 100% rename from Emby.Server.Implementations/Library/Resolvers/VideoResolver.cs rename to Emby.Server.Implementations/Library/Resolvers/GenericVideoResolver.cs diff --git a/Emby.Server.Implementations/Library/Resolvers/ItemResolver.cs b/Emby.Server.Implementations/Library/Resolvers/ItemResolver.cs index fa45ccf840..3f29ab191f 100644 --- a/Emby.Server.Implementations/Library/Resolvers/ItemResolver.cs +++ b/Emby.Server.Implementations/Library/Resolvers/ItemResolver.cs @@ -9,7 +9,7 @@ namespace Emby.Server.Implementations.Library.Resolvers /// /// Class ItemResolver. /// - /// + /// The type of BaseItem. public abstract class ItemResolver : IItemResolver where T : BaseItem, new() { diff --git a/Emby.Server.Implementations/Library/Resolvers/Movies/BoxSetResolver.cs b/Emby.Server.Implementations/Library/Resolvers/Movies/BoxSetResolver.cs index 69d71d0d9a..e7abe1e6da 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 /// /// Class BoxSetResolver. /// - public class BoxSetResolver : FolderResolver + public class BoxSetResolver : GenericFolderResolver { /// /// Resolves the specified args. diff --git a/Emby.Server.Implementations/Library/Resolvers/Movies/MovieResolver.cs b/Emby.Server.Implementations/Library/Resolvers/Movies/MovieResolver.cs index 889e29a6ba..f3b6ef0a28 100644 --- a/Emby.Server.Implementations/Library/Resolvers/Movies/MovieResolver.cs +++ b/Emby.Server.Implementations/Library/Resolvers/Movies/MovieResolver.cs @@ -24,6 +24,8 @@ namespace Emby.Server.Implementations.Library.Resolvers.Movies /// public class MovieResolver : BaseVideoResolver public class UserDataManager : IUserDataManager { - public event EventHandler UserDataSaved; - private readonly ConcurrentDictionary _userData = new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); @@ -44,6 +42,8 @@ namespace Emby.Server.Implementations.Library _repository = repository; } + public event EventHandler UserDataSaved; + public void SaveUserData(Guid userId, BaseItem item, UserItemData userData, UserDataSaveReason reason, CancellationToken cancellationToken) { var user = _userManager.GetUserById(userId); @@ -90,10 +90,9 @@ namespace Emby.Server.Implementations.Library /// /// Save the provided user data for the given user. Batch operation. Does not fire any events or update the cache. /// - /// - /// - /// - /// + /// The user id. + /// The user item data. + /// The cancellation token. public void SaveAllUserData(Guid userId, UserItemData[] userData, CancellationToken cancellationToken) { var user = _userManager.GetUserById(userId); @@ -104,8 +103,8 @@ namespace Emby.Server.Implementations.Library /// /// Retrieve all user data for the given user. /// - /// - /// + /// The user id. + /// A containing all of the user's item data. public List GetAllUserData(Guid userId) { var user = _userManager.GetUserById(userId); @@ -177,6 +176,7 @@ namespace Emby.Server.Implementations.Library return dto; } + /// public UserItemDataDto GetUserDataDto(BaseItem item, BaseItemDto itemDto, User user, DtoOptions options) { var userData = GetUserData(user, item); @@ -191,7 +191,7 @@ namespace Emby.Server.Implementations.Library /// /// The data. /// DtoUserItemData. - /// + /// is null. private UserItemDataDto GetUserItemDataDto(UserItemData data) { if (data == null) @@ -212,6 +212,7 @@ namespace Emby.Server.Implementations.Library }; } + /// public bool UpdatePlayState(BaseItem item, UserItemData data, long? reportedPositionTicks) { var playedToCompletion = false; diff --git a/Emby.Server.Implementations/Library/UserViewManager.cs b/Emby.Server.Implementations/Library/UserViewManager.cs index e2da672a3c..711647e7a6 100644 --- a/Emby.Server.Implementations/Library/UserViewManager.cs +++ b/Emby.Server.Implementations/Library/UserViewManager.cs @@ -341,19 +341,26 @@ 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(); + var excludeItemTypes = includeItemTypes.Length == 0 && mediaTypes.Count == 0 + ? new[] + { + nameof(Person), + nameof(Studio), + nameof(Year), + nameof(MusicGenre), + nameof(Genre) + } + : Array.Empty(); var query = new InternalItemsQuery(user) { IncludeItemTypes = includeItemTypes, - OrderBy = new[] { (ItemSortBy.DateCreated, SortOrder.Descending) }, + OrderBy = new[] + { + (ItemSortBy.DateCreated, SortOrder.Descending), + (ItemSortBy.SortName, SortOrder.Descending), + (ItemSortBy.ProductionYear, SortOrder.Descending) + }, IsFolder = includeItemTypes.Length == 0 ? false : (bool?)null, ExcludeItemTypes = excludeItemTypes, IsVirtualItem = false, diff --git a/Emby.Server.Implementations/Library/Validators/ArtistsValidator.cs b/Emby.Server.Implementations/Library/Validators/ArtistsValidator.cs index f9a3e2c64b..40436d9c5d 100644 --- a/Emby.Server.Implementations/Library/Validators/ArtistsValidator.cs +++ b/Emby.Server.Implementations/Library/Validators/ArtistsValidator.cs @@ -95,10 +95,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/StudiosValidator.cs b/Emby.Server.Implementations/Library/Validators/StudiosValidator.cs index 9a8c5f39d5..8577d722e0 100644 --- a/Emby.Server.Implementations/Library/Validators/StudiosValidator.cs +++ b/Emby.Server.Implementations/Library/Validators/StudiosValidator.cs @@ -87,12 +87,15 @@ namespace Emby.Server.Implementations.Library.Validators foreach (var item in deadEntities) { - _logger.LogInformation("Deleting dead {2} {0} {1}.", item.Id.ToString("N", CultureInfo.InvariantCulture), item.Name, item.GetType().Name); + _logger.LogInformation("Deleting dead {ItemType} {ItemId} {ItemName}", item.GetType().Name, item.Id.ToString("N", CultureInfo.InvariantCulture), item.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/LiveTv/EmbyTV/DirectRecorder.cs b/Emby.Server.Implementations/LiveTv/EmbyTV/DirectRecorder.cs index 3fcadf5b1b..6937cc0971 100644 --- a/Emby.Server.Implementations/LiveTv/EmbyTV/DirectRecorder.cs +++ b/Emby.Server.Implementations/LiveTv/EmbyTV/DirectRecorder.cs @@ -1,5 +1,3 @@ -#nullable disable - #pragma warning disable CS1591 using System; @@ -7,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; @@ -33,7 +32,7 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV return targetFile; } - public Task Record(IDirectStreamProvider directStreamProvider, MediaSourceInfo mediaSource, string targetFile, TimeSpan duration, Action onStarted, CancellationToken cancellationToken) + public Task Record(IDirectStreamProvider? directStreamProvider, MediaSourceInfo mediaSource, string targetFile, TimeSpan duration, Action onStarted, CancellationToken cancellationToken) { if (directStreamProvider != null) { @@ -45,23 +44,29 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV private async Task RecordFromDirectStreamProvider(IDirectStreamProvider directStreamProvider, string targetFile, TimeSpan duration, Action onStarted, CancellationToken cancellationToken) { - Directory.CreateDirectory(Path.GetDirectoryName(targetFile)); + 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)) + 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); - - await directStreamProvider.CopyToAsync(output, cancellationTokenSource.Token).ConfigureAwait(false); + var linkedCancellationToken = cancellationTokenSource.Token; + + 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,10 +76,9 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV _logger.LogInformation("Opened recording stream from tuner provider"); - Directory.CreateDirectory(Path.GetDirectoryName(targetFile)); + 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); + 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 7970631201..980b42729a 100644 --- a/Emby.Server.Implementations/LiveTv/EmbyTV/EmbyTV.cs +++ b/Emby.Server.Implementations/LiveTv/EmbyTV/EmbyTV.cs @@ -159,8 +159,7 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV try { var recordingFolders = GetRecordingFolders().ToArray(); - var virtualFolders = _libraryManager.GetVirtualFolders() - .ToList(); + var virtualFolders = _libraryManager.GetVirtualFolders(); var allExistingPaths = virtualFolders.SelectMany(i => i.Locations).ToList(); @@ -177,7 +176,7 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV continue; } - var mediaPathInfos = pathsToCreate.Select(i => new MediaPathInfo { Path = i }).ToArray(); + var mediaPathInfos = pathsToCreate.Select(i => new MediaPathInfo(i)).ToArray(); var libraryOptions = new LibraryOptions { @@ -210,7 +209,7 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV foreach (var path in pathsToRemove) { - await RemovePathFromLibrary(path).ConfigureAwait(false); + await RemovePathFromLibraryAsync(path).ConfigureAwait(false); } } catch (Exception ex) @@ -219,13 +218,12 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV } } - private async Task RemovePathFromLibrary(string path) + private async Task RemovePathFromLibraryAsync(string path) { _logger.LogDebug("Removing path from library: {0}", path); var requiresRefresh = false; - var virtualFolders = _libraryManager.GetVirtualFolders() - .ToList(); + var virtualFolders = _libraryManager.GetVirtualFolders(); foreach (var virtualFolder in virtualFolders) { @@ -460,7 +458,7 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV if (!string.IsNullOrWhiteSpace(tunerChannel.TunerChannelId)) { var tunerChannelId = tunerChannel.TunerChannelId; - if (tunerChannelId.IndexOf(".json.schedulesdirect.org", StringComparison.OrdinalIgnoreCase) != -1) + if (tunerChannelId.Contains(".json.schedulesdirect.org", StringComparison.OrdinalIgnoreCase)) { tunerChannelId = tunerChannelId.Replace(".json.schedulesdirect.org", string.Empty, StringComparison.OrdinalIgnoreCase).TrimStart('I'); } @@ -612,16 +610,16 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV throw new NotImplementedException(); } - public Task CreateTimer(TimerInfo timer, CancellationToken cancellationToken) + public Task CreateTimer(TimerInfo info, CancellationToken cancellationToken) { - var existingTimer = string.IsNullOrWhiteSpace(timer.ProgramId) ? + var existingTimer = string.IsNullOrWhiteSpace(info.ProgramId) ? null : - _timerProvider.GetTimerByProgramId(timer.ProgramId); + _timerProvider.GetTimerByProgramId(info.ProgramId); if (existingTimer != null) { - if (existingTimer.Status == RecordingStatus.Cancelled || - existingTimer.Status == RecordingStatus.Completed) + if (existingTimer.Status == RecordingStatus.Cancelled + || existingTimer.Status == RecordingStatus.Completed) { existingTimer.Status = RecordingStatus.New; existingTimer.IsManual = true; @@ -634,32 +632,32 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV } } - timer.Id = Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture); + info.Id = Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture); LiveTvProgram programInfo = null; - if (!string.IsNullOrWhiteSpace(timer.ProgramId)) + if (!string.IsNullOrWhiteSpace(info.ProgramId)) { - programInfo = GetProgramInfoFromCache(timer); + programInfo = GetProgramInfoFromCache(info); } if (programInfo == null) { - _logger.LogInformation("Unable to find program with Id {0}. Will search using start date", timer.ProgramId); - programInfo = GetProgramInfoFromCache(timer.ChannelId, timer.StartDate); + _logger.LogInformation("Unable to find program with Id {0}. Will search using start date", info.ProgramId); + programInfo = GetProgramInfoFromCache(info.ChannelId, info.StartDate); } if (programInfo != null) { - CopyProgramInfoToTimerInfo(programInfo, timer); + CopyProgramInfoToTimerInfo(programInfo, info); } - timer.IsManual = true; - _timerProvider.Add(timer); + info.IsManual = true; + _timerProvider.Add(info); - TimerCreated?.Invoke(this, new GenericEventArgs(timer)); + TimerCreated?.Invoke(this, new GenericEventArgs(info)); - return Task.FromResult(timer.Id); + return Task.FromResult(info.Id); } public async Task CreateSeriesTimer(SeriesTimerInfo info, CancellationToken cancellationToken) @@ -913,18 +911,14 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV var epgChannel = await GetEpgChannelFromTunerChannel(provider.Item1, provider.Item2, channel, cancellationToken).ConfigureAwait(false); - List programs; - if (epgChannel == null) { _logger.LogDebug("EPG channel not found for tuner channel {0}-{1} from {2}-{3}", channel.Number, channel.Name, provider.Item1.Name, provider.Item2.ListingsId ?? string.Empty); - programs = new List(); + continue; } - else - { - programs = (await provider.Item1.GetProgramsAsync(provider.Item2, epgChannel.Id, startDateUtc, endDateUtc, cancellationToken) + + List programs = (await provider.Item1.GetProgramsAsync(provider.Item2, epgChannel.Id, startDateUtc, endDateUtc, cancellationToken) .ConfigureAwait(false)).ToList(); - } // Replace the value that came from the provider with a normalized value foreach (var program in programs) @@ -940,7 +934,7 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV } } - return new List(); + return Enumerable.Empty(); } private List> GetListingProviders() @@ -1292,7 +1286,7 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV _logger.LogInformation("Beginning recording. Will record for {0} minutes.", duration.TotalMinutes.ToString(CultureInfo.InvariantCulture)); - _logger.LogInformation("Writing file to path: " + recordPath); + _logger.LogInformation("Writing file to: {Path}", recordPath); Action onStarted = async () => { @@ -1417,13 +1411,13 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV private void TriggerRefresh(string path) { - _logger.LogInformation("Triggering refresh on {path}", path); + _logger.LogInformation("Triggering refresh on {Path}", path); var item = GetAffectedBaseItem(Path.GetDirectoryName(path)); if (item != null) { - _logger.LogInformation("Refreshing recording parent {path}", item.Path); + _logger.LogInformation("Refreshing recording parent {Path}", item.Path); _providerManager.QueueRefresh( item.Id, @@ -1458,7 +1452,7 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV if (item.GetType() == typeof(Folder) && string.Equals(item.Path, parentPath, StringComparison.OrdinalIgnoreCase)) { var parentItem = item.GetParent(); - if (parentItem != null && !(parentItem is AggregateFolder)) + if (parentItem != null && parentItem is not AggregateFolder) { item = parentItem; } @@ -1512,8 +1506,7 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV DeleteLibraryItemsForTimers(timersToDelete); - var librarySeries = _libraryManager.FindByPath(seriesPath, true) as Folder; - if (librarySeries == null) + if (_libraryManager.FindByPath(seriesPath, true) is not Folder librarySeries) { return; } @@ -1667,7 +1660,7 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV _logger.LogInformation("Running recording post processor {0} {1}", process.StartInfo.FileName, process.StartInfo.Arguments); - process.Exited += Process_Exited; + process.Exited += OnProcessExited; process.Start(); } catch (Exception ex) @@ -1681,7 +1674,7 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV return arguments.Replace("{path}", path, StringComparison.OrdinalIgnoreCase); } - private void Process_Exited(object sender, EventArgs e) + private void OnProcessExited(object sender, EventArgs e) { using (var process = (Process)sender) { @@ -1855,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)) @@ -1920,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(); @@ -1997,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) { @@ -2239,7 +2228,7 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV var enabledTimersForSeries = new List(); foreach (var timer in allTimers) { - var existingTimer = _timerProvider.GetTimer(timer.Id) + var existingTimer = _timerProvider.GetTimer(timer.Id) ?? (string.IsNullOrWhiteSpace(timer.ProgramId) ? null : _timerProvider.GetTimerByProgramId(timer.ProgramId)); diff --git a/Emby.Server.Implementations/LiveTv/EmbyTV/EncodedRecorder.cs b/Emby.Server.Implementations/LiveTv/EmbyTV/EncodedRecorder.cs index 93781cb7ba..835028b92a 100644 --- a/Emby.Server.Implementations/LiveTv/EmbyTV/EncodedRecorder.cs +++ b/Emby.Server.Implementations/LiveTv/EmbyTV/EncodedRecorder.cs @@ -94,7 +94,7 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV 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, true); + _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); @@ -188,7 +188,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("\"", "\\\""), // Escape quotes in filename videoArgs, GetAudioArgs(mediaSource), subtitleArgs, @@ -205,9 +205,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"; } @@ -319,11 +319,6 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV } } } - catch (ObjectDisposedException) - { - // TODO Investigate and properly fix. - // Don't spam the log. This doesn't seem to throw in windows, but sometimes under linux - } catch (Exception ex) { _logger.LogError(ex, "Error reading ffmpeg recording log"); diff --git a/Emby.Server.Implementations/LiveTv/EmbyTV/EpgChannelData.cs b/Emby.Server.Implementations/LiveTv/EmbyTV/EpgChannelData.cs index 0ec52a9598..20a8213a77 100644 --- a/Emby.Server.Implementations/LiveTv/EmbyTV/EpgChannelData.cs +++ b/Emby.Server.Implementations/LiveTv/EmbyTV/EpgChannelData.cs @@ -8,7 +8,6 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV { internal class EpgChannelData { - private readonly Dictionary _channelsById; private readonly Dictionary _channelsByNumber; diff --git a/Emby.Server.Implementations/LiveTv/EmbyTV/IRecorder.cs b/Emby.Server.Implementations/LiveTv/EmbyTV/IRecorder.cs index 4712724d67..7705132da2 100644 --- a/Emby.Server.Implementations/LiveTv/EmbyTV/IRecorder.cs +++ b/Emby.Server.Implementations/LiveTv/EmbyTV/IRecorder.cs @@ -13,7 +13,14 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV /// /// Records the specified media source. /// - Task Record(IDirectStreamProvider directStreamProvider, MediaSourceInfo mediaSource, string targetFile, TimeSpan duration, Action onStarted, CancellationToken cancellationToken); + /// The direct stream provider, or null. + /// The media source. + /// The target file. + /// The duration to record. + /// An action to perform when recording starts. + /// The cancellation token. + /// A that represents the recording operation. + 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 4a031e4752..46979bfc57 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 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(bytes, _jsonOptions); + if (_items == null) + { + Logger.LogError("Error deserializing {Path}, data was null", _dataPath); + _items = Array.Empty(); + } + 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/EmbyTV/TimerManager.cs b/Emby.Server.Implementations/LiveTv/EmbyTV/TimerManager.cs index 6c52a9a73d..a861e6ae44 100644 --- a/Emby.Server.Implementations/LiveTv/EmbyTV/TimerManager.cs +++ b/Emby.Server.Implementations/LiveTv/EmbyTV/TimerManager.cs @@ -1,5 +1,3 @@ -#nullable disable - #pragma warning disable CS1591 using System; @@ -23,7 +21,7 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV { } - public event EventHandler> TimerFired; + public event EventHandler>? TimerFired; public void RestartTimers() { @@ -145,9 +143,9 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV } } - private void TimerCallback(object state) + private void TimerCallback(object? state) { - var timerId = (string)state; + var timerId = (string?)state ?? throw new ArgumentNullException(nameof(state)); var timer = GetAll().FirstOrDefault(i => string.Equals(i.Id, timerId, StringComparison.OrdinalIgnoreCase)); if (timer != null) @@ -156,12 +154,12 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV } } - public TimerInfo GetTimer(string id) + public TimerInfo? GetTimer(string id) { return GetAll().FirstOrDefault(r => string.Equals(r.Id, id, StringComparison.OrdinalIgnoreCase)); } - public TimerInfo GetTimerByProgramId(string programId) + public TimerInfo? GetTimerByProgramId(string programId) { return GetAll().FirstOrDefault(r => string.Equals(r.ProgramId, programId, StringComparison.OrdinalIgnoreCase)); } diff --git a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirect.cs b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirect.cs index b7639a51ce..1f963e4a29 100644 --- a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirect.cs +++ b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirect.cs @@ -9,12 +9,14 @@ using System.Globalization; using System.Linq; using System.Net; using System.Net.Http; +using System.Net.Http.Headers; using System.Net.Mime; using System.Text; using System.Text.Json; using System.Threading; using System.Threading.Tasks; -using MediaBrowser.Common; +using Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos; +using Jellyfin.Extensions; using Jellyfin.Extensions.Json; using MediaBrowser.Common.Net; using MediaBrowser.Controller.LiveTv; @@ -33,7 +35,6 @@ namespace Emby.Server.Implementations.LiveTv.Listings private readonly ILogger _logger; private readonly IHttpClientFactory _httpClientFactory; private readonly SemaphoreSlim _tokenSemaphore = new SemaphoreSlim(1, 1); - private readonly IApplicationHost _appHost; private readonly ICryptoProvider _cryptoProvider; private readonly ConcurrentDictionary _tokens = new ConcurrentDictionary(); @@ -43,12 +44,10 @@ namespace Emby.Server.Implementations.LiveTv.Listings public SchedulesDirect( ILogger logger, IHttpClientFactory httpClientFactory, - IApplicationHost appHost, ICryptoProvider cryptoProvider) { _logger = logger; _httpClientFactory = httpClientFactory; - _appHost = appHost; _cryptoProvider = cryptoProvider; } @@ -96,12 +95,12 @@ namespace Emby.Server.Implementations.LiveTv.Listings var dates = GetScheduleRequestDates(startDateUtc, endDateUtc); _logger.LogInformation("Channel Station ID is: {ChannelID}", channelId); - var requestList = new List() + var requestList = new List() { - new ScheduleDirect.RequestScheduleForChannel() + new RequestScheduleForChannelDto() { - stationID = channelId, - date = dates + StationId = channelId, + Date = dates } }; @@ -113,61 +112,77 @@ namespace Emby.Server.Implementations.LiveTv.Listings 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>(responseStream, _jsonOptions, cancellationToken).ConfigureAwait(false); + var dailySchedules = await JsonSerializer.DeserializeAsync>(responseStream, _jsonOptions, cancellationToken).ConfigureAwait(false); + if (dailySchedules == null) + { + return Array.Empty(); + } + _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 = new ByteArrayContent(JsonSerializer.SerializeToUtf8Bytes(programIds, _jsonOptions)); + programRequestOptions.Content.Headers.ContentType = MediaTypeHeaderValue.Parse(MediaTypeNames.Application.Json); 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>(innerResponseStream, _jsonOptions, cancellationToken).ConfigureAwait(false); - var programDict = programDetails.ToDictionary(p => p.programID, y => y); + var programDetails = await JsonSerializer.DeserializeAsync>(innerResponseStream, _jsonOptions, cancellationToken).ConfigureAwait(false); + if (programDetails == null) + { + return Array.Empty(); + } + + var programDict = programDetails.ToDictionary(p => p.ProgramId, y => y); var programIdsWithImages = programDetails - .Where(p => p.hasImageArtwork).Select(p => p.programID) + .Where(p => p.HasImageArtwork).Select(p => p.ProgramId) .ToList(); var images = await GetImageForPrograms(info, programIdsWithImages, cancellationToken).ConfigureAwait(false); var programsInfo = new List(); - foreach (ScheduleDirect.Program schedule in dailySchedules.SelectMany(d => d.programs)) + foreach (ProgramDto schedule in dailySchedules.SelectMany(d => d.Programs)) { // _logger.LogDebug("Proccesing Schedule for statio ID " + stationID + // " which corresponds to channel " + channelNumber + " and program id " + - // schedule.programID + " which says it has images? " + - // programDict[schedule.programID].hasImageArtwork); + // 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.Substring(0, 10)); + var imageIndex = images.FindIndex(i => i.ProgramId == schedule.ProgramId[..10]); if (imageIndex > -1) { - var programEntry = programDict[schedule.programID]; + var programEntry = programDict[schedule.ProgramId]; - var allImages = images[imageIndex].data ?? new List(); - 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)); + var imagesWithoutText = allImages.Where(i => string.Equals(i.Text, "no", StringComparison.OrdinalIgnoreCase)); const double DesiredAspect = 2.0 / 3; - programEntry.primaryImage = GetProgramImage(ApiUrl, imagesWithText, true, DesiredAspect) ?? + programEntry.PrimaryImage = GetProgramImage(ApiUrl, imagesWithText, true, DesiredAspect) ?? GetProgramImage(ApiUrl, allImages, true, DesiredAspect); const double WideAspect = 16.0 / 9; - programEntry.thumbImage = GetProgramImage(ApiUrl, imagesWithText, true, WideAspect); + programEntry.ThumbImage = GetProgramImage(ApiUrl, imagesWithText, true, WideAspect); // Don't supply the same image twice - if (string.Equals(programEntry.primaryImage, programEntry.thumbImage, StringComparison.Ordinal)) + if (string.Equals(programEntry.PrimaryImage, programEntry.ThumbImage, StringComparison.Ordinal)) { - programEntry.thumbImage = null; + programEntry.ThumbImage = null; } - programEntry.backdropImage = GetProgramImage(ApiUrl, imagesWithoutText, true, WideAspect); + programEntry.BackdropImage = GetProgramImage(ApiUrl, imagesWithoutText, true, WideAspect); // programEntry.bannerImage = GetProgramImage(ApiUrl, data, "Banner", false) ?? // GetProgramImage(ApiUrl, data, "Banner-L1", false) ?? @@ -176,15 +191,15 @@ namespace Emby.Server.Implementations.LiveTv.Listings } } - programsInfo.Add(GetProgram(channelId, schedule, programDict[schedule.programID])); + programsInfo.Add(GetProgram(channelId, schedule, programDict[schedule.ProgramId])); } return programsInfo; } - private static int GetSizeOrder(ScheduleDirect.ImageData image) + private static int GetSizeOrder(ImageDataDto image) { - if (int.TryParse(image.height, out int value)) + if (int.TryParse(image.Height, out int value)) { return value; } @@ -192,53 +207,58 @@ namespace Emby.Server.Implementations.LiveTv.Listings return 0; } - private static string GetChannelNumber(ScheduleDirect.Map map) + private static string GetChannelNumber(MapDto map) { - var channelNumber = map.logicalChannelNumber; + var channelNumber = map.LogicalChannelNumber; if (string.IsNullOrWhiteSpace(channelNumber)) { - channelNumber = map.channel; + channelNumber = map.Channel; } if (string.IsNullOrWhiteSpace(channelNumber)) { - channelNumber = map.atscMajor + "." + map.atscMinor; + channelNumber = map.AtscMajor + "." + map.AtscMinor; } return channelNumber.TrimStart('0'); } - private static bool IsMovie(ScheduleDirect.ProgramDetails programInfo) + private static bool IsMovie(ProgramDetailsDto programInfo) { - return string.Equals(programInfo.entityType, "movie", StringComparison.OrdinalIgnoreCase); + return string.Equals(programInfo.EntityType, "movie", StringComparison.OrdinalIgnoreCase); } - private ProgramInfo GetProgram(string channelId, ScheduleDirect.Program programInfo, ScheduleDirect.ProgramDetails details) + private ProgramInfo GetProgram(string channelId, ProgramDto programInfo, ProgramDetailsDto details) { - var startAt = GetDate(programInfo.airDateTime); - var endAt = startAt.AddSeconds(programInfo.duration); + if (programInfo.AirDateTime == null) + { + return null; + } + + var startAt = programInfo.AirDateTime.Value; + var endAt = startAt.AddSeconds(programInfo.Duration); var audioType = ProgramAudio.Stereo; - var programId = programInfo.programID ?? string.Empty; + var programId = programInfo.ProgramId ?? string.Empty; 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", StringComparer.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", StringComparer.OrdinalIgnoreCase)) { audioType = ProgramAudio.DolbyDigital; } - else if (programInfo.audioProperties.Exists(item => string.Equals(item, "dd", StringComparison.OrdinalIgnoreCase))) + else if (programInfo.AudioProperties.Contains("dd", StringComparer.OrdinalIgnoreCase)) { audioType = ProgramAudio.DolbyDigital; } - else if (programInfo.audioProperties.Exists(item => string.Equals(item, "stereo", StringComparison.OrdinalIgnoreCase))) + else if (programInfo.AudioProperties.Contains("stereo", StringComparer.OrdinalIgnoreCase)) { audioType = ProgramAudio.Stereo; } @@ -249,9 +269,9 @@ namespace Emby.Server.Implementations.LiveTv.Listings } string episodeTitle = null; - if (details.episodeTitle150 != null) + if (details.EpisodeTitle150 != null) { - episodeTitle = details.episodeTitle150; + episodeTitle = details.EpisodeTitle150; } var info = new ProgramInfo @@ -260,22 +280,22 @@ namespace Emby.Server.Implementations.LiveTv.Listings Id = newID, StartDate = startAt, EndDate = endAt, - Name = details.titles[0].title120 ?? "Unknown", + Name = details.Titles[0].Title120 ?? "Unknown", OfficialRating = null, CommunityRating = null, EpisodeTitle = episodeTitle, Audio = audioType, // IsNew = programInfo.@new ?? false, - IsRepeat = programInfo.@new == null, - IsSeries = string.Equals(details.entityType, "episode", StringComparison.OrdinalIgnoreCase), - ImageUrl = details.primaryImage, - ThumbImageUrl = details.thumbImage, - IsKids = string.Equals(details.audience, "children", StringComparison.OrdinalIgnoreCase), - IsSports = string.Equals(details.entityType, "sports", StringComparison.OrdinalIgnoreCase), + IsRepeat = programInfo.New == null, + IsSeries = string.Equals(details.EntityType, "episode", StringComparison.OrdinalIgnoreCase), + ImageUrl = details.PrimaryImage, + ThumbImageUrl = details.ThumbImage, + IsKids = string.Equals(details.Audience, "children", StringComparison.OrdinalIgnoreCase), + IsSports = string.Equals(details.EntityType, "sports", StringComparison.OrdinalIgnoreCase), IsMovie = IsMovie(details), - Etag = programInfo.md5, - IsLive = string.Equals(programInfo.liveTapeDelay, "live", StringComparison.OrdinalIgnoreCase), - IsPremiere = programInfo.premiere || (programInfo.isPremiereOrFinale ?? string.Empty).IndexOf("premiere", StringComparison.OrdinalIgnoreCase) != -1 + Etag = programInfo.Md5, + IsLive = string.Equals(programInfo.LiveTapeDelay, "live", StringComparison.OrdinalIgnoreCase), + IsPremiere = programInfo.Premiere || (programInfo.IsPremiereOrFinale ?? string.Empty).IndexOf("premiere", StringComparison.OrdinalIgnoreCase) != -1 }; var showId = programId; @@ -298,15 +318,15 @@ namespace Emby.Server.Implementations.LiveTv.Listings info.ShowId = showId; - if (programInfo.videoProperties != null) + 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", StringComparer.OrdinalIgnoreCase); + info.Is3D = programInfo.VideoProperties.Contains("3d", StringComparer.OrdinalIgnoreCase); } - if (details.contentRating != null && details.contentRating.Count > 0) + if (details.ContentRating != null && details.ContentRating.Count > 0) { - info.OfficialRating = details.contentRating[0].code.Replace("TV", "TV-", StringComparison.Ordinal) + info.OfficialRating = details.ContentRating[0].Code.Replace("TV", "TV-", StringComparison.Ordinal) .Replace("--", "-", StringComparison.Ordinal); var invalid = new[] { "N/A", "Approved", "Not Rated", "Passed" }; @@ -316,15 +336,15 @@ namespace Emby.Server.Implementations.LiveTv.Listings } } - if (details.descriptions != null) + if (details.Descriptions != null) { - if (details.descriptions.description1000 != null && details.descriptions.description1000.Count > 0) + if (details.Descriptions.Description1000 != null && details.Descriptions.Description1000.Count > 0) { - info.Overview = details.descriptions.description1000[0].description; + info.Overview = details.Descriptions.Description1000[0].Description; } - else if (details.descriptions.description100 != null && details.descriptions.description100.Count > 0) + else if (details.Descriptions.Description100 != null && details.Descriptions.Description100.Count > 0) { - info.Overview = details.descriptions.description100[0].description; + info.Overview = details.Descriptions.Description100[0].Description; } } @@ -334,18 +354,18 @@ namespace Emby.Server.Implementations.LiveTv.Listings info.SeriesProviderIds[MetadataProvider.Zap2It.ToString()] = info.SeriesId; - if (details.metadata != null) + if (details.Metadata != null) { - foreach (var metadataProgram in details.metadata) + foreach (var metadataProgram in details.Metadata) { var gracenote = metadataProgram.Gracenote; if (gracenote != null) { - info.SeasonNumber = gracenote.season; + info.SeasonNumber = gracenote.Season; - if (gracenote.episode > 0) + if (gracenote.Episode > 0) { - info.EpisodeNumber = gracenote.episode; + info.EpisodeNumber = gracenote.Episode; } break; @@ -354,25 +374,25 @@ 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; } - if (details.movie != null) + if (details.Movie != null) { - if (!string.IsNullOrEmpty(details.movie.year) - && int.TryParse(details.movie.year, out int year)) + if (!string.IsNullOrEmpty(details.Movie.Year) + && int.TryParse(details.Movie.Year, out int year)) { info.ProductionYear = year; } } - if (details.genres != null) + if (details.Genres != null) { - info.Genres = details.genres.Where(g => !string.IsNullOrWhiteSpace(g)).ToList(); - info.IsNews = details.genres.Contains("news", StringComparer.OrdinalIgnoreCase); + info.Genres = details.Genres.Where(g => !string.IsNullOrWhiteSpace(g)).ToList(); + info.IsNews = details.Genres.Contains("news", StringComparer.OrdinalIgnoreCase); if (info.Genres.Contains("children", StringComparer.OrdinalIgnoreCase)) { @@ -383,23 +403,11 @@ 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 images, bool returnDefaultImage, double desiredAspect) + private string GetProgramImage(string apiUrl, IEnumerable images, bool returnDefaultImage, double desiredAspect) { var match = images .OrderBy(i => Math.Abs(desiredAspect - GetAspectRatio(i))) - .ThenByDescending(GetSizeOrder) + .ThenByDescending(i => GetSizeOrder(i)) .FirstOrDefault(); if (match == null) @@ -407,7 +415,7 @@ namespace Emby.Server.Implementations.LiveTv.Listings return null; } - var uri = match.uri; + var uri = match.Uri; if (string.IsNullOrWhiteSpace(uri)) { @@ -423,19 +431,19 @@ namespace Emby.Server.Implementations.LiveTv.Listings } } - private static double GetAspectRatio(ScheduleDirect.ImageData i) + private static double GetAspectRatio(ImageDataDto i) { int width = 0; int height = 0; - if (!string.IsNullOrWhiteSpace(i.width)) + if (!string.IsNullOrWhiteSpace(i.Width)) { - int.TryParse(i.width, out width); + _ = int.TryParse(i.Width, out width); } - if (!string.IsNullOrWhiteSpace(i.height)) + if (!string.IsNullOrWhiteSpace(i.Height)) { - int.TryParse(i.height, out height); + _ = int.TryParse(i.Height, out height); } if (height == 0 || width == 0) @@ -448,14 +456,14 @@ namespace Emby.Server.Implementations.LiveTv.Listings return result; } - private async Task> GetImageForPrograms( + private async Task> GetImageForPrograms( ListingsProviderInfo info, IReadOnlyList programIds, CancellationToken cancellationToken) { if (programIds.Count == 0) { - return new List(); + return Array.Empty(); } StringBuilder str = new StringBuilder("[", 1 + (programIds.Count * 13)); @@ -479,13 +487,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>(response, _jsonOptions, cancellationToken).ConfigureAwait(false); + return await JsonSerializer.DeserializeAsync>(response, _jsonOptions, cancellationToken).ConfigureAwait(false); } catch (Exception ex) { _logger.LogError(ex, "Error getting image info from schedules direct"); - return new List(); + return Array.Empty(); } } @@ -508,18 +516,18 @@ 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>(response, _jsonOptions, cancellationToken).ConfigureAwait(false); + var root = await JsonSerializer.DeserializeAsync>(response, _jsonOptions, cancellationToken).ConfigureAwait(false); if (root != null) { - foreach (ScheduleDirect.Headends headend in root) + foreach (HeadendsDto headend in root) { - foreach (ScheduleDirect.Lineup lineup in headend.lineups) + foreach (LineupDto lineup in headend.Lineups) { lineups.Add(new NameIdPair { - Name = string.IsNullOrWhiteSpace(lineup.name) ? lineup.lineup : lineup.name, - Id = lineup.uri.Substring(18) + Name = string.IsNullOrWhiteSpace(lineup.Name) ? lineup.Lineup : lineup.Name, + Id = lineup.Uri?[18..] }); } } @@ -649,14 +657,14 @@ namespace Emby.Server.Implementations.LiveTv.Listings using var response = await Send(options, false, null, cancellationToken).ConfigureAwait(false); response.EnsureSuccessStatusCode(); await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); - var root = await JsonSerializer.DeserializeAsync(stream, _jsonOptions, cancellationToken).ConfigureAwait(false); - if (string.Equals(root.message, "OK", StringComparison.Ordinal)) + var root = await JsonSerializer.DeserializeAsync(stream, _jsonOptions, cancellationToken).ConfigureAwait(false); + if (string.Equals(root?.Message, "OK", StringComparison.Ordinal)) { - _logger.LogInformation("Authenticated with Schedules Direct token: " + root.token); - return root.token; + _logger.LogInformation("Authenticated with Schedules Direct token: {Token}", root.Token); + return root.Token; } - throw new Exception("Could not authenticate with Schedules Direct Error: " + root.message); + throw new Exception("Could not authenticate with Schedules Direct Error: " + root.Message); } private async Task AddLineupToAccount(ListingsProviderInfo info, CancellationToken cancellationToken) @@ -705,14 +713,14 @@ namespace Emby.Server.Implementations.LiveTv.Listings httpResponse.EnsureSuccessStatusCode(); await using var stream = await httpResponse.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); using var response = httpResponse.Content; - var root = await JsonSerializer.DeserializeAsync(stream, _jsonOptions, cancellationToken).ConfigureAwait(false); + var root = await JsonSerializer.DeserializeAsync(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; } @@ -777,35 +785,39 @@ 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(stream, _jsonOptions, cancellationToken).ConfigureAwait(false); - _logger.LogInformation("Found {ChannelCount} channels on the lineup on ScheduleDirect", root.map.Count); + var root = await JsonSerializer.DeserializeAsync(stream, _jsonOptions, cancellationToken).ConfigureAwait(false); + if (root == null) + { + return new List(); + } + + _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(); + var allStations = root.Stations; - var map = root.map; + var map = root.Map; var list = new List(map.Count); foreach (var channel in map) { var channelNumber = GetChannelNumber(channel); - var station = allStations.Find(item => string.Equals(item.stationID, channel.stationID, StringComparison.OrdinalIgnoreCase)) - ?? new ScheduleDirect.Station - { - 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 { - Id = station.stationID, - CallSign = station.callsign, + Id = station.StationId, + CallSign = station.Callsign, Number = channelNumber, - Name = string.IsNullOrWhiteSpace(station.name) ? channelNumber : station.name + Name = string.IsNullOrWhiteSpace(station.Name) ? channelNumber : station.Name }; - if (station.logo != null) + if (station.Logo != null) { - channelInfo.ImageUrl = station.logo.URL; + channelInfo.ImageUrl = station.Logo.Url; } list.Add(channelInfo); @@ -818,402 +830,5 @@ namespace Emby.Server.Implementations.LiveTv.Listings { return value.Replace(" ", string.Empty, StringComparison.Ordinal).Replace("-", string.Empty, StringComparison.Ordinal); } - - public class ScheduleDirect - { - public class Token - { - public int code { get; set; } - - public string message { get; set; } - - public string serverID { get; set; } - - public string token { get; set; } - } - - public class Lineup - { - public string lineup { get; set; } - - public string name { get; set; } - - public string transport { get; set; } - - public string location { get; set; } - - public string uri { get; set; } - } - - public class Lineups - { - public int code { get; set; } - - public string serverID { get; set; } - - public string datetime { get; set; } - - public List lineups { get; set; } - } - - public class Headends - { - public string headend { get; set; } - - public string transport { get; set; } - - public string location { get; set; } - - public List lineups { get; set; } - } - - public class Map - { - public string stationID { get; set; } - - public string channel { get; set; } - - public string logicalChannelNumber { get; set; } - - public int uhfVhf { get; set; } - - public int atscMajor { get; set; } - - public int atscMinor { get; set; } - } - - public class Broadcaster - { - public string city { get; set; } - - public string state { get; set; } - - public string postalcode { get; set; } - - public string country { get; set; } - } - - public class Logo - { - public string URL { get; set; } - - public int height { get; set; } - - public int width { get; set; } - - public string md5 { get; set; } - } - - public class Station - { - public string stationID { get; set; } - - public string name { get; set; } - - public string callsign { get; set; } - - public List broadcastLanguage { get; set; } - - public List descriptionLanguage { get; set; } - - public Broadcaster broadcaster { get; set; } - - public string affiliate { get; set; } - - public Logo logo { get; set; } - - public bool? isCommercialFree { get; set; } - } - - public class Metadata - { - public string lineup { get; set; } - - public string modified { get; set; } - - public string transport { get; set; } - } - - public class Channel - { - public List map { get; set; } - - public List stations { get; set; } - - public Metadata metadata { get; set; } - } - - public class RequestScheduleForChannel - { - public string stationID { get; set; } - - public List date { get; set; } - } - - public class Rating - { - public string body { get; set; } - - public string code { get; set; } - } - - public class Multipart - { - public int partNumber { get; set; } - - public int totalParts { get; set; } - } - - public class Program - { - public string programID { get; set; } - - public string airDateTime { get; set; } - - public int duration { get; set; } - - public string md5 { get; set; } - - public List audioProperties { get; set; } - - public List videoProperties { get; set; } - - public List ratings { get; set; } - - public bool? @new { get; set; } - - public Multipart multipart { get; set; } - - public string liveTapeDelay { get; set; } - - public bool premiere { get; set; } - - public bool repeat { get; set; } - - public string isPremiereOrFinale { get; set; } - } - - public class MetadataSchedule - { - public string modified { get; set; } - - public string md5 { get; set; } - - public string startDate { get; set; } - - public string endDate { get; set; } - - public int days { get; set; } - } - - public class Day - { - public string stationID { get; set; } - - public List programs { get; set; } - - public MetadataSchedule metadata { get; set; } - - public Day() - { - programs = new List(); - } - } - - public class Title - { - public string title120 { get; set; } - } - - public class EventDetails - { - public string subType { get; set; } - } - - public class Description100 - { - public string descriptionLanguage { get; set; } - - public string description { get; set; } - } - - public class Description1000 - { - public string descriptionLanguage { get; set; } - - public string description { get; set; } - } - - public class DescriptionsProgram - { - public List description100 { get; set; } - - public List description1000 { get; set; } - } - - public class Gracenote - { - public int season { get; set; } - - public int episode { get; set; } - } - - public class MetadataPrograms - { - public Gracenote Gracenote { get; set; } - } - - public class ContentRating - { - public string body { get; set; } - - public string code { get; set; } - } - - public class Cast - { - public string billingOrder { get; set; } - - public string role { get; set; } - - public string nameId { get; set; } - - public string personId { get; set; } - - public string name { get; set; } - - public string characterName { get; set; } - } - - public class Crew - { - public string billingOrder { get; set; } - - public string role { get; set; } - - public string nameId { get; set; } - - public string personId { get; set; } - - public string name { get; set; } - } - - public class QualityRating - { - public string ratingsBody { get; set; } - - public string rating { get; set; } - - public string minRating { get; set; } - - public string maxRating { get; set; } - - public string increment { get; set; } - } - - public class Movie - { - public string year { get; set; } - - public int duration { get; set; } - - public List qualityRating { get; set; } - } - - public class Recommendation - { - public string programID { get; set; } - - public string title120 { get; set; } - } - - public class ProgramDetails - { - public string audience { get; set; } - - public string programID { get; set; } - - public List titles { get; set; } - - public EventDetails eventDetails { get; set; } - - public DescriptionsProgram descriptions { get; set; } - - public string originalAirDate { get; set; } - - public List<string> genres { get; set; } - - public string episodeTitle150 { get; set; } - - public List<MetadataPrograms> metadata { get; set; } - - public List<ContentRating> contentRating { get; set; } - - public List<Cast> cast { get; set; } - - public List<Crew> crew { get; set; } - - public string entityType { get; set; } - - public string showType { get; set; } - - public bool hasImageArtwork { get; set; } - - public string primaryImage { get; set; } - - public string thumbImage { get; set; } - - public string backdropImage { get; set; } - - public string bannerImage { get; set; } - - public string imageID { get; set; } - - public string md5 { get; set; } - - public List<string> contentAdvisory { get; set; } - - public Movie movie { get; set; } - - public List<Recommendation> recommendations { get; set; } - } - - public class Caption - { - public string content { get; set; } - - public string lang { get; set; } - } - - public class ImageData - { - public string width { get; set; } - - public string height { get; set; } - - public string uri { get; set; } - - public string size { get; set; } - - public string aspect { get; set; } - - public string category { get; set; } - - public string text { get; set; } - - public string primary { get; set; } - - public string tier { get; set; } - - public Caption caption { get; set; } - } - - public class ShowImages - { - public string programID { get; set; } - - public List<ImageData> data { get; set; } - } - } } } diff --git a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/BroadcasterDto.cs b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/BroadcasterDto.cs new file mode 100644 index 0000000000..95ac996e01 --- /dev/null +++ b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/BroadcasterDto.cs @@ -0,0 +1,34 @@ +using System.Text.Json.Serialization; + +namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos +{ + /// <summary> + /// Broadcaster dto. + /// </summary> + public class BroadcasterDto + { + /// <summary> + /// Gets or sets the city. + /// </summary> + [JsonPropertyName("city")] + public string? City { get; set; } + + /// <summary> + /// Gets or sets the state. + /// </summary> + [JsonPropertyName("state")] + public string? State { get; set; } + + /// <summary> + /// Gets or sets the postal code. + /// </summary> + [JsonPropertyName("postalCode")] + public string? Postalcode { get; set; } + + /// <summary> + /// Gets or sets the country. + /// </summary> + [JsonPropertyName("country")] + 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 new file mode 100644 index 0000000000..f6251b9ad8 --- /dev/null +++ b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/CaptionDto.cs @@ -0,0 +1,22 @@ +using System.Text.Json.Serialization; + +namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos +{ + /// <summary> + /// Caption dto. + /// </summary> + public class CaptionDto + { + /// <summary> + /// Gets or sets the content. + /// </summary> + [JsonPropertyName("content")] + public string? Content { get; set; } + + /// <summary> + /// Gets or sets the lang. + /// </summary> + [JsonPropertyName("lang")] + 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 new file mode 100644 index 0000000000..0b7a2c63ad --- /dev/null +++ b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/CastDto.cs @@ -0,0 +1,46 @@ +using System.Text.Json.Serialization; + +namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos +{ + /// <summary> + /// Cast dto. + /// </summary> + public class CastDto + { + /// <summary> + /// Gets or sets the billing order. + /// </summary> + [JsonPropertyName("billingOrder")] + public string? BillingOrder { get; set; } + + /// <summary> + /// Gets or sets the role. + /// </summary> + [JsonPropertyName("role")] + public string? Role { get; set; } + + /// <summary> + /// Gets or sets the name id. + /// </summary> + [JsonPropertyName("nameId")] + public string? NameId { get; set; } + + /// <summary> + /// Gets or sets the person id. + /// </summary> + [JsonPropertyName("personId")] + public string? PersonId { get; set; } + + /// <summary> + /// Gets or sets the name. + /// </summary> + [JsonPropertyName("name")] + public string? Name { get; set; } + + /// <summary> + /// Gets or sets the character name. + /// </summary> + [JsonPropertyName("characterName")] + 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 new file mode 100644 index 0000000000..87c327ed82 --- /dev/null +++ b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/ChannelDto.cs @@ -0,0 +1,30 @@ +using System; +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos +{ + /// <summary> + /// Channel dto. + /// </summary> + public class ChannelDto + { + /// <summary> + /// Gets or sets the list of maps. + /// </summary> + [JsonPropertyName("map")] + public IReadOnlyList<MapDto> Map { get; set; } = Array.Empty<MapDto>(); + + /// <summary> + /// Gets or sets the list of stations. + /// </summary> + [JsonPropertyName("stations")] + public IReadOnlyList<StationDto> Stations { get; set; } = Array.Empty<StationDto>(); + + /// <summary> + /// Gets or sets the metadata. + /// </summary> + [JsonPropertyName("metadata")] + 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 new file mode 100644 index 0000000000..c19cd2e48d --- /dev/null +++ b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/ContentRatingDto.cs @@ -0,0 +1,22 @@ +using System.Text.Json.Serialization; + +namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos +{ + /// <summary> + /// Content rating dto. + /// </summary> + public class ContentRatingDto + { + /// <summary> + /// Gets or sets the body. + /// </summary> + [JsonPropertyName("body")] + public string? Body { get; set; } + + /// <summary> + /// Gets or sets the code. + /// </summary> + [JsonPropertyName("code")] + 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 new file mode 100644 index 0000000000..f00c9accdd --- /dev/null +++ b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/CrewDto.cs @@ -0,0 +1,40 @@ +using System.Text.Json.Serialization; + +namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos +{ + /// <summary> + /// Crew dto. + /// </summary> + public class CrewDto + { + /// <summary> + /// Gets or sets the billing order. + /// </summary> + [JsonPropertyName("billingOrder")] + public string? BillingOrder { get; set; } + + /// <summary> + /// Gets or sets the role. + /// </summary> + [JsonPropertyName("role")] + public string? Role { get; set; } + + /// <summary> + /// Gets or sets the name id. + /// </summary> + [JsonPropertyName("nameId")] + public string? NameId { get; set; } + + /// <summary> + /// Gets or sets the person id. + /// </summary> + [JsonPropertyName("personId")] + public string? PersonId { get; set; } + + /// <summary> + /// Gets or sets the name. + /// </summary> + [JsonPropertyName("name")] + 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 new file mode 100644 index 0000000000..1a371965cf --- /dev/null +++ b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/DayDto.cs @@ -0,0 +1,30 @@ +using System; +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos +{ + /// <summary> + /// Day dto. + /// </summary> + public class DayDto + { + /// <summary> + /// Gets or sets the station id. + /// </summary> + [JsonPropertyName("stationID")] + public string? StationId { get; set; } + + /// <summary> + /// Gets or sets the list of programs. + /// </summary> + [JsonPropertyName("programs")] + public IReadOnlyList<ProgramDto> Programs { get; set; } = Array.Empty<ProgramDto>(); + + /// <summary> + /// Gets or sets the metadata schedule. + /// </summary> + [JsonPropertyName("metadata")] + 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 new file mode 100644 index 0000000000..ca6ae7fb13 --- /dev/null +++ b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/Description1000Dto.cs @@ -0,0 +1,22 @@ +using System.Text.Json.Serialization; + +namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos +{ + /// <summary> + /// Description 1_000 dto. + /// </summary> + public class Description1000Dto + { + /// <summary> + /// Gets or sets the description language. + /// </summary> + [JsonPropertyName("descriptionLanguage")] + public string? DescriptionLanguage { get; set; } + + /// <summary> + /// Gets or sets the description. + /// </summary> + [JsonPropertyName("description")] + 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 new file mode 100644 index 0000000000..1577219ed2 --- /dev/null +++ b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/Description100Dto.cs @@ -0,0 +1,22 @@ +using System.Text.Json.Serialization; + +namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos +{ + /// <summary> + /// Description 100 dto. + /// </summary> + public class Description100Dto + { + /// <summary> + /// Gets or sets the description language. + /// </summary> + [JsonPropertyName("descriptionLanguage")] + public string? DescriptionLanguage { get; set; } + + /// <summary> + /// Gets or sets the description. + /// </summary> + [JsonPropertyName("description")] + 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 new file mode 100644 index 0000000000..eaf4a340bd --- /dev/null +++ b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/DescriptionsProgramDto.cs @@ -0,0 +1,24 @@ +using System; +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos +{ + /// <summary> + /// Descriptions program dto. + /// </summary> + public class DescriptionsProgramDto + { + /// <summary> + /// Gets or sets the list of description 100. + /// </summary> + [JsonPropertyName("description100")] + public IReadOnlyList<Description100Dto> Description100 { get; set; } = Array.Empty<Description100Dto>(); + + /// <summary> + /// Gets or sets the list of description1000. + /// </summary> + [JsonPropertyName("description1000")] + 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 new file mode 100644 index 0000000000..fbdfb1f716 --- /dev/null +++ b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/EventDetailsDto.cs @@ -0,0 +1,16 @@ +using System.Text.Json.Serialization; + +namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos +{ + /// <summary> + /// Event details dto. + /// </summary> + public class EventDetailsDto + { + /// <summary> + /// Gets or sets the sub type. + /// </summary> + [JsonPropertyName("subType")] + 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 new file mode 100644 index 0000000000..6852d89d71 --- /dev/null +++ b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/GracenoteDto.cs @@ -0,0 +1,22 @@ +using System.Text.Json.Serialization; + +namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos +{ + /// <summary> + /// Gracenote dto. + /// </summary> + public class GracenoteDto + { + /// <summary> + /// Gets or sets the season. + /// </summary> + [JsonPropertyName("season")] + public int Season { get; set; } + + /// <summary> + /// Gets or sets the episode. + /// </summary> + [JsonPropertyName("episode")] + public int Episode { get; set; } + } +} diff --git a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/HeadendsDto.cs b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/HeadendsDto.cs new file mode 100644 index 0000000000..b9844562f3 --- /dev/null +++ b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/HeadendsDto.cs @@ -0,0 +1,36 @@ +using System; +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos +{ + /// <summary> + /// Headends dto. + /// </summary> + public class HeadendsDto + { + /// <summary> + /// Gets or sets the headend. + /// </summary> + [JsonPropertyName("headend")] + public string? Headend { get; set; } + + /// <summary> + /// Gets or sets the transport. + /// </summary> + [JsonPropertyName("transport")] + public string? Transport { get; set; } + + /// <summary> + /// Gets or sets the location. + /// </summary> + [JsonPropertyName("location")] + public string? Location { get; set; } + + /// <summary> + /// Gets or sets the list of lineups. + /// </summary> + [JsonPropertyName("lineups")] + 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 new file mode 100644 index 0000000000..a1ae3ca6d4 --- /dev/null +++ b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/ImageDataDto.cs @@ -0,0 +1,70 @@ +using System.Text.Json.Serialization; + +namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos +{ + /// <summary> + /// Image data dto. + /// </summary> + public class ImageDataDto + { + /// <summary> + /// Gets or sets the width. + /// </summary> + [JsonPropertyName("width")] + public string? Width { get; set; } + + /// <summary> + /// Gets or sets the height. + /// </summary> + [JsonPropertyName("height")] + public string? Height { get; set; } + + /// <summary> + /// Gets or sets the uri. + /// </summary> + [JsonPropertyName("uri")] + public string? Uri { get; set; } + + /// <summary> + /// Gets or sets the size. + /// </summary> + [JsonPropertyName("size")] + public string? Size { get; set; } + + /// <summary> + /// Gets or sets the aspect. + /// </summary> + [JsonPropertyName("aspect")] + public string? Aspect { get; set; } + + /// <summary> + /// Gets or sets the category. + /// </summary> + [JsonPropertyName("category")] + public string? Category { get; set; } + + /// <summary> + /// Gets or sets the text. + /// </summary> + [JsonPropertyName("text")] + public string? Text { get; set; } + + /// <summary> + /// Gets or sets the primary. + /// </summary> + [JsonPropertyName("primary")] + public string? Primary { get; set; } + + /// <summary> + /// Gets or sets the tier. + /// </summary> + [JsonPropertyName("tier")] + public string? Tier { get; set; } + + /// <summary> + /// Gets or sets the caption. + /// </summary> + [JsonPropertyName("caption")] + 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 new file mode 100644 index 0000000000..3dc64e5d8a --- /dev/null +++ b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/LineupDto.cs @@ -0,0 +1,46 @@ +using System.Text.Json.Serialization; + +namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos +{ + /// <summary> + /// The lineup dto. + /// </summary> + public class LineupDto + { + /// <summary> + /// Gets or sets the linup. + /// </summary> + [JsonPropertyName("lineup")] + public string? Lineup { get; set; } + + /// <summary> + /// Gets or sets the lineup name. + /// </summary> + [JsonPropertyName("name")] + public string? Name { get; set; } + + /// <summary> + /// Gets or sets the transport. + /// </summary> + [JsonPropertyName("transport")] + public string? Transport { get; set; } + + /// <summary> + /// Gets or sets the location. + /// </summary> + [JsonPropertyName("location")] + public string? Location { get; set; } + + /// <summary> + /// Gets or sets the uri. + /// </summary> + [JsonPropertyName("uri")] + 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 new file mode 100644 index 0000000000..f190817813 --- /dev/null +++ b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/LineupsDto.cs @@ -0,0 +1,36 @@ +using System; +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos +{ + /// <summary> + /// Lineups dto. + /// </summary> + public class LineupsDto + { + /// <summary> + /// Gets or sets the response code. + /// </summary> + [JsonPropertyName("code")] + public int Code { get; set; } + + /// <summary> + /// Gets or sets the server id. + /// </summary> + [JsonPropertyName("serverID")] + public string? ServerId { get; set; } + + /// <summary> + /// Gets or sets the datetime. + /// </summary> + [JsonPropertyName("datetime")] + public DateTime? LineupTimestamp { get; set; } + + /// <summary> + /// Gets or sets the list of lineups. + /// </summary> + [JsonPropertyName("lineups")] + 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 new file mode 100644 index 0000000000..fecc55e037 --- /dev/null +++ b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/LogoDto.cs @@ -0,0 +1,34 @@ +using System.Text.Json.Serialization; + +namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos +{ + /// <summary> + /// Logo dto. + /// </summary> + public class LogoDto + { + /// <summary> + /// Gets or sets the url. + /// </summary> + [JsonPropertyName("URL")] + public string? Url { get; set; } + + /// <summary> + /// Gets or sets the height. + /// </summary> + [JsonPropertyName("height")] + public int Height { get; set; } + + /// <summary> + /// Gets or sets the width. + /// </summary> + [JsonPropertyName("width")] + public int Width { get; set; } + + /// <summary> + /// Gets or sets the md5. + /// </summary> + [JsonPropertyName("md5")] + 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 new file mode 100644 index 0000000000..ffd02d474b --- /dev/null +++ b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/MapDto.cs @@ -0,0 +1,58 @@ +using System.Text.Json.Serialization; + +namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos +{ + /// <summary> + /// Map dto. + /// </summary> + public class MapDto + { + /// <summary> + /// Gets or sets the station id. + /// </summary> + [JsonPropertyName("stationID")] + public string? StationId { get; set; } + + /// <summary> + /// Gets or sets the channel. + /// </summary> + [JsonPropertyName("channel")] + 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; } + + /// <summary> + /// Gets or sets the uhfvhf. + /// </summary> + [JsonPropertyName("uhfVhf")] + public int UhfVhf { get; set; } + + /// <summary> + /// Gets or sets the atsc major. + /// </summary> + [JsonPropertyName("atscMajor")] + public int AtscMajor { get; set; } + + /// <summary> + /// Gets or sets the atsc minor. + /// </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 new file mode 100644 index 0000000000..40faa493c5 --- /dev/null +++ b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/MetadataDto.cs @@ -0,0 +1,28 @@ +using System.Text.Json.Serialization; + +namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos +{ + /// <summary> + /// Metadata dto. + /// </summary> + public class MetadataDto + { + /// <summary> + /// Gets or sets the linup. + /// </summary> + [JsonPropertyName("lineup")] + public string? Lineup { get; set; } + + /// <summary> + /// Gets or sets the modified timestamp. + /// </summary> + [JsonPropertyName("modified")] + public string? Modified { get; set; } + + /// <summary> + /// Gets or sets the transport. + /// </summary> + [JsonPropertyName("transport")] + 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 new file mode 100644 index 0000000000..43f2901566 --- /dev/null +++ b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/MetadataProgramsDto.cs @@ -0,0 +1,16 @@ +using System.Text.Json.Serialization; + +namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos +{ + /// <summary> + /// Metadata programs dto. + /// </summary> + public class MetadataProgramsDto + { + /// <summary> + /// Gets or sets the gracenote object. + /// </summary> + [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 new file mode 100644 index 0000000000..04560ab55d --- /dev/null +++ b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/MetadataScheduleDto.cs @@ -0,0 +1,41 @@ +using System; +using System.Text.Json.Serialization; + +namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos +{ + /// <summary> + /// Metadata schedule dto. + /// </summary> + public class MetadataScheduleDto + { + /// <summary> + /// Gets or sets the modified timestamp. + /// </summary> + [JsonPropertyName("modified")] + public string? Modified { get; set; } + + /// <summary> + /// Gets or sets the md5. + /// </summary> + [JsonPropertyName("md5")] + public string? Md5 { get; set; } + + /// <summary> + /// Gets or sets the start date. + /// </summary> + [JsonPropertyName("startDate")] + public DateTime? StartDate { get; set; } + + /// <summary> + /// Gets or sets the end date. + /// </summary> + [JsonPropertyName("endDate")] + public DateTime? EndDate { get; set; } + + /// <summary> + /// Gets or sets the days count. + /// </summary> + [JsonPropertyName("days")] + public int Days { get; set; } + } +} diff --git a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/MovieDto.cs b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/MovieDto.cs new file mode 100644 index 0000000000..31bef423b2 --- /dev/null +++ b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/MovieDto.cs @@ -0,0 +1,30 @@ +using System; +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos +{ + /// <summary> + /// Movie dto. + /// </summary> + public class MovieDto + { + /// <summary> + /// Gets or sets the year. + /// </summary> + [JsonPropertyName("year")] + public string? Year { get; set; } + + /// <summary> + /// Gets or sets the duration. + /// </summary> + [JsonPropertyName("duration")] + public int Duration { get; set; } + + /// <summary> + /// Gets or sets the list of quality rating. + /// </summary> + [JsonPropertyName("qualityRating")] + 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 new file mode 100644 index 0000000000..e8b15dc07c --- /dev/null +++ b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/MultipartDto.cs @@ -0,0 +1,22 @@ +using System.Text.Json.Serialization; + +namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos +{ + /// <summary> + /// Multipart dto. + /// </summary> + public class MultipartDto + { + /// <summary> + /// Gets or sets the part number. + /// </summary> + [JsonPropertyName("partNumber")] + public int PartNumber { get; set; } + + /// <summary> + /// Gets or sets the total parts. + /// </summary> + [JsonPropertyName("totalParts")] + public int TotalParts { get; set; } + } +} diff --git a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/ProgramDetailsDto.cs b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/ProgramDetailsDto.cs new file mode 100644 index 0000000000..84c48f67f3 --- /dev/null +++ b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/ProgramDetailsDto.cs @@ -0,0 +1,156 @@ +using System; +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos +{ + /// <summary> + /// Program details dto. + /// </summary> + public class ProgramDetailsDto + { + /// <summary> + /// Gets or sets the audience. + /// </summary> + [JsonPropertyName("audience")] + public string? Audience { get; set; } + + /// <summary> + /// Gets or sets the program id. + /// </summary> + [JsonPropertyName("programID")] + public string? ProgramId { get; set; } + + /// <summary> + /// Gets or sets the list of titles. + /// </summary> + [JsonPropertyName("titles")] + 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; } + + /// <summary> + /// Gets or sets the descriptions. + /// </summary> + [JsonPropertyName("descriptions")] + public DescriptionsProgramDto? Descriptions { get; set; } + + /// <summary> + /// Gets or sets the original air date. + /// </summary> + [JsonPropertyName("originalAirDate")] + public DateTime? OriginalAirDate { get; set; } + + /// <summary> + /// Gets or sets the list of genres. + /// </summary> + [JsonPropertyName("genres")] + public IReadOnlyList<string> Genres { get; set; } = Array.Empty<string>(); + + /// <summary> + /// Gets or sets the episode title. + /// </summary> + [JsonPropertyName("episodeTitle150")] + public string? EpisodeTitle150 { get; set; } + + /// <summary> + /// Gets or sets the list of metadata. + /// </summary> + [JsonPropertyName("metadata")] + public IReadOnlyList<MetadataProgramsDto> Metadata { get; set; } = Array.Empty<MetadataProgramsDto>(); + + /// <summary> + /// Gets or sets the list of content raitings. + /// </summary> + [JsonPropertyName("contentRating")] + public IReadOnlyList<ContentRatingDto> ContentRating { get; set; } = Array.Empty<ContentRatingDto>(); + + /// <summary> + /// Gets or sets the list of cast. + /// </summary> + [JsonPropertyName("cast")] + public IReadOnlyList<CastDto> Cast { get; set; } = Array.Empty<CastDto>(); + + /// <summary> + /// Gets or sets the list of crew. + /// </summary> + [JsonPropertyName("crew")] + public IReadOnlyList<CrewDto> Crew { get; set; } = Array.Empty<CrewDto>(); + + /// <summary> + /// Gets or sets the entity type. + /// </summary> + [JsonPropertyName("entityType")] + public string? EntityType { get; set; } + + /// <summary> + /// Gets or sets the show type. + /// </summary> + [JsonPropertyName("showType")] + public string? ShowType { get; set; } + + /// <summary> + /// Gets or sets a value indicating whether there is image artwork. + /// </summary> + [JsonPropertyName("hasImageArtwork")] + public bool HasImageArtwork { get; set; } + + /// <summary> + /// Gets or sets the primary image. + /// </summary> + [JsonPropertyName("primaryImage")] + public string? PrimaryImage { get; set; } + + /// <summary> + /// Gets or sets the thumb image. + /// </summary> + [JsonPropertyName("thumbImage")] + public string? ThumbImage { get; set; } + + /// <summary> + /// Gets or sets the backdrop image. + /// </summary> + [JsonPropertyName("backdropImage")] + public string? BackdropImage { get; set; } + + /// <summary> + /// Gets or sets the banner image. + /// </summary> + [JsonPropertyName("bannerImage")] + public string? BannerImage { get; set; } + + /// <summary> + /// Gets or sets the image id. + /// </summary> + [JsonPropertyName("imageID")] + public string? ImageId { get; set; } + + /// <summary> + /// Gets or sets the md5. + /// </summary> + [JsonPropertyName("md5")] + public string? Md5 { get; set; } + + /// <summary> + /// Gets or sets the list of content advisory. + /// </summary> + [JsonPropertyName("contentAdvisory")] + public IReadOnlyList<string> ContentAdvisory { get; set; } = Array.Empty<string>(); + + /// <summary> + /// Gets or sets the movie object. + /// </summary> + [JsonPropertyName("movie")] + public MovieDto? Movie { get; set; } + + /// <summary> + /// Gets or sets the list of recommendations. + /// </summary> + [JsonPropertyName("recommendations")] + 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 new file mode 100644 index 0000000000..60389b45bf --- /dev/null +++ b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/ProgramDto.cs @@ -0,0 +1,90 @@ +using System; +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos +{ + /// <summary> + /// Program dto. + /// </summary> + public class ProgramDto + { + /// <summary> + /// Gets or sets the program id. + /// </summary> + [JsonPropertyName("programID")] + public string? ProgramId { get; set; } + + /// <summary> + /// Gets or sets the air date time. + /// </summary> + [JsonPropertyName("airDateTime")] + public DateTime? AirDateTime { get; set; } + + /// <summary> + /// Gets or sets the duration. + /// </summary> + [JsonPropertyName("duration")] + public int Duration { get; set; } + + /// <summary> + /// Gets or sets the md5. + /// </summary> + [JsonPropertyName("md5")] + public string? Md5 { get; set; } + + /// <summary> + /// Gets or sets the list of audio properties. + /// </summary> + [JsonPropertyName("audioProperties")] + public IReadOnlyList<string> AudioProperties { get; set; } = Array.Empty<string>(); + + /// <summary> + /// Gets or sets the list of video properties. + /// </summary> + [JsonPropertyName("videoProperties")] + public IReadOnlyList<string> VideoProperties { get; set; } = Array.Empty<string>(); + + /// <summary> + /// Gets or sets the list of ratings. + /// </summary> + [JsonPropertyName("ratings")] + public IReadOnlyList<RatingDto> Ratings { get; set; } = Array.Empty<RatingDto>(); + + /// <summary> + /// Gets or sets a value indicating whether this program is new. + /// </summary> + [JsonPropertyName("new")] + public bool? New { get; set; } + + /// <summary> + /// Gets or sets the multipart object. + /// </summary> + [JsonPropertyName("multipart")] + public MultipartDto? Multipart { get; set; } + + /// <summary> + /// Gets or sets the live tape delay. + /// </summary> + [JsonPropertyName("liveTapeDelay")] + public string? LiveTapeDelay { get; set; } + + /// <summary> + /// Gets or sets a value indicating whether this is the premiere. + /// </summary> + [JsonPropertyName("premiere")] + public bool Premiere { get; set; } + + /// <summary> + /// Gets or sets a value indicating whether this is a repeat. + /// </summary> + [JsonPropertyName("repeat")] + public bool Repeat { get; set; } + + /// <summary> + /// Gets or sets the premiere or finale. + /// </summary> + [JsonPropertyName("isPremiereOrFinale")] + 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 new file mode 100644 index 0000000000..c5ddcf7c51 --- /dev/null +++ b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/QualityRatingDto.cs @@ -0,0 +1,40 @@ +using System.Text.Json.Serialization; + +namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos +{ + /// <summary> + /// Quality rating dto. + /// </summary> + public class QualityRatingDto + { + /// <summary> + /// Gets or sets the ratings body. + /// </summary> + [JsonPropertyName("ratingsBody")] + public string? RatingsBody { get; set; } + + /// <summary> + /// Gets or sets the rating. + /// </summary> + [JsonPropertyName("rating")] + public string? Rating { get; set; } + + /// <summary> + /// Gets or sets the min rating. + /// </summary> + [JsonPropertyName("minRating")] + public string? MinRating { get; set; } + + /// <summary> + /// Gets or sets the max rating. + /// </summary> + [JsonPropertyName("maxRating")] + public string? MaxRating { get; set; } + + /// <summary> + /// Gets or sets the increment. + /// </summary> + [JsonPropertyName("increment")] + 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 new file mode 100644 index 0000000000..e04b619a4d --- /dev/null +++ b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/RatingDto.cs @@ -0,0 +1,22 @@ +using System.Text.Json.Serialization; + +namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos +{ + /// <summary> + /// Rating dto. + /// </summary> + public class RatingDto + { + /// <summary> + /// Gets or sets the body. + /// </summary> + [JsonPropertyName("body")] + public string? Body { get; set; } + + /// <summary> + /// Gets or sets the code. + /// </summary> + [JsonPropertyName("code")] + 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 new file mode 100644 index 0000000000..c8f79fd1c2 --- /dev/null +++ b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/RecommendationDto.cs @@ -0,0 +1,22 @@ +using System.Text.Json.Serialization; + +namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos +{ + /// <summary> + /// Recommendation dto. + /// </summary> + public class RecommendationDto + { + /// <summary> + /// Gets or sets the program id. + /// </summary> + [JsonPropertyName("programID")] + public string? ProgramId { get; set; } + + /// <summary> + /// Gets or sets the title. + /// </summary> + [JsonPropertyName("title120")] + 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 new file mode 100644 index 0000000000..0cd05709b3 --- /dev/null +++ b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/RequestScheduleForChannelDto.cs @@ -0,0 +1,24 @@ +using System; +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos +{ + /// <summary> + /// Request schedule for channel dto. + /// </summary> + public class RequestScheduleForChannelDto + { + /// <summary> + /// Gets or sets the station id. + /// </summary> + [JsonPropertyName("stationID")] + public string? StationId { get; set; } + + /// <summary> + /// Gets or sets the list of dates. + /// </summary> + [JsonPropertyName("date")] + 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 new file mode 100644 index 0000000000..84e224b71e --- /dev/null +++ b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/ShowImagesDto.cs @@ -0,0 +1,24 @@ +using System; +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos +{ + /// <summary> + /// Show image dto. + /// </summary> + public class ShowImagesDto + { + /// <summary> + /// Gets or sets the program id. + /// </summary> + [JsonPropertyName("programID")] + public string? ProgramId { get; set; } + + /// <summary> + /// Gets or sets the list of data. + /// </summary> + [JsonPropertyName("data")] + 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 new file mode 100644 index 0000000000..d797fd49b1 --- /dev/null +++ b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/StationDto.cs @@ -0,0 +1,66 @@ +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; } + + /// <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 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 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 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 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 new file mode 100644 index 0000000000..61cd4a9b00 --- /dev/null +++ b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/TitleDto.cs @@ -0,0 +1,16 @@ +using System.Text.Json.Serialization; + +namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos +{ + /// <summary> + /// Title dto. + /// </summary> + public class TitleDto + { + /// <summary> + /// Gets or sets the title. + /// </summary> + [JsonPropertyName("title120")] + 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 new file mode 100644 index 0000000000..afb9994869 --- /dev/null +++ b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/TokenDto.cs @@ -0,0 +1,47 @@ +using System; +using System.Text.Json.Serialization; + +namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos +{ + /// <summary> + /// The token dto. + /// </summary> + public class TokenDto + { + /// <summary> + /// Gets or sets the response code. + /// </summary> + [JsonPropertyName("code")] + public int Code { get; set; } + + /// <summary> + /// Gets or sets the response message. + /// </summary> + [JsonPropertyName("message")] + public string? Message { get; set; } + + /// <summary> + /// Gets or sets the server id. + /// </summary> + [JsonPropertyName("serverID")] + public string? ServerId { get; set; } + + /// <summary> + /// Gets or sets the token. + /// </summary> + [JsonPropertyName("token")] + 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 ebad4eddf1..0c0ec48d96 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)) + 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)); @@ -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/LiveTvManager.cs b/Emby.Server.Implementations/LiveTv/LiveTvManager.cs index d964769b59..ea1a28fe86 100644 --- a/Emby.Server.Implementations/LiveTv/LiveTvManager.cs +++ b/Emby.Server.Implementations/LiveTv/LiveTvManager.cs @@ -65,6 +65,8 @@ namespace Emby.Server.Implementations.LiveTv private ITunerHost[] _tunerHosts = Array.Empty<ITunerHost>(); private IListingsProvider[] _listingProviders = Array.Empty<IListingsProvider>(); + private bool _disposed = false; + public LiveTvManager( IServerConfigurationManager config, ILogger<LiveTvManager> logger, @@ -403,7 +405,7 @@ namespace Emby.Server.Implementations.LiveTv // Set the total bitrate if not already supplied mediaSource.InferTotalBitrate(); - if (!(service is EmbyTV.EmbyTV)) + if (service is not EmbyTV.EmbyTV) { // We can't trust that we'll be able to direct stream it through emby server, no matter what the provider says // mediaSource.SupportsDirectPlay = false; @@ -520,7 +522,7 @@ namespace Emby.Server.Implementations.LiveTv return item; } - private Tuple<LiveTvProgram, bool, bool> GetProgram(ProgramInfo info, Dictionary<Guid, LiveTvProgram> allExistingPrograms, LiveTvChannel channel, ChannelType channelType, string serviceName, CancellationToken cancellationToken) + private (LiveTvProgram item, bool isNew, bool isUpdated) GetProgram(ProgramInfo info, Dictionary<Guid, LiveTvProgram> allExistingPrograms, LiveTvChannel channel) { var id = _tvDtoService.GetInternalProgramId(info.Id); @@ -559,8 +561,6 @@ namespace Emby.Server.Implementations.LiveTv item.ParentId = channel.Id; - // item.ChannelType = channelType; - item.Audio = info.Audio; item.ChannelId = channel.Id; item.CommunityRating ??= info.CommunityRating; @@ -772,7 +772,7 @@ namespace Emby.Server.Implementations.LiveTv item.OnMetadataChanged(); } - return new Tuple<LiveTvProgram, bool, bool>(item, isNew, isUpdated); + return (item, isNew, isUpdated); } public async Task<BaseItemDto> GetProgram(string id, CancellationToken cancellationToken, User user = null) @@ -1187,14 +1187,14 @@ namespace Emby.Server.Implementations.LiveTv foreach (var program in channelPrograms) { - var programTuple = GetProgram(program, existingPrograms, currentChannel, currentChannel.ChannelType, service.Name, cancellationToken); - var programItem = programTuple.Item1; + var programTuple = GetProgram(program, existingPrograms, currentChannel); + var programItem = programTuple.item; - if (programTuple.Item2) + if (programTuple.isNew) { newPrograms.Add(programItem); } - else if (programTuple.Item3) + else if (programTuple.isUpdated) { updatedPrograms.Add(programItem); } @@ -1385,10 +1385,10 @@ namespace Emby.Server.Implementations.LiveTv // var items = allActivePaths.Select(i => _libraryManager.FindByPath(i, false)).Where(i => i != null).ToArray(); // return new QueryResult<BaseItem> - //{ + // { // Items = items, // TotalRecordCount = items.Length - //}; + // }; dtoOptions.Fields = dtoOptions.Fields.Concat(new[] { ItemFields.Tags }).Distinct().ToArray(); } @@ -1425,16 +1425,15 @@ namespace Emby.Server.Implementations.LiveTv return result; } - public Task AddInfoToProgramDto(IReadOnlyCollection<(BaseItem, BaseItemDto)> tuples, IReadOnlyList<ItemFields> fields, User user = null) + public Task AddInfoToProgramDto(IReadOnlyCollection<(BaseItem, BaseItemDto)> programs, IReadOnlyList<ItemFields> fields, User user = null) { var programTuples = new List<Tuple<BaseItemDto, string, string>>(); var hasChannelImage = fields.Contains(ItemFields.ChannelImage); var hasChannelInfo = fields.Contains(ItemFields.ChannelInfo); - foreach (var tuple in tuples) + foreach (var (item, dto) in programs) { - var program = (LiveTvProgram)tuple.Item1; - var dto = tuple.Item2; + var program = (LiveTvProgram)item; dto.StartDate = program.StartDate; dto.EpisodeTitle = program.EpisodeTitle; @@ -1724,7 +1723,7 @@ namespace Emby.Server.Implementations.LiveTv await service.CancelTimerAsync(timer.ExternalId, CancellationToken.None).ConfigureAwait(false); - if (!(service is EmbyTV.EmbyTV)) + if (service is not EmbyTV.EmbyTV) { TimerCancelled?.Invoke(this, new GenericEventArgs<TimerEventInfo>(new TimerEventInfo(id))); } @@ -1871,11 +1870,11 @@ namespace Emby.Server.Implementations.LiveTv return _libraryManager.GetItemById(internalChannelId); } - public void AddChannelInfo(IReadOnlyCollection<(BaseItemDto, LiveTvChannel)> tuples, DtoOptions options, User user) + public void AddChannelInfo(IReadOnlyCollection<(BaseItemDto, LiveTvChannel)> items, DtoOptions options, User user) { var now = DateTime.UtcNow; - var channelIds = tuples.Select(i => i.Item2.Id).Distinct().ToArray(); + var channelIds = items.Select(i => i.Item2.Id).Distinct().ToArray(); var programs = options.AddCurrentProgram ? _libraryManager.GetItemList(new InternalItemsQuery(user) { @@ -1896,7 +1895,7 @@ namespace Emby.Server.Implementations.LiveTv var addCurrentProgram = options.AddCurrentProgram; - foreach (var tuple in tuples) + foreach (var tuple in items) { var dto = tuple.Item1; var channel = tuple.Item2; @@ -2050,7 +2049,7 @@ namespace Emby.Server.Implementations.LiveTv _logger.LogInformation("New recording scheduled"); - if (!(service is EmbyTV.EmbyTV)) + if (service is not EmbyTV.EmbyTV) { TimerCreated?.Invoke(this, new GenericEventArgs<TimerEventInfo>( new TimerEventInfo(newTimerId) @@ -2118,17 +2117,13 @@ namespace Emby.Server.Implementations.LiveTv }; } - /// <summary> - /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources. - /// </summary> + /// <inheritdoc /> public void Dispose() { Dispose(true); GC.SuppressFinalize(this); } - private bool _disposed = false; - /// <summary> /// Releases unmanaged and - optionally - managed resources. /// </summary> @@ -2324,20 +2319,20 @@ namespace Emby.Server.Implementations.LiveTv _taskManager.CancelIfRunningAndQueue<RefreshGuideScheduledTask>(); } - public async Task<TunerChannelMapping> SetChannelMapping(string providerId, string tunerChannelId, string providerChannelId) + public async Task<TunerChannelMapping> SetChannelMapping(string providerId, string tunerChannelNumber, string providerChannelNumber) { var config = GetConfiguration(); var listingsProviderInfo = config.ListingProviders.First(i => string.Equals(providerId, i.Id, StringComparison.OrdinalIgnoreCase)); - listingsProviderInfo.ChannelMappings = listingsProviderInfo.ChannelMappings.Where(i => !string.Equals(i.Name, tunerChannelId, StringComparison.OrdinalIgnoreCase)).ToArray(); + listingsProviderInfo.ChannelMappings = listingsProviderInfo.ChannelMappings.Where(i => !string.Equals(i.Name, tunerChannelNumber, StringComparison.OrdinalIgnoreCase)).ToArray(); - if (!string.Equals(tunerChannelId, providerChannelId, StringComparison.OrdinalIgnoreCase)) + if (!string.Equals(tunerChannelNumber, providerChannelNumber, StringComparison.OrdinalIgnoreCase)) { var list = listingsProviderInfo.ChannelMappings.ToList(); list.Add(new NameValuePair { - Name = tunerChannelId, - Value = providerChannelId + Name = tunerChannelNumber, + Value = providerChannelNumber }); listingsProviderInfo.ChannelMappings = list.ToArray(); } @@ -2357,10 +2352,10 @@ namespace Emby.Server.Implementations.LiveTv _taskManager.CancelIfRunningAndQueue<RefreshGuideScheduledTask>(); - return tunerChannelMappings.First(i => string.Equals(i.Id, tunerChannelId, StringComparison.OrdinalIgnoreCase)); + return tunerChannelMappings.First(i => string.Equals(i.Id, tunerChannelNumber, StringComparison.OrdinalIgnoreCase)); } - public TunerChannelMapping GetTunerChannelMapping(ChannelInfo tunerChannel, NameValuePair[] mappings, List<ChannelInfo> epgChannels) + public TunerChannelMapping GetTunerChannelMapping(ChannelInfo tunerChannel, NameValuePair[] mappings, List<ChannelInfo> providerChannels) { var result = new TunerChannelMapping { @@ -2373,7 +2368,7 @@ namespace Emby.Server.Implementations.LiveTv result.Name = tunerChannel.Number + " " + result.Name; } - var providerChannel = EmbyTV.EmbyTV.Current.GetEpgChannelFromTunerChannel(mappings, tunerChannel, epgChannels); + var providerChannel = EmbyTV.EmbyTV.Current.GetEpgChannelFromTunerChannel(mappings, tunerChannel, providerChannels); if (providerChannel != null) { diff --git a/Emby.Server.Implementations/LiveTv/TunerHosts/BaseTunerHost.cs b/Emby.Server.Implementations/LiveTv/TunerHosts/BaseTunerHost.cs index 5941613cf9..2b82f24623 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; } - public virtual bool IsSupported => true; + protected IServerConfigurationManager Config { get; } - protected abstract Task<List<ChannelInfo>> GetChannelsInternal(TunerHostInfo tuner, CancellationToken cancellationToken); + protected ILogger<BaseTunerHost> Logger { get; } + + protected IFileSystem FileSystem { get; } + + public virtual bool IsSupported => true; 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; @@ -92,7 +96,7 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts try { Directory.CreateDirectory(Path.GetDirectoryName(channelCacheFile)); - await using var writeStream = File.OpenWrite(channelCacheFile); + await using var writeStream = AsyncFile.OpenWrite(channelCacheFile); await JsonSerializer.SerializeAsync(writeStream, channels, cancellationToken: cancellationToken).ConfigureAwait(false); } catch (IOException) @@ -108,7 +112,7 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts { try { - await using var readStream = File.OpenRead(channelCacheFile); + await using var readStream = AsyncFile.OpenRead(channelCacheFile); var channels = await JsonSerializer.DeserializeAsync<List<ChannelInfo>>(readStream, cancellationToken: cancellationToken) .ConfigureAwait(false); list.AddRange(channels); @@ -158,7 +162,7 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts return new List<MediaSourceInfo>(); } - protected abstract Task<ILiveStream> GetChannelStream(TunerHostInfo tuner, ChannelInfo channel, string streamId, List<ILiveStream> currentLiveStreams, CancellationToken cancellationToken); + protected abstract Task<ILiveStream> GetChannelStream(TunerHostInfo tunerHost, ChannelInfo channel, string streamId, List<ILiveStream> currentLiveStreams, CancellationToken cancellationToken); public async Task<ILiveStream> GetChannelStream(string channelId, string streamId, List<ILiveStream> currentLiveStreams, CancellationToken cancellationToken) { @@ -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 0000000000..069b4fab6b --- /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, 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); + } + } + } + } +} diff --git a/Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/HdHomerunHost.cs b/Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/HdHomerunHost.cs index 011748d1d6..78ea7bd0f5 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,22 +87,17 @@ 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 info, CancellationToken cancellationToken) + protected override async Task<List<ChannelInfo>> GetChannelsInternal(TunerHostInfo tuner, CancellationToken cancellationToken) { - var lineup = await GetLineup(info, cancellationToken).ConfigureAwait(false); + var lineup = await GetLineup(tuner, cancellationToken).ConfigureAwait(false); return lineup.Select(i => new HdHomerunChannelInfo { Name = i.GuideName, Number = i.GuideNumber, - Id = GetChannelId(info, i), + Id = GetChannelId(i), IsFavorite = i.Favorite, - TunerHostId = info.Id, + TunerHostId = tuner.Id, IsHD = i.HD, AudioCodec = i.AudioCodec, VideoCodec = i.VideoCodec, @@ -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, @@ -496,57 +488,53 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun return mediaSource; } - protected override async Task<List<MediaSourceInfo>> GetChannelStreamMediaSources(TunerHostInfo info, ChannelInfo channelInfo, CancellationToken cancellationToken) + protected override async Task<List<MediaSourceInfo>> GetChannelStreamMediaSources(TunerHostInfo tuner, ChannelInfo channel, CancellationToken cancellationToken) { var list = new List<MediaSourceInfo>(); - var channelId = channelInfo.Id; + var channelId = channel.Id; var hdhrId = GetHdHrIdFromChannelId(channelId); - var hdHomerunChannelInfo = channelInfo as HdHomerunChannelInfo; - - var isLegacyTuner = hdHomerunChannelInfo != null && hdHomerunChannelInfo.IsLegacyTuner; - - if (isLegacyTuner) + if (channel is HdHomerunChannelInfo hdHomerunChannelInfo && hdHomerunChannelInfo.IsLegacyTuner) { - list.Add(GetMediaSource(info, hdhrId, channelInfo, "native")); + list.Add(GetMediaSource(tuner, hdhrId, channel, "native")); } else { - var modelInfo = await GetModelInfo(info, false, cancellationToken).ConfigureAwait(false); + var modelInfo = await GetModelInfo(tuner, false, cancellationToken).ConfigureAwait(false); if (modelInfo != null && modelInfo.SupportsTranscoding) { - if (info.AllowHWTranscoding) + if (tuner.AllowHWTranscoding) { - list.Add(GetMediaSource(info, hdhrId, channelInfo, "heavy")); + list.Add(GetMediaSource(tuner, hdhrId, channel, "heavy")); - list.Add(GetMediaSource(info, hdhrId, channelInfo, "internet540")); - list.Add(GetMediaSource(info, hdhrId, channelInfo, "internet480")); - list.Add(GetMediaSource(info, hdhrId, channelInfo, "internet360")); - list.Add(GetMediaSource(info, hdhrId, channelInfo, "internet240")); - list.Add(GetMediaSource(info, hdhrId, channelInfo, "mobile")); + list.Add(GetMediaSource(tuner, hdhrId, channel, "internet540")); + list.Add(GetMediaSource(tuner, hdhrId, channel, "internet480")); + list.Add(GetMediaSource(tuner, hdhrId, channel, "internet360")); + list.Add(GetMediaSource(tuner, hdhrId, channel, "internet240")); + list.Add(GetMediaSource(tuner, hdhrId, channel, "mobile")); } - list.Add(GetMediaSource(info, hdhrId, channelInfo, "native")); + list.Add(GetMediaSource(tuner, hdhrId, channel, "native")); } if (list.Count == 0) { - list.Add(GetMediaSource(info, hdhrId, channelInfo, "native")); + list.Add(GetMediaSource(tuner, hdhrId, channel, "native")); } } return list; } - protected override async Task<ILiveStream> GetChannelStream(TunerHostInfo info, ChannelInfo channelInfo, string streamId, List<ILiveStream> currentLiveStreams, CancellationToken cancellationToken) + protected override async Task<ILiveStream> GetChannelStream(TunerHostInfo tunerHost, ChannelInfo channel, string streamId, List<ILiveStream> currentLiveStreams, CancellationToken cancellationToken) { - var tunerCount = info.TunerCount; + var tunerCount = tunerHost.TunerCount; if (tunerCount > 0) { - var tunerHostId = info.Id; + var tunerHostId = tunerHost.Id; var liveStreams = currentLiveStreams.Where(i => string.Equals(i.TunerHostId, tunerHostId, StringComparison.OrdinalIgnoreCase)); if (liveStreams.Count() >= tunerCount) @@ -555,28 +543,28 @@ 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}", channelInfo.Id, streamId, profile); + Logger.LogInformation("GetChannelStream: channel id: {0}. stream id: {1} profile: {2}", channel.Id, streamId, profile); - var hdhrId = GetHdHrIdFromChannelId(channelInfo.Id); + var hdhrId = GetHdHrIdFromChannelId(channel.Id); - var hdhomerunChannel = channelInfo as HdHomerunChannelInfo; + var hdhomerunChannel = channel as HdHomerunChannelInfo; - var modelInfo = await GetModelInfo(info, false, cancellationToken).ConfigureAwait(false); + var modelInfo = await GetModelInfo(tunerHost, false, cancellationToken).ConfigureAwait(false); if (!modelInfo.SupportsTranscoding) { profile = "native"; } - var mediaSource = GetMediaSource(info, hdhrId, channelInfo, profile); + var mediaSource = GetMediaSource(tunerHost, hdhrId, channel, profile); if (hdhomerunChannel != null && hdhomerunChannel.IsLegacyTuner) { return new HdHomerunUdpStream( mediaSource, - info, + tunerHost, streamId, new LegacyHdHomerunChannelCommands(hdhomerunChannel.Path), modelInfo.TunerCount, @@ -592,7 +580,7 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun { mediaSource.Protocol = MediaProtocol.Http; - var httpUrl = channelInfo.Path; + var httpUrl = channel.Path; // If raw was used, the tuner doesn't support params if (!string.IsNullOrWhiteSpace(profile) && !string.Equals(profile, "native", StringComparison.OrdinalIgnoreCase)) @@ -604,7 +592,7 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun return new SharedHttpStream( mediaSource, - info, + tunerHost, streamId, FileSystem, _httpClientFactory, @@ -616,7 +604,7 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun return new HdHomerunUdpStream( mediaSource, - info, + tunerHost, streamId, new HdHomerunChannelCommands(hdhomerunChannel.Number, profile), modelInfo.TunerCount, @@ -722,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 3016eeda24..f9d151a81a 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,69 +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; @@ -149,8 +84,7 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun if (!_lockkey.HasValue) { - var rand = new Random(); - _lockkey = (uint)rand.Next(); + _lockkey = (uint)Random.Shared.Next(); } var lockKeyValue = _lockkey.Value; diff --git a/Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/HdHomerunUdpStream.cs b/Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/HdHomerunUdpStream.cs index 58e0c7448a..31445e1ecc 100644 --- a/Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/HdHomerunUdpStream.cs +++ b/Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/HdHomerunUdpStream.cs @@ -101,7 +101,8 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun } } - if (localAddress.IsIPv4MappedToIPv6) { + if (localAddress.IsIPv4MappedToIPv6) + { localAddress = localAddress.MapToIPv4(); } @@ -156,11 +157,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) @@ -184,7 +180,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) @@ -201,7 +197,7 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun cancellationToken, timeOutSource.Token)) { - var resTask = udpClient.ReceiveAsync(); + var resTask = udpClient.ReceiveAsync(linkedSource.Token).AsTask(); if (await Task.WhenAny(resTask, Task.Delay(30000, linkedSource.Token)).ConfigureAwait(false) != resTask) { resTask.Dispose(); 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 0000000000..1533549320 --- /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, string)> 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 0000000000..26627b8aa6 --- /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, string)> 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 96a678c1d3..5581ba87c5 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,123 +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); - - public Task DeleteTempFiles() - { - return DeleteTempFiles(GetStreamFilePaths()); - } + FileOptions.SequentialScan | FileOptions.Asynchronous); - 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) - { - if (!File.Exists(path)) - { - continue; - } - - try - { - FileSystem.DeleteFile(path); - } - catch (Exception ex) - { - Logger.LogError(ex, "Error deleting file {path}", path); - failedFiles.Add(path); - } - } - - 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; - - // use non-async filestream on windows along with read due to https://github.com/dotnet/corefx/issues/6039 - var allowAsync = Environment.OSVersion.Platform != PlatformID.Win32NT; - - bool seekFile = (DateTime.UtcNow - DateOpened).TotalSeconds > 10; - - var nextFileInfo = GetNextFile(null); - var nextFile = nextFileInfo.file; - var isLastFile = nextFileInfo.isLastFile; - - 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)) + try { - return (files[^1], true); + FileSystem.DeleteFile(path); } - - 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)) + catch (Exception ex) { - if (seekFile) + Logger.LogError(ex, "Error deleting file {FilePath}", path); + if (retryCount <= 40) { - TrySeek(inputStream, -20000); + await Task.Delay(500).ConfigureAwait(false); + await DeleteTempFiles(path, retryCount + 1).ConfigureAwait(false); } - - 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 8fa6f5ad68..08b9260b98 100644 --- a/Emby.Server.Implementations/LiveTv/TunerHosts/M3UTunerHost.cs +++ b/Emby.Server.Implementations/LiveTv/TunerHosts/M3UTunerHost.cs @@ -71,12 +71,12 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts return ChannelIdPrefix + info.Url.GetMD5().ToString("N", CultureInfo.InvariantCulture); } - protected override async Task<List<ChannelInfo>> GetChannelsInternal(TunerHostInfo info, CancellationToken cancellationToken) + protected override async Task<List<ChannelInfo>> GetChannelsInternal(TunerHostInfo tuner, CancellationToken cancellationToken) { - var channelIdPrefix = GetFullChannelIdPrefix(info); + var channelIdPrefix = GetFullChannelIdPrefix(tuner); return await new M3uParser(Logger, _httpClientFactory) - .Parse(info, channelIdPrefix, cancellationToken) + .Parse(tuner, channelIdPrefix, cancellationToken) .ConfigureAwait(false); } @@ -96,13 +96,13 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts return Task.FromResult(list); } - protected override async Task<ILiveStream> GetChannelStream(TunerHostInfo info, ChannelInfo channelInfo, string streamId, List<ILiveStream> currentLiveStreams, CancellationToken cancellationToken) + protected override async Task<ILiveStream> GetChannelStream(TunerHostInfo tunerHost, ChannelInfo channel, string streamId, List<ILiveStream> currentLiveStreams, CancellationToken cancellationToken) { - var tunerCount = info.TunerCount; + var tunerCount = tunerHost.TunerCount; if (tunerCount > 0) { - var tunerHostId = info.Id; + var tunerHostId = tunerHost.Id; var liveStreams = currentLiveStreams.Where(i => string.Equals(i.TunerHostId, tunerHostId, StringComparison.OrdinalIgnoreCase)); if (liveStreams.Count() >= tunerCount) @@ -111,7 +111,7 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts } } - var sources = await GetChannelStreamMediaSources(info, channelInfo, cancellationToken).ConfigureAwait(false); + var sources = await GetChannelStreamMediaSources(tunerHost, channel, cancellationToken).ConfigureAwait(false); var mediaSource = sources[0]; @@ -121,11 +121,11 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts if (!_disallowedSharedStreamExtensions.Contains(extension, StringComparer.OrdinalIgnoreCase)) { - return new SharedHttpStream(mediaSource, info, streamId, FileSystem, _httpClientFactory, Logger, Config, _appHost, _streamHelper); + return new SharedHttpStream(mediaSource, tunerHost, streamId, FileSystem, _httpClientFactory, Logger, Config, _appHost, _streamHelper); } } - return new LiveStream(mediaSource, info, FileSystem, Logger, Config, _streamHelper); + return new LiveStream(mediaSource, tunerHost, FileSystem, Logger, Config, _streamHelper); } public async Task Validate(TunerHostInfo info) @@ -135,9 +135,9 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts } } - protected override Task<List<MediaSourceInfo>> GetChannelStreamMediaSources(TunerHostInfo info, ChannelInfo channelInfo, CancellationToken cancellationToken) + protected override Task<List<MediaSourceInfo>> GetChannelStreamMediaSources(TunerHostInfo tuner, ChannelInfo channel, CancellationToken cancellationToken) { - return Task.FromResult(new List<MediaSourceInfo> { CreateMediaSourceInfo(info, channelInfo) }); + return Task.FromResult(new List<MediaSourceInfo> { CreateMediaSourceInfo(tuner, channel) }); } protected virtual MediaSourceInfo CreateMediaSourceInfo(TunerHostInfo info, ChannelInfo channel) diff --git a/Emby.Server.Implementations/LiveTv/TunerHosts/M3uParser.cs b/Emby.Server.Implementations/LiveTv/TunerHosts/M3uParser.cs index c9657f6057..506ef5548a 100644 --- a/Emby.Server.Implementations/LiveTv/TunerHosts/M3uParser.cs +++ b/Emby.Server.Implementations/LiveTv/TunerHosts/M3uParser.cs @@ -13,8 +13,8 @@ using System.Threading.Tasks; using Jellyfin.Extensions; using MediaBrowser.Common.Extensions; using MediaBrowser.Common.Net; -using MediaBrowser.Controller; using MediaBrowser.Controller.LiveTv; +using MediaBrowser.Model.IO; using MediaBrowser.Model.LiveTv; using Microsoft.Extensions.Logging; @@ -44,22 +44,29 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts public async Task<Stream> GetListingsStream(TunerHostInfo info, CancellationToken cancellationToken) { - if (info.Url.StartsWith("http", StringComparison.OrdinalIgnoreCase)) + if (info == null) { - using var requestMessage = new HttpRequestMessage(HttpMethod.Get, info.Url); - if (!string.IsNullOrEmpty(info.UserAgent)) - { - requestMessage.Headers.UserAgent.TryParseAdd(info.UserAgent); - } + throw new ArgumentNullException(nameof(info)); + } + + if (!info.Url.StartsWith("http", StringComparison.OrdinalIgnoreCase)) + { + return AsyncFile.OpenRead(info.Url); + } - var response = await _httpClientFactory.CreateClient(NamedClient.Default) - .SendAsync(requestMessage, cancellationToken) - .ConfigureAwait(false); - response.EnsureSuccessStatusCode(); - return await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); + using var requestMessage = new HttpRequestMessage(HttpMethod.Get, info.Url); + if (!string.IsNullOrEmpty(info.UserAgent)) + { + requestMessage.Headers.UserAgent.TryParseAdd(info.UserAgent); } - return File.OpenRead(info.Url); + // Set HttpCompletionOption.ResponseHeadersRead to prevent timeouts on larger files + var response = await _httpClientFactory.CreateClient(NamedClient.Default) + .SendAsync(requestMessage, HttpCompletionOption.ResponseHeadersRead, cancellationToken) + .ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + + return await response.Content.ReadAsStreamAsync(cancellationToken); } private async Task<List<ChannelInfo>> GetChannelsAsync(TextReader reader, string channelIdPrefix, string tunerHostId) @@ -83,7 +90,6 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts if (trimmedLine.StartsWith(ExtInfPrefix, StringComparison.OrdinalIgnoreCase)) { extInf = trimmedLine.Substring(ExtInfPrefix.Length).Trim(); - _logger.LogInformation("Found m3u channel: {0}", extInf); } else if (!string.IsNullOrWhiteSpace(extInf) && !trimmedLine.StartsWith('#')) { @@ -99,6 +105,7 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts channel.Path = trimmedLine; channels.Add(channel); + _logger.LogInformation("Parsed channel: {ChannelName}", channel.Name); extInf = string.Empty; } } @@ -231,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)) { @@ -289,11 +296,11 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts } } - attributes.TryGetValue("tvg-name", out string name); + string name = nameInExtInf; if (string.IsNullOrWhiteSpace(name)) { - name = nameInExtInf; + attributes.TryGetValue("tvg-name", out name); } if (string.IsNullOrWhiteSpace(name)) diff --git a/Emby.Server.Implementations/LiveTv/TunerHosts/SharedHttpStream.cs b/Emby.Server.Implementations/LiveTv/TunerHosts/SharedHttpStream.cs index f572151b8a..3b69e55b01 100644 --- a/Emby.Server.Implementations/LiveTv/TunerHosts/SharedHttpStream.cs +++ b/Emby.Server.Implementations/LiveTv/TunerHosts/SharedHttpStream.cs @@ -3,7 +3,6 @@ #pragma warning disable CS1591 using System; -using System.Collections.Generic; using System.Globalization; using System.IO; using System.Net.Http; @@ -55,39 +54,26 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts Directory.CreateDirectory(Path.GetDirectoryName(TempFilePath)); 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) - { - 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 - if (requiresRemux) + 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)) { - using (response) - { - return; - } + // Close the stream without any sharing features + response.Dispose(); + return; } - SetTempFilePath(extension); + SetTempFilePath("ts"); var taskCompletionSource = new TaskCompletionSource<bool>(); @@ -117,49 +103,46 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts if (!taskCompletionSource.Task.Result) { - Logger.LogWarning("Zero bytes copied from stream {0} to {1} but no exception raised", GetType().Name, TempFilePath); + 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(async () => - { - try - { - Logger.LogInformation("Beginning {0} stream to {1}", 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); - await StreamHelper.CopyToAsync( - stream, - fileStream, - IODefaults.CopyToBufferSize, - () => Resolve(openTaskCompletionSource), - cancellationToken).ConfigureAwait(false); - } - catch (OperationCanceledException ex) + return Task.Run( + async () => { - Logger.LogInformation("Copying of {0} to {1} was canceled", GetType().Name, TempFilePath); - openTaskCompletionSource.TrySetException(ex); - } - catch (Exception ex) - { - Logger.LogError(ex, "Error copying live stream {0} to {1}.", GetType().Name, TempFilePath); - openTaskCompletionSource.TrySetException(ex); - } - - openTaskCompletionSource.TrySetResult(false); - - EnableStreamSharing = false; - await DeleteTempFiles(new List<string> { TempFilePath }).ConfigureAwait(false); - }, CancellationToken.None); + try + { + 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, FileOptions.Asynchronous); + await StreamHelper.CopyToAsync( + stream, + fileStream, + IODefaults.CopyToBufferSize, + () => Resolve(openTaskCompletionSource), + cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException ex) + { + 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 {StreamType} to {FilePath}", GetType().Name, TempFilePath); + openTaskCompletionSource.TrySetException(ex); + } + + openTaskCompletionSource.TrySetResult(false); + + EnableStreamSharing = false; + await DeleteTempFiles(TempFilePath).ConfigureAwait(false); + }, + CancellationToken.None); } private void Resolve(TaskCompletionSource<bool> openTaskCompletionSource) diff --git a/Emby.Server.Implementations/Localization/Core/af.json b/Emby.Server.Implementations/Localization/Core/af.json index 4f21c66bc4..18f17dda95 100644 --- a/Emby.Server.Implementations/Localization/Core/af.json +++ b/Emby.Server.Implementations/Localization/Core/af.json @@ -2,24 +2,24 @@ "Artists": "Kunstenare", "Channels": "Kanale", "Folders": "Lêergidse", - "Favorites": "Gunstellinge", + "Favorites": "Gunstelinge", "HeaderFavoriteShows": "Gunsteling Vertonings", "ValueSpecialEpisodeName": "Spesiale - {0}", - "HeaderAlbumArtists": "Album Kunstenaars", + "HeaderAlbumArtists": "Kunstenaars se Album", "Books": "Boeke", "HeaderNextUp": "Volgende", "Movies": "Flieks", "Shows": "Televisie Reekse", "HeaderContinueWatching": "Kyk Verder", "HeaderFavoriteEpisodes": "Gunsteling Episodes", - "Photos": "Fotos", + "Photos": "Foto's", "Playlists": "Snitlyste", "HeaderFavoriteArtists": "Gunsteling Kunstenaars", "HeaderFavoriteAlbums": "Gunsteling Albums", "Sync": "Sinkroniseer", "HeaderFavoriteSongs": "Gunsteling Liedjies", "Songs": "Liedjies", - "DeviceOnlineWithName": "{0} gekoppel is", + "DeviceOnlineWithName": "{0} is gekoppel", "DeviceOfflineWithName": "{0} is ontkoppel", "Collections": "Versamelings", "Inherit": "Ontvang", @@ -71,7 +71,7 @@ "NameSeasonUnknown": "Seisoen Onbekend", "NameSeasonNumber": "Seisoen {0}", "NameInstallFailed": "{0} installering het misluk", - "MusicVideos": "Musiek videos", + "MusicVideos": "Musiek Videos", "Music": "Musiek", "MixedContent": "Gemengde inhoud", "MessageServerConfigurationUpdated": "Bediener konfigurasie is opgedateer", @@ -79,15 +79,15 @@ "MessageApplicationUpdatedTo": "Jellyfin Bediener is opgedateer na {0}", "MessageApplicationUpdated": "Jellyfin Bediener is opgedateer", "Latest": "Nuutste", - "LabelRunningTimeValue": "Lopende tyd: {0}", + "LabelRunningTimeValue": "Werktyd: {0}", "LabelIpAddressValue": "IP adres: {0}", "ItemRemovedWithName": "{0} is uit versameling verwyder", - "ItemAddedWithName": "{0} is in die versameling", - "HomeVideos": "Tuis opnames", + "ItemAddedWithName": "{0} is by die versameling gevoeg", + "HomeVideos": "Tuis Videos", "HeaderRecordingGroups": "Groep Opnames", "Genres": "Genres", "FailedLoginAttemptWithUserName": "Mislukte aansluiting van {0}", - "ChapterNameValue": "Hoofstuk", + "ChapterNameValue": "Hoofstuk {0}", "CameraImageUploadedFrom": "'n Nuwe kamera photo opgelaai van {0}", "AuthenticationSucceededWithUserName": "{0} suksesvol geverifieer", "Albums": "Albums", @@ -117,5 +117,7 @@ "Forced": "Geforseer", "Default": "Oorspronklik", "TaskCleanActivityLogDescription": "Verwyder aktiwiteitsaantekeninge ouer as die opgestelde ouderdom.", - "TaskCleanActivityLog": "Maak Aktiwiteitsaantekeninge Skoon" + "TaskCleanActivityLog": "Maak Aktiwiteitsaantekeninge Skoon", + "TaskOptimizeDatabaseDescription": "Komprimeer databasis en verkort vrye ruimte. As hierdie taak uitgevoer word nadat die media versameling geskandeer is of ander veranderings aangebring is wat databasisaanpassings impliseer, kan dit die prestasie verbeter.", + "TaskOptimizeDatabase": "Optimaliseer databasis" } diff --git a/Emby.Server.Implementations/Localization/Core/ar.json b/Emby.Server.Implementations/Localization/Core/ar.json index 3d6e159b13..a83a453b4c 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": "المفضلة", + "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": "مزامنة", @@ -118,5 +118,7 @@ "TaskCleanActivityLog": "حذف سجل الأنشطة", "Default": "الإعدادات الافتراضية", "Undefined": "غير معرف", - "Forced": "ملحقة" + "Forced": "ملحقة", + "TaskOptimizeDatabaseDescription": "يضغط قاعدة البيانات ويقتطع المساحة الحرة. تشغيل هذه المهمة بعد فحص المكتبة أو إجراء تغييرات أخرى تشير ضمنًا إلى أن تعديلات قاعدة البيانات قد تؤدي إلى تحسين الأداء.", + "TaskOptimizeDatabase": "تحسين قاعدة البيانات" } diff --git a/Emby.Server.Implementations/Localization/Core/bg-BG.json b/Emby.Server.Implementations/Localization/Core/bg-BG.json index bc25531d30..e1c9233087 100644 --- a/Emby.Server.Implementations/Localization/Core/bg-BG.json +++ b/Emby.Server.Implementations/Localization/Core/bg-BG.json @@ -8,7 +8,7 @@ "CameraImageUploadedFrom": "Нова снимка от камера беше качена от {0}", "Channels": "Канали", "ChapterNameValue": "Глава {0}", - "Collections": "Поредици", + "Collections": "Колекции", "DeviceOfflineWithName": "{0} се разкачи", "DeviceOnlineWithName": "{0} е свързан", "FailedLoginAttemptWithUserName": "Неуспешен опит за влизане от {0}", @@ -25,21 +25,21 @@ "HeaderLiveTV": "Телевизия на живо", "HeaderNextUp": "Следва", "HeaderRecordingGroups": "Запис групи", - "HomeVideos": "Домашни клипове", + "HomeVideos": "Домашни Клипове", "Inherit": "Наследяване", "ItemAddedWithName": "{0} е добавено към библиотеката", "ItemRemovedWithName": "{0} е премахнато от библиотеката", - "LabelIpAddressValue": "ИП адрес: {0}", - "LabelRunningTimeValue": "Стартирано от: {0}", + "LabelIpAddressValue": "IP адрес: {0}", + "LabelRunningTimeValue": "Продължителност: {0}", "Latest": "Последни", - "MessageApplicationUpdated": "Сървърът е обновен", - "MessageApplicationUpdatedTo": "Сървърът е обновен до {0}", - "MessageNamedServerConfigurationUpdatedWithValue": "Секцията {0} от сървърната конфигурация се актуализира", - "MessageServerConfigurationUpdated": "Конфигурацията на сървъра се актуализира", + "MessageApplicationUpdated": "Сървърът беше обновен", + "MessageApplicationUpdatedTo": "Сървърът беше обновен до {0}", + "MessageNamedServerConfigurationUpdatedWithValue": "Секцията {0} от сървърната конфигурация беше актуализирана", + "MessageServerConfigurationUpdated": "Конфигурацията на сървъра беше актуализирана", "MixedContent": "Смесено съдържание", "Movies": "Филми", "Music": "Музика", - "MusicVideos": "Музикални видеа", + "MusicVideos": "Музикални Видеа", "NameInstallFailed": "{0} не можа да се инсталира", "NameSeasonNumber": "Сезон {0}", "NameSeasonUnknown": "Неразпознат сезон", @@ -118,5 +118,7 @@ "Forced": "Принудително", "Default": "По подразбиране", "TaskCleanActivityLogDescription": "Изтрива записите в дневника с активност по стари от конфигурираната възраст.", - "TaskCleanActivityLog": "Изчисти дневника с активност" + "TaskCleanActivityLog": "Изчисти дневника с активност", + "TaskOptimizeDatabaseDescription": "Прави базата данни по-компактна и освобождава място. Пускането на тази задача след сканиране на библиотеката или правене на други промени, свързани с модификации на базата данни, може да подобри производителността.", + "TaskOptimizeDatabase": "Оптимизирай базата данни" } diff --git a/Emby.Server.Implementations/Localization/Core/ca.json b/Emby.Server.Implementations/Localization/Core/ca.json index fd8437b6d9..db3c13d800 100644 --- a/Emby.Server.Implementations/Localization/Core/ca.json +++ b/Emby.Server.Implementations/Localization/Core/ca.json @@ -5,7 +5,7 @@ "Artists": "Artistes", "AuthenticationSucceededWithUserName": "{0} s'ha autenticat correctament", "Books": "Llibres", - "CameraImageUploadedFrom": "Una nova imatge de la càmera ha estat pujada des de {0}", + "CameraImageUploadedFrom": "S'ha pujat una nova imatge des de la camera desde {0}", "Channels": "Canals", "ChapterNameValue": "Capítol {0}", "Collections": "Col·leccions", @@ -15,7 +15,7 @@ "Favorites": "Preferits", "Folders": "Carpetes", "Genres": "Gèneres", - "HeaderAlbumArtists": "Artistes del Àlbum", + "HeaderAlbumArtists": "Àlbum de l'artista", "HeaderContinueWatching": "Continua Veient", "HeaderFavoriteAlbums": "Àlbums Preferits", "HeaderFavoriteArtists": "Artistes Predilectes", @@ -118,5 +118,7 @@ "TaskCleanActivityLog": "Buidar Registre d'Activitat", "Undefined": "Indefinit", "Forced": "Forçat", - "Default": "Defecto" + "Default": "Defecto", + "TaskOptimizeDatabaseDescription": "Compacta la base de dades i trunca l'espai lliure. Executar aquesta tasca després d’escanejar la biblioteca o fer altres canvis que impliquin modificacions a la base de dades pot millorar el rendiment.", + "TaskOptimizeDatabase": "Optimitzar la base de dades" } diff --git a/Emby.Server.Implementations/Localization/Core/cs.json b/Emby.Server.Implementations/Localization/Core/cs.json index ff14c1929c..1ea378321c 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": "Umělci alba", + "HeaderAlbumArtists": "Album umělce", "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", @@ -118,5 +118,7 @@ "TaskCleanActivityLog": "Smazat záznam aktivity", "Undefined": "Nedefinované", "Forced": "Vynucené", - "Default": "Výchozí" + "Default": "Výchozí", + "TaskOptimizeDatabaseDescription": "Zmenší databázi a odstraní prázdné místo. Spuštění této úlohy po skenování knihovny či jiných změnách databáze může zlepšit výkon.", + "TaskOptimizeDatabase": "Optimalizovat databázi" } diff --git a/Emby.Server.Implementations/Localization/Core/cy.json b/Emby.Server.Implementations/Localization/Core/cy.json new file mode 100644 index 0000000000..0fa72dea41 --- /dev/null +++ b/Emby.Server.Implementations/Localization/Core/cy.json @@ -0,0 +1,14 @@ +{ + "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" +} diff --git a/Emby.Server.Implementations/Localization/Core/da.json b/Emby.Server.Implementations/Localization/Core/da.json index 3453507d9e..cfe365f57a 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", @@ -118,5 +118,7 @@ "TaskCleanActivityLog": "Ryd Aktivitetslog", "Undefined": "Udefineret", "Forced": "Tvunget", - "Default": "Standard" + "Default": "Standard", + "TaskOptimizeDatabaseDescription": "Kompakter database og forkorter fri plads. Ved at køre denne proces efter at scanne biblioteket eller efter at ændre noget som kunne have indflydelse på databasen, kan forbedre ydeevne.", + "TaskOptimizeDatabase": "Optimér database" } diff --git a/Emby.Server.Implementations/Localization/Core/de.json b/Emby.Server.Implementations/Localization/Core/de.json index 9d82b58784..c924e5c150 100644 --- a/Emby.Server.Implementations/Localization/Core/de.json +++ b/Emby.Server.Implementations/Localization/Core/de.json @@ -3,7 +3,7 @@ "AppDeviceValues": "App: {0}, Gerät: {1}", "Application": "Anwendung", "Artists": "Interpreten", - "AuthenticationSucceededWithUserName": "{0} wurde angemeldet", + "AuthenticationSucceededWithUserName": "{0} erfolgreich authentifiziert", "Books": "Bücher", "CameraImageUploadedFrom": "Ein neues Kamerafoto wurde von {0} hochgeladen", "Channels": "Kanäle", @@ -16,7 +16,7 @@ "Folders": "Verzeichnisse", "Genres": "Genres", "HeaderAlbumArtists": "Album-Interpreten", - "HeaderContinueWatching": "Fortsetzen", + "HeaderContinueWatching": "Weiterschauen", "HeaderFavoriteAlbums": "Lieblingsalben", "HeaderFavoriteArtists": "Lieblings-Interpreten", "HeaderFavoriteEpisodes": "Lieblingsepisoden", @@ -118,5 +118,7 @@ "TaskCleanActivityLog": "Aktivitätsprotokoll aufräumen", "Undefined": "Undefiniert", "Forced": "Erzwungen", - "Default": "Standard" + "Default": "Standard", + "TaskOptimizeDatabaseDescription": "Komprimiert die Datenbank und trimmt den freien Speicherplatz. Die Ausführung dieser Aufgabe nach dem Scannen der Bibliothek oder nach anderen Änderungen, die Datenbankänderungen implizieren, kann die Leistung verbessern.", + "TaskOptimizeDatabase": "Datenbank optimieren" } diff --git a/Emby.Server.Implementations/Localization/Core/el.json b/Emby.Server.Implementations/Localization/Core/el.json index 23d45b4737..697063f262 100644 --- a/Emby.Server.Implementations/Localization/Core/el.json +++ b/Emby.Server.Implementations/Localization/Core/el.json @@ -1,5 +1,5 @@ { - "Albums": "Άλμπουμς", + "Albums": "Άλμπουμ", "AppDeviceValues": "Εφαρμογή: {0}, Συσκευή: {1}", "Application": "Εφαρμογή", "Artists": "Καλλιτέχνες", @@ -15,7 +15,7 @@ "Favorites": "Αγαπημένα", "Folders": "Φάκελοι", "Genres": "Είδη", - "HeaderAlbumArtists": "Καλλιτέχνες του Άλμπουμ", + "HeaderAlbumArtists": "Άλμπουμ Καλλιτέχνη", "HeaderContinueWatching": "Συνεχίστε την παρακολούθηση", "HeaderFavoriteAlbums": "Αγαπημένα Άλμπουμ", "HeaderFavoriteArtists": "Αγαπημένοι Καλλιτέχνες", @@ -39,7 +39,7 @@ "MixedContent": "Ανάμεικτο Περιεχόμενο", "Movies": "Ταινίες", "Music": "Μουσική", - "MusicVideos": "Μουσικά βίντεο", + "MusicVideos": "Μουσικά Βίντεο", "NameInstallFailed": "{0} η εγκατάσταση απέτυχε", "NameSeasonNumber": "Κύκλος {0}", "NameSeasonUnknown": "Άγνωστος Κύκλος", @@ -62,7 +62,7 @@ "NotificationOptionVideoPlaybackStopped": "Η αναπαραγωγή βίντεο σταμάτησε", "Photos": "Φωτογραφίες", "Playlists": "Λίστες αναπαραγωγής", - "Plugin": "Plugin", + "Plugin": "Πρόσθετο", "PluginInstalledWithName": "{0} εγκαταστήθηκε", "PluginUninstalledWithName": "{0} έχει απεγκατασταθεί", "PluginUpdatedWithName": "{0} έχει αναβαθμιστεί", @@ -118,5 +118,7 @@ "TaskCleanActivityLog": "Καθαρό Αρχείο Καταγραφής Δραστηριοτήτων", "Undefined": "Απροσδιόριστο", "Forced": "Εξαναγκασμένο", - "Default": "Προεπιλογή" + "Default": "Προεπιλογή", + "TaskOptimizeDatabaseDescription": "Συμπιέζει τη βάση δεδομένων και δημιουργεί ελεύθερο χώρο. Η εκτέλεση αυτής της εργασίας μετά τη σάρωση της βιβλιοθήκης ή την πραγματοποίηση άλλων αλλαγών που συνεπάγονται τροποποιήσεις της βάσης δεδομένων μπορεί να βελτιώσει την απόδοση.", + "TaskOptimizeDatabase": "Βελτιστοποίηση βάσης δεδομένων" } diff --git a/Emby.Server.Implementations/Localization/Core/en-GB.json b/Emby.Server.Implementations/Localization/Core/en-GB.json index 7667612b90..86ce9240e5 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": "Artist's Album", "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", @@ -118,5 +118,7 @@ "TaskCleanActivityLog": "Clean Activity Log", "Undefined": "Undefined", "Forced": "Forced", - "Default": "Default" + "Default": "Default", + "TaskOptimizeDatabaseDescription": "Compacts database and truncates free space. Running this task after scanning the library or doing other changes that imply database modifications might improve performance.", + "TaskOptimizeDatabase": "Optimise database" } diff --git a/Emby.Server.Implementations/Localization/Core/en-US.json b/Emby.Server.Implementations/Localization/Core/en-US.json index 65964f6d9f..ca127cdb86 100644 --- a/Emby.Server.Implementations/Localization/Core/en-US.json +++ b/Emby.Server.Implementations/Localization/Core/en-US.json @@ -17,7 +17,7 @@ "Folders": "Folders", "Forced": "Forced", "Genres": "Genres", - "HeaderAlbumArtists": "Album Artists", + "HeaderAlbumArtists": "Artist's Album", "HeaderContinueWatching": "Continue Watching", "HeaderFavoriteAlbums": "Favorite Albums", "HeaderFavoriteArtists": "Favorite Artists", @@ -27,7 +27,7 @@ "HeaderLiveTV": "Live TV", "HeaderNextUp": "Next Up", "HeaderRecordingGroups": "Recording Groups", - "HomeVideos": "Home videos", + "HomeVideos": "Home Videos", "Inherit": "Inherit", "ItemAddedWithName": "{0} was added to the library", "ItemRemovedWithName": "{0} was removed from the library", @@ -41,7 +41,7 @@ "MixedContent": "Mixed content", "Movies": "Movies", "Music": "Music", - "MusicVideos": "Music videos", + "MusicVideos": "Music Videos", "NameInstallFailed": "{0} installation failed", "NameSeasonNumber": "Season {0}", "NameSeasonUnknown": "Season Unknown", diff --git a/Emby.Server.Implementations/Localization/Core/eo.json b/Emby.Server.Implementations/Localization/Core/eo.json index ca615cc8cf..f92b5f6730 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", + "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": "Albumo de artisto", "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": "Favoratoj", + "TaskCleanLogs": "Purigi Ĵurnalan Katalogon", + "TaskRefreshLibrary": "Skanu Plurmeditekon", + "ValueSpecialEpisodeName": "Speciala - {0}", + "TaskOptimizeDatabase": "Optimigi datumbazon", + "TaskRefreshChannels": "Refreŝigi Kanalojn", + "TaskUpdatePlugins": "Ĝisdatigi Kromprogramojn", + "TaskRefreshPeople": "Refreŝigi Homojn", + "TasksChannelsCategory": "Interretaj Kanaloj", + "ProviderValue": "Provizanto: {0}", + "NotificationOptionPluginError": "Kromprograma malsukceso", + "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 teka 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ŝutu mankantajn subtekstojn", + "TaskCleanTranscode": "Malplenigi Transkodadan Katalogon", + "TaskRefreshChapterImages": "Eltiru Ĉapitro-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-AR.json b/Emby.Server.Implementations/Localization/Core/es-AR.json index 0d4a14be00..6321f695c4 100644 --- a/Emby.Server.Implementations/Localization/Core/es-AR.json +++ b/Emby.Server.Implementations/Localization/Core/es-AR.json @@ -118,5 +118,7 @@ "TaskCleanActivityLog": "Borrar log de actividades", "Undefined": "Indefinido", "Forced": "Forzado", - "Default": "Por Defecto" + "Default": "Por Defecto", + "TaskOptimizeDatabaseDescription": "Compacta la base de datos y restaura el espacio libre. Ejecutar esta tarea después de actualizar las librerías o realizar otros cambios que impliquen modificar las bases de datos puede mejorar la performance.", + "TaskOptimizeDatabase": "Optimización de base de datos" } diff --git a/Emby.Server.Implementations/Localization/Core/es-MX.json b/Emby.Server.Implementations/Localization/Core/es-MX.json index 5d7ed243f4..432814dac1 100644 --- a/Emby.Server.Implementations/Localization/Core/es-MX.json +++ b/Emby.Server.Implementations/Localization/Core/es-MX.json @@ -15,7 +15,7 @@ "Favorites": "Favoritos", "Folders": "Carpetas", "Genres": "Géneros", - "HeaderAlbumArtists": "Artistas del álbum", + "HeaderAlbumArtists": "Artistas del Álbum", "HeaderContinueWatching": "Continuar viendo", "HeaderFavoriteAlbums": "Álbumes favoritos", "HeaderFavoriteArtists": "Artistas favoritos", @@ -25,7 +25,7 @@ "HeaderLiveTV": "TV en vivo", "HeaderNextUp": "A continuación", "HeaderRecordingGroups": "Grupos de grabación", - "HomeVideos": "Videos caseros", + "HomeVideos": "Videos Caseros", "Inherit": "Heredar", "ItemAddedWithName": "{0} fue agregado a la biblioteca", "ItemRemovedWithName": "{0} fue removido de la biblioteca", @@ -39,7 +39,7 @@ "MixedContent": "Contenido mezclado", "Movies": "Películas", "Music": "Música", - "MusicVideos": "Videos musicales", + "MusicVideos": "Videos Musicales", "NameInstallFailed": "Falló la instalación de {0}", "NameSeasonNumber": "Temporada {0}", "NameSeasonUnknown": "Temporada desconocida", @@ -49,7 +49,7 @@ "NotificationOptionAudioPlayback": "Reproducción de audio iniciada", "NotificationOptionAudioPlaybackStopped": "Reproducción de audio detenida", "NotificationOptionCameraImageUploaded": "Imagen de la cámara subida", - "NotificationOptionInstallationFailed": "Falla de instalación", + "NotificationOptionInstallationFailed": "Fallo en la instalación", "NotificationOptionNewLibraryContent": "Nuevo contenido agregado", "NotificationOptionPluginError": "Falla de complemento", "NotificationOptionPluginInstalled": "Complemento instalado", @@ -69,7 +69,7 @@ "ProviderValue": "Proveedor: {0}", "ScheduledTaskFailedWithName": "{0} falló", "ScheduledTaskStartedWithName": "{0} iniciado", - "ServerNameNeedsToBeRestarted": "{0} debe ser reiniciado", + "ServerNameNeedsToBeRestarted": "{0} necesita ser reiniciado", "Shows": "Programas", "Songs": "Canciones", "StartupEmbyServerIsLoading": "El servidor Jellyfin está cargando. Por favor, intente de nuevo pronto.", @@ -94,9 +94,9 @@ "VersionNumber": "Versión {0}", "TaskDownloadMissingSubtitlesDescription": "Busca subtítulos faltantes en Internet basándose en la configuración de metadatos.", "TaskDownloadMissingSubtitles": "Descargar subtítulos faltantes", - "TaskRefreshChannelsDescription": "Actualiza la información de canales de Internet.", + "TaskRefreshChannelsDescription": "Actualiza la información de los canales de Internet.", "TaskRefreshChannels": "Actualizar canales", - "TaskCleanTranscodeDescription": "Elimina archivos transcodificados que tengan más de un día.", + "TaskCleanTranscodeDescription": "Elimina archivos transcodificados que tengan más de un día de antigüedad.", "TaskCleanTranscode": "Limpiar directorio de transcodificado", "TaskUpdatePluginsDescription": "Descarga e instala actualizaciones para complementos que están configurados para actualizarse automáticamente.", "TaskUpdatePlugins": "Actualizar complementos", @@ -118,5 +118,7 @@ "TaskCleanActivityLog": "Limpiar registro de actividades", "Undefined": "Sin definir", "Forced": "Forzado", - "Default": "Predeterminado" + "Default": "Predeterminado", + "TaskOptimizeDatabase": "Optimizar base de datos", + "TaskOptimizeDatabaseDescription": "Compacta la base de datos y trunca el espacio libre. Puede mejorar el rendimiento si se realiza esta tarea después de escanear la biblioteca o después de realizar otros cambios que impliquen modificar la base de datos." } diff --git a/Emby.Server.Implementations/Localization/Core/es.json b/Emby.Server.Implementations/Localization/Core/es.json index 91939843fa..d3d9d27038 100644 --- a/Emby.Server.Implementations/Localization/Core/es.json +++ b/Emby.Server.Implementations/Localization/Core/es.json @@ -15,7 +15,7 @@ "Favorites": "Favoritos", "Folders": "Carpetas", "Genres": "Géneros", - "HeaderAlbumArtists": "Artistas del álbum", + "HeaderAlbumArtists": "Artista del álbum", "HeaderContinueWatching": "Continuar viendo", "HeaderFavoriteAlbums": "Álbumes favoritos", "HeaderFavoriteArtists": "Artistas favoritos", @@ -70,7 +70,7 @@ "ScheduledTaskFailedWithName": "{0} falló", "ScheduledTaskStartedWithName": "{0} iniciada", "ServerNameNeedsToBeRestarted": "{0} necesita ser reiniciado", - "Shows": "Series de Televisión", + "Shows": "Series", "Songs": "Canciones", "StartupEmbyServerIsLoading": "Jellyfin Server se está cargando. Vuelve a intentarlo en breve.", "SubtitleDownloadFailureForItem": "Error al descargar subtítulos para {0}", diff --git a/Emby.Server.Implementations/Localization/Core/es_419.json b/Emby.Server.Implementations/Localization/Core/es_419.json index 6d2a5c7ac0..a968c6daba 100644 --- a/Emby.Server.Implementations/Localization/Core/es_419.json +++ b/Emby.Server.Implementations/Localization/Core/es_419.json @@ -117,5 +117,7 @@ "TaskCleanActivityLog": "Limpiar Registro de Actividades", "Undefined": "Sin definir", "Forced": "Forzado", - "Default": "Por Defecto" + "Default": "Por Defecto", + "TaskOptimizeDatabaseDescription": "Compacta la base de datos y restaura el espacio libre. Ejecutar esta tarea después de actualizar las librerías o realizar otros cambios que impliquen modificar las bases de datos puede mejorar la performance.", + "TaskOptimizeDatabase": "Optimización de base de datos" } diff --git a/Emby.Server.Implementations/Localization/Core/et.json b/Emby.Server.Implementations/Localization/Core/et.json new file mode 100644 index 0000000000..c3596ecf14 --- /dev/null +++ b/Emby.Server.Implementations/Localization/Core/et.json @@ -0,0 +1,118 @@ +{ + "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": "Kollektsioonid", + "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} parooli on muudetud", + "UserLockedOutWithName": "Kasutaja {0} on lukustatud", + "UserDeletedWithName": "Kasutaja {0} on kustutatud", + "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 on peatatud", + "NotificationOptionVideoPlayback": "Video taasesitus algas", + "NotificationOptionUserLockedOut": "Kasutaja on lukustatud", + "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 peatati", + "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 esitaja", + "Genres": "Žanrid", + "Forced": "Sunnitud", + "Folders": "Kaustad", + "Favorites": "Lemmikud", + "FailedLoginAttemptWithUserName": "Ebaõnnestunud sisselogimiskatse kasutajalt {0}", + "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" +} diff --git a/Emby.Server.Implementations/Localization/Core/fi.json b/Emby.Server.Implementations/Localization/Core/fi.json index 633968d26d..4a1f4f1d52 100644 --- a/Emby.Server.Implementations/Localization/Core/fi.json +++ b/Emby.Server.Implementations/Localization/Core/fi.json @@ -117,5 +117,7 @@ "Default": "Oletus", "TaskCleanActivityLogDescription": "Poistaa määritettyä ikää vanhemmat tapahtumat toimintahistoriasta.", "TaskCleanActivityLog": "Tyhjennä toimintahistoria", - "Undefined": "Määrittelemätön" + "Undefined": "Määrittelemätön", + "TaskOptimizeDatabaseDescription": "Tiivistää ja puhdistaa tietokannan. Tämän toiminnon suorittaminen kirjastojen skannauksen tai muiden tietokantaan liittyvien muutoksien jälkeen voi parantaa suorituskykyä.", + "TaskOptimizeDatabase": "Optimoi tietokanta" } diff --git a/Emby.Server.Implementations/Localization/Core/fr-CA.json b/Emby.Server.Implementations/Localization/Core/fr-CA.json index 3c51d64e04..2a56d07450 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 ce1493be8a..1265b6ef58 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", @@ -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.", @@ -118,5 +118,7 @@ "TaskCleanActivityLog": "Nettoyer le journal d'activité", "Undefined": "Non défini", "Forced": "Forcé", - "Default": "Par défaut" + "Default": "Par défaut", + "TaskOptimizeDatabaseDescription": "Réduit les espaces vides/inutiles et compacte la base de données. Utiliser cette fonction après une mise à jour de la bibliothèque ou toute autre modification de la base de données peut améliorer les performances du serveur.", + "TaskOptimizeDatabase": "Optimiser la base de données" } diff --git a/Emby.Server.Implementations/Localization/Core/gl.json b/Emby.Server.Implementations/Localization/Core/gl.json index 0398e1c9e5..b433c6f68c 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", @@ -88,5 +88,36 @@ "NotificationOptionVideoPlaybackStopped": "Reproducción de vídeo parada", "NotificationOptionVideoPlayback": "Reproducción de vídeo iniciada", "NotificationOptionUserLockedOut": "Usuario bloqueado", - "NotificationOptionTaskFailed": "Falla na tarefa axendada" + "NotificationOptionTaskFailed": "Falla na tarefa axendada", + "TaskCleanTranscodeDescription": "Borra os arquivos de transcode anteriores a un día.", + "TaskCleanTranscode": "Limpar Directorio de Transcode", + "UserStoppedPlayingItemWithValues": "{0} rematou de reproducir {1} en {2}", + "UserStartedPlayingItemWithValues": "{0} está reproducindo {1} en {2}", + "TaskDownloadMissingSubtitlesDescription": "Busca en internet por subtítulos que faltan baseado na configuración de metadatos.", + "TaskDownloadMissingSubtitles": "Descargar subtítulos que faltan", + "TaskRefreshChannelsDescription": "Refresca a información do canle de internet.", + "TaskRefreshChannels": "Refrescar Canles", + "TaskUpdatePluginsDescription": "Descarga e instala actualizacións para plugins que están configurados para actualizarse automáticamente.", + "TaskRefreshPeopleDescription": "Actualiza os metadatos dos actores e directores na túa libraría multimedia.", + "TaskRefreshPeople": "Refrescar Persoas", + "TaskCleanLogsDescription": "Borra arquivos de rexistro que son mais antigos que {0} días.", + "TaskRefreshLibraryDescription": "Escanea a tua libraría multimedia buscando novos arquivos e refrescando os metadatos.", + "TaskRefreshLibrary": "Escanear Libraría Multimedia", + "TaskRefreshChapterImagesDescription": "Crea previsualizacións para videos que teñen capítulos.", + "TaskRefreshChapterImages": "Extraer Imaxes dos Capítulos", + "TaskCleanCacheDescription": "Borra ficheiros da caché que xa non son necesarios para o sistema.", + "TaskCleanCache": "Limpa Directorio de Caché", + "TaskCleanActivityLogDescription": "Borra as entradas no rexistro de actividade anteriores á data configurada.", + "TasksApplicationCategory": "Aplicación", + "ValueSpecialEpisodeName": "Especial - {0}", + "ValueHasBeenAddedToLibrary": "{0} foi engadido a túa libraría multimedia", + "TasksLibraryCategory": "Libraría", + "TasksMaintenanceCategory": "Mantemento", + "VersionNumber": "Versión {0}", + "UserPolicyUpdatedWithName": "A política de usuario foi actualizada para {0}", + "UserPasswordChangedWithName": "Cambiouse o contrasinal para o usuario {0}", + "UserOnlineFromDevice": "{0} está en liña desde {1}", + "UserOfflineFromDevice": "{0} desconectouse desde {1}", + "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/hr.json b/Emby.Server.Implementations/Localization/Core/hr.json index 9eb80b83ba..d7cda61da6 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": "Album od izvođača", "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 85848fed60..85ab1511a4 100644 --- a/Emby.Server.Implementations/Localization/Core/hu.json +++ b/Emby.Server.Implementations/Localization/Core/hu.json @@ -15,7 +15,7 @@ "Favorites": "Kedvencek", "Folders": "Könyvtárak", "Genres": "Műfajok", - "HeaderAlbumArtists": "Album előadók", + "HeaderAlbumArtists": "Előadó albumai", "HeaderContinueWatching": "Megtekintés folytatása", "HeaderFavoriteAlbums": "Kedvenc albumok", "HeaderFavoriteArtists": "Kedvenc előadók", @@ -118,5 +118,7 @@ "TaskCleanActivityLog": "Tevékenységnapló törlése", "Undefined": "Meghatározatlan", "Forced": "Kényszerített", - "Default": "Alapértelmezett" + "Default": "Alapértelmezett", + "TaskOptimizeDatabaseDescription": "Tömöríti az adatbázist és csonkolja a szabad helyet. A feladat futtatása a könyvtár beolvasása után, vagy egyéb, adatbázis-módosítást igénylő változtatások végrehajtása javíthatja a teljesítményt.", + "TaskOptimizeDatabase": "Adatbázis optimalizálása" } diff --git a/Emby.Server.Implementations/Localization/Core/it.json b/Emby.Server.Implementations/Localization/Core/it.json index bd06f0a258..5e28cf09fb 100644 --- a/Emby.Server.Implementations/Localization/Core/it.json +++ b/Emby.Server.Implementations/Localization/Core/it.json @@ -15,7 +15,7 @@ "Favorites": "Preferiti", "Folders": "Cartelle", "Genres": "Generi", - "HeaderAlbumArtists": "Artisti degli Album", + "HeaderAlbumArtists": "Artisti dell'Album", "HeaderContinueWatching": "Continua a guardare", "HeaderFavoriteAlbums": "Album Preferiti", "HeaderFavoriteArtists": "Artisti Preferiti", @@ -25,7 +25,7 @@ "HeaderLiveTV": "Diretta TV", "HeaderNextUp": "Prossimo", "HeaderRecordingGroups": "Gruppi di Registrazione", - "HomeVideos": "Video personali", + "HomeVideos": "Video Personali", "Inherit": "Eredita", "ItemAddedWithName": "{0} è stato aggiunto alla libreria", "ItemRemovedWithName": "{0} è stato rimosso dalla libreria", @@ -39,7 +39,7 @@ "MixedContent": "Contenuto misto", "Movies": "Film", "Music": "Musica", - "MusicVideos": "Video musicali", + "MusicVideos": "Video Musicali", "NameInstallFailed": "{0} installazione fallita", "NameSeasonNumber": "Stagione {0}", "NameSeasonUnknown": "Stagione sconosciuta", @@ -70,7 +70,7 @@ "ScheduledTaskFailedWithName": "{0} fallito", "ScheduledTaskStartedWithName": "{0} avviati", "ServerNameNeedsToBeRestarted": "{0} deve essere riavviato", - "Shows": "Programmi", + "Shows": "Serie TV", "Songs": "Canzoni", "StartupEmbyServerIsLoading": "Jellyfin server si sta avviando. Per favore riprova più tardi.", "SubtitleDownloadFailureForItem": "Impossibile scaricare i sottotitoli per {0}", @@ -118,5 +118,7 @@ "TaskCleanActivityLogDescription": "Elimina gli inserimenti nel registro delle attività più vecchie dell’età configurata.", "Undefined": "Non Definito", "Forced": "Forzato", - "Default": "Predefinito" + "Default": "Predefinito", + "TaskOptimizeDatabaseDescription": "Compatta Database e tronca spazi liberi. Eseguire questa azione dopo la scansione o dopo aver fatto altri cambiamenti inerenti il database potrebbe aumentarne la performance.", + "TaskOptimizeDatabase": "Ottimizza Database" } diff --git a/Emby.Server.Implementations/Localization/Core/ja.json b/Emby.Server.Implementations/Localization/Core/ja.json index fa0ab8b927..c689bc58a5 100644 --- a/Emby.Server.Implementations/Localization/Core/ja.json +++ b/Emby.Server.Implementations/Localization/Core/ja.json @@ -15,7 +15,7 @@ "Favorites": "お気に入り", "Folders": "フォルダー", "Genres": "ジャンル", - "HeaderAlbumArtists": "アルバムアーティスト", + "HeaderAlbumArtists": "アーティストのアルバム", "HeaderContinueWatching": "視聴を続ける", "HeaderFavoriteAlbums": "お気に入りのアルバム", "HeaderFavoriteArtists": "お気に入りのアーティスト", @@ -117,5 +117,7 @@ "TaskCleanActivityLog": "アクティビティの履歴を消去", "Undefined": "未定義", "Forced": "強制", - "Default": "デフォルト" + "Default": "デフォルト", + "TaskOptimizeDatabaseDescription": "データベースをコンパクトにして、空き領域を切り詰めます。メディアライブラリのスキャン後でこのタスクを実行するとパフォーマンスが向上する可能性があります。", + "TaskOptimizeDatabase": "データベースの最適化" } diff --git a/Emby.Server.Implementations/Localization/Core/kk.json b/Emby.Server.Implementations/Localization/Core/kk.json index 4eee369892..d28564a7c6 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": "Älbom oryndauşylary", + "HeaderAlbumArtists": "Oryndauşynyñ älbomy", "HeaderContinueWatching": "Qaraudy jalğastyru", "HeaderFavoriteAlbums": "Tañdauly älbomdar", "HeaderFavoriteArtists": "Tañdauly oryndauşylar", @@ -118,5 +118,7 @@ "TaskRefreshLibraryDescription": "Tasyğyşhanadağy jaña faildardy skanerleidі jäne metaderekterdı jañğyrtady.", "TaskRefreshChapterImagesDescription": "Sahnalary bar beineler üşın nobailar jasaidy.", "TaskCleanCacheDescription": "Jüiede qajet emes keştelgen faildardy joiady.", - "TaskCleanActivityLogDescription": "Äreket jūrnalyndağy teñşelgen jasynan asqan jazbalary joiady." + "TaskCleanActivityLogDescription": "Äreket jūrnalyndağy teñşelgen jasynan asqan jazbalary joiady.", + "TaskOptimizeDatabaseDescription": "Derekqordy qysyp, bos oryndy qysqartady. Būl tapsyrmany tasyğyşhanany skanerlegennen keiın nemese derekqorğa meñzeitın basqa özgertuler ıstelgennen keiın oryndau önımdılıktı damytuy mümkın.", + "TaskOptimizeDatabase": "Derekqordy oñtailandyru" } diff --git a/Emby.Server.Implementations/Localization/Core/ko.json b/Emby.Server.Implementations/Localization/Core/ko.json index 9179bbc8de..a37de07488 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": "즐겨찾는 아티스트", @@ -118,5 +118,7 @@ "TaskCleanActivityLog": "활동내역청소", "Undefined": "일치하지 않음", "Forced": "강제하기", - "Default": "기본 설정" + "Default": "기본 설정", + "TaskOptimizeDatabaseDescription": "데이터베이스를 압축하고 사용 가능한 공간을 늘립니다. 라이브러리를 검색한 후 이 작업을 실행하거나 데이터베이스 수정같은 비슷한 작업을 수행하면 성능이 향상될 수 있습니다.", + "TaskOptimizeDatabase": "데이터베이스 최적화" } diff --git a/Emby.Server.Implementations/Localization/Core/lv.json b/Emby.Server.Implementations/Localization/Core/lv.json index a46bdc3ded..443a74a10f 100644 --- a/Emby.Server.Implementations/Localization/Core/lv.json +++ b/Emby.Server.Implementations/Localization/Core/lv.json @@ -117,5 +117,7 @@ "TaskCleanActivityLogDescription": "Nodzēš darbību žurnāla ierakstus, kuri ir vecāki par doto vecumu.", "TaskCleanActivityLog": "Notīrīt Darbību Žurnālu", "Undefined": "Nenoteikts", - "Default": "Noklusējums" + "Default": "Noklusējums", + "TaskOptimizeDatabaseDescription": "Saspiež datubāzi un atbrīvo atmiņu. Uzdevum palaišana pēc bibliotēku skenēšanas vai citām, ar datubāzi saistītām, izmaiņām iespējams uzlabos ātrdarbību.", + "TaskOptimizeDatabase": "Optimizēt datubāzi" } diff --git a/Emby.Server.Implementations/Localization/Core/ml.json b/Emby.Server.Implementations/Localization/Core/ml.json index e764963cff..09ef349136 100644 --- a/Emby.Server.Implementations/Localization/Core/ml.json +++ b/Emby.Server.Implementations/Localization/Core/ml.json @@ -103,7 +103,7 @@ "ValueSpecialEpisodeName": "പ്രത്യേക - {0}", "Collections": "ശേഖരങ്ങൾ", "Folders": "ഫോൾഡറുകൾ", - "HeaderAlbumArtists": "ആൽബം ആർട്ടിസ്റ്റുകൾ", + "HeaderAlbumArtists": "കലാകാരന്റെ ആൽബം", "Sync": "സമന്വയിപ്പിക്കുക", "Movies": "സിനിമകൾ", "Photos": "ഫോട്ടോകൾ", @@ -117,5 +117,7 @@ "Favorites": "പ്രിയങ്കരങ്ങൾ", "Books": "പുസ്തകങ്ങൾ", "Genres": "വിഭാഗങ്ങൾ", - "Channels": "ചാനലുകൾ" + "Channels": "ചാനലുകൾ", + "TaskOptimizeDatabaseDescription": "ഡാറ്റാബേസ് ചുരുക്കുകയും സ്വതന്ത്ര ഇടം വെട്ടിച്ചുരുക്കുകയും ചെയ്യുന്നു. ലൈബ്രറി സ്‌കാൻ ചെയ്‌തതിനുശേഷം അല്ലെങ്കിൽ ഡാറ്റാബേസ് പരിഷ്‌ക്കരണങ്ങളെ സൂചിപ്പിക്കുന്ന മറ്റ് മാറ്റങ്ങൾ ചെയ്‌തതിന് ശേഷം ഈ ടാസ്‌ക് പ്രവർത്തിപ്പിക്കുന്നത് പ്രകടനം മെച്ചപ്പെടുത്തും.", + "TaskOptimizeDatabase": "ഡാറ്റാബേസ് ഒപ്റ്റിമൈസ് ചെയ്യുക" } diff --git a/Emby.Server.Implementations/Localization/Core/ms.json b/Emby.Server.Implementations/Localization/Core/ms.json index 5b4c8ae107..b2dcf270c1 100644 --- a/Emby.Server.Implementations/Localization/Core/ms.json +++ b/Emby.Server.Implementations/Localization/Core/ms.json @@ -5,7 +5,7 @@ "Artists": "Artis", "AuthenticationSucceededWithUserName": "{0} berjaya disahkan", "Books": "Buku-buku", - "CameraImageUploadedFrom": "Ada gambar dari kamera yang baru dimuat naik melalui {0}", + "CameraImageUploadedFrom": "Gambar baharu telah dimuat naik melalui {0}", "Channels": "Saluran", "ChapterNameValue": "Bab {0}", "Collections": "Koleksi", @@ -101,5 +101,13 @@ "Forced": "Paksa", "Default": "Asal", "TaskCleanCache": "Bersihkan Direktori Cache", - "TaskCleanActivityLogDescription": "Padamkan entri log aktiviti yang lebih tua daripada usia yang dikonfigurasi." + "TaskCleanActivityLogDescription": "Padamkan entri log aktiviti yang lebih tua daripada usia yang dikonfigurasi.", + "TaskRefreshPeople": "Segarkan Orang", + "TaskCleanLogsDescription": "Padamkan fail log yang berumur lebih dari {0} hari.", + "TaskCleanLogs": "Bersihkan Direktotri Log", + "TaskRefreshLibraryDescription": "Imbas perpustakaan media untuk mencari fail-fail baru dan menyegarkan metadata.", + "TaskRefreshLibrary": "Imbas Perpustakaan Media", + "TaskRefreshChapterImagesDescription": "Membuat gambaran kecil untuk video yang mempunyai bab.", + "TaskRefreshChapterImages": "Ekstrak Gambar-gambar Bab", + "TaskCleanCacheDescription": "Menghapuskan fail cache yang tidak lagi diperlukan oleh sistem." } diff --git a/Emby.Server.Implementations/Localization/Core/nb.json b/Emby.Server.Implementations/Localization/Core/nb.json index fbe1f7c4d5..317bdcfcb6 100644 --- a/Emby.Server.Implementations/Localization/Core/nb.json +++ b/Emby.Server.Implementations/Localization/Core/nb.json @@ -118,5 +118,7 @@ "Undefined": "Udefinert", "Forced": "Tvunget", "Default": "Standard", - "TaskCleanActivityLogDescription": "Sletter oppføringer i aktivitetsloggen som er eldre enn den konfigurerte alderen." + "TaskCleanActivityLogDescription": "Sletter oppføringer i aktivitetsloggen som er eldre enn den konfigurerte alderen.", + "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/nl.json b/Emby.Server.Implementations/Localization/Core/nl.json index 2973c8c6ea..79f921bcb2 100644 --- a/Emby.Server.Implementations/Localization/Core/nl.json +++ b/Emby.Server.Implementations/Localization/Core/nl.json @@ -1,7 +1,7 @@ { "Albums": "Albums", "AppDeviceValues": "App: {0}, Apparaat: {1}", - "Application": "Applicatie", + "Application": "Toepassing", "Artists": "Artiesten", "AuthenticationSucceededWithUserName": "{0} is succesvol geauthenticeerd", "Books": "Boeken", @@ -15,7 +15,7 @@ "Favorites": "Favorieten", "Folders": "Mappen", "Genres": "Genres", - "HeaderAlbumArtists": "Albumartiesten", + "HeaderAlbumArtists": "Artiests Album", "HeaderContinueWatching": "Kijken hervatten", "HeaderFavoriteAlbums": "Favoriete albums", "HeaderFavoriteArtists": "Favoriete artiesten", @@ -25,7 +25,7 @@ "HeaderLiveTV": "Live TV", "HeaderNextUp": "Volgende", "HeaderRecordingGroups": "Opnamegroepen", - "HomeVideos": "Home video's", + "HomeVideos": "Thuis video's", "Inherit": "Erven", "ItemAddedWithName": "{0} is toegevoegd aan de bibliotheek", "ItemRemovedWithName": "{0} is verwijderd uit de bibliotheek", @@ -92,11 +92,11 @@ "ValueHasBeenAddedToLibrary": "{0} is toegevoegd aan je mediabibliotheek", "ValueSpecialEpisodeName": "Speciaal - {0}", "VersionNumber": "Versie {0}", - "TaskDownloadMissingSubtitlesDescription": "Zoekt op het internet naar missende ondertitels gebaseerd op metadata configuratie.", - "TaskDownloadMissingSubtitles": "Download missende ondertitels", + "TaskDownloadMissingSubtitlesDescription": "Zoekt op het internet naar missende ondertiteling gebaseerd op metadata configuratie.", + "TaskDownloadMissingSubtitles": "Download missende ondertiteling", "TaskRefreshChannelsDescription": "Vernieuwt informatie van internet kanalen.", "TaskRefreshChannels": "Vernieuw Kanalen", - "TaskCleanTranscodeDescription": "Verwijder transcode bestanden ouder dan 1 dag.", + "TaskCleanTranscodeDescription": "Verwijdert transcode bestanden ouder dan 1 dag.", "TaskCleanLogs": "Log Folder Opschonen", "TaskCleanTranscode": "Transcode Folder Opschonen", "TaskUpdatePluginsDescription": "Download en installeert updates voor plugins waar automatisch updaten aan staat.", @@ -108,15 +108,17 @@ "TaskRefreshLibrary": "Scan Media Bibliotheek", "TaskRefreshChapterImagesDescription": "Maakt thumbnails aan voor videos met hoofdstukken.", "TaskRefreshChapterImages": "Hoofdstukafbeeldingen Uitpakken", - "TaskCleanCacheDescription": "Verwijder gecachte bestanden die het systeem niet langer nodig heeft.", + "TaskCleanCacheDescription": "Verwijdert gecachte bestanden die het systeem niet langer nodig heeft.", "TaskCleanCache": "Cache Folder Opschonen", "TasksChannelsCategory": "Internet Kanalen", "TasksApplicationCategory": "Applicatie", "TasksLibraryCategory": "Bibliotheek", "TasksMaintenanceCategory": "Onderhoud", - "TaskCleanActivityLogDescription": "Verwijder activiteiten logs ouder dan de ingestelde tijd.", + "TaskCleanActivityLogDescription": "Verwijdert activiteiten logs ouder dan de ingestelde tijd.", "TaskCleanActivityLog": "Leeg activiteiten logboek", "Undefined": "Niet gedefinieerd", "Forced": "Geforceerd", - "Default": "Standaard" + "Default": "Standaard", + "TaskOptimizeDatabaseDescription": "Comprimeert de database en trimt vrije ruimte. Het uitvoeren van deze taak kan de prestaties verbeteren, na het scannen van de bibliotheek of andere aanpassingen die invloed hebben op de database.", + "TaskOptimizeDatabase": "Database optimaliseren" } diff --git a/Emby.Server.Implementations/Localization/Core/pl.json b/Emby.Server.Implementations/Localization/Core/pl.json index e3da96a85a..e8a32a13e8 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": "Wykonawcy albumów", + "HeaderAlbumArtists": "Album artysty", "HeaderContinueWatching": "Kontynuuj odtwarzanie", "HeaderFavoriteAlbums": "Ulubione albumy", "HeaderFavoriteArtists": "Ulubieni wykonawcy", @@ -25,7 +25,7 @@ "HeaderLiveTV": "Telewizja", "HeaderNextUp": "Do obejrzenia", "HeaderRecordingGroups": "Grupy nagrań", - "HomeVideos": "Nagrania prywatne", + "HomeVideos": "Nagrania domowe", "Inherit": "Dziedzicz", "ItemAddedWithName": "{0} zostało dodane do biblioteki", "ItemRemovedWithName": "{0} zostało usunięte z biblioteki", @@ -118,5 +118,7 @@ "TaskCleanActivityLog": "Czyść dziennik aktywności", "Undefined": "Nieustalony", "Forced": "Wymuszony", - "Default": "Domyślne" + "Default": "Domyślne", + "TaskOptimizeDatabase": "Optymalizuj bazę danych", + "TaskOptimizeDatabaseDescription": "Kompaktuje bazę danych i obcina wolne miejsce. Uruchomienie tego zadania po przeskanowaniu biblioteki lub dokonaniu innych zmian, które pociągają za sobą modyfikacje bazy danych, może poprawić wydajność." } diff --git a/Emby.Server.Implementations/Localization/Core/pr.json b/Emby.Server.Implementations/Localization/Core/pr.json new file mode 100644 index 0000000000..e3a3bfaf1c --- /dev/null +++ b/Emby.Server.Implementations/Localization/Core/pr.json @@ -0,0 +1,5 @@ +{ + "Books": "Libros", + "AuthenticationSucceededWithUserName": "{0} autentificado correctamente", + "Artists": "Artistas" +} diff --git a/Emby.Server.Implementations/Localization/Core/pt-BR.json b/Emby.Server.Implementations/Localization/Core/pt-BR.json index 323dcced07..be71289b1b 100644 --- a/Emby.Server.Implementations/Localization/Core/pt-BR.json +++ b/Emby.Server.Implementations/Localization/Core/pt-BR.json @@ -118,5 +118,7 @@ "TaskCleanActivityLog": "Limpar Registro de Atividades", "Undefined": "Indefinido", "Forced": "Forçado", - "Default": "Padrão" + "Default": "Padrão", + "TaskOptimizeDatabaseDescription": "Compactar base de dados e liberar espaço livre. Executar esta tarefa após realizar mudanças que impliquem em modificações da base de dados pode trazer melhorias de 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 f1a78b2d32..a9dbd53ea2 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": "Videoclips", + "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,13 +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." + "TaskCleanActivityLogDescription": "Apaga itens no registro com idade acima do que é configurado.", + "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/ru.json b/Emby.Server.Implementations/Localization/Core/ru.json index e58f8c39d0..cd016b51b4 100644 --- a/Emby.Server.Implementations/Localization/Core/ru.json +++ b/Emby.Server.Implementations/Localization/Core/ru.json @@ -25,7 +25,7 @@ "HeaderLiveTV": "Эфир", "HeaderNextUp": "Очередное", "HeaderRecordingGroups": "Группы записей", - "HomeVideos": "Домашнее видео", + "HomeVideos": "Домашние видео", "Inherit": "Наследуемое", "ItemAddedWithName": "{0} - добавлено в медиатеку", "ItemRemovedWithName": "{0} - изъято из медиатеки", @@ -118,5 +118,7 @@ "TaskCleanActivityLog": "Очистить журнал активности", "Undefined": "Не определено", "Forced": "Форсир-ые", - "Default": "По умолчанию" + "Default": "По умолчанию", + "TaskOptimizeDatabaseDescription": "Сжимает базу данных и обрезает свободное место. Выполнение этой задачи после сканирования библиотеки или внесения других изменений, предполагающих модификации базы данных, может повысить производительность.", + "TaskOptimizeDatabase": "Оптимизировать базу данных" } diff --git a/Emby.Server.Implementations/Localization/Core/sk.json b/Emby.Server.Implementations/Localization/Core/sk.json index 99fbd39540..ad90bd8134 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é videoklipy", + "MusicVideos": "Hudobné videá", "NameInstallFailed": "Inštalácia {0} zlyhala", "NameSeasonNumber": "Séria {0}", "NameSeasonUnknown": "Neznáma séria", @@ -118,5 +118,7 @@ "TaskCleanActivityLog": "Vyčistiť log aktivít", "Undefined": "Nedefinované", "Forced": "Vynútené", - "Default": "Predvolené" + "Default": "Predvolené", + "TaskOptimizeDatabaseDescription": "Zmenší databázu a odstráni prázdne miesto. Spustenie tejto úlohy po skenovaní knižnice alebo po iných zmenách zahŕňajúcich úpravy databáze môže zlepšiť výkon.", + "TaskOptimizeDatabase": "Optimalizovať databázu" } diff --git a/Emby.Server.Implementations/Localization/Core/sl-SI.json b/Emby.Server.Implementations/Localization/Core/sl-SI.json index 343e067b79..a6fcbd3e29 100644 --- a/Emby.Server.Implementations/Localization/Core/sl-SI.json +++ b/Emby.Server.Implementations/Localization/Core/sl-SI.json @@ -16,7 +16,7 @@ "Folders": "Mape", "Genres": "Zvrsti", "HeaderAlbumArtists": "Izvajalci albuma", - "HeaderContinueWatching": "Nadaljuj z ogledom", + "HeaderContinueWatching": "Nadaljuj ogled", "HeaderFavoriteAlbums": "Priljubljeni albumi", "HeaderFavoriteArtists": "Priljubljeni izvajalci", "HeaderFavoriteEpisodes": "Priljubljene epizode", @@ -90,7 +90,7 @@ "UserStartedPlayingItemWithValues": "{0} predvaja {1} na {2}", "UserStoppedPlayingItemWithValues": "{0} je nehal predvajati {1} na {2}", "ValueHasBeenAddedToLibrary": "{0} je bil dodan vaši knjižnici", - "ValueSpecialEpisodeName": "Posebna - {0}", + "ValueSpecialEpisodeName": "Bonus - {0}", "VersionNumber": "Različica {0}", "TaskDownloadMissingSubtitles": "Prenesi manjkajoče podnapise", "TaskRefreshChannelsDescription": "Osveži podatke spletnih kanalov.", @@ -118,5 +118,7 @@ "TaskCleanActivityLog": "Počisti dnevnik aktivnosti", "Undefined": "Nedoločen", "Forced": "Prisilno", - "Default": "Privzeto" + "Default": "Privzeto", + "TaskOptimizeDatabaseDescription": "Stisne bazo podatkov in uredi prazen prostor. Zagon tega opravila po iskanju predstavnosti ali drugih spremembah ki vplivajo na bazo podatkov lahko izboljša hitrost delovanja.", + "TaskOptimizeDatabase": "Optimiziraj bazo podatkov" } diff --git a/Emby.Server.Implementations/Localization/Core/sq.json b/Emby.Server.Implementations/Localization/Core/sq.json index 0d909b06e9..066ef55878 100644 --- a/Emby.Server.Implementations/Localization/Core/sq.json +++ b/Emby.Server.Implementations/Localization/Core/sq.json @@ -42,8 +42,8 @@ "Sync": "Sinkronizo", "SubtitleDownloadFailureFromForItem": "Titrat deshtuan të shkarkohen nga {0} për {1}", "StartupEmbyServerIsLoading": "Serveri Jellyfin po ngarkohet. Ju lutemi provoni përseri pas pak.", - "Songs": "Këngë", - "Shows": "Seriale", + "Songs": "Këngët", + "Shows": "Serialet", "ServerNameNeedsToBeRestarted": "{0} duhet të ristartoj", "ScheduledTaskStartedWithName": "{0} filloi", "ScheduledTaskFailedWithName": "{0} dështoi", @@ -76,7 +76,7 @@ "NameInstallFailed": "Instalimi i {0} dështoi", "MusicVideos": "Video muzikore", "Music": "Muzikë", - "Movies": "Filma", + "Movies": "Filmat", "MixedContent": "Përmbajtje e përzier", "MessageServerConfigurationUpdated": "Konfigurimet e serverit u përditësuan", "MessageNamedServerConfigurationUpdatedWithValue": "Seksioni i konfigurimit të serverit {0} u përditësua", @@ -96,21 +96,28 @@ "HeaderFavoriteArtists": "Artistët e preferuar", "HeaderFavoriteAlbums": "Albumet e preferuar", "HeaderContinueWatching": "Vazhdo të shikosh", - "HeaderAlbumArtists": "Artistët e albumeve", - "Genres": "Zhanre", - "Folders": "Dosje", - "Favorites": "Të preferuara", + "HeaderAlbumArtists": "Artistët e Albumeve", + "Genres": "Zhanret", + "Folders": "Skedarët", + "Favorites": "Të preferuarat", "FailedLoginAttemptWithUserName": "Përpjekja për hyrje dështoi nga {0}", "DeviceOnlineWithName": "{0} u lidh", "DeviceOfflineWithName": "{0} u shkëput", - "Collections": "Koleksione", + "Collections": "Koleksionet", "ChapterNameValue": "Kapituj", - "Channels": "Kanale", + "Channels": "Kanalet", "CameraImageUploadedFrom": "Një foto e re nga kamera u ngarkua nga {0}", - "Books": "Libra", + "Books": "Librat", "AuthenticationSucceededWithUserName": "{0} u identifikua me sukses", - "Artists": "Artistë", + "Artists": "Artistët", "Application": "Aplikacioni", "AppDeviceValues": "Aplikacioni: {0}, Pajisja: {1}", - "Albums": "Albume" + "Albums": "Albumet", + "TaskCleanActivityLogDescription": "Pastro të dhënat mbi aktivitetin më të vjetra sesa koha e përcaktuar.", + "TaskCleanActivityLog": "Pastro të dhënat mbi aktivitetin", + "Undefined": "I papërcaktuar", + "Forced": "I detyruar", + "Default": "Parazgjedhur", + "TaskOptimizeDatabaseDescription": "Kompakton bazën e të dhënave dhe shkurton hapësirën e lirë. Drejtimi i kësaj detyre pasi skanoni bibliotekën ose bëni ndryshime të tjera që nënkuptojnë modifikime të bazës së të dhënave mund të përmirësojë performancën.", + "TaskOptimizeDatabase": "Optimizo databazën" } diff --git a/Emby.Server.Implementations/Localization/Core/sv.json b/Emby.Server.Implementations/Localization/Core/sv.json index d992bf79b1..6c772c6a24 100644 --- a/Emby.Server.Implementations/Localization/Core/sv.json +++ b/Emby.Server.Implementations/Localization/Core/sv.json @@ -15,7 +15,7 @@ "Favorites": "Favoriter", "Folders": "Mappar", "Genres": "Genrer", - "HeaderAlbumArtists": "Albumartister", + "HeaderAlbumArtists": "Artistens album", "HeaderContinueWatching": "Fortsätt kolla", "HeaderFavoriteAlbums": "Favoritalbum", "HeaderFavoriteArtists": "Favoritartister", @@ -25,7 +25,7 @@ "HeaderLiveTV": "Live-TV", "HeaderNextUp": "Nästa", "HeaderRecordingGroups": "Inspelningsgrupper", - "HomeVideos": "Hemvideor", + "HomeVideos": "Hemmavideor", "Inherit": "Ärv", "ItemAddedWithName": "{0} lades till i biblioteket", "ItemRemovedWithName": "{0} togs bort från biblioteket", @@ -118,5 +118,7 @@ "TaskCleanActivityLog": "Rensa Aktivitets Logg", "Undefined": "odefinierad", "Forced": "Tvingad", - "Default": "Standard" + "Default": "Standard", + "TaskOptimizeDatabase": "Optimera databasen", + "TaskOptimizeDatabaseDescription": "Komprimerar databasen och trunkerar ledigt utrymme. Prestandan kan förbättras genom att köra denna task efter att du har skannat biblioteket eller gjort andra förändringar som indikerar att databasen har modifierats." } diff --git a/Emby.Server.Implementations/Localization/Core/ta.json b/Emby.Server.Implementations/Localization/Core/ta.json index 129986ed0e..d6e9aa8e5a 100644 --- a/Emby.Server.Implementations/Localization/Core/ta.json +++ b/Emby.Server.Implementations/Localization/Core/ta.json @@ -117,5 +117,7 @@ "TaskCleanActivityLog": "செயல்பாட்டு பதிவை அழி", "Undefined": "வரையறுக்கப்படாத", "Forced": "கட்டாயப்படுத்தப்பட்டது", - "Default": "இயல்புநிலை" + "Default": "இயல்புநிலை", + "TaskOptimizeDatabaseDescription": "தரவுத்தளத்தை சுருக்கி, இலவச இடத்தை குறைக்கிறது. நூலகத்தை ஸ்கேன் செய்தபின் அல்லது தரவுத்தள மாற்றங்களைக் குறிக்கும் பிற மாற்றங்களைச் செய்தபின் இந்த பணியை இயக்குவது செயல்திறனை மேம்படுத்தக்கூடும்.", + "TaskOptimizeDatabase": "தரவுத்தளத்தை மேம்படுத்தவும்" } diff --git a/Emby.Server.Implementations/Localization/Core/tr.json b/Emby.Server.Implementations/Localization/Core/tr.json index c6b904045b..e661299c41 100644 --- a/Emby.Server.Implementations/Localization/Core/tr.json +++ b/Emby.Server.Implementations/Localization/Core/tr.json @@ -25,7 +25,7 @@ "HeaderLiveTV": "Canlı TV", "HeaderNextUp": "Gelecek Hafta", "HeaderRecordingGroups": "Kayıt Grupları", - "HomeVideos": "Ev videoları", + "HomeVideos": "Ana sayfa videoları", "Inherit": "Devral", "ItemAddedWithName": "{0} kütüphaneye eklendi", "ItemRemovedWithName": "{0} kütüphaneden silindi", @@ -43,7 +43,7 @@ "NameInstallFailed": "{0} kurulumu başarısız", "NameSeasonNumber": "Sezon {0}", "NameSeasonUnknown": "Bilinmeyen Sezon", - "NewVersionIsAvailable": "Jellyfin Sunucusunun yeni bir versiyonu indirmek için hazır.", + "NewVersionIsAvailable": "Jellyfin Sunucusunun yeni bir sürümü indirmek için hazır.", "NotificationOptionApplicationUpdateAvailable": "Uygulama güncellemesi mevcut", "NotificationOptionApplicationUpdateInstalled": "Uygulama güncellemesi yüklendi", "NotificationOptionAudioPlayback": "Ses çalma başladı", @@ -75,7 +75,7 @@ "StartupEmbyServerIsLoading": "Jellyfin Sunucusu yükleniyor. Lütfen kısa süre sonra tekrar deneyin.", "SubtitleDownloadFailureForItem": "Subtitles failed to download for {0}", "SubtitleDownloadFailureFromForItem": "{1} için alt yazılar {0} 'dan indirilemedi", - "Sync": "Eşitle", + "Sync": "Eşzamanlama", "System": "Sistem", "TvShows": "Diziler", "User": "Kullanıcı", @@ -89,34 +89,36 @@ "UserPolicyUpdatedWithName": "Kullanıcı politikası {0} için güncellendi", "UserStartedPlayingItemWithValues": "{0}, {2} cihazında {1} izliyor", "UserStoppedPlayingItemWithValues": "{0}, {2} cihazında {1} izlemeyi bitirdi", - "ValueHasBeenAddedToLibrary": "Medya kitaplığınıza {0} eklendi", + "ValueHasBeenAddedToLibrary": "Medya kütüphanenize {0} eklendi", "ValueSpecialEpisodeName": "Özel - {0}", - "VersionNumber": "Versiyon {0}", + "VersionNumber": "Sürüm {0}", "TaskCleanCache": "Geçici dosya klasörünü temizle", "TasksChannelsCategory": "İnternet kanalları", "TasksApplicationCategory": "Uygulama", "TasksLibraryCategory": "Kütüphane", - "TasksMaintenanceCategory": "Onarım", + "TasksMaintenanceCategory": "Bakım", "TaskRefreshPeopleDescription": "Medya kütüphanenizdeki videoların oyuncu ve yönetmen bilgilerini günceller.", "TaskDownloadMissingSubtitlesDescription": "Metadata ayarlarını baz alarak eksik altyazıları internette arar.", "TaskDownloadMissingSubtitles": "Eksik altyazıları indir", "TaskRefreshChannelsDescription": "Internet kanal bilgilerini yenile.", "TaskRefreshChannels": "Kanalları Yenile", - "TaskCleanTranscodeDescription": "Bir günü dolmuş dönüştürme bilgisi içeren dosyaları siler.", + "TaskCleanTranscodeDescription": "Bir günden daha eski dönüştürme dosyalarını siler.", "TaskCleanTranscode": "Dönüşüm Dizinini Temizle", "TaskUpdatePluginsDescription": "Otomatik güncellenmeye ayarlanmış eklentilerin güncellemelerini indirir ve kurar.", "TaskUpdatePlugins": "Eklentileri Güncelle", "TaskRefreshPeople": "Kullanıcıları Yenile", - "TaskCleanLogsDescription": "{0} günden eski log dosyalarını siler.", - "TaskCleanLogs": "Log Dizinini Temizle", - "TaskRefreshLibraryDescription": "Medya kütüphanenize eklenen yeni dosyaları arar ve bilgileri yeniler.", + "TaskCleanLogsDescription": "{0} günden eski günlük dosyalarını siler.", + "TaskCleanLogs": "Günlük Dizinini Temizle", + "TaskRefreshLibraryDescription": "Medya kütüphanenize eklenen yeni dosyaları arar ve ortam bilgilerini yeniler.", "TaskRefreshLibrary": "Medya Kütüphanesini Tara", "TaskRefreshChapterImagesDescription": "Sahnelere ayrılmış videolar için küçük resimler oluştur.", "TaskRefreshChapterImages": "Bölüm Resimlerini Çıkar", "TaskCleanCacheDescription": "Sistem tarafından artık ihtiyaç duyulmayan önbellek dosyalarını siler.", - "TaskCleanActivityLog": "İşlem Günlüğünü Temizle", - "TaskCleanActivityLogDescription": "Belirtilen sureden daha eski etkinlik log kayıtları silindi.", + "TaskCleanActivityLog": "Etkinlik Günlüğünü Temizle", + "TaskCleanActivityLogDescription": "Yapılandırılan tarihten daha eski olan etkinlik günlüğü girişlerini siler.", "Undefined": "Bilinmeyen", "Default": "Varsayılan", - "Forced": "Zorla" + "Forced": "Zorla", + "TaskOptimizeDatabaseDescription": "Veritabanını sıkıştırır ve boş alanı keser. Kitaplığı taradıktan sonra veya veritabanında değişiklik anlamına gelen diğer işlemleri yaptıktan sonra bu görevi çalıştırmak performansı artırabilir.", + "TaskOptimizeDatabase": "Veritabanını optimize et" } diff --git a/Emby.Server.Implementations/Localization/Core/uk.json b/Emby.Server.Implementations/Localization/Core/uk.json index 5a2069df56..e99ed6f0b9 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}", @@ -16,7 +16,7 @@ "HeaderFavoriteArtists": "Улюблені виконавці", "HeaderFavoriteAlbums": "Улюблені альбоми", "HeaderContinueWatching": "Продовжити перегляд", - "HeaderAlbumArtists": "Виконавці альбому", + "HeaderAlbumArtists": "Виконавці альбомів", "Genres": "Жанри", "Folders": "Каталоги", "Favorites": "Улюблені", @@ -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 5d6d0775c4..11af9fc987 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,19 @@ "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": "جَبری" } diff --git a/Emby.Server.Implementations/Localization/Core/vi.json b/Emby.Server.Implementations/Localization/Core/vi.json index 58652c4693..33aa0eea07 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": "Tuyển Tập 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ĩ", + "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", @@ -82,7 +82,7 @@ "NameSeasonUnknown": "Không Rõ Mùa", "NameSeasonNumber": "Phần {0}", "NameInstallFailed": "{0} cài đặt thất bại", - "MusicVideos": "Video Nhạc", + "MusicVideos": "Videos Nhạc", "Music": "Nhạc", "MixedContent": "Nội dung hỗn hợp", "MessageServerConfigurationUpdated": "Cấu hình máy chủ đã được cập nhật", @@ -117,5 +117,7 @@ "TaskCleanActivityLog": "Xóa Nhật Ký Hoạt Động", "Undefined": "Không Xác Định", "Forced": "Bắt Buộc", - "Default": "Mặc Định" + "Default": "Mặc Định", + "TaskOptimizeDatabaseDescription": "Thu gọn cơ sở dữ liệu và cắt bớt dung lượng trống. Chạy tác vụ này sau khi quét thư viện hoặc thực hiện các thay đổi khác ngụ ý sửa đổi cơ sở dữ liệu có thể cải thiện hiệu suất.", + "TaskOptimizeDatabase": "Tối ưu hóa cơ sở dữ liệu" } diff --git a/Emby.Server.Implementations/Localization/Core/zh-CN.json b/Emby.Server.Implementations/Localization/Core/zh-CN.json index 12803456e3..f9df627245 100644 --- a/Emby.Server.Implementations/Localization/Core/zh-CN.json +++ b/Emby.Server.Implementations/Localization/Core/zh-CN.json @@ -7,7 +7,7 @@ "Books": "书籍", "CameraImageUploadedFrom": "新的相机图像已从 {0} 上传", "Channels": "频道", - "ChapterNameValue": "第 {0} 集", + "ChapterNameValue": "章节 {0}", "Collections": "合集", "DeviceOfflineWithName": "{0} 已断开", "DeviceOnlineWithName": "{0} 已连接", @@ -15,8 +15,8 @@ "Favorites": "我的最爱", "Folders": "文件夹", "Genres": "风格", - "HeaderAlbumArtists": "专辑作家", - "HeaderContinueWatching": "继续观影", + "HeaderAlbumArtists": "专辑艺术家", + "HeaderContinueWatching": "继续观看", "HeaderFavoriteAlbums": "收藏的专辑", "HeaderFavoriteArtists": "最爱的艺术家", "HeaderFavoriteEpisodes": "最爱的剧集", @@ -108,8 +108,8 @@ "TaskCleanLogs": "清理日志目录", "TaskRefreshLibraryDescription": "扫描你的媒体库以获取新文件并刷新元数据。", "TaskRefreshLibrary": "扫描媒体库", - "TaskRefreshChapterImagesDescription": "为包含剧集的视频提取缩略图。", - "TaskRefreshChapterImages": "提取剧集图片", + "TaskRefreshChapterImagesDescription": "为包含章节的视频提取缩略图。", + "TaskRefreshChapterImages": "提取章节图片", "TaskCleanCacheDescription": "删除系统不再需要的缓存文件。", "TaskCleanCache": "清理缓存目录", "TasksApplicationCategory": "应用程序", @@ -118,5 +118,7 @@ "TaskCleanActivityLogDescription": "删除早于设置时间的活动日志条目。", "Undefined": "未定义", "Forced": "强制的", - "Default": "默认" + "Default": "默认", + "TaskOptimizeDatabaseDescription": "压缩数据库并优化可用空间,在扫描库或执行其他数据库修改后运行此任务可能会提高性能。", + "TaskOptimizeDatabase": "优化数据库" } diff --git a/Emby.Server.Implementations/Localization/Core/zh-HK.json b/Emby.Server.Implementations/Localization/Core/zh-HK.json index 3dad21dcbc..1cc97bc27e 100644 --- a/Emby.Server.Implementations/Localization/Core/zh-HK.json +++ b/Emby.Server.Implementations/Localization/Core/zh-HK.json @@ -13,7 +13,7 @@ "DeviceOnlineWithName": "{0} 已經連接", "FailedLoginAttemptWithUserName": "來自 {0} 的登入失敗", "Favorites": "我的最愛", - "Folders": "檔案夾", + "Folders": "資料夾", "Genres": "風格", "HeaderAlbumArtists": "專輯藝人", "HeaderContinueWatching": "繼續觀看", @@ -39,7 +39,7 @@ "MixedContent": "混合內容", "Movies": "電影", "Music": "音樂", - "MusicVideos": "音樂視頻", + "MusicVideos": "音樂影片", "NameInstallFailed": "{0} 安裝失敗", "NameSeasonNumber": "第 {0} 季", "NameSeasonUnknown": "未知季數", @@ -117,5 +117,8 @@ "TaskCleanActivityLog": "清理活動記錄", "Undefined": "未定義", "Forced": "強制", - "Default": "預設" + "Default": "預設", + "TaskOptimizeDatabaseDescription": "壓縮數據庫並截斷可用空間。在掃描媒體庫或執行其他數據庫的修改後運行此任務可能會提高性能。", + "TaskOptimizeDatabase": "最佳化數據庫", + "TaskCleanActivityLogDescription": "刪除早於設定時間的日誌記錄。" } diff --git a/Emby.Server.Implementations/Localization/Core/zh-TW.json b/Emby.Server.Implementations/Localization/Core/zh-TW.json index c3b223f631..585d81450a 100644 --- a/Emby.Server.Implementations/Localization/Core/zh-TW.json +++ b/Emby.Server.Implementations/Localization/Core/zh-TW.json @@ -24,7 +24,7 @@ "HeaderFavoriteSongs": "最愛歌曲", "HeaderLiveTV": "電視直播", "HeaderNextUp": "接下來", - "HomeVideos": "自製影片", + "HomeVideos": "家庭影片", "ItemAddedWithName": "{0} 已新增至媒體庫", "ItemRemovedWithName": "{0} 已從媒體庫移除", "LabelIpAddressValue": "IP 位址:{0}", @@ -117,5 +117,7 @@ "TaskCleanActivityLog": "清除活動紀錄", "Undefined": "未定義的", "Forced": "強制", - "Default": "原本" + "Default": "原本", + "TaskOptimizeDatabaseDescription": "縮小資料庫並釋放可用空間。在掃描資料庫或進行資料庫相關的更動後使用此功能會增加效能。", + "TaskOptimizeDatabase": "最佳化資料庫" } diff --git a/Emby.Server.Implementations/Localization/LocalizationManager.cs b/Emby.Server.Implementations/Localization/LocalizationManager.cs index a9e3bfdb0e..03919197e2 100644 --- a/Emby.Server.Implementations/Localization/LocalizationManager.cs +++ b/Emby.Server.Implementations/Localization/LocalizationManager.cs @@ -1,5 +1,3 @@ -#nullable disable - using System; using System.Collections.Concurrent; using System.Collections.Generic; @@ -23,6 +21,9 @@ namespace Emby.Server.Implementations.Localization public class LocalizationManager : ILocalizationManager { private const string DefaultCulture = "en-US"; + private const string RatingsPath = "Emby.Server.Implementations.Localization.Ratings."; + private const string CulturesPath = "Emby.Server.Implementations.Localization.iso6392.txt"; + private const string CountriesPath = "Emby.Server.Implementations.Localization.countries.json"; private static readonly Assembly _assembly = typeof(LocalizationManager).Assembly; private static readonly string[] _unratedValues = { "n/a", "unrated", "not rated" }; @@ -35,10 +36,10 @@ namespace Emby.Server.Implementations.Localization private readonly ConcurrentDictionary<string, Dictionary<string, string>> _dictionaries = new ConcurrentDictionary<string, Dictionary<string, string>>(StringComparer.OrdinalIgnoreCase); - private List<CultureDto> _cultures; - private readonly JsonSerializerOptions _jsonOptions = JsonDefaults.Options; + private List<CultureDto> _cultures = new List<CultureDto>(); + /// <summary> /// Initializes a new instance of the <see cref="LocalizationManager" /> class. /// </summary> @@ -58,43 +59,39 @@ namespace Emby.Server.Implementations.Localization /// <returns><see cref="Task" />.</returns> public async Task LoadAll() { - const string RatingsResource = "Emby.Server.Implementations.Localization.Ratings."; - // Extract from the assembly foreach (var resource in _assembly.GetManifestResourceNames()) { - if (!resource.StartsWith(RatingsResource, StringComparison.Ordinal)) + if (!resource.StartsWith(RatingsPath, StringComparison.Ordinal)) { continue; } - string countryCode = resource.Substring(RatingsResource.Length, 2); + string countryCode = resource.Substring(RatingsPath.Length, 2); var dict = new Dictionary<string, ParentalRating>(StringComparer.OrdinalIgnoreCase); - using (var str = _assembly.GetManifestResourceStream(resource)) - using (var reader = new StreamReader(str)) + await using var stream = _assembly.GetManifestResourceStream(resource); + using var reader = new StreamReader(stream!); // shouldn't be null here, we just got the resource path from Assembly.GetManifestResourceNames() + await foreach (var line in reader.ReadAllLinesAsync().ConfigureAwait(false)) { - await foreach (var line in reader.ReadAllLinesAsync().ConfigureAwait(false)) + if (string.IsNullOrWhiteSpace(line)) { - if (string.IsNullOrWhiteSpace(line)) - { - continue; - } - - string[] parts = line.Split(','); - if (parts.Length == 2 - && int.TryParse(parts[1], NumberStyles.Integer, CultureInfo.InvariantCulture, out var value)) - { - var name = parts[0]; - dict.Add(name, new ParentalRating(name, value)); - } + continue; + } + + string[] parts = line.Split(','); + if (parts.Length == 2 + && int.TryParse(parts[1], NumberStyles.Integer, CultureInfo.InvariantCulture, out var value)) + { + var name = parts[0]; + dict.Add(name, new ParentalRating(name, value)); + } #if DEBUG - else - { - _logger.LogWarning("Malformed line in ratings file for country {CountryCode}", countryCode); - } -#endif + else + { + _logger.LogWarning("Malformed line in ratings file for country {CountryCode}", countryCode); } +#endif } _allParentalRatings[countryCode] = dict; @@ -114,52 +111,49 @@ namespace Emby.Server.Implementations.Localization { List<CultureDto> list = new List<CultureDto>(); - const string ResourcePath = "Emby.Server.Implementations.Localization.iso6392.txt"; - - using (var stream = _assembly.GetManifestResourceStream(ResourcePath)) - using (var reader = new StreamReader(stream)) + await using var stream = _assembly.GetManifestResourceStream(CulturesPath) + ?? throw new InvalidOperationException($"Invalid resource path: '{CulturesPath}'"); + using var reader = new StreamReader(stream); + await foreach (var line in reader.ReadAllLinesAsync().ConfigureAwait(false)) { - await foreach (var line in reader.ReadAllLinesAsync().ConfigureAwait(false)) + if (string.IsNullOrWhiteSpace(line)) { - if (string.IsNullOrWhiteSpace(line)) + continue; + } + + var parts = line.Split('|'); + + if (parts.Length == 5) + { + string name = parts[3]; + if (string.IsNullOrWhiteSpace(name)) { continue; } - var parts = line.Split('|'); + string twoCharName = parts[2]; + if (string.IsNullOrWhiteSpace(twoCharName)) + { + continue; + } - if (parts.Length == 5) + string[] threeletterNames; + if (string.IsNullOrWhiteSpace(parts[1])) { - string name = parts[3]; - if (string.IsNullOrWhiteSpace(name)) - { - continue; - } - - string twoCharName = parts[2]; - if (string.IsNullOrWhiteSpace(twoCharName)) - { - continue; - } - - string[] threeletterNames; - if (string.IsNullOrWhiteSpace(parts[1])) - { - threeletterNames = new[] { parts[0] }; - } - else - { - threeletterNames = new[] { parts[0], parts[1] }; - } - - list.Add(new CultureDto - { - DisplayName = name, - Name = name, - ThreeLetterISOLanguageNames = threeletterNames, - TwoLetterISOLanguageName = twoCharName - }); + threeletterNames = new[] { parts[0] }; } + else + { + threeletterNames = new[] { parts[0], parts[1] }; + } + + list.Add(new CultureDto + { + DisplayName = name, + Name = name, + ThreeLetterISOLanguageNames = threeletterNames, + TwoLetterISOLanguageName = twoCharName + }); } } @@ -167,7 +161,7 @@ namespace Emby.Server.Implementations.Localization } /// <inheritdoc /> - public CultureDto FindLanguageInfo(string language) + public CultureDto? FindLanguageInfo(string language) { // TODO language should ideally be a ReadOnlySpan but moq cannot mock ref structs for (var i = 0; i < _cultures.Count; i++) @@ -188,9 +182,10 @@ namespace Emby.Server.Implementations.Localization /// <inheritdoc /> public IEnumerable<CountryInfo> GetCountries() { - using StreamReader reader = new StreamReader(_assembly.GetManifestResourceStream("Emby.Server.Implementations.Localization.countries.json")); - - return JsonSerializer.Deserialize<IEnumerable<CountryInfo>>(reader.ReadToEnd(), _jsonOptions); + using StreamReader reader = new StreamReader( + _assembly.GetManifestResourceStream(CountriesPath) ?? throw new InvalidOperationException($"Invalid resource path: '{CountriesPath}'")); + return JsonSerializer.Deserialize<IEnumerable<CountryInfo>>(reader.ReadToEnd(), _jsonOptions) + ?? throw new InvalidOperationException($"Resource contains invalid data: '{CountriesPath}'"); } /// <inheritdoc /> @@ -210,7 +205,9 @@ namespace Emby.Server.Implementations.Localization countryCode = "us"; } - return GetRatings(countryCode) ?? GetRatings("us"); + return GetRatings(countryCode) + ?? GetRatings("us") + ?? throw new InvalidOperationException($"Invalid resource path: '{CountriesPath}'"); } /// <summary> @@ -218,7 +215,7 @@ namespace Emby.Server.Implementations.Localization /// </summary> /// <param name="countryCode">The country code.</param> /// <returns>The ratings.</returns> - private Dictionary<string, ParentalRating> GetRatings(string countryCode) + private Dictionary<string, ParentalRating>? GetRatings(string countryCode) { _allParentalRatings.TryGetValue(countryCode, out var value); @@ -243,7 +240,7 @@ namespace Emby.Server.Implementations.Localization var ratingsDictionary = GetParentalRatingsDictionary(); - if (ratingsDictionary.TryGetValue(rating, out ParentalRating value)) + if (ratingsDictionary.TryGetValue(rating, out ParentalRating? value)) { return value.Value; } @@ -273,20 +270,6 @@ namespace Emby.Server.Implementations.Localization return null; } - /// <inheritdoc /> - public bool HasUnicodeCategory(string value, UnicodeCategory category) - { - foreach (var chr in value) - { - if (char.GetUnicodeCategory(chr) == category) - { - return true; - } - } - - return false; - } - /// <inheritdoc /> public string GetLocalizedString(string phrase) { @@ -350,22 +333,23 @@ namespace Emby.Server.Implementations.Localization private async Task CopyInto(IDictionary<string, string> dictionary, string resourcePath) { - using (var stream = _assembly.GetManifestResourceStream(resourcePath)) + await using var stream = _assembly.GetManifestResourceStream(resourcePath); + // If a Culture doesn't have a translation the stream will be null and it defaults to en-us further up the chain + if (stream == null) { - // If a Culture doesn't have a translation the stream will be null and it defaults to en-us further up the chain - if (stream != null) - { - var dict = await JsonSerializer.DeserializeAsync<Dictionary<string, string>>(stream, _jsonOptions).ConfigureAwait(false); + _logger.LogError("Missing translation/culture resource: {ResourcePath}", resourcePath); + return; + } - foreach (var key in dict.Keys) - { - dictionary[key] = dict[key]; - } - } - else - { - _logger.LogError("Missing translation/culture resource: {ResourcePath}", resourcePath); - } + var dict = await JsonSerializer.DeserializeAsync<Dictionary<string, string>>(stream, _jsonOptions).ConfigureAwait(false); + if (dict == null) + { + throw new InvalidOperationException($"Resource contains invalid data: '{stream}'"); + } + + foreach (var key in dict.Keys) + { + dictionary[key] = dict[key]; } } diff --git a/Emby.Server.Implementations/MediaEncoder/EncodingManager.cs b/Emby.Server.Implementations/MediaEncoder/EncodingManager.cs index 8aaa1f7bbe..ac6606d39a 100644 --- a/Emby.Server.Implementations/MediaEncoder/EncodingManager.cs +++ b/Emby.Server.Implementations/MediaEncoder/EncodingManager.cs @@ -23,7 +23,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; @@ -193,7 +192,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); } diff --git a/Emby.Server.Implementations/Net/SocketFactory.cs b/Emby.Server.Implementations/Net/SocketFactory.cs index 1377286168..6d0c8731e9 100644 --- a/Emby.Server.Implementations/Net/SocketFactory.cs +++ b/Emby.Server.Implementations/Net/SocketFactory.cs @@ -11,6 +11,7 @@ namespace Emby.Server.Implementations.Net { public class SocketFactory : ISocketFactory { + /// <inheritdoc /> public ISocket CreateUdpBroadcastSocket(int localPort) { if (localPort < 0) @@ -35,11 +36,8 @@ namespace Emby.Server.Implementations.Net } } - /// <summary> - /// Creates a new UDP acceptSocket that is a member of the SSDP multicast local admin group and binds it to the specified local port. - /// </summary> - /// <returns>An implementation of the <see cref="ISocket"/> interface used by RSSDP components to perform acceptSocket operations.</returns> - public ISocket CreateSsdpUdpSocket(IPAddress localIpAddress, int localPort) + /// <inheritdoc /> + public ISocket CreateSsdpUdpSocket(IPAddress localIp, int localPort) { if (localPort < 0) { @@ -53,8 +51,8 @@ namespace Emby.Server.Implementations.Net retVal.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true); retVal.SetSocketOption(SocketOptionLevel.IP, SocketOptionName.MulticastTimeToLive, 4); - retVal.SetSocketOption(SocketOptionLevel.IP, SocketOptionName.AddMembership, new MulticastOption(IPAddress.Parse("239.255.255.250"), localIpAddress)); - return new UdpSocket(retVal, localPort, localIpAddress); + retVal.SetSocketOption(SocketOptionLevel.IP, SocketOptionName.AddMembership, new MulticastOption(IPAddress.Parse("239.255.255.250"), localIp)); + return new UdpSocket(retVal, localPort, localIp); } catch { @@ -64,13 +62,7 @@ namespace Emby.Server.Implementations.Net } } - /// <summary> - /// Creates a new UDP acceptSocket that is a member of the specified multicast IP address, and binds it to the specified local port. - /// </summary> - /// <param name="ipAddress">The multicast IP address to make the acceptSocket a member of.</param> - /// <param name="multicastTimeToLive">The multicast time to live value for the acceptSocket.</param> - /// <param name="localPort">The number of the local port to bind to.</param> - /// <returns></returns> + /// <inheritdoc /> public ISocket CreateUdpMulticastSocket(string ipAddress, int multicastTimeToLive, int localPort) { if (ipAddress == null) diff --git a/Emby.Server.Implementations/Net/UdpSocket.cs b/Emby.Server.Implementations/Net/UdpSocket.cs index a8b18d2925..0c451ccb60 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() @@ -191,7 +191,7 @@ namespace Emby.Server.Implementations.Net return taskCompletion.Task; } - public Task SendToAsync(byte[] buffer, int offset, int size, IPEndPoint endPoint, CancellationToken cancellationToken) + public Task SendToAsync(byte[] buffer, int offset, int bytes, IPEndPoint endPoint, CancellationToken cancellationToken) { ThrowIfDisposed(); @@ -214,7 +214,7 @@ namespace Emby.Server.Implementations.Net } }; - var result = BeginSendTo(buffer, offset, size, endPoint, new AsyncCallback(callback), null); + var result = BeginSendTo(buffer, offset, bytes, endPoint, new AsyncCallback(callback), null); if (result.CompletedSynchronously) { diff --git a/Emby.Server.Implementations/Playlists/PlaylistManager.cs b/Emby.Server.Implementations/Playlists/PlaylistManager.cs index 9a1ca99467..b07798fa40 100644 --- a/Emby.Server.Implementations/Playlists/PlaylistManager.cs +++ b/Emby.Server.Implementations/Playlists/PlaylistManager.cs @@ -147,7 +147,7 @@ namespace Emby.Server.Implementations.Playlists playlist.SetMediaType(options.MediaType); - parentFolder.AddChild(playlist, CancellationToken.None); + parentFolder.AddChild(playlist); await playlist.RefreshMetadata(new MetadataRefreshOptions(new DirectoryService(_fileSystem)) { ForceSave = true }, CancellationToken.None) .ConfigureAwait(false); @@ -260,7 +260,7 @@ namespace Emby.Server.Implementations.Playlists public async Task RemoveFromPlaylistAsync(string playlistId, IEnumerable<string> entryIds) { - if (!(_libraryManager.GetItemById(playlistId) is Playlist playlist)) + if (_libraryManager.GetItemById(playlistId) is not Playlist playlist) { throw new ArgumentException("No Playlist exists with the supplied Id"); } @@ -293,7 +293,7 @@ namespace Emby.Server.Implementations.Playlists public async Task MoveItemAsync(string playlistId, string entryId, int newIndex) { - if (!(_libraryManager.GetItemById(playlistId) is Playlist playlist)) + if (_libraryManager.GetItemById(playlistId) is not Playlist playlist) { throw new ArgumentException("No Playlist exists with the supplied Id"); } diff --git a/Emby.Server.Implementations/Playlists/ManualPlaylistsFolder.cs b/Emby.Server.Implementations/Playlists/PlaylistsFolder.cs similarity index 93% rename from Emby.Server.Implementations/Playlists/ManualPlaylistsFolder.cs rename to Emby.Server.Implementations/Playlists/PlaylistsFolder.cs index 358606b0dc..8b1cee89d1 100644 --- a/Emby.Server.Implementations/Playlists/ManualPlaylistsFolder.cs +++ b/Emby.Server.Implementations/Playlists/PlaylistsFolder.cs @@ -17,6 +17,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 +36,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) @@ -49,5 +49,10 @@ namespace Emby.Server.Implementations.Playlists query.Parent = null; return LibraryManager.GetItemsResult(query); } + + public override string GetClientTypeName() + { + return "ManualPlaylistsFolder"; + } } } diff --git a/Emby.Server.Implementations/Plugins/PluginManager.cs b/Emby.Server.Implementations/Plugins/PluginManager.cs index fc0920edfa..d52c0b2a14 100644 --- a/Emby.Server.Implementations/Plugins/PluginManager.cs +++ b/Emby.Server.Implementations/Plugins/PluginManager.cs @@ -8,13 +8,14 @@ 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; +using MediaBrowser.Model.IO; using MediaBrowser.Model.Plugins; using MediaBrowser.Model.Updates; using Microsoft.Extensions.DependencyInjection; @@ -38,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> @@ -85,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> @@ -371,7 +372,7 @@ namespace Emby.Server.Implementations.Plugins var url = new Uri(packageInfo.ImageUrl); imagePath = Path.Join(path, url.Segments[^1]); - await using var fileStream = File.OpenWrite(imagePath); + await using var fileStream = AsyncFile.OpenWrite(imagePath); try { diff --git a/Emby.Server.Implementations/Properties/AssemblyInfo.cs b/Emby.Server.Implementations/Properties/AssemblyInfo.cs index cb7972173e..41c396ac1d 100644 --- a/Emby.Server.Implementations/Properties/AssemblyInfo.cs +++ b/Emby.Server.Implementations/Properties/AssemblyInfo.cs @@ -16,6 +16,7 @@ using System.Runtime.InteropServices; [assembly: AssemblyCulture("")] [assembly: NeutralResourcesLanguage("en")] [assembly: InternalsVisibleTo("Jellyfin.Server.Implementations.Tests")] +[assembly: InternalsVisibleTo("Emby.Server.Implementations.Fuzz")] // Setting ComVisible to false makes the types in this assembly not visible // to COM components. If you need to access a type in this assembly from diff --git a/Emby.Server.Implementations/QuickConnect/QuickConnectManager.cs b/Emby.Server.Implementations/QuickConnect/QuickConnectManager.cs index 6f9797969d..c81c269945 100644 --- a/Emby.Server.Implementations/QuickConnect/QuickConnectManager.cs +++ b/Emby.Server.Implementations/QuickConnect/QuickConnectManager.cs @@ -3,12 +3,13 @@ using System.Collections.Concurrent; using System.Globalization; using System.Linq; using System.Security.Cryptography; +using System.Threading.Tasks; using MediaBrowser.Common.Extensions; -using MediaBrowser.Controller; using MediaBrowser.Controller.Authentication; using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.Net; using MediaBrowser.Controller.QuickConnect; -using MediaBrowser.Controller.Security; +using MediaBrowser.Controller.Session; using MediaBrowser.Model.QuickConnect; using Microsoft.Extensions.Logging; @@ -17,13 +18,8 @@ namespace Emby.Server.Implementations.QuickConnect /// <summary> /// Quick connect implementation. /// </summary> - public class QuickConnectManager : IQuickConnect, IDisposable + public class QuickConnectManager : IQuickConnect { - /// <summary> - /// The name of internal access tokens. - /// </summary> - private const string TokenName = "QuickConnect"; - /// <summary> /// The length of user facing codes. /// </summary> @@ -34,13 +30,12 @@ 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, QuickConnectResult> _currentRequests = new (); + private readonly ConcurrentDictionary<string, (DateTime Timestamp, AuthenticationResult AuthenticationResult)> _authorizedSecrets = new (); private readonly IServerConfigurationManager _config; private readonly ILogger<QuickConnectManager> _logger; - private readonly IServerApplicationHost _appHost; - private readonly IAuthenticationRepository _authenticationRepository; + private readonly ISessionManager _sessionManager; /// <summary> /// Initializes a new instance of the <see cref="QuickConnectManager"/> class. @@ -48,18 +43,15 @@ namespace Emby.Server.Implementations.QuickConnect /// </summary> /// <param name="config">Configuration.</param> /// <param name="logger">Logger.</param> - /// <param name="appHost">Application host.</param> - /// <param name="authenticationRepository">Authentication repository.</param> + /// <param name="sessionManager">Session Manager.</param> public QuickConnectManager( IServerConfigurationManager config, ILogger<QuickConnectManager> logger, - IServerApplicationHost appHost, - IAuthenticationRepository authenticationRepository) + ISessionManager sessionManager) { _config = config; _logger = logger; - _appHost = appHost; - _authenticationRepository = authenticationRepository; + _sessionManager = sessionManager; } /// <inheritdoc /> @@ -77,14 +69,41 @@ namespace Emby.Server.Implementations.QuickConnect } /// <inheritdoc/> - public QuickConnectResult TryConnect() + public QuickConnectResult TryConnect(AuthorizationInfo authorizationInfo) { + if (string.IsNullOrEmpty(authorizationInfo.DeviceId)) + { + throw new ArgumentException(nameof(authorizationInfo.DeviceId) + " is required"); + } + + if (string.IsNullOrEmpty(authorizationInfo.Device)) + { + throw new ArgumentException(nameof(authorizationInfo.Device) + " is required"); + } + + if (string.IsNullOrEmpty(authorizationInfo.Client)) + { + throw new ArgumentException(nameof(authorizationInfo.Client) + " is required"); + } + + if (string.IsNullOrEmpty(authorizationInfo.Version)) + { + throw new ArgumentException(nameof(authorizationInfo.Version) + "is required"); + } + AssertActive(); ExpireRequests(); var secret = GenerateSecureRandom(); var code = GenerateCode(); - var result = new QuickConnectResult(secret, code, DateTime.UtcNow); + var result = new QuickConnectResult( + secret, + code, + DateTime.UtcNow, + authorizationInfo.DeviceId, + authorizationInfo.Device, + authorizationInfo.Client, + authorizationInfo.Version); _currentRequests[code] = result; return result; @@ -120,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); } @@ -129,7 +148,7 @@ namespace Emby.Server.Implementations.QuickConnect } /// <inheritdoc/> - public bool AuthorizeRequest(Guid userId, string code) + public async Task<bool> AuthorizeRequest(Guid userId, string code) { AssertActive(); ExpireRequests(); @@ -144,53 +163,45 @@ namespace Emby.Server.Implementations.QuickConnect throw new InvalidOperationException("Request is already authorized"); } - var token = Guid.NewGuid(); - result.Authentication = token; - // Change the time on the request so it expires one minute into the future. It can't expire immediately as otherwise some clients wouldn't ever see that they have been authenticated. - result.DateAdded = DateTime.Now.Add(TimeSpan.FromMinutes(1)); + result.DateAdded = DateTime.UtcNow.Add(TimeSpan.FromMinutes(1)); - _authenticationRepository.Create(new AuthenticationInfo + var authenticationResult = await _sessionManager.AuthenticateDirect(new AuthenticationRequest { - AppName = TokenName, - AccessToken = token.ToString("N", CultureInfo.InvariantCulture), - DateCreated = DateTime.UtcNow, - DeviceId = _appHost.SystemId, - DeviceName = _appHost.FriendlyName, - AppVersion = _appHost.ApplicationVersionString, - UserId = userId - }); + UserId = userId, + DeviceId = result.DeviceId, + DeviceName = result.DeviceName, + App = result.AppName, + AppVersion = result.AppVersion + }).ConfigureAwait(false); + + _authorizedSecrets[result.Secret] = (DateTime.UtcNow, authenticationResult); + result.Authenticated = true; + _currentRequests[code] = result; - _logger.LogDebug("Authorizing device with code {Code} to login as user {userId}", code, userId); + _logger.LogDebug("Authorizing device with code {Code} to login as user {UserId}", code, userId); return true; } - /// <summary> - /// Dispose. - /// </summary> - public void Dispose() + /// <inheritdoc/> + public AuthenticationResult GetAuthorizedRequest(string secret) { - Dispose(true); - GC.SuppressFinalize(this); - } + AssertActive(); + ExpireRequests(); - /// <summary> - /// Dispose. - /// </summary> - /// <param name="disposing">Dispose unmanaged resources.</param> - protected virtual void Dispose(bool disposing) - { - if (disposing) + if (!_authorizedSecrets.TryGetValue(secret, out var result)) { - _rng.Dispose(); + throw new ResourceNotFoundException("Unable to find request"); } + + return result.AuthenticationResult; } private string GenerateSecureRandom(int length = 32) { Span<byte> bytes = stackalloc byte[length]; - _rng.GetBytes(bytes); + RandomNumberGenerator.Fill(bytes); return Convert.ToHexString(bytes); } @@ -207,7 +218,7 @@ namespace Emby.Server.Implementations.QuickConnect // Expire stale connection requests foreach (var (_, currentRequest) in _currentRequests) { - if (expireAll || currentRequest.DateAdded > minTime) + if (expireAll || currentRequest.DateAdded < minTime) { var code = currentRequest.Code; _logger.LogDebug("Removing expired request {Code}", code); @@ -218,6 +229,18 @@ namespace Emby.Server.Implementations.QuickConnect } } } + + foreach (var (secret, (timestamp, _)) in _authorizedSecrets) + { + if (expireAll || timestamp < minTime) + { + _logger.LogDebug("Removing expired secret {Secret}", secret); + if (!_authorizedSecrets.TryRemove(secret, out _)) + { + _logger.LogWarning("Secret {Secret} already expired", secret); + } + } + } } } } diff --git a/Emby.Server.Implementations/ScheduledTasks/ScheduledTaskWorker.cs b/Emby.Server.Implementations/ScheduledTasks/ScheduledTaskWorker.cs index b343250419..f2cdfeb16c 100644 --- a/Emby.Server.Implementations/ScheduledTasks/ScheduledTaskWorker.cs +++ b/Emby.Server.Implementations/ScheduledTasks/ScheduledTaskWorker.cs @@ -10,9 +10,9 @@ using System.Text.Json; using System.Threading; using System.Threading.Tasks; 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 +24,10 @@ 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. @@ -67,11 +71,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> @@ -267,7 +266,7 @@ namespace Emby.Server.Implementations.ScheduledTasks } /// <summary> - /// Gets the triggers that define when the task will run. + /// Gets or sets the triggers that define when the task will run. /// </summary> /// <value>The triggers.</value> /// <exception cref="ArgumentNullException"><c>value</c> is <c>null</c>.</exception> @@ -366,7 +365,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)); diff --git a/Emby.Server.Implementations/ScheduledTasks/TaskManager.cs b/Emby.Server.Implementations/ScheduledTasks/TaskManager.cs index 4f0df75bf8..0431858fca 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 baeb86a221..09ea6271d9 100644 --- a/Emby.Server.Implementations/ScheduledTasks/Tasks/ChapterImagesTask.cs +++ b/Emby.Server.Implementations/ScheduledTasks/Tasks/ChapterImagesTask.cs @@ -39,6 +39,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, @@ -55,9 +61,19 @@ namespace Emby.Server.Implementations.ScheduledTasks _localization = localization; } - /// <summary> - /// Creates the triggers that define when the task will run. - /// </summary> + /// <inheritdoc /> + public string Name => _localization.GetLocalizedString("TaskRefreshChapterImages"); + + /// <inheritdoc /> + public string Description => _localization.GetLocalizedString("TaskRefreshChapterImagesDescription"); + + /// <inheritdoc /> + public string Category => _localization.GetLocalizedString("TasksLibraryCategory"); + + /// <inheritdoc /> + public string Key => "RefreshChapterImages"; + + /// <inheritdoc /> public IEnumerable<TaskTriggerInfo> GetDefaultTriggers() { return new[] @@ -162,26 +178,5 @@ namespace Emby.Server.Implementations.ScheduledTasks } } } - - /// <inheritdoc /> - public string Name => _localization.GetLocalizedString("TaskRefreshChapterImages"); - - /// <inheritdoc /> - public string Description => _localization.GetLocalizedString("TaskRefreshChapterImagesDescription"); - - /// <inheritdoc /> - public string Category => _localization.GetLocalizedString("TasksLibraryCategory"); - - /// <inheritdoc /> - public string Key => "RefreshChapterImages"; - - /// <inheritdoc /> - public bool IsHidden => false; - - /// <inheritdoc /> - public bool IsEnabled => true; - - /// <inheritdoc /> - public bool IsLogged => true; } } diff --git a/Emby.Server.Implementations/ScheduledTasks/Tasks/CleanActivityLogTask.cs b/Emby.Server.Implementations/ScheduledTasks/Tasks/CleanActivityLogTask.cs index 50ba9bc899..79886cb52c 100644 --- a/Emby.Server.Implementations/ScheduledTasks/Tasks/CleanActivityLogTask.cs +++ b/Emby.Server.Implementations/ScheduledTasks/Tasks/CleanActivityLogTask.cs @@ -60,12 +60,12 @@ namespace Emby.Server.Implementations.ScheduledTasks.Tasks public Task Execute(CancellationToken cancellationToken, IProgress<double> progress) { var retentionDays = _serverConfigurationManager.Configuration.ActivityLogRetentionDays; - if (!retentionDays.HasValue || retentionDays <= 0) + if (!retentionDays.HasValue || retentionDays < 0) { throw new Exception($"Activity Log Retention days must be at least 0. Currently: {retentionDays}"); } - var startDate = DateTime.UtcNow.AddDays(retentionDays.Value * -1); + var startDate = DateTime.UtcNow.AddDays(-retentionDays.Value); return _activityManager.CleanAsync(startDate); } diff --git a/Emby.Server.Implementations/ScheduledTasks/Tasks/DeleteCacheFileTask.cs b/Emby.Server.Implementations/ScheduledTasks/Tasks/DeleteCacheFileTask.cs index 692d1667d2..a575b260cb 100644 --- a/Emby.Server.Implementations/ScheduledTasks/Tasks/DeleteCacheFileTask.cs +++ b/Emby.Server.Implementations/ScheduledTasks/Tasks/DeleteCacheFileTask.cs @@ -29,6 +29,10 @@ namespace Emby.Server.Implementations.ScheduledTasks.Tasks /// <summary> /// Initializes a new instance of the <see cref="DeleteCacheFileTask" /> class. /// </summary> + /// <param name="appPaths">Instance of the <see cref="IApplicationPaths"/> interface.</param> + /// <param name="logger">Instance of the <see cref="ILogger"/> interface.</param> + /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param> + /// <param name="localization">Instance of the <see cref="ILocalizationManager"/> interface.</param> public DeleteCacheFileTask( IApplicationPaths appPaths, ILogger<DeleteCacheFileTask> logger, diff --git a/Emby.Server.Implementations/ScheduledTasks/Tasks/OptimizeDatabaseTask.cs b/Emby.Server.Implementations/ScheduledTasks/Tasks/OptimizeDatabaseTask.cs index 1ad1d0f50a..35a4aeef69 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 57d294a408..53c692a461 100644 --- a/Emby.Server.Implementations/ScheduledTasks/Tasks/PeopleValidationTask.cs +++ b/Emby.Server.Implementations/ScheduledTasks/Tasks/PeopleValidationTask.cs @@ -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/RefreshMediaLibraryTask.cs b/Emby.Server.Implementations/ScheduledTasks/Tasks/RefreshMediaLibraryTask.cs index 51b620404b..2184b3d033 100644 --- a/Emby.Server.Implementations/ScheduledTasks/Tasks/RefreshMediaLibraryTask.cs +++ b/Emby.Server.Implementations/ScheduledTasks/Tasks/RefreshMediaLibraryTask.cs @@ -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/Security/AuthenticationRepository.cs b/Emby.Server.Implementations/Security/AuthenticationRepository.cs deleted file mode 100644 index e8eac315b6..0000000000 --- a/Emby.Server.Implementations/Security/AuthenticationRepository.cs +++ /dev/null @@ -1,408 +0,0 @@ -#nullable disable - -#pragma warning disable CS1591 - -using System; -using System.Collections.Generic; -using System.Globalization; -using System.IO; -using System.Linq; -using Emby.Server.Implementations.Data; -using MediaBrowser.Controller.Configuration; -using MediaBrowser.Controller.Security; -using MediaBrowser.Model.Devices; -using MediaBrowser.Model.Querying; -using Microsoft.Extensions.Logging; -using SQLitePCL.pretty; - -namespace Emby.Server.Implementations.Security -{ - public class AuthenticationRepository : BaseSqliteRepository, IAuthenticationRepository - { - public AuthenticationRepository(ILogger<AuthenticationRepository> logger, IServerConfigurationManager config) - : base(logger) - { - DbFilePath = Path.Combine(config.ApplicationPaths.DataPath, "authentication.db"); - } - - public void Initialize() - { - string[] queries = - { - "create table if not exists Tokens (Id INTEGER PRIMARY KEY, AccessToken TEXT NOT NULL, DeviceId TEXT NOT NULL, AppName TEXT NOT NULL, AppVersion TEXT NOT NULL, DeviceName TEXT NOT NULL, UserId TEXT, UserName TEXT, IsActive BIT NOT NULL, DateCreated DATETIME NOT NULL, DateLastActivity DATETIME NOT NULL)", - "create table if not exists Devices (Id TEXT NOT NULL PRIMARY KEY, CustomName TEXT, Capabilities TEXT)", - "drop index if exists idx_AccessTokens", - "drop index if exists Tokens1", - "drop index if exists Tokens2", - - "create index if not exists Tokens3 on Tokens (AccessToken, DateLastActivity)", - "create index if not exists Tokens4 on Tokens (Id, DateLastActivity)", - "create index if not exists Devices1 on Devices (Id)" - }; - - using (var connection = GetConnection()) - { - var tableNewlyCreated = !TableExists(connection, "Tokens"); - - connection.RunQueries(queries); - - TryMigrate(connection, tableNewlyCreated); - } - } - - private void TryMigrate(ManagedConnection connection, bool tableNewlyCreated) - { - try - { - if (tableNewlyCreated && TableExists(connection, "AccessTokens")) - { - connection.RunInTransaction( - db => - { - var existingColumnNames = GetColumnNames(db, "AccessTokens"); - - AddColumn(db, "AccessTokens", "UserName", "TEXT", existingColumnNames); - AddColumn(db, "AccessTokens", "DateLastActivity", "DATETIME", existingColumnNames); - AddColumn(db, "AccessTokens", "AppVersion", "TEXT", existingColumnNames); - }, TransactionMode); - - connection.RunQueries(new[] - { - "update accesstokens set DateLastActivity=DateCreated where DateLastActivity is null", - "update accesstokens set DeviceName='Unknown' where DeviceName is null", - "update accesstokens set AppName='Unknown' where AppName is null", - "update accesstokens set AppVersion='1' where AppVersion is null", - "INSERT INTO Tokens (AccessToken, DeviceId, AppName, AppVersion, DeviceName, UserId, UserName, IsActive, DateCreated, DateLastActivity) SELECT AccessToken, DeviceId, AppName, AppVersion, DeviceName, UserId, UserName, IsActive, DateCreated, DateLastActivity FROM AccessTokens where deviceid not null and devicename not null and appname not null and isactive=1" - }); - } - } - catch (Exception ex) - { - Logger.LogError(ex, "Error migrating authentication database"); - } - } - - public void Create(AuthenticationInfo info) - { - if (info == null) - { - throw new ArgumentNullException(nameof(info)); - } - - using (var connection = GetConnection()) - { - connection.RunInTransaction( - db => - { - using (var statement = db.PrepareStatement("insert into Tokens (AccessToken, DeviceId, AppName, AppVersion, DeviceName, UserId, UserName, IsActive, DateCreated, DateLastActivity) values (@AccessToken, @DeviceId, @AppName, @AppVersion, @DeviceName, @UserId, @UserName, @IsActive, @DateCreated, @DateLastActivity)")) - { - statement.TryBind("@AccessToken", info.AccessToken); - - statement.TryBind("@DeviceId", info.DeviceId); - statement.TryBind("@AppName", info.AppName); - statement.TryBind("@AppVersion", info.AppVersion); - statement.TryBind("@DeviceName", info.DeviceName); - statement.TryBind("@UserId", info.UserId.Equals(Guid.Empty) ? null : info.UserId.ToString("N", CultureInfo.InvariantCulture)); - statement.TryBind("@UserName", info.UserName); - statement.TryBind("@IsActive", true); - statement.TryBind("@DateCreated", info.DateCreated.ToDateTimeParamValue()); - statement.TryBind("@DateLastActivity", info.DateLastActivity.ToDateTimeParamValue()); - - statement.MoveNext(); - } - }, TransactionMode); - } - } - - public void Update(AuthenticationInfo info) - { - if (info == null) - { - throw new ArgumentNullException(nameof(info)); - } - - using (var connection = GetConnection()) - { - connection.RunInTransaction( - db => - { - using (var statement = db.PrepareStatement("Update Tokens set AccessToken=@AccessToken, DeviceId=@DeviceId, AppName=@AppName, AppVersion=@AppVersion, DeviceName=@DeviceName, UserId=@UserId, UserName=@UserName, DateCreated=@DateCreated, DateLastActivity=@DateLastActivity where Id=@Id")) - { - statement.TryBind("@Id", info.Id); - - statement.TryBind("@AccessToken", info.AccessToken); - - statement.TryBind("@DeviceId", info.DeviceId); - statement.TryBind("@AppName", info.AppName); - statement.TryBind("@AppVersion", info.AppVersion); - statement.TryBind("@DeviceName", info.DeviceName); - statement.TryBind("@UserId", info.UserId.Equals(Guid.Empty) ? null : info.UserId.ToString("N", CultureInfo.InvariantCulture)); - statement.TryBind("@UserName", info.UserName); - statement.TryBind("@DateCreated", info.DateCreated.ToDateTimeParamValue()); - statement.TryBind("@DateLastActivity", info.DateLastActivity.ToDateTimeParamValue()); - - statement.MoveNext(); - } - }, TransactionMode); - } - } - - public void Delete(AuthenticationInfo info) - { - if (info == null) - { - throw new ArgumentNullException(nameof(info)); - } - - using (var connection = GetConnection()) - { - connection.RunInTransaction( - db => - { - using (var statement = db.PrepareStatement("Delete from Tokens where Id=@Id")) - { - statement.TryBind("@Id", info.Id); - - statement.MoveNext(); - } - }, TransactionMode); - } - } - - private const string BaseSelectText = "select Tokens.Id, AccessToken, DeviceId, AppName, AppVersion, DeviceName, UserId, UserName, DateCreated, DateLastActivity, Devices.CustomName from Tokens left join Devices on Tokens.DeviceId=Devices.Id"; - - private static void BindAuthenticationQueryParams(AuthenticationInfoQuery query, IStatement statement) - { - if (!string.IsNullOrEmpty(query.AccessToken)) - { - statement.TryBind("@AccessToken", query.AccessToken); - } - - if (!query.UserId.Equals(Guid.Empty)) - { - statement.TryBind("@UserId", query.UserId.ToString("N", CultureInfo.InvariantCulture)); - } - - if (!string.IsNullOrEmpty(query.DeviceId)) - { - statement.TryBind("@DeviceId", query.DeviceId); - } - } - - public QueryResult<AuthenticationInfo> Get(AuthenticationInfoQuery query) - { - if (query == null) - { - throw new ArgumentNullException(nameof(query)); - } - - var commandText = BaseSelectText; - - var whereClauses = new List<string>(); - - if (!string.IsNullOrEmpty(query.AccessToken)) - { - whereClauses.Add("AccessToken=@AccessToken"); - } - - if (!string.IsNullOrEmpty(query.DeviceId)) - { - whereClauses.Add("DeviceId=@DeviceId"); - } - - if (!query.UserId.Equals(Guid.Empty)) - { - whereClauses.Add("UserId=@UserId"); - } - - if (query.HasUser.HasValue) - { - if (query.HasUser.Value) - { - whereClauses.Add("UserId not null"); - } - else - { - whereClauses.Add("UserId is null"); - } - } - - var whereTextWithoutPaging = whereClauses.Count == 0 ? - string.Empty : - " where " + string.Join(" AND ", whereClauses.ToArray()); - - commandText += whereTextWithoutPaging; - - commandText += " ORDER BY DateLastActivity desc"; - - if (query.Limit.HasValue || query.StartIndex.HasValue) - { - var offset = query.StartIndex ?? 0; - - if (query.Limit.HasValue || offset > 0) - { - commandText += " LIMIT " + (query.Limit ?? int.MaxValue).ToString(CultureInfo.InvariantCulture); - } - - if (offset > 0) - { - commandText += " OFFSET " + offset.ToString(CultureInfo.InvariantCulture); - } - } - - var statementTexts = new[] - { - commandText, - "select count (Id) from Tokens" + whereTextWithoutPaging - }; - - var list = new List<AuthenticationInfo>(); - var result = new QueryResult<AuthenticationInfo>(); - using (var connection = GetConnection(true)) - { - connection.RunInTransaction( - db => - { - var statements = PrepareAll(db, statementTexts); - - using (var statement = statements[0]) - { - BindAuthenticationQueryParams(query, statement); - - foreach (var row in statement.ExecuteQuery()) - { - list.Add(Get(row)); - } - - using (var totalCountStatement = statements[1]) - { - BindAuthenticationQueryParams(query, totalCountStatement); - - result.TotalRecordCount = totalCountStatement.ExecuteQuery() - .SelectScalarInt() - .First(); - } - } - }, - ReadTransactionMode); - } - - result.Items = list; - return result; - } - - private static AuthenticationInfo Get(IReadOnlyList<ResultSetValue> reader) - { - var info = new AuthenticationInfo - { - Id = reader[0].ToInt64(), - AccessToken = reader[1].ToString() - }; - - if (reader.TryGetString(2, out var deviceId)) - { - info.DeviceId = deviceId; - } - - if (reader.TryGetString(3, out var appName)) - { - info.AppName = appName; - } - - if (reader.TryGetString(4, out var appVersion)) - { - info.AppVersion = appVersion; - } - - if (reader.TryGetString(6, out var userId)) - { - info.UserId = new Guid(userId); - } - - if (reader.TryGetString(7, out var userName)) - { - info.UserName = userName; - } - - info.DateCreated = reader[8].ReadDateTime(); - - if (reader.TryReadDateTime(9, out var dateLastActivity)) - { - info.DateLastActivity = dateLastActivity; - } - else - { - info.DateLastActivity = info.DateCreated; - } - - if (reader.TryGetString(10, out var customName)) - { - info.DeviceName = customName; - } - else if (reader.TryGetString(5, out var deviceName)) - { - info.DeviceName = deviceName; - } - - return info; - } - - public DeviceOptions GetDeviceOptions(string deviceId) - { - using (var connection = GetConnection(true)) - { - return connection.RunInTransaction( - db => - { - using (var statement = base.PrepareStatement(db, "select CustomName from Devices where Id=@DeviceId")) - { - statement.TryBind("@DeviceId", deviceId); - - var result = new DeviceOptions(); - - foreach (var row in statement.ExecuteQuery()) - { - if (row.TryGetString(0, out var customName)) - { - result.CustomName = customName; - } - } - - return result; - } - }, ReadTransactionMode); - } - } - - public void UpdateDeviceOptions(string deviceId, DeviceOptions options) - { - if (options == null) - { - throw new ArgumentNullException(nameof(options)); - } - - using (var connection = GetConnection()) - { - connection.RunInTransaction( - db => - { - using (var statement = db.PrepareStatement("replace into devices (Id, CustomName, Capabilities) VALUES (@Id, @CustomName, (Select Capabilities from Devices where Id=@Id))")) - { - statement.TryBind("@Id", deviceId); - - if (string.IsNullOrWhiteSpace(options.CustomName)) - { - statement.TryBindNull("@CustomName"); - } - else - { - statement.TryBind("@CustomName", options.CustomName); - } - - statement.MoveNext(); - } - }, TransactionMode); - } - } - } -} diff --git a/Emby.Server.Implementations/Serialization/MyXmlSerializer.cs b/Emby.Server.Implementations/Serialization/MyXmlSerializer.cs index 5ff73de819..059211a0b9 100644 --- a/Emby.Server.Implementations/Serialization/MyXmlSerializer.cs +++ b/Emby.Server.Implementations/Serialization/MyXmlSerializer.cs @@ -72,7 +72,7 @@ namespace Emby.Server.Implementations.Serialization /// <param name="file">The file.</param> public void SerializeToFile(object obj, string file) { - using (var stream = new FileStream(file, FileMode.Create)) + using (var stream = new FileStream(file, FileMode.Create, FileAccess.Write)) { SerializeToStream(obj, stream); } diff --git a/Emby.Server.Implementations/ServerApplicationPaths.cs b/Emby.Server.Implementations/ServerApplicationPaths.cs index 6cf9a8f71e..369a2b0d88 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 c4b19f4171..341d2c8e61 100644 --- a/Emby.Server.Implementations/Session/SessionManager.cs +++ b/Emby.Server.Implementations/Session/SessionManager.cs @@ -10,8 +10,10 @@ using System.Linq; using System.Threading; using System.Threading.Tasks; using Jellyfin.Data.Entities; +using Jellyfin.Data.Entities.Security; using Jellyfin.Data.Enums; using Jellyfin.Data.Events; +using Jellyfin.Data.Queries; using Jellyfin.Extensions; using MediaBrowser.Common.Events; using MediaBrowser.Common.Extensions; @@ -25,9 +27,7 @@ using MediaBrowser.Controller.Events; using MediaBrowser.Controller.Events.Session; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Net; -using MediaBrowser.Controller.Security; using MediaBrowser.Controller.Session; -using MediaBrowser.Model.Devices; using MediaBrowser.Model.Dto; using MediaBrowser.Model.Entities; using MediaBrowser.Model.Library; @@ -55,7 +55,6 @@ namespace Emby.Server.Implementations.Session private readonly IImageProcessor _imageProcessor; private readonly IMediaSourceManager _mediaSourceManager; private readonly IServerApplicationHost _appHost; - private readonly IAuthenticationRepository _authRepo; private readonly IDeviceManager _deviceManager; /// <summary> @@ -78,7 +77,6 @@ namespace Emby.Server.Implementations.Session IDtoService dtoService, IImageProcessor imageProcessor, IServerApplicationHost appHost, - IAuthenticationRepository authRepo, IDeviceManager deviceManager, IMediaSourceManager mediaSourceManager) { @@ -91,7 +89,6 @@ namespace Emby.Server.Implementations.Session _dtoService = dtoService; _imageProcessor = imageProcessor; _appHost = appHost; - _authRepo = authRepo; _deviceManager = deviceManager; _mediaSourceManager = mediaSourceManager; @@ -238,12 +235,12 @@ namespace Emby.Server.Implementations.Session } /// <inheritdoc /> - public void UpdateDeviceName(string sessionId, string deviceName) + public void UpdateDeviceName(string sessionId, string reportedDeviceName) { var session = GetSession(sessionId); if (session != null) { - session.DeviceName = deviceName; + session.DeviceName = reportedDeviceName; } } @@ -257,7 +254,7 @@ namespace Emby.Server.Implementations.Session /// <param name="remoteEndPoint">The remote end point.</param> /// <param name="user">The user.</param> /// <returns>SessionInfo.</returns> - public SessionInfo LogSessionActivity( + public async Task<SessionInfo> LogSessionActivity( string appName, string appVersion, string deviceId, @@ -283,7 +280,7 @@ namespace Emby.Server.Implementations.Session } var activityDate = DateTime.UtcNow; - var session = GetSessionInfo(appName, appVersion, deviceId, deviceName, remoteEndPoint, user); + var session = await GetSessionInfo(appName, appVersion, deviceId, deviceName, remoteEndPoint, user).ConfigureAwait(false); var lastActivityDate = session.LastActivityDate; session.LastActivityDate = activityDate; @@ -296,7 +293,7 @@ namespace Emby.Server.Implementations.Session try { user.LastActivityDate = activityDate; - _userManager.UpdateUser(user); + await _userManager.UpdateUserAsync(user).ConfigureAwait(false); } catch (DbUpdateConcurrencyException e) { @@ -319,14 +316,14 @@ namespace Emby.Server.Implementations.Session } /// <inheritdoc /> - public void OnSessionControllerConnected(SessionInfo info) + public void OnSessionControllerConnected(SessionInfo session) { EventHelper.QueueEventIfNotNull( SessionControllerConnected, this, new SessionEventArgs { - SessionInfo = info + SessionInfo = session }, _logger); } @@ -461,7 +458,7 @@ namespace Emby.Server.Implementations.Session /// <param name="remoteEndPoint">The remote end point.</param> /// <param name="user">The user.</param> /// <returns>SessionInfo.</returns> - private SessionInfo GetSessionInfo( + private async Task<SessionInfo> GetSessionInfo( string appName, string appVersion, string deviceId, @@ -480,9 +477,11 @@ namespace Emby.Server.Implementations.Session CheckDisposed(); - var sessionInfo = _activeConnections.GetOrAdd( - key, - k => CreateSession(k, appName, appVersion, deviceId, deviceName, remoteEndPoint, user)); + if (!_activeConnections.TryGetValue(key, out var sessionInfo)) + { + _activeConnections[key] = await CreateSession(key, appName, appVersion, deviceId, deviceName, remoteEndPoint, user).ConfigureAwait(false); + sessionInfo = _activeConnections[key]; + } sessionInfo.UserId = user?.Id ?? Guid.Empty; sessionInfo.UserName = user?.Username; @@ -505,7 +504,7 @@ namespace Emby.Server.Implementations.Session return sessionInfo; } - private SessionInfo CreateSession( + private async Task<SessionInfo> CreateSession( string key, string appName, string appVersion, @@ -535,7 +534,7 @@ namespace Emby.Server.Implementations.Session deviceName = "Network Device"; } - var deviceOptions = _deviceManager.GetDeviceOptions(deviceId); + var deviceOptions = await _deviceManager.GetDeviceOptions(deviceId).ConfigureAwait(false); if (string.IsNullOrEmpty(deviceOptions.CustomName)) { sessionInfo.DeviceName = deviceName; @@ -742,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) { @@ -1200,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); } @@ -1433,38 +1436,20 @@ namespace Emby.Server.Implementations.Session /// <summary> /// Authenticates the new session. /// </summary> - /// <param name="request">The request.</param> - /// <returns>Task{SessionInfo}.</returns> + /// <param name="request">The authenticationrequest.</param> + /// <returns>The authentication result.</returns> public Task<AuthenticationResult> AuthenticateNewSession(AuthenticationRequest request) { return AuthenticateNewSessionInternal(request, true); } - public Task<AuthenticationResult> CreateNewSession(AuthenticationRequest request) - { - return AuthenticateNewSessionInternal(request, false); - } - - public Task<AuthenticationResult> AuthenticateQuickConnect(AuthenticationRequest request, string token) + /// <summary> + /// Directly authenticates the session without enforcing password. + /// </summary> + /// <param name="request">The authentication request.</param> + /// <returns>The authentication result.</returns> + public Task<AuthenticationResult> AuthenticateDirect(AuthenticationRequest request) { - var result = _authRepo.Get(new AuthenticationInfoQuery() - { - AccessToken = token, - DeviceId = _appHost.SystemId, - Limit = 1 - }); - - if (result.TotalRecordCount == 0) - { - throw new SecurityException("Unknown quick connect token"); - } - - var info = result.Items[0]; - request.UserId = info.UserId; - - // There's no need to keep the quick connect token in the database, as AuthenticateNewSessionInternal() issues a long lived token. - _authRepo.Delete(info); - return AuthenticateNewSessionInternal(request, false); } @@ -1510,15 +1495,15 @@ namespace Emby.Server.Implementations.Session throw new SecurityException("User is at their maximum number of sessions."); } - var token = GetAuthorizationToken(user, request.DeviceId, request.App, request.AppVersion, request.DeviceName); + var token = await GetAuthorizationToken(user, request.DeviceId, request.App, request.AppVersion, request.DeviceName).ConfigureAwait(false); - var session = LogSessionActivity( + var session = await LogSessionActivity( request.App, request.AppVersion, request.DeviceId, request.DeviceName, request.RemoteEndPoint, - user); + user).ConfigureAwait(false); var returnResult = new AuthenticationResult { @@ -1533,36 +1518,33 @@ namespace Emby.Server.Implementations.Session return returnResult; } - private string GetAuthorizationToken(User user, string deviceId, string app, string appVersion, string deviceName) + private async Task<string> GetAuthorizationToken(User user, string deviceId, string app, string appVersion, string deviceName) { - var existing = _authRepo.Get( - new AuthenticationInfoQuery + var existing = (await _deviceManager.GetDevices( + new DeviceQuery { DeviceId = deviceId, UserId = user.Id, Limit = 1 - }).Items.FirstOrDefault(); + }).ConfigureAwait(false)).Items.FirstOrDefault(); - if (!string.IsNullOrEmpty(deviceId)) - { - var allExistingForDevice = _authRepo.Get( - new AuthenticationInfoQuery - { - DeviceId = deviceId - }).Items; + var allExistingForDevice = (await _deviceManager.GetDevices( + new DeviceQuery + { + DeviceId = deviceId + }).ConfigureAwait(false)).Items; - foreach (var auth in allExistingForDevice) + foreach (var auth in allExistingForDevice) + { + if (existing == null || !string.Equals(auth.AccessToken, existing.AccessToken, StringComparison.Ordinal)) { - if (existing == null || !string.Equals(auth.AccessToken, existing.AccessToken, StringComparison.Ordinal)) + try { - try - { - Logout(auth); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error while logging out."); - } + await Logout(auth).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error while logging out."); } } } @@ -1573,29 +1555,14 @@ namespace Emby.Server.Implementations.Session return existing.AccessToken; } - var now = DateTime.UtcNow; - - var newToken = new AuthenticationInfo - { - AppName = app, - AppVersion = appVersion, - DateCreated = now, - DateLastActivity = now, - DeviceId = deviceId, - DeviceName = deviceName, - UserId = user.Id, - AccessToken = Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture), - UserName = user.Username - }; - _logger.LogInformation("Creating new access token for user {0}", user.Id); - _authRepo.Create(newToken); + var device = await _deviceManager.CreateDevice(new Device(user.Id, app, appVersion, deviceName, deviceId)).ConfigureAwait(false); - return newToken.AccessToken; + return device.AccessToken; } /// <inheritdoc /> - public void Logout(string accessToken) + public async Task Logout(string accessToken) { CheckDisposed(); @@ -1604,30 +1571,30 @@ namespace Emby.Server.Implementations.Session throw new ArgumentNullException(nameof(accessToken)); } - var existing = _authRepo.Get( - new AuthenticationInfoQuery + var existing = (await _deviceManager.GetDevices( + new DeviceQuery { Limit = 1, AccessToken = accessToken - }).Items; + }).ConfigureAwait(false)).Items; if (existing.Count > 0) { - Logout(existing[0]); + await Logout(existing[0]).ConfigureAwait(false); } } /// <inheritdoc /> - public void Logout(AuthenticationInfo existing) + public async Task Logout(Device device) { CheckDisposed(); - _logger.LogInformation("Logging out access token {0}", existing.AccessToken); + _logger.LogInformation("Logging out access token {0}", device.AccessToken); - _authRepo.Delete(existing); + await _deviceManager.DeleteDevice(device).ConfigureAwait(false); var sessions = Sessions - .Where(i => string.Equals(i.DeviceId, existing.DeviceId, StringComparison.OrdinalIgnoreCase)) + .Where(i => string.Equals(i.DeviceId, device.DeviceId, StringComparison.OrdinalIgnoreCase)) .ToList(); foreach (var session in sessions) @@ -1638,36 +1605,30 @@ namespace Emby.Server.Implementations.Session } catch (Exception ex) { - _logger.LogError("Error reporting session ended", ex); + _logger.LogError(ex, "Error reporting session ended"); } } } /// <inheritdoc /> - public void RevokeUserTokens(Guid userId, string currentAccessToken) + public async Task RevokeUserTokens(Guid userId, string currentAccessToken) { CheckDisposed(); - var existing = _authRepo.Get(new AuthenticationInfoQuery + var existing = await _deviceManager.GetDevices(new DeviceQuery { UserId = userId - }); + }).ConfigureAwait(false); foreach (var info in existing.Items) { if (!string.Equals(currentAccessToken, info.AccessToken, StringComparison.OrdinalIgnoreCase)) { - Logout(info); + await Logout(info).ConfigureAwait(false); } } } - /// <inheritdoc /> - public void RevokeToken(string token) - { - Logout(token); - } - /// <summary> /// Reports the capabilities. /// </summary> @@ -1787,18 +1748,9 @@ namespace Emby.Server.Implementations.Session } var item = _libraryManager.GetItemById(new Guid(itemId)); - - var info = GetItemInfo(item, null); - - ReportNowViewingItem(sessionId, info); - } - - /// <inheritdoc /> - public void ReportNowViewingItem(string sessionId, BaseItemDto item) - { var session = GetSession(sessionId); - session.NowViewingItem = item; + session.NowViewingItem = GetItemInfo(item, null); } /// <inheritdoc /> @@ -1828,7 +1780,7 @@ namespace Emby.Server.Implementations.Session } /// <inheritdoc /> - public SessionInfo GetSessionByAuthenticationToken(AuthenticationInfo info, string deviceId, string remoteEndpoint, string appVersion) + public Task<SessionInfo> GetSessionByAuthenticationToken(Device info, string deviceId, string remoteEndpoint, string appVersion) { if (info == null) { @@ -1861,20 +1813,20 @@ namespace Emby.Server.Implementations.Session } /// <inheritdoc /> - public SessionInfo GetSessionByAuthenticationToken(string token, string deviceId, string remoteEndpoint) + public async Task<SessionInfo> GetSessionByAuthenticationToken(string token, string deviceId, string remoteEndpoint) { - var items = _authRepo.Get(new AuthenticationInfoQuery + var items = (await _deviceManager.GetDevices(new DeviceQuery { AccessToken = token, Limit = 1 - }).Items; + }).ConfigureAwait(false)).Items; if (items.Count == 0) { return null; } - return GetSessionByAuthenticationToken(items[0], deviceId, remoteEndpoint, null); + return await GetSessionByAuthenticationToken(items[0], deviceId, remoteEndpoint, null).ConfigureAwait(false); } /// <inheritdoc /> diff --git a/Emby.Server.Implementations/Session/SessionWebSocketListener.cs b/Emby.Server.Implementations/Session/SessionWebSocketListener.cs index e9e3ca7f4b..2a14a8c7b2 100644 --- a/Emby.Server.Implementations/Session/SessionWebSocketListener.cs +++ b/Emby.Server.Implementations/Session/SessionWebSocketListener.cs @@ -99,7 +99,7 @@ namespace Emby.Server.Implementations.Session /// <inheritdoc /> public async Task ProcessWebSocketConnectedAsync(IWebSocketConnection connection) { - var session = GetSession(connection.QueryString, connection.RemoteEndPoint.ToString()); + var session = await GetSession(connection.QueryString, connection.RemoteEndPoint.ToString()).ConfigureAwait(false); if (session != null) { EnsureController(session, connection); @@ -111,7 +111,7 @@ namespace Emby.Server.Implementations.Session } } - private SessionInfo GetSession(IQueryCollection queryString, string remoteEndpoint) + private Task<SessionInfo> GetSession(IQueryCollection queryString, string remoteEndpoint) { if (queryString == null) { diff --git a/Emby.Server.Implementations/Sorting/AiredEpisodeOrderComparer.cs b/Emby.Server.Implementations/Sorting/AiredEpisodeOrderComparer.cs index 2b0ab536f9..db8b689491 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 42e644970c..bd19666238 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> @@ -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 1db3f5e9ca..fe7dc84cbf 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> @@ -33,11 +39,5 @@ namespace Emby.Server.Implementations.Sorting return audio == null ? string.Empty : audio.Album; } - - /// <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 98bee3fd9f..7b7ba57531 100644 --- a/Emby.Server.Implementations/Sorting/ArtistComparer.cs +++ b/Emby.Server.Implementations/Sorting/ArtistComparer.cs @@ -27,7 +27,7 @@ namespace Emby.Server.Implementations.Sorting /// <returns>System.String.</returns> private static string? GetValue(BaseItem? x) { - if (!(x is Audio audio)) + if (x is not Audio audio) { return string.Empty; } diff --git a/Emby.Server.Implementations/Sorting/CriticRatingComparer.cs b/Emby.Server.Implementations/Sorting/CriticRatingComparer.cs index d20dedc2d4..ba1835e4f2 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 d3f10f78cb..8b460166ca 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 08a44319f2..ec818253be 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 4de81a69e3..8f87717f49 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> @@ -30,11 +36,5 @@ namespace Emby.Server.Implementations.Sorting return string.Compare(x.Name, y.Name, StringComparison.CurrentCultureIgnoreCase); } - - /// <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 04e4865cbf..45c9044c52 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 c98f97bf1e..b217556ef3 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 df9f9957d6..d2022df7a6 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 af3bc27508..bf0168222d 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 1293153031..e32e5552e4 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/SortNameComparer.cs b/Emby.Server.Implementations/Sorting/SortNameComparer.cs index 8d30716d39..fb97a0349d 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> @@ -32,11 +38,5 @@ namespace Emby.Server.Implementations.Sorting return string.Compare(x.SortName, y.SortName, StringComparison.CurrentCultureIgnoreCase); } - - /// <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 6826aee3b1..4d89cfa8b2 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 12efff261b..75cf890e5f 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); } } @@ -536,6 +536,16 @@ namespace Emby.Server.Implementations.SyncPlay return itemFound; } + /// <inheritdoc /> + public void ClearPlayQueue(bool clearPlayingItem) + { + PlayQueue.ClearPlaylist(clearPlayingItem); + if (clearPlayingItem) + { + RestartCurrentItem(); + } + } + /// <inheritdoc /> public bool RemoveFromPlayQueue(IReadOnlyList<Guid> playlistItemIds) { @@ -649,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; @@ -669,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 993456196d..2ebeea7176 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,7 +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); + _sessionManager.SendSyncPlayGroupUpdate(session.Id, error, CancellationToken.None); return; } } @@ -329,7 +329,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); } } diff --git a/Emby.Server.Implementations/TV/TVSeriesManager.cs b/Emby.Server.Implementations/TV/TVSeriesManager.cs index af453d1480..4d990c8718 100644 --- a/Emby.Server.Implementations/TV/TVSeriesManager.cs +++ b/Emby.Server.Implementations/TV/TVSeriesManager.cs @@ -33,9 +33,9 @@ namespace Emby.Server.Implementations.TV _configurationManager = configurationManager; } - public QueryResult<BaseItem> GetNextUp(NextUpQuery request, DtoOptions dtoOptions) + public QueryResult<BaseItem> GetNextUp(NextUpQuery query, DtoOptions options) { - var user = _userManager.GetUserById(request.UserId); + var user = _userManager.GetUserById(query.UserId); if (user == null) { @@ -43,9 +43,9 @@ namespace Emby.Server.Implementations.TV } string presentationUniqueKey = null; - if (!string.IsNullOrEmpty(request.SeriesId)) + if (!string.IsNullOrEmpty(query.SeriesId)) { - if (_libraryManager.GetItemById(request.SeriesId) is Series series) + if (_libraryManager.GetItemById(query.SeriesId) is Series series) { presentationUniqueKey = GetUniqueSeriesKey(series); } @@ -53,14 +53,14 @@ namespace Emby.Server.Implementations.TV if (!string.IsNullOrEmpty(presentationUniqueKey)) { - return GetResult(GetNextUpEpisodes(request, user, new[] { presentationUniqueKey }, dtoOptions), request); + return GetResult(GetNextUpEpisodes(query, user, new[] { presentationUniqueKey }, options), query); } BaseItem[] parents; - if (request.ParentId.HasValue) + if (query.ParentId.HasValue) { - var parent = _libraryManager.GetItemById(request.ParentId.Value); + var parent = _libraryManager.GetItemById(query.ParentId.Value); if (parent != null) { @@ -79,10 +79,10 @@ namespace Emby.Server.Implementations.TV .ToArray(); } - return GetNextUp(request, parents, dtoOptions); + return GetNextUp(query, parents, options); } - public QueryResult<BaseItem> GetNextUp(NextUpQuery request, BaseItem[] parentsFolders, DtoOptions dtoOptions) + public QueryResult<BaseItem> GetNextUp(NextUpQuery request, BaseItem[] parentsFolders, DtoOptions options) { var user = _userManager.GetUserById(request.UserId); @@ -104,7 +104,7 @@ namespace Emby.Server.Implementations.TV if (!string.IsNullOrEmpty(presentationUniqueKey)) { - return GetResult(GetNextUpEpisodes(request, user, new[] { presentationUniqueKey }, dtoOptions), request); + return GetResult(GetNextUpEpisodes(request, user, new[] { presentationUniqueKey }, options), request); } if (limit.HasValue) @@ -128,7 +128,7 @@ namespace Emby.Server.Implementations.TV .Select(GetUniqueSeriesKey); // Avoid implicitly captured closure - var episodes = GetNextUpEpisodes(request, user, items, dtoOptions); + var episodes = GetNextUpEpisodes(request, user, items, options); return GetResult(episodes, request); } diff --git a/Emby.Server.Implementations/Udp/UdpServer.cs b/Emby.Server.Implementations/Udp/UdpServer.cs index 8179e26c5e..bf51c39684 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> diff --git a/Emby.Server.Implementations/Updates/InstallationManager.cs b/Emby.Server.Implementations/Updates/InstallationManager.cs index 7b0afa4e24..4a022c5dbc 100644 --- a/Emby.Server.Implementations/Updates/InstallationManager.cs +++ b/Emby.Server.Implementations/Updates/InstallationManager.cs @@ -10,8 +10,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; diff --git a/Jellyfin.Api/Auth/AnonymousLanAccessPolicy/AnonymousLanAccessHandler.cs b/Jellyfin.Api/Auth/AnonymousLanAccessPolicy/AnonymousLanAccessHandler.cs new file mode 100644 index 0000000000..88af08dd33 --- /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 0000000000..49af24ff3b --- /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 392498c530..13d3257dff 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 c56233794a..369e846aef 100644 --- a/Jellyfin.Api/Auth/CustomAuthenticationHandler.cs +++ b/Jellyfin.Api/Auth/CustomAuthenticationHandler.cs @@ -40,11 +40,11 @@ namespace Jellyfin.Api.Auth } /// <inheritdoc /> - protected override Task<AuthenticateResult> HandleAuthenticateAsync() + protected override async Task<AuthenticateResult> HandleAuthenticateAsync() { try { - var authorizationInfo = _authService.Authenticate(Request); + var authorizationInfo = await _authService.Authenticate(Request).ConfigureAwait(false); var role = UserRoles.User; if (authorizationInfo.IsApiKey || authorizationInfo.User.HasPermission(PermissionKind.IsAdministrator)) { @@ -68,16 +68,16 @@ namespace Jellyfin.Api.Auth var principal = new ClaimsPrincipal(identity); var ticket = new AuthenticationTicket(principal, Scheme.Name); - return Task.FromResult(AuthenticateResult.Success(ticket)); + return AuthenticateResult.Success(ticket); } catch (AuthenticationException ex) { _logger.LogDebug(ex, "Error authenticating with {Handler}", nameof(CustomAuthenticationHandler)); - return Task.FromResult(AuthenticateResult.NoResult()); + return AuthenticateResult.NoResult(); } catch (SecurityException ex) { - return Task.FromResult(AuthenticateResult.Fail(ex)); + return AuthenticateResult.Fail(ex); } } } diff --git a/Jellyfin.Api/Auth/FirstTimeSetupOrDefaultPolicy/FirstTimeSetupOrDefaultHandler.cs b/Jellyfin.Api/Auth/FirstTimeSetupOrDefaultPolicy/FirstTimeSetupOrDefaultHandler.cs index 9815e252ee..dd0bd4ec2f 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 decbe0c035..90b76ee99a 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 b898ac76c8..e6c04eb082 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 632dedb3cd..a72eeea284 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/ActivityLogController.cs b/Jellyfin.Api/Controllers/ActivityLogController.cs index b429cebecb..ae45f647f7 100644 --- a/Jellyfin.Api/Controllers/ActivityLogController.cs +++ b/Jellyfin.Api/Controllers/ActivityLogController.cs @@ -47,7 +47,7 @@ namespace Jellyfin.Api.Controllers { return await _activityManager.GetPagedResultAsync(new ActivityLogQuery { - StartIndex = startIndex, + Skip = startIndex, Limit = limit, MinDate = minDate, HasUserId = hasUserId diff --git a/Jellyfin.Api/Controllers/ApiKeyController.cs b/Jellyfin.Api/Controllers/ApiKeyController.cs index 8c43d786a7..8e0332d3e1 100644 --- a/Jellyfin.Api/Controllers/ApiKeyController.cs +++ b/Jellyfin.Api/Controllers/ApiKeyController.cs @@ -1,10 +1,7 @@ -using System; using System.ComponentModel.DataAnnotations; -using System.Globalization; +using System.Threading.Tasks; using Jellyfin.Api.Constants; -using MediaBrowser.Controller; using MediaBrowser.Controller.Security; -using MediaBrowser.Controller.Session; using MediaBrowser.Model.Querying; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; @@ -18,24 +15,15 @@ namespace Jellyfin.Api.Controllers [Route("Auth")] public class ApiKeyController : BaseJellyfinApiController { - private readonly ISessionManager _sessionManager; - private readonly IServerApplicationHost _appHost; - private readonly IAuthenticationRepository _authRepo; + private readonly IAuthenticationManager _authenticationManager; /// <summary> /// Initializes a new instance of the <see cref="ApiKeyController"/> class. /// </summary> - /// <param name="sessionManager">Instance of <see cref="ISessionManager"/> interface.</param> - /// <param name="appHost">Instance of <see cref="IServerApplicationHost"/> interface.</param> - /// <param name="authRepo">Instance of <see cref="IAuthenticationRepository"/> interface.</param> - public ApiKeyController( - ISessionManager sessionManager, - IServerApplicationHost appHost, - IAuthenticationRepository authRepo) + /// <param name="authenticationManager">Instance of <see cref="IAuthenticationManager"/> interface.</param> + public ApiKeyController(IAuthenticationManager authenticationManager) { - _sessionManager = sessionManager; - _appHost = appHost; - _authRepo = authRepo; + _authenticationManager = authenticationManager; } /// <summary> @@ -46,14 +34,15 @@ namespace Jellyfin.Api.Controllers [HttpGet("Keys")] [Authorize(Policy = Policies.RequiresElevation)] [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult<QueryResult<AuthenticationInfo>> GetKeys() + public async Task<ActionResult<QueryResult<AuthenticationInfo>>> GetKeys() { - var result = _authRepo.Get(new AuthenticationInfoQuery - { - HasUser = false - }); + var keys = await _authenticationManager.GetApiKeys(); - return result; + return new QueryResult<AuthenticationInfo> + { + Items = keys, + TotalRecordCount = keys.Count + }; } /// <summary> @@ -65,17 +54,10 @@ namespace Jellyfin.Api.Controllers [HttpPost("Keys")] [Authorize(Policy = Policies.RequiresElevation)] [ProducesResponseType(StatusCodes.Status204NoContent)] - public ActionResult CreateKey([FromQuery, Required] string app) + public async Task<ActionResult> CreateKey([FromQuery, Required] string app) { - _authRepo.Create(new AuthenticationInfo - { - AppName = app, - AccessToken = Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture), - DateCreated = DateTime.UtcNow, - DeviceId = _appHost.SystemId, - DeviceName = _appHost.FriendlyName, - AppVersion = _appHost.ApplicationVersionString - }); + await _authenticationManager.CreateApiKey(app).ConfigureAwait(false); + return NoContent(); } @@ -88,9 +70,10 @@ namespace Jellyfin.Api.Controllers [HttpDelete("Keys/{key}")] [Authorize(Policy = Policies.RequiresElevation)] [ProducesResponseType(StatusCodes.Status204NoContent)] - public ActionResult RevokeKey([FromRoute, Required] string key) + public async Task<ActionResult> RevokeKey([FromRoute, Required] string key) { - _sessionManager.RevokeToken(key); + await _authenticationManager.DeleteApiKey(key).ConfigureAwait(false); + return NoContent(); } } diff --git a/Jellyfin.Api/Controllers/AudioController.cs b/Jellyfin.Api/Controllers/AudioController.cs index a6e70e72d3..54ac06276e 100644 --- a/Jellyfin.Api/Controllers/AudioController.cs +++ b/Jellyfin.Api/Controllers/AudioController.cs @@ -76,7 +76,7 @@ namespace Jellyfin.Api.Controllers /// <param name="cpuCoreLimit">Optional. The limit of how many cpu cores to use.</param> /// <param name="liveStreamId">The live stream id.</param> /// <param name="enableMpegtsM2TsMode">Optional. Whether to enable the MpegtsM2Ts mode.</param> - /// <param name="videoCodec">Optional. Specify a video codec to encode to, e.g. h264. If omitted the server will auto-select using the url's extension. Options: h265, h264, mpeg4, theora, vpx, wmv.</param> + /// <param name="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> @@ -241,7 +241,7 @@ namespace Jellyfin.Api.Controllers /// <param name="cpuCoreLimit">Optional. The limit of how many cpu cores to use.</param> /// <param name="liveStreamId">The live stream id.</param> /// <param name="enableMpegtsM2TsMode">Optional. Whether to enable the MpegtsM2Ts mode.</param> - /// <param name="videoCodec">Optional. Specify a video codec to encode to, e.g. h264. If omitted the server will auto-select using the url's extension. Options: h265, h264, mpeg4, theora, vpx, wmv.</param> + /// <param name="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> diff --git a/Jellyfin.Api/Controllers/CollectionController.cs b/Jellyfin.Api/Controllers/CollectionController.cs index 852d1e9cbd..8a98d856c4 100644 --- a/Jellyfin.Api/Controllers/CollectionController.cs +++ b/Jellyfin.Api/Controllers/CollectionController.cs @@ -58,7 +58,7 @@ namespace Jellyfin.Api.Controllers [FromQuery] Guid? parentId, [FromQuery] bool isLocked = false) { - var userId = _authContext.GetAuthorizationInfo(Request).UserId; + var userId = (await _authContext.GetAuthorizationInfo(Request).ConfigureAwait(false)).UserId; var item = await _collectionManager.CreateCollectionAsync(new CollectionCreationOptions { diff --git a/Jellyfin.Api/Controllers/DashboardController.cs b/Jellyfin.Api/Controllers/DashboardController.cs index 445733c24d..87cb418d97 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/DevicesController.cs b/Jellyfin.Api/Controllers/DevicesController.cs index b3e3490c2a..8292cf83b5 100644 --- a/Jellyfin.Api/Controllers/DevicesController.cs +++ b/Jellyfin.Api/Controllers/DevicesController.cs @@ -1,8 +1,11 @@ using System; using System.ComponentModel.DataAnnotations; +using System.Threading.Tasks; using Jellyfin.Api.Constants; +using Jellyfin.Data.Dtos; +using Jellyfin.Data.Entities.Security; +using Jellyfin.Data.Queries; using MediaBrowser.Controller.Devices; -using MediaBrowser.Controller.Security; using MediaBrowser.Controller.Session; using MediaBrowser.Model.Devices; using MediaBrowser.Model.Querying; @@ -19,22 +22,18 @@ namespace Jellyfin.Api.Controllers public class DevicesController : BaseJellyfinApiController { private readonly IDeviceManager _deviceManager; - private readonly IAuthenticationRepository _authenticationRepository; private readonly ISessionManager _sessionManager; /// <summary> /// Initializes a new instance of the <see cref="DevicesController"/> class. /// </summary> /// <param name="deviceManager">Instance of <see cref="IDeviceManager"/> interface.</param> - /// <param name="authenticationRepository">Instance of <see cref="IAuthenticationRepository"/> interface.</param> /// <param name="sessionManager">Instance of <see cref="ISessionManager"/> interface.</param> public DevicesController( IDeviceManager deviceManager, - IAuthenticationRepository authenticationRepository, ISessionManager sessionManager) { _deviceManager = deviceManager; - _authenticationRepository = authenticationRepository; _sessionManager = sessionManager; } @@ -47,10 +46,9 @@ namespace Jellyfin.Api.Controllers /// <returns>An <see cref="OkResult"/> containing the list of devices.</returns> [HttpGet] [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult<QueryResult<DeviceInfo>> GetDevices([FromQuery] bool? supportsSync, [FromQuery] Guid? userId) + public async Task<ActionResult<QueryResult<DeviceInfo>>> GetDevices([FromQuery] bool? supportsSync, [FromQuery] Guid? userId) { - var deviceQuery = new DeviceQuery { SupportsSync = supportsSync, UserId = userId ?? Guid.Empty }; - return _deviceManager.GetDevices(deviceQuery); + return await _deviceManager.GetDevicesForUser(userId, supportsSync).ConfigureAwait(false); } /// <summary> @@ -63,9 +61,9 @@ namespace Jellyfin.Api.Controllers [HttpGet("Info")] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] - public ActionResult<DeviceInfo> GetDeviceInfo([FromQuery, Required] string id) + public async Task<ActionResult<DeviceInfo>> GetDeviceInfo([FromQuery, Required] string id) { - var deviceInfo = _deviceManager.GetDevice(id); + var deviceInfo = await _deviceManager.GetDevice(id).ConfigureAwait(false); if (deviceInfo == null) { return NotFound(); @@ -84,9 +82,9 @@ namespace Jellyfin.Api.Controllers [HttpGet("Options")] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] - public ActionResult<DeviceOptions> GetDeviceOptions([FromQuery, Required] string id) + public async Task<ActionResult<DeviceOptions>> GetDeviceOptions([FromQuery, Required] string id) { - var deviceInfo = _deviceManager.GetDeviceOptions(id); + var deviceInfo = await _deviceManager.GetDeviceOptions(id).ConfigureAwait(false); if (deviceInfo == null) { return NotFound(); @@ -101,22 +99,14 @@ namespace Jellyfin.Api.Controllers /// <param name="id">Device Id.</param> /// <param name="deviceOptions">Device Options.</param> /// <response code="204">Device options updated.</response> - /// <response code="404">Device not found.</response> - /// <returns>A <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if the device could not be found.</returns> + /// <returns>A <see cref="NoContentResult"/>.</returns> [HttpPost("Options")] [ProducesResponseType(StatusCodes.Status204NoContent)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public ActionResult UpdateDeviceOptions( + public async Task<ActionResult> UpdateDeviceOptions( [FromQuery, Required] string id, - [FromBody, Required] DeviceOptions deviceOptions) + [FromBody, Required] DeviceOptionsDto deviceOptions) { - var existingDeviceOptions = _deviceManager.GetDeviceOptions(id); - if (existingDeviceOptions == null) - { - return NotFound(); - } - - _deviceManager.UpdateDeviceOptions(id, deviceOptions); + await _deviceManager.UpdateDeviceOptions(id, deviceOptions.CustomName).ConfigureAwait(false); return NoContent(); } @@ -130,19 +120,19 @@ namespace Jellyfin.Api.Controllers [HttpDelete] [ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status404NotFound)] - public ActionResult DeleteDevice([FromQuery, Required] string id) + public async Task<ActionResult> DeleteDevice([FromQuery, Required] string id) { - var existingDevice = _deviceManager.GetDevice(id); + var existingDevice = await _deviceManager.GetDevice(id).ConfigureAwait(false); if (existingDevice == null) { return NotFound(); } - var sessions = _authenticationRepository.Get(new AuthenticationInfoQuery { DeviceId = id }).Items; + var sessions = await _deviceManager.GetDevices(new DeviceQuery { DeviceId = id }).ConfigureAwait(false); - foreach (var session in sessions) + foreach (var session in sessions.Items) { - _sessionManager.Logout(session); + await _sessionManager.Logout(session).ConfigureAwait(false); } return NoContent(); diff --git a/Jellyfin.Api/Controllers/DisplayPreferencesController.cs b/Jellyfin.Api/Controllers/DisplayPreferencesController.cs index 87b4577b62..2079476d0a 100644 --- a/Jellyfin.Api/Controllers/DisplayPreferencesController.cs +++ b/Jellyfin.Api/Controllers/DisplayPreferencesController.cs @@ -137,7 +137,7 @@ 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; diff --git a/Jellyfin.Api/Controllers/DlnaServerController.cs b/Jellyfin.Api/Controllers/DlnaServerController.cs index 694d16ad97..4e8c01577e 100644 --- a/Jellyfin.Api/Controllers/DlnaServerController.cs +++ b/Jellyfin.Api/Controllers/DlnaServerController.cs @@ -7,7 +7,9 @@ using System.Threading.Tasks; using Emby.Dlna; using Emby.Dlna.Main; using Jellyfin.Api.Attributes; +using Jellyfin.Api.Constants; using MediaBrowser.Controller.Dlna; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; @@ -17,6 +19,7 @@ namespace Jellyfin.Api.Controllers /// Dlna Server Controller. /// </summary> [Route("Dlna")] + [Authorize(Policy = Policies.AnonymousLanAccessPolicy)] public class DlnaServerController : BaseJellyfinApiController { private readonly IDlnaManager _dlnaManager; diff --git a/Jellyfin.Api/Controllers/DynamicHlsController.cs b/Jellyfin.Api/Controllers/DynamicHlsController.cs index 62283d038d..42e82dd5b1 100644 --- a/Jellyfin.Api/Controllers/DynamicHlsController.cs +++ b/Jellyfin.Api/Controllers/DynamicHlsController.cs @@ -5,7 +5,6 @@ using System.Diagnostics.CodeAnalysis; using System.Globalization; using System.IO; using System.Linq; -using System.Runtime.InteropServices; using System.Text; using System.Threading; using System.Threading.Tasks; @@ -150,7 +149,7 @@ namespace Jellyfin.Api.Controllers /// <param name="cpuCoreLimit">Optional. The limit of how many cpu cores to use.</param> /// <param name="liveStreamId">The live stream id.</param> /// <param name="enableMpegtsM2TsMode">Optional. Whether to enable the MpegtsM2Ts mode.</param> - /// <param name="videoCodec">Optional. Specify a video codec to encode to, e.g. h264. If omitted the server will auto-select using the url's extension. Options: h265, h264, mpeg4, theora, vpx, wmv.</param> + /// <param name="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> @@ -316,7 +315,7 @@ namespace Jellyfin.Api.Controllers /// <param name="cpuCoreLimit">Optional. The limit of how many cpu cores to use.</param> /// <param name="liveStreamId">The live stream id.</param> /// <param name="enableMpegtsM2TsMode">Optional. Whether to enable the MpegtsM2Ts mode.</param> - /// <param name="videoCodec">Optional. Specify a video codec to encode to, e.g. h264. If omitted the server will auto-select using the url's extension. Options: h265, h264, mpeg4, theora, vpx, wmv.</param> + /// <param name="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> @@ -482,7 +481,7 @@ namespace Jellyfin.Api.Controllers /// <param name="cpuCoreLimit">Optional. The limit of how many cpu cores to use.</param> /// <param name="liveStreamId">The live stream id.</param> /// <param name="enableMpegtsM2TsMode">Optional. Whether to enable the MpegtsM2Ts mode.</param> - /// <param name="videoCodec">Optional. Specify a video codec to encode to, e.g. h264. If omitted the server will auto-select using the url's extension. Options: h265, h264, mpeg4, theora, vpx, wmv.</param> + /// <param name="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> @@ -813,7 +812,7 @@ namespace Jellyfin.Api.Controllers /// <param name="cpuCoreLimit">Optional. The limit of how many cpu cores to use.</param> /// <param name="liveStreamId">The live stream id.</param> /// <param name="enableMpegtsM2TsMode">Optional. Whether to enable the MpegtsM2Ts mode.</param> - /// <param name="videoCodec">Optional. Specify a video codec to encode to, e.g. h264. If omitted the server will auto-select using the url's extension. Options: h265, h264, mpeg4, theora, vpx, wmv.</param> + /// <param name="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> @@ -1380,7 +1379,7 @@ namespace Jellyfin.Api.Controllers } else if (string.Equals(segmentFormat, "mp4", StringComparison.OrdinalIgnoreCase)) { - var outputFmp4HeaderArg = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) switch + var outputFmp4HeaderArg = OperatingSystem.IsWindows() switch { // on Windows, the path of fmp4 header file needs to be configured true => " -hls_fmp4_init_filename \"" + outputPrefix + "-1" + outputExtension + "\"", @@ -1496,7 +1495,7 @@ namespace Jellyfin.Api.Controllers args += " -ar " + state.OutputAudioSampleRate.Value.ToString(CultureInfo.InvariantCulture); } - args += _encodingHelper.GetAudioFilterParam(state, _encodingOptions, true); + args += _encodingHelper.GetAudioFilterParam(state, _encodingOptions); return args; } @@ -1710,7 +1709,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) diff --git a/Jellyfin.Api/Controllers/HlsSegmentController.cs b/Jellyfin.Api/Controllers/HlsSegmentController.cs index 473bdc523c..71caa0fe0d 100644 --- a/Jellyfin.Api/Controllers/HlsSegmentController.cs +++ b/Jellyfin.Api/Controllers/HlsSegmentController.cs @@ -69,7 +69,7 @@ namespace Jellyfin.Api.Controllers return BadRequest("Invalid segment."); } - return FileStreamResponseHelpers.GetStaticFileResult(file, MimeTypes.GetMimeType(file)!, false, HttpContext); + return FileStreamResponseHelpers.GetStaticFileResult(file, MimeTypes.GetMimeType(file), false, HttpContext); } /// <summary> @@ -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 e1b8080984..99ab7f232e 100644 --- a/Jellyfin.Api/Controllers/ImageByNameController.cs +++ b/Jellyfin.Api/Controllers/ImageByNameController.cs @@ -88,7 +88,7 @@ namespace Jellyfin.Api.Controllers } var contentType = MimeTypes.GetMimeType(path); - return File(System.IO.File.OpenRead(path), contentType); + return File(AsyncFile.OpenRead(path), contentType); } /// <summary> diff --git a/Jellyfin.Api/Controllers/ImageController.cs b/Jellyfin.Api/Controllers/ImageController.cs index 8f7500ac69..86933074d0 100644 --- a/Jellyfin.Api/Controllers/ImageController.cs +++ b/Jellyfin.Api/Controllers/ImageController.cs @@ -97,7 +97,7 @@ namespace Jellyfin.Api.Controllers [FromRoute, Required] ImageType imageType, [FromQuery] int? index = null) { - if (!RequestHelpers.AssertCanUpdateUser(_authContext, HttpContext.Request, userId, true)) + if (!await RequestHelpers.AssertCanUpdateUser(_authContext, HttpContext.Request, userId, true).ConfigureAwait(false)) { return StatusCode(StatusCodes.Status403Forbidden, "User is not allowed to update the image."); } @@ -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) { @@ -144,7 +144,7 @@ namespace Jellyfin.Api.Controllers [FromRoute, Required] ImageType imageType, [FromRoute] int index) { - if (!RequestHelpers.AssertCanUpdateUser(_authContext, HttpContext.Request, userId, true)) + if (!await RequestHelpers.AssertCanUpdateUser(_authContext, HttpContext.Request, userId, true).ConfigureAwait(false)) { return StatusCode(StatusCodes.Status403Forbidden, "User is not allowed to update the image."); } @@ -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) { @@ -190,7 +190,7 @@ namespace Jellyfin.Api.Controllers [FromRoute, Required] ImageType imageType, [FromQuery] int? index = null) { - if (!RequestHelpers.AssertCanUpdateUser(_authContext, HttpContext.Request, userId, true)) + if (!await RequestHelpers.AssertCanUpdateUser(_authContext, HttpContext.Request, userId, true).ConfigureAwait(false)) { return StatusCode(StatusCodes.Status403Forbidden, "User is not allowed to delete the image."); } @@ -234,7 +234,7 @@ namespace Jellyfin.Api.Controllers [FromRoute, Required] ImageType imageType, [FromRoute] int index) { - if (!RequestHelpers.AssertCanUpdateUser(_authContext, HttpContext.Request, userId, true)) + if (!await RequestHelpers.AssertCanUpdateUser(_authContext, HttpContext.Request, userId, true).ConfigureAwait(false)) { return StatusCode(StatusCodes.Status403Forbidden, "User is not allowed to delete the image."); } @@ -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); @@ -2007,7 +2007,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 +2026,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 f232dffaa5..a6c2e07c93 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,43 +223,7 @@ namespace Jellyfin.Api.Controllers : null; var dtoOptions = new DtoOptions { Fields = fields } .AddClientFields(Request) - .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes!); - var items = _musicManager.GetInstantMixFromItem(item, user, dtoOptions); - return GetResult(items, user, limit, dtoOptions); - } - - /// <summary> - /// Creates an instant playlist based on a given genre. - /// </summary> - /// <param name="id">The item id.</param> - /// <param name="userId">Optional. Filter by user id, and attach user data.</param> - /// <param name="limit">Optional. The maximum number of records to return.</param> - /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param> - /// <param name="enableImages">Optional. Include image information in output.</param> - /// <param name="enableUserData">Optional. Include user data.</param> - /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param> - /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> - /// <response code="200">Instant playlist returned.</response> - /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the playlist items.</returns> - [HttpGet("MusicGenres/{id}/InstantMix")] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult<QueryResult<BaseItemDto>> GetInstantMixFromMusicGenreById( - [FromRoute, Required] Guid id, - [FromQuery] Guid? userId, - [FromQuery] int? limit, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, - [FromQuery] bool? enableImages, - [FromQuery] bool? enableUserData, - [FromQuery] int? imageTypeLimit, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes) - { - var item = _libraryManager.GetItemById(id); - var user = userId.HasValue && !userId.Equals(Guid.Empty) - ? _userManager.GetUserById(userId.Value) - : 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); } @@ -295,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); } @@ -352,8 +316,7 @@ namespace Jellyfin.Api.Controllers /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the playlist items.</returns> [HttpGet("MusicGenres/InstantMix")] [ProducesResponseType(StatusCodes.Status200OK)] - [Obsolete("Use GetInstantMixFromMusicGenres instead")] - public ActionResult<QueryResult<BaseItemDto>> GetInstantMixFromMusicGenreById2( + public ActionResult<QueryResult<BaseItemDto>> GetInstantMixFromMusicGenreById( [FromQuery, Required] Guid id, [FromQuery] Guid? userId, [FromQuery] int? limit, @@ -363,15 +326,15 @@ namespace Jellyfin.Api.Controllers [FromQuery] int? imageTypeLimit, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes) { - return GetInstantMixFromMusicGenreById( - id, - userId, - limit, - fields, - enableImages, - enableUserData, - imageTypeLimit, - enableImageTypes); + var item = _libraryManager.GetItemById(id); + var user = userId.HasValue && !userId.Equals(Guid.Empty) + ? _userManager.GetUserById(userId.Value) + : null; + var dtoOptions = new DtoOptions { Fields = fields } + .AddClientFields(Request) + .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); + var items = _musicManager.GetInstantMixFromItem(item, user, dtoOptions); + return GetResult(items, user, limit, dtoOptions); } private QueryResult<BaseItemDto> GetResult(List<BaseItem> items, User? user, int? limit, DtoOptions dtoOptions) diff --git a/Jellyfin.Api/Controllers/ItemLookupController.cs b/Jellyfin.Api/Controllers/ItemLookupController.cs index 9fa307858f..448510c06a 100644 --- a/Jellyfin.Api/Controllers/ItemLookupController.cs +++ b/Jellyfin.Api/Controllers/ItemLookupController.cs @@ -1,13 +1,10 @@ using System; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; -using System.IO; using System.Text.Json; using System.Threading; using System.Threading.Tasks; -using Jellyfin.Api.Attributes; using Jellyfin.Api.Constants; -using MediaBrowser.Common.Extensions; using MediaBrowser.Controller; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Entities; @@ -17,7 +14,6 @@ using MediaBrowser.Controller.Entities.TV; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.IO; -using MediaBrowser.Model.Net; using MediaBrowser.Model.Providers; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; diff --git a/Jellyfin.Api/Controllers/ItemUpdateController.cs b/Jellyfin.Api/Controllers/ItemUpdateController.cs index a9f4a5a58c..fd137f98f1 100644 --- a/Jellyfin.Api/Controllers/ItemUpdateController.cs +++ b/Jellyfin.Api/Controllers/ItemUpdateController.cs @@ -154,11 +154,11 @@ namespace Jellyfin.Api.Controllers }; if (!item.IsVirtualItem - && !(item is ICollectionFolder) - && !(item is UserView) - && !(item is AggregateFolder) - && !(item is LiveTvChannel) - && !(item is IItemByName) + && item is not ICollectionFolder + && item is not UserView + && item is not AggregateFolder + && item is not LiveTvChannel + && item is not IItemByName && item.SourceType == SourceType.Library) { var inheritedContentType = _libraryManager.GetInheritedContentType(item); @@ -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 35c27dd0e9..f0d44e5cc8 100644 --- a/Jellyfin.Api/Controllers/ItemsController.cs +++ b/Jellyfin.Api/Controllers/ItemsController.cs @@ -241,7 +241,7 @@ namespace Jellyfin.Api.Controllers var item = _libraryManager.GetParentItem(parentId, userId); QueryResult<BaseItem> result; - if (!(item is Folder folder)) + if (item is not Folder folder) { folder = _libraryManager.GetUserRootFolder(); } @@ -285,9 +285,9 @@ namespace Jellyfin.Api.Controllers return Unauthorized($"{user.Username} is not permitted to access Library {item.Name}."); } - if ((recursive.HasValue && recursive.Value) || ids.Length != 0 || !(item is UserRootFolder)) + 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, diff --git a/Jellyfin.Api/Controllers/LibraryController.cs b/Jellyfin.Api/Controllers/LibraryController.cs index 4ed15e1d51..0be853ca4f 100644 --- a/Jellyfin.Api/Controllers/LibraryController.cs +++ b/Jellyfin.Api/Controllers/LibraryController.cs @@ -331,10 +331,10 @@ namespace Jellyfin.Api.Controllers [Authorize(Policy = Policies.DefaultAuthorization)] [ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status401Unauthorized)] - public ActionResult DeleteItem(Guid itemId) + public async Task<ActionResult> DeleteItem(Guid itemId) { var item = _libraryManager.GetItemById(itemId); - var auth = _authContext.GetAuthorizationInfo(Request); + var auth = await _authContext.GetAuthorizationInfo(Request).ConfigureAwait(false); var user = auth.User; if (!item.CanDelete(user)) @@ -361,7 +361,7 @@ namespace Jellyfin.Api.Controllers [Authorize(Policy = Policies.DefaultAuthorization)] [ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status401Unauthorized)] - public ActionResult DeleteItems([FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] ids) + public async Task<ActionResult> DeleteItems([FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] ids) { if (ids.Length == 0) { @@ -371,7 +371,7 @@ namespace Jellyfin.Api.Controllers foreach (var i in ids) { var item = _libraryManager.GetItemById(i); - var auth = _authContext.GetAuthorizationInfo(Request); + var auth = await _authContext.GetAuthorizationInfo(Request).ConfigureAwait(false); var user = auth.User; if (!item.CanDelete(user)) @@ -627,7 +627,7 @@ namespace Jellyfin.Api.Controllers return NotFound(); } - var auth = _authContext.GetAuthorizationInfo(Request); + var auth = await _authContext.GetAuthorizationInfo(Request).ConfigureAwait(false); var user = auth.User; @@ -700,7 +700,7 @@ namespace Jellyfin.Api.Controllers : _libraryManager.RootFolder) : _libraryManager.GetItemById(itemId); - if (item is Episode || (item is IItemByName && !(item is MusicArtist))) + if (item is Episode || (item is IItemByName && item is not MusicArtist)) { return new QueryResult<BaseItemDto>(); } diff --git a/Jellyfin.Api/Controllers/LibraryStructureController.cs b/Jellyfin.Api/Controllers/LibraryStructureController.cs index be9127dd39..ec1170411c 100644 --- a/Jellyfin.Api/Controllers/LibraryStructureController.cs +++ b/Jellyfin.Api/Controllers/LibraryStructureController.cs @@ -84,7 +84,7 @@ namespace Jellyfin.Api.Controllers if (paths != null && paths.Length > 0) { - libraryOptions.PathInfos = paths.Select(i => new MediaPathInfo { Path = i }).ToArray(); + libraryOptions.PathInfos = paths.Select(i => new MediaPathInfo(i)).ToArray(); } await _libraryManager.AddVirtualFolder(name, collectionType, libraryOptions, refreshLibrary).ConfigureAwait(false); @@ -212,7 +212,7 @@ namespace Jellyfin.Api.Controllers try { - var mediaPath = mediaPathDto.PathInfo ?? new MediaPathInfo { Path = mediaPathDto.Path }; + var mediaPath = mediaPathDto.PathInfo ?? new MediaPathInfo(mediaPathDto.Path ?? throw new ArgumentException("PathInfo and Path can't both be null.")); _libraryManager.AddMediaPath(mediaPathDto.Name, mediaPath); } diff --git a/Jellyfin.Api/Controllers/LiveTvController.cs b/Jellyfin.Api/Controllers/LiveTvController.cs index 24ee833ef7..b131530c93 100644 --- a/Jellyfin.Api/Controllers/LiveTvController.cs +++ b/Jellyfin.Api/Controllers/LiveTvController.cs @@ -2,7 +2,6 @@ using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.Diagnostics.CodeAnalysis; -using System.IO; using System.Linq; using System.Net.Http; using System.Net.Mime; @@ -429,10 +428,10 @@ namespace Jellyfin.Api.Controllers [HttpPost("Tuners/{tunerId}/Reset")] [ProducesResponseType(StatusCodes.Status204NoContent)] [Authorize(Policy = Policies.DefaultAuthorization)] - public ActionResult ResetTuner([FromRoute, Required] string tunerId) + public async Task<ActionResult> ResetTuner([FromRoute, Required] string tunerId) { - AssertUserCanManageLiveTv(); - _liveTvManager.ResetTuner(tunerId, CancellationToken.None); + await AssertUserCanManageLiveTv().ConfigureAwait(false); + await _liveTvManager.ResetTuner(tunerId, CancellationToken.None).ConfigureAwait(false); return NoContent(); } @@ -761,9 +760,9 @@ namespace Jellyfin.Api.Controllers [Authorize(Policy = Policies.DefaultAuthorization)] [ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status404NotFound)] - public ActionResult DeleteRecording([FromRoute, Required] Guid recordingId) + public async Task<ActionResult> DeleteRecording([FromRoute, Required] Guid recordingId) { - AssertUserCanManageLiveTv(); + await AssertUserCanManageLiveTv().ConfigureAwait(false); var item = _libraryManager.GetItemById(recordingId); if (item == null) @@ -790,7 +789,7 @@ namespace Jellyfin.Api.Controllers [ProducesResponseType(StatusCodes.Status204NoContent)] public async Task<ActionResult> CancelTimer([FromRoute, Required] string timerId) { - AssertUserCanManageLiveTv(); + await AssertUserCanManageLiveTv().ConfigureAwait(false); await _liveTvManager.CancelTimer(timerId).ConfigureAwait(false); return NoContent(); } @@ -808,7 +807,7 @@ namespace Jellyfin.Api.Controllers [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "timerId", Justification = "Imported from ServiceStack")] public async Task<ActionResult> UpdateTimer([FromRoute, Required] string timerId, [FromBody] TimerInfoDto timerInfo) { - AssertUserCanManageLiveTv(); + await AssertUserCanManageLiveTv().ConfigureAwait(false); await _liveTvManager.UpdateTimer(timerInfo, CancellationToken.None).ConfigureAwait(false); return NoContent(); } @@ -824,7 +823,7 @@ namespace Jellyfin.Api.Controllers [ProducesResponseType(StatusCodes.Status204NoContent)] public async Task<ActionResult> CreateTimer([FromBody] TimerInfoDto timerInfo) { - AssertUserCanManageLiveTv(); + await AssertUserCanManageLiveTv().ConfigureAwait(false); await _liveTvManager.CreateTimer(timerInfo, CancellationToken.None).ConfigureAwait(false); return NoContent(); } @@ -882,7 +881,7 @@ namespace Jellyfin.Api.Controllers [ProducesResponseType(StatusCodes.Status204NoContent)] public async Task<ActionResult> CancelSeriesTimer([FromRoute, Required] string timerId) { - AssertUserCanManageLiveTv(); + await AssertUserCanManageLiveTv().ConfigureAwait(false); await _liveTvManager.CancelSeriesTimer(timerId).ConfigureAwait(false); return NoContent(); } @@ -900,7 +899,7 @@ namespace Jellyfin.Api.Controllers [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "timerId", Justification = "Imported from ServiceStack")] public async Task<ActionResult> UpdateSeriesTimer([FromRoute, Required] string timerId, [FromBody] SeriesTimerInfoDto seriesTimerInfo) { - AssertUserCanManageLiveTv(); + await AssertUserCanManageLiveTv().ConfigureAwait(false); await _liveTvManager.UpdateSeriesTimer(seriesTimerInfo, CancellationToken.None).ConfigureAwait(false); return NoContent(); } @@ -916,7 +915,7 @@ namespace Jellyfin.Api.Controllers [ProducesResponseType(StatusCodes.Status204NoContent)] public async Task<ActionResult> CreateSeriesTimer([FromBody] SeriesTimerInfoDto seriesTimerInfo) { - AssertUserCanManageLiveTv(); + await AssertUserCanManageLiveTv().ConfigureAwait(false); await _liveTvManager.CreateSeriesTimer(seriesTimerInfo, CancellationToken.None).ConfigureAwait(false); return NoContent(); } @@ -1172,7 +1171,7 @@ namespace Jellyfin.Api.Controllers [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesVideoFile] - public async Task<ActionResult> GetLiveRecordingFile([FromRoute, Required] string recordingId) + public ActionResult GetLiveRecordingFile([FromRoute, Required] string recordingId) { var path = _liveTvManager.GetEmbyTvActiveRecordingPath(recordingId); @@ -1181,11 +1180,8 @@ namespace Jellyfin.Api.Controllers return NotFound(); } - await using var memoryStream = new MemoryStream(); - await new ProgressiveFileCopier(path, null, _transcodingJobHelper, CancellationToken.None) - .WriteToAsync(memoryStream, CancellationToken.None) - .ConfigureAwait(false); - return File(memoryStream, MimeTypes.GetMimeType(path)); + var stream = new ProgressiveFileStream(path, null, _transcodingJobHelper); + return new FileStreamResult(stream, MimeTypes.GetMimeType(path)); } /// <summary> @@ -1203,21 +1199,21 @@ 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)); } - private void AssertUserCanManageLiveTv() + private async Task AssertUserCanManageLiveTv() { - var user = _sessionContext.GetUser(Request); + var user = await _sessionContext.GetUser(Request).ConfigureAwait(false); if (user == null) { diff --git a/Jellyfin.Api/Controllers/MediaInfoController.cs b/Jellyfin.Api/Controllers/MediaInfoController.cs index e330f02b61..b422eb78c6 100644 --- a/Jellyfin.Api/Controllers/MediaInfoController.cs +++ b/Jellyfin.Api/Controllers/MediaInfoController.cs @@ -123,7 +123,7 @@ namespace Jellyfin.Api.Controllers [FromQuery, ParameterObsolete] bool? allowAudioStreamCopy, [FromBody(EmptyBodyBehavior = EmptyBodyBehavior.Allow)] PlaybackInfoDto? playbackInfoDto) { - var authInfo = _authContext.GetAuthorizationInfo(Request); + var authInfo = await _authContext.GetAuthorizationInfo(Request).ConfigureAwait(false); var profile = playbackInfoDto?.DeviceProfile; _logger.LogInformation("GetPostedPlaybackInfo profile: {@Profile}", profile); @@ -161,6 +161,11 @@ namespace Jellyfin.Api.Controllers liveStreamId) .ConfigureAwait(false); + if (info.ErrorCode != null) + { + return info; + } + if (profile != null) { // set device specific data @@ -179,7 +184,7 @@ namespace Jellyfin.Api.Controllers audioStreamIndex, subtitleStreamIndex, maxAudioChannels, - info!.PlaySessionId!, + info.PlaySessionId!, userId ?? Guid.Empty, enableDirectPlay.Value, enableDirectStream.Value, @@ -302,31 +307,16 @@ namespace Jellyfin.Api.Controllers /// </summary> /// <param name="size">The bitrate. Defaults to 102400.</param> /// <response code="200">Test buffer returned.</response> - /// <response code="400">Size has to be a numer between 0 and 10,000,000.</response> /// <returns>A <see cref="FileResult"/> with specified bitrate.</returns> [HttpGet("Playback/BitrateTest")] [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status400BadRequest)] - [Produces(MediaTypeNames.Application.Octet)] [ProducesFile(MediaTypeNames.Application.Octet)] - public ActionResult GetBitrateTestBytes([FromQuery] int size = 102400) + public ActionResult GetBitrateTestBytes([FromQuery][Range(1, 100_000_000, ErrorMessage = "The requested size must be greater than or equal to {1} and less than or equal to {2}")] int size = 102400) { - const int MaxSize = 10_000_000; - - if (size <= 0) - { - return BadRequest($"The requested size ({size}) is equal to or smaller than 0."); - } - - if (size > MaxSize) - { - return BadRequest($"The requested size ({size}) is larger than the max allowed value ({MaxSize})."); - } - byte[] buffer = ArrayPool<byte>.Shared.Rent(size); try { - 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 010a3b19a7..99c90d19e7 100644 --- a/Jellyfin.Api/Controllers/MoviesController.cs +++ b/Jellyfin.Api/Controllers/MoviesController.cs @@ -18,7 +18,6 @@ using MediaBrowser.Model.Dto; using MediaBrowser.Model.Entities; using MediaBrowser.Model.Querying; using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; namespace Jellyfin.Api.Controllers diff --git a/Jellyfin.Api/Controllers/PlaystateController.cs b/Jellyfin.Api/Controllers/PlaystateController.cs index f256c8c25c..6dee1c2192 100644 --- a/Jellyfin.Api/Controllers/PlaystateController.cs +++ b/Jellyfin.Api/Controllers/PlaystateController.cs @@ -72,13 +72,13 @@ namespace Jellyfin.Api.Controllers /// <returns>An <see cref="OkResult"/> containing the <see cref="UserItemDataDto"/>.</returns> [HttpPost("Users/{userId}/PlayedItems/{itemId}")] [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult<UserItemDataDto> MarkPlayedItem( + public async Task<ActionResult<UserItemDataDto>> MarkPlayedItem( [FromRoute, Required] Guid userId, [FromRoute, Required] Guid itemId, [FromQuery, ModelBinder(typeof(LegacyDateTimeModelBinder))] DateTime? datePlayed) { var user = _userManager.GetUserById(userId); - var session = RequestHelpers.GetSession(_sessionManager, _authContext, Request); + var session = await RequestHelpers.GetSession(_sessionManager, _authContext, Request).ConfigureAwait(false); var dto = UpdatePlayedStatus(user, itemId, true, datePlayed); foreach (var additionalUserInfo in session.AdditionalUsers) { @@ -98,10 +98,10 @@ namespace Jellyfin.Api.Controllers /// <returns>A <see cref="OkResult"/> containing the <see cref="UserItemDataDto"/>.</returns> [HttpDelete("Users/{userId}/PlayedItems/{itemId}")] [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult<UserItemDataDto> MarkUnplayedItem([FromRoute, Required] Guid userId, [FromRoute, Required] Guid itemId) + public async Task<ActionResult<UserItemDataDto>> MarkUnplayedItem([FromRoute, Required] Guid userId, [FromRoute, Required] Guid itemId) { var user = _userManager.GetUserById(userId); - var session = RequestHelpers.GetSession(_sessionManager, _authContext, Request); + var session = await RequestHelpers.GetSession(_sessionManager, _authContext, Request).ConfigureAwait(false); var dto = UpdatePlayedStatus(user, itemId, false, null); foreach (var additionalUserInfo in session.AdditionalUsers) { @@ -123,7 +123,7 @@ namespace Jellyfin.Api.Controllers public async Task<ActionResult> ReportPlaybackStart([FromBody] PlaybackStartInfo playbackStartInfo) { playbackStartInfo.PlayMethod = ValidatePlayMethod(playbackStartInfo.PlayMethod, playbackStartInfo.PlaySessionId); - playbackStartInfo.SessionId = RequestHelpers.GetSession(_sessionManager, _authContext, Request).Id; + playbackStartInfo.SessionId = await RequestHelpers.GetSessionId(_sessionManager, _authContext, Request).ConfigureAwait(false); await _sessionManager.OnPlaybackStart(playbackStartInfo).ConfigureAwait(false); return NoContent(); } @@ -139,7 +139,7 @@ namespace Jellyfin.Api.Controllers public async Task<ActionResult> ReportPlaybackProgress([FromBody] PlaybackProgressInfo playbackProgressInfo) { playbackProgressInfo.PlayMethod = ValidatePlayMethod(playbackProgressInfo.PlayMethod, playbackProgressInfo.PlaySessionId); - playbackProgressInfo.SessionId = RequestHelpers.GetSession(_sessionManager, _authContext, Request).Id; + playbackProgressInfo.SessionId = await RequestHelpers.GetSessionId(_sessionManager, _authContext, Request).ConfigureAwait(false); await _sessionManager.OnPlaybackProgress(playbackProgressInfo).ConfigureAwait(false); return NoContent(); } @@ -171,10 +171,11 @@ namespace Jellyfin.Api.Controllers _logger.LogDebug("ReportPlaybackStopped PlaySessionId: {0}", playbackStopInfo.PlaySessionId ?? string.Empty); if (!string.IsNullOrWhiteSpace(playbackStopInfo.PlaySessionId)) { - await _transcodingJobHelper.KillTranscodingJobs(_authContext.GetAuthorizationInfo(Request).DeviceId, playbackStopInfo.PlaySessionId, s => true).ConfigureAwait(false); + var authInfo = await _authContext.GetAuthorizationInfo(Request).ConfigureAwait(false); + await _transcodingJobHelper.KillTranscodingJobs(authInfo.DeviceId, playbackStopInfo.PlaySessionId, s => true).ConfigureAwait(false); } - playbackStopInfo.SessionId = RequestHelpers.GetSession(_sessionManager, _authContext, Request).Id; + playbackStopInfo.SessionId = await RequestHelpers.GetSessionId(_sessionManager, _authContext, Request).ConfigureAwait(false); await _sessionManager.OnPlaybackStopped(playbackStopInfo).ConfigureAwait(false); return NoContent(); } @@ -220,7 +221,7 @@ namespace Jellyfin.Api.Controllers }; playbackStartInfo.PlayMethod = ValidatePlayMethod(playbackStartInfo.PlayMethod, playbackStartInfo.PlaySessionId); - playbackStartInfo.SessionId = RequestHelpers.GetSession(_sessionManager, _authContext, Request).Id; + playbackStartInfo.SessionId = await RequestHelpers.GetSessionId(_sessionManager, _authContext, Request).ConfigureAwait(false); await _sessionManager.OnPlaybackStart(playbackStartInfo).ConfigureAwait(false); return NoContent(); } @@ -278,7 +279,7 @@ namespace Jellyfin.Api.Controllers }; playbackProgressInfo.PlayMethod = ValidatePlayMethod(playbackProgressInfo.PlayMethod, playbackProgressInfo.PlaySessionId); - playbackProgressInfo.SessionId = RequestHelpers.GetSession(_sessionManager, _authContext, Request).Id; + playbackProgressInfo.SessionId = await RequestHelpers.GetSessionId(_sessionManager, _authContext, Request).ConfigureAwait(false); await _sessionManager.OnPlaybackProgress(playbackProgressInfo).ConfigureAwait(false); return NoContent(); } @@ -320,10 +321,11 @@ namespace Jellyfin.Api.Controllers _logger.LogDebug("ReportPlaybackStopped PlaySessionId: {0}", playbackStopInfo.PlaySessionId ?? string.Empty); if (!string.IsNullOrWhiteSpace(playbackStopInfo.PlaySessionId)) { - await _transcodingJobHelper.KillTranscodingJobs(_authContext.GetAuthorizationInfo(Request).DeviceId, playbackStopInfo.PlaySessionId, s => true).ConfigureAwait(false); + var authInfo = await _authContext.GetAuthorizationInfo(Request).ConfigureAwait(false); + await _transcodingJobHelper.KillTranscodingJobs(authInfo.DeviceId, playbackStopInfo.PlaySessionId, s => true).ConfigureAwait(false); } - playbackStopInfo.SessionId = RequestHelpers.GetSession(_sessionManager, _authContext, Request).Id; + playbackStopInfo.SessionId = await RequestHelpers.GetSessionId(_sessionManager, _authContext, Request).ConfigureAwait(false); await _sessionManager.OnPlaybackStopped(playbackStopInfo).ConfigureAwait(false); return NoContent(); } diff --git a/Jellyfin.Api/Controllers/QuickConnectController.cs b/Jellyfin.Api/Controllers/QuickConnectController.cs index 3cd1bc6d4c..87b78fe93f 100644 --- a/Jellyfin.Api/Controllers/QuickConnectController.cs +++ b/Jellyfin.Api/Controllers/QuickConnectController.cs @@ -1,8 +1,10 @@ using System.ComponentModel.DataAnnotations; +using System.Threading.Tasks; using Jellyfin.Api.Constants; using Jellyfin.Api.Helpers; using MediaBrowser.Common.Extensions; using MediaBrowser.Controller.Authentication; +using MediaBrowser.Controller.Net; using MediaBrowser.Controller.QuickConnect; using MediaBrowser.Model.QuickConnect; using Microsoft.AspNetCore.Authorization; @@ -17,14 +19,17 @@ namespace Jellyfin.Api.Controllers public class QuickConnectController : BaseJellyfinApiController { private readonly IQuickConnect _quickConnect; + private readonly IAuthorizationContext _authContext; /// <summary> /// Initializes a new instance of the <see cref="QuickConnectController"/> class. /// </summary> /// <param name="quickConnect">Instance of the <see cref="IQuickConnect"/> interface.</param> - public QuickConnectController(IQuickConnect quickConnect) + /// <param name="authContext">Instance of the <see cref="IAuthorizationContext"/> interface.</param> + public QuickConnectController(IQuickConnect quickConnect, IAuthorizationContext authContext) { _quickConnect = quickConnect; + _authContext = authContext; } /// <summary> @@ -47,11 +52,12 @@ namespace Jellyfin.Api.Controllers /// <returns>A <see cref="QuickConnectResult"/> with a secret and code for future use or an error message.</returns> [HttpGet("Initiate")] [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult<QuickConnectResult> Initiate() + public async Task<ActionResult<QuickConnectResult>> Initiate() { try { - return _quickConnect.TryConnect(); + var auth = await _authContext.GetAuthorizationInfo(Request).ConfigureAwait(false); + return _quickConnect.TryConnect(auth); } catch (AuthenticationException) { @@ -96,7 +102,7 @@ namespace Jellyfin.Api.Controllers [Authorize(Policy = Policies.DefaultAuthorization)] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status403Forbidden)] - public ActionResult<bool> Authorize([FromQuery, Required] string code) + public async Task<ActionResult<bool>> Authorize([FromQuery, Required] string code) { var userId = ClaimHelpers.GetUserId(Request.HttpContext.User); if (!userId.HasValue) @@ -106,7 +112,7 @@ namespace Jellyfin.Api.Controllers try { - return _quickConnect.AuthorizeRequest(userId.Value, code); + return await _quickConnect.AuthorizeRequest(userId.Value, code).ConfigureAwait(false); } catch (AuthenticationException) { diff --git a/Jellyfin.Api/Controllers/RemoteImageController.cs b/Jellyfin.Api/Controllers/RemoteImageController.cs index ec836f43e3..35921ede8f 100644 --- a/Jellyfin.Api/Controllers/RemoteImageController.cs +++ b/Jellyfin.Api/Controllers/RemoteImageController.cs @@ -4,11 +4,10 @@ using System.ComponentModel.DataAnnotations; using System.IO; using System.Linq; using System.Net.Http; -using System.Net.Mime; using System.Threading; using System.Threading.Tasks; -using Jellyfin.Api.Attributes; using Jellyfin.Api.Constants; +using Jellyfin.Extensions; using MediaBrowser.Common.Extensions; using MediaBrowser.Common.Net; using MediaBrowser.Controller; @@ -16,7 +15,6 @@ using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Entities; using MediaBrowser.Model.IO; -using MediaBrowser.Model.Net; using MediaBrowser.Model.Providers; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; @@ -185,36 +183,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, true); - 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/SessionController.cs b/Jellyfin.Api/Controllers/SessionController.cs index 7bd0b6918f..3a04cb3a4a 100644 --- a/Jellyfin.Api/Controllers/SessionController.cs +++ b/Jellyfin.Api/Controllers/SessionController.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.Linq; using System.Threading; +using System.Threading.Tasks; using Jellyfin.Api.Constants; using Jellyfin.Api.Helpers; using Jellyfin.Api.ModelBinders; @@ -124,7 +125,7 @@ namespace Jellyfin.Api.Controllers [HttpPost("Sessions/{sessionId}/Viewing")] [Authorize(Policy = Policies.DefaultAuthorization)] [ProducesResponseType(StatusCodes.Status204NoContent)] - public ActionResult DisplayContent( + public async Task<ActionResult> DisplayContent( [FromRoute, Required] string sessionId, [FromQuery, Required] string itemType, [FromQuery, Required] string itemId, @@ -137,11 +138,12 @@ namespace Jellyfin.Api.Controllers ItemType = itemType }; - _sessionManager.SendBrowseCommand( - RequestHelpers.GetSession(_sessionManager, _authContext, Request).Id, + await _sessionManager.SendBrowseCommand( + await RequestHelpers.GetSessionId(_sessionManager, _authContext, Request).ConfigureAwait(false), sessionId, command, - CancellationToken.None); + CancellationToken.None) + .ConfigureAwait(false); return NoContent(); } @@ -162,7 +164,7 @@ namespace Jellyfin.Api.Controllers [HttpPost("Sessions/{sessionId}/Playing")] [Authorize(Policy = Policies.DefaultAuthorization)] [ProducesResponseType(StatusCodes.Status204NoContent)] - public ActionResult Play( + public async Task<ActionResult> Play( [FromRoute, Required] string sessionId, [FromQuery, Required] PlayCommand playCommand, [FromQuery, Required, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] itemIds, @@ -183,11 +185,12 @@ namespace Jellyfin.Api.Controllers StartIndex = startIndex }; - _sessionManager.SendPlayCommand( - RequestHelpers.GetSession(_sessionManager, _authContext, Request).Id, + await _sessionManager.SendPlayCommand( + await RequestHelpers.GetSessionId(_sessionManager, _authContext, Request).ConfigureAwait(false), sessionId, playRequest, - CancellationToken.None); + CancellationToken.None) + .ConfigureAwait(false); return NoContent(); } @@ -204,14 +207,14 @@ namespace Jellyfin.Api.Controllers [HttpPost("Sessions/{sessionId}/Playing/{command}")] [Authorize(Policy = Policies.DefaultAuthorization)] [ProducesResponseType(StatusCodes.Status204NoContent)] - public ActionResult SendPlaystateCommand( + public async Task<ActionResult> SendPlaystateCommand( [FromRoute, Required] string sessionId, [FromRoute, Required] PlaystateCommand command, [FromQuery] long? seekPositionTicks, [FromQuery] string? controllingUserId) { - _sessionManager.SendPlaystateCommand( - RequestHelpers.GetSession(_sessionManager, _authContext, Request).Id, + await _sessionManager.SendPlaystateCommand( + await RequestHelpers.GetSessionId(_sessionManager, _authContext, Request).ConfigureAwait(false), sessionId, new PlaystateRequest() { @@ -219,7 +222,8 @@ namespace Jellyfin.Api.Controllers ControllingUserId = controllingUserId, SeekPositionTicks = seekPositionTicks, }, - CancellationToken.None); + CancellationToken.None) + .ConfigureAwait(false); return NoContent(); } @@ -234,18 +238,18 @@ namespace Jellyfin.Api.Controllers [HttpPost("Sessions/{sessionId}/System/{command}")] [Authorize(Policy = Policies.DefaultAuthorization)] [ProducesResponseType(StatusCodes.Status204NoContent)] - public ActionResult SendSystemCommand( + public async Task<ActionResult> SendSystemCommand( [FromRoute, Required] string sessionId, [FromRoute, Required] GeneralCommandType command) { - var currentSession = RequestHelpers.GetSession(_sessionManager, _authContext, Request); + var currentSession = await RequestHelpers.GetSession(_sessionManager, _authContext, Request).ConfigureAwait(false); var generalCommand = new GeneralCommand { Name = command, ControllingUserId = currentSession.UserId }; - _sessionManager.SendGeneralCommand(currentSession.Id, sessionId, generalCommand, CancellationToken.None); + await _sessionManager.SendGeneralCommand(currentSession.Id, sessionId, generalCommand, CancellationToken.None); return NoContent(); } @@ -260,11 +264,11 @@ namespace Jellyfin.Api.Controllers [HttpPost("Sessions/{sessionId}/Command/{command}")] [Authorize(Policy = Policies.DefaultAuthorization)] [ProducesResponseType(StatusCodes.Status204NoContent)] - public ActionResult SendGeneralCommand( + public async Task<ActionResult> SendGeneralCommand( [FromRoute, Required] string sessionId, [FromRoute, Required] GeneralCommandType command) { - var currentSession = RequestHelpers.GetSession(_sessionManager, _authContext, Request); + var currentSession = await RequestHelpers.GetSession(_sessionManager, _authContext, Request).ConfigureAwait(false); var generalCommand = new GeneralCommand { @@ -272,7 +276,8 @@ namespace Jellyfin.Api.Controllers ControllingUserId = currentSession.UserId }; - _sessionManager.SendGeneralCommand(currentSession.Id, sessionId, generalCommand, CancellationToken.None); + await _sessionManager.SendGeneralCommand(currentSession.Id, sessionId, generalCommand, CancellationToken.None) + .ConfigureAwait(false); return NoContent(); } @@ -287,11 +292,12 @@ namespace Jellyfin.Api.Controllers [HttpPost("Sessions/{sessionId}/Command")] [Authorize(Policy = Policies.DefaultAuthorization)] [ProducesResponseType(StatusCodes.Status204NoContent)] - public ActionResult SendFullGeneralCommand( + public async Task<ActionResult> SendFullGeneralCommand( [FromRoute, Required] string sessionId, [FromBody, Required] GeneralCommand command) { - var currentSession = RequestHelpers.GetSession(_sessionManager, _authContext, Request); + var currentSession = await RequestHelpers.GetSession(_sessionManager, _authContext, Request) + .ConfigureAwait(false); if (command == null) { @@ -300,11 +306,12 @@ namespace Jellyfin.Api.Controllers command.ControllingUserId = currentSession.UserId; - _sessionManager.SendGeneralCommand( + await _sessionManager.SendGeneralCommand( currentSession.Id, sessionId, command, - CancellationToken.None); + CancellationToken.None) + .ConfigureAwait(false); return NoContent(); } @@ -319,7 +326,7 @@ namespace Jellyfin.Api.Controllers [HttpPost("Sessions/{sessionId}/Message")] [Authorize(Policy = Policies.DefaultAuthorization)] [ProducesResponseType(StatusCodes.Status204NoContent)] - public ActionResult SendMessageCommand( + public async Task<ActionResult> SendMessageCommand( [FromRoute, Required] string sessionId, [FromBody, Required] MessageCommand command) { @@ -328,7 +335,12 @@ namespace Jellyfin.Api.Controllers command.Header = "Message from Server"; } - _sessionManager.SendMessageCommand(RequestHelpers.GetSession(_sessionManager, _authContext, Request).Id, sessionId, command, CancellationToken.None); + await _sessionManager.SendMessageCommand( + await RequestHelpers.GetSessionId(_sessionManager, _authContext, Request).ConfigureAwait(false), + sessionId, + command, + CancellationToken.None) + .ConfigureAwait(false); return NoContent(); } @@ -383,7 +395,7 @@ namespace Jellyfin.Api.Controllers [HttpPost("Sessions/Capabilities")] [Authorize(Policy = Policies.DefaultAuthorization)] [ProducesResponseType(StatusCodes.Status204NoContent)] - public ActionResult PostCapabilities( + public async Task<ActionResult> PostCapabilities( [FromQuery] string? id, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] playableMediaTypes, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] GeneralCommandType[] supportedCommands, @@ -393,7 +405,7 @@ namespace Jellyfin.Api.Controllers { if (string.IsNullOrWhiteSpace(id)) { - id = RequestHelpers.GetSession(_sessionManager, _authContext, Request).Id; + id = await RequestHelpers.GetSessionId(_sessionManager, _authContext, Request).ConfigureAwait(false); } _sessionManager.ReportCapabilities(id, new ClientCapabilities @@ -417,13 +429,13 @@ namespace Jellyfin.Api.Controllers [HttpPost("Sessions/Capabilities/Full")] [Authorize(Policy = Policies.DefaultAuthorization)] [ProducesResponseType(StatusCodes.Status204NoContent)] - public ActionResult PostFullCapabilities( + public async Task<ActionResult> PostFullCapabilities( [FromQuery] string? id, [FromBody, Required] ClientCapabilitiesDto capabilities) { if (string.IsNullOrWhiteSpace(id)) { - id = RequestHelpers.GetSession(_sessionManager, _authContext, Request).Id; + id = await RequestHelpers.GetSessionId(_sessionManager, _authContext, Request).ConfigureAwait(false); } _sessionManager.ReportCapabilities(id, capabilities.ToClientCapabilities()); @@ -441,11 +453,11 @@ namespace Jellyfin.Api.Controllers [HttpPost("Sessions/Viewing")] [Authorize(Policy = Policies.DefaultAuthorization)] [ProducesResponseType(StatusCodes.Status204NoContent)] - public ActionResult ReportViewing( + public async Task<ActionResult> ReportViewing( [FromQuery] string? sessionId, [FromQuery, Required] string? itemId) { - string session = sessionId ?? RequestHelpers.GetSession(_sessionManager, _authContext, Request).Id; + string session = sessionId ?? await RequestHelpers.GetSessionId(_sessionManager, _authContext, Request).ConfigureAwait(false); _sessionManager.ReportNowViewingItem(session, itemId); return NoContent(); @@ -459,11 +471,11 @@ namespace Jellyfin.Api.Controllers [HttpPost("Sessions/Logout")] [Authorize(Policy = Policies.DefaultAuthorization)] [ProducesResponseType(StatusCodes.Status204NoContent)] - public ActionResult ReportSessionEnded() + public async Task<ActionResult> ReportSessionEnded() { - AuthorizationInfo auth = _authContext.GetAuthorizationInfo(Request); + AuthorizationInfo auth = await _authContext.GetAuthorizationInfo(Request).ConfigureAwait(false); - _sessionManager.Logout(auth.Token); + await _sessionManager.Logout(auth.Token).ConfigureAwait(false); return NoContent(); } diff --git a/Jellyfin.Api/Controllers/SubtitleController.cs b/Jellyfin.Api/Controllers/SubtitleController.cs index b473574e0a..db8307f284 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> @@ -361,7 +361,7 @@ namespace Jellyfin.Api.Controllers long positionTicks = 0; - var accessToken = _authContext.GetAuthorizationInfo(Request).Token; + var accessToken = (await _authContext.GetAuthorizationInfo(Request).ConfigureAwait(false)).Token; while (positionTicks < runtime) { @@ -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(); } diff --git a/Jellyfin.Api/Controllers/SuggestionsController.cs b/Jellyfin.Api/Controllers/SuggestionsController.cs index a811a29c31..97eec4bd2e 100644 --- a/Jellyfin.Api/Controllers/SuggestionsController.cs +++ b/Jellyfin.Api/Controllers/SuggestionsController.cs @@ -1,6 +1,5 @@ using System; using System.ComponentModel.DataAnnotations; -using System.Linq; using Jellyfin.Api.Constants; using Jellyfin.Api.Extensions; using Jellyfin.Api.ModelBinders; diff --git a/Jellyfin.Api/Controllers/SyncPlayController.cs b/Jellyfin.Api/Controllers/SyncPlayController.cs index f878f2329c..c6b70f3d20 100644 --- a/Jellyfin.Api/Controllers/SyncPlayController.cs +++ b/Jellyfin.Api/Controllers/SyncPlayController.cs @@ -1,6 +1,7 @@ using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.Threading; +using System.Threading.Tasks; using Jellyfin.Api.Constants; using Jellyfin.Api.Helpers; using Jellyfin.Api.Models.SyncPlayDtos; @@ -51,10 +52,10 @@ namespace Jellyfin.Api.Controllers [HttpPost("New")] [ProducesResponseType(StatusCodes.Status204NoContent)] [Authorize(Policy = Policies.SyncPlayCreateGroup)] - public ActionResult SyncPlayCreateGroup( + public async Task<ActionResult> SyncPlayCreateGroup( [FromBody, Required] NewGroupRequestDto requestData) { - var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request); + var currentSession = await RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request).ConfigureAwait(false); var syncPlayRequest = new NewGroupRequest(requestData.GroupName); _syncPlayManager.NewGroup(currentSession, syncPlayRequest, CancellationToken.None); return NoContent(); @@ -69,10 +70,10 @@ namespace Jellyfin.Api.Controllers [HttpPost("Join")] [ProducesResponseType(StatusCodes.Status204NoContent)] [Authorize(Policy = Policies.SyncPlayJoinGroup)] - public ActionResult SyncPlayJoinGroup( + public async Task<ActionResult> SyncPlayJoinGroup( [FromBody, Required] JoinGroupRequestDto requestData) { - var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request); + var currentSession = await RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request).ConfigureAwait(false); var syncPlayRequest = new JoinGroupRequest(requestData.GroupId); _syncPlayManager.JoinGroup(currentSession, syncPlayRequest, CancellationToken.None); return NoContent(); @@ -86,9 +87,9 @@ namespace Jellyfin.Api.Controllers [HttpPost("Leave")] [ProducesResponseType(StatusCodes.Status204NoContent)] [Authorize(Policy = Policies.SyncPlayIsInGroup)] - public ActionResult SyncPlayLeaveGroup() + public async Task<ActionResult> SyncPlayLeaveGroup() { - var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request); + var currentSession = await RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request).ConfigureAwait(false); var syncPlayRequest = new LeaveGroupRequest(); _syncPlayManager.LeaveGroup(currentSession, syncPlayRequest, CancellationToken.None); return NoContent(); @@ -102,9 +103,9 @@ namespace Jellyfin.Api.Controllers [HttpGet("List")] [ProducesResponseType(StatusCodes.Status200OK)] [Authorize(Policy = Policies.SyncPlayJoinGroup)] - public ActionResult<IEnumerable<GroupInfoDto>> SyncPlayGetGroups() + public async Task<ActionResult<IEnumerable<GroupInfoDto>>> SyncPlayGetGroups() { - var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request); + var currentSession = await RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request).ConfigureAwait(false); var syncPlayRequest = new ListGroupsRequest(); return Ok(_syncPlayManager.ListGroups(currentSession, syncPlayRequest)); } @@ -118,10 +119,10 @@ namespace Jellyfin.Api.Controllers [HttpPost("SetNewQueue")] [ProducesResponseType(StatusCodes.Status204NoContent)] [Authorize(Policy = Policies.SyncPlayIsInGroup)] - public ActionResult SyncPlaySetNewQueue( + public async Task<ActionResult> SyncPlaySetNewQueue( [FromBody, Required] PlayRequestDto requestData) { - var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request); + var currentSession = await RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request).ConfigureAwait(false); var syncPlayRequest = new PlayGroupRequest( requestData.PlayingQueue, requestData.PlayingItemPosition, @@ -139,10 +140,10 @@ namespace Jellyfin.Api.Controllers [HttpPost("SetPlaylistItem")] [ProducesResponseType(StatusCodes.Status204NoContent)] [Authorize(Policy = Policies.SyncPlayIsInGroup)] - public ActionResult SyncPlaySetPlaylistItem( + public async Task<ActionResult> SyncPlaySetPlaylistItem( [FromBody, Required] SetPlaylistItemRequestDto requestData) { - var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request); + var currentSession = await RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request).ConfigureAwait(false); var syncPlayRequest = new SetPlaylistItemGroupRequest(requestData.PlaylistItemId); _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None); return NoContent(); @@ -157,11 +158,11 @@ namespace Jellyfin.Api.Controllers [HttpPost("RemoveFromPlaylist")] [ProducesResponseType(StatusCodes.Status204NoContent)] [Authorize(Policy = Policies.SyncPlayIsInGroup)] - public ActionResult SyncPlayRemoveFromPlaylist( + public async Task<ActionResult> SyncPlayRemoveFromPlaylist( [FromBody, Required] RemoveFromPlaylistRequestDto requestData) { - var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request); - var syncPlayRequest = new RemoveFromPlaylistGroupRequest(requestData.PlaylistItemIds); + var currentSession = await RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request).ConfigureAwait(false); + var syncPlayRequest = new RemoveFromPlaylistGroupRequest(requestData.PlaylistItemIds, requestData.ClearPlaylist, requestData.ClearPlayingItem); _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None); return NoContent(); } @@ -175,10 +176,10 @@ namespace Jellyfin.Api.Controllers [HttpPost("MovePlaylistItem")] [ProducesResponseType(StatusCodes.Status204NoContent)] [Authorize(Policy = Policies.SyncPlayIsInGroup)] - public ActionResult SyncPlayMovePlaylistItem( + public async Task<ActionResult> SyncPlayMovePlaylistItem( [FromBody, Required] MovePlaylistItemRequestDto requestData) { - var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request); + var currentSession = await RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request).ConfigureAwait(false); var syncPlayRequest = new MovePlaylistItemGroupRequest(requestData.PlaylistItemId, requestData.NewIndex); _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None); return NoContent(); @@ -193,10 +194,10 @@ namespace Jellyfin.Api.Controllers [HttpPost("Queue")] [ProducesResponseType(StatusCodes.Status204NoContent)] [Authorize(Policy = Policies.SyncPlayIsInGroup)] - public ActionResult SyncPlayQueue( + public async Task<ActionResult> SyncPlayQueue( [FromBody, Required] QueueRequestDto requestData) { - var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request); + var currentSession = await RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request).ConfigureAwait(false); var syncPlayRequest = new QueueGroupRequest(requestData.ItemIds, requestData.Mode); _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None); return NoContent(); @@ -210,9 +211,9 @@ namespace Jellyfin.Api.Controllers [HttpPost("Unpause")] [ProducesResponseType(StatusCodes.Status204NoContent)] [Authorize(Policy = Policies.SyncPlayIsInGroup)] - public ActionResult SyncPlayUnpause() + public async Task<ActionResult> SyncPlayUnpause() { - var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request); + var currentSession = await RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request).ConfigureAwait(false); var syncPlayRequest = new UnpauseGroupRequest(); _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None); return NoContent(); @@ -226,9 +227,9 @@ namespace Jellyfin.Api.Controllers [HttpPost("Pause")] [ProducesResponseType(StatusCodes.Status204NoContent)] [Authorize(Policy = Policies.SyncPlayIsInGroup)] - public ActionResult SyncPlayPause() + public async Task<ActionResult> SyncPlayPause() { - var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request); + var currentSession = await RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request).ConfigureAwait(false); var syncPlayRequest = new PauseGroupRequest(); _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None); return NoContent(); @@ -242,9 +243,9 @@ namespace Jellyfin.Api.Controllers [HttpPost("Stop")] [ProducesResponseType(StatusCodes.Status204NoContent)] [Authorize(Policy = Policies.SyncPlayIsInGroup)] - public ActionResult SyncPlayStop() + public async Task<ActionResult> SyncPlayStop() { - var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request); + var currentSession = await RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request).ConfigureAwait(false); var syncPlayRequest = new StopGroupRequest(); _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None); return NoContent(); @@ -259,10 +260,10 @@ namespace Jellyfin.Api.Controllers [HttpPost("Seek")] [ProducesResponseType(StatusCodes.Status204NoContent)] [Authorize(Policy = Policies.SyncPlayIsInGroup)] - public ActionResult SyncPlaySeek( + public async Task<ActionResult> SyncPlaySeek( [FromBody, Required] SeekRequestDto requestData) { - var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request); + var currentSession = await RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request).ConfigureAwait(false); var syncPlayRequest = new SeekGroupRequest(requestData.PositionTicks); _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None); return NoContent(); @@ -277,10 +278,10 @@ namespace Jellyfin.Api.Controllers [HttpPost("Buffering")] [ProducesResponseType(StatusCodes.Status204NoContent)] [Authorize(Policy = Policies.SyncPlayIsInGroup)] - public ActionResult SyncPlayBuffering( + public async Task<ActionResult> SyncPlayBuffering( [FromBody, Required] BufferRequestDto requestData) { - var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request); + var currentSession = await RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request).ConfigureAwait(false); var syncPlayRequest = new BufferGroupRequest( requestData.When, requestData.PositionTicks, @@ -299,10 +300,10 @@ namespace Jellyfin.Api.Controllers [HttpPost("Ready")] [ProducesResponseType(StatusCodes.Status204NoContent)] [Authorize(Policy = Policies.SyncPlayIsInGroup)] - public ActionResult SyncPlayReady( + public async Task<ActionResult> SyncPlayReady( [FromBody, Required] ReadyRequestDto requestData) { - var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request); + var currentSession = await RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request).ConfigureAwait(false); var syncPlayRequest = new ReadyGroupRequest( requestData.When, requestData.PositionTicks, @@ -321,10 +322,10 @@ namespace Jellyfin.Api.Controllers [HttpPost("SetIgnoreWait")] [ProducesResponseType(StatusCodes.Status204NoContent)] [Authorize(Policy = Policies.SyncPlayIsInGroup)] - public ActionResult SyncPlaySetIgnoreWait( + public async Task<ActionResult> SyncPlaySetIgnoreWait( [FromBody, Required] IgnoreWaitRequestDto requestData) { - var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request); + var currentSession = await RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request).ConfigureAwait(false); var syncPlayRequest = new IgnoreWaitGroupRequest(requestData.IgnoreWait); _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None); return NoContent(); @@ -339,10 +340,10 @@ namespace Jellyfin.Api.Controllers [HttpPost("NextItem")] [ProducesResponseType(StatusCodes.Status204NoContent)] [Authorize(Policy = Policies.SyncPlayIsInGroup)] - public ActionResult SyncPlayNextItem( + public async Task<ActionResult> SyncPlayNextItem( [FromBody, Required] NextItemRequestDto requestData) { - var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request); + var currentSession = await RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request).ConfigureAwait(false); var syncPlayRequest = new NextItemGroupRequest(requestData.PlaylistItemId); _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None); return NoContent(); @@ -357,10 +358,10 @@ namespace Jellyfin.Api.Controllers [HttpPost("PreviousItem")] [ProducesResponseType(StatusCodes.Status204NoContent)] [Authorize(Policy = Policies.SyncPlayIsInGroup)] - public ActionResult SyncPlayPreviousItem( + public async Task<ActionResult> SyncPlayPreviousItem( [FromBody, Required] PreviousItemRequestDto requestData) { - var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request); + var currentSession = await RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request).ConfigureAwait(false); var syncPlayRequest = new PreviousItemGroupRequest(requestData.PlaylistItemId); _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None); return NoContent(); @@ -375,10 +376,10 @@ namespace Jellyfin.Api.Controllers [HttpPost("SetRepeatMode")] [ProducesResponseType(StatusCodes.Status204NoContent)] [Authorize(Policy = Policies.SyncPlayIsInGroup)] - public ActionResult SyncPlaySetRepeatMode( + public async Task<ActionResult> SyncPlaySetRepeatMode( [FromBody, Required] SetRepeatModeRequestDto requestData) { - var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request); + var currentSession = await RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request).ConfigureAwait(false); var syncPlayRequest = new SetRepeatModeGroupRequest(requestData.Mode); _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None); return NoContent(); @@ -393,10 +394,10 @@ namespace Jellyfin.Api.Controllers [HttpPost("SetShuffleMode")] [ProducesResponseType(StatusCodes.Status204NoContent)] [Authorize(Policy = Policies.SyncPlayIsInGroup)] - public ActionResult SyncPlaySetShuffleMode( + public async Task<ActionResult> SyncPlaySetShuffleMode( [FromBody, Required] SetShuffleModeRequestDto requestData) { - var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request); + var currentSession = await RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request).ConfigureAwait(false); var syncPlayRequest = new SetShuffleModeGroupRequest(requestData.Mode); _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None); return NoContent(); @@ -410,10 +411,10 @@ namespace Jellyfin.Api.Controllers /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> [HttpPost("Ping")] [ProducesResponseType(StatusCodes.Status204NoContent)] - public ActionResult SyncPlayPing( + public async Task<ActionResult> SyncPlayPing( [FromBody, Required] PingRequestDto requestData) { - var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request); + var currentSession = await RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request).ConfigureAwait(false); var syncPlayRequest = new PingGroupRequest(requestData.Ping); _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None); return NoContent(); diff --git a/Jellyfin.Api/Controllers/SystemController.cs b/Jellyfin.Api/Controllers/SystemController.cs index bbbe5fb8da..904738bb45 100644 --- a/Jellyfin.Api/Controllers/SystemController.cs +++ b/Jellyfin.Api/Controllers/SystemController.cs @@ -66,7 +66,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 +78,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 +201,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); + FileStream stream = new FileStream(file.FullName, FileMode.Open, FileAccess.Read, fileShare, IODefaults.FileStreamBufferSize, FileOptions.Asynchronous); return File(stream, "text/plain; charset=utf-8"); } diff --git a/Jellyfin.Api/Controllers/TimeSyncController.cs b/Jellyfin.Api/Controllers/TimeSyncController.cs index 7df51c7afb..e7c5a71257 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 51d40994e1..5dd7733316 100644 --- a/Jellyfin.Api/Controllers/TvShowsController.cs +++ b/Jellyfin.Api/Controllers/TvShowsController.cs @@ -88,7 +88,7 @@ namespace Jellyfin.Api.Controllers { var options = new DtoOptions { Fields = fields } .AddClientFields(Request) - .AddAdditionalDtoOptions(enableImges, enableUserData, imageTypeLimit, enableImageTypes!); + .AddAdditionalDtoOptions(enableImges, enableUserData, imageTypeLimit, enableImageTypes); var result = _tvSeriesManager.GetNextUp( new NextUpQuery @@ -147,13 +147,13 @@ 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(enableImges, enableUserData, imageTypeLimit, enableImageTypes); var itemsResult = _libraryManager.GetItemList(new InternalItemsQuery(user) { @@ -223,12 +223,12 @@ 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. { var item = _libraryManager.GetItemById(seasonId.Value); - if (!(item is Season seasonItem)) + if (item is not Season seasonItem) { return NotFound("No season exists with Id " + seasonId); } @@ -237,7 +237,7 @@ namespace Jellyfin.Api.Controllers } else if (season.HasValue) // Season number was supplied. Get episodes by season number { - if (!(_libraryManager.GetItemById(seriesId) is Series series)) + if (_libraryManager.GetItemById(seriesId) is not Series series) { return NotFound("Series not found"); } @@ -252,7 +252,7 @@ namespace Jellyfin.Api.Controllers } else // No season number or season id was supplied. Returning all episodes. { - if (!(_libraryManager.GetItemById(seriesId) is Series series)) + if (_libraryManager.GetItemById(seriesId) is not Series series) { return NotFound("Series not found"); } @@ -336,7 +336,7 @@ namespace Jellyfin.Api.Controllers ? _userManager.GetUserById(userId.Value) : null; - if (!(_libraryManager.GetItemById(seriesId) is Series series)) + if (_libraryManager.GetItemById(seriesId) is not Series series) { return NotFound("Series not found"); } @@ -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 679f055bc2..bc9527a0bc 100644 --- a/Jellyfin.Api/Controllers/UniversalAudioController.cs +++ b/Jellyfin.Api/Controllers/UniversalAudioController.cs @@ -116,9 +116,9 @@ namespace Jellyfin.Api.Controllers [FromQuery] bool enableRedirection = true) { var deviceProfile = GetDeviceProfile(container, transcodingContainer, audioCodec, transcodingProtocol, breakOnNonKeyFrames, transcodingAudioChannels, maxAudioSampleRate, maxAudioBitDepth, maxAudioChannels); - _authorizationContext.GetAuthorizationInfo(Request).DeviceId = deviceId; + (await _authorizationContext.GetAuthorizationInfo(Request).ConfigureAwait(false)).DeviceId = deviceId; - var authInfo = _authorizationContext.GetAuthorizationInfo(Request); + var authInfo = await _authorizationContext.GetAuthorizationInfo(Request).ConfigureAwait(false); _logger.LogInformation("GetPostedPlaybackInfo profile: {@Profile}", deviceProfile); @@ -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/UserController.cs b/Jellyfin.Api/Controllers/UserController.cs index b13db4baa9..4263d4fe5f 100644 --- a/Jellyfin.Api/Controllers/UserController.cs +++ b/Jellyfin.Api/Controllers/UserController.cs @@ -14,6 +14,7 @@ using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Devices; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Net; +using MediaBrowser.Controller.QuickConnect; using MediaBrowser.Controller.Session; using MediaBrowser.Model.Configuration; using MediaBrowser.Model.Dto; @@ -38,6 +39,7 @@ namespace Jellyfin.Api.Controllers private readonly IAuthorizationContext _authContext; private readonly IServerConfigurationManager _config; private readonly ILogger _logger; + private readonly IQuickConnect _quickConnectManager; /// <summary> /// Initializes a new instance of the <see cref="UserController"/> class. @@ -49,6 +51,7 @@ namespace Jellyfin.Api.Controllers /// <param name="authContext">Instance of the <see cref="IAuthorizationContext"/> interface.</param> /// <param name="config">Instance of the <see cref="IServerConfigurationManager"/> interface.</param> /// <param name="logger">Instance of the <see cref="ILogger"/> interface.</param> + /// <param name="quickConnectManager">Instance of the <see cref="IQuickConnect"/> interface.</param> public UserController( IUserManager userManager, ISessionManager sessionManager, @@ -56,7 +59,8 @@ namespace Jellyfin.Api.Controllers IDeviceManager deviceManager, IAuthorizationContext authContext, IServerConfigurationManager config, - ILogger<UserController> logger) + ILogger<UserController> logger, + IQuickConnect quickConnectManager) { _userManager = userManager; _sessionManager = sessionManager; @@ -65,6 +69,7 @@ namespace Jellyfin.Api.Controllers _authContext = authContext; _config = config; _logger = logger; + _quickConnectManager = quickConnectManager; } /// <summary> @@ -77,11 +82,11 @@ namespace Jellyfin.Api.Controllers [HttpGet] [Authorize(Policy = Policies.DefaultAuthorization)] [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult<IEnumerable<UserDto>> GetUsers( + public async Task<ActionResult<IEnumerable<UserDto>>> GetUsers( [FromQuery] bool? isHidden, [FromQuery] bool? isDisabled) { - var users = Get(isHidden, isDisabled, false, false); + var users = await Get(isHidden, isDisabled, false, false).ConfigureAwait(false); return Ok(users); } @@ -92,15 +97,15 @@ namespace Jellyfin.Api.Controllers /// <returns>An <see cref="IEnumerable{UserDto}"/> containing the public users.</returns> [HttpGet("Public")] [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult<IEnumerable<UserDto>> GetPublicUsers() + public async Task<ActionResult<IEnumerable<UserDto>>> GetPublicUsers() { // If the startup wizard hasn't been completed then just return all users if (!_config.Configuration.IsStartupWizardCompleted) { - return Ok(Get(false, false, false, false)); + return Ok(await Get(false, false, false, false).ConfigureAwait(false)); } - return Ok(Get(false, false, true, true)); + return Ok(await Get(false, false, true, true).ConfigureAwait(false)); } /// <summary> @@ -141,7 +146,7 @@ namespace Jellyfin.Api.Controllers public async Task<ActionResult> DeleteUser([FromRoute, Required] Guid userId) { var user = _userManager.GetUserById(userId); - _sessionManager.RevokeUserTokens(user.Id, null); + await _sessionManager.RevokeUserTokens(user.Id, null).ConfigureAwait(false); await _userManager.DeleteUserAsync(userId).ConfigureAwait(false); return NoContent(); } @@ -195,7 +200,7 @@ namespace Jellyfin.Api.Controllers [ProducesResponseType(StatusCodes.Status200OK)] public async Task<ActionResult<AuthenticationResult>> AuthenticateUserByName([FromBody, Required] AuthenticateUserByName request) { - var auth = _authContext.GetAuthorizationInfo(Request); + var auth = await _authContext.GetAuthorizationInfo(Request).ConfigureAwait(false); try { @@ -228,23 +233,11 @@ namespace Jellyfin.Api.Controllers /// <returns>A <see cref="Task"/> containing an <see cref="AuthenticationRequest"/> with information about the new session.</returns> [HttpPost("AuthenticateWithQuickConnect")] [ProducesResponseType(StatusCodes.Status200OK)] - public async Task<ActionResult<AuthenticationResult>> AuthenticateWithQuickConnect([FromBody, Required] QuickConnectDto request) + public ActionResult<AuthenticationResult> AuthenticateWithQuickConnect([FromBody, Required] QuickConnectDto request) { - var auth = _authContext.GetAuthorizationInfo(Request); - try { - var authRequest = new AuthenticationRequest - { - App = auth.Client, - AppVersion = auth.Version, - DeviceId = auth.DeviceId, - DeviceName = auth.Device, - }; - - return await _sessionManager.AuthenticateQuickConnect( - authRequest, - request.Token).ConfigureAwait(false); + return _quickConnectManager.GetAuthorizedRequest(request.Secret); } catch (SecurityException e) { @@ -271,7 +264,7 @@ namespace Jellyfin.Api.Controllers [FromRoute, Required] Guid userId, [FromBody, Required] UpdateUserPassword request) { - if (!RequestHelpers.AssertCanUpdateUser(_authContext, HttpContext.Request, userId, true)) + if (!await RequestHelpers.AssertCanUpdateUser(_authContext, HttpContext.Request, userId, true).ConfigureAwait(false)) { return StatusCode(StatusCodes.Status403Forbidden, "User is not allowed to update the password."); } @@ -303,9 +296,9 @@ namespace Jellyfin.Api.Controllers await _userManager.ChangePassword(user, request.NewPw).ConfigureAwait(false); - var currentToken = _authContext.GetAuthorizationInfo(Request).Token; + var currentToken = (await _authContext.GetAuthorizationInfo(Request).ConfigureAwait(false)).Token; - _sessionManager.RevokeUserTokens(user.Id, currentToken); + await _sessionManager.RevokeUserTokens(user.Id, currentToken).ConfigureAwait(false); } return NoContent(); @@ -325,11 +318,11 @@ namespace Jellyfin.Api.Controllers [ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status403Forbidden)] [ProducesResponseType(StatusCodes.Status404NotFound)] - public ActionResult UpdateUserEasyPassword( + public async Task<ActionResult> UpdateUserEasyPassword( [FromRoute, Required] Guid userId, [FromBody, Required] UpdateUserEasyPassword request) { - if (!RequestHelpers.AssertCanUpdateUser(_authContext, HttpContext.Request, userId, true)) + if (!await RequestHelpers.AssertCanUpdateUser(_authContext, HttpContext.Request, userId, true).ConfigureAwait(false)) { return StatusCode(StatusCodes.Status403Forbidden, "User is not allowed to update the easy password."); } @@ -343,11 +336,11 @@ namespace Jellyfin.Api.Controllers if (request.ResetPassword) { - _userManager.ResetEasyPassword(user); + await _userManager.ResetEasyPassword(user).ConfigureAwait(false); } else { - _userManager.ChangeEasyPassword(user, request.NewPw, request.NewPassword); + await _userManager.ChangeEasyPassword(user, request.NewPw, request.NewPassword).ConfigureAwait(false); } return NoContent(); @@ -371,7 +364,7 @@ namespace Jellyfin.Api.Controllers [FromRoute, Required] Guid userId, [FromBody, Required] UserDto updateUser) { - if (!RequestHelpers.AssertCanUpdateUser(_authContext, HttpContext.Request, userId, false)) + if (!await RequestHelpers.AssertCanUpdateUser(_authContext, HttpContext.Request, userId, false).ConfigureAwait(false)) { return StatusCode(StatusCodes.Status403Forbidden, "User update not allowed."); } @@ -431,8 +424,8 @@ namespace Jellyfin.Api.Controllers return StatusCode(StatusCodes.Status403Forbidden, "There must be at least one enabled user in the system."); } - var currentToken = _authContext.GetAuthorizationInfo(Request).Token; - _sessionManager.RevokeUserTokens(user.Id, currentToken); + var currentToken = (await _authContext.GetAuthorizationInfo(Request).ConfigureAwait(false)).Token; + await _sessionManager.RevokeUserTokens(user.Id, currentToken).ConfigureAwait(false); } await _userManager.UpdatePolicyAsync(userId, newPolicy).ConfigureAwait(false); @@ -456,7 +449,7 @@ namespace Jellyfin.Api.Controllers [FromRoute, Required] Guid userId, [FromBody, Required] UserConfiguration userConfig) { - if (!RequestHelpers.AssertCanUpdateUser(_authContext, HttpContext.Request, userId, false)) + if (!await RequestHelpers.AssertCanUpdateUser(_authContext, HttpContext.Request, userId, false).ConfigureAwait(false)) { return StatusCode(StatusCodes.Status403Forbidden, "User configuration update not allowed"); } @@ -555,7 +548,7 @@ namespace Jellyfin.Api.Controllers return _userManager.GetUserDto(user); } - private IEnumerable<UserDto> Get(bool? isHidden, bool? isDisabled, bool filterByDevice, bool filterByNetwork) + private async Task<IEnumerable<UserDto>> Get(bool? isHidden, bool? isDisabled, bool filterByDevice, bool filterByNetwork) { var users = _userManager.Users; @@ -571,7 +564,7 @@ namespace Jellyfin.Api.Controllers if (filterByDevice) { - var deviceId = _authContext.GetAuthorizationInfo(Request).DeviceId; + var deviceId = (await _authContext.GetAuthorizationInfo(Request).ConfigureAwait(false)).DeviceId; if (!string.IsNullOrWhiteSpace(deviceId)) { diff --git a/Jellyfin.Api/Controllers/UserViewsController.cs b/Jellyfin.Api/Controllers/UserViewsController.cs index 7bc5ecdf1d..3d27371f63 100644 --- a/Jellyfin.Api/Controllers/UserViewsController.cs +++ b/Jellyfin.Api/Controllers/UserViewsController.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.Globalization; using System.Linq; +using System.Threading.Tasks; using Jellyfin.Api.Extensions; using Jellyfin.Api.ModelBinders; using Jellyfin.Api.Models.UserViewDtos; @@ -64,7 +65,7 @@ namespace Jellyfin.Api.Controllers /// <returns>An <see cref="OkResult"/> containing the user views.</returns> [HttpGet("Users/{userId}/Views")] [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult<QueryResult<BaseItemDto>> GetUserViews( + public async Task<ActionResult<QueryResult<BaseItemDto>>> GetUserViews( [FromRoute, Required] Guid userId, [FromQuery] bool? includeExternalContent, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] presetViews, @@ -86,7 +87,7 @@ namespace Jellyfin.Api.Controllers query.PresetViews = presetViews; } - var app = _authContext.GetAuthorizationInfo(Request).Client ?? string.Empty; + var app = (await _authContext.GetAuthorizationInfo(Request).ConfigureAwait(false)).Client ?? string.Empty; if (app.IndexOf("emby rt", StringComparison.OrdinalIgnoreCase) != -1) { query.PresetViews = new[] { CollectionType.Movies, CollectionType.TvShows }; diff --git a/Jellyfin.Api/Controllers/VideoHlsController.cs b/Jellyfin.Api/Controllers/VideoHlsController.cs index 6a720b1a45..ef25db8c93 100644 --- a/Jellyfin.Api/Controllers/VideoHlsController.cs +++ b/Jellyfin.Api/Controllers/VideoHlsController.cs @@ -3,7 +3,6 @@ using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.Globalization; using System.IO; -using System.Runtime.InteropServices; using System.Threading; using System.Threading.Tasks; using Jellyfin.Api.Attributes; @@ -20,12 +19,10 @@ using MediaBrowser.Controller.MediaEncoding; using MediaBrowser.Controller.Net; using MediaBrowser.Model.Configuration; using MediaBrowser.Model.Dlna; -using MediaBrowser.Model.IO; using MediaBrowser.Model.Net; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; namespace Jellyfin.Api.Controllers @@ -140,7 +137,7 @@ namespace Jellyfin.Api.Controllers /// <param name="cpuCoreLimit">Optional. The limit of how many cpu cores to use.</param> /// <param name="liveStreamId">The live stream id.</param> /// <param name="enableMpegtsM2TsMode">Optional. Whether to enable the MpegtsM2Ts mode.</param> - /// <param name="videoCodec">Optional. Specify a video codec to encode to, e.g. h264. If omitted the server will auto-select using the url's extension. Options: h265, h264, mpeg4, theora, vpx, wmv.</param> + /// <param name="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> @@ -267,6 +264,9 @@ namespace Jellyfin.Api.Controllers // 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, @@ -281,7 +281,7 @@ namespace Jellyfin.Api.Controllers _deviceManager, _transcodingJobHelper, TranscodingJobType, - cancellationTokenSource.Token) + cancellationToken) .ConfigureAwait(false); TranscodingJobDto? job = null; @@ -290,7 +290,7 @@ namespace Jellyfin.Api.Controllers if (!System.IO.File.Exists(playlistPath)) { var transcodingLock = _transcodingJobHelper.GetTranscodingLock(playlistPath); - await transcodingLock.WaitAsync(cancellationTokenSource.Token).ConfigureAwait(false); + await transcodingLock.WaitAsync(cancellationToken).ConfigureAwait(false); try { if (!System.IO.File.Exists(playlistPath)) @@ -317,7 +317,7 @@ namespace Jellyfin.Api.Controllers minSegments = state.MinSegments; if (minSegments > 0) { - await HlsHelpers.WaitForMinimumSegmentCount(playlistPath, minSegments, _logger, cancellationTokenSource.Token).ConfigureAwait(false); + await HlsHelpers.WaitForMinimumSegmentCount(playlistPath, minSegments, _logger, cancellationToken).ConfigureAwait(false); } } } @@ -366,8 +366,7 @@ namespace Jellyfin.Api.Controllers else if (string.Equals(segmentFormat, "mp4", StringComparison.OrdinalIgnoreCase)) { var outputFmp4HeaderArg = string.Empty; - var isWindows = RuntimeInformation.IsOSPlatform(OSPlatform.Windows); - if (isWindows) + if (OperatingSystem.IsWindows()) { // on Windows, the path of fmp4 header file needs to be configured outputFmp4HeaderArg = " -hls_fmp4_init_filename \"" + outputPrefix + "-1" + outputExtension + "\""; @@ -485,7 +484,7 @@ namespace Jellyfin.Api.Controllers args += " -ar " + state.OutputAudioSampleRate.Value.ToString(CultureInfo.InvariantCulture); } - args += _encodingHelper.GetAudioFilterParam(state, _encodingOptions, true); + args += _encodingHelper.GetAudioFilterParam(state, _encodingOptions); return args; } diff --git a/Jellyfin.Api/Controllers/VideosController.cs b/Jellyfin.Api/Controllers/VideosController.cs index dc64a0f1bc..e1cbc6f331 100644 --- a/Jellyfin.Api/Controllers/VideosController.cs +++ b/Jellyfin.Api/Controllers/VideosController.cs @@ -25,14 +25,12 @@ using MediaBrowser.Controller.Net; using MediaBrowser.Model.Dlna; using MediaBrowser.Model.Dto; using MediaBrowser.Model.Entities; -using MediaBrowser.Model.IO; using MediaBrowser.Model.MediaInfo; using MediaBrowser.Model.Net; using MediaBrowser.Model.Querying; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Configuration; namespace Jellyfin.Api.Controllers { @@ -296,6 +294,8 @@ namespace Jellyfin.Api.Controllers /// <param name="startTimeTicks">Optional. Specify a starting offset, in ticks. 1 tick = 10000 ms.</param> /// <param name="width">Optional. The fixed horizontal resolution of the encoded video.</param> /// <param name="height">Optional. The fixed vertical resolution of the encoded video.</param> + /// <param name="maxWidth">Optional. The maximum horizontal resolution of the encoded video.</param> + /// <param name="maxHeight">Optional. The maximum vertical resolution of the encoded video.</param> /// <param name="videoBitRate">Optional. Specify a video bitrate to encode to, e.g. 500000. If omitted this will be left to encoder defaults.</param> /// <param name="subtitleStreamIndex">Optional. The index of the subtitle stream to use. If omitted no subtitles will be used.</param> /// <param name="subtitleMethod">Optional. Specify the subtitle delivery method.</param> @@ -308,7 +308,7 @@ namespace Jellyfin.Api.Controllers /// <param name="cpuCoreLimit">Optional. The limit of how many cpu cores to use.</param> /// <param name="liveStreamId">The live stream id.</param> /// <param name="enableMpegtsM2TsMode">Optional. Whether to enable the MpegtsM2Ts mode.</param> - /// <param name="videoCodec">Optional. Specify a video codec to encode to, e.g. h264. If omitted the server will auto-select using the url's extension. Options: h265, h264, mpeg4, theora, vpx, wmv.</param> + /// <param name="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> @@ -352,6 +352,8 @@ namespace Jellyfin.Api.Controllers [FromQuery] long? startTimeTicks, [FromQuery] int? width, [FromQuery] int? height, + [FromQuery] int? maxWidth, + [FromQuery] int? maxHeight, [FromQuery] int? videoBitRate, [FromQuery] int? subtitleStreamIndex, [FromQuery] SubtitleDeliveryMethod? subtitleMethod, @@ -407,6 +409,8 @@ namespace Jellyfin.Api.Controllers StartTimeTicks = startTimeTicks, Width = width, Height = height, + MaxWidth = maxWidth, + MaxHeight = maxHeight, VideoBitRate = videoBitRate, SubtitleStreamIndex = subtitleStreamIndex, SubtitleMethod = subtitleMethod ?? SubtitleDeliveryMethod.Encode, @@ -449,14 +453,15 @@ namespace Jellyfin.Api.Controllers { StreamingHelpers.AddDlnaHeaders(state, Response.Headers, true, startTimeTicks, Request, _dlnaManager); - await new ProgressiveFileCopier(state.DirectStreamProvider, null, _transcodingJobHelper, CancellationToken.None) - { - AllowEndOfFile = false - }.WriteToAsync(Response.Body, CancellationToken.None) - .ConfigureAwait(false); + var liveStreamInfo = _mediaSourceManager.GetLiveStreamInfo(streamingRequest.LiveStreamId); + if (liveStreamInfo == null) + { + 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 @@ -488,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( @@ -550,6 +550,8 @@ namespace Jellyfin.Api.Controllers /// <param name="startTimeTicks">Optional. Specify a starting offset, in ticks. 1 tick = 10000 ms.</param> /// <param name="width">Optional. The fixed horizontal resolution of the encoded video.</param> /// <param name="height">Optional. The fixed vertical resolution of the encoded video.</param> + /// <param name="maxWidth">Optional. The maximum horizontal resolution of the encoded video.</param> + /// <param name="maxHeight">Optional. The maximum vertical resolution of the encoded video.</param> /// <param name="videoBitRate">Optional. Specify a video bitrate to encode to, e.g. 500000. If omitted this will be left to encoder defaults.</param> /// <param name="subtitleStreamIndex">Optional. The index of the subtitle stream to use. If omitted no subtitles will be used.</param> /// <param name="subtitleMethod">Optional. Specify the subtitle delivery method.</param> @@ -562,7 +564,7 @@ namespace Jellyfin.Api.Controllers /// <param name="cpuCoreLimit">Optional. The limit of how many cpu cores to use.</param> /// <param name="liveStreamId">The live stream id.</param> /// <param name="enableMpegtsM2TsMode">Optional. Whether to enable the MpegtsM2Ts mode.</param> - /// <param name="videoCodec">Optional. Specify a video codec to encode to, e.g. h264. If omitted the server will auto-select using the url's extension. Options: h265, h264, mpeg4, theora, vpx, wmv.</param> + /// <param name="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> @@ -606,6 +608,8 @@ namespace Jellyfin.Api.Controllers [FromQuery] long? startTimeTicks, [FromQuery] int? width, [FromQuery] int? height, + [FromQuery] int? maxWidth, + [FromQuery] int? maxHeight, [FromQuery] int? videoBitRate, [FromQuery] int? subtitleStreamIndex, [FromQuery] SubtitleDeliveryMethod? subtitleMethod, @@ -657,6 +661,8 @@ namespace Jellyfin.Api.Controllers startTimeTicks, width, height, + maxWidth, + maxHeight, videoBitRate, subtitleStreamIndex, subtitleMethod, diff --git a/Jellyfin.Api/Helpers/AudioHelper.cs b/Jellyfin.Api/Helpers/AudioHelper.cs index 264131905f..bec961dadb 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; @@ -11,12 +12,10 @@ using MediaBrowser.Controller.Dlna; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.MediaEncoding; using MediaBrowser.Controller.Net; -using MediaBrowser.Model.IO; using MediaBrowser.Model.MediaInfo; using MediaBrowser.Model.Net; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Configuration; namespace Jellyfin.Api.Helpers { @@ -122,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 @@ -147,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; @@ -161,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 29e6b4193e..c1c2f93b49 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/DynamicHlsHelper.cs b/Jellyfin.Api/Helpers/DynamicHlsHelper.cs index dc5d6715b5..4abe4c5d57 100644 --- a/Jellyfin.Api/Helpers/DynamicHlsHelper.cs +++ b/Jellyfin.Api/Helpers/DynamicHlsHelper.cs @@ -18,11 +18,9 @@ using MediaBrowser.Controller.MediaEncoding; using MediaBrowser.Controller.Net; using MediaBrowser.Model.Dlna; using MediaBrowser.Model.Entities; -using MediaBrowser.Model.IO; using MediaBrowser.Model.Net; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; using Microsoft.Net.Http.Headers; diff --git a/Jellyfin.Api/Helpers/FileStreamResponseHelpers.cs b/Jellyfin.Api/Helpers/FileStreamResponseHelpers.cs index b0fd59e5e3..6385b62c96 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 d0666034e9..4567621470 100644 --- a/Jellyfin.Api/Helpers/HlsHelpers.cs +++ b/Jellyfin.Api/Helpers/HlsHelpers.cs @@ -1,7 +1,6 @@ using System; using System.Globalization; using System.IO; -using System.Runtime.InteropServices; using System.Threading; using System.Threading.Tasks; using Jellyfin.Api.Models.StreamingDtos; @@ -39,7 +38,7 @@ namespace Jellyfin.Api.Helpers FileAccess.Read, FileShare.ReadWrite, IODefaults.FileStreamBufferSize, - FileOptions.SequentialScan); + FileOptions.Asynchronous | FileOptions.SequentialScan); await using (fileStream.ConfigureAwait(false)) { using var reader = new StreamReader(fileStream); @@ -99,8 +98,7 @@ namespace Jellyfin.Api.Helpers return fmp4InitFileName; } - var isWindows = RuntimeInformation.IsOSPlatform(OSPlatform.Windows); - if (isWindows) + if (OperatingSystem.IsWindows()) { // on Windows // #EXT-X-MAP:URI="X:\transcodes\prefix-1.mp4" diff --git a/Jellyfin.Api/Helpers/MediaInfoHelper.cs b/Jellyfin.Api/Helpers/MediaInfoHelper.cs index 295cfaf089..3b8dc7e316 100644 --- a/Jellyfin.Api/Helpers/MediaInfoHelper.cs +++ b/Jellyfin.Api/Helpers/MediaInfoHelper.cs @@ -468,7 +468,7 @@ namespace Jellyfin.Api.Helpers /// <returns>A <see cref="Task"/> containing the <see cref="LiveStreamResponse"/>.</returns> public async Task<LiveStreamResponse> OpenMediaSource(HttpRequest httpRequest, LiveStreamRequest request) { - var authInfo = _authContext.GetAuthorizationInfo(httpRequest); + var authInfo = await _authContext.GetAuthorizationInfo(httpRequest).ConfigureAwait(false); var result = await _mediaSourceManager.OpenLiveStream(request, CancellationToken.None).ConfigureAwait(false); diff --git a/Jellyfin.Api/Helpers/ProgressiveFileCopier.cs b/Jellyfin.Api/Helpers/ProgressiveFileCopier.cs deleted file mode 100644 index 963e177245..0000000000 --- a/Jellyfin.Api/Helpers/ProgressiveFileCopier.cs +++ /dev/null @@ -1,189 +0,0 @@ -using System; -using System.Buffers; -using System.IO; -using System.Runtime.InteropServices; -using System.Threading; -using System.Threading.Tasks; -using Jellyfin.Api.Models.PlaybackDtos; -using MediaBrowser.Common.Extensions; -using MediaBrowser.Controller.Library; -using MediaBrowser.Model.IO; - -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; - - // use non-async filestream along with read due to https://github.com/dotnet/corefx/issues/6039 - if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - { - fileOptions |= FileOptions.Asynchronous; - allowAsyncFileRead = true; - } - - 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 824870c7ef..61e18220a1 100644 --- a/Jellyfin.Api/Helpers/ProgressiveFileStream.cs +++ b/Jellyfin.Api/Helpers/ProgressiveFileStream.cs @@ -1,6 +1,6 @@ using System; +using System.Diagnostics; using System.IO; -using System.Runtime.InteropServices; using System.Threading; using System.Threading.Tasks; using Jellyfin.Api.Models.PlaybackDtos; @@ -13,10 +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 bool _allowAsyncFileRead; + private readonly TranscodingJobHelper? _transcodingJobHelper; + private readonly int _timeoutMs; private int _bytesWritten; private bool _disposed; @@ -26,27 +26,31 @@ namespace Jellyfin.Api.Helpers /// <param name="filePath">The path to the transcoded file.</param> /// <param name="job">The transcoding job information.</param> /// <param name="transcodingJobHelper">The transcoding job helper.</param> - public ProgressiveFileStream(string filePath, TranscodingJobDto? job, TranscodingJobHelper transcodingJobHelper) + /// <param name="timeoutMs">The timeout duration in milliseconds.</param> + public ProgressiveFileStream(string filePath, TranscodingJobDto? job, TranscodingJobHelper transcodingJobHelper, int timeoutMs = 30000) { _job = job; _transcodingJobHelper = transcodingJobHelper; - _bytesWritten = 0; + _timeoutMs = timeoutMs; - var fileOptions = FileOptions.SequentialScan; - _allowAsyncFileRead = false; - - // use non-async filestream along with read due to https://github.com/dotnet/corefx/issues/6039 - if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - { - fileOptions |= FileOptions.Asynchronous; - _allowAsyncFileRead = true; - } + _stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite, IODefaults.FileStreamBufferSize, FileOptions.Asynchronous | FileOptions.SequentialScan); + } - _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; @@ -67,13 +71,13 @@ namespace Jellyfin.Api.Helpers /// <inheritdoc /> public override void Flush() { - _fileStream.Flush(); + _stream.Flush(); } /// <inheritdoc /> public override int Read(byte[] buffer, int offset, int count) { - return _fileStream.Read(buffer, offset, count); + return _stream.Read(buffer, offset, count); } /// <inheritdoc /> @@ -81,20 +85,13 @@ namespace Jellyfin.Api.Helpers { int totalBytesRead = 0; int remainingBytesToRead = count; + var stopwatch = Stopwatch.StartNew(); int newOffset = offset; while (remainingBytesToRead > 0) { cancellationToken.ThrowIfCancellationRequested(); - int bytesRead; - if (_allowAsyncFileRead) - { - bytesRead = await _fileStream.ReadAsync(buffer, newOffset, remainingBytesToRead, cancellationToken).ConfigureAwait(false); - } - else - { - bytesRead = _fileStream.Read(buffer, newOffset, remainingBytesToRead); - } + int bytesRead = await _stream.ReadAsync(buffer, newOffset, remainingBytesToRead, cancellationToken).ConfigureAwait(false); remainingBytesToRead -= bytesRead; newOffset += bytesRead; @@ -111,8 +108,8 @@ namespace Jellyfin.Api.Helpers } else { - // If the job is null it's a live stream and will require user action to close - if (_job?.HasExited ?? false) + // 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; } @@ -148,11 +145,11 @@ namespace Jellyfin.Api.Helpers { if (disposing) { - _fileStream.Dispose(); + _stream.Dispose(); if (_job != null) { - _transcodingJobHelper.OnTranscodeEndRequest(_job); + _transcodingJobHelper?.OnTranscodeEndRequest(_job); } } } diff --git a/Jellyfin.Api/Helpers/RequestHelpers.cs b/Jellyfin.Api/Helpers/RequestHelpers.cs index 56585aeab5..0efd3443b8 100644 --- a/Jellyfin.Api/Helpers/RequestHelpers.cs +++ b/Jellyfin.Api/Helpers/RequestHelpers.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Threading.Tasks; using Jellyfin.Data.Entities; using Jellyfin.Data.Enums; using MediaBrowser.Common.Extensions; @@ -59,9 +60,9 @@ namespace Jellyfin.Api.Helpers /// <param name="userId">The user id.</param> /// <param name="restrictUserPreferences">Whether to restrict the user preferences.</param> /// <returns>A <see cref="bool"/> whether the user can update the entry.</returns> - internal static bool AssertCanUpdateUser(IAuthorizationContext authContext, HttpRequest requestContext, Guid userId, bool restrictUserPreferences) + internal static async Task<bool> AssertCanUpdateUser(IAuthorizationContext authContext, HttpRequest requestContext, Guid userId, bool restrictUserPreferences) { - var auth = authContext.GetAuthorizationInfo(requestContext); + var auth = await authContext.GetAuthorizationInfo(requestContext).ConfigureAwait(false); var authenticatedUser = auth.User; @@ -75,17 +76,17 @@ namespace Jellyfin.Api.Helpers return true; } - internal static SessionInfo GetSession(ISessionManager sessionManager, IAuthorizationContext authContext, HttpRequest request) + internal static async Task<SessionInfo> GetSession(ISessionManager sessionManager, IAuthorizationContext authContext, HttpRequest request) { - var authorization = authContext.GetAuthorizationInfo(request); + var authorization = await authContext.GetAuthorizationInfo(request).ConfigureAwait(false); var user = authorization.User; - var session = sessionManager.LogSessionActivity( + var session = await sessionManager.LogSessionActivity( authorization.Client, authorization.Version, authorization.DeviceId, authorization.Device, request.HttpContext.GetNormalizedRemoteIp().ToString(), - user); + user).ConfigureAwait(false); if (session == null) { @@ -95,6 +96,13 @@ namespace Jellyfin.Api.Helpers return session; } + internal static async Task<string> GetSessionId(ISessionManager sessionManager, IAuthorizationContext authContext, HttpRequest request) + { + var session = await GetSession(sessionManager, authContext, request).ConfigureAwait(false); + + return session.Id; + } + internal static QueryResult<BaseItemDto> CreateQueryResult( QueryResult<(BaseItem, ItemCounts)> result, DtoOptions dtoOptions, diff --git a/Jellyfin.Api/Helpers/StreamingHelpers.cs b/Jellyfin.Api/Helpers/StreamingHelpers.cs index 8cffe9c4c9..4fc791665e 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; @@ -17,9 +18,7 @@ using MediaBrowser.Controller.Net; using MediaBrowser.Model.Dlna; using MediaBrowser.Model.Dto; using MediaBrowser.Model.Entities; -using MediaBrowser.Model.IO; using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Primitives; using Microsoft.Net.Http.Headers; @@ -83,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)) { @@ -101,7 +100,7 @@ namespace Jellyfin.Api.Helpers EnableDlnaHeaders = enableDlnaHeaders }; - var auth = authorizationContext.GetAuthorizationInfo(httpRequest); + var auth = await authorizationContext.GetAuthorizationInfo(httpRequest).ConfigureAwait(false); if (!auth.UserId.Equals(Guid.Empty)) { state.User = userManager.GetUserById(auth.UserId); @@ -222,11 +221,7 @@ namespace Jellyfin.Api.Helpers { var resolution = ResolutionNormalizer.Normalize( state.VideoStream?.BitRate, - state.VideoStream?.Width, - state.VideoStream?.Height, state.OutputVideoBitrate.Value, - state.VideoStream?.Codec, - state.OutputVideoCodec, state.VideoRequest.MaxWidth, state.VideoRequest.MaxHeight); @@ -439,7 +434,9 @@ namespace Jellyfin.Api.Helpers return ".ogv"; } - if (string.Equals(videoCodec, "vpx", StringComparison.OrdinalIgnoreCase)) + if (string.Equals(videoCodec, "vp8", StringComparison.OrdinalIgnoreCase) + || string.Equals(videoCodec, "vp9", StringComparison.OrdinalIgnoreCase) + || string.Equals(videoCodec, "vpx", StringComparison.OrdinalIgnoreCase)) { return ".webm"; } diff --git a/Jellyfin.Api/Helpers/TranscodingJobHelper.cs b/Jellyfin.Api/Helpers/TranscodingJobHelper.cs index c295af7eb3..07d0b55433 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> @@ -380,7 +381,7 @@ namespace Jellyfin.Api.Helpers private void DeleteHlsPartialStreamFiles(string outputFilePath) { var directory = Path.GetDirectoryName(outputFilePath) - ?? throw new ArgumentException("Path can't be a root directory.", nameof(outputFilePath)); + ?? throw new ArgumentException("Path can't be a root directory.", nameof(outputFilePath)); var name = Path.GetFileNameWithoutExtension(outputFilePath); @@ -444,6 +445,10 @@ namespace Jellyfin.Api.Helpers { var audioCodec = state.ActualOutputAudioCodec; var videoCodec = state.ActualOutputVideoCodec; + var hardwareAccelerationTypeString = _serverConfigurationManager.GetEncodingOptions().HardwareAccelerationType; + HardwareEncodingType? hardwareAccelerationType = string.IsNullOrEmpty(hardwareAccelerationTypeString) + ? null + : (HardwareEncodingType)Enum.Parse(typeof(HardwareEncodingType), hardwareAccelerationTypeString, true); _sessionManager.ReportTranscodingInfo(deviceId, new TranscodingInfo { @@ -458,6 +463,7 @@ namespace Jellyfin.Api.Helpers AudioChannels = state.OutputAudioChannels, IsAudioDirect = EncodingHelper.IsCopyCodec(state.OutputAudioCodec), IsVideoDirect = EncodingHelper.IsCopyCodec(state.OutputVideoCodec), + HardwareAccelerationType = hardwareAccelerationType, TranscodeReasons = state.TranscodeReasons }); } @@ -490,7 +496,7 @@ namespace Jellyfin.Api.Helpers if (state.VideoRequest != null && !EncodingHelper.IsCopyCodec(state.OutputVideoCodec)) { - var auth = _authorizationContext.GetAuthorizationInfo(request); + var auth = await _authorizationContext.GetAuthorizationInfo(request).ConfigureAwait(false); if (auth.User != null && !auth.User.HasPermission(PermissionKind.EnableVideoPlaybackTranscoding)) { this.OnTranscodeFailedToStart(outputPath, transcodingJobType, state); @@ -552,7 +558,7 @@ 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, true); + Stream logStream = new FileStream(logFilePath, FileMode.Create, FileAccess.Write, FileShare.Read, IODefaults.FileStreamBufferSize, FileOptions.Asynchronous); 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); @@ -602,6 +608,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"); @@ -738,6 +748,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(); @@ -759,8 +770,8 @@ namespace Jellyfin.Api.Helpers if (state.MediaSource.RequiresOpening && string.IsNullOrWhiteSpace(state.Request.LiveStreamId)) { var liveStreamResponse = await _mediaSourceManager.OpenLiveStream( - new LiveStreamRequest { OpenToken = state.MediaSource.OpenToken }, - cancellationTokenSource.Token) + new LiveStreamRequest { OpenToken = state.MediaSource.OpenToken }, + cancellationTokenSource.Token) .ConfigureAwait(false); var encodingOptions = _serverConfigurationManager.GetEncodingOptions(); @@ -873,8 +884,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 bd7da9b067..57480b2f35 100644 --- a/Jellyfin.Api/Jellyfin.Api.csproj +++ b/Jellyfin.Api/Jellyfin.Api.csproj @@ -6,19 +6,17 @@ </PropertyGroup> <PropertyGroup> - <TargetFramework>net5.0</TargetFramework> + <TargetFramework>net6.0</TargetFramework> <GenerateDocumentationFile>true</GenerateDocumentationFile> - <TreatWarningsAsErrors>true</TreatWarningsAsErrors> - <Nullable>enable</Nullable> <!-- https://github.com/microsoft/ApplicationInsights-dotnet/issues/2047 --> <NoWarn>AD0001</NoWarn> </PropertyGroup> <ItemGroup> - <PackageReference Include="Microsoft.AspNetCore.Authorization" Version="5.0.7" /> - <PackageReference Include="Microsoft.Extensions.Http" Version="5.0.0" /> - <PackageReference Include="Swashbuckle.AspNetCore" Version="6.1.4" /> - <PackageReference Include="Swashbuckle.AspNetCore.ReDoc" Version="6.1.4" /> + <PackageReference Include="Microsoft.AspNetCore.Authorization" Version="6.0.0-rc.2*" /> + <PackageReference Include="Microsoft.Extensions.Http" Version="6.0.0-rc.2*" /> + <PackageReference Include="Swashbuckle.AspNetCore" Version="6.2.3" /> + <PackageReference Include="Swashbuckle.AspNetCore.ReDoc" Version="6.2.3" /> </ItemGroup> <ItemGroup> @@ -33,10 +31,6 @@ <PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" PrivateAssets="All" /> </ItemGroup> - <PropertyGroup Condition=" '$(Configuration)' == 'Debug' "> - <CodeAnalysisRuleSet>../jellyfin.ruleset</CodeAnalysisRuleSet> - </PropertyGroup> - <ItemGroup> <AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleTo"> <_Parameter1>Jellyfin.Api.Tests</_Parameter1> diff --git a/Jellyfin.Api/ModelBinders/NullableEnumModelBinder.cs b/Jellyfin.Api/ModelBinders/NullableEnumModelBinder.cs index be2045fbab..d2e78ac884 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/Models/PlaybackDtos/TranscodingJobDto.cs b/Jellyfin.Api/Models/PlaybackDtos/TranscodingJobDto.cs index 291e571dcb..fed837b856 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> diff --git a/Jellyfin.Api/Models/StreamingDtos/StreamState.cs b/Jellyfin.Api/Models/StreamingDtos/StreamState.cs index e95f2d1f43..cbabf087bc 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/SyncPlayDtos/RemoveFromPlaylistRequestDto.cs b/Jellyfin.Api/Models/SyncPlayDtos/RemoveFromPlaylistRequestDto.cs index e9b2b2cb37..02ce5a0488 100644 --- a/Jellyfin.Api/Models/SyncPlayDtos/RemoveFromPlaylistRequestDto.cs +++ b/Jellyfin.Api/Models/SyncPlayDtos/RemoveFromPlaylistRequestDto.cs @@ -17,9 +17,21 @@ namespace Jellyfin.Api.Models.SyncPlayDtos } /// <summary> - /// Gets or sets the playlist identifiers ot the items. + /// Gets or sets the playlist identifiers ot the items. Ignored when clearing the playlist. /// </summary> /// <value>The playlist identifiers ot the items.</value> public IReadOnlyList<Guid> PlaylistItemIds { get; set; } + + /// <summary> + /// Gets or sets a value indicating whether the entire playlist should be cleared. + /// </summary> + /// <value>Whether the entire playlist should be cleared.</value> + public bool ClearPlaylist { get; set; } + + /// <summary> + /// Gets or sets a value indicating whether the playing item should be removed as well. Used only when clearing the playlist. + /// </summary> + /// <value>Whether the playing item should be removed as well.</value> + public bool ClearPlayingItem { get; set; } } } diff --git a/Jellyfin.Api/Models/UserDtos/QuickConnectDto.cs b/Jellyfin.Api/Models/UserDtos/QuickConnectDto.cs index c3a2d5cec2..9493c08c28 100644 --- a/Jellyfin.Api/Models/UserDtos/QuickConnectDto.cs +++ b/Jellyfin.Api/Models/UserDtos/QuickConnectDto.cs @@ -8,9 +8,9 @@ namespace Jellyfin.Api.Models.UserDtos public class QuickConnectDto { /// <summary> - /// Gets or sets the quick connect token. + /// Gets or sets the quick connect secret. /// </summary> [Required] - public string? Token { get; set; } + public string Secret { get; set; } = null!; } } diff --git a/Jellyfin.Data/Dtos/DeviceOptionsDto.cs b/Jellyfin.Data/Dtos/DeviceOptionsDto.cs new file mode 100644 index 0000000000..392ef5ff4e --- /dev/null +++ b/Jellyfin.Data/Dtos/DeviceOptionsDto.cs @@ -0,0 +1,23 @@ +namespace Jellyfin.Data.Dtos +{ + /// <summary> + /// A dto representing custom options for a device. + /// </summary> + public class DeviceOptionsDto + { + /// <summary> + /// Gets or sets the id. + /// </summary> + public int Id { get; set; } + + /// <summary> + /// Gets or sets the device id. + /// </summary> + public string? DeviceId { get; set; } + + /// <summary> + /// Gets or sets the custom name. + /// </summary> + public string? CustomName { get; set; } + } +} diff --git a/Jellyfin.Data/Entities/Security/ApiKey.cs b/Jellyfin.Data/Entities/Security/ApiKey.cs new file mode 100644 index 0000000000..31d865d018 --- /dev/null +++ b/Jellyfin.Data/Entities/Security/ApiKey.cs @@ -0,0 +1,56 @@ +using System; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using System.Globalization; + +namespace Jellyfin.Data.Entities.Security +{ + /// <summary> + /// An entity representing an API key. + /// </summary> + public class ApiKey + { + /// <summary> + /// Initializes a new instance of the <see cref="ApiKey"/> class. + /// </summary> + /// <param name="name">The name.</param> + public ApiKey(string name) + { + Name = name; + + AccessToken = Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture); + DateCreated = DateTime.UtcNow; + } + + /// <summary> + /// Gets the id. + /// </summary> + /// <remarks> + /// Identity, Indexed, Required. + /// </remarks> + [DatabaseGenerated(DatabaseGeneratedOption.Identity)] + public int Id { get; private set; } + + /// <summary> + /// Gets or sets the date created. + /// </summary> + public DateTime DateCreated { get; set; } + + /// <summary> + /// Gets or sets the date of last activity. + /// </summary> + public DateTime DateLastActivity { get; set; } + + /// <summary> + /// Gets or sets the name. + /// </summary> + [MaxLength(64)] + [StringLength(64)] + public string Name { get; set; } + + /// <summary> + /// Gets or sets the access token. + /// </summary> + public string AccessToken { get; set; } + } +} diff --git a/Jellyfin.Data/Entities/Security/Device.cs b/Jellyfin.Data/Entities/Security/Device.cs new file mode 100644 index 0000000000..67d7f78eda --- /dev/null +++ b/Jellyfin.Data/Entities/Security/Device.cs @@ -0,0 +1,107 @@ +using System; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using System.Globalization; + +namespace Jellyfin.Data.Entities.Security +{ + /// <summary> + /// An entity representing a device. + /// </summary> + public class Device + { + /// <summary> + /// Initializes a new instance of the <see cref="Device"/> class. + /// </summary> + /// <param name="userId">The user id.</param> + /// <param name="appName">The app name.</param> + /// <param name="appVersion">The app version.</param> + /// <param name="deviceName">The device name.</param> + /// <param name="deviceId">The device id.</param> + public Device(Guid userId, string appName, string appVersion, string deviceName, string deviceId) + { + UserId = userId; + AppName = appName; + AppVersion = appVersion; + DeviceName = deviceName; + DeviceId = deviceId; + + AccessToken = Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture); + DateCreated = DateTime.UtcNow; + DateModified = DateCreated; + DateLastActivity = DateCreated; + + // Non-nullable for EF Core, as this is a required relationship. + User = null!; + } + + /// <summary> + /// Gets the id. + /// </summary> + [DatabaseGenerated(DatabaseGeneratedOption.Identity)] + public int Id { get; private set; } + + /// <summary> + /// Gets the user id. + /// </summary> + public Guid UserId { get; private set; } + + /// <summary> + /// Gets or sets the access token. + /// </summary> + public string AccessToken { get; set; } + + /// <summary> + /// Gets or sets the app name. + /// </summary> + [MaxLength(64)] + [StringLength(64)] + public string AppName { get; set; } + + /// <summary> + /// Gets or sets the app version. + /// </summary> + [MaxLength(32)] + [StringLength(32)] + public string AppVersion { get; set; } + + /// <summary> + /// Gets or sets the device name. + /// </summary> + [MaxLength(64)] + [StringLength(64)] + public string DeviceName { get; set; } + + /// <summary> + /// Gets or sets the device id. + /// </summary> + [MaxLength(256)] + [StringLength(256)] + public string DeviceId { get; set; } + + /// <summary> + /// Gets or sets a value indicating whether this device is active. + /// </summary> + public bool IsActive { get; set; } + + /// <summary> + /// Gets or sets the date created. + /// </summary> + public DateTime DateCreated { get; set; } + + /// <summary> + /// Gets or sets the date modified. + /// </summary> + public DateTime DateModified { get; set; } + + /// <summary> + /// Gets or sets the date of last activity. + /// </summary> + public DateTime DateLastActivity { get; set; } + + /// <summary> + /// Gets the user. + /// </summary> + public User User { get; private set; } + } +} diff --git a/Jellyfin.Data/Entities/Security/DeviceOptions.cs b/Jellyfin.Data/Entities/Security/DeviceOptions.cs new file mode 100644 index 0000000000..531f66c627 --- /dev/null +++ b/Jellyfin.Data/Entities/Security/DeviceOptions.cs @@ -0,0 +1,35 @@ +using System.ComponentModel.DataAnnotations.Schema; + +namespace Jellyfin.Data.Entities.Security +{ + /// <summary> + /// An entity representing custom options for a device. + /// </summary> + public class DeviceOptions + { + /// <summary> + /// Initializes a new instance of the <see cref="DeviceOptions"/> class. + /// </summary> + /// <param name="deviceId">The device id.</param> + public DeviceOptions(string deviceId) + { + DeviceId = deviceId; + } + + /// <summary> + /// Gets the id. + /// </summary> + [DatabaseGenerated(DatabaseGeneratedOption.Identity)] + public int Id { get; private set; } + + /// <summary> + /// Gets the device id. + /// </summary> + public string DeviceId { get; private set; } + + /// <summary> + /// Gets or sets the custom name. + /// </summary> + public string? CustomName { get; set; } + } +} diff --git a/Jellyfin.Data/Enums/BaseItemKind.cs b/Jellyfin.Data/Enums/BaseItemKind.cs index aac30279ed..8757817463 100644 --- a/Jellyfin.Data/Enums/BaseItemKind.cs +++ b/Jellyfin.Data/Enums/BaseItemKind.cs @@ -78,6 +78,16 @@ /// </summary> Movie, + /// <summary> + /// Item is a live tv channel. + /// </summary> + LiveTvChannel, + + /// <summary> + /// Item is a live tv program. + /// </summary> + LiveTvProgram, + /// <summary> /// Item is music album. /// </summary> @@ -118,6 +128,11 @@ /// </summary> Playlist, + /// <summary> + /// Item is playlist folder. + /// </summary> + PlaylistsFolder, + /// <summary> /// Item is program /// </summary> @@ -187,4 +202,4 @@ /// </summary> Year } -} \ No newline at end of file +} diff --git a/Jellyfin.Data/Jellyfin.Data.csproj b/Jellyfin.Data/Jellyfin.Data.csproj index 3b14d33125..2de53e7c83 100644 --- a/Jellyfin.Data/Jellyfin.Data.csproj +++ b/Jellyfin.Data/Jellyfin.Data.csproj @@ -1,13 +1,9 @@ <Project Sdk="Microsoft.NET.Sdk"> <PropertyGroup> - <TargetFramework>net5.0</TargetFramework> + <TargetFramework>net6.0</TargetFramework> <GenerateAssemblyInfo>false</GenerateAssemblyInfo> <GenerateDocumentationFile>true</GenerateDocumentationFile> - <TreatWarningsAsErrors>true</TreatWarningsAsErrors> - <AnalysisMode>AllEnabledByDefault</AnalysisMode> - <CodeAnalysisRuleSet>../jellyfin.ruleset</CodeAnalysisRuleSet> - <Nullable>enable</Nullable> <PublishRepositoryUrl>true</PublishRepositoryUrl> <EmbedUntrackedSources>true</EmbedUntrackedSources> <IncludeSymbols>true</IncludeSymbols> @@ -39,7 +35,7 @@ </ItemGroup> <ItemGroup> - <PackageReference Include="Microsoft.Extensions.Logging" Version="5.0.0" /> + <PackageReference Include="Microsoft.Extensions.Logging" Version="6.0.0-rc.2*" /> </ItemGroup> <ItemGroup> diff --git a/Jellyfin.Data/Queries/ActivityLogQuery.cs b/Jellyfin.Data/Queries/ActivityLogQuery.cs index 92919d3a51..f1af099d3c 100644 --- a/Jellyfin.Data/Queries/ActivityLogQuery.cs +++ b/Jellyfin.Data/Queries/ActivityLogQuery.cs @@ -5,18 +5,8 @@ namespace Jellyfin.Data.Queries /// <summary> /// A class representing a query to the activity logs. /// </summary> - public class ActivityLogQuery + public class ActivityLogQuery : PaginatedQuery { - /// <summary> - /// Gets or sets the index to start at. - /// </summary> - public int? StartIndex { get; set; } - - /// <summary> - /// Gets or sets the maximum number of items to include. - /// </summary> - public int? Limit { get; set; } - /// <summary> /// Gets or sets a value indicating whether to take entries with a user id. /// </summary> diff --git a/Jellyfin.Data/Queries/DeviceQuery.cs b/Jellyfin.Data/Queries/DeviceQuery.cs new file mode 100644 index 0000000000..083e00548d --- /dev/null +++ b/Jellyfin.Data/Queries/DeviceQuery.cs @@ -0,0 +1,25 @@ +using System; + +namespace Jellyfin.Data.Queries +{ + /// <summary> + /// A query to retrieve devices. + /// </summary> + public class DeviceQuery : PaginatedQuery + { + /// <summary> + /// Gets or sets the user id of the device. + /// </summary> + public Guid? UserId { get; set; } + + /// <summary> + /// Gets or sets the device id. + /// </summary> + public string? DeviceId { get; set; } + + /// <summary> + /// Gets or sets the access token. + /// </summary> + public string? AccessToken { get; set; } + } +} diff --git a/Jellyfin.Data/Queries/PaginatedQuery.cs b/Jellyfin.Data/Queries/PaginatedQuery.cs new file mode 100644 index 0000000000..58267ebe7f --- /dev/null +++ b/Jellyfin.Data/Queries/PaginatedQuery.cs @@ -0,0 +1,18 @@ +namespace Jellyfin.Data.Queries +{ + /// <summary> + /// An abstract class for paginated queries. + /// </summary> + public abstract class PaginatedQuery + { + /// <summary> + /// Gets or sets the index to start at. + /// </summary> + public int? Skip { get; set; } + + /// <summary> + /// Gets or sets the maximum number of items to include. + /// </summary> + public int? Limit { get; set; } + } +} diff --git a/Jellyfin.Drawing.Skia/Jellyfin.Drawing.Skia.csproj b/Jellyfin.Drawing.Skia/Jellyfin.Drawing.Skia.csproj index ee43c2159a..5fa386ecac 100644 --- a/Jellyfin.Drawing.Skia/Jellyfin.Drawing.Skia.csproj +++ b/Jellyfin.Drawing.Skia/Jellyfin.Drawing.Skia.csproj @@ -6,13 +6,9 @@ </PropertyGroup> <PropertyGroup> - <TargetFramework>net5.0</TargetFramework> + <TargetFramework>net6.0</TargetFramework> <GenerateAssemblyInfo>false</GenerateAssemblyInfo> <GenerateDocumentationFile>true</GenerateDocumentationFile> - <TreatWarningsAsErrors>true</TreatWarningsAsErrors> - <Nullable>enable</Nullable> - <AnalysisMode>AllEnabledByDefault</AnalysisMode> - <CodeAnalysisRuleSet>../jellyfin.ruleset</CodeAnalysisRuleSet> </PropertyGroup> <ItemGroup> @@ -22,8 +18,8 @@ <ItemGroup> <PackageReference Include="BlurHashSharp" Version="1.2.0" /> <PackageReference Include="BlurHashSharp.SkiaSharp" Version="1.2.0" /> - <PackageReference Include="SkiaSharp" Version="2.80.2" /> - <PackageReference Include="SkiaSharp.NativeAssets.Linux" Version="2.80.2" /> + <PackageReference Include="SkiaSharp" Version="2.80.3" /> + <PackageReference Include="SkiaSharp.NativeAssets.Linux" Version="2.80.3" /> </ItemGroup> <ItemGroup> @@ -32,11 +28,6 @@ <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" /> diff --git a/Jellyfin.Drawing.Skia/StripCollageBuilder.cs b/Jellyfin.Drawing.Skia/StripCollageBuilder.cs index 09a3702385..d1cc2255d7 100644 --- a/Jellyfin.Drawing.Skia/StripCollageBuilder.cs +++ b/Jellyfin.Drawing.Skia/StripCollageBuilder.cs @@ -112,7 +112,7 @@ namespace Jellyfin.Drawing.Skia canvas.DrawImage(residedBackdrop, 0, 0); // draw shadow rectangle - var paintColor = new SKPaint + using var paintColor = new SKPaint { Color = SKColors.Black.WithAlpha(0x78), Style = SKPaintStyle.Fill @@ -130,7 +130,7 @@ namespace Jellyfin.Drawing.Skia } // draw library name - var textPaint = new SKPaint + using var textPaint = new SKPaint { Color = SKColors.White, Style = SKPaintStyle.Fill, diff --git a/Jellyfin.Networking/Configuration/NetworkConfiguration.cs b/Jellyfin.Networking/Configuration/NetworkConfiguration.cs index faf814c060..61db223d92 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/Jellyfin.Networking.csproj b/Jellyfin.Networking/Jellyfin.Networking.csproj index 63557e91f0..0cd9a59156 100644 --- a/Jellyfin.Networking/Jellyfin.Networking.csproj +++ b/Jellyfin.Networking/Jellyfin.Networking.csproj @@ -1,12 +1,8 @@ <Project Sdk="Microsoft.NET.Sdk"> <PropertyGroup> - <TargetFramework>net5.0</TargetFramework> + <TargetFramework>net6.0</TargetFramework> <GenerateAssemblyInfo>false</GenerateAssemblyInfo> <GenerateDocumentationFile>true</GenerateDocumentationFile> - <TreatWarningsAsErrors>true</TreatWarningsAsErrors> - <Nullable>enable</Nullable> - <AnalysisMode>AllEnabledByDefault</AnalysisMode> - <CodeAnalysisRuleSet>../jellyfin.ruleset</CodeAnalysisRuleSet> </PropertyGroup> <ItemGroup> diff --git a/Jellyfin.Server.Implementations/Activity/ActivityManager.cs b/Jellyfin.Server.Implementations/Activity/ActivityManager.cs index 27360afb0a..ba2c8b54f9 100644 --- a/Jellyfin.Server.Implementations/Activity/ActivityManager.cs +++ b/Jellyfin.Server.Implementations/Activity/ActivityManager.cs @@ -62,7 +62,7 @@ namespace Jellyfin.Server.Implementations.Activity return new QueryResult<ActivityLogEntry> { Items = await entries - .Skip(query.StartIndex ?? 0) + .Skip(query.Skip ?? 0) .Take(query.Limit ?? 100) .AsAsyncEnumerable() .Select(ConvertToOldModel) @@ -86,15 +86,12 @@ namespace Jellyfin.Server.Implementations.Activity private static ActivityLogEntry ConvertToOldModel(ActivityLog entry) { - return new ActivityLogEntry + return new ActivityLogEntry(entry.Name, entry.Type, entry.UserId) { Id = entry.Id, - Name = entry.Name, Overview = entry.Overview, ShortOverview = entry.ShortOverview, - Type = entry.Type, ItemId = entry.ItemId, - UserId = entry.UserId, Date = entry.DateCreated, Severity = entry.LogSeverity }; diff --git a/Jellyfin.Server.Implementations/Devices/DeviceManager.cs b/Jellyfin.Server.Implementations/Devices/DeviceManager.cs new file mode 100644 index 0000000000..0655c9813d --- /dev/null +++ b/Jellyfin.Server.Implementations/Devices/DeviceManager.cs @@ -0,0 +1,243 @@ +using System; +using System.Collections.Concurrent; +using System.Linq; +using System.Threading.Tasks; +using Jellyfin.Data.Entities; +using Jellyfin.Data.Entities.Security; +using Jellyfin.Data.Enums; +using Jellyfin.Data.Events; +using Jellyfin.Data.Queries; +using MediaBrowser.Controller.Devices; +using MediaBrowser.Controller.Library; +using MediaBrowser.Model.Devices; +using MediaBrowser.Model.Querying; +using MediaBrowser.Model.Session; +using Microsoft.EntityFrameworkCore; + +namespace Jellyfin.Server.Implementations.Devices +{ + /// <summary> + /// Manages the creation, updating, and retrieval of devices. + /// </summary> + public class DeviceManager : IDeviceManager + { + private readonly JellyfinDbProvider _dbProvider; + private readonly IUserManager _userManager; + private readonly ConcurrentDictionary<string, ClientCapabilities> _capabilitiesMap = new (); + + /// <summary> + /// Initializes a new instance of the <see cref="DeviceManager"/> class. + /// </summary> + /// <param name="dbProvider">The database provider.</param> + /// <param name="userManager">The user manager.</param> + public DeviceManager(JellyfinDbProvider dbProvider, IUserManager userManager) + { + _dbProvider = dbProvider; + _userManager = userManager; + } + + /// <inheritdoc /> + public event EventHandler<GenericEventArgs<Tuple<string, DeviceOptions>>>? DeviceOptionsUpdated; + + /// <inheritdoc /> + public void SaveCapabilities(string deviceId, ClientCapabilities capabilities) + { + _capabilitiesMap[deviceId] = capabilities; + } + + /// <inheritdoc /> + public async Task UpdateDeviceOptions(string deviceId, string deviceName) + { + await using var dbContext = _dbProvider.CreateContext(); + var deviceOptions = await dbContext.DeviceOptions.AsQueryable().FirstOrDefaultAsync(dev => dev.DeviceId == deviceId).ConfigureAwait(false); + if (deviceOptions == null) + { + deviceOptions = new DeviceOptions(deviceId); + dbContext.DeviceOptions.Add(deviceOptions); + } + + deviceOptions.CustomName = deviceName; + await dbContext.SaveChangesAsync().ConfigureAwait(false); + + DeviceOptionsUpdated?.Invoke(this, new GenericEventArgs<Tuple<string, DeviceOptions>>(new Tuple<string, DeviceOptions>(deviceId, deviceOptions))); + } + + /// <inheritdoc /> + public async Task<Device> CreateDevice(Device device) + { + await using var dbContext = _dbProvider.CreateContext(); + + dbContext.Devices.Add(device); + + await dbContext.SaveChangesAsync().ConfigureAwait(false); + return device; + } + + /// <inheritdoc /> + public async Task<DeviceOptions> GetDeviceOptions(string deviceId) + { + await using var dbContext = _dbProvider.CreateContext(); + var deviceOptions = await dbContext.DeviceOptions + .AsQueryable() + .FirstOrDefaultAsync(d => d.DeviceId == deviceId) + .ConfigureAwait(false); + + return deviceOptions ?? new DeviceOptions(deviceId); + } + + /// <inheritdoc /> + public ClientCapabilities GetCapabilities(string deviceId) + { + return _capabilitiesMap.TryGetValue(deviceId, out ClientCapabilities? result) + ? result + : new ClientCapabilities(); + } + + /// <inheritdoc /> + public async Task<DeviceInfo?> GetDevice(string id) + { + await using var dbContext = _dbProvider.CreateContext(); + var device = await dbContext.Devices + .AsQueryable() + .Where(d => d.DeviceId == id) + .OrderByDescending(d => d.DateLastActivity) + .Include(d => d.User) + .FirstOrDefaultAsync() + .ConfigureAwait(false); + + var deviceInfo = device == null ? null : ToDeviceInfo(device); + + return deviceInfo; + } + + /// <inheritdoc /> + public async Task<QueryResult<Device>> GetDevices(DeviceQuery query) + { + await using var dbContext = _dbProvider.CreateContext(); + + var devices = dbContext.Devices.AsQueryable(); + + if (query.UserId.HasValue) + { + devices = devices.Where(device => device.UserId == query.UserId.Value); + } + + if (query.DeviceId != null) + { + devices = devices.Where(device => device.DeviceId == query.DeviceId); + } + + if (query.AccessToken != null) + { + devices = devices.Where(device => device.AccessToken == query.AccessToken); + } + + var count = await devices.CountAsync().ConfigureAwait(false); + + if (query.Skip.HasValue) + { + devices = devices.Skip(query.Skip.Value); + } + + if (query.Limit.HasValue) + { + devices = devices.Take(query.Limit.Value); + } + + return new QueryResult<Device> + { + Items = await devices.ToListAsync().ConfigureAwait(false), + StartIndex = query.Skip ?? 0, + TotalRecordCount = count + }; + } + + /// <inheritdoc /> + public async Task<QueryResult<DeviceInfo>> GetDeviceInfos(DeviceQuery query) + { + var devices = await GetDevices(query).ConfigureAwait(false); + + return new QueryResult<DeviceInfo> + { + Items = devices.Items.Select(device => ToDeviceInfo(device)).ToList(), + StartIndex = devices.StartIndex, + TotalRecordCount = devices.TotalRecordCount + }; + } + + /// <inheritdoc /> + public async Task<QueryResult<DeviceInfo>> GetDevicesForUser(Guid? userId, bool? supportsSync) + { + await using var dbContext = _dbProvider.CreateContext(); + var sessions = dbContext.Devices + .Include(d => d.User) + .AsQueryable() + .OrderBy(d => d.DeviceId) + .ThenByDescending(d => d.DateLastActivity) + .AsAsyncEnumerable(); + + if (supportsSync.HasValue) + { + sessions = sessions.Where(i => GetCapabilities(i.DeviceId).SupportsSync == supportsSync.Value); + } + + if (userId.HasValue) + { + var user = _userManager.GetUserById(userId.Value); + + sessions = sessions.Where(i => CanAccessDevice(user, i.DeviceId)); + } + + var array = await sessions.Select(device => ToDeviceInfo(device)).ToArrayAsync().ConfigureAwait(false); + + return new QueryResult<DeviceInfo>(array); + } + + /// <inheritdoc /> + public async Task DeleteDevice(Device device) + { + await using var dbContext = _dbProvider.CreateContext(); + dbContext.Devices.Remove(device); + await dbContext.SaveChangesAsync().ConfigureAwait(false); + } + + /// <inheritdoc /> + public bool CanAccessDevice(User user, string deviceId) + { + if (user == null) + { + throw new ArgumentNullException(nameof(user)); + } + + if (string.IsNullOrEmpty(deviceId)) + { + throw new ArgumentNullException(nameof(deviceId)); + } + + if (user.HasPermission(PermissionKind.EnableAllDevices) || user.HasPermission(PermissionKind.IsAdministrator)) + { + return true; + } + + return user.GetPreference(PreferenceKind.EnabledDevices).Contains(deviceId, StringComparer.OrdinalIgnoreCase) + || !GetCapabilities(deviceId).SupportsPersistentIdentifier; + } + + private DeviceInfo ToDeviceInfo(Device authInfo) + { + var caps = GetCapabilities(authInfo.DeviceId); + + return new DeviceInfo + { + AppName = authInfo.AppName, + AppVersion = authInfo.AppVersion, + Id = authInfo.DeviceId, + LastUserId = authInfo.UserId, + LastUserName = authInfo.User.Username, + Name = authInfo.DeviceName, + DateLastActivity = authInfo.DateLastActivity, + IconUrl = caps.IconUrl + }; + } + } +} diff --git a/Jellyfin.Server.Implementations/Jellyfin.Server.Implementations.csproj b/Jellyfin.Server.Implementations/Jellyfin.Server.Implementations.csproj index eeeb1d19bf..e26cf093b9 100644 --- a/Jellyfin.Server.Implementations/Jellyfin.Server.Implementations.csproj +++ b/Jellyfin.Server.Implementations/Jellyfin.Server.Implementations.csproj @@ -1,17 +1,9 @@ <Project Sdk="Microsoft.NET.Sdk"> <PropertyGroup> - <TargetFramework>net5.0</TargetFramework> + <TargetFramework>net6.0</TargetFramework> <GenerateAssemblyInfo>false</GenerateAssemblyInfo> <GenerateDocumentationFile>true</GenerateDocumentationFile> - <TreatWarningsAsErrors>true</TreatWarningsAsErrors> - <Nullable>enable</Nullable> - <AnalysisMode>AllEnabledByDefault</AnalysisMode> - <CodeAnalysisRuleSet>../jellyfin.ruleset</CodeAnalysisRuleSet> - </PropertyGroup> - - <PropertyGroup Condition=" '$(Configuration)' == 'Debug' "> - <CodeAnalysisRuleSet>../jellyfin.ruleset</CodeAnalysisRuleSet> </PropertyGroup> <!-- Code analysers--> @@ -27,13 +19,13 @@ <ItemGroup> <PackageReference Include="System.Linq.Async" Version="5.0.0" /> - <PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="5.0.7" /> - <PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="5.0.7" /> - <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="5.0.7"> + <PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="6.0.0-rc.2*" /> + <PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="6.0.0-rc.2*" /> + <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="6.0.0-rc.2*"> <PrivateAssets>all</PrivateAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> </PackageReference> - <PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="5.0.7"> + <PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="6.0.0-rc.2*"> <PrivateAssets>all</PrivateAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> </PackageReference> diff --git a/Jellyfin.Server.Implementations/JellyfinDb.cs b/Jellyfin.Server.Implementations/JellyfinDb.cs index db648472d1..dc4f53913c 100644 --- a/Jellyfin.Server.Implementations/JellyfinDb.cs +++ b/Jellyfin.Server.Implementations/JellyfinDb.cs @@ -4,6 +4,7 @@ using System; using System.Linq; using Jellyfin.Data.Entities; +using Jellyfin.Data.Entities.Security; using Jellyfin.Data.Interfaces; using Microsoft.EntityFrameworkCore; @@ -29,6 +30,12 @@ namespace Jellyfin.Server.Implementations public virtual DbSet<ActivityLog> ActivityLogs { get; set; } + public virtual DbSet<ApiKey> ApiKeys { get; set; } + + public virtual DbSet<Device> Devices { get; set; } + + public virtual DbSet<DeviceOptions> DeviceOptions { get; set; } + public virtual DbSet<DisplayPreferences> DisplayPreferences { get; set; } public virtual DbSet<ImageInfo> ImageInfos { get; set; } @@ -146,80 +153,10 @@ namespace Jellyfin.Server.Implementations { modelBuilder.SetDefaultDateTimeKind(DateTimeKind.Utc); base.OnModelCreating(modelBuilder); - modelBuilder.HasDefaultSchema("jellyfin"); - // Collations - - modelBuilder.Entity<User>() - .Property(user => user.Username) - .UseCollation("NOCASE"); - - // Delete behavior - - modelBuilder.Entity<User>() - .HasOne(u => u.ProfileImage) - .WithOne() - .OnDelete(DeleteBehavior.Cascade); - - modelBuilder.Entity<User>() - .HasMany(u => u.Permissions) - .WithOne() - .HasForeignKey(p => p.UserId) - .OnDelete(DeleteBehavior.Cascade); - - modelBuilder.Entity<User>() - .HasMany(u => u.Preferences) - .WithOne() - .HasForeignKey(p => p.UserId) - .OnDelete(DeleteBehavior.Cascade); - - modelBuilder.Entity<User>() - .HasMany(u => u.AccessSchedules) - .WithOne() - .OnDelete(DeleteBehavior.Cascade); - - modelBuilder.Entity<User>() - .HasMany(u => u.DisplayPreferences) - .WithOne() - .OnDelete(DeleteBehavior.Cascade); - - modelBuilder.Entity<User>() - .HasMany(u => u.ItemDisplayPreferences) - .WithOne() - .OnDelete(DeleteBehavior.Cascade); - - modelBuilder.Entity<DisplayPreferences>() - .HasMany(d => d.HomeSections) - .WithOne() - .OnDelete(DeleteBehavior.Cascade); - - // Indexes - - modelBuilder.Entity<User>() - .HasIndex(entity => entity.Username) - .IsUnique(); - - modelBuilder.Entity<DisplayPreferences>() - .HasIndex(entity => new { entity.UserId, entity.ItemId, entity.Client }) - .IsUnique(); - - modelBuilder.Entity<CustomItemDisplayPreferences>() - .HasIndex(entity => new { entity.UserId, entity.ItemId, entity.Client, entity.Key }) - .IsUnique(); - - // Used to get a user's permissions or a specific permission for a user. - // Also prevents multiple values being created for a user. - // Filtered over non-null user ids for when other entities (groups, API keys) get permissions - modelBuilder.Entity<Permission>() - .HasIndex(p => new { p.UserId, p.Kind }) - .HasFilter("[UserId] IS NOT NULL") - .IsUnique(); - - modelBuilder.Entity<Preference>() - .HasIndex(p => new { p.UserId, p.Kind }) - .HasFilter("[UserId] IS NOT NULL") - .IsUnique(); + // Configuration for each entity is in it's own class inside 'ModelConfiguration'. + modelBuilder.ApplyConfigurationsFromAssembly(typeof(JellyfinDb).Assembly); } } } diff --git a/Jellyfin.Server.Implementations/JellyfinDbProvider.cs b/Jellyfin.Server.Implementations/JellyfinDbProvider.cs index 486be60537..c2c5198d14 100644 --- a/Jellyfin.Server.Implementations/JellyfinDbProvider.cs +++ b/Jellyfin.Server.Implementations/JellyfinDbProvider.cs @@ -1,8 +1,10 @@ using System; using System.IO; +using System.Linq; using MediaBrowser.Common.Configuration; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; namespace Jellyfin.Server.Implementations { @@ -13,19 +15,27 @@ namespace Jellyfin.Server.Implementations { private readonly IServiceProvider _serviceProvider; private readonly IApplicationPaths _appPaths; + private readonly ILogger<JellyfinDbProvider> _logger; /// <summary> /// Initializes a new instance of the <see cref="JellyfinDbProvider"/> class. /// </summary> /// <param name="serviceProvider">The application's service provider.</param> /// <param name="appPaths">The application paths.</param> - public JellyfinDbProvider(IServiceProvider serviceProvider, IApplicationPaths appPaths) + /// <param name="logger">The logger.</param> + public JellyfinDbProvider(IServiceProvider serviceProvider, IApplicationPaths appPaths, ILogger<JellyfinDbProvider> logger) { _serviceProvider = serviceProvider; _appPaths = appPaths; + _logger = logger; using var jellyfinDb = CreateContext(); - jellyfinDb.Database.Migrate(); + if (jellyfinDb.Database.GetPendingMigrations().Any()) + { + _logger.LogInformation("There are pending EFCore migrations in the database. Applying... (This may take a while, do not stop Jellyfin)"); + jellyfinDb.Database.Migrate(); + _logger.LogInformation("EFCore migrations applied successfully"); + } } /// <summary> diff --git a/Jellyfin.Server.Implementations/Migrations/20210814002109_AddDevices.Designer.cs b/Jellyfin.Server.Implementations/Migrations/20210814002109_AddDevices.Designer.cs new file mode 100644 index 0000000000..7e9566e2ea --- /dev/null +++ b/Jellyfin.Server.Implementations/Migrations/20210814002109_AddDevices.Designer.cs @@ -0,0 +1,653 @@ +#pragma warning disable CS1591 + +// <auto-generated /> +using System; +using Jellyfin.Server.Implementations; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +namespace Jellyfin.Server.Implementations.Migrations +{ + [DbContext(typeof(JellyfinDb))] + [Migration("20210814002109_AddDevices")] + partial class AddDevices + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("jellyfin") + .HasAnnotation("ProductVersion", "5.0.7"); + + modelBuilder.Entity("Jellyfin.Data.Entities.AccessSchedule", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property<int>("DayOfWeek") + .HasColumnType("INTEGER"); + + b.Property<double>("EndHour") + .HasColumnType("REAL"); + + b.Property<double>("StartHour") + .HasColumnType("REAL"); + + b.Property<Guid>("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AccessSchedules"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.ActivityLog", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property<DateTime>("DateCreated") + .HasColumnType("TEXT"); + + b.Property<string>("ItemId") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property<int>("LogSeverity") + .HasColumnType("INTEGER"); + + b.Property<string>("Name") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property<string>("Overview") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property<uint>("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property<string>("ShortOverview") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property<string>("Type") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property<Guid>("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("ActivityLogs"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.CustomItemDisplayPreferences", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property<string>("Client") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property<Guid>("ItemId") + .HasColumnType("TEXT"); + + b.Property<string>("Key") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property<Guid>("UserId") + .HasColumnType("TEXT"); + + b.Property<string>("Value") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "ItemId", "Client", "Key") + .IsUnique(); + + b.ToTable("CustomItemDisplayPreferences"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.DisplayPreferences", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property<int>("ChromecastVersion") + .HasColumnType("INTEGER"); + + b.Property<string>("Client") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property<string>("DashboardTheme") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property<bool>("EnableNextVideoInfoOverlay") + .HasColumnType("INTEGER"); + + b.Property<int?>("IndexBy") + .HasColumnType("INTEGER"); + + b.Property<Guid>("ItemId") + .HasColumnType("TEXT"); + + b.Property<int>("ScrollDirection") + .HasColumnType("INTEGER"); + + b.Property<bool>("ShowBackdrop") + .HasColumnType("INTEGER"); + + b.Property<bool>("ShowSidebar") + .HasColumnType("INTEGER"); + + b.Property<int>("SkipBackwardLength") + .HasColumnType("INTEGER"); + + b.Property<int>("SkipForwardLength") + .HasColumnType("INTEGER"); + + b.Property<string>("TvHome") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property<Guid>("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "ItemId", "Client") + .IsUnique(); + + b.ToTable("DisplayPreferences"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.HomeSection", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property<int>("DisplayPreferencesId") + .HasColumnType("INTEGER"); + + b.Property<int>("Order") + .HasColumnType("INTEGER"); + + b.Property<int>("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("DisplayPreferencesId"); + + b.ToTable("HomeSection"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.ImageInfo", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property<DateTime>("LastModified") + .HasColumnType("TEXT"); + + b.Property<string>("Path") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property<Guid?>("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("ImageInfos"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.ItemDisplayPreferences", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property<string>("Client") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property<int?>("IndexBy") + .HasColumnType("INTEGER"); + + b.Property<Guid>("ItemId") + .HasColumnType("TEXT"); + + b.Property<bool>("RememberIndexing") + .HasColumnType("INTEGER"); + + b.Property<bool>("RememberSorting") + .HasColumnType("INTEGER"); + + b.Property<string>("SortBy") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property<int>("SortOrder") + .HasColumnType("INTEGER"); + + b.Property<Guid>("UserId") + .HasColumnType("TEXT"); + + b.Property<int>("ViewType") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("ItemDisplayPreferences"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.Permission", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property<int>("Kind") + .HasColumnType("INTEGER"); + + b.Property<Guid?>("Permission_Permissions_Guid") + .HasColumnType("TEXT"); + + b.Property<uint>("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property<Guid?>("UserId") + .HasColumnType("TEXT"); + + b.Property<bool>("Value") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "Kind") + .IsUnique() + .HasFilter("[UserId] IS NOT NULL"); + + b.ToTable("Permissions"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.Preference", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property<int>("Kind") + .HasColumnType("INTEGER"); + + b.Property<Guid?>("Preference_Preferences_Guid") + .HasColumnType("TEXT"); + + b.Property<uint>("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property<Guid?>("UserId") + .HasColumnType("TEXT"); + + b.Property<string>("Value") + .IsRequired() + .HasMaxLength(65535) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "Kind") + .IsUnique() + .HasFilter("[UserId] IS NOT NULL"); + + b.ToTable("Preferences"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.Security.ApiKey", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property<string>("AccessToken") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property<DateTime>("DateCreated") + .HasColumnType("TEXT"); + + b.Property<DateTime>("DateLastActivity") + .HasColumnType("TEXT"); + + b.Property<string>("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AccessToken") + .IsUnique(); + + b.ToTable("ApiKeys"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.Security.Device", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property<string>("AccessToken") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property<string>("AppName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property<string>("AppVersion") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property<DateTime>("DateCreated") + .HasColumnType("TEXT"); + + b.Property<DateTime>("DateLastActivity") + .HasColumnType("TEXT"); + + b.Property<DateTime>("DateModified") + .HasColumnType("TEXT"); + + b.Property<string>("DeviceId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property<string>("DeviceName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property<bool>("IsActive") + .HasColumnType("INTEGER"); + + b.Property<Guid>("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DeviceId"); + + b.HasIndex("AccessToken", "DateLastActivity"); + + b.HasIndex("DeviceId", "DateLastActivity"); + + b.HasIndex("UserId", "DeviceId"); + + b.ToTable("Devices"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.Security.DeviceOptions", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property<string>("CustomName") + .HasColumnType("TEXT"); + + b.Property<string>("DeviceId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DeviceId") + .IsUnique(); + + b.ToTable("DeviceOptions"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.User", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property<string>("AudioLanguagePreference") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property<string>("AuthenticationProviderId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property<bool>("DisplayCollectionsView") + .HasColumnType("INTEGER"); + + b.Property<bool>("DisplayMissingEpisodes") + .HasColumnType("INTEGER"); + + b.Property<string>("EasyPassword") + .HasMaxLength(65535) + .HasColumnType("TEXT"); + + b.Property<bool>("EnableAutoLogin") + .HasColumnType("INTEGER"); + + b.Property<bool>("EnableLocalPassword") + .HasColumnType("INTEGER"); + + b.Property<bool>("EnableNextEpisodeAutoPlay") + .HasColumnType("INTEGER"); + + b.Property<bool>("EnableUserPreferenceAccess") + .HasColumnType("INTEGER"); + + b.Property<bool>("HidePlayedInLatest") + .HasColumnType("INTEGER"); + + b.Property<long>("InternalId") + .HasColumnType("INTEGER"); + + b.Property<int>("InvalidLoginAttemptCount") + .HasColumnType("INTEGER"); + + b.Property<DateTime?>("LastActivityDate") + .HasColumnType("TEXT"); + + b.Property<DateTime?>("LastLoginDate") + .HasColumnType("TEXT"); + + b.Property<int?>("LoginAttemptsBeforeLockout") + .HasColumnType("INTEGER"); + + b.Property<int>("MaxActiveSessions") + .HasColumnType("INTEGER"); + + b.Property<int?>("MaxParentalAgeRating") + .HasColumnType("INTEGER"); + + b.Property<bool>("MustUpdatePassword") + .HasColumnType("INTEGER"); + + b.Property<string>("Password") + .HasMaxLength(65535) + .HasColumnType("TEXT"); + + b.Property<string>("PasswordResetProviderId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property<bool>("PlayDefaultAudioTrack") + .HasColumnType("INTEGER"); + + b.Property<bool>("RememberAudioSelections") + .HasColumnType("INTEGER"); + + b.Property<bool>("RememberSubtitleSelections") + .HasColumnType("INTEGER"); + + b.Property<int?>("RemoteClientBitrateLimit") + .HasColumnType("INTEGER"); + + b.Property<uint>("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property<string>("SubtitleLanguagePreference") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property<int>("SubtitleMode") + .HasColumnType("INTEGER"); + + b.Property<int>("SyncPlayAccess") + .HasColumnType("INTEGER"); + + b.Property<string>("Username") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT") + .UseCollation("NOCASE"); + + b.HasKey("Id"); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.AccessSchedule", b => + { + b.HasOne("Jellyfin.Data.Entities.User", null) + .WithMany("AccessSchedules") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.DisplayPreferences", b => + { + b.HasOne("Jellyfin.Data.Entities.User", null) + .WithMany("DisplayPreferences") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.HomeSection", b => + { + b.HasOne("Jellyfin.Data.Entities.DisplayPreferences", null) + .WithMany("HomeSections") + .HasForeignKey("DisplayPreferencesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.ImageInfo", b => + { + b.HasOne("Jellyfin.Data.Entities.User", null) + .WithOne("ProfileImage") + .HasForeignKey("Jellyfin.Data.Entities.ImageInfo", "UserId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.ItemDisplayPreferences", b => + { + b.HasOne("Jellyfin.Data.Entities.User", null) + .WithMany("ItemDisplayPreferences") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.Permission", b => + { + b.HasOne("Jellyfin.Data.Entities.User", null) + .WithMany("Permissions") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.Preference", b => + { + b.HasOne("Jellyfin.Data.Entities.User", null) + .WithMany("Preferences") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.Security.Device", b => + { + b.HasOne("Jellyfin.Data.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.DisplayPreferences", b => + { + b.Navigation("HomeSections"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.User", b => + { + b.Navigation("AccessSchedules"); + + b.Navigation("DisplayPreferences"); + + b.Navigation("ItemDisplayPreferences"); + + b.Navigation("Permissions"); + + b.Navigation("Preferences"); + + b.Navigation("ProfileImage"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Jellyfin.Server.Implementations/Migrations/20210814002109_AddDevices.cs b/Jellyfin.Server.Implementations/Migrations/20210814002109_AddDevices.cs new file mode 100644 index 0000000000..ac062317a7 --- /dev/null +++ b/Jellyfin.Server.Implementations/Migrations/20210814002109_AddDevices.cs @@ -0,0 +1,128 @@ +#pragma warning disable CS1591, SA1601 + +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +namespace Jellyfin.Server.Implementations.Migrations +{ + public partial class AddDevices : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "ApiKeys", + schema: "jellyfin", + columns: table => new + { + Id = table.Column<int>(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + DateCreated = table.Column<DateTime>(type: "TEXT", nullable: false), + DateLastActivity = table.Column<DateTime>(type: "TEXT", nullable: false), + Name = table.Column<string>(type: "TEXT", maxLength: 64, nullable: false), + AccessToken = table.Column<string>(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ApiKeys", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "DeviceOptions", + schema: "jellyfin", + columns: table => new + { + Id = table.Column<int>(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + DeviceId = table.Column<string>(type: "TEXT", nullable: false), + CustomName = table.Column<string>(type: "TEXT", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_DeviceOptions", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "Devices", + schema: "jellyfin", + columns: table => new + { + Id = table.Column<int>(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + UserId = table.Column<Guid>(type: "TEXT", nullable: false), + AccessToken = table.Column<string>(type: "TEXT", nullable: false), + AppName = table.Column<string>(type: "TEXT", maxLength: 64, nullable: false), + AppVersion = table.Column<string>(type: "TEXT", maxLength: 32, nullable: false), + DeviceName = table.Column<string>(type: "TEXT", maxLength: 64, nullable: false), + DeviceId = table.Column<string>(type: "TEXT", maxLength: 256, nullable: false), + IsActive = table.Column<bool>(type: "INTEGER", nullable: false), + DateCreated = table.Column<DateTime>(type: "TEXT", nullable: false), + DateModified = table.Column<DateTime>(type: "TEXT", nullable: false), + DateLastActivity = table.Column<DateTime>(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Devices", x => x.Id); + table.ForeignKey( + name: "FK_Devices_Users_UserId", + column: x => x.UserId, + principalSchema: "jellyfin", + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_ApiKeys_AccessToken", + schema: "jellyfin", + table: "ApiKeys", + column: "AccessToken", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_DeviceOptions_DeviceId", + schema: "jellyfin", + table: "DeviceOptions", + column: "DeviceId", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_Devices_AccessToken_DateLastActivity", + schema: "jellyfin", + table: "Devices", + columns: new[] { "AccessToken", "DateLastActivity" }); + + migrationBuilder.CreateIndex( + name: "IX_Devices_DeviceId", + schema: "jellyfin", + table: "Devices", + column: "DeviceId"); + + migrationBuilder.CreateIndex( + name: "IX_Devices_DeviceId_DateLastActivity", + schema: "jellyfin", + table: "Devices", + columns: new[] { "DeviceId", "DateLastActivity" }); + + migrationBuilder.CreateIndex( + name: "IX_Devices_UserId_DeviceId", + schema: "jellyfin", + table: "Devices", + columns: new[] { "UserId", "DeviceId" }); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "ApiKeys", + schema: "jellyfin"); + + migrationBuilder.DropTable( + name: "DeviceOptions", + schema: "jellyfin"); + + migrationBuilder.DropTable( + name: "Devices", + schema: "jellyfin"); + } + } +} diff --git a/Jellyfin.Server.Implementations/Migrations/JellyfinDbModelSnapshot.cs b/Jellyfin.Server.Implementations/Migrations/JellyfinDbModelSnapshot.cs index 286eb7468a..fcc360e260 100644 --- a/Jellyfin.Server.Implementations/Migrations/JellyfinDbModelSnapshot.cs +++ b/Jellyfin.Server.Implementations/Migrations/JellyfinDbModelSnapshot.cs @@ -15,7 +15,7 @@ namespace Jellyfin.Server.Implementations.Migrations #pragma warning disable 612, 618 modelBuilder .HasDefaultSchema("jellyfin") - .HasAnnotation("ProductVersion", "5.0.3"); + .HasAnnotation("ProductVersion", "5.0.7"); modelBuilder.Entity("Jellyfin.Data.Entities.AccessSchedule", b => { @@ -332,6 +332,114 @@ namespace Jellyfin.Server.Implementations.Migrations b.ToTable("Preferences"); }); + modelBuilder.Entity("Jellyfin.Data.Entities.Security.ApiKey", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property<string>("AccessToken") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property<DateTime>("DateCreated") + .HasColumnType("TEXT"); + + b.Property<DateTime>("DateLastActivity") + .HasColumnType("TEXT"); + + b.Property<string>("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AccessToken") + .IsUnique(); + + b.ToTable("ApiKeys"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.Security.Device", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property<string>("AccessToken") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property<string>("AppName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property<string>("AppVersion") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property<DateTime>("DateCreated") + .HasColumnType("TEXT"); + + b.Property<DateTime>("DateLastActivity") + .HasColumnType("TEXT"); + + b.Property<DateTime>("DateModified") + .HasColumnType("TEXT"); + + b.Property<string>("DeviceId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property<string>("DeviceName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property<bool>("IsActive") + .HasColumnType("INTEGER"); + + b.Property<Guid>("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DeviceId"); + + b.HasIndex("AccessToken", "DateLastActivity"); + + b.HasIndex("DeviceId", "DateLastActivity"); + + b.HasIndex("UserId", "DeviceId"); + + b.ToTable("Devices"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.Security.DeviceOptions", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property<string>("CustomName") + .HasColumnType("TEXT"); + + b.Property<string>("DeviceId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DeviceId") + .IsUnique(); + + b.ToTable("DeviceOptions"); + }); + modelBuilder.Entity("Jellyfin.Data.Entities.User", b => { b.Property<Guid>("Id") @@ -505,6 +613,17 @@ namespace Jellyfin.Server.Implementations.Migrations .OnDelete(DeleteBehavior.Cascade); }); + modelBuilder.Entity("Jellyfin.Data.Entities.Security.Device", b => + { + b.HasOne("Jellyfin.Data.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + modelBuilder.Entity("Jellyfin.Data.Entities.DisplayPreferences", b => { b.Navigation("HomeSections"); diff --git a/Jellyfin.Server.Implementations/ModelConfiguration/ApiKeyConfiguration.cs b/Jellyfin.Server.Implementations/ModelConfiguration/ApiKeyConfiguration.cs new file mode 100644 index 0000000000..3f19b6986a --- /dev/null +++ b/Jellyfin.Server.Implementations/ModelConfiguration/ApiKeyConfiguration.cs @@ -0,0 +1,20 @@ +using Jellyfin.Data.Entities.Security; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Jellyfin.Server.Implementations.ModelConfiguration +{ + /// <summary> + /// FluentAPI configuration for the ApiKey entity. + /// </summary> + public class ApiKeyConfiguration : IEntityTypeConfiguration<ApiKey> + { + /// <inheritdoc/> + public void Configure(EntityTypeBuilder<ApiKey> builder) + { + builder + .HasIndex(entity => entity.AccessToken) + .IsUnique(); + } + } +} diff --git a/Jellyfin.Server.Implementations/ModelConfiguration/CustomItemDisplayPreferencesConfiguration.cs b/Jellyfin.Server.Implementations/ModelConfiguration/CustomItemDisplayPreferencesConfiguration.cs new file mode 100644 index 0000000000..779aec986b --- /dev/null +++ b/Jellyfin.Server.Implementations/ModelConfiguration/CustomItemDisplayPreferencesConfiguration.cs @@ -0,0 +1,20 @@ +using Jellyfin.Data.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Jellyfin.Server.Implementations.ModelConfiguration +{ + /// <summary> + /// FluentAPI configuration for the CustomItemDisplayPreferences entity. + /// </summary> + public class CustomItemDisplayPreferencesConfiguration : IEntityTypeConfiguration<CustomItemDisplayPreferences> + { + /// <inheritdoc/> + public void Configure(EntityTypeBuilder<CustomItemDisplayPreferences> builder) + { + builder + .HasIndex(entity => new { entity.UserId, entity.ItemId, entity.Client, entity.Key }) + .IsUnique(); + } + } +} diff --git a/Jellyfin.Server.Implementations/ModelConfiguration/DeviceConfiguration.cs b/Jellyfin.Server.Implementations/ModelConfiguration/DeviceConfiguration.cs new file mode 100644 index 0000000000..a750b65c03 --- /dev/null +++ b/Jellyfin.Server.Implementations/ModelConfiguration/DeviceConfiguration.cs @@ -0,0 +1,28 @@ +using Jellyfin.Data.Entities.Security; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Jellyfin.Server.Implementations.ModelConfiguration +{ + /// <summary> + /// FluentAPI configuration for the Device entity. + /// </summary> + public class DeviceConfiguration : IEntityTypeConfiguration<Device> + { + /// <inheritdoc/> + public void Configure(EntityTypeBuilder<Device> builder) + { + builder + .HasIndex(entity => new { entity.DeviceId, entity.DateLastActivity }); + + builder + .HasIndex(entity => new { entity.AccessToken, entity.DateLastActivity }); + + builder + .HasIndex(entity => new { entity.UserId, entity.DeviceId }); + + builder + .HasIndex(entity => entity.DeviceId); + } + } +} diff --git a/Jellyfin.Server.Implementations/ModelConfiguration/DeviceOptionsConfiguration.cs b/Jellyfin.Server.Implementations/ModelConfiguration/DeviceOptionsConfiguration.cs new file mode 100644 index 0000000000..038afd7524 --- /dev/null +++ b/Jellyfin.Server.Implementations/ModelConfiguration/DeviceOptionsConfiguration.cs @@ -0,0 +1,20 @@ +using Jellyfin.Data.Entities.Security; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Jellyfin.Server.Implementations.ModelConfiguration +{ + /// <summary> + /// FluentAPI configuration for the DeviceOptions entity. + /// </summary> + public class DeviceOptionsConfiguration : IEntityTypeConfiguration<DeviceOptions> + { + /// <inheritdoc/> + public void Configure(EntityTypeBuilder<DeviceOptions> builder) + { + builder + .HasIndex(entity => entity.DeviceId) + .IsUnique(); + } + } +} diff --git a/Jellyfin.Server.Implementations/ModelConfiguration/DisplayPreferencesConfiguration.cs b/Jellyfin.Server.Implementations/ModelConfiguration/DisplayPreferencesConfiguration.cs new file mode 100644 index 0000000000..9b437861bb --- /dev/null +++ b/Jellyfin.Server.Implementations/ModelConfiguration/DisplayPreferencesConfiguration.cs @@ -0,0 +1,25 @@ +using Jellyfin.Data.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Jellyfin.Server.Implementations.ModelConfiguration +{ + /// <summary> + /// FluentAPI configuration for the DisplayPreferencesConfiguration entity. + /// </summary> + public class DisplayPreferencesConfiguration : IEntityTypeConfiguration<DisplayPreferences> + { + /// <inheritdoc/> + public void Configure(EntityTypeBuilder<DisplayPreferences> builder) + { + builder + .HasMany(d => d.HomeSections) + .WithOne() + .OnDelete(DeleteBehavior.Cascade); + + builder + .HasIndex(entity => new { entity.UserId, entity.ItemId, entity.Client }) + .IsUnique(); + } + } +} diff --git a/Jellyfin.Server.Implementations/ModelConfiguration/PermissionConfiguration.cs b/Jellyfin.Server.Implementations/ModelConfiguration/PermissionConfiguration.cs new file mode 100644 index 0000000000..240e284c0c --- /dev/null +++ b/Jellyfin.Server.Implementations/ModelConfiguration/PermissionConfiguration.cs @@ -0,0 +1,24 @@ +using Jellyfin.Data.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Jellyfin.Server.Implementations.ModelConfiguration +{ + /// <summary> + /// FluentAPI configuration for the Permission entity. + /// </summary> + public class PermissionConfiguration : IEntityTypeConfiguration<Permission> + { + /// <inheritdoc/> + public void Configure(EntityTypeBuilder<Permission> builder) + { + // Used to get a user's permissions or a specific permission for a user. + // Also prevents multiple values being created for a user. + // Filtered over non-null user ids for when other entities (groups, API keys) get permissions + builder + .HasIndex(p => new { p.UserId, p.Kind }) + .HasFilter("[UserId] IS NOT NULL") + .IsUnique(); + } + } +} diff --git a/Jellyfin.Server.Implementations/ModelConfiguration/PreferenceConfiguration.cs b/Jellyfin.Server.Implementations/ModelConfiguration/PreferenceConfiguration.cs new file mode 100644 index 0000000000..49c869c6a8 --- /dev/null +++ b/Jellyfin.Server.Implementations/ModelConfiguration/PreferenceConfiguration.cs @@ -0,0 +1,21 @@ +using Jellyfin.Data.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Jellyfin.Server.Implementations.ModelConfiguration +{ + /// <summary> + /// FluentAPI configuration for the Permission entity. + /// </summary> + public class PreferenceConfiguration : IEntityTypeConfiguration<Preference> + { + /// <inheritdoc/> + public void Configure(EntityTypeBuilder<Preference> builder) + { + builder + .HasIndex(p => new { p.UserId, p.Kind }) + .HasFilter("[UserId] IS NOT NULL") + .IsUnique(); + } + } +} diff --git a/Jellyfin.Server.Implementations/ModelConfiguration/UserConfiguration.cs b/Jellyfin.Server.Implementations/ModelConfiguration/UserConfiguration.cs new file mode 100644 index 0000000000..a369cf6562 --- /dev/null +++ b/Jellyfin.Server.Implementations/ModelConfiguration/UserConfiguration.cs @@ -0,0 +1,56 @@ +using Jellyfin.Data.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Jellyfin.Server.Implementations.ModelConfiguration +{ + /// <summary> + /// FluentAPI configuration for the User entity. + /// </summary> + public class UserConfiguration : IEntityTypeConfiguration<User> + { + /// <inheritdoc/> + public void Configure(EntityTypeBuilder<User> builder) + { + builder + .Property(user => user.Username) + .UseCollation("NOCASE"); + + builder + .HasOne(u => u.ProfileImage) + .WithOne() + .OnDelete(DeleteBehavior.Cascade); + + builder + .HasMany(u => u.Permissions) + .WithOne() + .HasForeignKey(p => p.UserId) + .OnDelete(DeleteBehavior.Cascade); + + builder + .HasMany(u => u.Preferences) + .WithOne() + .HasForeignKey(p => p.UserId) + .OnDelete(DeleteBehavior.Cascade); + + builder + .HasMany(u => u.AccessSchedules) + .WithOne() + .OnDelete(DeleteBehavior.Cascade); + + builder + .HasMany(u => u.DisplayPreferences) + .WithOne() + .OnDelete(DeleteBehavior.Cascade); + + builder + .HasMany(u => u.ItemDisplayPreferences) + .WithOne() + .OnDelete(DeleteBehavior.Cascade); + + builder + .HasIndex(entity => entity.Username) + .IsUnique(); + } + } +} diff --git a/Jellyfin.Server.Implementations/Security/AuthenticationManager.cs b/Jellyfin.Server.Implementations/Security/AuthenticationManager.cs new file mode 100644 index 0000000000..b79e46469c --- /dev/null +++ b/Jellyfin.Server.Implementations/Security/AuthenticationManager.cs @@ -0,0 +1,73 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Jellyfin.Data.Entities.Security; +using MediaBrowser.Controller.Security; +using Microsoft.EntityFrameworkCore; + +namespace Jellyfin.Server.Implementations.Security +{ + /// <inheritdoc /> + public class AuthenticationManager : IAuthenticationManager + { + private readonly JellyfinDbProvider _dbProvider; + + /// <summary> + /// Initializes a new instance of the <see cref="AuthenticationManager"/> class. + /// </summary> + /// <param name="dbProvider">The database provider.</param> + public AuthenticationManager(JellyfinDbProvider dbProvider) + { + _dbProvider = dbProvider; + } + + /// <inheritdoc /> + public async Task CreateApiKey(string name) + { + await using var dbContext = _dbProvider.CreateContext(); + + dbContext.ApiKeys.Add(new ApiKey(name)); + + await dbContext.SaveChangesAsync().ConfigureAwait(false); + } + + /// <inheritdoc /> + public async Task<IReadOnlyList<AuthenticationInfo>> GetApiKeys() + { + await using var dbContext = _dbProvider.CreateContext(); + + return await dbContext.ApiKeys + .AsAsyncEnumerable() + .Select(key => new AuthenticationInfo + { + AppName = key.Name, + AccessToken = key.AccessToken, + DateCreated = key.DateCreated, + DeviceId = string.Empty, + DeviceName = string.Empty, + AppVersion = string.Empty + }).ToListAsync().ConfigureAwait(false); + } + + /// <inheritdoc /> + public async Task DeleteApiKey(string accessToken) + { + await using var dbContext = _dbProvider.CreateContext(); + + var key = await dbContext.ApiKeys + .AsQueryable() + .Where(apiKey => apiKey.AccessToken == accessToken) + .FirstOrDefaultAsync() + .ConfigureAwait(false); + + if (key == null) + { + return; + } + + dbContext.Remove(key); + + await dbContext.SaveChangesAsync().ConfigureAwait(false); + } + } +} diff --git a/Emby.Server.Implementations/HttpServer/Security/AuthorizationContext.cs b/Jellyfin.Server.Implementations/Security/AuthorizationContext.cs similarity index 51% rename from Emby.Server.Implementations/HttpServer/Security/AuthorizationContext.cs rename to Jellyfin.Server.Implementations/Security/AuthorizationContext.cs index 488614609a..3ab043c648 100644 --- a/Emby.Server.Implementations/HttpServer/Security/AuthorizationContext.cs +++ b/Jellyfin.Server.Implementations/Security/AuthorizationContext.cs @@ -3,40 +3,40 @@ using System; using System.Collections.Generic; using System.Net; -using Jellyfin.Extensions; +using System.Threading.Tasks; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Net; -using MediaBrowser.Controller.Security; using Microsoft.AspNetCore.Http; +using Microsoft.EntityFrameworkCore; using Microsoft.Net.Http.Headers; -namespace Emby.Server.Implementations.HttpServer.Security +namespace Jellyfin.Server.Implementations.Security { public class AuthorizationContext : IAuthorizationContext { - private readonly IAuthenticationRepository _authRepo; + private readonly JellyfinDbProvider _jellyfinDbProvider; private readonly IUserManager _userManager; - public AuthorizationContext(IAuthenticationRepository authRepo, IUserManager userManager) + public AuthorizationContext(JellyfinDbProvider jellyfinDb, IUserManager userManager) { - _authRepo = authRepo; + _jellyfinDbProvider = jellyfinDb; _userManager = userManager; } - public AuthorizationInfo GetAuthorizationInfo(HttpContext requestContext) + public Task<AuthorizationInfo> GetAuthorizationInfo(HttpContext requestContext) { - if (requestContext.Request.HttpContext.Items.TryGetValue("AuthorizationInfo", out var cached)) + if (requestContext.Request.HttpContext.Items.TryGetValue("AuthorizationInfo", out var cached) && cached != null) { - return (AuthorizationInfo)cached!; // Cache should never contain null + return Task.FromResult((AuthorizationInfo)cached); // Cache should never contain null } return GetAuthorization(requestContext); } - public AuthorizationInfo GetAuthorizationInfo(HttpRequest requestContext) + public async Task<AuthorizationInfo> GetAuthorizationInfo(HttpRequest requestContext) { var auth = GetAuthorizationDictionary(requestContext); - var authInfo = GetAuthorizationInfoFromDictionary(auth, requestContext.Headers, requestContext.Query); + var authInfo = await GetAuthorizationInfoFromDictionary(auth, requestContext.Headers, requestContext.Query).ConfigureAwait(false); return authInfo; } @@ -45,22 +45,22 @@ namespace Emby.Server.Implementations.HttpServer.Security /// </summary> /// <param name="httpReq">The HTTP req.</param> /// <returns>Dictionary{System.StringSystem.String}.</returns> - private AuthorizationInfo GetAuthorization(HttpContext httpReq) + private async Task<AuthorizationInfo> GetAuthorization(HttpContext httpReq) { var auth = GetAuthorizationDictionary(httpReq); - var authInfo = GetAuthorizationInfoFromDictionary(auth, httpReq.Request.Headers, httpReq.Request.Query); + var authInfo = await GetAuthorizationInfoFromDictionary(auth, httpReq.Request.Headers, httpReq.Request.Query).ConfigureAwait(false); httpReq.Request.HttpContext.Items["AuthorizationInfo"] = authInfo; return authInfo; } - private AuthorizationInfo GetAuthorizationInfoFromDictionary( - in Dictionary<string, string>? auth, - in IHeaderDictionary headers, - in IQueryCollection queryString) + private async Task<AuthorizationInfo> GetAuthorizationInfoFromDictionary( + IReadOnlyDictionary<string, string>? auth, + IHeaderDictionary headers, + IQueryCollection queryString) { string? deviceId = null; - string? device = null; + string? deviceName = null; string? client = null; string? version = null; string? token = null; @@ -68,12 +68,13 @@ namespace Emby.Server.Implementations.HttpServer.Security if (auth != null) { auth.TryGetValue("DeviceId", out deviceId); - auth.TryGetValue("Device", out device); + auth.TryGetValue("Device", out deviceName); auth.TryGetValue("Client", out client); auth.TryGetValue("Version", out version); auth.TryGetValue("Token", out token); } +#pragma warning disable CA1508 // string.IsNullOrEmpty(token) is always false. if (string.IsNullOrEmpty(token)) { token = headers["X-Emby-Token"]; @@ -98,7 +99,7 @@ namespace Emby.Server.Implementations.HttpServer.Security var authInfo = new AuthorizationInfo { Client = client, - Device = device, + Device = deviceName, DeviceId = deviceId, Version = version, Token = token, @@ -111,90 +112,83 @@ namespace Emby.Server.Implementations.HttpServer.Security // Request doesn't contain a token. return authInfo; } +#pragma warning restore CA1508 authInfo.HasToken = true; - var result = _authRepo.Get(new AuthenticationInfoQuery - { - AccessToken = token - }); + await using var dbContext = _jellyfinDbProvider.CreateContext(); + var device = await dbContext.Devices.FirstOrDefaultAsync(d => d.AccessToken == token).ConfigureAwait(false); - if (result.Items.Count > 0) + if (device != null) { authInfo.IsAuthenticated = true; - } - - var originalAuthenticationInfo = result.Items.Count > 0 ? result.Items[0] : null; - - if (originalAuthenticationInfo != null) - { var updateToken = false; // TODO: Remove these checks for IsNullOrWhiteSpace if (string.IsNullOrWhiteSpace(authInfo.Client)) { - authInfo.Client = originalAuthenticationInfo.AppName; + authInfo.Client = device.AppName; } if (string.IsNullOrWhiteSpace(authInfo.DeviceId)) { - authInfo.DeviceId = originalAuthenticationInfo.DeviceId; + authInfo.DeviceId = device.DeviceId; } // Temporary. TODO - allow clients to specify that the token has been shared with a casting device - var allowTokenInfoUpdate = authInfo.Client == null || authInfo.Client.IndexOf("chromecast", StringComparison.OrdinalIgnoreCase) == -1; + var allowTokenInfoUpdate = !authInfo.Client.Contains("chromecast", StringComparison.OrdinalIgnoreCase); if (string.IsNullOrWhiteSpace(authInfo.Device)) { - authInfo.Device = originalAuthenticationInfo.DeviceName; + authInfo.Device = device.DeviceName; } - else if (!string.Equals(authInfo.Device, originalAuthenticationInfo.DeviceName, StringComparison.OrdinalIgnoreCase)) + else if (!string.Equals(authInfo.Device, device.DeviceName, StringComparison.OrdinalIgnoreCase)) { if (allowTokenInfoUpdate) { updateToken = true; - originalAuthenticationInfo.DeviceName = authInfo.Device; + device.DeviceName = authInfo.Device; } } if (string.IsNullOrWhiteSpace(authInfo.Version)) { - authInfo.Version = originalAuthenticationInfo.AppVersion; + authInfo.Version = device.AppVersion; } - else if (!string.Equals(authInfo.Version, originalAuthenticationInfo.AppVersion, StringComparison.OrdinalIgnoreCase)) + else if (!string.Equals(authInfo.Version, device.AppVersion, StringComparison.OrdinalIgnoreCase)) { if (allowTokenInfoUpdate) { updateToken = true; - originalAuthenticationInfo.AppVersion = authInfo.Version; + device.AppVersion = authInfo.Version; } } - if ((DateTime.UtcNow - originalAuthenticationInfo.DateLastActivity).TotalMinutes > 3) + if ((DateTime.UtcNow - device.DateLastActivity).TotalMinutes > 3) { - originalAuthenticationInfo.DateLastActivity = DateTime.UtcNow; + device.DateLastActivity = DateTime.UtcNow; updateToken = true; } - if (!originalAuthenticationInfo.UserId.Equals(Guid.Empty)) - { - authInfo.User = _userManager.GetUserById(originalAuthenticationInfo.UserId); - - if (authInfo.User != null && !string.Equals(authInfo.User.Username, originalAuthenticationInfo.UserName, StringComparison.OrdinalIgnoreCase)) - { - originalAuthenticationInfo.UserName = authInfo.User.Username; - updateToken = true; - } + authInfo.User = _userManager.GetUserById(device.UserId); - authInfo.IsApiKey = false; - } - else + if (updateToken) { - authInfo.IsApiKey = true; + dbContext.Devices.Update(device); + await dbContext.SaveChangesAsync().ConfigureAwait(false); } - - if (updateToken) + } + else + { + var key = await dbContext.ApiKeys.FirstOrDefaultAsync(apiKey => apiKey.AccessToken == token).ConfigureAwait(false); + if (key != null) { - _authRepo.Update(originalAuthenticationInfo); + authInfo.IsAuthenticated = true; + authInfo.Client = key.Name; + authInfo.Token = key.AccessToken; + authInfo.DeviceId = string.Empty; + authInfo.Device = string.Empty; + authInfo.Version = string.Empty; + authInfo.IsApiKey = true; } } @@ -206,7 +200,7 @@ namespace Emby.Server.Implementations.HttpServer.Security /// </summary> /// <param name="httpReq">The HTTP req.</param> /// <returns>Dictionary{System.StringSystem.String}.</returns> - private Dictionary<string, string>? GetAuthorizationDictionary(HttpContext httpReq) + private static Dictionary<string, string>? GetAuthorizationDictionary(HttpContext httpReq) { var auth = httpReq.Request.Headers["X-Emby-Authorization"]; @@ -215,7 +209,7 @@ namespace Emby.Server.Implementations.HttpServer.Security auth = httpReq.Request.Headers[HeaderNames.Authorization]; } - return GetAuthorization(auth.Count > 0 ? auth[0] : null); + return auth.Count > 0 ? GetAuthorization(auth[0]) : null; } /// <summary> @@ -223,7 +217,7 @@ namespace Emby.Server.Implementations.HttpServer.Security /// </summary> /// <param name="httpReq">The HTTP req.</param> /// <returns>Dictionary{System.StringSystem.String}.</returns> - private Dictionary<string, string>? GetAuthorizationDictionary(HttpRequest httpReq) + private static Dictionary<string, string>? GetAuthorizationDictionary(HttpRequest httpReq) { var auth = httpReq.Headers["X-Emby-Authorization"]; @@ -232,7 +226,7 @@ namespace Emby.Server.Implementations.HttpServer.Security auth = httpReq.Headers[HeaderNames.Authorization]; } - return GetAuthorization(auth.Count > 0 ? auth[0] : null); + return auth.Count > 0 ? GetAuthorization(auth[0]) : null; } /// <summary> @@ -240,13 +234,8 @@ namespace Emby.Server.Implementations.HttpServer.Security /// </summary> /// <param name="authorizationHeader">The authorization header.</param> /// <returns>Dictionary{System.StringSystem.String}.</returns> - private Dictionary<string, string>? GetAuthorization(ReadOnlySpan<char> authorizationHeader) + private static Dictionary<string, string>? GetAuthorization(ReadOnlySpan<char> authorizationHeader) { - if (authorizationHeader == null) - { - return null; - } - var firstSpace = authorizationHeader.IndexOf(' '); // There should be at least two parts @@ -263,29 +252,57 @@ namespace Emby.Server.Implementations.HttpServer.Security return null; } + // Remove up until the first space authorizationHeader = authorizationHeader[(firstSpace + 1)..]; + return GetParts(authorizationHeader); + } - var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase); + /// <summary> + /// Get the authorization header components. + /// </summary> + /// <param name="authorizationHeader">The authorization header.</param> + /// <returns>Dictionary{System.StringSystem.String}.</returns> + public static Dictionary<string, string> GetParts(ReadOnlySpan<char> authorizationHeader) + { + var result = new Dictionary<string, string>(); + var escaped = false; + int start = 0; + string key = string.Empty; - foreach (var item in authorizationHeader.Split(',')) + int i; + for (i = 0; i < authorizationHeader.Length; i++) { - var trimmedItem = item.Trim(); - var firstEqualsSign = trimmedItem.IndexOf('='); - - if (firstEqualsSign > 0) + var token = authorizationHeader[i]; + if (token == '"' || token == ',') { - var key = trimmedItem[..firstEqualsSign].ToString(); - var value = NormalizeValue(trimmedItem[(firstEqualsSign + 1)..].Trim('"').ToString()); - result[key] = value; + // Applying a XOR logic to evaluate whether it is opening or closing a value + escaped = (!escaped) == (token == '"'); + if (token == ',' && !escaped) + { + // Meeting a comma after a closing escape char means the value is complete + if (start < i) + { + result[key] = WebUtility.UrlDecode(authorizationHeader[start..i].Trim('"').ToString()); + key = string.Empty; + } + + start = i + 1; + } + } + else if (!escaped && token == '=') + { + key = authorizationHeader[start.. i].Trim().ToString(); + start = i + 1; } } - return result; - } + // Add last value + if (start < i) + { + result[key] = WebUtility.UrlDecode(authorizationHeader[start..i].Trim('"').ToString()); + } - private static string NormalizeValue(string value) - { - return string.IsNullOrEmpty(value) ? value : WebUtility.HtmlEncode(value); + return result; } } } diff --git a/Jellyfin.Server.Implementations/Users/DefaultPasswordResetProvider.cs b/Jellyfin.Server.Implementations/Users/DefaultPasswordResetProvider.cs index c99c5e4efc..5e84255f91 100644 --- a/Jellyfin.Server.Implementations/Users/DefaultPasswordResetProvider.cs +++ b/Jellyfin.Server.Implementations/Users/DefaultPasswordResetProvider.cs @@ -10,6 +10,7 @@ using MediaBrowser.Common.Extensions; using MediaBrowser.Controller.Authentication; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Library; +using MediaBrowser.Model.IO; using MediaBrowser.Model.Users; namespace Jellyfin.Server.Implementations.Users @@ -53,7 +54,7 @@ namespace Jellyfin.Server.Implementations.Users foreach (var resetFile in Directory.EnumerateFiles(_passwordResetFileBaseDir, $"{BaseResetFileName}*")) { SerializablePasswordReset spr; - await using (var str = File.OpenRead(resetFile)) + await using (var str = AsyncFile.OpenRead(resetFile)) { spr = await JsonSerializer.DeserializeAsync<SerializablePasswordReset>(str).ConfigureAwait(false) ?? throw new ResourceNotFoundException($"Provided path ({resetFile}) is not valid."); @@ -92,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"; @@ -110,10 +107,9 @@ namespace Jellyfin.Server.Implementations.Users UserName = user.Username }; - await using (FileStream fileStream = File.OpenWrite(filePath)) + await using (FileStream fileStream = AsyncFile.OpenWrite(filePath)) { await JsonSerializer.SerializeAsync(fileStream, spr).ConfigureAwait(false); - await fileStream.FlushAsync().ConfigureAwait(false); } user.EasyPassword = pin; @@ -122,6 +118,7 @@ namespace Jellyfin.Server.Implementations.Users { Action = ForgotPasswordAction.PinCode, PinExpirationDate = expireTime, + PinFile = filePath }; } diff --git a/Jellyfin.Server.Implementations/Users/DeviceAccessEntryPoint.cs b/Jellyfin.Server.Implementations/Users/DeviceAccessEntryPoint.cs index dbba80c210..a471ea1d50 100644 --- a/Jellyfin.Server.Implementations/Users/DeviceAccessEntryPoint.cs +++ b/Jellyfin.Server.Implementations/Users/DeviceAccessEntryPoint.cs @@ -4,10 +4,10 @@ using System.Threading.Tasks; using Jellyfin.Data.Entities; using Jellyfin.Data.Enums; using Jellyfin.Data.Events; +using Jellyfin.Data.Queries; using MediaBrowser.Controller.Devices; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Plugins; -using MediaBrowser.Controller.Security; using MediaBrowser.Controller.Session; namespace Jellyfin.Server.Implementations.Users @@ -15,14 +15,12 @@ namespace Jellyfin.Server.Implementations.Users public sealed class DeviceAccessEntryPoint : IServerEntryPoint { private readonly IUserManager _userManager; - private readonly IAuthenticationRepository _authRepo; private readonly IDeviceManager _deviceManager; private readonly ISessionManager _sessionManager; - public DeviceAccessEntryPoint(IUserManager userManager, IAuthenticationRepository authRepo, IDeviceManager deviceManager, ISessionManager sessionManager) + public DeviceAccessEntryPoint(IUserManager userManager, IDeviceManager deviceManager, ISessionManager sessionManager) { _userManager = userManager; - _authRepo = authRepo; _deviceManager = deviceManager; _sessionManager = sessionManager; } @@ -38,27 +36,27 @@ namespace Jellyfin.Server.Implementations.Users { } - private void OnUserUpdated(object? sender, GenericEventArgs<User> e) + private async void OnUserUpdated(object? sender, GenericEventArgs<User> e) { var user = e.Argument; if (!user.HasPermission(PermissionKind.EnableAllDevices)) { - UpdateDeviceAccess(user); + await UpdateDeviceAccess(user).ConfigureAwait(false); } } - private void UpdateDeviceAccess(User user) + private async Task UpdateDeviceAccess(User user) { - var existing = _authRepo.Get(new AuthenticationInfoQuery + var existing = (await _deviceManager.GetDevices(new DeviceQuery { UserId = user.Id - }).Items; + }).ConfigureAwait(false)).Items; - foreach (var authInfo in existing) + foreach (var device in existing) { - if (!string.IsNullOrEmpty(authInfo.DeviceId) && !_deviceManager.CanAccessDevice(user, authInfo.DeviceId)) + if (!string.IsNullOrEmpty(device.DeviceId) && !_deviceManager.CanAccessDevice(user, device.DeviceId)) { - _sessionManager.Logout(authInfo); + await _sessionManager.Logout(device).ConfigureAwait(false); } } } diff --git a/Jellyfin.Server.Implementations/Users/UserManager.cs b/Jellyfin.Server.Implementations/Users/UserManager.cs index 27d4f40d30..8ca6e8d21b 100644 --- a/Jellyfin.Server.Implementations/Users/UserManager.cs +++ b/Jellyfin.Server.Implementations/Users/UserManager.cs @@ -163,15 +163,6 @@ namespace Jellyfin.Server.Implementations.Users OnUserUpdated?.Invoke(this, new GenericEventArgs<User>(user)); } - /// <inheritdoc/> - public void UpdateUser(User user) - { - using var dbContext = _dbProvider.CreateContext(); - dbContext.Users.Update(user); - _users[user.Id] = user; - dbContext.SaveChanges(); - } - /// <inheritdoc/> public async Task UpdateUserAsync(User user) { @@ -271,9 +262,9 @@ namespace Jellyfin.Server.Implementations.Users } /// <inheritdoc/> - public void ResetEasyPassword(User user) + public Task ResetEasyPassword(User user) { - ChangeEasyPassword(user, string.Empty, null); + return ChangeEasyPassword(user, string.Empty, null); } /// <inheritdoc/> @@ -291,7 +282,7 @@ namespace Jellyfin.Server.Implementations.Users } /// <inheritdoc/> - public void ChangeEasyPassword(User user, string newPassword, string? newPasswordSha1) + public async Task ChangeEasyPassword(User user, string newPassword, string? newPasswordSha1) { if (newPassword != null) { @@ -304,7 +295,7 @@ namespace Jellyfin.Server.Implementations.Users } user.EasyPassword = newPasswordSha1; - UpdateUser(user); + await UpdateUserAsync(user).ConfigureAwait(false); _eventManager.Publish(new UserPasswordChangedEventArgs(user)); } @@ -539,11 +530,7 @@ namespace Jellyfin.Server.Implementations.Users } } - return new PinRedeemResult - { - Success = false, - UsersReset = Array.Empty<string>() - }; + return new PinRedeemResult(); } /// <inheritdoc /> @@ -710,6 +697,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); diff --git a/Jellyfin.Server/Configuration/CorsPolicyProvider.cs b/Jellyfin.Server/Configuration/CorsPolicyProvider.cs index 0d04b6bb13..b061be33b5 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 94c3ca4a95..21bd9ba011 100644 --- a/Jellyfin.Server/CoreAppHost.cs +++ b/Jellyfin.Server/CoreAppHost.cs @@ -9,14 +9,18 @@ using Jellyfin.Api.WebSocketListeners; using Jellyfin.Drawing.Skia; using Jellyfin.Server.Implementations; using Jellyfin.Server.Implementations.Activity; +using Jellyfin.Server.Implementations.Devices; using Jellyfin.Server.Implementations.Events; +using Jellyfin.Server.Implementations.Security; using Jellyfin.Server.Implementations.Users; using MediaBrowser.Controller; using MediaBrowser.Controller.BaseItemManager; +using MediaBrowser.Controller.Devices; using MediaBrowser.Controller.Drawing; using MediaBrowser.Controller.Events; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Net; +using MediaBrowser.Controller.Security; using MediaBrowser.Model.Activity; using MediaBrowser.Model.IO; using Microsoft.EntityFrameworkCore; @@ -74,7 +78,9 @@ namespace Jellyfin.Server } ServiceCollection.AddDbContextPool<JellyfinDb>( - options => options.UseSqlite($"Filename={Path.Combine(ApplicationPaths.DataPath, "jellyfin.db")}")); + options => options + .UseLoggerFactory(LoggerFactory) + .UseSqlite($"Filename={Path.Combine(ApplicationPaths.DataPath, "jellyfin.db")}")); ServiceCollection.AddEventServices(); ServiceCollection.AddSingleton<IBaseItemManager, BaseItemManager>(); @@ -84,6 +90,7 @@ namespace Jellyfin.Server 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>(); @@ -91,6 +98,10 @@ namespace Jellyfin.Server ServiceCollection.AddSingleton<IWebSocketListener, ScheduledTasksWebSocketListener>(); ServiceCollection.AddSingleton<IWebSocketListener, SessionInfoWebSocketListener>(); + ServiceCollection.AddSingleton<IAuthorizationContext, AuthorizationContext>(); + + ServiceCollection.AddScoped<IAuthenticationManager, AuthenticationManager>(); + base.RegisterServices(); } diff --git a/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs b/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs index 15dc438561..e853609d69 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" }); @@ -303,7 +313,7 @@ namespace Jellyfin.Server.Extensions { description.TryGetMethodInfo(out MethodInfo methodInfo); // Attribute name, method name, none. - return description?.ActionDescriptor?.AttributeRouteInfo?.Name + return description?.ActionDescriptor.AttributeRouteInfo?.Name ?? methodInfo?.Name ?? null; }); @@ -341,7 +351,7 @@ namespace Jellyfin.Server.Extensions { foreach (var address in host.GetAddresses()) { - AddIpAddress(config, options, addr.Address, addr.PrefixLength); + AddIpAddress(config, options, address, address.AddressFamily == AddressFamily.InterNetwork ? 32 : 128); } } } @@ -397,7 +407,7 @@ namespace Jellyfin.Server.Extensions Type = "object", Properties = typeof(ImageType).GetEnumNames().ToDictionary( name => name, - name => new OpenApiSchema + _ => new OpenApiSchema { Type = "object", AdditionalProperties = new OpenApiSchema diff --git a/Jellyfin.Server/Infrastructure/SymlinkFollowingPhysicalFileResultExecutor.cs b/Jellyfin.Server/Infrastructure/SymlinkFollowingPhysicalFileResultExecutor.cs new file mode 100644 index 0000000000..73a619b8d9 --- /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 ea782cb66b..8983eb50fd 100644 --- a/Jellyfin.Server/Jellyfin.Server.csproj +++ b/Jellyfin.Server/Jellyfin.Server.csproj @@ -8,15 +8,10 @@ <PropertyGroup> <AssemblyName>jellyfin</AssemblyName> <OutputType>Exe</OutputType> - <TargetFramework>net5.0</TargetFramework> + <TargetFramework>net6.0</TargetFramework> <ServerGarbageCollection>false</ServerGarbageCollection> <GenerateAssemblyInfo>false</GenerateAssemblyInfo> <GenerateDocumentationFile>true</GenerateDocumentationFile> - <TreatWarningsAsErrors>true</TreatWarningsAsErrors> - <Nullable>enable</Nullable> - <AnalysisMode>AllEnabledByDefault</AnalysisMode> - <CodeAnalysisRuleSet>../jellyfin.ruleset</CodeAnalysisRuleSet> - <!-- <DisableImplicitAspNetCoreAnalyzers>true</DisableImplicitAspNetCoreAnalyzers> --> </PropertyGroup> <ItemGroup> @@ -36,20 +31,20 @@ <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.7" /> - <PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="5.0.7" /> - <PackageReference Include="prometheus-net" Version="4.1.1" /> - <PackageReference Include="prometheus-net.AspNetCore" Version="4.1.1" /> + <PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="6.0.0-rc.2*" /> + <PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="6.0.0-rc.2*" /> + <PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks" Version="6.0.0-rc.2*" /> + <PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="6.0.0-rc.2*" /> + <PackageReference Include="prometheus-net" Version="5.0.1" /> + <PackageReference Include="prometheus-net.AspNetCore" Version="5.0.1" /> <PackageReference Include="Serilog.AspNetCore" Version="4.1.0" /> <PackageReference Include="Serilog.Enrichers.Thread" Version="3.1.0" /> - <PackageReference Include="Serilog.Settings.Configuration" Version="3.1.0" /> - <PackageReference Include="Serilog.Sinks.Async" Version="1.4.0" /> - <PackageReference Include="Serilog.Sinks.Console" Version="3.1.1" /> - <PackageReference Include="Serilog.Sinks.File" Version="4.1.0" /> + <PackageReference Include="Serilog.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.File" Version="5.0.0" /> <PackageReference Include="Serilog.Sinks.Graylog" Version="2.2.2" /> - <PackageReference Include="SQLitePCLRaw.bundle_e_sqlite3" Version="2.0.4" /> + <PackageReference Include="SQLitePCLRaw.bundle_e_sqlite3" Version="2.0.6" /> </ItemGroup> <ItemGroup> diff --git a/Jellyfin.Server/Middleware/BaseUrlRedirectionMiddleware.cs b/Jellyfin.Server/Middleware/BaseUrlRedirectionMiddleware.cs index 2eef223e52..3e5982eedf 100644 --- a/Jellyfin.Server/Middleware/BaseUrlRedirectionMiddleware.cs +++ b/Jellyfin.Server/Middleware/BaseUrlRedirectionMiddleware.cs @@ -58,9 +58,12 @@ namespace Jellyfin.Server.Middleware return; } - if (!startsWithBaseUrl) + if (!startsWithBaseUrl + || localPath.Length == baseUrlPrefix.Length + // Local path is /baseUrl/ + || (localPath.Length == baseUrlPrefix.Length + 1 && localPath[^1] == '/')) { - // Always redirect back to the default path if the base prefix is invalid or missing + // Always redirect back to the default path if the base prefix is invalid, missing, or is the full path. _logger.LogDebug("Normalizing an URL at {LocalPath}", localPath); httpContext.Response.Redirect(baseUrlPrefix + "/" + _configuration[ConfigurationExtensions.DefaultRedirectKey]); return; diff --git a/Jellyfin.Server/Middleware/ExceptionMiddleware.cs b/Jellyfin.Server/Middleware/ExceptionMiddleware.cs index f6c76e4d9e..db7877c31e 100644 --- a/Jellyfin.Server/Middleware/ExceptionMiddleware.cs +++ b/Jellyfin.Server/Middleware/ExceptionMiddleware.cs @@ -137,11 +137,6 @@ namespace Jellyfin.Server.Middleware private string NormalizeExceptionMessage(string msg) { - if (msg == null) - { - return string.Empty; - } - // Strip any information we don't want to reveal return msg.Replace( _configuration.ApplicationPaths.ProgramSystemPath, diff --git a/Jellyfin.Server/Middleware/QueryStringDecodingMiddleware.cs b/Jellyfin.Server/Middleware/QueryStringDecodingMiddleware.cs index fd0ebbf438..cdd86e28e6 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/UrlDecodeQueryFeature.cs b/Jellyfin.Server/Middleware/UrlDecodeQueryFeature.cs index c1f5b5dfaf..e4d2937e7e 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('=')) { - // 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 cf938ab8cd..7365c8dbc1 100644 --- a/Jellyfin.Server/Migrations/MigrationRunner.cs +++ b/Jellyfin.Server/Migrations/MigrationRunner.cs @@ -25,7 +25,8 @@ namespace Jellyfin.Server.Migrations typeof(Routines.ReaddDefaultPluginRepository), typeof(Routines.MigrateDisplayPreferencesDb), typeof(Routines.RemoveDownloadImagesInAdvance), - typeof(Routines.AddPeopleQueryIndex) + typeof(Routines.AddPeopleQueryIndex), + typeof(Routines.MigrateAuthenticationDb) }; /// <summary> @@ -40,7 +41,7 @@ namespace Jellyfin.Server.Migrations .Select(m => ActivatorUtilities.CreateInstance(host.ServiceProvider, m)) .OfType<IMigrationRoutine>() .ToArray(); - var migrationOptions = ((IConfigurationManager)host.ConfigurationManager).GetConfiguration<MigrationOptions>(MigrationsListStore.StoreKey); + var migrationOptions = host.ConfigurationManager.GetConfiguration<MigrationOptions>(MigrationsListStore.StoreKey); if (!host.ConfigurationManager.Configuration.IsStartupWizardCompleted && migrationOptions.Applied.Count == 0) { diff --git a/Jellyfin.Server/Migrations/Routines/MigrateActivityLogDb.cs b/Jellyfin.Server/Migrations/Routines/MigrateActivityLogDb.cs index 6048160c63..9e22978aee 100644 --- a/Jellyfin.Server/Migrations/Routines/MigrateActivityLogDb.cs +++ b/Jellyfin.Server/Migrations/Routines/MigrateActivityLogDb.cs @@ -92,7 +92,7 @@ namespace Jellyfin.Server.Migrations.Routines if (entry[6].SQLiteType != SQLiteType.Null && !Guid.TryParse(entry[6].ToString(), out guid)) { // This is not a valid Guid, see if it is an internal ID from an old Emby schema - _logger.LogWarning("Invalid Guid in UserId column: ", entry[6].ToString()); + _logger.LogWarning("Invalid Guid in UserId column: {Guid}", entry[6].ToString()); using var statement = userDbConnection.PrepareStatement("SELECT guid FROM LocalUsersv2 WHERE Id=@Id"); statement.TryBind("@Id", entry[6].ToString()); diff --git a/Jellyfin.Server/Migrations/Routines/MigrateAuthenticationDb.cs b/Jellyfin.Server/Migrations/Routines/MigrateAuthenticationDb.cs new file mode 100644 index 0000000000..21f153623e --- /dev/null +++ b/Jellyfin.Server/Migrations/Routines/MigrateAuthenticationDb.cs @@ -0,0 +1,129 @@ +using System; +using System.Collections.Generic; +using System.IO; +using Emby.Server.Implementations.Data; +using Jellyfin.Data.Entities.Security; +using Jellyfin.Server.Implementations; +using MediaBrowser.Controller; +using Microsoft.Extensions.Logging; +using SQLitePCL.pretty; + +namespace Jellyfin.Server.Migrations.Routines +{ + /// <summary> + /// A migration that moves data from the authentication database into the new schema. + /// </summary> + public class MigrateAuthenticationDb : IMigrationRoutine + { + private const string DbFilename = "authentication.db"; + + private readonly ILogger<MigrateAuthenticationDb> _logger; + private readonly JellyfinDbProvider _dbProvider; + private readonly IServerApplicationPaths _appPaths; + + /// <summary> + /// Initializes a new instance of the <see cref="MigrateAuthenticationDb"/> class. + /// </summary> + /// <param name="logger">The logger.</param> + /// <param name="dbProvider">The database provider.</param> + /// <param name="appPaths">The server application paths.</param> + public MigrateAuthenticationDb(ILogger<MigrateAuthenticationDb> logger, JellyfinDbProvider dbProvider, IServerApplicationPaths appPaths) + { + _logger = logger; + _dbProvider = dbProvider; + _appPaths = appPaths; + } + + /// <inheritdoc /> + public Guid Id => Guid.Parse("5BD72F41-E6F3-4F60-90AA-09869ABE0E22"); + + /// <inheritdoc /> + public string Name => "MigrateAuthenticationDatabase"; + + /// <inheritdoc /> + public bool PerformOnNewInstall => false; + + /// <inheritdoc /> + public void Perform() + { + var dataPath = _appPaths.DataPath; + using (var connection = SQLite3.Open( + Path.Combine(dataPath, DbFilename), + ConnectionFlags.ReadOnly, + null)) + { + using var dbContext = _dbProvider.CreateContext(); + + var authenticatedDevices = connection.Query("SELECT * FROM Tokens"); + + foreach (var row in authenticatedDevices) + { + if (row[6].IsDbNull()) + { + dbContext.ApiKeys.Add(new ApiKey(row[3].ToString()) + { + AccessToken = row[1].ToString(), + DateCreated = row[9].ToDateTime(), + DateLastActivity = row[10].ToDateTime() + }); + } + else + { + dbContext.Devices.Add(new Device( + new Guid(row[6].ToString()), + row[3].ToString(), + row[4].ToString(), + row[5].ToString(), + row[2].ToString()) + { + AccessToken = row[1].ToString(), + IsActive = row[8].ToBool(), + DateCreated = row[9].ToDateTime(), + DateLastActivity = row[10].ToDateTime() + }); + } + } + + var deviceOptions = connection.Query("SELECT * FROM Devices"); + var deviceIds = new HashSet<string>(); + foreach (var row in deviceOptions) + { + if (row[2].IsDbNull()) + { + continue; + } + + var deviceId = row[2].ToString(); + if (deviceIds.Contains(deviceId)) + { + continue; + } + + deviceIds.Add(deviceId); + + dbContext.DeviceOptions.Add(new DeviceOptions(deviceId) + { + CustomName = row[1].IsDbNull() ? null : row[1].ToString() + }); + } + + dbContext.SaveChanges(); + } + + try + { + File.Move(Path.Combine(dataPath, DbFilename), Path.Combine(dataPath, DbFilename + ".old")); + + var journalPath = Path.Combine(dataPath, DbFilename + "-journal"); + if (File.Exists(journalPath)) + { + File.Move(journalPath, Path.Combine(dataPath, DbFilename + ".old-journal")); + } + } + catch (IOException e) + { + _logger.LogError(e, "Error renaming legacy activity log database to 'authentication.db.old'"); + } + } + } +} diff --git a/Jellyfin.Server/Migrations/Routines/MigrateDisplayPreferencesDb.cs b/Jellyfin.Server/Migrations/Routines/MigrateDisplayPreferencesDb.cs index e25d291226..40f871759c 100644 --- a/Jellyfin.Server/Migrations/Routines/MigrateDisplayPreferencesDb.cs +++ b/Jellyfin.Server/Migrations/Routines/MigrateDisplayPreferencesDb.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Generic; -using System.Globalization; using System.IO; using System.Linq; using System.Text.Json; @@ -121,7 +120,7 @@ namespace Jellyfin.Server.Migrations.Routines 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 d9524645a1..9b2d603c7f 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 3a3d7415bf..45699f3aff 100644 --- a/Jellyfin.Server/Program.cs +++ b/Jellyfin.Server/Program.cs @@ -5,7 +5,6 @@ using System.IO; using System.Linq; using System.Net; using System.Reflection; -using System.Runtime.InteropServices; using System.Text; using System.Threading; using System.Threading.Tasks; @@ -16,6 +15,7 @@ using Jellyfin.Server.Implementations; using MediaBrowser.Common.Configuration; using MediaBrowser.Common.Net; using MediaBrowser.Controller.Extensions; +using MediaBrowser.Model.IO; using Microsoft.AspNetCore.Hosting; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; @@ -121,11 +121,11 @@ namespace Jellyfin.Server // Log uncaught exceptions to the logging instead of std error AppDomain.CurrentDomain.UnhandledException -= UnhandledExceptionToConsole; - AppDomain.CurrentDomain.UnhandledException += (sender, e) + AppDomain.CurrentDomain.UnhandledException += (_, e) => _logger.LogCritical((Exception)e.ExceptionObject, "Unhandled Exception"); // Intercept Ctrl+C and Ctrl+Break - Console.CancelKeyPress += (sender, e) => + Console.CancelKeyPress += (_, e) => { if (_tokenSource.IsCancellationRequested) { @@ -139,7 +139,7 @@ namespace Jellyfin.Server }; // Register a SIGTERM handler - AppDomain.CurrentDomain.ProcessExit += (sender, e) => + AppDomain.CurrentDomain.ProcessExit += (_, _) => { if (_tokenSource.IsCancellationRequested) { @@ -180,7 +180,7 @@ namespace Jellyfin.Server "The server is expected to host the web client, but the provided content directory is either " + $"invalid or empty: {webContentPath}. If you do not want to host the web client with the " + "server, you may set the '--nowebclient' command line flag, or set" + - $"'{MediaBrowser.Controller.Extensions.ConfigurationExtensions.HostWebClientKey}=false' in your config settings."); + $"'{ConfigurationExtensions.HostWebClientKey}=false' in your config settings."); } } @@ -195,9 +195,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; @@ -224,7 +224,7 @@ namespace Jellyfin.Server { _logger.LogInformation("Running query planner optimizations in the database... This might take a while"); // Run before disposing the application - using var context = new JellyfinDbProvider(appHost.ServiceProvider, appPaths).CreateContext(); + using var context = appHost.Resolve<JellyfinDbProvider>().CreateContext(); if (context.Database.IsSqlite()) { context.Database.ExecuteSqlRaw("PRAGMA optimize"); @@ -318,8 +318,8 @@ namespace Jellyfin.Server } } - // Bind to unix socket (only on macOS and Linux) - if (startupConfig.UseUnixSocket() && !RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + // Bind to unix socket (only on unix systems) + if (startupConfig.UseUnixSocket() && Environment.OSVersion.Platform == PlatformID.Unix) { var socketPath = startupConfig.GetUnixSocketPath(); if (string.IsNullOrEmpty(socketPath)) @@ -404,7 +404,7 @@ namespace Jellyfin.Server { if (options.DataDir != null || Directory.Exists(Path.Combine(dataDir, "config")) - || RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + || OperatingSystem.IsWindows()) { // Hang config folder off already set dataDir configDir = Path.Combine(dataDir, "config"); @@ -442,7 +442,7 @@ namespace Jellyfin.Server if (string.IsNullOrEmpty(cacheDir)) { - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + if (OperatingSystem.IsWindows()) { // Hang cache folder off already set dataDir cacheDir = Path.Combine(dataDir, "cache"); @@ -543,11 +543,11 @@ namespace Jellyfin.Server // Get a stream of the resource contents // NOTE: The .csproj name is used instead of the assembly name in the resource path const string ResourcePath = "Jellyfin.Server.Resources.Configuration.logging.json"; - await using Stream? resource = typeof(Program).Assembly.GetManifestResourceStream(ResourcePath) + await using Stream resource = typeof(Program).Assembly.GetManifestResourceStream(ResourcePath) ?? throw new InvalidOperationException($"Invalid resource path: '{ResourcePath}'"); // Copy the resource contents to the expected file path for the config file - await using Stream dst = File.Open(configPath, FileMode.CreateNew); + 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 +594,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 +602,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 +613,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"); } } diff --git a/Jellyfin.Server/Startup.cs b/Jellyfin.Server/Startup.cs index 60cdc2f6fe..8085c26308 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/Extensions/ProcessExtensions.cs b/MediaBrowser.Common/Extensions/ProcessExtensions.cs index c747871222..08e01bfd65 100644 --- a/MediaBrowser.Common/Extensions/ProcessExtensions.cs +++ b/MediaBrowser.Common/Extensions/ProcessExtensions.cs @@ -40,7 +40,7 @@ namespace MediaBrowser.Common.Extensions // Add an event handler for the process exit event var tcs = new TaskCompletionSource<bool>(); - process.Exited += (sender, args) => tcs.TrySetResult(true); + process.Exited += (_, _) => tcs.TrySetResult(true); // Return immediately if the process has already exited if (process.HasExitedSafe()) diff --git a/MediaBrowser.MediaEncoding/FfmpegException.cs b/MediaBrowser.Common/FfmpegException.cs similarity index 97% rename from MediaBrowser.MediaEncoding/FfmpegException.cs rename to MediaBrowser.Common/FfmpegException.cs index 1697fd33a1..be420196d7 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/MediaBrowser.Common.csproj b/MediaBrowser.Common/MediaBrowser.Common.csproj index 0299a84563..9c8ce4ac57 100644 --- a/MediaBrowser.Common/MediaBrowser.Common.csproj +++ b/MediaBrowser.Common/MediaBrowser.Common.csproj @@ -19,8 +19,8 @@ </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.Extensions.Configuration.Abstractions" Version="6.0.0-rc.2*" /> + <PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="6.0.0-rc.2*" /> <PackageReference Include="Microsoft.SourceLink.GitHub" Version="1.0.0" PrivateAssets="All" /> </ItemGroup> @@ -29,13 +29,9 @@ </ItemGroup> <PropertyGroup> - <TargetFramework>net5.0</TargetFramework> + <TargetFramework>net6.0</TargetFramework> <GenerateAssemblyInfo>false</GenerateAssemblyInfo> <GenerateDocumentationFile>true</GenerateDocumentationFile> - <TreatWarningsAsErrors>true</TreatWarningsAsErrors> - <Nullable>enable</Nullable> - <AnalysisMode>AllEnabledByDefault</AnalysisMode> - <CodeAnalysisRuleSet>../jellyfin.ruleset</CodeAnalysisRuleSet> <PublishRepositoryUrl>true</PublishRepositoryUrl> <EmbedUntrackedSources>true</EmbedUntrackedSources> <IncludeSymbols>true</IncludeSymbols> diff --git a/MediaBrowser.Common/Net/IPHost.cs b/MediaBrowser.Common/Net/IPHost.cs index 5db8817ee6..1f125f2b1d 100644 --- a/MediaBrowser.Common/Net/IPHost.cs +++ b/MediaBrowser.Common/Net/IPHost.cs @@ -4,7 +4,6 @@ using System.Linq; using System.Net; using System.Net.Sockets; using System.Text.RegularExpressions; -using System.Threading.Tasks; namespace MediaBrowser.Common.Net { @@ -79,16 +78,11 @@ namespace MediaBrowser.Common.Net /// </summary> public override byte PrefixLength { - get - { - return (byte)(ResolveHost() ? 128 : 32); - } + get => (byte)(ResolveHost() ? 128 : 32); - set - { - // Not implemented, as a host object can only have a prefix length of 128 (IPv6) or 32 (IPv4) prefix length, - // which is automatically determined by it's IP type. Anything else is meaningless. - } + // Not implemented, as a host object can only have a prefix length of 128 (IPv6) or 32 (IPv4) prefix length, + // which is automatically determined by it's IP type. Anything else is meaningless. + set => throw new NotImplementedException(); } /// <summary> @@ -201,7 +195,7 @@ namespace MediaBrowser.Common.Net return res; } - throw new InvalidCastException("Host does not contain a valid value. {host}"); + throw new InvalidCastException($"Host does not contain a valid value. {host}"); } /// <summary> @@ -226,7 +220,7 @@ namespace MediaBrowser.Common.Net return res; } - throw new InvalidCastException("Host does not contain a valid value. {host}"); + throw new InvalidCastException($"Host does not contain a valid value. {host}"); } /// <summary> @@ -354,7 +348,7 @@ namespace MediaBrowser.Common.Net } } - output = output[0..^1]; + output = output[..^1]; if (moreThanOne) { @@ -405,7 +399,7 @@ namespace MediaBrowser.Common.Net if ((_addresses.Length == 0 && !Resolved) || (DateTime.UtcNow > _lastResolved.Value.AddMinutes(Timeout))) { _lastResolved = DateTime.UtcNow; - ResolveHostInternal().GetAwaiter().GetResult(); + ResolveHostInternal(); Resolved = true; } @@ -415,30 +409,31 @@ namespace MediaBrowser.Common.Net /// <summary> /// Task that looks up a Host name and returns its IP addresses. /// </summary> - /// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns> - private async Task ResolveHostInternal() + private void ResolveHostInternal() { - if (!string.IsNullOrEmpty(HostName)) + var hostName = HostName; + if (string.IsNullOrEmpty(hostName)) + { + return; + } + + // Resolves the host name - so save a DNS lookup. + if (string.Equals(hostName, "localhost", StringComparison.OrdinalIgnoreCase)) { - // Resolves the host name - so save a DNS lookup. - if (string.Equals(HostName, "localhost", StringComparison.OrdinalIgnoreCase)) + _addresses = new IPAddress[] { IPAddress.Loopback, IPAddress.IPv6Loopback }; + return; + } + + if (Uri.CheckHostName(hostName) == UriHostNameType.Dns) + { + try { - _addresses = new IPAddress[] { IPAddress.Loopback, IPAddress.IPv6Loopback }; - return; + _addresses = Dns.GetHostEntry(hostName).AddressList; } - - if (Uri.CheckHostName(HostName).Equals(UriHostNameType.Dns)) + catch (SocketException ex) { - try - { - IPHostEntry ip = await Dns.GetHostEntryAsync(HostName).ConfigureAwait(false); - _addresses = ip.AddressList; - } - catch (SocketException ex) - { - // Log and then ignore socket errors, as the result value will just be an empty array. - Debug.WriteLine("GetHostEntryAsync failed with {Message}.", ex.Message); - } + // Log and then ignore socket errors, as the result value will just be an empty array. + Debug.WriteLine("GetHostAddresses failed with {Message}.", ex.Message); } } } diff --git a/MediaBrowser.Common/Plugins/BasePluginOfT.cs b/MediaBrowser.Common/Plugins/BasePluginOfT.cs index 8a6d28e0f6..afda83a7c5 100644 --- a/MediaBrowser.Common/Plugins/BasePluginOfT.cs +++ b/MediaBrowser.Common/Plugins/BasePluginOfT.cs @@ -47,10 +47,10 @@ namespace MediaBrowser.Common.Plugins var assemblyFilePath = assembly.Location; var dataFolderPath = Path.Combine(ApplicationPaths.PluginsPath, Path.GetFileNameWithoutExtension(assemblyFilePath)); - if (!Directory.Exists(dataFolderPath) && Version != null) + if (Version != null && !Directory.Exists(dataFolderPath)) { // Try again with the version number appended to the folder name. - dataFolderPath = dataFolderPath + "_" + Version.ToString(); + dataFolderPath += "_" + Version.ToString(); } SetAttributes(assemblyFilePath, dataFolderPath, assemblyName.Version); diff --git a/MediaBrowser.Common/Providers/ProviderIdParsers.cs b/MediaBrowser.Common/Providers/ProviderIdParsers.cs index 33d09ed385..487b5a6d29 100644 --- a/MediaBrowser.Common/Providers/ProviderIdParsers.cs +++ b/MediaBrowser.Common/Providers/ProviderIdParsers.cs @@ -18,7 +18,7 @@ namespace MediaBrowser.Common.Providers /// <param name="text">The text to parse.</param> /// <param name="imdbId">The parsed IMDb id.</param> /// <returns>True if parsing was successful, false otherwise.</returns> - public static bool TryFindImdbId(ReadOnlySpan<char> text, [NotNullWhen(true)] out ReadOnlySpan<char> imdbId) + public static bool TryFindImdbId(ReadOnlySpan<char> text, out ReadOnlySpan<char> imdbId) { // imdb id is at least 9 chars (tt + 7 numbers) while (text.Length >= 2 + ImdbMinNumbers) @@ -62,7 +62,7 @@ namespace MediaBrowser.Common.Providers /// <param name="text">The text with the url to parse.</param> /// <param name="tmdbId">The parsed TMDb id.</param> /// <returns>True if parsing was successful, false otherwise.</returns> - public static bool TryFindTmdbMovieId(ReadOnlySpan<char> text, [NotNullWhen(true)] out ReadOnlySpan<char> tmdbId) + public static bool TryFindTmdbMovieId(ReadOnlySpan<char> text, out ReadOnlySpan<char> tmdbId) => TryFindProviderId(text, "themoviedb.org/movie/", out tmdbId); /// <summary> @@ -71,7 +71,7 @@ namespace MediaBrowser.Common.Providers /// <param name="text">The text with the url to parse.</param> /// <param name="tmdbId">The parsed TMDb id.</param> /// <returns>True if parsing was successful, false otherwise.</returns> - public static bool TryFindTmdbSeriesId(ReadOnlySpan<char> text, [NotNullWhen(true)] out ReadOnlySpan<char> tmdbId) + public static bool TryFindTmdbSeriesId(ReadOnlySpan<char> text, out ReadOnlySpan<char> tmdbId) => TryFindProviderId(text, "themoviedb.org/tv/", out tmdbId); /// <summary> @@ -80,7 +80,7 @@ namespace MediaBrowser.Common.Providers /// <param name="text">The text with the url to parse.</param> /// <param name="tvdbId">The parsed TVDb id.</param> /// <returns>True if parsing was successful, false otherwise.</returns> - public static bool TryFindTvdbId(ReadOnlySpan<char> text, [NotNullWhen(true)] out ReadOnlySpan<char> tvdbId) + public static bool TryFindTvdbId(ReadOnlySpan<char> text, out ReadOnlySpan<char> tvdbId) => TryFindProviderId(text, "thetvdb.com/?tab=series&id=", out tvdbId); private static bool TryFindProviderId(ReadOnlySpan<char> text, ReadOnlySpan<char> searchString, [NotNullWhen(true)] out ReadOnlySpan<char> providerId) diff --git a/MediaBrowser.Controller/BaseItemManager/BaseItemManager.cs b/MediaBrowser.Controller/BaseItemManager/BaseItemManager.cs index 97f40b5372..abfdb41d80 100644 --- a/MediaBrowser.Controller/BaseItemManager/BaseItemManager.cs +++ b/MediaBrowser.Controller/BaseItemManager/BaseItemManager.cs @@ -1,6 +1,5 @@ -#nullable disable - using System; +using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Threading; using Jellyfin.Extensions; @@ -16,7 +15,7 @@ namespace MediaBrowser.Controller.BaseItemManager { private readonly IServerConfigurationManager _serverConfigurationManager; - private int _metadataRefreshConcurrency = 0; + private int _metadataRefreshConcurrency; /// <summary> /// Initializes a new instance of the <see cref="BaseItemManager"/> class. @@ -101,7 +100,7 @@ namespace MediaBrowser.Controller.BaseItemManager /// Called when the configuration is updated. /// It will refresh the metadata throttler if the relevant config changed. /// </summary> - private void OnConfigurationUpdated(object sender, EventArgs e) + private void OnConfigurationUpdated(object? sender, EventArgs e) { int newMetadataRefreshConcurrency = GetMetadataRefreshConcurrency(); if (_metadataRefreshConcurrency != newMetadataRefreshConcurrency) @@ -114,6 +113,7 @@ namespace MediaBrowser.Controller.BaseItemManager /// <summary> /// Creates the metadata refresh throttler. /// </summary> + [MemberNotNull(nameof(MetadataRefreshThrottler))] private void SetupMetadataThrottler() { MetadataRefreshThrottler = new SemaphoreSlim(_metadataRefreshConcurrency); diff --git a/MediaBrowser.Controller/BaseItemManager/IBaseItemManager.cs b/MediaBrowser.Controller/BaseItemManager/IBaseItemManager.cs index b2b36c040b..e18994214e 100644 --- a/MediaBrowser.Controller/BaseItemManager/IBaseItemManager.cs +++ b/MediaBrowser.Controller/BaseItemManager/IBaseItemManager.cs @@ -1,5 +1,3 @@ -#nullable disable - using System.Threading; using MediaBrowser.Controller.Entities; using MediaBrowser.Model.Configuration; @@ -34,4 +32,4 @@ namespace MediaBrowser.Controller.BaseItemManager /// <returns><c>true</c> if image fetcher is enabled, else false.</returns> bool IsImageFetcherEnabled(BaseItem baseItem, LibraryOptions libraryOptions, string name); } -} \ No newline at end of file +} diff --git a/MediaBrowser.Controller/Channels/Channel.cs b/MediaBrowser.Controller/Channels/Channel.cs index 26a936be0c..e6923b55ca 100644 --- a/MediaBrowser.Controller/Channels/Channel.cs +++ b/MediaBrowser.Controller/Channels/Channel.cs @@ -17,6 +17,12 @@ namespace MediaBrowser.Controller.Channels { public class Channel : Folder { + [JsonIgnore] + public override bool SupportsInheritedParentImages => false; + + [JsonIgnore] + public override SourceType SourceType => SourceType.Channel; + public override bool IsVisible(User user) { var blockedChannelsPreference = user.GetPreferenceValues<Guid>(PreferenceKind.BlockedChannels); @@ -39,12 +45,6 @@ namespace MediaBrowser.Controller.Channels return base.IsVisible(user); } - [JsonIgnore] - public override bool SupportsInheritedParentImages => false; - - [JsonIgnore] - public override SourceType SourceType => SourceType.Channel; - protected override QueryResult<BaseItem> GetItemsInternal(InternalItemsQuery query) { try diff --git a/MediaBrowser.Controller/Channels/ChannelItemInfo.cs b/MediaBrowser.Controller/Channels/ChannelItemInfo.cs index 4d1e35f9ea..55f80b240f 100644 --- a/MediaBrowser.Controller/Channels/ChannelItemInfo.cs +++ b/MediaBrowser.Controller/Channels/ChannelItemInfo.cs @@ -1,6 +1,6 @@ #nullable disable -#pragma warning disable CS1591 +#pragma warning disable CA1002, CA2227, CS1591 using System; using System.Collections.Generic; diff --git a/MediaBrowser.Controller/Channels/ChannelItemResult.cs b/MediaBrowser.Controller/Channels/ChannelItemResult.cs index 6b20776629..ca7721991d 100644 --- a/MediaBrowser.Controller/Channels/ChannelItemResult.cs +++ b/MediaBrowser.Controller/Channels/ChannelItemResult.cs @@ -1,7 +1,6 @@ -#nullable disable - #pragma warning disable CS1591 +using System; using System.Collections.Generic; namespace MediaBrowser.Controller.Channels @@ -10,10 +9,10 @@ namespace MediaBrowser.Controller.Channels { public ChannelItemResult() { - Items = new List<ChannelItemInfo>(); + Items = Array.Empty<ChannelItemInfo>(); } - public List<ChannelItemInfo> Items { get; set; } + public IReadOnlyList<ChannelItemInfo> Items { get; set; } public int? TotalRecordCount { get; set; } } diff --git a/MediaBrowser.Controller/Channels/IHasFolderAttributes.cs b/MediaBrowser.Controller/Channels/IHasFolderAttributes.cs index 47277a8cc8..64af8496c7 100644 --- a/MediaBrowser.Controller/Channels/IHasFolderAttributes.cs +++ b/MediaBrowser.Controller/Channels/IHasFolderAttributes.cs @@ -1,4 +1,4 @@ -#pragma warning disable CS1591 +#pragma warning disable CA1819, CS1591 namespace MediaBrowser.Controller.Channels { diff --git a/MediaBrowser.Controller/Channels/InternalChannelFeatures.cs b/MediaBrowser.Controller/Channels/InternalChannelFeatures.cs index 45cd08173a..394996868e 100644 --- a/MediaBrowser.Controller/Channels/InternalChannelFeatures.cs +++ b/MediaBrowser.Controller/Channels/InternalChannelFeatures.cs @@ -1,6 +1,6 @@ #nullable disable -#pragma warning disable CS1591 +#pragma warning disable CA1002, CA2227, CS1591 using System.Collections.Generic; using MediaBrowser.Model.Channels; diff --git a/MediaBrowser.Controller/Chapters/IChapterManager.cs b/MediaBrowser.Controller/Chapters/IChapterManager.cs index f82e5b41a2..c049bb97e7 100644 --- a/MediaBrowser.Controller/Chapters/IChapterManager.cs +++ b/MediaBrowser.Controller/Chapters/IChapterManager.cs @@ -12,6 +12,8 @@ namespace MediaBrowser.Controller.Chapters /// <summary> /// Saves the chapters. /// </summary> + /// <param name="itemId">The item.</param> + /// <param name="chapters">The set of chapters.</param> void SaveChapters(Guid itemId, IReadOnlyList<ChapterInfo> chapters); } } diff --git a/MediaBrowser.Controller/Collections/CollectionModifiedEventArgs.cs b/MediaBrowser.Controller/Collections/CollectionModifiedEventArgs.cs index 8155cf3dbd..e538fa4b3f 100644 --- a/MediaBrowser.Controller/Collections/CollectionModifiedEventArgs.cs +++ b/MediaBrowser.Controller/Collections/CollectionModifiedEventArgs.cs @@ -1,5 +1,3 @@ -#nullable disable - #pragma warning disable CS1591 using System; diff --git a/MediaBrowser.Controller/Collections/ICollectionManager.cs b/MediaBrowser.Controller/Collections/ICollectionManager.cs index 46bc37e7f6..b8c33ee5a0 100644 --- a/MediaBrowser.Controller/Collections/ICollectionManager.cs +++ b/MediaBrowser.Controller/Collections/ICollectionManager.cs @@ -1,5 +1,3 @@ -#nullable disable - #pragma warning disable CS1591 using System; @@ -16,22 +14,23 @@ namespace MediaBrowser.Controller.Collections /// <summary> /// Occurs when [collection created]. /// </summary> - event EventHandler<CollectionCreatedEventArgs> CollectionCreated; + event EventHandler<CollectionCreatedEventArgs>? CollectionCreated; /// <summary> /// Occurs when [items added to collection]. /// </summary> - event EventHandler<CollectionModifiedEventArgs> ItemsAddedToCollection; + event EventHandler<CollectionModifiedEventArgs>? ItemsAddedToCollection; /// <summary> /// Occurs when [items removed from collection]. /// </summary> - event EventHandler<CollectionModifiedEventArgs> ItemsRemovedFromCollection; + event EventHandler<CollectionModifiedEventArgs>? ItemsRemovedFromCollection; /// <summary> /// Creates the collection. /// </summary> /// <param name="options">The options.</param> + /// <returns>BoxSet wrapped in an awaitable task.</returns> Task<BoxSet> CreateCollectionAsync(CollectionCreationOptions options); /// <summary> diff --git a/MediaBrowser.Controller/Configuration/IServerConfigurationManager.cs b/MediaBrowser.Controller/Configuration/IServerConfigurationManager.cs index 44e2c45dd4..43ad04dbac 100644 --- a/MediaBrowser.Controller/Configuration/IServerConfigurationManager.cs +++ b/MediaBrowser.Controller/Configuration/IServerConfigurationManager.cs @@ -1,5 +1,3 @@ -#nullable disable - using MediaBrowser.Common.Configuration; using MediaBrowser.Model.Configuration; diff --git a/MediaBrowser.Controller/Devices/IDeviceManager.cs b/MediaBrowser.Controller/Devices/IDeviceManager.cs index 8096be1bd3..8362db1a71 100644 --- a/MediaBrowser.Controller/Devices/IDeviceManager.cs +++ b/MediaBrowser.Controller/Devices/IDeviceManager.cs @@ -3,8 +3,11 @@ #pragma warning disable CS1591 using System; +using System.Threading.Tasks; using Jellyfin.Data.Entities; +using Jellyfin.Data.Entities.Security; using Jellyfin.Data.Events; +using Jellyfin.Data.Queries; using MediaBrowser.Model.Devices; using MediaBrowser.Model.Querying; using MediaBrowser.Model.Session; @@ -15,33 +18,52 @@ namespace MediaBrowser.Controller.Devices { event EventHandler<GenericEventArgs<Tuple<string, DeviceOptions>>> DeviceOptionsUpdated; + /// <summary> + /// Creates a new device. + /// </summary> + /// <param name="device">The device to create.</param> + /// <returns>A <see cref="Task{Device}"/> representing the creation of the device.</returns> + Task<Device> CreateDevice(Device device); + /// <summary> /// Saves the capabilities. /// </summary> - /// <param name="reportedId">The reported identifier.</param> + /// <param name="deviceId">The device id.</param> /// <param name="capabilities">The capabilities.</param> - void SaveCapabilities(string reportedId, ClientCapabilities capabilities); + void SaveCapabilities(string deviceId, ClientCapabilities capabilities); /// <summary> /// Gets the capabilities. /// </summary> - /// <param name="reportedId">The reported identifier.</param> + /// <param name="deviceId">The device id.</param> /// <returns>ClientCapabilities.</returns> - ClientCapabilities GetCapabilities(string reportedId); + ClientCapabilities GetCapabilities(string deviceId); /// <summary> /// Gets the device information. /// </summary> /// <param name="id">The identifier.</param> /// <returns>DeviceInfo.</returns> - DeviceInfo GetDevice(string id); + Task<DeviceInfo> GetDevice(string id); + + /// <summary> + /// Gets devices based on the provided query. + /// </summary> + /// <param name="query">The device query.</param> + /// <returns>A <see cref="Task{QueryResult}"/> representing the retrieval of the devices.</returns> + Task<QueryResult<Device>> GetDevices(DeviceQuery query); + + Task<QueryResult<DeviceInfo>> GetDeviceInfos(DeviceQuery query); /// <summary> /// Gets the devices. /// </summary> - /// <param name="query">The query.</param> + /// <param name="userId">The user's id, or <c>null</c>.</param> + /// <param name="supportsSync">A value indicating whether the device supports sync, or <c>null</c>.</param> /// <returns>IEnumerable<DeviceInfo>.</returns> - QueryResult<DeviceInfo> GetDevices(DeviceQuery query); + Task<QueryResult<DeviceInfo>> GetDevicesForUser(Guid? userId, bool? supportsSync); + + Task DeleteDevice(Device device); /// <summary> /// Determines whether this instance [can access device] the specified user identifier. @@ -51,8 +73,8 @@ namespace MediaBrowser.Controller.Devices /// <returns>Whether the user can access the device.</returns> bool CanAccessDevice(User user, string deviceId); - void UpdateDeviceOptions(string deviceId, DeviceOptions options); + Task UpdateDeviceOptions(string deviceId, string deviceName); - DeviceOptions GetDeviceOptions(string deviceId); + Task<DeviceOptions> GetDeviceOptions(string deviceId); } } diff --git a/MediaBrowser.Controller/Dlna/IDlnaManager.cs b/MediaBrowser.Controller/Dlna/IDlnaManager.cs index b51dc255ce..cc0a107a81 100644 --- a/MediaBrowser.Controller/Dlna/IDlnaManager.cs +++ b/MediaBrowser.Controller/Dlna/IDlnaManager.cs @@ -1,5 +1,3 @@ -#nullable disable - #pragma warning disable CS1591 using System.Collections.Generic; @@ -22,7 +20,7 @@ namespace MediaBrowser.Controller.Dlna /// </summary> /// <param name="headers">The headers.</param> /// <returns>DeviceProfile.</returns> - DeviceProfile GetProfile(IHeaderDictionary headers); + DeviceProfile? GetProfile(IHeaderDictionary headers); /// <summary> /// Gets the default profile. @@ -53,14 +51,14 @@ namespace MediaBrowser.Controller.Dlna /// </summary> /// <param name="id">The identifier.</param> /// <returns>DeviceProfile.</returns> - DeviceProfile GetProfile(string id); + DeviceProfile? GetProfile(string id); /// <summary> /// Gets the profile. /// </summary> /// <param name="deviceInfo">The device information.</param> /// <returns>DeviceProfile.</returns> - DeviceProfile GetProfile(DeviceIdentification deviceInfo); + DeviceProfile? GetProfile(DeviceIdentification deviceInfo); /// <summary> /// Gets the server description XML. @@ -76,6 +74,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/IImageEncoder.cs b/MediaBrowser.Controller/Drawing/IImageEncoder.cs index 4e640d4215..4e67cfee4f 100644 --- a/MediaBrowser.Controller/Drawing/IImageEncoder.cs +++ b/MediaBrowser.Controller/Drawing/IImageEncoder.cs @@ -57,6 +57,15 @@ namespace MediaBrowser.Controller.Drawing /// <summary> /// Encode an image. /// </summary> + /// <param name="inputPath">Input path of image.</param> + /// <param name="dateModified">Date modified.</param> + /// <param name="outputPath">Output path of image.</param> + /// <param name="autoOrient">Auto-orient image.</param> + /// <param name="orientation">Desired orientation of image.</param> + /// <param name="quality">Quality of encoded image.</param> + /// <param name="options">Image processing options.</param> + /// <param name="outputFormat">Image format of output.</param> + /// <returns>Path of encoded image.</returns> string EncodeImage(string inputPath, DateTime dateModified, string outputPath, bool autoOrient, ImageOrientation? orientation, int quality, ImageProcessingOptions options, ImageFormat outputFormat); /// <summary> diff --git a/MediaBrowser.Controller/Drawing/IImageProcessor.cs b/MediaBrowser.Controller/Drawing/IImageProcessor.cs index c7f61a90bb..7ca0e851bd 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); diff --git a/MediaBrowser.Controller/Drawing/ImageStream.cs b/MediaBrowser.Controller/Drawing/ImageStream.cs index 5ee781ffa9..f4c3057993 100644 --- a/MediaBrowser.Controller/Drawing/ImageStream.cs +++ b/MediaBrowser.Controller/Drawing/ImageStream.cs @@ -1,4 +1,4 @@ -#pragma warning disable CS1591 +#pragma warning disable CA1711, CS1591 using System; using System.IO; @@ -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/Dto/IDtoService.cs b/MediaBrowser.Controller/Dto/IDtoService.cs index 61d7962357..89aafc84fb 100644 --- a/MediaBrowser.Controller/Dto/IDtoService.cs +++ b/MediaBrowser.Controller/Dto/IDtoService.cs @@ -1,4 +1,5 @@ #nullable disable +#pragma warning disable CA1002 using System.Collections.Generic; using Jellyfin.Data.Entities; diff --git a/MediaBrowser.Controller/Entities/AggregateFolder.cs b/MediaBrowser.Controller/Entities/AggregateFolder.cs index fe1bc62aba..9589f52452 100644 --- a/MediaBrowser.Controller/Entities/AggregateFolder.cs +++ b/MediaBrowser.Controller/Entities/AggregateFolder.cs @@ -1,6 +1,6 @@ #nullable disable -#pragma warning disable CS1591 +#pragma warning disable CA1819, CS1591 using System; using System.Collections.Concurrent; @@ -18,52 +18,51 @@ namespace MediaBrowser.Controller.Entities { /// <summary> /// Specialized folder that can have items added to it's children by external entities. - /// Used for our RootFolder so plug-ins can add items. + /// Used for our RootFolder so plugins can add items. /// </summary> public class AggregateFolder : Folder { + private readonly object _childIdsLock = new object(); + + /// <summary> + /// The _virtual children. + /// </summary> + private readonly ConcurrentBag<BaseItem> _virtualChildren = new ConcurrentBag<BaseItem>(); private bool _requiresRefresh; + private Guid[] _childrenIds = null; public AggregateFolder() { PhysicalLocationsList = Array.Empty<string>(); } - [JsonIgnore] - public override bool IsPhysicalRoot => true; - - public override bool CanDelete() - { - return false; - } - - [JsonIgnore] - public override bool SupportsPlayedStatus => false; - - /// <summary> - /// The _virtual children. - /// </summary> - private readonly ConcurrentBag<BaseItem> _virtualChildren = new ConcurrentBag<BaseItem>(); - /// <summary> /// Gets the virtual children. /// </summary> /// <value>The virtual children.</value> public ConcurrentBag<BaseItem> VirtualChildren => _virtualChildren; + [JsonIgnore] + public override bool IsPhysicalRoot => true; + + [JsonIgnore] + public override bool SupportsPlayedStatus => false; + [JsonIgnore] public override string[] PhysicalLocations => PhysicalLocationsList; public string[] PhysicalLocationsList { get; set; } + public override bool CanDelete() + { + return false; + } + protected override FileSystemMetadata[] GetFileSystemChildren(IDirectoryService directoryService) { return CreateResolveArgs(directoryService, true).FileSystemChildren; } - private Guid[] _childrenIds = null; - private readonly object _childIdsLock = new object(); - protected override List<BaseItem> LoadChildren() { lock (_childIdsLock) @@ -169,7 +168,7 @@ namespace MediaBrowser.Controller.Entities /// Adds the virtual child. /// </summary> /// <param name="child">The child.</param> - /// <exception cref="ArgumentNullException"></exception> + /// <exception cref="ArgumentNullException">Throws if child is null.</exception> public void AddVirtualChild(BaseItem child) { if (child == null) diff --git a/MediaBrowser.Controller/Entities/Audio/Audio.cs b/MediaBrowser.Controller/Entities/Audio/Audio.cs index 576ab67a22..536668e508 100644 --- a/MediaBrowser.Controller/Entities/Audio/Audio.cs +++ b/MediaBrowser.Controller/Entities/Audio/Audio.cs @@ -1,6 +1,6 @@ #nullable disable -#pragma warning disable CS1591 +#pragma warning disable CA1002, CA1724, CA1826, CS1591 using System; using System.Collections.Generic; @@ -25,6 +25,12 @@ namespace MediaBrowser.Controller.Entities.Audio IHasLookupInfo<SongInfo>, IHasMediaSources { + public Audio() + { + Artists = Array.Empty<string>(); + AlbumArtists = Array.Empty<string>(); + } + /// <inheritdoc /> [JsonIgnore] public IReadOnlyList<string> Artists { get; set; } @@ -33,22 +39,11 @@ namespace MediaBrowser.Controller.Entities.Audio [JsonIgnore] public IReadOnlyList<string> AlbumArtists { get; set; } - public Audio() - { - Artists = Array.Empty<string>(); - AlbumArtists = Array.Empty<string>(); - } - - public override double GetDefaultPrimaryImageAspectRatio() - { - return 1; - } - [JsonIgnore] public override bool SupportsPlayedStatus => true; [JsonIgnore] - public override bool SupportsPeople => false; + public override bool SupportsPeople => true; [JsonIgnore] public override bool SupportsAddingToPlaylist => true; @@ -62,11 +57,6 @@ namespace MediaBrowser.Controller.Entities.Audio [JsonIgnore] public override Folder LatestItemsIndexContainer => AlbumEntity; - public override bool CanDownload() - { - return IsFileProtocol; - } - [JsonIgnore] public MusicAlbum AlbumEntity => FindParent<MusicAlbum>(); @@ -77,6 +67,16 @@ namespace MediaBrowser.Controller.Entities.Audio [JsonIgnore] public override string MediaType => Model.Entities.MediaType.Audio; + public override double GetDefaultPrimaryImageAspectRatio() + { + return 1; + } + + public override bool CanDownload() + { + return IsFileProtocol; + } + /// <summary> /// Creates the name of the sort. /// </summary> diff --git a/MediaBrowser.Controller/Entities/Audio/IHasMusicGenres.cs b/MediaBrowser.Controller/Entities/Audio/IHasMusicGenres.cs index db60c3071d..c2dae5a2dc 100644 --- a/MediaBrowser.Controller/Entities/Audio/IHasMusicGenres.cs +++ b/MediaBrowser.Controller/Entities/Audio/IHasMusicGenres.cs @@ -1,6 +1,6 @@ #nullable disable -#pragma warning disable CS1591 +#pragma warning disable CA1819, CS1591 namespace MediaBrowser.Controller.Entities.Audio { diff --git a/MediaBrowser.Controller/Entities/Audio/MusicAlbum.cs b/MediaBrowser.Controller/Entities/Audio/MusicAlbum.cs index 610bce4f5f..03d1f33043 100644 --- a/MediaBrowser.Controller/Entities/Audio/MusicAlbum.cs +++ b/MediaBrowser.Controller/Entities/Audio/MusicAlbum.cs @@ -1,6 +1,6 @@ #nullable disable -#pragma warning disable CS1591 +#pragma warning disable CA1721, CA1826, CS1591 using System; using System.Collections.Generic; @@ -23,18 +23,18 @@ namespace MediaBrowser.Controller.Entities.Audio /// </summary> public class MusicAlbum : Folder, IHasAlbumArtist, IHasArtist, IHasMusicGenres, IHasLookupInfo<AlbumInfo>, IMetadataContainer { - /// <inheritdoc /> - public IReadOnlyList<string> AlbumArtists { get; set; } - - /// <inheritdoc /> - public IReadOnlyList<string> Artists { get; set; } - public MusicAlbum() { Artists = Array.Empty<string>(); AlbumArtists = Array.Empty<string>(); } + /// <inheritdoc /> + public IReadOnlyList<string> AlbumArtists { get; set; } + + /// <inheritdoc /> + public IReadOnlyList<string> Artists { get; set; } + [JsonIgnore] public override bool SupportsAddingToPlaylist => true; @@ -44,6 +44,25 @@ namespace MediaBrowser.Controller.Entities.Audio [JsonIgnore] public MusicArtist MusicArtist => GetMusicArtist(new DtoOptions(true)); + [JsonIgnore] + public override bool SupportsPlayedStatus => false; + + [JsonIgnore] + public override bool SupportsCumulativeRunTimeTicks => true; + + [JsonIgnore] + public string AlbumArtist => AlbumArtists.FirstOrDefault(); + + [JsonIgnore] + public override bool SupportsPeople => false; + + /// <summary> + /// Gets the tracks. + /// </summary> + /// <value>The tracks.</value> + [JsonIgnore] + public IEnumerable<Audio> Tracks => GetRecursiveChildren(i => i is Audio).Cast<Audio>(); + public MusicArtist GetMusicArtist(DtoOptions options) { var parents = GetParents(); @@ -64,25 +83,6 @@ namespace MediaBrowser.Controller.Entities.Audio return null; } - [JsonIgnore] - public override bool SupportsPlayedStatus => false; - - [JsonIgnore] - public override bool SupportsCumulativeRunTimeTicks => true; - - [JsonIgnore] - public string AlbumArtist => AlbumArtists.FirstOrDefault(); - - [JsonIgnore] - public override bool SupportsPeople => false; - - /// <summary> - /// Gets the tracks. - /// </summary> - /// <value>The tracks.</value> - [JsonIgnore] - public IEnumerable<Audio> Tracks => GetRecursiveChildren(i => i is Audio).Cast<Audio>(); - protected override IEnumerable<BaseItem> GetEligibleChildrenForRecursiveChildren(User user) { return Tracks; diff --git a/MediaBrowser.Controller/Entities/Audio/MusicArtist.cs b/MediaBrowser.Controller/Entities/Audio/MusicArtist.cs index 53fcdbf428..f30f8ce7f2 100644 --- a/MediaBrowser.Controller/Entities/Audio/MusicArtist.cs +++ b/MediaBrowser.Controller/Entities/Audio/MusicArtist.cs @@ -44,6 +44,36 @@ namespace MediaBrowser.Controller.Entities.Audio [JsonIgnore] public override bool SupportsPlayedStatus => false; + /// <summary> + /// Gets the folder containing the item. + /// If the item is a folder, it returns the folder itself. + /// </summary> + /// <value>The containing folder path.</value> + [JsonIgnore] + public override string ContainingFolderPath => Path; + + [JsonIgnore] + public override IEnumerable<BaseItem> Children + { + get + { + if (IsAccessedByName) + { + return new List<BaseItem>(); + } + + return base.Children; + } + } + + [JsonIgnore] + public override bool SupportsPeople => false; + + public static string GetPath(string name) + { + return GetPath(name, true); + } + public override double GetDefaultPrimaryImageAspectRatio() { return 1; @@ -65,20 +95,6 @@ namespace MediaBrowser.Controller.Entities.Audio return LibraryManager.GetItemList(query); } - [JsonIgnore] - public override IEnumerable<BaseItem> Children - { - get - { - if (IsAccessedByName) - { - return new List<BaseItem>(); - } - - return base.Children; - } - } - public override int GetChildCount(User user) { return IsAccessedByName ? 0 : base.GetChildCount(user); @@ -113,14 +129,6 @@ namespace MediaBrowser.Controller.Entities.Audio return list; } - /// <summary> - /// Gets the folder containing the item. - /// If the item is a folder, it returns the folder itself. - /// </summary> - /// <value>The containing folder path.</value> - [JsonIgnore] - public override string ContainingFolderPath => Path; - /// <summary> /// Gets the user data key. /// </summary> @@ -167,14 +175,6 @@ namespace MediaBrowser.Controller.Entities.Audio return info; } - [JsonIgnore] - public override bool SupportsPeople => false; - - public static string GetPath(string name) - { - return GetPath(name, true); - } - public static string GetPath(string name, bool normalizeName) { // Trim the period at the end because windows will have a hard time with that @@ -208,6 +208,8 @@ namespace MediaBrowser.Controller.Entities.Audio /// <summary> /// This is called before any metadata refresh and returns true or false indicating if changes were made. /// </summary> + /// <param name="replaceAllMetadata">Option to replace metadata.</param> + /// <returns>True if metadata changed.</returns> public override bool BeforeMetadataRefresh(bool replaceAllMetadata) { var hasChanges = base.BeforeMetadataRefresh(replaceAllMetadata); diff --git a/MediaBrowser.Controller/Entities/Audio/MusicGenre.cs b/MediaBrowser.Controller/Entities/Audio/MusicGenre.cs index b1559ff245..dc6fcc55a5 100644 --- a/MediaBrowser.Controller/Entities/Audio/MusicGenre.cs +++ b/MediaBrowser.Controller/Entities/Audio/MusicGenre.cs @@ -15,19 +15,6 @@ namespace MediaBrowser.Controller.Entities.Audio /// </summary> public class MusicGenre : BaseItem, IItemByName { - public override List<string> GetUserDataKeys() - { - var list = base.GetUserDataKeys(); - - list.Insert(0, GetType().Name + "-" + (Name ?? string.Empty).RemoveDiacritics()); - return list; - } - - public override string CreatePresentationUniqueKey() - { - return GetUserDataKeys()[0]; - } - [JsonIgnore] public override bool SupportsAddingToPlaylist => true; @@ -45,6 +32,22 @@ namespace MediaBrowser.Controller.Entities.Audio [JsonIgnore] public override string ContainingFolderPath => Path; + [JsonIgnore] + public override bool SupportsPeople => false; + + public override List<string> GetUserDataKeys() + { + var list = base.GetUserDataKeys(); + + list.Insert(0, GetType().Name + "-" + (Name ?? string.Empty).RemoveDiacritics()); + return list; + } + + public override string CreatePresentationUniqueKey() + { + return GetUserDataKeys()[0]; + } + public override double GetDefaultPrimaryImageAspectRatio() { return 1; @@ -60,9 +63,6 @@ namespace MediaBrowser.Controller.Entities.Audio return true; } - [JsonIgnore] - public override bool SupportsPeople => false; - public IList<BaseItem> GetTaggedItems(InternalItemsQuery query) { query.GenreIds = new[] { Id }; @@ -106,6 +106,8 @@ namespace MediaBrowser.Controller.Entities.Audio /// <summary> /// This is called before any metadata refresh and returns true or false indicating if changes were made. /// </summary> + /// <param name="replaceAllMetadata">Option to replace metadata.</param> + /// <returns>True if metadata changed.</returns> public override bool BeforeMetadataRefresh(bool replaceAllMetadata) { var hasChanges = base.BeforeMetadataRefresh(replaceAllMetadata); diff --git a/MediaBrowser.Controller/Entities/AudioBook.cs b/MediaBrowser.Controller/Entities/AudioBook.cs index 4052846228..782481fbcd 100644 --- a/MediaBrowser.Controller/Entities/AudioBook.cs +++ b/MediaBrowser.Controller/Entities/AudioBook.cs @@ -1,6 +1,6 @@ #nullable disable -#pragma warning disable CS1591 +#pragma warning disable CA1724, CS1591 using System; using System.Text.Json.Serialization; diff --git a/MediaBrowser.Controller/Entities/BaseItem.cs b/MediaBrowser.Controller/Entities/BaseItem.cs index e5be5421a2..838a9f2f8d 100644 --- a/MediaBrowser.Controller/Entities/BaseItem.cs +++ b/MediaBrowser.Controller/Entities/BaseItem.cs @@ -1,6 +1,6 @@ #nullable disable -#pragma warning disable CS1591 +#pragma warning disable CS1591, SA1401 using System; using System.Collections.Generic; @@ -19,6 +19,7 @@ using MediaBrowser.Common.Extensions; using MediaBrowser.Controller.Channels; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Dto; +using MediaBrowser.Controller.Entities.Audio; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Persistence; using MediaBrowser.Controller.Providers; @@ -40,6 +41,14 @@ namespace MediaBrowser.Controller.Entities /// </summary> public abstract class BaseItem : IHasProviderIds, IHasLookupInfo<ItemLookupInfo>, IEquatable<BaseItem> { + /// <summary> + /// The trailer folder name. + /// </summary> + public const string TrailersFolderName = "trailers"; + public const string ThemeSongsFolderName = "theme-music"; + public const string ThemeSongFileName = "theme"; + public const string ThemeVideosFolderName = "backdrops"; + /// <summary> /// The supported image extensions. /// </summary> @@ -61,6 +70,47 @@ namespace MediaBrowser.Controller.Entities ".ttml" }; + /// <summary> + /// Extra types that should be counted and displayed as "Special Features" in the UI. + /// </summary> + public static readonly IReadOnlyCollection<ExtraType> DisplayExtraTypes = new HashSet<ExtraType> + { + Model.Entities.ExtraType.Unknown, + Model.Entities.ExtraType.BehindTheScenes, + Model.Entities.ExtraType.Clip, + Model.Entities.ExtraType.DeletedScene, + Model.Entities.ExtraType.Interview, + Model.Entities.ExtraType.Sample, + Model.Entities.ExtraType.Scene + }; + + public static readonly char[] SlugReplaceChars = { '?', '/', '&' }; + + /// <summary> + /// The supported extra folder names and types. See <see cref="Emby.Naming.Common.NamingOptions" />. + /// </summary> + public static readonly Dictionary<string, ExtraType> AllExtrasTypesFolderNames = new Dictionary<string, ExtraType>(StringComparer.OrdinalIgnoreCase) + { + ["extras"] = MediaBrowser.Model.Entities.ExtraType.Unknown, + ["behind the scenes"] = MediaBrowser.Model.Entities.ExtraType.BehindTheScenes, + ["deleted scenes"] = MediaBrowser.Model.Entities.ExtraType.DeletedScene, + ["interviews"] = MediaBrowser.Model.Entities.ExtraType.Interview, + ["scenes"] = MediaBrowser.Model.Entities.ExtraType.Scene, + ["samples"] = MediaBrowser.Model.Entities.ExtraType.Sample, + ["shorts"] = MediaBrowser.Model.Entities.ExtraType.Clip, + ["featurettes"] = MediaBrowser.Model.Entities.ExtraType.Clip + }; + + private string _sortName; + private Guid[] _themeSongIds; + private Guid[] _themeVideoIds; + + private string _forcedSortName; + + private string _name; + + public const char SlugChar = '-'; + protected BaseItem() { Tags = Array.Empty<string>(); @@ -74,37 +124,6 @@ namespace MediaBrowser.Controller.Entities ExtraIds = Array.Empty<Guid>(); } - public static readonly char[] SlugReplaceChars = { '?', '/', '&' }; - public static char SlugChar = '-'; - - /// <summary> - /// The trailer folder name. - /// </summary> - public const string TrailerFolderName = "trailers"; - public const string ThemeSongsFolderName = "theme-music"; - public const string ThemeSongFilename = "theme"; - public const string ThemeVideosFolderName = "backdrops"; - public const string ExtrasFolderName = "extras"; - public const string BehindTheScenesFolderName = "behind the scenes"; - public const string DeletedScenesFolderName = "deleted scenes"; - public const string InterviewFolderName = "interviews"; - public const string SceneFolderName = "scenes"; - public const string SampleFolderName = "samples"; - public const string ShortsFolderName = "shorts"; - public const string FeaturettesFolderName = "featurettes"; - - public static readonly string[] AllExtrasTypesFolderNames = - { - ExtrasFolderName, - BehindTheScenesFolderName, - DeletedScenesFolderName, - InterviewFolderName, - SceneFolderName, - SampleFolderName, - ShortsFolderName, - FeaturettesFolderName - }; - [JsonIgnore] public Guid[] ThemeSongIds { @@ -194,8 +213,6 @@ namespace MediaBrowser.Controller.Entities [JsonIgnore] public virtual bool SupportsRemoteImageDownloading => true; - private string _name; - /// <summary> /// Gets or sets the name. /// </summary> @@ -328,12 +345,6 @@ namespace MediaBrowser.Controller.Entities [JsonIgnore] public virtual bool IsHidden => false; - public BaseItem GetOwner() - { - var ownerId = OwnerId; - return ownerId.Equals(Guid.Empty) ? null : LibraryManager.GetItemById(ownerId); - } - /// <summary> /// Gets the type of the location. /// </summary> @@ -379,13 +390,6 @@ namespace MediaBrowser.Controller.Entities } } - public bool IsPathProtocol(MediaProtocol protocol) - { - var current = PathProtocol; - - return current.HasValue && current.Value == protocol; - } - [JsonIgnore] public bool IsFileProtocol => IsPathProtocol(MediaProtocol.File); @@ -423,35 +427,17 @@ namespace MediaBrowser.Controller.Entities [JsonIgnore] public virtual bool EnableAlphaNumericSorting => true; - private List<Tuple<StringBuilder, bool>> GetSortChunks(string s1) - { - var list = new List<Tuple<StringBuilder, bool>>(); - - int thisMarker = 0; - - while (thisMarker < s1.Length) - { - char thisCh = s1[thisMarker]; + public virtual bool IsHD => Height >= 720; - var thisChunk = new StringBuilder(); - bool isNumeric = char.IsDigit(thisCh); + public bool IsShortcut { get; set; } - while (thisMarker < s1.Length && char.IsDigit(thisCh) == isNumeric) - { - thisChunk.Append(thisCh); - thisMarker++; + public string ShortcutPath { get; set; } - if (thisMarker < s1.Length) - { - thisCh = s1[thisMarker]; - } - } + public int Width { get; set; } - list.Add(new Tuple<StringBuilder, bool>(thisChunk, isNumeric)); - } + public int Height { get; set; } - return list; - } + public Guid[] ExtraIds { get; set; } /// <summary> /// Gets the primary image path. @@ -463,72 +449,6 @@ namespace MediaBrowser.Controller.Entities [JsonIgnore] public string PrimaryImagePath => this.GetImagePath(ImageType.Primary); - public virtual bool CanDelete() - { - if (SourceType == SourceType.Channel) - { - return ChannelManager.CanDelete(this); - } - - return IsFileProtocol; - } - - public virtual bool IsAuthorizedToDelete(User user, List<Folder> allCollectionFolders) - { - if (user.HasPermission(PermissionKind.EnableContentDeletion)) - { - return true; - } - - var allowed = user.GetPreferenceValues<Guid>(PreferenceKind.EnableContentDeletionFromFolders); - - if (SourceType == SourceType.Channel) - { - return allowed.Contains(ChannelId); - } - else - { - var collectionFolders = LibraryManager.GetCollectionFolders(this, allCollectionFolders); - - foreach (var folder in collectionFolders) - { - if (allowed.Contains(folder.Id)) - { - return true; - } - } - } - - return false; - } - - public bool CanDelete(User user, List<Folder> allCollectionFolders) - { - return CanDelete() && IsAuthorizedToDelete(user, allCollectionFolders); - } - - public bool CanDelete(User user) - { - var allCollectionFolders = LibraryManager.GetUserRootFolder().Children.OfType<Folder>().ToList(); - - return CanDelete(user, allCollectionFolders); - } - - public virtual bool CanDownload() - { - return false; - } - - public virtual bool IsAuthorizedToDownload(User user) - { - return user.HasPermission(PermissionKind.EnableContentDownloading); - } - - public bool CanDownload(User user) - { - return CanDownload() && IsAuthorizedToDownload(user); - } - /// <summary> /// Gets or sets the date created. /// </summary> @@ -548,38 +468,6 @@ namespace MediaBrowser.Controller.Entities [JsonIgnore] public DateTime DateLastRefreshed { get; set; } - /// <summary> - /// Gets or sets the logger. - /// </summary> - public static ILogger<BaseItem> Logger { get; set; } - - public static ILibraryManager LibraryManager { get; set; } - - public static IServerConfigurationManager ConfigurationManager { get; set; } - - public static IProviderManager ProviderManager { get; set; } - - public static ILocalizationManager LocalizationManager { get; set; } - - public static IItemRepository ItemRepository { get; set; } - - public static IFileSystem FileSystem { get; set; } - - public static IUserDataManager UserDataManager { get; set; } - - public static IChannelManager ChannelManager { get; set; } - - public static IMediaSourceManager MediaSourceManager { get; set; } - - /// <summary> - /// Returns a <see cref="string" /> that represents this instance. - /// </summary> - /// <returns>A <see cref="string" /> that represents this instance.</returns> - public override string ToString() - { - return Name; - } - [JsonIgnore] public bool IsLocked { get; set; } @@ -611,7 +499,45 @@ namespace MediaBrowser.Controller.Entities } } - private string _forcedSortName; + [JsonIgnore] + public bool EnableMediaSourceDisplay + { + get + { + if (SourceType == SourceType.Channel) + { + return ChannelManager.EnableMediaSourceDisplay(this); + } + + return true; + } + } + + [JsonIgnore] + public Guid ParentId { get; set; } + + /// <summary> + /// Gets or sets the logger. + /// </summary> + public static ILogger<BaseItem> Logger { get; set; } + + public static ILibraryManager LibraryManager { get; set; } + + public static IServerConfigurationManager ConfigurationManager { get; set; } + + public static IProviderManager ProviderManager { get; set; } + + public static ILocalizationManager LocalizationManager { get; set; } + + public static IItemRepository ItemRepository { get; set; } + + public static IFileSystem FileSystem { get; set; } + + public static IUserDataManager UserDataManager { get; set; } + + public static IChannelManager ChannelManager { get; set; } + + public static IMediaSourceManager MediaSourceManager { get; set; } /// <summary> /// Gets or sets the name of the forced sort. @@ -628,10 +554,6 @@ namespace MediaBrowser.Controller.Entities } } - private string _sortName; - private Guid[] _themeSongIds; - private Guid[] _themeVideoIds; - /// <summary> /// Gets or sets the name of the sort. /// </summary> @@ -660,198 +582,26 @@ namespace MediaBrowser.Controller.Entities set => _sortName = value; } - public string GetInternalMetadataPath() + [JsonIgnore] + public virtual Guid DisplayParentId { - var basePath = ConfigurationManager.ApplicationPaths.InternalMetadataPath; - - return GetInternalMetadataPath(basePath); + get + { + var parentId = ParentId; + return parentId; + } } - protected virtual string GetInternalMetadataPath(string basePath) + [JsonIgnore] + public BaseItem DisplayParent { - if (SourceType == SourceType.Channel) + get { - return System.IO.Path.Join(basePath, "channels", ChannelId.ToString("N", CultureInfo.InvariantCulture), Id.ToString("N", CultureInfo.InvariantCulture)); - } - - ReadOnlySpan<char> idString = Id.ToString("N", CultureInfo.InvariantCulture); - - return System.IO.Path.Join(basePath, "library", idString.Slice(0, 2), idString); - } - - /// <summary> - /// Creates the name of the sort. - /// </summary> - /// <returns>System.String.</returns> - protected virtual string CreateSortName() - { - if (Name == null) - { - return null; // some items may not have name filled in properly - } - - if (!EnableAlphaNumericSorting) - { - return Name.TrimStart(); - } - - var sortable = Name.Trim().ToLowerInvariant(); - - foreach (var removeChar in ConfigurationManager.Configuration.SortRemoveCharacters) - { - sortable = sortable.Replace(removeChar, string.Empty, StringComparison.Ordinal); - } - - foreach (var replaceChar in ConfigurationManager.Configuration.SortReplaceCharacters) - { - sortable = sortable.Replace(replaceChar, " ", StringComparison.Ordinal); - } - - foreach (var search in ConfigurationManager.Configuration.SortRemoveWords) - { - // Remove from beginning if a space follows - if (sortable.StartsWith(search + " ", StringComparison.Ordinal)) - { - sortable = sortable.Remove(0, search.Length + 1); - } - - // Remove from middle if surrounded by spaces - sortable = sortable.Replace(" " + search + " ", " ", StringComparison.Ordinal); - - // Remove from end if followed by a space - if (sortable.EndsWith(" " + search, StringComparison.Ordinal)) - { - sortable = sortable.Remove(sortable.Length - (search.Length + 1)); - } - } - - return ModifySortChunks(sortable); - } - - private string ModifySortChunks(string name) - { - var chunks = GetSortChunks(name); - - var builder = new StringBuilder(); - - foreach (var chunk in chunks) - { - var chunkBuilder = chunk.Item1; - - // This chunk is numeric - if (chunk.Item2) - { - while (chunkBuilder.Length < 10) - { - chunkBuilder.Insert(0, '0'); - } - } - - builder.Append(chunkBuilder); - } - - // logger.LogDebug("ModifySortChunks Start: {0} End: {1}", name, builder.ToString()); - return builder.ToString().RemoveDiacritics(); - } - - [JsonIgnore] - public bool EnableMediaSourceDisplay - { - get - { - if (SourceType == SourceType.Channel) - { - return ChannelManager.EnableMediaSourceDisplay(this); - } - - return true; - } - } - - [JsonIgnore] - public Guid ParentId { get; set; } - - /// <summary> - /// Gets or sets the parent. - /// </summary> - /// <value>The parent.</value> - [JsonIgnore] - public Folder Parent - { - get => GetParent() as Folder; - set - { - } - } - - public void SetParent(Folder parent) - { - ParentId = parent == null ? Guid.Empty : parent.Id; - } - - public BaseItem GetParent() - { - var parentId = ParentId; - if (!parentId.Equals(Guid.Empty)) - { - return LibraryManager.GetItemById(parentId); - } - - return null; - } - - public IEnumerable<BaseItem> GetParents() - { - var parent = GetParent(); - - while (parent != null) - { - yield return parent; - - parent = parent.GetParent(); - } - } - - /// <summary> - /// Finds a parent of a given type. - /// </summary> - /// <typeparam name="T"></typeparam> - /// <returns>``0.</returns> - public T FindParent<T>() - where T : Folder - { - foreach (var parent in GetParents()) - { - var item = parent as T; - if (item != null) - { - return item; - } - } - - return null; - } - - [JsonIgnore] - public virtual Guid DisplayParentId - { - get - { - var parentId = ParentId; - return parentId; - } - } - - [JsonIgnore] - public BaseItem DisplayParent - { - get - { - var id = DisplayParentId; - if (id.Equals(Guid.Empty)) - { - return null; - } + var id = DisplayParentId; + if (id.Equals(Guid.Empty)) + { + return null; + } return LibraryManager.GetItemById(id); } @@ -972,46 +722,389 @@ namespace MediaBrowser.Controller.Entities [JsonIgnore] public virtual bool HasLocalAlternateVersions => false; - [JsonIgnore] - public string OfficialRatingForComparison + [JsonIgnore] + public string OfficialRatingForComparison + { + get + { + var officialRating = OfficialRating; + if (!string.IsNullOrEmpty(officialRating)) + { + return officialRating; + } + + var parent = DisplayParent; + if (parent != null) + { + return parent.OfficialRatingForComparison; + } + + return null; + } + } + + [JsonIgnore] + public string CustomRatingForComparison + { + get + { + var customRating = CustomRating; + if (!string.IsNullOrEmpty(customRating)) + { + return customRating; + } + + var parent = DisplayParent; + if (parent != null) + { + return parent.CustomRatingForComparison; + } + + return null; + } + } + + /// <summary> + /// Gets or sets the provider ids. + /// </summary> + /// <value>The provider ids.</value> + [JsonIgnore] + public Dictionary<string, string> ProviderIds { get; set; } + + [JsonIgnore] + public virtual Folder LatestItemsIndexContainer => null; + + [JsonIgnore] + public string PresentationUniqueKey { get; set; } + + [JsonIgnore] + public virtual bool EnableRememberingTrackSelections => true; + + [JsonIgnore] + public virtual bool IsTopParent + { + get + { + if (this is BasePluginFolder || this is Channel) + { + return true; + } + + if (this is IHasCollectionType view) + { + if (string.Equals(view.CollectionType, CollectionType.LiveTv, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + + if (GetParent() is AggregateFolder) + { + return true; + } + + return false; + } + } + + [JsonIgnore] + public virtual bool SupportsAncestors => true; + + [JsonIgnore] + public virtual bool StopRefreshIfLocalMetadataFound => true; + + [JsonIgnore] + protected virtual bool SupportsOwnedItems => !ParentId.Equals(Guid.Empty) && IsFileProtocol; + + [JsonIgnore] + public virtual bool SupportsPeople => false; + + [JsonIgnore] + public virtual bool SupportsThemeMedia => false; + + [JsonIgnore] + public virtual bool SupportsInheritedParentImages => false; + + /// <summary> + /// Gets a value indicating whether this instance is folder. + /// </summary> + /// <value><c>true</c> if this instance is folder; otherwise, <c>false</c>.</value> + [JsonIgnore] + public virtual bool IsFolder => false; + + [JsonIgnore] + public virtual bool IsDisplayedAsFolder => false; + + /// <summary> + /// Gets or sets the remote trailers. + /// </summary> + /// <value>The remote trailers.</value> + public IReadOnlyList<MediaUrl> RemoteTrailers { get; set; } + + public virtual bool SupportsExternalTransfer => false; + + public virtual double GetDefaultPrimaryImageAspectRatio() + { + return 0; + } + + public virtual string CreatePresentationUniqueKey() + { + return Id.ToString("N", CultureInfo.InvariantCulture); + } + + public bool IsPathProtocol(MediaProtocol protocol) + { + var current = PathProtocol; + + return current.HasValue && current.Value == protocol; + } + + private List<Tuple<StringBuilder, bool>> GetSortChunks(string s1) + { + var list = new List<Tuple<StringBuilder, bool>>(); + + int thisMarker = 0; + + while (thisMarker < s1.Length) + { + char thisCh = s1[thisMarker]; + + var thisChunk = new StringBuilder(); + bool isNumeric = char.IsDigit(thisCh); + + while (thisMarker < s1.Length && char.IsDigit(thisCh) == isNumeric) + { + thisChunk.Append(thisCh); + thisMarker++; + + if (thisMarker < s1.Length) + { + thisCh = s1[thisMarker]; + } + } + + list.Add(new Tuple<StringBuilder, bool>(thisChunk, isNumeric)); + } + + return list; + } + + public virtual bool CanDelete() + { + if (SourceType == SourceType.Channel) + { + return ChannelManager.CanDelete(this); + } + + return IsFileProtocol; + } + + public virtual bool IsAuthorizedToDelete(User user, List<Folder> allCollectionFolders) + { + if (user.HasPermission(PermissionKind.EnableContentDeletion)) + { + return true; + } + + var allowed = user.GetPreferenceValues<Guid>(PreferenceKind.EnableContentDeletionFromFolders); + + if (SourceType == SourceType.Channel) + { + return allowed.Contains(ChannelId); + } + else + { + var collectionFolders = LibraryManager.GetCollectionFolders(this, allCollectionFolders); + + foreach (var folder in collectionFolders) + { + if (allowed.Contains(folder.Id)) + { + return true; + } + } + } + + return false; + } + + public BaseItem GetOwner() + { + var ownerId = OwnerId; + return ownerId.Equals(Guid.Empty) ? null : LibraryManager.GetItemById(ownerId); + } + + public bool CanDelete(User user, List<Folder> allCollectionFolders) + { + return CanDelete() && IsAuthorizedToDelete(user, allCollectionFolders); + } + + public bool CanDelete(User user) + { + var allCollectionFolders = LibraryManager.GetUserRootFolder().Children.OfType<Folder>().ToList(); + + return CanDelete(user, allCollectionFolders); + } + + public virtual bool CanDownload() + { + return false; + } + + public virtual bool IsAuthorizedToDownload(User user) + { + return user.HasPermission(PermissionKind.EnableContentDownloading); + } + + public bool CanDownload(User user) + { + return CanDownload() && IsAuthorizedToDownload(user); + } + + /// <summary> + /// Returns a <see cref="string" /> that represents this instance. + /// </summary> + /// <returns>A <see cref="string" /> that represents this instance.</returns> + public override string ToString() + { + return Name; + } + + public string GetInternalMetadataPath() + { + var basePath = ConfigurationManager.ApplicationPaths.InternalMetadataPath; + + return GetInternalMetadataPath(basePath); + } + + protected virtual string GetInternalMetadataPath(string basePath) + { + if (SourceType == SourceType.Channel) + { + return System.IO.Path.Join(basePath, "channels", ChannelId.ToString("N", CultureInfo.InvariantCulture), Id.ToString("N", CultureInfo.InvariantCulture)); + } + + ReadOnlySpan<char> idString = Id.ToString("N", CultureInfo.InvariantCulture); + + return System.IO.Path.Join(basePath, "library", idString.Slice(0, 2), idString); + } + + /// <summary> + /// Creates the name of the sort. + /// </summary> + /// <returns>System.String.</returns> + protected virtual string CreateSortName() + { + if (Name == null) + { + return null; // some items may not have name filled in properly + } + + if (!EnableAlphaNumericSorting) + { + return Name.TrimStart(); + } + + var sortable = Name.Trim().ToLowerInvariant(); + + foreach (var removeChar in ConfigurationManager.Configuration.SortRemoveCharacters) + { + sortable = sortable.Replace(removeChar, string.Empty, StringComparison.Ordinal); + } + + foreach (var replaceChar in ConfigurationManager.Configuration.SortReplaceCharacters) + { + sortable = sortable.Replace(replaceChar, " ", StringComparison.Ordinal); + } + + foreach (var search in ConfigurationManager.Configuration.SortRemoveWords) + { + // Remove from beginning if a space follows + if (sortable.StartsWith(search + " ", StringComparison.Ordinal)) + { + sortable = sortable.Remove(0, search.Length + 1); + } + + // Remove from middle if surrounded by spaces + sortable = sortable.Replace(" " + search + " ", " ", StringComparison.Ordinal); + + // Remove from end if followed by a space + if (sortable.EndsWith(" " + search, StringComparison.Ordinal)) + { + sortable = sortable.Remove(sortable.Length - (search.Length + 1)); + } + } + + return ModifySortChunks(sortable); + } + + private string ModifySortChunks(string name) + { + var chunks = GetSortChunks(name); + + var builder = new StringBuilder(); + + foreach (var chunk in chunks) + { + var chunkBuilder = chunk.Item1; + + // This chunk is numeric + if (chunk.Item2) + { + while (chunkBuilder.Length < 10) + { + chunkBuilder.Insert(0, '0'); + } + } + + builder.Append(chunkBuilder); + } + + // logger.LogDebug("ModifySortChunks Start: {0} End: {1}", name, builder.ToString()); + return builder.ToString().RemoveDiacritics(); + } + + public BaseItem GetParent() { - get + var parentId = ParentId; + if (!parentId.Equals(Guid.Empty)) { - var officialRating = OfficialRating; - if (!string.IsNullOrEmpty(officialRating)) - { - return officialRating; - } + return LibraryManager.GetItemById(parentId); + } - var parent = DisplayParent; - if (parent != null) - { - return parent.OfficialRatingForComparison; - } + return null; + } - return null; + public IEnumerable<BaseItem> GetParents() + { + var parent = GetParent(); + + while (parent != null) + { + yield return parent; + + parent = parent.GetParent(); } } - [JsonIgnore] - public string CustomRatingForComparison + /// <summary> + /// Finds a parent of a given type. + /// </summary> + /// <typeparam name="T">Type of parent.</typeparam> + /// <returns>``0.</returns> + public T FindParent<T>() + where T : Folder { - get + foreach (var parent in GetParents()) { - var customRating = CustomRating; - if (!string.IsNullOrEmpty(customRating)) - { - return customRating; - } - - var parent = DisplayParent; - if (parent != null) + if (parent is T item) { - return parent.CustomRatingForComparison; + return item; } - - return null; } + + return null; } /// <summary> @@ -1261,7 +1354,7 @@ namespace MediaBrowser.Controller.Entities // Support plex/xbmc convention files.AddRange(fileSystemChildren - .Where(i => !i.IsDirectory && System.IO.Path.GetFileNameWithoutExtension(i.FullName.AsSpan()).Equals(ThemeSongFilename, StringComparison.OrdinalIgnoreCase))); + .Where(i => !i.IsDirectory && System.IO.Path.GetFileNameWithoutExtension(i.FullName.AsSpan()).Equals(ThemeSongFileName, StringComparison.OrdinalIgnoreCase))); return LibraryManager.ResolvePaths(files, directoryService, null, new LibraryOptions()) .OfType<Audio.Audio>() @@ -1320,39 +1413,24 @@ namespace MediaBrowser.Controller.Entities 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) + return fileSystemChildren + .Where(child => child.IsDirectory && AllExtrasTypesFolderNames.ContainsKey(child.Name)) + .SelectMany(folder => LibraryManager + .ResolvePaths(FileSystem.GetFiles(folder.FullName), directoryService, null, new LibraryOptions()) .OfType<Video>() - .Select(item => + .Select(video => { // 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) + if (LibraryManager.GetItemById(video.Id) is Video dbItem) { - item = dbItem; + video = 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(); + video.ExtraType = AllExtrasTypesFolderNames[folder.Name]; + return video; + }) + .OrderBy(video => video.Path)) // Sort them so that the list can be easily compared for changes + .ToArray(); } public Task RefreshMetadata(CancellationToken cancellationToken) @@ -1419,14 +1497,46 @@ namespace MediaBrowser.Controller.Entities } } - [JsonIgnore] - protected virtual bool SupportsOwnedItems => !ParentId.Equals(Guid.Empty) && IsFileProtocol; + protected bool IsVisibleStandaloneInternal(User user, bool checkFolders) + { + if (!IsVisible(user)) + { + return false; + } - [JsonIgnore] - public virtual bool SupportsPeople => false; + if (GetParents().Any(i => !i.IsVisible(user))) + { + return false; + } - [JsonIgnore] - public virtual bool SupportsThemeMedia => false; + if (checkFolders) + { + var topParent = GetParents().LastOrDefault() ?? this; + + if (string.IsNullOrEmpty(topParent.Path)) + { + return true; + } + + var itemCollectionFolders = LibraryManager.GetCollectionFolders(this).Select(i => i.Id).ToList(); + + if (itemCollectionFolders.Count > 0) + { + var userCollectionFolders = LibraryManager.GetUserRootFolder().GetChildren(user, true).Select(i => i.Id).ToList(); + if (!itemCollectionFolders.Any(userCollectionFolders.Contains)) + { + return false; + } + } + } + + return true; + } + + public void SetParent(Folder parent) + { + ParentId = parent == null ? Guid.Empty : parent.Id; + } /// <summary> /// Refreshes owned items such as trailers, theme videos, special features, etc. @@ -1623,29 +1733,6 @@ namespace MediaBrowser.Controller.Entities return themeSongsChanged; } - /// <summary> - /// Gets or sets the provider ids. - /// </summary> - /// <value>The provider ids.</value> - [JsonIgnore] - public Dictionary<string, string> ProviderIds { get; set; } - - [JsonIgnore] - public virtual Folder LatestItemsIndexContainer => null; - - public virtual double GetDefaultPrimaryImageAspectRatio() - { - return 0; - } - - public virtual string CreatePresentationUniqueKey() - { - return Id.ToString("N", CultureInfo.InvariantCulture); - } - - [JsonIgnore] - public string PresentationUniqueKey { get; set; } - public string GetPresentationUniqueKey() { return PresentationUniqueKey ?? CreatePresentationUniqueKey(); @@ -1943,58 +2030,9 @@ namespace MediaBrowser.Controller.Entities return IsVisibleStandaloneInternal(user, true); } - [JsonIgnore] - public virtual bool SupportsInheritedParentImages => false; - - protected bool IsVisibleStandaloneInternal(User user, bool checkFolders) - { - if (!IsVisible(user)) - { - return false; - } - - if (GetParents().Any(i => !i.IsVisible(user))) - { - return false; - } - - if (checkFolders) - { - var topParent = GetParents().LastOrDefault() ?? this; - - if (string.IsNullOrEmpty(topParent.Path)) - { - return true; - } - - var itemCollectionFolders = LibraryManager.GetCollectionFolders(this).Select(i => i.Id).ToList(); - - if (itemCollectionFolders.Count > 0) - { - var userCollectionFolders = LibraryManager.GetUserRootFolder().GetChildren(user, true).Select(i => i.Id).ToList(); - if (!itemCollectionFolders.Any(userCollectionFolders.Contains)) - { - return false; - } - } - } - - return true; - } - - /// <summary> - /// Gets a value indicating whether this instance is folder. - /// </summary> - /// <value><c>true</c> if this instance is folder; otherwise, <c>false</c>.</value> - [JsonIgnore] - public virtual bool IsFolder => false; - - [JsonIgnore] - public virtual bool IsDisplayedAsFolder => false; - public virtual string GetClientTypeName() { - if (IsFolder && SourceType == SourceType.Channel && !(this is Channel)) + if (IsFolder && SourceType == SourceType.Channel && this is not Channel) { return "ChannelFolderItem"; } @@ -2080,14 +2118,11 @@ namespace MediaBrowser.Controller.Entities return null; } - [JsonIgnore] - public virtual bool EnableRememberingTrackSelections => true; - /// <summary> /// Adds a studio to the item. /// </summary> /// <param name="name">The name.</param> - /// <exception cref="ArgumentNullException"></exception> + /// <exception cref="ArgumentNullException">Throws if name is null.</exception> public void AddStudio(string name) { if (string.IsNullOrEmpty(name)) @@ -2123,7 +2158,7 @@ namespace MediaBrowser.Controller.Entities /// Adds a genre to the item. /// </summary> /// <param name="name">The name.</param> - /// <exception cref="ArgumentNullException"></exception> + /// <exception cref="ArgumentNullException">Throwns if name is null.</exception> public void AddGenre(string name) { if (string.IsNullOrEmpty(name)) @@ -2146,8 +2181,7 @@ namespace MediaBrowser.Controller.Entities /// <param name="user">The user.</param> /// <param name="datePlayed">The date played.</param> /// <param name="resetPosition">if set to <c>true</c> [reset position].</param> - /// <returns>Task.</returns> - /// <exception cref="ArgumentNullException"></exception> + /// <exception cref="ArgumentNullException">Throws if user is null.</exception> public virtual void MarkPlayed( User user, DateTime? datePlayed, @@ -2184,8 +2218,7 @@ namespace MediaBrowser.Controller.Entities /// Marks the unplayed. /// </summary> /// <param name="user">The user.</param> - /// <returns>Task.</returns> - /// <exception cref="ArgumentNullException"></exception> + /// <exception cref="ArgumentNullException">Throws if user is null.</exception> public virtual void MarkUnplayed(User user) { if (user == null) @@ -2285,6 +2318,7 @@ namespace MediaBrowser.Controller.Entities /// </summary> /// <param name="type">The type.</param> /// <param name="index">The index.</param> + /// <returns>A task.</returns> public async Task DeleteImageAsync(ImageType type, int index) { var info = GetImageInfo(type, index); @@ -2322,6 +2356,8 @@ namespace MediaBrowser.Controller.Entities /// <summary> /// Validates that images within the item are still on the filesystem. /// </summary> + /// <param name="directoryService">The directory service to use.</param> + /// <returns><c>true</c> if the images validate, <c>false</c> if not.</returns> public bool ValidateImages(IDirectoryService directoryService) { var allFiles = ImageInfos @@ -2349,7 +2385,6 @@ namespace MediaBrowser.Controller.Entities /// <param name="imageType">Type of the image.</param> /// <param name="imageIndex">Index of the image.</param> /// <returns>System.String.</returns> - /// <exception cref="InvalidOperationException"> </exception> /// <exception cref="ArgumentNullException">Item is null.</exception> public string GetImagePath(ImageType imageType, int imageIndex) => GetImageInfo(imageType, imageIndex)?.Path; @@ -2386,6 +2421,17 @@ namespace MediaBrowser.Controller.Entities }; } + // Music albums usually don't have dedicated backdrops, so return one from the artist instead + if (GetType() == typeof(MusicAlbum) && imageType == ImageType.Backdrop) + { + var artist = FindParent<MusicArtist>(); + + if (artist != null) + { + return artist.GetImages(imageType).ElementAtOrDefault(imageIndex); + } + } + return GetImages(imageType) .ElementAtOrDefault(imageIndex); } @@ -2835,39 +2881,6 @@ namespace MediaBrowser.Controller.Entities return GetParents().FirstOrDefault(parent => parent.IsTopParent); } - [JsonIgnore] - public virtual bool IsTopParent - { - get - { - if (this is BasePluginFolder || this is Channel) - { - return true; - } - - if (this is IHasCollectionType view) - { - if (string.Equals(view.CollectionType, CollectionType.LiveTv, StringComparison.OrdinalIgnoreCase)) - { - return true; - } - } - - if (GetParent() is AggregateFolder) - { - return true; - } - - return false; - } - } - - [JsonIgnore] - public virtual bool SupportsAncestors => true; - - [JsonIgnore] - public virtual bool StopRefreshIfLocalMetadataFound => true; - public virtual IEnumerable<Guid> GetIdsForAncestorQuery() { return new[] { Id }; @@ -2902,6 +2915,7 @@ namespace MediaBrowser.Controller.Entities /// <summary> /// Updates the official rating based on content and returns true or false indicating if it changed. /// </summary> + /// <param name="children">Media children.</param> /// <returns><c>true</c> if the rating was updated; otherwise <c>false</c>.</returns> public bool UpdateRatingToItems(IList<BaseItem> children) { @@ -2934,12 +2948,6 @@ namespace MediaBrowser.Controller.Entities return ThemeVideoIds.Select(LibraryManager.GetItemById); } - /// <summary> - /// Gets or sets the remote trailers. - /// </summary> - /// <value>The remote trailers.</value> - public IReadOnlyList<MediaUrl> RemoteTrailers { get; set; } - /// <summary> /// Get all extras associated with this item, sorted by <see cref="SortName"/>. /// </summary> @@ -2977,39 +2985,11 @@ namespace MediaBrowser.Controller.Entities } } - public virtual bool IsHD => Height >= 720; - - public bool IsShortcut { get; set; } - - public string ShortcutPath { get; set; } - - public int Width { get; set; } - - public int Height { get; set; } - - public Guid[] ExtraIds { get; set; } - public virtual long GetRunTimeTicksForPlayState() { return RunTimeTicks ?? 0; } - /// <summary> - /// Extra types that should be counted and displayed as "Special Features" in the UI. - /// </summary> - public static readonly IReadOnlyCollection<ExtraType> DisplayExtraTypes = new HashSet<ExtraType> - { - Model.Entities.ExtraType.Unknown, - Model.Entities.ExtraType.BehindTheScenes, - Model.Entities.ExtraType.Clip, - Model.Entities.ExtraType.DeletedScene, - Model.Entities.ExtraType.Interview, - Model.Entities.ExtraType.Sample, - Model.Entities.ExtraType.Scene - }; - - public virtual bool SupportsExternalTransfer => false; - /// <inheritdoc /> public override bool Equals(object obj) { @@ -3017,7 +2997,7 @@ namespace MediaBrowser.Controller.Entities } /// <inheritdoc /> - public bool Equals(BaseItem other) => object.Equals(Id, other?.Id); + public bool Equals(BaseItem other) => Equals(Id, other?.Id); /// <inheritdoc /> public override int GetHashCode() => HashCode.Combine(Id); diff --git a/MediaBrowser.Controller/Entities/BaseItemExtensions.cs b/MediaBrowser.Controller/Entities/BaseItemExtensions.cs index 89ad392a4b..33870e2fbf 100644 --- a/MediaBrowser.Controller/Entities/BaseItemExtensions.cs +++ b/MediaBrowser.Controller/Entities/BaseItemExtensions.cs @@ -44,7 +44,7 @@ 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 @@ -64,6 +64,8 @@ namespace MediaBrowser.Controller.Entities /// </summary> /// <param name="source">The source object.</param> /// <param name="dest">The destination object.</param> + /// <typeparam name="T">Source type.</typeparam> + /// <typeparam name="TU">Destination type.</typeparam> public static void DeepCopy<T, TU>(this T source, TU dest) where T : BaseItem where TU : BaseItem @@ -109,6 +111,9 @@ namespace MediaBrowser.Controller.Entities /// Copies all properties on newly created object. Skips properties that do not exist. /// </summary> /// <param name="source">The source object.</param> + /// <typeparam name="T">Source type.</typeparam> + /// <typeparam name="TU">Destination type.</typeparam> + /// <returns>Destination object.</returns> public static TU DeepCopy<T, TU>(this T source) where T : BaseItem where TU : BaseItem, new() diff --git a/MediaBrowser.Controller/Entities/BasePluginFolder.cs b/MediaBrowser.Controller/Entities/BasePluginFolder.cs index 1bd25042f2..272a37df1b 100644 --- a/MediaBrowser.Controller/Entities/BasePluginFolder.cs +++ b/MediaBrowser.Controller/Entities/BasePluginFolder.cs @@ -15,6 +15,12 @@ namespace MediaBrowser.Controller.Entities [JsonIgnore] public virtual string CollectionType => null; + [JsonIgnore] + public override bool SupportsInheritedParentImages => false; + + [JsonIgnore] + public override bool SupportsPeople => false; + public override bool CanDelete() { return false; @@ -24,11 +30,5 @@ namespace MediaBrowser.Controller.Entities { return true; } - - [JsonIgnore] - public override bool SupportsInheritedParentImages => false; - - [JsonIgnore] - public override bool SupportsPeople => false; } } diff --git a/MediaBrowser.Controller/Entities/CollectionFolder.cs b/MediaBrowser.Controller/Entities/CollectionFolder.cs index 4f367fe2b5..7dc7f774d0 100644 --- a/MediaBrowser.Controller/Entities/CollectionFolder.cs +++ b/MediaBrowser.Controller/Entities/CollectionFolder.cs @@ -41,6 +41,23 @@ namespace MediaBrowser.Controller.Entities PhysicalFolderIds = Array.Empty<Guid>(); } + /// <summary> + /// Gets the display preferences id. + /// </summary> + /// <remarks> + /// Allow different display preferences for each collection folder. + /// </remarks> + /// <value>The display prefs id.</value> + [JsonIgnore] + public override Guid DisplayPreferencesId => Id; + + [JsonIgnore] + public override string[] PhysicalLocations => PhysicalLocationsList; + + public string[] PhysicalLocationsList { get; set; } + + public Guid[] PhysicalFolderIds { get; set; } + public static IXmlSerializer XmlSerializer { get; set; } public static IServerApplicationHost ApplicationHost { get; set; } @@ -63,6 +80,9 @@ namespace MediaBrowser.Controller.Entities [JsonIgnore] public override IEnumerable<BaseItem> Children => GetActualChildren(); + [JsonIgnore] + public override bool SupportsPeople => false; + public override bool CanDelete() { return false; @@ -77,8 +97,7 @@ namespace MediaBrowser.Controller.Entities { try { - var result = XmlSerializer.DeserializeFromFile(typeof(LibraryOptions), GetLibraryOptionsPath(path)) as LibraryOptions; - if (result == null) + if (XmlSerializer.DeserializeFromFile(typeof(LibraryOptions), GetLibraryOptionsPath(path)) is not LibraryOptions result) { return new LibraryOptions(); } @@ -160,23 +179,6 @@ namespace MediaBrowser.Controller.Entities } } - /// <summary> - /// Gets the display preferences id. - /// </summary> - /// <remarks> - /// Allow different display preferences for each collection folder. - /// </remarks> - /// <value>The display prefs id.</value> - [JsonIgnore] - public override Guid DisplayPreferencesId => Id; - - [JsonIgnore] - public override string[] PhysicalLocations => PhysicalLocationsList; - - public string[] PhysicalLocationsList { get; set; } - - public Guid[] PhysicalFolderIds { get; set; } - public override bool IsSaveLocalMetadataEnabled() { return true; @@ -373,8 +375,5 @@ namespace MediaBrowser.Controller.Entities return result; } - - [JsonIgnore] - public override bool SupportsPeople => false; } } diff --git a/MediaBrowser.Controller/Entities/Extensions.cs b/MediaBrowser.Controller/Entities/Extensions.cs index d8bc0069c7..9ce8eebe34 100644 --- a/MediaBrowser.Controller/Entities/Extensions.cs +++ b/MediaBrowser.Controller/Entities/Extensions.cs @@ -15,6 +15,8 @@ namespace MediaBrowser.Controller.Entities /// <summary> /// Adds the trailer URL. /// </summary> + /// <param name="item">Media item.</param> + /// <param name="url">Trailer URL.</param> public static void AddTrailerUrl(this BaseItem item, string url) { if (string.IsNullOrEmpty(url)) diff --git a/MediaBrowser.Controller/Entities/Folder.cs b/MediaBrowser.Controller/Entities/Folder.cs index 5417474221..18b4ec3c66 100644 --- a/MediaBrowser.Controller/Entities/Folder.cs +++ b/MediaBrowser.Controller/Entities/Folder.cs @@ -1,6 +1,6 @@ #nullable disable -#pragma warning disable CS1591 +#pragma warning disable CA1002, CA1721, CA1819, CS1591 using System; using System.Collections.Generic; @@ -165,6 +165,8 @@ namespace MediaBrowser.Controller.Entities } } + public static ICollectionManager CollectionManager { get; set; } + public override bool CanDelete() { if (IsRoot) @@ -206,9 +208,8 @@ namespace MediaBrowser.Controller.Entities /// Adds the child. /// </summary> /// <param name="item">The item.</param> - /// <param name="cancellationToken">The cancellation token.</param> /// <exception cref="InvalidOperationException">Unable to add + item.Name.</exception> - public void AddChild(BaseItem item, CancellationToken cancellationToken) + public void AddChild(BaseItem item) { item.SetParent(this); @@ -232,7 +233,7 @@ namespace MediaBrowser.Controller.Entities public override bool IsVisible(User user) { - if (this is ICollectionFolder && !(this is BasePluginFolder)) + if (this is ICollectionFolder && this is not BasePluginFolder) { var blockedMediaFolders = user.GetPreferenceValues<Guid>(PreferenceKind.BlockedMediaFolders); if (blockedMediaFolders.Length > 0) @@ -259,6 +260,7 @@ namespace MediaBrowser.Controller.Entities /// Loads our children. Validation will occur externally. /// We want this synchronous. /// </summary> + /// <returns>Returns children.</returns> protected virtual List<BaseItem> LoadChildren() { // logger.LogDebug("Loading children from {0} {1} {2}", GetType().Name, Id, Path); @@ -643,6 +645,8 @@ namespace MediaBrowser.Controller.Entities /// Get the children of this folder from the actual file system. /// </summary> /// <returns>IEnumerable{BaseItem}.</returns> + /// <param name="directoryService">The directory service to use for operation.</param> + /// <returns>Returns set of base items.</returns> protected virtual IEnumerable<BaseItem> GetNonCachedChildren(IDirectoryService directoryService) { var collectionType = LibraryManager.GetContentType(this); @@ -669,7 +673,7 @@ namespace MediaBrowser.Controller.Entities { if (LinkedChildren.Length > 0) { - if (!(this is ICollectionFolder)) + if (this is not ICollectionFolder) { return GetChildren(user, true).Count; } @@ -726,7 +730,7 @@ namespace MediaBrowser.Controller.Entities return PostFilterAndSort(items, query, true); } - if (!(this is UserRootFolder) && !(this is AggregateFolder) && query.ParentId == Guid.Empty) + if (this is not UserRootFolder && this is not AggregateFolder && query.ParentId == Guid.Empty) { query.Parent = this; } @@ -801,7 +805,7 @@ namespace MediaBrowser.Controller.Entities { if (LinkedChildren.Length > 0) { - if (!(this is ICollectionFolder)) + if (this is not ICollectionFolder) { Logger.LogDebug("Query requires post-filtering due to LinkedChildren. Type: " + GetType().Name); return true; @@ -999,8 +1003,6 @@ namespace MediaBrowser.Controller.Entities return PostFilterAndSort(items, query, true); } - public static ICollectionManager CollectionManager { get; set; } - protected QueryResult<BaseItem> PostFilterAndSort(IEnumerable<BaseItem> items, InternalItemsQuery query, bool enableSorting) { var user = query.User; @@ -1013,17 +1015,17 @@ namespace MediaBrowser.Controller.Entities 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); } // This must be the last filter @@ -1385,18 +1387,6 @@ namespace MediaBrowser.Controller.Entities } } - /// <summary> - /// Gets allowed recursive children of an item. - /// </summary> - /// <param name="user">The user.</param> - /// <param name="includeLinkedChildren">if set to <c>true</c> [include linked children].</param> - /// <returns>IEnumerable{BaseItem}.</returns> - /// <exception cref="ArgumentNullException"></exception> - public IEnumerable<BaseItem> GetRecursiveChildren(User user, bool includeLinkedChildren = true) - { - return GetRecursiveChildren(user, null); - } - public virtual IEnumerable<BaseItem> GetRecursiveChildren(User user, InternalItemsQuery query) { if (user == null) @@ -1555,7 +1545,7 @@ namespace MediaBrowser.Controller.Entities var childOwner = child.GetOwner() ?? child; - if (childOwner != null && !(child is IItemByName)) + if (child is not IItemByName) { var childProtocol = childOwner.PathProtocol; if (!childProtocol.HasValue || childProtocol.Value != Model.MediaInfo.MediaProtocol.File) @@ -1679,7 +1669,6 @@ namespace MediaBrowser.Controller.Entities /// <param name="user">The user.</param> /// <param name="datePlayed">The date played.</param> /// <param name="resetPosition">if set to <c>true</c> [reset position].</param> - /// <returns>Task.</returns> public override void MarkPlayed( User user, DateTime? datePlayed, @@ -1721,7 +1710,6 @@ namespace MediaBrowser.Controller.Entities /// Marks the unplayed. /// </summary> /// <param name="user">The user.</param> - /// <returns>Task.</returns> public override void MarkUnplayed(User user) { var itemsResult = GetItemList(new InternalItemsQuery diff --git a/MediaBrowser.Controller/Entities/ICollectionFolder.cs b/MediaBrowser.Controller/Entities/ICollectionFolder.cs index 2304570fd7..89e494ebc3 100644 --- a/MediaBrowser.Controller/Entities/ICollectionFolder.cs +++ b/MediaBrowser.Controller/Entities/ICollectionFolder.cs @@ -1,6 +1,6 @@ #nullable disable -#pragma warning disable CS1591 +#pragma warning disable CA1819, CS1591 using System; diff --git a/MediaBrowser.Controller/Entities/IHasMediaSources.cs b/MediaBrowser.Controller/Entities/IHasMediaSources.cs index 98c3b3edf6..90d9bdd2d3 100644 --- a/MediaBrowser.Controller/Entities/IHasMediaSources.cs +++ b/MediaBrowser.Controller/Entities/IHasMediaSources.cs @@ -20,6 +20,8 @@ namespace MediaBrowser.Controller.Entities /// <summary> /// Gets the media sources. /// </summary> + /// <param name="enablePathSubstitution"><c>true</c> to enable path substitution, <c>false</c> to not.</param> + /// <returns>A list of media sources.</returns> List<MediaSourceInfo> GetMediaSources(bool enablePathSubstitution); List<MediaStream> GetMediaStreams(); diff --git a/MediaBrowser.Controller/Entities/IHasShares.cs b/MediaBrowser.Controller/Entities/IHasShares.cs index bdde744a37..dca5af873f 100644 --- a/MediaBrowser.Controller/Entities/IHasShares.cs +++ b/MediaBrowser.Controller/Entities/IHasShares.cs @@ -1,6 +1,6 @@ #nullable disable -#pragma warning disable CS1591 +#pragma warning disable CA1819, CS1591 namespace MediaBrowser.Controller.Entities { diff --git a/MediaBrowser.Controller/Entities/IHasTrailers.cs b/MediaBrowser.Controller/Entities/IHasTrailers.cs index 2bd9ded337..f4271678d4 100644 --- a/MediaBrowser.Controller/Entities/IHasTrailers.cs +++ b/MediaBrowser.Controller/Entities/IHasTrailers.cs @@ -39,6 +39,7 @@ namespace MediaBrowser.Controller.Entities /// <summary> /// Gets the trailer count. /// </summary> + /// <param name="item">Media item.</param> /// <returns><see cref="IReadOnlyList{Guid}" />.</returns> public static int GetTrailerCount(this IHasTrailers item) => item.LocalTrailerIds.Count + item.RemoteTrailerIds.Count; @@ -46,6 +47,7 @@ namespace MediaBrowser.Controller.Entities /// <summary> /// Gets the trailer ids. /// </summary> + /// <param name="item">Media item.</param> /// <returns><see cref="IReadOnlyList{Guid}" />.</returns> public static IReadOnlyList<Guid> GetTrailerIds(this IHasTrailers item) { @@ -70,6 +72,7 @@ namespace MediaBrowser.Controller.Entities /// <summary> /// Gets the trailers. /// </summary> + /// <param name="item">Media item.</param> /// <returns><see cref="IReadOnlyList{BaseItem}" />.</returns> public static IReadOnlyList<BaseItem> GetTrailers(this IHasTrailers item) { diff --git a/MediaBrowser.Controller/Entities/InternalItemsQuery.cs b/MediaBrowser.Controller/Entities/InternalItemsQuery.cs index ebaf5506d6..0baa7725e1 100644 --- a/MediaBrowser.Controller/Entities/InternalItemsQuery.cs +++ b/MediaBrowser.Controller/Entities/InternalItemsQuery.cs @@ -1,4 +1,4 @@ -#pragma warning disable CS1591 +#pragma warning disable CA1044, CA1819, CA2227, CS1591 using System; using System.Collections.Generic; @@ -12,6 +12,55 @@ namespace MediaBrowser.Controller.Entities { public class InternalItemsQuery { + public InternalItemsQuery() + { + AlbumArtistIds = Array.Empty<Guid>(); + AlbumIds = Array.Empty<Guid>(); + AncestorIds = Array.Empty<Guid>(); + ArtistIds = Array.Empty<Guid>(); + BlockUnratedItems = Array.Empty<UnratedItem>(); + BoxSetLibraryFolders = Array.Empty<Guid>(); + ChannelIds = Array.Empty<Guid>(); + ContributingArtistIds = Array.Empty<Guid>(); + DtoOptions = new DtoOptions(); + EnableTotalRecordCount = true; + ExcludeArtistIds = Array.Empty<Guid>(); + ExcludeInheritedTags = Array.Empty<string>(); + ExcludeItemIds = Array.Empty<Guid>(); + ExcludeItemTypes = Array.Empty<string>(); + ExcludeTags = Array.Empty<string>(); + GenreIds = Array.Empty<Guid>(); + Genres = Array.Empty<string>(); + GroupByPresentationUniqueKey = true; + ImageTypes = Array.Empty<ImageType>(); + IncludeItemTypes = Array.Empty<string>(); + ItemIds = Array.Empty<Guid>(); + MediaTypes = Array.Empty<string>(); + MinSimilarityScore = 20; + OfficialRatings = Array.Empty<string>(); + OrderBy = Array.Empty<ValueTuple<string, SortOrder>>(); + PersonIds = Array.Empty<Guid>(); + PersonTypes = Array.Empty<string>(); + PresetViews = Array.Empty<string>(); + SeriesStatuses = Array.Empty<SeriesStatus>(); + SourceTypes = Array.Empty<SourceType>(); + StudioIds = Array.Empty<Guid>(); + Tags = Array.Empty<string>(); + TopParentIds = Array.Empty<Guid>(); + TrailerTypes = Array.Empty<TrailerType>(); + VideoTypes = Array.Empty<VideoType>(); + Years = Array.Empty<int>(); + } + + public InternalItemsQuery(User? user) + : this() + { + if (user != null) + { + SetUser(user); + } + } + public bool Recursive { get; set; } public int? StartIndex { get; set; } @@ -186,23 +235,6 @@ namespace MediaBrowser.Controller.Entities public Guid[] TopParentIds { get; set; } - public BaseItem? Parent - { - set - { - if (value == null) - { - ParentId = Guid.Empty; - ParentType = null; - } - else - { - ParentId = value.Id; - ParentType = value.GetType().Name; - } - } - } - public string[] PresetViews { get; set; } public TrailerType[] TrailerTypes { get; set; } @@ -270,70 +302,21 @@ namespace MediaBrowser.Controller.Entities /// </summary> public bool? DisplayAlbumFolders { get; set; } - public InternalItemsQuery() - { - AlbumArtistIds = Array.Empty<Guid>(); - AlbumIds = Array.Empty<Guid>(); - AncestorIds = Array.Empty<Guid>(); - ArtistIds = Array.Empty<Guid>(); - BlockUnratedItems = Array.Empty<UnratedItem>(); - BoxSetLibraryFolders = Array.Empty<Guid>(); - ChannelIds = Array.Empty<Guid>(); - ContributingArtistIds = Array.Empty<Guid>(); - DtoOptions = new DtoOptions(); - EnableTotalRecordCount = true; - ExcludeArtistIds = Array.Empty<Guid>(); - ExcludeInheritedTags = Array.Empty<string>(); - ExcludeItemIds = Array.Empty<Guid>(); - ExcludeItemTypes = Array.Empty<string>(); - ExcludeTags = Array.Empty<string>(); - GenreIds = Array.Empty<Guid>(); - Genres = Array.Empty<string>(); - GroupByPresentationUniqueKey = true; - ImageTypes = Array.Empty<ImageType>(); - IncludeItemTypes = Array.Empty<string>(); - ItemIds = Array.Empty<Guid>(); - MediaTypes = Array.Empty<string>(); - MinSimilarityScore = 20; - OfficialRatings = Array.Empty<string>(); - OrderBy = Array.Empty<ValueTuple<string, SortOrder>>(); - PersonIds = Array.Empty<Guid>(); - PersonTypes = Array.Empty<string>(); - PresetViews = Array.Empty<string>(); - SeriesStatuses = Array.Empty<SeriesStatus>(); - SourceTypes = Array.Empty<SourceType>(); - StudioIds = Array.Empty<Guid>(); - Tags = Array.Empty<string>(); - TopParentIds = Array.Empty<Guid>(); - TrailerTypes = Array.Empty<TrailerType>(); - VideoTypes = Array.Empty<VideoType>(); - Years = Array.Empty<int>(); - } - - public InternalItemsQuery(User? user) - : this() - { - if (user != null) - { - SetUser(user); - } - } - - public void SetUser(User user) + public BaseItem? Parent { - MaxParentalRating = user.MaxParentalAgeRating; - - if (MaxParentalRating.HasValue) + set { - string other = UnratedItem.Other.ToString(); - BlockUnratedItems = user.GetPreference(PreferenceKind.BlockUnratedItems) - .Where(i => i != other) - .Select(e => Enum.Parse<UnratedItem>(e, true)).ToArray(); + if (value == null) + { + ParentId = Guid.Empty; + ParentType = null; + } + else + { + ParentId = value.Id; + ParentType = value.GetType().Name; + } } - - ExcludeInheritedTags = user.GetPreference(PreferenceKind.BlockedTags); - - User = user; } public Dictionary<string, string>? HasAnyProviderId { get; set; } @@ -361,5 +344,22 @@ namespace MediaBrowser.Controller.Entities public string? SearchTerm { get; set; } public string? SeriesTimerId { get; set; } + + public void SetUser(User user) + { + MaxParentalRating = user.MaxParentalAgeRating; + + if (MaxParentalRating.HasValue) + { + string other = UnratedItem.Other.ToString(); + BlockUnratedItems = user.GetPreference(PreferenceKind.BlockUnratedItems) + .Where(i => i != other) + .Select(e => Enum.Parse<UnratedItem>(e, true)).ToArray(); + } + + ExcludeInheritedTags = user.GetPreference(PreferenceKind.BlockedTags); + + User = user; + } } } diff --git a/MediaBrowser.Controller/Entities/Movies/BoxSet.cs b/MediaBrowser.Controller/Entities/Movies/BoxSet.cs index 74e84288d1..e46f99cd57 100644 --- a/MediaBrowser.Controller/Entities/Movies/BoxSet.cs +++ b/MediaBrowser.Controller/Entities/Movies/BoxSet.cs @@ -1,6 +1,6 @@ #nullable disable -#pragma warning disable CS1591 +#pragma warning disable CA1721, CA1819, CS1591 using System; using System.Collections.Generic; @@ -49,6 +49,30 @@ namespace MediaBrowser.Controller.Entities.Movies /// <value>The display order.</value> public string DisplayOrder { get; set; } + [JsonIgnore] + private bool IsLegacyBoxSet + { + get + { + if (string.IsNullOrEmpty(Path)) + { + return false; + } + + if (LinkedChildren.Length > 0) + { + return false; + } + + return !FileSystem.ContainsSubPath(ConfigurationManager.ApplicationPaths.DataPath, Path); + } + } + + [JsonIgnore] + public override bool IsPreSorted => true; + + public Guid[] LibraryFolderIds { get; set; } + protected override bool GetBlockUnratedValue(User user) { return user.GetPreferenceValues<UnratedItem>(PreferenceKind.BlockUnratedItems).Contains(UnratedItem.Movie); @@ -83,28 +107,6 @@ namespace MediaBrowser.Controller.Entities.Movies return new List<BaseItem>(); } - [JsonIgnore] - private bool IsLegacyBoxSet - { - get - { - if (string.IsNullOrEmpty(Path)) - { - return false; - } - - if (LinkedChildren.Length > 0) - { - return false; - } - - return !FileSystem.ContainsSubPath(ConfigurationManager.ApplicationPaths.DataPath, Path); - } - } - - [JsonIgnore] - public override bool IsPreSorted => true; - public override bool IsAuthorizedToDelete(User user, List<Folder> allCollectionFolders) { return true; @@ -191,8 +193,6 @@ namespace MediaBrowser.Controller.Entities.Movies return IsVisible(user); } - public Guid[] LibraryFolderIds { get; set; } - private Guid[] GetLibraryFolderIds(User user) { return LibraryManager.GetUserRootFolder().GetChildren(user, true) diff --git a/MediaBrowser.Controller/Entities/Person.cs b/MediaBrowser.Controller/Entities/Person.cs index b0ab280af4..045c1b89fd 100644 --- a/MediaBrowser.Controller/Entities/Person.cs +++ b/MediaBrowser.Controller/Entities/Person.cs @@ -16,6 +16,26 @@ namespace MediaBrowser.Controller.Entities /// </summary> public class Person : BaseItem, IItemByName, IHasLookupInfo<PersonLookupInfo> { + /// <summary> + /// Gets the folder containing the item. + /// If the item is a folder, it returns the folder itself. + /// </summary> + /// <value>The containing folder path.</value> + [JsonIgnore] + public override string ContainingFolderPath => Path; + + /// <summary> + /// Gets a value indicating whether to enable alpha numeric sorting. + /// </summary> + [JsonIgnore] + public override bool EnableAlphaNumericSorting => false; + + [JsonIgnore] + public override bool SupportsPeople => false; + + [JsonIgnore] + public override bool SupportsAncestors => false; + public override List<string> GetUserDataKeys() { var list = base.GetUserDataKeys(); @@ -49,14 +69,6 @@ namespace MediaBrowser.Controller.Entities return LibraryManager.GetItemList(query); } - /// <summary> - /// Gets the folder containing the item. - /// If the item is a folder, it returns the folder itself. - /// </summary> - /// <value>The containing folder path.</value> - [JsonIgnore] - public override string ContainingFolderPath => Path; - public override bool CanDelete() { return false; @@ -67,18 +79,6 @@ namespace MediaBrowser.Controller.Entities return true; } - /// <summary> - /// Gets a value indicating whether to enable alpha numeric sorting. - /// </summary> - [JsonIgnore] - public override bool EnableAlphaNumericSorting => false; - - [JsonIgnore] - public override bool SupportsPeople => false; - - [JsonIgnore] - public override bool SupportsAncestors => false; - public static string GetPath(string name) { return GetPath(name, true); @@ -129,6 +129,8 @@ namespace MediaBrowser.Controller.Entities /// <summary> /// This is called before any metadata refresh and returns true or false indicating if changes were made. /// </summary> + /// <param name="replaceAllMetadata"><c>true</c> to replace all metadata, <c>false</c> to not.</param> + /// <returns><c>true</c> if changes were made, <c>false</c> if not.</returns> public override bool BeforeMetadataRefresh(bool replaceAllMetadata) { var hasChanges = base.BeforeMetadataRefresh(replaceAllMetadata); diff --git a/MediaBrowser.Controller/Entities/PersonInfo.cs b/MediaBrowser.Controller/Entities/PersonInfo.cs index fb79323f8f..2b689ae7e2 100644 --- a/MediaBrowser.Controller/Entities/PersonInfo.cs +++ b/MediaBrowser.Controller/Entities/PersonInfo.cs @@ -1,6 +1,6 @@ #nullable disable -#pragma warning disable CS1591 +#pragma warning disable CA2227, CS1591 using System; using System.Collections.Generic; diff --git a/MediaBrowser.Controller/Entities/Photo.cs b/MediaBrowser.Controller/Entities/Photo.cs index 3312a0e3e2..ba6ce189ac 100644 --- a/MediaBrowser.Controller/Entities/Photo.cs +++ b/MediaBrowser.Controller/Entities/Photo.cs @@ -36,6 +36,30 @@ namespace MediaBrowser.Controller.Entities } } + public string CameraMake { get; set; } + + public string CameraModel { get; set; } + + public string Software { get; set; } + + public double? ExposureTime { get; set; } + + public double? FocalLength { get; set; } + + public ImageOrientation? Orientation { get; set; } + + public double? Aperture { get; set; } + + public double? ShutterSpeed { get; set; } + + public double? Latitude { get; set; } + + public double? Longitude { get; set; } + + public double? Altitude { get; set; } + + public int? IsoSpeedRating { get; set; } + public override bool CanDownload() { return true; @@ -69,29 +93,5 @@ namespace MediaBrowser.Controller.Entities return base.GetDefaultPrimaryImageAspectRatio(); } - - public string CameraMake { get; set; } - - public string CameraModel { get; set; } - - public string Software { get; set; } - - public double? ExposureTime { get; set; } - - public double? FocalLength { get; set; } - - public ImageOrientation? Orientation { get; set; } - - public double? Aperture { get; set; } - - public double? ShutterSpeed { get; set; } - - public double? Latitude { get; set; } - - public double? Longitude { get; set; } - - public double? Altitude { get; set; } - - public int? IsoSpeedRating { get; set; } } } diff --git a/MediaBrowser.Controller/Entities/Studio.cs b/MediaBrowser.Controller/Entities/Studio.cs index 888b300012..c8feb1c946 100644 --- a/MediaBrowser.Controller/Entities/Studio.cs +++ b/MediaBrowser.Controller/Entities/Studio.cs @@ -15,19 +15,6 @@ namespace MediaBrowser.Controller.Entities /// </summary> public class Studio : BaseItem, IItemByName { - public override List<string> GetUserDataKeys() - { - var list = base.GetUserDataKeys(); - - list.Insert(0, GetType().Name + "-" + (Name ?? string.Empty).RemoveDiacritics()); - return list; - } - - public override string CreatePresentationUniqueKey() - { - return GetUserDataKeys()[0]; - } - /// <summary> /// Gets the folder containing the item. /// If the item is a folder, it returns the folder itself. @@ -42,6 +29,22 @@ namespace MediaBrowser.Controller.Entities [JsonIgnore] public override bool SupportsAncestors => false; + [JsonIgnore] + public override bool SupportsPeople => false; + + public override List<string> GetUserDataKeys() + { + var list = base.GetUserDataKeys(); + + list.Insert(0, GetType().Name + "-" + (Name ?? string.Empty).RemoveDiacritics()); + return list; + } + + public override string CreatePresentationUniqueKey() + { + return GetUserDataKeys()[0]; + } + public override double GetDefaultPrimaryImageAspectRatio() { double value = 16; @@ -67,9 +70,6 @@ namespace MediaBrowser.Controller.Entities return LibraryManager.GetItemList(query); } - [JsonIgnore] - public override bool SupportsPeople => false; - public static string GetPath(string name) { return GetPath(name, true); @@ -105,6 +105,8 @@ namespace MediaBrowser.Controller.Entities /// <summary> /// This is called before any metadata refresh and returns true or false indicating if changes were made. /// </summary> + /// <param name="replaceAllMetadata"><c>true</c> to replace all metadata, <c>false</c> to not.</param> + /// <returns><c>true</c> if changes were made, <c>false</c> if not.</returns> public override bool BeforeMetadataRefresh(bool replaceAllMetadata) { var hasChanges = base.BeforeMetadataRefresh(replaceAllMetadata); diff --git a/MediaBrowser.Controller/Entities/TV/Episode.cs b/MediaBrowser.Controller/Entities/TV/Episode.cs index 31c179bcac..27c3ff81bd 100644 --- a/MediaBrowser.Controller/Entities/TV/Episode.cs +++ b/MediaBrowser.Controller/Entities/TV/Episode.cs @@ -49,12 +49,6 @@ namespace MediaBrowser.Controller.Entities.TV /// <value>The index number.</value> public int? IndexNumberEnd { get; set; } - public string FindSeriesSortName() - { - var series = Series; - return series == null ? SeriesName : series.SortName; - } - [JsonIgnore] protected override bool SupportsOwnedItems => IsStacked || MediaSourceCount > 1; @@ -76,45 +70,6 @@ namespace MediaBrowser.Controller.Entities.TV [JsonIgnore] protected override bool EnableDefaultVideoUserDataKeys => false; - public override double GetDefaultPrimaryImageAspectRatio() - { - // hack for tv plugins - if (SourceType == SourceType.Channel) - { - return 0; - } - - return 16.0 / 9; - } - - public override List<string> GetUserDataKeys() - { - var list = base.GetUserDataKeys(); - - var series = Series; - if (series != null && ParentIndexNumber.HasValue && IndexNumber.HasValue) - { - var seriesUserDataKeys = series.GetUserDataKeys(); - var take = seriesUserDataKeys.Count; - if (seriesUserDataKeys.Count > 1) - { - take--; - } - - var newList = seriesUserDataKeys.GetRange(0, take); - var suffix = ParentIndexNumber.Value.ToString("000", CultureInfo.InvariantCulture) + IndexNumber.Value.ToString("000", CultureInfo.InvariantCulture); - for (int i = 0; i < take; i++) - { - newList[i] = newList[i] + suffix; - } - - newList.AddRange(list); - list = newList; - } - - return list; - } - /// <summary> /// Gets the Episode's Series Instance. /// </summary> @@ -161,6 +116,74 @@ namespace MediaBrowser.Controller.Entities.TV [JsonIgnore] public string SeasonName { get; set; } + [JsonIgnore] + public override bool SupportsRemoteImageDownloading + { + get + { + if (IsMissingEpisode) + { + return false; + } + + return true; + } + } + + [JsonIgnore] + public bool IsMissingEpisode => LocationType == LocationType.Virtual; + + [JsonIgnore] + public Guid SeasonId { get; set; } + + [JsonIgnore] + public Guid SeriesId { get; set; } + + public string FindSeriesSortName() + { + var series = Series; + return series == null ? SeriesName : series.SortName; + } + + public override double GetDefaultPrimaryImageAspectRatio() + { + // hack for tv plugins + if (SourceType == SourceType.Channel) + { + return 0; + } + + return 16.0 / 9; + } + + public override List<string> GetUserDataKeys() + { + var list = base.GetUserDataKeys(); + + var series = Series; + if (series != null && ParentIndexNumber.HasValue && IndexNumber.HasValue) + { + var seriesUserDataKeys = series.GetUserDataKeys(); + var take = seriesUserDataKeys.Count; + if (seriesUserDataKeys.Count > 1) + { + take--; + } + + var newList = seriesUserDataKeys.GetRange(0, take); + var suffix = ParentIndexNumber.Value.ToString("000", CultureInfo.InvariantCulture) + IndexNumber.Value.ToString("000", CultureInfo.InvariantCulture); + for (int i = 0; i < take; i++) + { + newList[i] = newList[i] + suffix; + } + + newList.AddRange(list); + list = newList; + } + + return list; + } + public string FindSeriesPresentationUniqueKey() { var series = Series; @@ -242,29 +265,6 @@ namespace MediaBrowser.Controller.Entities.TV return false; } - [JsonIgnore] - public override bool SupportsRemoteImageDownloading - { - get - { - if (IsMissingEpisode) - { - return false; - } - - return true; - } - } - - [JsonIgnore] - public bool IsMissingEpisode => LocationType == LocationType.Virtual; - - [JsonIgnore] - public Guid SeasonId { get; set; } - - [JsonIgnore] - public Guid SeriesId { get; set; } - public Guid FindSeriesId() { var series = FindParent<Series>(); diff --git a/MediaBrowser.Controller/Entities/TV/Season.cs b/MediaBrowser.Controller/Entities/TV/Season.cs index aa62bb35b0..926c7b0459 100644 --- a/MediaBrowser.Controller/Entities/TV/Season.cs +++ b/MediaBrowser.Controller/Entities/TV/Season.cs @@ -38,6 +38,50 @@ namespace MediaBrowser.Controller.Entities.TV [JsonIgnore] public override Guid DisplayParentId => SeriesId; + /// <summary> + /// Gets this Episode's Series Instance. + /// </summary> + /// <value>The series.</value> + [JsonIgnore] + public Series Series + { + get + { + var seriesId = SeriesId; + if (seriesId == Guid.Empty) + { + seriesId = FindSeriesId(); + } + + return seriesId == Guid.Empty ? null : (LibraryManager.GetItemById(seriesId) as Series); + } + } + + [JsonIgnore] + public string SeriesPath + { + get + { + var series = Series; + + if (series != null) + { + return series.Path; + } + + return System.IO.Path.GetDirectoryName(Path); + } + } + + [JsonIgnore] + public string SeriesPresentationUniqueKey { get; set; } + + [JsonIgnore] + public string SeriesName { get; set; } + + [JsonIgnore] + public Guid SeriesId { get; set; } + public override double GetDefaultPrimaryImageAspectRatio() { double value = 2; @@ -80,41 +124,6 @@ namespace MediaBrowser.Controller.Entities.TV return result; } - /// <summary> - /// Gets this Episode's Series Instance. - /// </summary> - /// <value>The series.</value> - [JsonIgnore] - public Series Series - { - get - { - var seriesId = SeriesId; - if (seriesId == Guid.Empty) - { - seriesId = FindSeriesId(); - } - - return seriesId == Guid.Empty ? null : (LibraryManager.GetItemById(seriesId) as Series); - } - } - - [JsonIgnore] - public string SeriesPath - { - get - { - var series = Series; - - if (series != null) - { - return series.Path; - } - - return System.IO.Path.GetDirectoryName(Path); - } - } - public override string CreatePresentationUniqueKey() { if (IndexNumber.HasValue) @@ -157,6 +166,9 @@ namespace MediaBrowser.Controller.Entities.TV /// <summary> /// Gets the episodes. /// </summary> + /// <param name="user">The user.</param> + /// <param name="options">The options to use.</param> + /// <returns>Set of episodes.</returns> public List<BaseItem> GetEpisodes(User user, DtoOptions options) { return GetEpisodes(Series, user, options); @@ -193,15 +205,6 @@ namespace MediaBrowser.Controller.Entities.TV return UnratedItem.Series; } - [JsonIgnore] - public string SeriesPresentationUniqueKey { get; set; } - - [JsonIgnore] - public string SeriesName { get; set; } - - [JsonIgnore] - public Guid SeriesId { get; set; } - public string FindSeriesPresentationUniqueKey() { var series = Series; @@ -241,6 +244,7 @@ namespace MediaBrowser.Controller.Entities.TV /// <summary> /// This is called before any metadata refresh and returns true or false indicating if changes were made. /// </summary> + /// <param name="replaceAllMetadata"><c>true</c> to replace metdata, <c>false</c> to not.</param> /// <returns><c>true</c> if XXXX, <c>false</c> otherwise.</returns> public override bool BeforeMetadataRefresh(bool replaceAllMetadata) { diff --git a/MediaBrowser.Controller/Entities/TV/Series.cs b/MediaBrowser.Controller/Entities/TV/Series.cs index 44d07b4a48..e4933e9682 100644 --- a/MediaBrowser.Controller/Entities/TV/Series.cs +++ b/MediaBrowser.Controller/Entities/TV/Series.cs @@ -72,6 +72,9 @@ namespace MediaBrowser.Controller.Entities.TV /// <value>The status.</value> public SeriesStatus? Status { get; set; } + [JsonIgnore] + public override bool StopRefreshIfLocalMetadataFound => false; + public override double GetDefaultPrimaryImageAspectRatio() { double value = 2; @@ -293,7 +296,7 @@ namespace MediaBrowser.Controller.Entities.TV // Refresh seasons foreach (var item in items) { - if (!(item is Season)) + if (item is not Season) { continue; } @@ -394,6 +397,10 @@ namespace MediaBrowser.Controller.Entities.TV /// <summary> /// Filters the episodes by season. /// </summary> + /// <param name="episodes">The episodes.</param> + /// <param name="parentSeason">The season.</param> + /// <param name="includeSpecials"><c>true</c> to include special, <c>false</c> to not.</param> + /// <returns>The set of episodes.</returns> public static IEnumerable<BaseItem> FilterEpisodesBySeason(IEnumerable<BaseItem> episodes, Season parentSeason, bool includeSpecials) { var seasonNumber = parentSeason.IndexNumber; @@ -424,6 +431,10 @@ namespace MediaBrowser.Controller.Entities.TV /// <summary> /// Filters the episodes by season. /// </summary> + /// <param name="episodes">The episodes.</param> + /// <param name="seasonNumber">The season.</param> + /// <param name="includeSpecials"><c>true</c> to include special, <c>false</c> to not.</param> + /// <returns>The set of episodes.</returns> public static IEnumerable<Episode> FilterEpisodesBySeason(IEnumerable<Episode> episodes, int seasonNumber, bool includeSpecials) { if (!includeSpecials || seasonNumber < 1) @@ -499,8 +510,5 @@ namespace MediaBrowser.Controller.Entities.TV return list; } - - [JsonIgnore] - public override bool StopRefreshIfLocalMetadataFound => false; } } diff --git a/MediaBrowser.Controller/Entities/Trailer.cs b/MediaBrowser.Controller/Entities/Trailer.cs index 732b45521b..1c558d4196 100644 --- a/MediaBrowser.Controller/Entities/Trailer.cs +++ b/MediaBrowser.Controller/Entities/Trailer.cs @@ -1,6 +1,6 @@ #nullable disable -#pragma warning disable CS1591 +#pragma warning disable CA1819, CS1591 using System; using System.Collections.Generic; @@ -23,6 +23,9 @@ namespace MediaBrowser.Controller.Entities TrailerTypes = Array.Empty<TrailerType>(); } + [JsonIgnore] + public override bool StopRefreshIfLocalMetadataFound => false; + public TrailerType[] TrailerTypes { get; set; } public override double GetDefaultPrimaryImageAspectRatio() @@ -97,8 +100,5 @@ namespace MediaBrowser.Controller.Entities return list; } - - [JsonIgnore] - public override bool StopRefreshIfLocalMetadataFound => false; } } diff --git a/MediaBrowser.Controller/Entities/UserItemData.cs b/MediaBrowser.Controller/Entities/UserItemData.cs index 6ab2116d73..50ba9ef308 100644 --- a/MediaBrowser.Controller/Entities/UserItemData.cs +++ b/MediaBrowser.Controller/Entities/UserItemData.cs @@ -12,6 +12,13 @@ namespace MediaBrowser.Controller.Entities /// </summary> public class UserItemData { + public const double MinLikeValue = 6.5; + + /// <summary> + /// The _rating. + /// </summary> + private double? _rating; + /// <summary> /// Gets or sets the user id. /// </summary> @@ -24,11 +31,6 @@ namespace MediaBrowser.Controller.Entities /// <value>The key.</value> public string Key { get; set; } - /// <summary> - /// The _rating. - /// </summary> - private double? _rating; - /// <summary> /// Gets or sets the users 0-10 rating. /// </summary> @@ -93,8 +95,6 @@ namespace MediaBrowser.Controller.Entities /// <value>The index of the subtitle stream.</value> public int? SubtitleStreamIndex { get; set; } - public const double MinLikeValue = 6.5; - /// <summary> /// Gets or sets a value indicating whether the item is liked or not. /// This should never be serialized. diff --git a/MediaBrowser.Controller/Entities/UserRootFolder.cs b/MediaBrowser.Controller/Entities/UserRootFolder.cs index 2b15a52f09..e547db5231 100644 --- a/MediaBrowser.Controller/Entities/UserRootFolder.cs +++ b/MediaBrowser.Controller/Entities/UserRootFolder.cs @@ -21,22 +21,15 @@ namespace MediaBrowser.Controller.Entities /// </summary> public class UserRootFolder : Folder { - private List<Guid> _childrenIds = null; private readonly object _childIdsLock = new object(); + private List<Guid> _childrenIds = null; - protected override List<BaseItem> LoadChildren() + /// <summary> + /// Initializes a new instance of the <see cref="UserRootFolder"/> class. + /// </summary> + public UserRootFolder() { - lock (_childIdsLock) - { - if (_childrenIds == null) - { - var list = base.LoadChildren(); - _childrenIds = list.Select(i => i.Id).ToList(); - return list; - } - - return _childrenIds.Select(LibraryManager.GetItemById).Where(i => i != null).ToList(); - } + IsRoot = true; } [JsonIgnore] @@ -45,6 +38,12 @@ namespace MediaBrowser.Controller.Entities [JsonIgnore] public override bool SupportsPlayedStatus => false; + [JsonIgnore] + protected override bool SupportsShortcutChildren => true; + + [JsonIgnore] + public override bool IsPreSorted => true; + private void ClearCache() { lock (_childIdsLock) @@ -53,6 +52,21 @@ namespace MediaBrowser.Controller.Entities } } + protected override List<BaseItem> LoadChildren() + { + lock (_childIdsLock) + { + if (_childrenIds == null) + { + var list = base.LoadChildren(); + _childrenIds = list.Select(i => i.Id).ToList(); + return list; + } + + return _childrenIds.Select(LibraryManager.GetItemById).Where(i => i != null).ToList(); + } + } + protected override QueryResult<BaseItem> GetItemsInternal(InternalItemsQuery query) { if (query.Recursive) @@ -74,12 +88,6 @@ namespace MediaBrowser.Controller.Entities return GetChildren(user, true).Count; } - [JsonIgnore] - protected override bool SupportsShortcutChildren => true; - - [JsonIgnore] - public override bool IsPreSorted => true; - protected override IEnumerable<BaseItem> GetEligibleChildrenForRecursiveChildren(User user) { var list = base.GetEligibleChildrenForRecursiveChildren(user).ToList(); diff --git a/MediaBrowser.Controller/Entities/UserView.cs b/MediaBrowser.Controller/Entities/UserView.cs index 57dc9b59ba..62f3c4b557 100644 --- a/MediaBrowser.Controller/Entities/UserView.cs +++ b/MediaBrowser.Controller/Entities/UserView.cs @@ -15,6 +15,25 @@ namespace MediaBrowser.Controller.Entities { public class UserView : Folder, IHasCollectionType { + private static readonly string[] _viewTypesEligibleForGrouping = new string[] + { + Model.Entities.CollectionType.Movies, + Model.Entities.CollectionType.TvShows, + string.Empty + }; + + private static readonly string[] _originalFolderViewTypes = new string[] + { + Model.Entities.CollectionType.Books, + Model.Entities.CollectionType.MusicVideos, + Model.Entities.CollectionType.HomeVideos, + Model.Entities.CollectionType.Photos, + Model.Entities.CollectionType.Music, + Model.Entities.CollectionType.BoxSets + }; + + public static ITVSeriesManager TVSeriesManager { get; set; } + /// <summary> /// Gets or sets the view type. /// </summary> @@ -30,12 +49,22 @@ namespace MediaBrowser.Controller.Entities /// </summary> public Guid? UserId { get; set; } - public static ITVSeriesManager TVSeriesManager; - /// <inheritdoc /> [JsonIgnore] public string CollectionType => ViewType; + /// <inheritdoc /> + [JsonIgnore] + public override bool SupportsInheritedParentImages => false; + + /// <inheritdoc /> + [JsonIgnore] + public override bool SupportsPlayedStatus => false; + + /// <inheritdoc /> + [JsonIgnore] + public override bool SupportsPeople => false; + /// <inheritdoc /> public override IEnumerable<Guid> GetIdsForAncestorQuery() { @@ -53,17 +82,13 @@ namespace MediaBrowser.Controller.Entities } } - [JsonIgnore] - public override bool SupportsInheritedParentImages => false; - - [JsonIgnore] - public override bool SupportsPlayedStatus => false; - + /// <inheritdoc /> public override int GetChildCount(User user) { return GetChildren(user, true).Count; } + /// <inheritdoc /> protected override QueryResult<BaseItem> GetItemsInternal(InternalItemsQuery query) { var parent = this as Folder; @@ -81,6 +106,7 @@ namespace MediaBrowser.Controller.Entities .GetUserItems(parent, this, CollectionType, query); } + /// <inheritdoc /> public override List<BaseItem> GetChildren(User user, bool includeLinkedChildren, InternalItemsQuery query) { query ??= new InternalItemsQuery(user); @@ -91,16 +117,19 @@ namespace MediaBrowser.Controller.Entities return result.ToList(); } + /// <inheritdoc /> public override bool CanDelete() { return false; } + /// <inheritdoc /> public override bool IsSaveLocalMetadataEnabled() { return true; } + /// <inheritdoc /> public override IEnumerable<BaseItem> GetRecursiveChildren(User user, InternalItemsQuery query) { query.SetUser(user); @@ -111,32 +140,26 @@ namespace MediaBrowser.Controller.Entities return GetItemList(query); } + /// <inheritdoc /> protected override IEnumerable<BaseItem> GetEligibleChildrenForRecursiveChildren(User user) { return GetChildren(user, false); } - private static readonly string[] UserSpecificViewTypes = new string[] - { - Model.Entities.CollectionType.Playlists - }; - public static bool IsUserSpecific(Folder folder) { - var collectionFolder = folder as ICollectionFolder; - - if (collectionFolder == null) + if (folder is not ICollectionFolder collectionFolder) { return false; } - var supportsUserSpecific = folder as ISupportsUserSpecificView; - if (supportsUserSpecific != null && supportsUserSpecific.EnableUserSpecificView) + if (folder is ISupportsUserSpecificView supportsUserSpecific + && supportsUserSpecific.EnableUserSpecificView) { return true; } - return UserSpecificViewTypes.Contains(collectionFolder.CollectionType ?? string.Empty, StringComparer.OrdinalIgnoreCase); + return string.Equals(Model.Entities.CollectionType.Playlists, collectionFolder.CollectionType, StringComparison.OrdinalIgnoreCase); } public static bool IsEligibleForGrouping(Folder folder) @@ -145,39 +168,19 @@ namespace MediaBrowser.Controller.Entities && IsEligibleForGrouping(collectionFolder.CollectionType); } - private static string[] ViewTypesEligibleForGrouping = new string[] - { - Model.Entities.CollectionType.Movies, - Model.Entities.CollectionType.TvShows, - string.Empty - }; - public static bool IsEligibleForGrouping(string viewType) { - return ViewTypesEligibleForGrouping.Contains(viewType ?? string.Empty, StringComparer.OrdinalIgnoreCase); + return _viewTypesEligibleForGrouping.Contains(viewType ?? string.Empty, StringComparer.OrdinalIgnoreCase); } - private static string[] OriginalFolderViewTypes = new string[] - { - Model.Entities.CollectionType.Books, - Model.Entities.CollectionType.MusicVideos, - Model.Entities.CollectionType.HomeVideos, - Model.Entities.CollectionType.Photos, - Model.Entities.CollectionType.Music, - Model.Entities.CollectionType.BoxSets - }; - public static bool EnableOriginalFolder(string viewType) { - return OriginalFolderViewTypes.Contains(viewType ?? string.Empty, StringComparer.OrdinalIgnoreCase); + return _originalFolderViewTypes.Contains(viewType ?? string.Empty, StringComparer.OrdinalIgnoreCase); } protected override Task ValidateChildrenInternal(IProgress<double> progress, bool recursive, bool refreshChildMetadata, Providers.MetadataRefreshOptions refreshOptions, Providers.IDirectoryService directoryService, System.Threading.CancellationToken cancellationToken) { return Task.CompletedTask; } - - [JsonIgnore] - public override bool SupportsPeople => false; } } diff --git a/MediaBrowser.Controller/Entities/UserViewBuilder.cs b/MediaBrowser.Controller/Entities/UserViewBuilder.cs index add734f626..266fda767d 100644 --- a/MediaBrowser.Controller/Entities/UserViewBuilder.cs +++ b/MediaBrowser.Controller/Entities/UserViewBuilder.cs @@ -65,7 +65,7 @@ namespace MediaBrowser.Controller.Entities switch (viewType) { case CollectionType.Folders: - return GetResult(_libraryManager.GetUserRootFolder().GetChildren(user, true), queryParent, query); + return GetResult(_libraryManager.GetUserRootFolder().GetChildren(user, true), query); case CollectionType.TvShows: return GetTvView(queryParent, user, query); @@ -110,7 +110,7 @@ namespace MediaBrowser.Controller.Entities return GetMovieMovies(queryParent, user, query); case SpecialFolder.MovieCollections: - return GetMovieCollections(queryParent, user, query); + return GetMovieCollections(user, query); case SpecialFolder.TvFavoriteEpisodes: return GetFavoriteEpisodes(queryParent, user, query); @@ -122,7 +122,7 @@ namespace MediaBrowser.Controller.Entities { if (queryParent is UserView) { - return GetResult(GetMediaFolders(user).OfType<Folder>().SelectMany(i => i.GetChildren(user, true)), queryParent, query); + return GetResult(GetMediaFolders(user).OfType<Folder>().SelectMany(i => i.GetChildren(user, true)), query); } return queryParent.GetItems(query); @@ -160,7 +160,7 @@ namespace MediaBrowser.Controller.Entities GetUserView(SpecialFolder.MovieGenres, "Genres", "5", parent) }; - return GetResult(list, parent, query); + return GetResult(list, query); } private QueryResult<BaseItem> GetFavoriteMovies(Folder parent, User user, InternalItemsQuery query) @@ -207,7 +207,7 @@ namespace MediaBrowser.Controller.Entities return _libraryManager.GetItemsResult(query); } - private QueryResult<BaseItem> GetMovieCollections(Folder parent, User user, InternalItemsQuery query) + private QueryResult<BaseItem> GetMovieCollections(User user, InternalItemsQuery query) { query.Parent = null; query.IncludeItemTypes = new[] { nameof(BoxSet) }; @@ -275,9 +275,9 @@ namespace MediaBrowser.Controller.Entities } }) .Where(i => i != null) - .Select(i => GetUserViewWithName(i.Name, SpecialFolder.MovieGenre, i.SortName, parent)); + .Select(i => GetUserViewWithName(SpecialFolder.MovieGenre, i.SortName, parent)); - return GetResult(genres, parent, query); + return GetResult(genres, query); } private QueryResult<BaseItem> GetMovieGenreItems(Folder queryParent, Folder displayParent, User user, InternalItemsQuery query) @@ -323,7 +323,7 @@ namespace MediaBrowser.Controller.Entities GetUserView(SpecialFolder.TvGenres, "Genres", "6", parent) }; - return GetResult(list, parent, query); + return GetResult(list, query); } private QueryResult<BaseItem> GetTvLatest(Folder parent, User user, InternalItemsQuery query) @@ -403,9 +403,9 @@ namespace MediaBrowser.Controller.Entities } }) .Where(i => i != null) - .Select(i => GetUserViewWithName(i.Name, SpecialFolder.TvGenre, i.SortName, parent)); + .Select(i => GetUserViewWithName(SpecialFolder.TvGenre, i.SortName, parent)); - return GetResult(genres, parent, query); + return GetResult(genres, query); } private QueryResult<BaseItem> GetTvGenreItems(Folder queryParent, Folder displayParent, User user, InternalItemsQuery query) @@ -432,13 +432,12 @@ namespace MediaBrowser.Controller.Entities private QueryResult<BaseItem> GetResult<T>( IEnumerable<T> items, - BaseItem queryParent, InternalItemsQuery query) where T : BaseItem { items = items.Where(i => Filter(i, query.User, query, _userDataManager, _libraryManager)); - return PostFilterAndSort(items, queryParent, null, query, _libraryManager, _config); + return PostFilterAndSort(items, null, query, _libraryManager); } public static bool FilterItem(BaseItem item, InternalItemsQuery query) @@ -448,11 +447,9 @@ namespace MediaBrowser.Controller.Entities public static QueryResult<BaseItem> PostFilterAndSort( IEnumerable<BaseItem> items, - BaseItem queryParent, int? totalRecordLimit, InternalItemsQuery query, - ILibraryManager libraryManager, - IServerConfigurationManager configurationManager) + ILibraryManager libraryManager) { var user = query.User; @@ -1001,7 +998,7 @@ namespace MediaBrowser.Controller.Entities return new BaseItem[] { parent }; } - private UserView GetUserViewWithName(string name, string type, string sortName, BaseItem parent) + private UserView GetUserViewWithName(string type, string sortName, BaseItem parent) { return _userViewManager.GetUserSubView(parent.Id, parent.Id.ToString("N", CultureInfo.InvariantCulture), type, sortName); } diff --git a/MediaBrowser.Controller/Entities/Video.cs b/MediaBrowser.Controller/Entities/Video.cs index d05b5df2f1..7dd95b85cf 100644 --- a/MediaBrowser.Controller/Entities/Video.cs +++ b/MediaBrowser.Controller/Entities/Video.cs @@ -28,6 +28,14 @@ namespace MediaBrowser.Controller.Entities ISupportsPlaceHolders, IHasMediaSources { + public Video() + { + AdditionalParts = Array.Empty<string>(); + LocalAlternateVersions = Array.Empty<string>(); + SubtitleFiles = Array.Empty<string>(); + LinkedAlternateVersions = Array.Empty<LinkedChild>(); + } + [JsonIgnore] public string PrimaryVersionId { get; set; } @@ -74,30 +82,6 @@ namespace MediaBrowser.Controller.Entities } } - public void SetPrimaryVersionId(string id) - { - if (string.IsNullOrEmpty(id)) - { - PrimaryVersionId = null; - } - else - { - PrimaryVersionId = id; - } - - PresentationUniqueKey = CreatePresentationUniqueKey(); - } - - public override string CreatePresentationUniqueKey() - { - if (!string.IsNullOrEmpty(PrimaryVersionId)) - { - return PrimaryVersionId; - } - - return base.CreatePresentationUniqueKey(); - } - [JsonIgnore] public override bool SupportsThemeMedia => true; @@ -151,24 +135,6 @@ namespace MediaBrowser.Controller.Entities /// <value>The aspect ratio.</value> public string AspectRatio { get; set; } - public Video() - { - AdditionalParts = Array.Empty<string>(); - LocalAlternateVersions = Array.Empty<string>(); - SubtitleFiles = Array.Empty<string>(); - LinkedAlternateVersions = Array.Empty<LinkedChild>(); - } - - public override bool CanDownload() - { - if (VideoType == VideoType.Dvd || VideoType == VideoType.BluRay) - { - return false; - } - - return IsFileProtocol; - } - [JsonIgnore] public override bool SupportsAddingToPlaylist => true; @@ -196,16 +162,6 @@ namespace MediaBrowser.Controller.Entities [JsonIgnore] public override bool HasLocalAlternateVersions => LocalAlternateVersions.Length > 0; - public IEnumerable<Guid> GetAdditionalPartIds() - { - return AdditionalParts.Select(i => LibraryManager.GetNewItemId(i, typeof(Video))); - } - - public IEnumerable<Guid> GetLocalAlternateVersionIds() - { - return LocalAlternateVersions.Select(i => LibraryManager.GetNewItemId(i, typeof(Video))); - } - public static ILiveTvManager LiveTvManager { get; set; } [JsonIgnore] @@ -222,37 +178,77 @@ namespace MediaBrowser.Controller.Entities } } - protected override bool IsActiveRecording() + [JsonIgnore] + public bool IsCompleteMedia { - return LiveTvManager.GetActiveRecordingInfo(Path) != null; + get + { + if (SourceType == SourceType.Channel) + { + return !Tags.Contains("livestream", StringComparer.OrdinalIgnoreCase); + } + + return !IsActiveRecording(); + } } - public override bool CanDelete() + [JsonIgnore] + protected virtual bool EnableDefaultVideoUserDataKeys => true; + + [JsonIgnore] + public override string ContainingFolderPath { - if (IsActiveRecording()) + get { - return false; - } + if (IsStacked) + { + return System.IO.Path.GetDirectoryName(Path); + } - return base.CanDelete(); + if (!IsPlaceHolder) + { + if (VideoType == VideoType.BluRay || VideoType == VideoType.Dvd) + { + return Path; + } + } + + return base.ContainingFolderPath; + } } [JsonIgnore] - public bool IsCompleteMedia + public override string FileNameWithoutExtension { get { - if (SourceType == SourceType.Channel) + if (IsFileProtocol) { - return !Tags.Contains("livestream", StringComparer.OrdinalIgnoreCase); + if (VideoType == VideoType.BluRay || VideoType == VideoType.Dvd) + { + return System.IO.Path.GetFileName(Path); + } + + return System.IO.Path.GetFileNameWithoutExtension(Path); } - return !IsActiveRecording(); + return null; } } + /// <summary> + /// Gets a value indicating whether [is3 D]. + /// </summary> + /// <value><c>true</c> if [is3 D]; otherwise, <c>false</c>.</value> [JsonIgnore] - protected virtual bool EnableDefaultVideoUserDataKeys => true; + public bool Is3D => Video3DFormat.HasValue; + + /// <summary> + /// Gets the type of the media. + /// </summary> + /// <value>The type of the media.</value> + [JsonIgnore] + public override string MediaType => Model.Entities.MediaType.Video; public override List<string> GetUserDataKeys() { @@ -293,6 +289,65 @@ namespace MediaBrowser.Controller.Entities return list; } + public void SetPrimaryVersionId(string id) + { + if (string.IsNullOrEmpty(id)) + { + PrimaryVersionId = null; + } + else + { + PrimaryVersionId = id; + } + + PresentationUniqueKey = CreatePresentationUniqueKey(); + } + + public override string CreatePresentationUniqueKey() + { + if (!string.IsNullOrEmpty(PrimaryVersionId)) + { + return PrimaryVersionId; + } + + return base.CreatePresentationUniqueKey(); + } + + public override bool CanDownload() + { + if (VideoType == VideoType.Dvd || VideoType == VideoType.BluRay) + { + return false; + } + + return IsFileProtocol; + } + + protected override bool IsActiveRecording() + { + return LiveTvManager.GetActiveRecordingInfo(Path) != null; + } + + public override bool CanDelete() + { + if (IsActiveRecording()) + { + return false; + } + + return base.CanDelete(); + } + + public IEnumerable<Guid> GetAdditionalPartIds() + { + return AdditionalParts.Select(i => LibraryManager.GetNewItemId(i, typeof(Video))); + } + + public IEnumerable<Guid> GetLocalAlternateVersionIds() + { + return LocalAlternateVersions.Select(i => LibraryManager.GetNewItemId(i, typeof(Video))); + } + private string GetUserDataKey(string providerId) { var key = providerId + "-" + ExtraType.ToString().ToLowerInvariant(); @@ -328,47 +383,6 @@ namespace MediaBrowser.Controller.Entities .OrderBy(i => i.SortName); } - [JsonIgnore] - public override string ContainingFolderPath - { - get - { - if (IsStacked) - { - return System.IO.Path.GetDirectoryName(Path); - } - - if (!IsPlaceHolder) - { - if (VideoType == VideoType.BluRay || VideoType == VideoType.Dvd) - { - return Path; - } - } - - return base.ContainingFolderPath; - } - } - - [JsonIgnore] - public override string FileNameWithoutExtension - { - get - { - if (IsFileProtocol) - { - if (VideoType == VideoType.BluRay || VideoType == VideoType.Dvd) - { - return System.IO.Path.GetFileName(Path); - } - - return System.IO.Path.GetFileNameWithoutExtension(Path); - } - - return null; - } - } - internal override ItemUpdateType UpdateFromResolvedItem(BaseItem newItem) { var updateType = base.UpdateFromResolvedItem(newItem); @@ -397,20 +411,6 @@ namespace MediaBrowser.Controller.Entities return updateType; } - /// <summary> - /// Gets a value indicating whether [is3 D]. - /// </summary> - /// <value><c>true</c> if [is3 D]; otherwise, <c>false</c>.</value> - [JsonIgnore] - public bool Is3D => Video3DFormat.HasValue; - - /// <summary> - /// Gets the type of the media. - /// </summary> - /// <value>The type of the media.</value> - [JsonIgnore] - public override string MediaType => Model.Entities.MediaType.Video; - protected override async Task<bool> RefreshedOwnedItems(MetadataRefreshOptions options, List<FileSystemMetadata> fileSystemChildren, CancellationToken cancellationToken) { var hasChanges = await base.RefreshedOwnedItems(options, fileSystemChildren, cancellationToken).ConfigureAwait(false); diff --git a/MediaBrowser.Controller/Entities/Year.cs b/MediaBrowser.Controller/Entities/Year.cs index f268bc939e..afdaf448b7 100644 --- a/MediaBrowser.Controller/Entities/Year.cs +++ b/MediaBrowser.Controller/Entities/Year.cs @@ -15,13 +15,11 @@ namespace MediaBrowser.Controller.Entities /// </summary> public class Year : BaseItem, IItemByName { - public override List<string> GetUserDataKeys() - { - var list = base.GetUserDataKeys(); + [JsonIgnore] + public override bool SupportsAncestors => false; - list.Insert(0, "Year-" + Name); - return list; - } + [JsonIgnore] + public override bool SupportsPeople => false; /// <summary> /// Gets the folder containing the item. @@ -31,6 +29,19 @@ namespace MediaBrowser.Controller.Entities [JsonIgnore] public override string ContainingFolderPath => Path; + public override bool CanDelete() + { + return false; + } + + public override List<string> GetUserDataKeys() + { + var list = base.GetUserDataKeys(); + + list.Insert(0, "Year-" + Name); + return list; + } + public override double GetDefaultPrimaryImageAspectRatio() { double value = 2; @@ -39,14 +50,6 @@ namespace MediaBrowser.Controller.Entities return value; } - [JsonIgnore] - public override bool SupportsAncestors => false; - - public override bool CanDelete() - { - return false; - } - public override bool IsSaveLocalMetadataEnabled() { return true; @@ -54,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>(); } @@ -76,9 +77,6 @@ namespace MediaBrowser.Controller.Entities return null; } - [JsonIgnore] - public override bool SupportsPeople => false; - public static string GetPath(string name) { return GetPath(name, true); diff --git a/MediaBrowser.Controller/IServerApplicationHost.cs b/MediaBrowser.Controller/IServerApplicationHost.cs index 0949238426..7e5e742f5d 100644 --- a/MediaBrowser.Controller/IServerApplicationHost.cs +++ b/MediaBrowser.Controller/IServerApplicationHost.cs @@ -2,7 +2,6 @@ #pragma warning disable CS1591 -using System; using System.Collections.Generic; using System.Net; using MediaBrowser.Common; @@ -16,8 +15,6 @@ namespace MediaBrowser.Controller /// </summary> public interface IServerApplicationHost : IApplicationHost { - event EventHandler HasUpdateAvailableChanged; - bool CoreStartupHasCompleted { get; } bool CanLaunchWebBrowser { get; } @@ -39,12 +36,6 @@ namespace MediaBrowser.Controller /// </summary> bool ListenWithHttps { get; } - /// <summary> - /// Gets a value indicating whether this instance has update available. - /// </summary> - /// <value><c>true</c> if this instance has update available; otherwise, <c>false</c>.</value> - bool HasUpdateAvailable { get; } - /// <summary> /// Gets the name of the friendly. /// </summary> @@ -59,11 +50,11 @@ namespace MediaBrowser.Controller /// <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. @@ -112,13 +103,6 @@ namespace MediaBrowser.Controller /// <returns>The API URL.</returns> string GetLocalApiUrl(string hostname, string scheme = null, int? port = null); - /// <summary> - /// Open a URL in an external browser window. - /// </summary> - /// <param name="url">The URL to open.</param> - /// <exception cref="NotSupportedException"><see cref="CanLaunchWebBrowser"/> is false.</exception> - void LaunchUrl(string url); - IEnumerable<WakeOnLanInfo> GetWakeOnLanInfo(); string ExpandVirtualPath(string path); diff --git a/MediaBrowser.Controller/Library/IDirectStreamProvider.cs b/MediaBrowser.Controller/Library/IDirectStreamProvider.cs new file mode 100644 index 0000000000..96f8b7eba9 --- /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 7a4ba6a24a..d40e56c7d9 100644 --- a/MediaBrowser.Controller/Library/ILibraryManager.cs +++ b/MediaBrowser.Controller/Library/ILibraryManager.cs @@ -1,6 +1,6 @@ #nullable disable -#pragma warning disable CS1591 +#pragma warning disable CA1002, CS1591 using System; using System.Collections.Generic; @@ -31,6 +31,29 @@ namespace MediaBrowser.Controller.Library /// </summary> public interface ILibraryManager { + /// <summary> + /// Occurs when [item added]. + /// </summary> + event EventHandler<ItemChangeEventArgs> ItemAdded; + + /// <summary> + /// Occurs when [item updated]. + /// </summary> + event EventHandler<ItemChangeEventArgs> ItemUpdated; + + /// <summary> + /// Occurs when [item removed]. + /// </summary> + event EventHandler<ItemChangeEventArgs> ItemRemoved; + + /// <summary> + /// Gets the root folder. + /// </summary> + /// <value>The root folder.</value> + AggregateFolder RootFolder { get; } + + bool IsScanRunning { get; } + /// <summary> /// Resolves the path. /// </summary> @@ -57,16 +80,10 @@ namespace MediaBrowser.Controller.Library LibraryOptions libraryOptions, string collectionType = null); - /// <summary> - /// Gets the root folder. - /// </summary> - /// <value>The root folder.</value> - AggregateFolder RootFolder { get; } - /// <summary> /// Gets a Person. /// </summary> - /// <param name="name">The name.</param> + /// <param name="name">The name of the person.</param> /// <returns>Task{Person}.</returns> Person GetPerson(string name); @@ -81,7 +98,7 @@ namespace MediaBrowser.Controller.Library /// <summary> /// Gets the artist. /// </summary> - /// <param name="name">The name.</param> + /// <param name="name">The name of the artist.</param> /// <returns>Task{Artist}.</returns> MusicArtist GetArtist(string name); @@ -90,21 +107,21 @@ namespace MediaBrowser.Controller.Library /// <summary> /// Gets a Studio. /// </summary> - /// <param name="name">The name.</param> + /// <param name="name">The name of the studio.</param> /// <returns>Task{Studio}.</returns> Studio GetStudio(string name); /// <summary> /// Gets a Genre. /// </summary> - /// <param name="name">The name.</param> + /// <param name="name">The name of the genre.</param> /// <returns>Task{Genre}.</returns> Genre GetGenre(string name); /// <summary> /// Gets the genre. /// </summary> - /// <param name="name">The name.</param> + /// <param name="name">The name of the music genre.</param> /// <returns>Task{MusicGenre}.</returns> MusicGenre GetMusicGenre(string name); @@ -113,7 +130,7 @@ namespace MediaBrowser.Controller.Library /// </summary> /// <param name="value">The value.</param> /// <returns>Task{Year}.</returns> - /// <exception cref="ArgumentOutOfRangeException"></exception> + /// <exception cref="ArgumentOutOfRangeException">Throws if year is invalid.</exception> Year GetYear(int value); /// <summary> @@ -205,16 +222,26 @@ namespace MediaBrowser.Controller.Library /// <summary> /// Creates the item. /// </summary> + /// <param name="item">Item to create.</param> + /// <param name="parent">Parent of new item.</param> void CreateItem(BaseItem item, BaseItem parent); /// <summary> /// Creates the items. /// </summary> + /// <param name="items">Items to create.</param> + /// <param name="parent">Parent of new items.</param> + /// <param name="cancellationToken">CancellationToken to use for operation.</param> void CreateItems(IReadOnlyList<BaseItem> items, BaseItem parent, CancellationToken cancellationToken); /// <summary> /// Updates the item. /// </summary> + /// <param name="items">Items to update.</param> + /// <param name="parent">Parent of updated items.</param> + /// <param name="updateReason">Reason for update.</param> + /// <param name="cancellationToken">CancellationToken to use for operation.</param> + /// <returns>Returns a Task that can be awaited.</returns> Task UpdateItemsAsync(IReadOnlyList<BaseItem> items, BaseItem parent, ItemUpdateType updateReason, CancellationToken cancellationToken); /// <summary> @@ -224,6 +251,7 @@ namespace MediaBrowser.Controller.Library /// <param name="parent">The parent item.</param> /// <param name="updateReason">The update reason.</param> /// <param name="cancellationToken">The cancellation token.</param> + /// <returns>Returns a Task that can be awaited.</returns> Task UpdateItemAsync(BaseItem item, BaseItem parent, ItemUpdateType updateReason, CancellationToken cancellationToken); /// <summary> @@ -233,23 +261,6 @@ namespace MediaBrowser.Controller.Library /// <returns>BaseItem.</returns> BaseItem RetrieveItem(Guid id); - bool IsScanRunning { get; } - - /// <summary> - /// Occurs when [item added]. - /// </summary> - event EventHandler<ItemChangeEventArgs> ItemAdded; - - /// <summary> - /// Occurs when [item updated]. - /// </summary> - event EventHandler<ItemChangeEventArgs> ItemUpdated; - - /// <summary> - /// Occurs when [item removed]. - /// </summary> - event EventHandler<ItemChangeEventArgs> ItemRemoved; - /// <summary> /// Finds the type of the collection. /// </summary> @@ -294,16 +305,25 @@ namespace MediaBrowser.Controller.Library /// <summary> /// Deletes the item. /// </summary> + /// <param name="item">Item to delete.</param> + /// <param name="options">Options to use for deletion.</param> void DeleteItem(BaseItem item, DeleteOptions options); /// <summary> /// Deletes the item. /// </summary> + /// <param name="item">Item to delete.</param> + /// <param name="options">Options to use for deletion.</param> + /// <param name="notifyParentItem">Notify parent of deletion.</param> void DeleteItem(BaseItem item, DeleteOptions options, bool notifyParentItem); /// <summary> /// Deletes the item. /// </summary> + /// <param name="item">Item to delete.</param> + /// <param name="options">Options to use for deletion.</param> + /// <param name="parent">Parent of item.</param> + /// <param name="notifyParentItem">Notify parent of deletion.</param> void DeleteItem(BaseItem item, DeleteOptions options, BaseItem parent, bool notifyParentItem); /// <summary> @@ -314,6 +334,7 @@ namespace MediaBrowser.Controller.Library /// <param name="parentId">The parent identifier.</param> /// <param name="viewType">Type of the view.</param> /// <param name="sortName">Name of the sort.</param> + /// <returns>The named view.</returns> UserView GetNamedView( User user, string name, @@ -328,6 +349,7 @@ namespace MediaBrowser.Controller.Library /// <param name="name">The name.</param> /// <param name="viewType">Type of the view.</param> /// <param name="sortName">Name of the sort.</param> + /// <returns>The named view.</returns> UserView GetNamedView( User user, string name, @@ -340,6 +362,7 @@ namespace MediaBrowser.Controller.Library /// <param name="name">The name.</param> /// <param name="viewType">Type of the view.</param> /// <param name="sortName">Name of the sort.</param> + /// <returns>The named view.</returns> UserView GetNamedView( string name, string viewType, @@ -397,6 +420,9 @@ namespace MediaBrowser.Controller.Library /// <summary> /// Fills the missing episode numbers from path. /// </summary> + /// <param name="episode">Episode to use.</param> + /// <param name="forceRefresh">Option to force refresh of episode numbers.</param> + /// <returns>True if successful.</returns> bool FillMissingEpisodeNumbersFromPath(Episode episode, bool forceRefresh); /// <summary> @@ -539,6 +565,9 @@ namespace MediaBrowser.Controller.Library /// <summary> /// Gets the items. /// </summary> + /// <param name="query">The query to use.</param> + /// <param name="parents">Items to use for query.</param> + /// <returns>List of items.</returns> List<BaseItem> GetItemList(InternalItemsQuery query, List<BaseItem> parents); /// <summary> @@ -566,11 +595,11 @@ namespace MediaBrowser.Controller.Library Task RemoveVirtualFolder(string name, bool refreshLibrary); - void AddMediaPath(string virtualFolderName, MediaPathInfo path); + void AddMediaPath(string virtualFolderName, MediaPathInfo mediaPath); - void UpdateMediaPath(string virtualFolderName, MediaPathInfo path); + void UpdateMediaPath(string virtualFolderName, MediaPathInfo mediaPath); - void RemoveMediaPath(string virtualFolderName, string path); + void RemoveMediaPath(string virtualFolderName, string mediaPath); QueryResult<(BaseItem, ItemCounts)> GetGenres(InternalItemsQuery query); diff --git a/MediaBrowser.Controller/Library/ILiveStream.cs b/MediaBrowser.Controller/Library/ILiveStream.cs index 85d866de5c..4c44a17fdd 100644 --- a/MediaBrowser.Controller/Library/ILiveStream.cs +++ b/MediaBrowser.Controller/Library/ILiveStream.cs @@ -1,7 +1,8 @@ #nullable disable -#pragma warning disable CS1591 +#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 d3d85a0563..e802796d34 100644 --- a/MediaBrowser.Controller/Library/IMediaSourceManager.cs +++ b/MediaBrowser.Controller/Library/IMediaSourceManager.cs @@ -1,10 +1,9 @@ #nullable disable -#pragma warning disable CS1591 +#pragma warning disable CA1002, CS1591 using System; using System.Collections.Generic; -using System.IO; using System.Threading; using System.Threading.Tasks; using Jellyfin.Data.Entities; @@ -62,16 +61,32 @@ namespace MediaBrowser.Controller.Library /// <summary> /// Gets the playack media sources. /// </summary> + /// <param name="item">Item to use.</param> + /// <param name="user">User to use for operation.</param> + /// <param name="allowMediaProbe">Option to allow media probe.</param> + /// <param name="enablePathSubstitution">Option to enable path substitution.</param> + /// <param name="cancellationToken">CancellationToken to use for operation.</param> + /// <returns>List of media sources wrapped in an awaitable task.</returns> Task<List<MediaSourceInfo>> GetPlaybackMediaSources(BaseItem item, User user, bool allowMediaProbe, bool enablePathSubstitution, CancellationToken cancellationToken); /// <summary> /// Gets the static media sources. /// </summary> + /// <param name="item">Item to use.</param> + /// <param name="enablePathSubstitution">Option to enable path substitution.</param> + /// <param name="user">User to use for operation.</param> + /// <returns>List of media sources.</returns> List<MediaSourceInfo> GetStaticMediaSources(BaseItem item, bool enablePathSubstitution, User user = null); /// <summary> /// Gets the static media source. /// </summary> + /// <param name="item">Item to use.</param> + /// <param name="mediaSourceId">Media source to get.</param> + /// <param name="liveStreamId">Live stream to use.</param> + /// <param name="enablePathSubstitution">Option to enable path substitution.</param> + /// <param name="cancellationToken">CancellationToken to use for operation.</param> + /// <returns>The static media source wrapped in an awaitable task.</returns> Task<MediaSourceInfo> GetMediaSource(BaseItem item, string mediaSourceId, string liveStreamId, bool enablePathSubstitution, CancellationToken cancellationToken); /// <summary> @@ -94,6 +109,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> @@ -110,14 +139,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/IMediaSourceProvider.cs b/MediaBrowser.Controller/Library/IMediaSourceProvider.cs index 5bf4acebb4..ca4b53fbee 100644 --- a/MediaBrowser.Controller/Library/IMediaSourceProvider.cs +++ b/MediaBrowser.Controller/Library/IMediaSourceProvider.cs @@ -1,4 +1,4 @@ -#pragma warning disable CS1591 +#pragma warning disable CA1002, CS1591 using System.Collections.Generic; using System.Threading; @@ -21,6 +21,10 @@ namespace MediaBrowser.Controller.Library /// <summary> /// Opens the media source. /// </summary> + /// <param name="openToken">Token to use.</param> + /// <param name="currentLiveStreams">List of live streams.</param> + /// <param name="cancellationToken">CancellationToken to use for operation.</param> + /// <returns>The media source wrapped as an awaitable task.</returns> Task<ILiveStream> OpenMediaSource(string openToken, List<ILiveStream> currentLiveStreams, CancellationToken cancellationToken); } } diff --git a/MediaBrowser.Controller/Library/IMetadataSaver.cs b/MediaBrowser.Controller/Library/IMetadataSaver.cs index 5fbfad8814..d963fd2491 100644 --- a/MediaBrowser.Controller/Library/IMetadataSaver.cs +++ b/MediaBrowser.Controller/Library/IMetadataSaver.cs @@ -29,7 +29,6 @@ namespace MediaBrowser.Controller.Library /// </summary> /// <param name="item">The item.</param> /// <param name="cancellationToken">The cancellation token.</param> - /// <returns>Task.</returns> void Save(BaseItem item, CancellationToken cancellationToken); } } diff --git a/MediaBrowser.Controller/Library/IMusicManager.cs b/MediaBrowser.Controller/Library/IMusicManager.cs index 5329841bf5..ec34a868b3 100644 --- a/MediaBrowser.Controller/Library/IMusicManager.cs +++ b/MediaBrowser.Controller/Library/IMusicManager.cs @@ -1,6 +1,6 @@ #nullable disable -#pragma warning disable CS1591 +#pragma warning disable CA1002, CS1591 using System.Collections.Generic; using Jellyfin.Data.Entities; @@ -15,16 +15,28 @@ namespace MediaBrowser.Controller.Library /// <summary> /// Gets the instant mix from song. /// </summary> + /// <param name="item">The item to use.</param> + /// <param name="user">The user to use.</param> + /// <param name="dtoOptions">The options to use.</param> + /// <returns>List of items.</returns> List<BaseItem> GetInstantMixFromItem(BaseItem item, User user, DtoOptions dtoOptions); /// <summary> /// Gets the instant mix from artist. /// </summary> + /// <param name="artist">The artist to use.</param> + /// <param name="user">The user to use.</param> + /// <param name="dtoOptions">The options to use.</param> + /// <returns>List of items.</returns> List<BaseItem> GetInstantMixFromArtist(MusicArtist artist, User user, DtoOptions dtoOptions); /// <summary> /// Gets the instant mix from genre. /// </summary> + /// <param name="genres">The genres to use.</param> + /// <param name="user">The user to use.</param> + /// <param name="dtoOptions">The options to use.</param> + /// <returns>List of items.</returns> List<BaseItem> GetInstantMixFromGenres(IEnumerable<string> genres, User user, DtoOptions dtoOptions); } } diff --git a/MediaBrowser.Controller/Library/IUserDataManager.cs b/MediaBrowser.Controller/Library/IUserDataManager.cs index e5dcfcff04..034c405910 100644 --- a/MediaBrowser.Controller/Library/IUserDataManager.cs +++ b/MediaBrowser.Controller/Library/IUserDataManager.cs @@ -1,6 +1,6 @@ #nullable disable -#pragma warning disable CS1591 +#pragma warning disable CA1002, CA1707, CS1591 using System; using System.Collections.Generic; @@ -42,9 +42,12 @@ namespace MediaBrowser.Controller.Library /// <summary> /// Gets the user data dto. /// </summary> + /// <param name="item">Item to use.</param> + /// <param name="user">User to use.</param> + /// <returns>User data dto.</returns> UserItemDataDto GetUserDataDto(BaseItem item, User user); - UserItemDataDto GetUserDataDto(BaseItem item, BaseItemDto itemDto, User user, DtoOptions dto_options); + UserItemDataDto GetUserDataDto(BaseItem item, BaseItemDto itemDto, User user, DtoOptions options); /// <summary> /// Get all user data for the given user. @@ -64,6 +67,10 @@ namespace MediaBrowser.Controller.Library /// <summary> /// Updates playstate for an item and returns true or false indicating if it was played to completion. /// </summary> - bool UpdatePlayState(BaseItem item, UserItemData data, long? positionTicks); + /// <param name="item">Item to update.</param> + /// <param name="data">Data to update.</param> + /// <param name="reportedPositionTicks">New playstate.</param> + /// <returns>True if playstate was updated.</returns> + bool UpdatePlayState(BaseItem item, UserItemData data, long? reportedPositionTicks); } } diff --git a/MediaBrowser.Controller/Library/IUserManager.cs b/MediaBrowser.Controller/Library/IUserManager.cs index 1801b1c413..993e3e18f9 100644 --- a/MediaBrowser.Controller/Library/IUserManager.cs +++ b/MediaBrowser.Controller/Library/IUserManager.cs @@ -38,6 +38,7 @@ namespace MediaBrowser.Controller.Library /// <summary> /// Initializes the user manager and ensures that a user exists. /// </summary> + /// <returns>Awaitable task.</returns> Task InitializeAsync(); /// <summary> @@ -65,14 +66,6 @@ namespace MediaBrowser.Controller.Library /// <exception cref="ArgumentException">If the provided user doesn't exist.</exception> Task RenameUser(User user, string newName); - /// <summary> - /// Updates the user. - /// </summary> - /// <param name="user">The user.</param> - /// <exception cref="ArgumentNullException">If user is <c>null</c>.</exception> - /// <exception cref="ArgumentException">If the provided user doesn't exist.</exception> - void UpdateUser(User user); - /// <summary> /// Updates the user. /// </summary> @@ -110,17 +103,24 @@ namespace MediaBrowser.Controller.Library /// </summary> /// <param name="user">The user.</param> /// <returns>Task.</returns> - void ResetEasyPassword(User user); + Task ResetEasyPassword(User user); /// <summary> /// Changes the password. /// </summary> + /// <param name="user">The user.</param> + /// <param name="newPassword">New password to use.</param> + /// <returns>Awaitable task.</returns> Task ChangePassword(User user, string newPassword); /// <summary> /// Changes the easy password. /// </summary> - void ChangeEasyPassword(User user, string newPassword, string newPasswordSha1); + /// <param name="user">The user.</param> + /// <param name="newPassword">New password to use.</param> + /// <param name="newPasswordSha1">Hash of new password.</param> + /// <returns>Task.</returns> + Task ChangeEasyPassword(User user, string newPassword, string newPasswordSha1); /// <summary> /// Gets the user dto. @@ -133,6 +133,12 @@ namespace MediaBrowser.Controller.Library /// <summary> /// Authenticates the user. /// </summary> + /// <param name="username">The user.</param> + /// <param name="password">The password to use.</param> + /// <param name="passwordSha1">Hash of password.</param> + /// <param name="remoteEndPoint">Remove endpoint to use.</param> + /// <param name="isUserSession">Specifies if a user session.</param> + /// <returns>User wrapped in awaitable task.</returns> Task<User> AuthenticateUser(string username, string password, string passwordSha1, string remoteEndPoint, bool isUserSession); /// <summary> @@ -157,7 +163,7 @@ namespace MediaBrowser.Controller.Library /// <summary> /// This method updates the user's configuration. /// This is only included as a stopgap until the new API, using this internally is not recommended. - /// Instead, modify the user object directly, then call <see cref="UpdateUser"/>. + /// Instead, modify the user object directly, then call <see cref="UpdateUserAsync"/>. /// </summary> /// <param name="userId">The user's Id.</param> /// <param name="config">The request containing the new user configuration.</param> @@ -167,7 +173,7 @@ namespace MediaBrowser.Controller.Library /// <summary> /// This method updates the user's policy. /// This is only included as a stopgap until the new API, using this internally is not recommended. - /// Instead, modify the user object directly, then call <see cref="UpdateUser"/>. + /// Instead, modify the user object directly, then call <see cref="UpdateUserAsync"/>. /// </summary> /// <param name="userId">The user's Id.</param> /// <param name="policy">The request containing the new user policy.</param> diff --git a/MediaBrowser.Controller/Library/IUserViewManager.cs b/MediaBrowser.Controller/Library/IUserViewManager.cs index 46004e42f7..055627d3e3 100644 --- a/MediaBrowser.Controller/Library/IUserViewManager.cs +++ b/MediaBrowser.Controller/Library/IUserViewManager.cs @@ -1,6 +1,6 @@ #nullable disable -#pragma warning disable CS1591 +#pragma warning disable CA1002, CS1591 using System; using System.Collections.Generic; @@ -13,10 +13,29 @@ namespace MediaBrowser.Controller.Library { public interface IUserViewManager { + /// <summary> + /// Gets user views. + /// </summary> + /// <param name="query">Query to use.</param> + /// <returns>Set of folders.</returns> Folder[] GetUserViews(UserViewQuery query); + /// <summary> + /// Gets user sub views. + /// </summary> + /// <param name="parentId">Parent to use.</param> + /// <param name="type">Type to use.</param> + /// <param name="localizationKey">Localization key to use.</param> + /// <param name="sortName">Sort to use.</param> + /// <returns>User view.</returns> UserView GetUserSubView(Guid parentId, string type, string localizationKey, string sortName); + /// <summary> + /// Gets latest items. + /// </summary> + /// <param name="request">Query to use.</param> + /// <param name="options">Options to use.</param> + /// <returns>Set of items.</returns> List<Tuple<BaseItem, List<BaseItem>>> GetLatestItems(LatestItemsQuery request, DtoOptions options); } } diff --git a/MediaBrowser.Controller/Library/ItemChangeEventArgs.cs b/MediaBrowser.Controller/Library/ItemChangeEventArgs.cs index a37dc7af11..3586dc69d6 100644 --- a/MediaBrowser.Controller/Library/ItemChangeEventArgs.cs +++ b/MediaBrowser.Controller/Library/ItemChangeEventArgs.cs @@ -1,6 +1,6 @@ #nullable disable -#pragma warning disable CS1591 +#pragma warning disable CA1711, CS1591 using MediaBrowser.Controller.Entities; diff --git a/MediaBrowser.Controller/Library/ItemResolveArgs.cs b/MediaBrowser.Controller/Library/ItemResolveArgs.cs index 521e372742..bfc1e4857f 100644 --- a/MediaBrowser.Controller/Library/ItemResolveArgs.cs +++ b/MediaBrowser.Controller/Library/ItemResolveArgs.cs @@ -1,6 +1,6 @@ #nullable disable -#pragma warning disable CS1591 +#pragma warning disable CA1721, CA1819, CS1591 using System; using System.Collections.Generic; @@ -109,6 +109,21 @@ namespace MediaBrowser.Controller.Library /// <value>The additional locations.</value> private List<string> AdditionalLocations { get; set; } + /// <summary> + /// Gets the physical locations. + /// </summary> + /// <value>The physical locations.</value> + public string[] PhysicalLocations + { + get + { + var paths = string.IsNullOrEmpty(Path) ? Array.Empty<string>() : new[] { Path }; + return AdditionalLocations == null ? paths : paths.Concat(AdditionalLocations).ToArray(); + } + } + + public string CollectionType { get; set; } + public bool HasParent<T>() where T : Folder { @@ -138,6 +153,16 @@ namespace MediaBrowser.Controller.Library return false; } + /// <summary> + /// Determines whether the specified <see cref="object" /> is equal to this instance. + /// </summary> + /// <param name="obj">The object to compare with the current object.</param> + /// <returns><c>true</c> if the specified <see cref="object" /> is equal to this instance; otherwise, <c>false</c>.</returns> + public override bool Equals(object obj) + { + return Equals(obj as ItemResolveArgs); + } + /// <summary> /// Adds the additional location. /// </summary> @@ -156,19 +181,6 @@ namespace MediaBrowser.Controller.Library // REVIEW: @bond - /// <summary> - /// Gets the physical locations. - /// </summary> - /// <value>The physical locations.</value> - public string[] PhysicalLocations - { - get - { - var paths = string.IsNullOrEmpty(Path) ? Array.Empty<string>() : new[] { Path }; - return AdditionalLocations == null ? paths : paths.Concat(AdditionalLocations).ToArray(); - } - } - /// <summary> /// Gets the name of the file system entry by. /// </summary> @@ -190,7 +202,7 @@ namespace MediaBrowser.Controller.Library /// </summary> /// <param name="path">The path.</param> /// <returns>FileSystemInfo.</returns> - /// <exception cref="ArgumentNullException"></exception> + /// <exception cref="ArgumentNullException">Throws if path is invalid.</exception> public FileSystemMetadata GetFileSystemEntryByPath(string path) { if (string.IsNullOrEmpty(path)) @@ -224,18 +236,6 @@ namespace MediaBrowser.Controller.Library return CollectionType; } - public string CollectionType { get; set; } - - /// <summary> - /// Determines whether the specified <see cref="object" /> is equal to this instance. - /// </summary> - /// <param name="obj">The object to compare with the current object.</param> - /// <returns><c>true</c> if the specified <see cref="object" /> is equal to this instance; otherwise, <c>false</c>.</returns> - public override bool Equals(object obj) - { - return Equals(obj as ItemResolveArgs); - } - /// <summary> /// Returns a hash code for this instance. /// </summary> diff --git a/MediaBrowser.Controller/Library/NameExtensions.cs b/MediaBrowser.Controller/Library/NameExtensions.cs index a49dcacc1d..d2ed3465a8 100644 --- a/MediaBrowser.Controller/Library/NameExtensions.cs +++ b/MediaBrowser.Controller/Library/NameExtensions.cs @@ -4,7 +4,6 @@ using System; using System.Collections.Generic; using System.Linq; using Diacritics.Extensions; -using MediaBrowser.Controller.Extensions; namespace MediaBrowser.Controller.Library { diff --git a/MediaBrowser.Controller/Library/PlaybackProgressEventArgs.cs b/MediaBrowser.Controller/Library/PlaybackProgressEventArgs.cs index 609336ec4d..76e9eb1f54 100644 --- a/MediaBrowser.Controller/Library/PlaybackProgressEventArgs.cs +++ b/MediaBrowser.Controller/Library/PlaybackProgressEventArgs.cs @@ -1,6 +1,6 @@ #nullable disable -#pragma warning disable CS1591 +#pragma warning disable CA1002, CA2227, CS1591 using System; using System.Collections.Generic; diff --git a/MediaBrowser.Controller/Library/UserDataSaveEventArgs.cs b/MediaBrowser.Controller/Library/UserDataSaveEventArgs.cs index bfe433c971..4d90346f29 100644 --- a/MediaBrowser.Controller/Library/UserDataSaveEventArgs.cs +++ b/MediaBrowser.Controller/Library/UserDataSaveEventArgs.cs @@ -1,6 +1,6 @@ #nullable disable -#pragma warning disable CS1591 +#pragma warning disable CA1002, CA2227, CS1591 using System; using System.Collections.Generic; diff --git a/MediaBrowser.Controller/LiveTv/ILiveTvManager.cs b/MediaBrowser.Controller/LiveTv/ILiveTvManager.cs index f4dc18e11c..dbd18165db 100644 --- a/MediaBrowser.Controller/LiveTv/ILiveTvManager.cs +++ b/MediaBrowser.Controller/LiveTv/ILiveTvManager.cs @@ -22,12 +22,22 @@ namespace MediaBrowser.Controller.LiveTv /// </summary> public interface ILiveTvManager { + event EventHandler<GenericEventArgs<TimerEventInfo>> SeriesTimerCancelled; + + event EventHandler<GenericEventArgs<TimerEventInfo>> TimerCancelled; + + event EventHandler<GenericEventArgs<TimerEventInfo>> TimerCreated; + + event EventHandler<GenericEventArgs<TimerEventInfo>> SeriesTimerCreated; + /// <summary> /// Gets the services. /// </summary> /// <value>The services.</value> IReadOnlyList<ILiveTvService> Services { get; } + IListingsProvider[] ListingProviders { get; } + /// <summary> /// Gets the new timer defaults asynchronous. /// </summary> @@ -86,6 +96,7 @@ namespace MediaBrowser.Controller.LiveTv /// </summary> /// <param name="query">The query.</param> /// <param name="options">The options.</param> + /// <returns>A recording.</returns> QueryResult<BaseItemDto> GetRecordings(RecordingQuery query, DtoOptions options); /// <summary> @@ -176,11 +187,16 @@ namespace MediaBrowser.Controller.LiveTv /// <param name="query">The query.</param> /// <param name="options">The options.</param> /// <param name="cancellationToken">The cancellation token.</param> + /// <returns>Recommended programs.</returns> QueryResult<BaseItemDto> GetRecommendedPrograms(InternalItemsQuery query, DtoOptions options, CancellationToken cancellationToken); /// <summary> /// Gets the recommended programs internal. /// </summary> + /// <param name="query">The query.</param> + /// <param name="options">The options.</param> + /// <param name="cancellationToken">The cancellation token.</param> + /// <returns>Recommended programs.</returns> QueryResult<BaseItem> GetRecommendedProgramsInternal(InternalItemsQuery query, DtoOptions options, CancellationToken cancellationToken); /// <summary> @@ -202,6 +218,7 @@ namespace MediaBrowser.Controller.LiveTv /// Gets the live tv folder. /// </summary> /// <param name="cancellationToken">The cancellation token.</param> + /// <returns>Live TV folder.</returns> Folder GetInternalLiveTvFolder(CancellationToken cancellationToken); /// <summary> @@ -213,11 +230,18 @@ namespace MediaBrowser.Controller.LiveTv /// <summary> /// Gets the internal channels. /// </summary> + /// <param name="query">The query.</param> + /// <param name="dtoOptions">The options.</param> + /// <param name="cancellationToken">The cancellation token.</param> + /// <returns>Internal channels.</returns> QueryResult<BaseItem> GetInternalChannels(LiveTvChannelQuery query, DtoOptions dtoOptions, CancellationToken cancellationToken); /// <summary> /// Gets the channel media sources. /// </summary> + /// <param name="item">Item to search for.</param> + /// <param name="cancellationToken">CancellationToken to use for operation.</param> + /// <returns>Channel media sources wrapped in a task.</returns> Task<IEnumerable<MediaSourceInfo>> GetChannelMediaSources(BaseItem item, CancellationToken cancellationToken); /// <summary> @@ -232,6 +256,9 @@ namespace MediaBrowser.Controller.LiveTv /// <summary> /// Saves the tuner host. /// </summary> + /// <param name="info">Turner host to save.</param> + /// <param name="dataSourceChanged">Option to specify that data source has changed.</param> + /// <returns>Tuner host information wrapped in a task.</returns> Task<TunerHostInfo> SaveTunerHost(TunerHostInfo info, bool dataSourceChanged = true); /// <summary> @@ -247,7 +274,7 @@ namespace MediaBrowser.Controller.LiveTv Task<TunerChannelMapping> SetChannelMapping(string providerId, string tunerChannelNumber, string providerChannelNumber); - TunerChannelMapping GetTunerChannelMapping(ChannelInfo channel, NameValuePair[] mappings, List<ChannelInfo> providerChannels); + TunerChannelMapping GetTunerChannelMapping(ChannelInfo tunerChannel, NameValuePair[] mappings, List<ChannelInfo> providerChannels); /// <summary> /// Gets the lineups. @@ -271,20 +298,10 @@ namespace MediaBrowser.Controller.LiveTv Task<List<ChannelInfo>> GetChannelsFromListingsProviderData(string id, CancellationToken cancellationToken); - IListingsProvider[] ListingProviders { get; } - List<NameIdPair> GetTunerHostTypes(); Task<List<TunerHostInfo>> DiscoverTuners(bool newDevicesOnly, CancellationToken cancellationToken); - event EventHandler<GenericEventArgs<TimerEventInfo>> SeriesTimerCancelled; - - event EventHandler<GenericEventArgs<TimerEventInfo>> TimerCancelled; - - event EventHandler<GenericEventArgs<TimerEventInfo>> TimerCreated; - - event EventHandler<GenericEventArgs<TimerEventInfo>> SeriesTimerCreated; - string GetEmbyTvActiveRecordingPath(string id); ActiveRecordingInfo GetActiveRecordingInfo(string path); diff --git a/MediaBrowser.Controller/LiveTv/ILiveTvService.cs b/MediaBrowser.Controller/LiveTv/ILiveTvService.cs index 897f263f3d..ce34954e3f 100644 --- a/MediaBrowser.Controller/LiveTv/ILiveTvService.cs +++ b/MediaBrowser.Controller/LiveTv/ILiveTvService.cs @@ -70,10 +70,10 @@ namespace MediaBrowser.Controller.LiveTv /// <summary> /// Updates the timer asynchronous. /// </summary> - /// <param name="info">The information.</param> + /// <param name="updatedTimer">The updated timer information.</param> /// <param name="cancellationToken">The cancellation token.</param> /// <returns>Task.</returns> - Task UpdateTimerAsync(TimerInfo info, CancellationToken cancellationToken); + Task UpdateTimerAsync(TimerInfo updatedTimer, CancellationToken cancellationToken); /// <summary> /// Updates the series timer asynchronous. diff --git a/MediaBrowser.Controller/LiveTv/ITunerHost.cs b/MediaBrowser.Controller/LiveTv/ITunerHost.cs index 7dced9f5ef..24820abb90 100644 --- a/MediaBrowser.Controller/LiveTv/ITunerHost.cs +++ b/MediaBrowser.Controller/LiveTv/ITunerHost.cs @@ -30,6 +30,8 @@ namespace MediaBrowser.Controller.LiveTv /// <summary> /// Gets the channels. /// </summary> + /// <param name="enableCache">Option to enable using cache.</param> + /// <param name="cancellationToken">The CancellationToken for this operation.</param> /// <returns>Task<IEnumerable<ChannelInfo>>.</returns> Task<List<ChannelInfo>> GetChannels(bool enableCache, CancellationToken cancellationToken); @@ -47,6 +49,7 @@ namespace MediaBrowser.Controller.LiveTv /// <param name="streamId">The stream identifier.</param> /// <param name="currentLiveStreams">The current live streams.</param> /// <param name="cancellationToken">The cancellation token to cancel operation.</param> + /// <returns>Live stream wrapped in a task.</returns> Task<ILiveStream> GetChannelStream(string channelId, string streamId, List<ILiveStream> currentLiveStreams, CancellationToken cancellationToken); /// <summary> diff --git a/MediaBrowser.Controller/LiveTv/LiveTvChannel.cs b/MediaBrowser.Controller/LiveTv/LiveTvChannel.cs index 1a893fc2d0..074e023e8d 100644 --- a/MediaBrowser.Controller/LiveTv/LiveTvChannel.cs +++ b/MediaBrowser.Controller/LiveTv/LiveTvChannel.cs @@ -18,23 +18,6 @@ namespace MediaBrowser.Controller.LiveTv { public class LiveTvChannel : BaseItem, IHasMediaSources, IHasProgramAttributes { - public override List<string> GetUserDataKeys() - { - var list = base.GetUserDataKeys(); - - if (!ConfigurationManager.Configuration.DisableLiveTvChannelUserDataName) - { - list.Insert(0, GetClientTypeName() + "-" + Name); - } - - return list; - } - - public override UnratedItem GetBlockUnratedType() - { - return UnratedItem.LiveTvChannel; - } - [JsonIgnore] public override bool SupportsPositionTicksResume => false; @@ -59,6 +42,67 @@ namespace MediaBrowser.Controller.LiveTv [JsonIgnore] public override LocationType LocationType => LocationType.Remote; + [JsonIgnore] + public override string MediaType => ChannelType == ChannelType.Radio ? Model.Entities.MediaType.Audio : Model.Entities.MediaType.Video; + + [JsonIgnore] + public bool IsMovie { get; set; } + + /// <summary> + /// Gets or sets a value indicating whether this instance is sports. + /// </summary> + /// <value><c>true</c> if this instance is sports; otherwise, <c>false</c>.</value> + [JsonIgnore] + public bool IsSports { get; set; } + + /// <summary> + /// Gets or sets a value indicating whether this instance is series. + /// </summary> + /// <value><c>true</c> if this instance is series; otherwise, <c>false</c>.</value> + [JsonIgnore] + public bool IsSeries { get; set; } + + /// <summary> + /// Gets or sets a value indicating whether this instance is news. + /// </summary> + /// <value><c>true</c> if this instance is news; otherwise, <c>false</c>.</value> + [JsonIgnore] + public bool IsNews { get; set; } + + /// <summary> + /// Gets a value indicating whether this instance is kids. + /// </summary> + /// <value><c>true</c> if this instance is kids; otherwise, <c>false</c>.</value> + [JsonIgnore] + public bool IsKids => Tags.Contains("Kids", StringComparer.OrdinalIgnoreCase); + + [JsonIgnore] + public bool IsRepeat { get; set; } + + /// <summary> + /// Gets or sets the episode title. + /// </summary> + /// <value>The episode title.</value> + [JsonIgnore] + public string EpisodeTitle { get; set; } + + public override List<string> GetUserDataKeys() + { + var list = base.GetUserDataKeys(); + + if (!ConfigurationManager.Configuration.DisableLiveTvChannelUserDataName) + { + list.Insert(0, GetClientTypeName() + "-" + Name); + } + + return list; + } + + public override UnratedItem GetBlockUnratedType() + { + return UnratedItem.LiveTvChannel; + } + protected override string CreateSortName() { if (!string.IsNullOrEmpty(Number)) @@ -74,15 +118,12 @@ namespace MediaBrowser.Controller.LiveTv return (Number ?? string.Empty) + "-" + (Name ?? string.Empty); } - [JsonIgnore] - public override string MediaType => ChannelType == ChannelType.Radio ? Model.Entities.MediaType.Audio : Model.Entities.MediaType.Video; - public override string GetClientTypeName() { return "TvChannel"; } - public IEnumerable<BaseItem> GetTaggedItems(IEnumerable<BaseItem> inputItems) + public IEnumerable<BaseItem> GetTaggedItems() { return new List<BaseItem>(); } @@ -122,46 +163,5 @@ namespace MediaBrowser.Controller.LiveTv { return false; } - - [JsonIgnore] - public bool IsMovie { get; set; } - - /// <summary> - /// Gets or sets a value indicating whether this instance is sports. - /// </summary> - /// <value><c>true</c> if this instance is sports; otherwise, <c>false</c>.</value> - [JsonIgnore] - public bool IsSports { get; set; } - - /// <summary> - /// Gets or sets a value indicating whether this instance is series. - /// </summary> - /// <value><c>true</c> if this instance is series; otherwise, <c>false</c>.</value> - [JsonIgnore] - public bool IsSeries { get; set; } - - /// <summary> - /// Gets or sets a value indicating whether this instance is news. - /// </summary> - /// <value><c>true</c> if this instance is news; otherwise, <c>false</c>.</value> - [JsonIgnore] - public bool IsNews { get; set; } - - /// <summary> - /// Gets a value indicating whether this instance is kids. - /// </summary> - /// <value><c>true</c> if this instance is kids; otherwise, <c>false</c>.</value> - [JsonIgnore] - public bool IsKids => Tags.Contains("Kids", StringComparer.OrdinalIgnoreCase); - - [JsonIgnore] - public bool IsRepeat { get; set; } - - /// <summary> - /// Gets or sets the episode title. - /// </summary> - /// <value>The episode title.</value> - [JsonIgnore] - public string EpisodeTitle { get; set; } } } diff --git a/MediaBrowser.Controller/LiveTv/LiveTvProgram.cs b/MediaBrowser.Controller/LiveTv/LiveTvProgram.cs index 9d638a0bf6..111dc0d275 100644 --- a/MediaBrowser.Controller/LiveTv/LiveTvProgram.cs +++ b/MediaBrowser.Controller/LiveTv/LiveTvProgram.cs @@ -1,6 +1,6 @@ #nullable disable -#pragma warning disable CS1591 +#pragma warning disable CS1591, SA1306 using System; using System.Collections.Generic; @@ -19,54 +19,14 @@ namespace MediaBrowser.Controller.LiveTv { public class LiveTvProgram : BaseItem, IHasLookupInfo<ItemLookupInfo>, IHasStartDate, IHasProgramAttributes { + private static string EmbyServiceName = "Emby"; + public LiveTvProgram() { IsVirtualItem = true; } - public override List<string> GetUserDataKeys() - { - var list = base.GetUserDataKeys(); - - if (!IsSeries) - { - var key = this.GetProviderId(MetadataProvider.Imdb); - if (!string.IsNullOrEmpty(key)) - { - list.Insert(0, key); - } - - key = this.GetProviderId(MetadataProvider.Tmdb); - if (!string.IsNullOrEmpty(key)) - { - list.Insert(0, key); - } - } - else if (!string.IsNullOrEmpty(EpisodeTitle)) - { - var name = GetClientTypeName(); - - list.Insert(0, name + "-" + Name + (EpisodeTitle ?? string.Empty)); - } - - return list; - } - - private static string EmbyServiceName = "Emby"; - - public override double GetDefaultPrimaryImageAspectRatio() - { - var serviceName = ServiceName; - - if (string.Equals(serviceName, EmbyServiceName, StringComparison.OrdinalIgnoreCase) || string.Equals(serviceName, "Next Pvr", StringComparison.OrdinalIgnoreCase)) - { - return 2.0 / 3; - } - else - { - return 16.0 / 9; - } - } + public string SeriesName { get; set; } [JsonIgnore] public override SourceType SourceType => SourceType.LiveTV; @@ -182,6 +142,66 @@ namespace MediaBrowser.Controller.LiveTv } } + [JsonIgnore] + public override bool SupportsPeople + { + get + { + // Optimization + if (IsNews || IsSports) + { + return false; + } + + return base.SupportsPeople; + } + } + + [JsonIgnore] + public override bool SupportsAncestors => false; + + public override List<string> GetUserDataKeys() + { + var list = base.GetUserDataKeys(); + + if (!IsSeries) + { + var key = this.GetProviderId(MetadataProvider.Imdb); + if (!string.IsNullOrEmpty(key)) + { + list.Insert(0, key); + } + + key = this.GetProviderId(MetadataProvider.Tmdb); + if (!string.IsNullOrEmpty(key)) + { + list.Insert(0, key); + } + } + else if (!string.IsNullOrEmpty(EpisodeTitle)) + { + var name = GetClientTypeName(); + + list.Insert(0, name + "-" + Name + (EpisodeTitle ?? string.Empty)); + } + + return list; + } + + public override double GetDefaultPrimaryImageAspectRatio() + { + var serviceName = ServiceName; + + if (string.Equals(serviceName, EmbyServiceName, StringComparison.OrdinalIgnoreCase) || string.Equals(serviceName, "Next Pvr", StringComparison.OrdinalIgnoreCase)) + { + return 2.0 / 3; + } + else + { + return 16.0 / 9; + } + } + public override string GetClientTypeName() { return "Program"; @@ -202,24 +222,6 @@ namespace MediaBrowser.Controller.LiveTv return false; } - [JsonIgnore] - public override bool SupportsPeople - { - get - { - // Optimization - if (IsNews || IsSports) - { - return false; - } - - return base.SupportsPeople; - } - } - - [JsonIgnore] - public override bool SupportsAncestors => false; - private LiveTvOptions GetConfiguration() { return ConfigurationManager.GetConfiguration<LiveTvOptions>("livetv"); @@ -273,7 +275,5 @@ namespace MediaBrowser.Controller.LiveTv return list; } - - public string SeriesName { get; set; } } } diff --git a/MediaBrowser.Controller/MediaBrowser.Controller.csproj b/MediaBrowser.Controller/MediaBrowser.Controller.csproj index 4bed112e43..d378808658 100644 --- a/MediaBrowser.Controller/MediaBrowser.Controller.csproj +++ b/MediaBrowser.Controller/MediaBrowser.Controller.csproj @@ -14,11 +14,11 @@ </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="Diacritics" Version="3.3.4" /> + <PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="6.0.0-rc.2*" /> + <PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="6.0.0-rc.2*" /> <PackageReference Include="Microsoft.SourceLink.GitHub" Version="1.0.0" PrivateAssets="All" /> - <PackageReference Include="System.Threading.Tasks.Dataflow" Version="5.0.0" /> + <PackageReference Include="System.Threading.Tasks.Dataflow" Version="6.0.0-rc.2*" /> </ItemGroup> <ItemGroup> @@ -32,13 +32,9 @@ </ItemGroup> <PropertyGroup> - <TargetFramework>net5.0</TargetFramework> + <TargetFramework>net6.0</TargetFramework> <GenerateAssemblyInfo>false</GenerateAssemblyInfo> <GenerateDocumentationFile>true</GenerateDocumentationFile> - <TreatWarningsAsErrors Condition=" '$(Configuration)' == 'Release' ">true</TreatWarningsAsErrors> - <Nullable>enable</Nullable> - <AnalysisMode Condition=" '$(Configuration)' == 'Debug' ">AllEnabledByDefault</AnalysisMode> - <CodeAnalysisRuleSet>../jellyfin.ruleset</CodeAnalysisRuleSet> <PublishRepositoryUrl>true</PublishRepositoryUrl> <EmbedUntrackedSources>true</EmbedUntrackedSources> <IncludeSymbols>true</IncludeSymbols> diff --git a/MediaBrowser.Controller/MediaEncoding/BaseEncodingJobOptions.cs b/MediaBrowser.Controller/MediaEncoding/BaseEncodingJobOptions.cs index 745ee6bdb5..dd6f468dab 100644 --- a/MediaBrowser.Controller/MediaEncoding/BaseEncodingJobOptions.cs +++ b/MediaBrowser.Controller/MediaEncoding/BaseEncodingJobOptions.cs @@ -10,6 +10,15 @@ namespace MediaBrowser.Controller.MediaEncoding { public class BaseEncodingJobOptions { + public BaseEncodingJobOptions() + { + EnableAutoStreamCopy = true; + AllowVideoStreamCopy = true; + AllowAudioStreamCopy = true; + Context = EncodingContext.Streaming; + StreamOptions = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase); + } + /// <summary> /// Gets or sets the id. /// </summary> @@ -191,14 +200,5 @@ namespace MediaBrowser.Controller.MediaEncoding return null; } - - public BaseEncodingJobOptions() - { - EnableAutoStreamCopy = true; - AllowVideoStreamCopy = true; - AllowAudioStreamCopy = true; - Context = EncodingContext.Streaming; - StreamOptions = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase); - } } } \ No newline at end of file diff --git a/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs b/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs index 26b0bc3def..5715194b85 100644 --- a/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs +++ b/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs @@ -7,7 +7,6 @@ using System.Collections.Generic; using System.Globalization; using System.IO; using System.Linq; -using System.Runtime.InteropServices; using System.Text; using System.Text.RegularExpressions; using System.Threading; @@ -16,16 +15,12 @@ using MediaBrowser.Model.Configuration; using MediaBrowser.Model.Dlna; using MediaBrowser.Model.Dto; using MediaBrowser.Model.Entities; -using MediaBrowser.Model.IO; using MediaBrowser.Model.MediaInfo; -using Microsoft.Extensions.Configuration; namespace MediaBrowser.Controller.MediaEncoding { public class EncodingHelper { - private static readonly CultureInfo _usCulture = new CultureInfo("en-US"); - private readonly IMediaEncoder _mediaEncoder; private readonly ISubtitleEncoder _subtitleEncoder; @@ -40,6 +35,8 @@ namespace MediaBrowser.Controller.MediaEncoding "ConstrainedHigh" }; + private static readonly Version _minVersionForCudaOverlay = new Version(4, 4); + public EncodingHelper( IMediaEncoder mediaEncoder, ISubtitleEncoder subtitleEncoder) @@ -109,17 +106,41 @@ namespace MediaBrowser.Controller.MediaEncoding private bool IsCudaSupported() { return _mediaEncoder.SupportsHwaccel("cuda") - && _mediaEncoder.SupportsFilter("scale_cuda", null) - && _mediaEncoder.SupportsFilter("yadif_cuda", null); + && _mediaEncoder.SupportsFilter("scale_cuda") + && _mediaEncoder.SupportsFilter("yadif_cuda") + && _mediaEncoder.SupportsFilter("hwupload_cuda"); } - private bool IsTonemappingSupported(EncodingJobInfo state, EncodingOptions options) + private bool IsOpenclTonemappingSupported(EncodingJobInfo state, EncodingOptions options) { var videoStream = state.VideoStream; - return IsColorDepth10(state) + if (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") - && options.EnableTonemapping - && string.Equals(videoStream.VideoRange, "HDR", StringComparison.OrdinalIgnoreCase); + && _mediaEncoder.SupportsFilter("tonemap_opencl"); + } + + private bool IsCudaTonemappingSupported(EncodingJobInfo state, EncodingOptions options) + { + var videoStream = state.VideoStream; + if (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) @@ -135,24 +156,25 @@ namespace MediaBrowser.Controller.MediaEncoding if (string.Equals(options.HardwareAccelerationType, "vaapi", StringComparison.OrdinalIgnoreCase)) { // Limited to HEVC for now since the filter doesn't accept master data from VP9. - return IsColorDepth10(state) + return options.EnableVppTonemapping + && string.Equals(videoStream.ColorTransfer, "smpte2084", StringComparison.OrdinalIgnoreCase) + && IsColorDepth10(state) && string.Equals(codec, "hevc", StringComparison.OrdinalIgnoreCase) && _mediaEncoder.SupportsHwaccel("vaapi") - && options.EnableVppTonemapping - && string.Equals(videoStream.ColorTransfer, "smpte2084", StringComparison.OrdinalIgnoreCase); + && _mediaEncoder.SupportsFilter("tonemap_vaapi"); } // Hybrid VPP tonemapping for QSV with VAAPI - var isLinux = RuntimeInformation.IsOSPlatform(OSPlatform.Linux); - if (isLinux && string.Equals(options.HardwareAccelerationType, "qsv", StringComparison.OrdinalIgnoreCase)) + if (OperatingSystem.IsLinux() && string.Equals(options.HardwareAccelerationType, "qsv", StringComparison.OrdinalIgnoreCase)) { // Limited to HEVC for now since the filter doesn't accept master data from VP9. - return IsColorDepth10(state) + return options.EnableVppTonemapping + && string.Equals(videoStream.ColorTransfer, "smpte2084", StringComparison.OrdinalIgnoreCase) + && IsColorDepth10(state) && string.Equals(codec, "hevc", StringComparison.OrdinalIgnoreCase) && _mediaEncoder.SupportsHwaccel("vaapi") - && _mediaEncoder.SupportsHwaccel("qsv") - && options.EnableVppTonemapping - && string.Equals(videoStream.ColorTransfer, "smpte2084", StringComparison.OrdinalIgnoreCase); + && _mediaEncoder.SupportsFilter("tonemap_vaapi") + && _mediaEncoder.SupportsHwaccel("qsv"); } // Native VPP tonemapping may come to QSV in the future. @@ -162,6 +184,9 @@ namespace MediaBrowser.Controller.MediaEncoding /// <summary> /// Gets the name of the output video codec. /// </summary> + /// <param name="state">Encording state.</param> + /// <param name="encodingOptions">Encoding options.</param> + /// <returns>Encoder string.</returns> public string GetVideoEncoder(EncodingJobInfo state, EncodingOptions encodingOptions) { var codec = state.OutputVideoCodec; @@ -179,11 +204,17 @@ namespace MediaBrowser.Controller.MediaEncoding return GetH264Encoder(state, encodingOptions); } - if (string.Equals(codec, "vpx", StringComparison.OrdinalIgnoreCase)) + if (string.Equals(codec, "vp8", StringComparison.OrdinalIgnoreCase) + || string.Equals(codec, "vpx", StringComparison.OrdinalIgnoreCase)) { return "libvpx"; } + if (string.Equals(codec, "vp9", StringComparison.OrdinalIgnoreCase)) + { + return "libvpx-vp9"; + } + if (string.Equals(codec, "wmv", StringComparison.OrdinalIgnoreCase)) { return "wmv2"; @@ -316,6 +347,11 @@ namespace MediaBrowser.Controller.MediaEncoding return container; } + /// <summary> + /// Gets decoder from a codec. + /// </summary> + /// <param name="codec">Codec to use.</param> + /// <returns>Decoder string.</returns> public string GetDecoderFromCodec(string codec) { // For these need to find out the ffmpeg names @@ -345,6 +381,8 @@ namespace MediaBrowser.Controller.MediaEncoding /// <summary> /// Infers the audio codec based on the url. /// </summary> + /// <param name="container">Container to use.</param> + /// <returns>Codec string.</returns> public string InferAudioCodec(string container) { var ext = "." + (container ?? string.Empty); @@ -408,7 +446,8 @@ namespace MediaBrowser.Controller.MediaEncoding if (string.Equals(ext, ".webm", StringComparison.OrdinalIgnoreCase)) { - return "vpx"; + // TODO: this may not always mean VP8, as the codec ages + return "vp8"; } if (string.Equals(ext, ".ogg", StringComparison.OrdinalIgnoreCase) || string.Equals(ext, ".ogv", StringComparison.OrdinalIgnoreCase)) @@ -490,12 +529,20 @@ namespace MediaBrowser.Controller.MediaEncoding /// <summary> /// Gets the input argument. /// </summary> - public string GetInputArgument(EncodingJobInfo state, EncodingOptions encodingOptions) + /// <param name="state">Encoding state.</param> + /// <param name="options">Encoding options.</param> + /// <returns>Input arguments.</returns> + public string GetInputArgument(EncodingJobInfo state, EncodingOptions options) { var arg = new StringBuilder(); - var videoDecoder = GetHardwareAcceleratedVideoDecoder(state, encodingOptions) ?? string.Empty; - var outputVideoCodec = GetVideoEncoder(state, encodingOptions) ?? string.Empty; + 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; @@ -503,42 +550,40 @@ namespace MediaBrowser.Controller.MediaEncoding var isQsvEncoder = outputVideoCodec.IndexOf("qsv", StringComparison.OrdinalIgnoreCase) != -1; var isNvdecDecoder = videoDecoder.Contains("cuda", StringComparison.OrdinalIgnoreCase); var isCuvidHevcDecoder = videoDecoder.Contains("hevc_cuvid", StringComparison.OrdinalIgnoreCase); - var isWindows = RuntimeInformation.IsOSPlatform(OSPlatform.Windows); - var isLinux = RuntimeInformation.IsOSPlatform(OSPlatform.Linux); - var isMacOS = RuntimeInformation.IsOSPlatform(OSPlatform.OSX); - var isTonemappingSupported = IsTonemappingSupported(state, encodingOptions); - var isVppTonemappingSupported = IsVppTonemappingSupported(state, encodingOptions); + var isCuvidVp9Decoder = videoDecoder.Contains("vp9_cuvid", StringComparison.OrdinalIgnoreCase); + var isOpenclTonemappingSupported = IsOpenclTonemappingSupported(state, options); + var isVppTonemappingSupported = IsVppTonemappingSupported(state, options); + var isCudaTonemappingSupported = IsCudaTonemappingSupported(state, options); if (!IsCopyCodec(outputVideoCodec)) { if (state.IsVideoRequest && _mediaEncoder.SupportsHwaccel("vaapi") - && string.Equals(encodingOptions.HardwareAccelerationType, "vaapi", StringComparison.OrdinalIgnoreCase)) + && string.Equals(options.HardwareAccelerationType, "vaapi", StringComparison.OrdinalIgnoreCase)) { if (isVaapiDecoder) { - if (isTonemappingSupported && !isVppTonemappingSupported) + if (isOpenclTonemappingSupported && !isVppTonemappingSupported) { - arg.Append("-init_hw_device vaapi=va:") - .Append(encodingOptions.VaapiDevice) - .Append(' ') - .Append("-init_hw_device opencl=ocl@va ") - .Append("-hwaccel_device va ") - .Append("-hwaccel_output_format vaapi ") - .Append("-filter_hw_device ocl "); + 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(encodingOptions.VaapiDevice) + .Append(options.VaapiDevice) .Append(' '); } } else if (!isVaapiDecoder && isVaapiEncoder) { arg.Append("-vaapi_device ") - .Append(encodingOptions.VaapiDevice) + .Append(options.VaapiDevice) .Append(' '); } @@ -546,7 +591,7 @@ namespace MediaBrowser.Controller.MediaEncoding } if (state.IsVideoRequest - && string.Equals(encodingOptions.HardwareAccelerationType, "qsv", StringComparison.OrdinalIgnoreCase)) + && string.Equals(options.HardwareAccelerationType, "qsv", StringComparison.OrdinalIgnoreCase)) { var hasGraphicalSubs = state.SubtitleStream != null && !state.SubtitleStream.IsTextSubtitleStream && state.SubtitleDeliveryMethod == SubtitleDeliveryMethod.Encode; @@ -582,9 +627,8 @@ namespace MediaBrowser.Controller.MediaEncoding else if (isVaapiDecoder && isVppTonemappingSupported) { arg.Append("-init_hw_device vaapi=va:") - .Append(encodingOptions.VaapiDevice) - .Append(' ') - .Append("-init_hw_device qsv@va ") + .Append(options.VaapiDevice) + .Append(" -init_hw_device qsv@va ") .Append("-hwaccel_output_format vaapi "); } @@ -593,7 +637,7 @@ namespace MediaBrowser.Controller.MediaEncoding } if (state.IsVideoRequest - && string.Equals(encodingOptions.HardwareAccelerationType, "nvenc", StringComparison.OrdinalIgnoreCase) + && string.Equals(options.HardwareAccelerationType, "nvenc", StringComparison.OrdinalIgnoreCase) && isNvdecDecoder) { // Fix for 'No decoder surfaces left' error. https://trac.ffmpeg.org/ticket/7562 @@ -601,22 +645,31 @@ namespace MediaBrowser.Controller.MediaEncoding } if (state.IsVideoRequest - && ((string.Equals(encodingOptions.HardwareAccelerationType, "nvenc", StringComparison.OrdinalIgnoreCase) - && (isNvdecDecoder || isCuvidHevcDecoder || isSwDecoder)) - || (string.Equals(encodingOptions.HardwareAccelerationType, "amf", StringComparison.OrdinalIgnoreCase) - && (isD3d11vaDecoder || isSwDecoder)))) + && 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 (isTonemappingSupported) + if (isOpenclTonemappingSupported) { arg.Append("-init_hw_device opencl=ocl:") - .Append(encodingOptions.OpenclDevice) - .Append(' ') - .Append("-filter_hw_device ocl "); + .Append(options.OpenclDevice) + .Append(" -filter_hw_device ocl "); } } if (state.IsVideoRequest - && string.Equals(encodingOptions.HardwareAccelerationType, "videotoolbox", StringComparison.OrdinalIgnoreCase)) + && string.Equals(options.HardwareAccelerationType, "videotoolbox", StringComparison.OrdinalIgnoreCase)) { arg.Append("-hwaccel videotoolbox "); } @@ -726,54 +779,42 @@ namespace MediaBrowser.Controller.MediaEncoding public string GetVideoBitrateParam(EncodingJobInfo state, string videoCodec) { - var bitrate = state.OutputVideoBitrate; - - if (bitrate.HasValue) + if (state.OutputVideoBitrate == null) { - if (string.Equals(videoCodec, "libvpx", StringComparison.OrdinalIgnoreCase)) - { - // When crf is used with vpx, b:v becomes a max rate - // https://trac.ffmpeg.org/wiki/Encode/VP9 - return string.Format( - CultureInfo.InvariantCulture, - " -maxrate:v {0} -bufsize:v {1} -b:v {0}", - bitrate.Value, - bitrate.Value * 2); - } + return string.Empty; + } - if (string.Equals(videoCodec, "msmpeg4", StringComparison.OrdinalIgnoreCase)) - { - return string.Format( - CultureInfo.InvariantCulture, - " -b:v {0}", - bitrate.Value); - } + int bitrate = state.OutputVideoBitrate.Value; - if (string.Equals(videoCodec, "libx264", StringComparison.OrdinalIgnoreCase) || - string.Equals(videoCodec, "libx265", StringComparison.OrdinalIgnoreCase)) - { - // h264 - return string.Format( - CultureInfo.InvariantCulture, - " -maxrate {0} -bufsize {1}", - bitrate.Value, - bitrate.Value * 2); - } + // Currently use the same buffer size for all encoders + int bufsize = bitrate * 2; - // h264 - return string.Format( - CultureInfo.InvariantCulture, - " -b:v {0} -maxrate {0} -bufsize {1}", - bitrate.Value, - bitrate.Value * 2); + if (string.Equals(videoCodec, "libvpx", StringComparison.OrdinalIgnoreCase) + || string.Equals(videoCodec, "libvpx-vp9", StringComparison.OrdinalIgnoreCase)) + { + // When crf is used with vpx, b:v becomes a max rate + // https://trac.ffmpeg.org/wiki/Encode/VP8 + // https://trac.ffmpeg.org/wiki/Encode/VP9 + return FormattableString.Invariant($" -maxrate:v {bitrate} -bufsize:v {bufsize} -b:v {bitrate}"); } - return string.Empty; + if (string.Equals(videoCodec, "msmpeg4", StringComparison.OrdinalIgnoreCase)) + { + return FormattableString.Invariant($" -b:v {bitrate}"); + } + + if (string.Equals(videoCodec, "libx264", StringComparison.OrdinalIgnoreCase) + || string.Equals(videoCodec, "libx265", StringComparison.OrdinalIgnoreCase)) + { + return FormattableString.Invariant($" -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)) @@ -868,7 +909,7 @@ namespace MediaBrowser.Controller.MediaEncoding CultureInfo.InvariantCulture, "subtitles='{0}:si={1}'{2}", _mediaEncoder.EscapeSubtitleFilterPath(mediaPath), - state.InternalSubtitleStreamOffset.ToString(_usCulture), + state.InternalSubtitleStreamOffset.ToString(CultureInfo.InvariantCulture), // fallbackFontParam, setPtsParam); } @@ -966,6 +1007,11 @@ namespace MediaBrowser.Controller.MediaEncoding /// <summary> /// Gets the video bitrate to specify on the command line. /// </summary> + /// <param name="state">Encoding state.</param> + /// <param name="videoEncoder">Video encoder to use.</param> + /// <param name="encodingOptions">Encoding options.</param> + /// <param name="defaultPreset">Default present to use for encoding.</param> + /// <returns>Video bitrate.</returns> public string GetVideoQualityParam(EncodingJobInfo state, string videoEncoder, EncodingOptions encodingOptions, string defaultPreset) { var param = string.Empty; @@ -1146,7 +1192,7 @@ namespace MediaBrowser.Controller.MediaEncoding param += " -header_insertion_mode gop -gops_per_idr 1"; } } - else if (string.Equals(videoEncoder, "libvpx", StringComparison.OrdinalIgnoreCase)) // webm + else if (string.Equals(videoEncoder, "libvpx", StringComparison.OrdinalIgnoreCase)) // vp8 { // Values 0-3, 0 being highest quality but slower var profileScore = 0; @@ -1169,11 +1215,60 @@ 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); } + else if (string.Equals(videoEncoder, "libvpx-vp9", StringComparison.OrdinalIgnoreCase)) // vp9 + { + // When `-deadline` is set to `good` or `best`, `-cpu-used` ranges from 0-5. + // When `-deadline` is set to `realtime`, `-cpu-used` ranges from 0-15. + // Resources: + // * https://trac.ffmpeg.org/wiki/Encode/VP9 + // * https://superuser.com/questions/1586934 + // * https://developers.google.com/media/vp9 + param += encodingOptions.EncoderPreset switch + { + "veryslow" => " -deadline best -cpu-used 0", + "slower" => " -deadline best -cpu-used 2", + "slow" => " -deadline best -cpu-used 3", + "medium" => " -deadline good -cpu-used 0", + "fast" => " -deadline good -cpu-used 1", + "faster" => " -deadline good -cpu-used 2", + "veryfast" => " -deadline good -cpu-used 3", + "superfast" => " -deadline good -cpu-used 4", + "ultrafast" => " -deadline good -cpu-used 5", + _ => " -deadline good -cpu-used 1" + }; + + // TODO: until VP9 gets its own CRF setting, base CRF on H.265. + int h265Crf = encodingOptions.H265Crf; + int defaultVp9Crf = 31; + if (h265Crf >= 0 && h265Crf <= 51) + { + // This conversion factor is chosen to match the default CRF for H.265 to the + // recommended 1080p CRF from Google. The factor also maps the logarithmic CRF + // scale of x265 [0, 51] to that of VP9 [0, 63] relatively well. + + // Resources: + // * https://developers.google.com/media/vp9/settings/vod + const float H265ToVp9CrfConversionFactor = 1.12F; + + var vp9Crf = Convert.ToInt32(h265Crf * H265ToVp9CrfConversionFactor); + + // Encoder allows for CRF values in the range [0, 63]. + vp9Crf = Math.Clamp(vp9Crf, 0, 63); + + param += FormattableString.Invariant($" -crf {vp9Crf}"); + } + else + { + param += FormattableString.Invariant($" -crf {defaultVp9Crf}"); + } + + param += " -row-mt 1 -profile 1"; + } else if (string.Equals(videoEncoder, "mpeg4", StringComparison.OrdinalIgnoreCase)) { param += " -mbd rd -flags +mv4+aic -trellis 2 -cmp 2 -subcmp 2 -bf 2"; @@ -1192,7 +1287,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; @@ -1296,7 +1391,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); } @@ -1458,7 +1553,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) { @@ -1692,7 +1787,7 @@ namespace MediaBrowser.Controller.MediaEncoding return 128000; } - public string GetAudioFilterParam(EncodingJobInfo state, EncodingOptions encodingOptions, bool isHls) + public string GetAudioFilterParam(EncodingJobInfo state, EncodingOptions encodingOptions) { var channels = state.OutputAudioChannels; @@ -1706,7 +1801,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; @@ -1745,7 +1840,7 @@ namespace MediaBrowser.Controller.MediaEncoding var request = state.BaseRequest; - var inputChannels = audioStream?.Channels; + var inputChannels = audioStream.Channels; if (inputChannels <= 0) { @@ -1967,8 +2062,12 @@ namespace MediaBrowser.Controller.MediaEncoding } /// <summary> - /// Gets the graphical subtitle param. + /// Gets the graphical subtitle parameter. /// </summary> + /// <param name="state">Encoding state.</param> + /// <param name="options">Encoding options.</param> + /// <param name="outputVideoCodec">Video codec to use.</param> + /// <returns>Graphical subtitle parameter.</returns> public string GetGraphicalSubtitleParam( EncodingJobInfo state, EncodingOptions options, @@ -1983,7 +2082,7 @@ namespace MediaBrowser.Controller.MediaEncoding var videoSizeParam = string.Empty; var videoDecoder = GetHardwareAcceleratedVideoDecoder(state, options) ?? string.Empty; - var isLinux = RuntimeInformation.IsOSPlatform(OSPlatform.Linux); + var isLinux = OperatingSystem.IsLinux(); var isVaapiDecoder = videoDecoder.IndexOf("vaapi", StringComparison.OrdinalIgnoreCase) != -1; var isVaapiH264Encoder = outputVideoCodec.IndexOf("h264_vaapi", StringComparison.OrdinalIgnoreCase) != -1; @@ -1992,14 +2091,18 @@ namespace MediaBrowser.Controller.MediaEncoding var isQsvHevcEncoder = outputVideoCodec.Contains("hevc_qsv", StringComparison.OrdinalIgnoreCase); var isNvdecDecoder = videoDecoder.Contains("cuda", StringComparison.OrdinalIgnoreCase); var isNvencEncoder = outputVideoCodec.Contains("nvenc", StringComparison.OrdinalIgnoreCase); - var isTonemappingSupported = IsTonemappingSupported(state, options); - var isVppTonemappingSupported = IsVppTonemappingSupported(state, options); var isTonemappingSupportedOnVaapi = string.Equals(options.HardwareAccelerationType, "vaapi", StringComparison.OrdinalIgnoreCase) && isVaapiDecoder && (isVaapiH264Encoder || isVaapiHevcEncoder); var isTonemappingSupportedOnQsv = string.Equals(options.HardwareAccelerationType, "qsv", StringComparison.OrdinalIgnoreCase) && isVaapiDecoder && (isQsvH264Encoder || isQsvHevcEncoder); + 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 && isTonemappingSupported && !isVppTonemappingSupported) + if (isTonemappingSupportedOnVaapi && isOpenclTonemappingSupported && !isVppTonemappingSupported) { return GetOutputSizeParam(state, options, outputVideoCodec); } @@ -2009,8 +2112,8 @@ namespace MediaBrowser.Controller.MediaEncoding { // Adjust the size of graphical subtitles to fit the video stream. var videoStream = state.VideoStream; - var inputWidth = videoStream?.Width; - var inputHeight = videoStream?.Height; + 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) @@ -2025,13 +2128,22 @@ namespace MediaBrowser.Controller.MediaEncoding if (!string.IsNullOrEmpty(videoSizeParam) && !(isTonemappingSupportedOnQsv && isVppTonemappingSupported)) { - // For QSV, feed it into hardware encoder now + // 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 ? @@ -2044,9 +2156,9 @@ namespace MediaBrowser.Controller.MediaEncoding // 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}]{3}[base];[base][sub]overlay\"" - : " -filter_complex \"[{0}:{1}]{4}[sub];[0:{2}][sub]overlay\""; + 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) @@ -2057,9 +2169,9 @@ namespace MediaBrowser.Controller.MediaEncoding [sub]: SW scaling subtitle to FixedOutputSize [base][sub]: SW overlay */ - retStr = !outputSizeParam.IsEmpty - ? " -filter_complex \"[{0}:{1}]{4}[sub];[0:{2}]{3},hwdownload[base];[base][sub]overlay,format=nv12,hwupload\"" - : " -filter_complex \"[{0}:{1}]{4}[sub];[0:{2}]hwdownload[base];[base][sub]overlay,format=nv12,hwupload\""; + 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 @@ -2072,9 +2184,9 @@ namespace MediaBrowser.Controller.MediaEncoding [sub]: SW scaling subtitle to FixedOutputSize [base][sub]: SW overlay */ - retStr = !outputSizeParam.IsEmpty - ? " -filter_complex \"[{0}:{1}]{4}[sub];[0:{2}]{3}[base];[base][sub]overlay\"" - : " -filter_complex \"[{0}:{1}]{4}[sub];[0:{2}][sub]overlay\""; + 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)) @@ -2091,16 +2203,25 @@ namespace MediaBrowser.Controller.MediaEncoding } else if (isLinux) { - retStr = !outputSizeParam.IsEmpty - ? " -filter_complex \"[{0}:{1}]{4}[sub];[0:{2}]{3}[base];[base][sub]overlay_qsv\"" - : " -filter_complex \"[{0}:{1}]{4}[sub];[0:{2}][sub]overlay_qsv\""; + 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) { - retStr = !outputSizeParam.IsEmpty - ? " -filter_complex \"[{0}:{1}]{4}[sub];[0:{2}]{3}[base];[base][sub]overlay,format=nv12|yuv420p,hwupload_cuda\"" - : " -filter_complex \"[{0}:{1}]{4}[sub];[0:{2}][sub]overlay,format=nv12|yuv420p,hwupload_cuda\""; + 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( @@ -2197,11 +2318,11 @@ namespace MediaBrowser.Controller.MediaEncoding var isVaapiHevcEncoder = videoEncoder.Contains("hevc_vaapi", StringComparison.OrdinalIgnoreCase); var isQsvH264Encoder = videoEncoder.Contains("h264_qsv", StringComparison.OrdinalIgnoreCase); var isQsvHevcEncoder = videoEncoder.Contains("hevc_qsv", StringComparison.OrdinalIgnoreCase); - var isTonemappingSupported = IsTonemappingSupported(state, options); + var 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 && (isTonemappingSupported || isVppTonemappingSupported)) + var isP010PixFmtRequired = (isTonemappingSupportedOnVaapi && (isOpenclTonemappingSupported || isVppTonemappingSupported)) || (isTonemappingSupportedOnQsv && isVppTonemappingSupported); var outputPixFmt = "format=nv12"; @@ -2252,15 +2373,23 @@ namespace MediaBrowser.Controller.MediaEncoding var outputWidth = width.Value; var outputHeight = height.Value; - var isTonemappingSupported = IsTonemappingSupported(state, options); + 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 isCudaFormatConversionSupported = _mediaEncoder.SupportsFilter("scale_cuda", "Output format (default \"same\")"); + 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 = "format=nv12"; - if (isTonemappingSupported && isTonemappingSupportedOnNvenc) + outputPixFmt = (hasGraphicalSubs && isCudaOverlaySupported && isNvencEncoder) + ? "format=yuv420p" + : "format=nv12"; + if ((isOpenclTonemappingSupported || isCudaTonemappingSupported) + && isTonemappingSupportedOnNvenc) { outputPixFmt = "format=p010"; } @@ -2303,8 +2432,8 @@ namespace MediaBrowser.Controller.MediaEncoding { if (isExynosV4L2) { - var widthParam = requestedWidth.Value.ToString(_usCulture); - var heightParam = requestedHeight.Value.ToString(_usCulture); + var widthParam = requestedWidth.Value.ToString(CultureInfo.InvariantCulture); + var heightParam = requestedHeight.Value.ToString(CultureInfo.InvariantCulture); filters.Add( string.Format( @@ -2322,8 +2451,8 @@ namespace MediaBrowser.Controller.MediaEncoding // 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); + var maxWidthParam = requestedMaxWidth.Value.ToString(CultureInfo.InvariantCulture); + var maxHeightParam = requestedMaxHeight.Value.ToString(CultureInfo.InvariantCulture); if (isExynosV4L2) { @@ -2355,7 +2484,7 @@ namespace MediaBrowser.Controller.MediaEncoding } else { - var widthParam = requestedWidth.Value.ToString(_usCulture); + var widthParam = requestedWidth.Value.ToString(CultureInfo.InvariantCulture); filters.Add( string.Format( @@ -2368,7 +2497,7 @@ namespace MediaBrowser.Controller.MediaEncoding // If a fixed height was requested else if (requestedHeight.HasValue) { - var heightParam = requestedHeight.Value.ToString(_usCulture); + var heightParam = requestedHeight.Value.ToString(CultureInfo.InvariantCulture); if (isExynosV4L2) { @@ -2391,7 +2520,7 @@ namespace MediaBrowser.Controller.MediaEncoding // If a max width was requested else if (requestedMaxWidth.HasValue) { - var maxWidthParam = requestedMaxWidth.Value.ToString(_usCulture); + var maxWidthParam = requestedMaxWidth.Value.ToString(CultureInfo.InvariantCulture); if (isExynosV4L2) { @@ -2414,7 +2543,7 @@ namespace MediaBrowser.Controller.MediaEncoding // If a max height was requested else if (requestedMaxHeight.HasValue) { - var maxHeightParam = requestedMaxHeight.Value.ToString(_usCulture); + var maxHeightParam = requestedMaxHeight.Value.ToString(CultureInfo.InvariantCulture); if (isExynosV4L2) { @@ -2486,6 +2615,13 @@ namespace MediaBrowser.Controller.MediaEncoding return string.Format(CultureInfo.InvariantCulture, filter, widthParam, heightParam); } + /// <summary> + /// Gets the output size parameter. + /// </summary> + /// <param name="state">Encoding state.</param> + /// <param name="options">Encoding options.</param> + /// <param name="outputVideoCodec">Video codec to use.</param> + /// <returns>The output size parameter.</returns> public string GetOutputSizeParam( EncodingJobInfo state, EncodingOptions options, @@ -2496,8 +2632,13 @@ namespace MediaBrowser.Controller.MediaEncoding } /// <summary> + /// Gets the output size parameter. /// If we're going to put a fixed size on the command line, this will calculate it. /// </summary> + /// <param name="state">Encoding state.</param> + /// <param name="options">Encoding options.</param> + /// <param name="outputVideoCodec">Video codec to use.</param> + /// <returns>The output size parameter.</returns> public string GetOutputSizeParamInternal( EncodingJobInfo state, EncodingOptions options, @@ -2526,16 +2667,21 @@ namespace MediaBrowser.Controller.MediaEncoding 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 = RuntimeInformation.IsOSPlatform(OSPlatform.Linux); + var isLinux = OperatingSystem.IsLinux(); var isColorDepth10 = IsColorDepth10(state); - var isTonemappingSupported = IsTonemappingSupported(state, options); - var isVppTonemappingSupported = IsVppTonemappingSupported(state, options); - var isTonemappingSupportedOnNvenc = string.Equals(options.HardwareAccelerationType, "nvenc", StringComparison.OrdinalIgnoreCase) && (isNvdecDecoder || isCuvidHevcDecoder || isSwDecoder); + + var 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; @@ -2547,19 +2693,25 @@ namespace MediaBrowser.Controller.MediaEncoding 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 || isTonemappingSupportedOnAmf || (isTonemappingSupportedOnVaapi && !isVppTonemappingSupported)) + if ((isTonemappingSupportedOnNvenc && !isCudaTonemappingSupported) || isTonemappingSupportedOnAmf || (isTonemappingSupportedOnVaapi && !isVppTonemappingSupported)) { - // Currently only with the use of NVENC decoder can we get a decent performance. - // Currently only the HEVC/H265 format is supported with NVDEC decoder. // NVIDIA Pascal and Turing or higher are recommended. // AMD Polaris and Vega or higher are recommended. // Intel Kaby Lake or newer is required. - if (isTonemappingSupported) + 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) @@ -2631,7 +2783,11 @@ namespace MediaBrowser.Controller.MediaEncoding filters.Add("hwdownload,format=p010"); } - if (isNvdecDecoder || isCuvidHevcDecoder || isSwDecoder || isD3d11vaDecoder) + if (isNvdecDecoder + || isCuvidHevcDecoder + || isCuvidVp9Decoder + || isSwDecoder + || isD3d11vaDecoder) { // Upload the HDR10 or HLG data to the OpenCL device, // use tonemap_opencl filter for tone mapping, @@ -2639,6 +2795,14 @@ namespace MediaBrowser.Controller.MediaEncoding 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, @@ -2650,7 +2814,11 @@ namespace MediaBrowser.Controller.MediaEncoding options.TonemappingParam, options.TonemappingRange)); - if (isNvdecDecoder || isCuvidHevcDecoder || isSwDecoder || isD3d11vaDecoder) + if (isNvdecDecoder + || isCuvidHevcDecoder + || isCuvidVp9Decoder + || isSwDecoder + || isD3d11vaDecoder) { filters.Add("hwdownload"); filters.Add("format=nv12"); @@ -2666,12 +2834,18 @@ namespace MediaBrowser.Controller.MediaEncoding // 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); + } } } // When the input may or may not be hardware VAAPI decodable. if ((isVaapiH264Encoder || isVaapiHevcEncoder) - && !(isTonemappingSupportedOnVaapi && (isTonemappingSupported || isVppTonemappingSupported))) + && !(isTonemappingSupportedOnVaapi && (isOpenclTonemappingSupported || isVppTonemappingSupported))) { filters.Add("format=nv12|vaapi"); filters.Add("hwupload"); @@ -2779,6 +2953,61 @@ namespace MediaBrowser.Controller.MediaEncoding 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}"; + + if (options.TonemappingParam != 0) + { + parameters += ":param={3}"; + } + + 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); + } + } + // Add VPP tonemapping filter for VAAPI. // Full hardware based video post processing, faster than OpenCL but lacks fine tuning options. if ((isTonemappingSupportedOnVaapi || isTonemappingSupportedOnQsv) @@ -2788,10 +3017,10 @@ namespace MediaBrowser.Controller.MediaEncoding } // Another case is when using Nvenc decoder. - if (isNvdecDecoder && !isTonemappingSupported) + if (isNvdecDecoder && !isOpenclTonemappingSupported && !isCudaTonemappingSupported) { var codec = videoStream.Codec; - var isCudaFormatConversionSupported = _mediaEncoder.SupportsFilter("scale_cuda", "Output format (default \"same\")"); + var isCudaFormatConversionSupported = _mediaEncoder.SupportsFilterWithOption(FilterOptionType.ScaleCudaFormat); // Assert 10-bit hardware decodable if (isColorDepth10 && (string.Equals(codec, "hevc", StringComparison.OrdinalIgnoreCase) @@ -2800,7 +3029,10 @@ namespace MediaBrowser.Controller.MediaEncoding { if (isCudaFormatConversionSupported) { - if (isLibX264Encoder || isLibX265Encoder || hasSubs) + if (isLibX264Encoder + || isLibX265Encoder + || hasTextSubs + || (hasGraphicalSubs && !isCudaOverlaySupported && isNvencEncoder)) { if (isNvencEncoder) { @@ -2827,7 +3059,11 @@ namespace MediaBrowser.Controller.MediaEncoding } // Assert 8-bit hardware decodable - else if (!isColorDepth10 && (isLibX264Encoder || isLibX265Encoder || hasSubs)) + else if (!isColorDepth10 + && (isLibX264Encoder + || isLibX265Encoder + || hasTextSubs + || (hasGraphicalSubs && !isCudaOverlaySupported && isNvencEncoder))) { if (isNvencEncoder) { @@ -2848,7 +3084,7 @@ namespace MediaBrowser.Controller.MediaEncoding { // Convert hw context from ocl to va. // For tonemapping and text subs burn-in. - if (isTonemappingSupportedOnVaapi && isTonemappingSupported && !isVppTonemappingSupported) + if (isTonemappingSupportedOnVaapi && isOpenclTonemappingSupported && !isVppTonemappingSupported) { filters.Add("scale_vaapi"); } @@ -2894,6 +3130,17 @@ namespace MediaBrowser.Controller.MediaEncoding 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) { @@ -2906,26 +3153,56 @@ namespace MediaBrowser.Controller.MediaEncoding return output; } + public static string GetInputHdrParams(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"; + } + } + + public static string GetOutputSdrParams(string tonemappingRange) + { + // SDR + if (string.Equals(tonemappingRange, "tv", StringComparison.OrdinalIgnoreCase)) + { + return "setparams=color_primaries=bt709:color_trc=bt709:colorspace=bt709:range=tv"; + } + + if (string.Equals(tonemappingRange, "pc", StringComparison.OrdinalIgnoreCase)) + { + return "setparams=color_primaries=bt709:color_trc=bt709:colorspace=bt709:range=pc"; + } + + return "setparams=color_primaries=bt709:color_trc=bt709:colorspace=bt709"; + } + /// <summary> /// Gets the number of threads. /// </summary> + /// <param name="state">Encoding state.</param> + /// <param name="encodingOptions">Encoding options.</param> + /// <param name="outputVideoCodec">Video codec to use.</param> + /// <returns>Number of threads.</returns> #nullable enable public static int GetNumberOfThreads(EncodingJobInfo? state, EncodingOptions encodingOptions, string? outputVideoCodec) { - if (string.Equals(outputVideoCodec, "libvpx", StringComparison.OrdinalIgnoreCase)) - { - // per docs: - // -threads number of threads to use for encoding, can't be 0 [auto] with VP8 - // (recommended value : number of real cores - 1) - return Math.Max(Environment.ProcessorCount - 1, 1); - } + // VP8 and VP9 encoders must have their thread counts set. + bool mustSetThreadCount = string.Equals(outputVideoCodec, "libvpx", StringComparison.OrdinalIgnoreCase) + || string.Equals(outputVideoCodec, "libvpx-vp9", StringComparison.OrdinalIgnoreCase); var threads = state?.BaseRequest.CpuCoreLimit ?? encodingOptions.EncodingThreadCount; - // Automatic if (threads <= 0) { - return 0; + // Automatically set thread count + return mustSetThreadCount ? Math.Max(Environment.ProcessorCount - 1, 1) : 0; } else if (threads >= Environment.ProcessorCount) { @@ -3067,7 +3344,7 @@ namespace MediaBrowser.Controller.MediaEncoding inputModifier += " " + videoDecoder; if (!IsCopyCodec(state.OutputVideoCodec) - && (videoDecoder ?? string.Empty).IndexOf("cuvid", StringComparison.OrdinalIgnoreCase) != -1) + && videoDecoder.Contains("cuvid", StringComparison.OrdinalIgnoreCase)) { var videoStream = state.VideoStream; var inputWidth = videoStream?.Width; @@ -3076,7 +3353,7 @@ namespace MediaBrowser.Controller.MediaEncoding var (width, height) = GetFixedOutputSize(inputWidth, inputHeight, request.Width, request.Height, request.MaxWidth, request.MaxHeight); - if ((videoDecoder ?? string.Empty).IndexOf("cuvid", StringComparison.OrdinalIgnoreCase) != -1 + if (videoDecoder.Contains("cuvid", StringComparison.OrdinalIgnoreCase) && width.HasValue && height.HasValue) { @@ -3372,8 +3649,13 @@ namespace MediaBrowser.Controller.MediaEncoding if (string.Equals(encodingOptions.HardwareAccelerationType, "qsv", StringComparison.OrdinalIgnoreCase) && IsVppTonemappingSupported(state, encodingOptions)) { - // Since tonemap_vaapi only support HEVC for now, no need to check the codec again. - return GetHwaccelType(state, encodingOptions, "hevc", isColorDepth10); + 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)) @@ -3552,6 +3834,11 @@ namespace MediaBrowser.Controller.MediaEncoding /// <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); @@ -3570,10 +3857,15 @@ namespace MediaBrowser.Controller.MediaEncoding /// <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 = RuntimeInformation.IsOSPlatform(OSPlatform.Windows); - var isLinux = RuntimeInformation.IsOSPlatform(OSPlatform.Linux); + 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); @@ -3828,15 +4120,15 @@ 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, false); + args += GetAudioFilterParam(state, encodingOptions); return args; } @@ -3849,12 +4141,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 @@ -3862,7 +4154,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)); } } @@ -3896,6 +4188,11 @@ namespace MediaBrowser.Controller.MediaEncoding if (videoStream != null) { + if (videoStream.BitDepth.HasValue) + { + return videoStream.BitDepth.Value == 10; + } + if (!string.IsNullOrEmpty(videoStream.PixelFormat)) { result = videoStream.PixelFormat.Contains("p10", StringComparison.OrdinalIgnoreCase); @@ -3915,12 +4212,6 @@ namespace MediaBrowser.Controller.MediaEncoding return true; } } - - result = (videoStream.BitDepth ?? 8) == 10; - if (result) - { - return true; - } } return result; diff --git a/MediaBrowser.Controller/MediaEncoding/EncodingJobInfo.cs b/MediaBrowser.Controller/MediaEncoding/EncodingJobInfo.cs index bc0318ad7c..e92c4a08a6 100644 --- a/MediaBrowser.Controller/MediaEncoding/EncodingJobInfo.cs +++ b/MediaBrowser.Controller/MediaEncoding/EncodingJobInfo.cs @@ -1,6 +1,6 @@ #nullable disable -#pragma warning disable CS1591 +#pragma warning disable CS1591, SA1401 using System; using System.Collections.Generic; @@ -20,6 +20,44 @@ namespace MediaBrowser.Controller.MediaEncoding // For now, a common base class until the API and MediaEncoding classes are unified public class EncodingJobInfo { + public int? OutputAudioBitrate; + public int? OutputAudioChannels; + + private TranscodeReason[] _transcodeReasons = null; + + public EncodingJobInfo(TranscodingJobType jobType) + { + TranscodingType = jobType; + RemoteHttpHeaders = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase); + SupportedAudioCodecs = Array.Empty<string>(); + SupportedVideoCodecs = Array.Empty<string>(); + SupportedSubtitleCodecs = Array.Empty<string>(); + } + + public TranscodeReason[] TranscodeReasons + { + get + { + if (_transcodeReasons == null) + { + if (BaseRequest.TranscodeReasons == null) + { + return Array.Empty<TranscodeReason>(); + } + + _transcodeReasons = BaseRequest.TranscodeReasons + .Split(',') + .Where(i => !string.IsNullOrEmpty(i)) + .Select(v => (TranscodeReason)Enum.Parse(typeof(TranscodeReason), v, true)) + .ToArray(); + } + + return _transcodeReasons; + } + } + + public IProgress<double> Progress { get; set; } + public MediaStream VideoStream { get; set; } public VideoType VideoType { get; set; } @@ -58,40 +96,6 @@ namespace MediaBrowser.Controller.MediaEncoding public string MimeType { get; set; } - public string GetMimeType(string outputPath, bool enableStreamDefault = true) - { - if (!string.IsNullOrEmpty(MimeType)) - { - return MimeType; - } - - return MimeTypes.GetMimeType(outputPath, enableStreamDefault); - } - - private TranscodeReason[] _transcodeReasons = null; - - public TranscodeReason[] TranscodeReasons - { - get - { - if (_transcodeReasons == null) - { - if (BaseRequest.TranscodeReasons == null) - { - return Array.Empty<TranscodeReason>(); - } - - _transcodeReasons = BaseRequest.TranscodeReasons - .Split(',') - .Where(i => !string.IsNullOrEmpty(i)) - .Select(v => (TranscodeReason)Enum.Parse(typeof(TranscodeReason), v, true)) - .ToArray(); - } - - return _transcodeReasons; - } - } - public bool IgnoreInputDts => MediaSource.IgnoreDts; public bool IgnoreInputIndex => MediaSource.IgnoreIndex; @@ -144,196 +148,17 @@ namespace MediaBrowser.Controller.MediaEncoding public BaseEncodingJobOptions BaseRequest { get; set; } - public long? StartTimeTicks => BaseRequest.StartTimeTicks; - - public bool CopyTimestamps => BaseRequest.CopyTimestamps; - - public int? OutputAudioBitrate; - public int? OutputAudioChannels; - - public bool DeInterlace(string videoCodec, bool forceDeinterlaceIfSourceIsInterlaced) - { - var videoStream = VideoStream; - var isInputInterlaced = videoStream != null && videoStream.IsInterlaced; - - if (!isInputInterlaced) - { - return false; - } - - // Support general param - if (BaseRequest.DeInterlace) - { - return true; - } - - if (!string.IsNullOrEmpty(videoCodec)) - { - if (string.Equals(BaseRequest.GetOption(videoCodec, "deinterlace"), "true", StringComparison.OrdinalIgnoreCase)) - { - return true; - } - } - - return forceDeinterlaceIfSourceIsInterlaced && isInputInterlaced; - } - - public string[] GetRequestedProfiles(string codec) - { - if (!string.IsNullOrEmpty(BaseRequest.Profile)) - { - return BaseRequest.Profile.Split(new[] { '|', ',' }, StringSplitOptions.RemoveEmptyEntries); - } - - if (!string.IsNullOrEmpty(codec)) - { - var profile = BaseRequest.GetOption(codec, "profile"); - - if (!string.IsNullOrEmpty(profile)) - { - return profile.Split(new[] { '|', ',' }, StringSplitOptions.RemoveEmptyEntries); - } - } - - return Array.Empty<string>(); - } - - public string GetRequestedLevel(string codec) - { - if (!string.IsNullOrEmpty(BaseRequest.Level)) - { - return BaseRequest.Level; - } - - if (!string.IsNullOrEmpty(codec)) - { - return BaseRequest.GetOption(codec, "level"); - } - - return null; - } - - public int? GetRequestedMaxRefFrames(string codec) - { - if (BaseRequest.MaxRefFrames.HasValue) - { - return BaseRequest.MaxRefFrames; - } - - if (!string.IsNullOrEmpty(codec)) - { - var value = BaseRequest.GetOption(codec, "maxrefframes"); - if (!string.IsNullOrEmpty(value) - && int.TryParse(value, NumberStyles.Any, CultureInfo.InvariantCulture, out var result)) - { - return result; - } - } - - return null; - } - - public int? GetRequestedVideoBitDepth(string codec) - { - if (BaseRequest.MaxVideoBitDepth.HasValue) - { - return BaseRequest.MaxVideoBitDepth; - } - - if (!string.IsNullOrEmpty(codec)) - { - var value = BaseRequest.GetOption(codec, "videobitdepth"); - if (!string.IsNullOrEmpty(value) - && int.TryParse(value, NumberStyles.Any, CultureInfo.InvariantCulture, out var result)) - { - return result; - } - } - - return null; - } - - public int? GetRequestedAudioBitDepth(string codec) - { - if (BaseRequest.MaxAudioBitDepth.HasValue) - { - return BaseRequest.MaxAudioBitDepth; - } - - if (!string.IsNullOrEmpty(codec)) - { - var value = BaseRequest.GetOption(codec, "audiobitdepth"); - if (!string.IsNullOrEmpty(value) - && int.TryParse(value, NumberStyles.Any, CultureInfo.InvariantCulture, out var result)) - { - return result; - } - } - - return null; - } - - public int? GetRequestedAudioChannels(string codec) - { - if (!string.IsNullOrEmpty(codec)) - { - var value = BaseRequest.GetOption(codec, "audiochannels"); - if (!string.IsNullOrEmpty(value) - && int.TryParse(value, NumberStyles.Any, CultureInfo.InvariantCulture, out var result)) - { - return result; - } - } - - if (BaseRequest.MaxAudioChannels.HasValue) - { - return BaseRequest.MaxAudioChannels; - } - - if (BaseRequest.AudioChannels.HasValue) - { - return BaseRequest.AudioChannels; - } - - if (BaseRequest.TranscodingMaxAudioChannels.HasValue) - { - return BaseRequest.TranscodingMaxAudioChannels; - } - - return null; - } - public bool IsVideoRequest { get; set; } public TranscodingJobType TranscodingType { get; set; } - public EncodingJobInfo(TranscodingJobType jobType) - { - TranscodingType = jobType; - RemoteHttpHeaders = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase); - SupportedAudioCodecs = Array.Empty<string>(); - SupportedVideoCodecs = Array.Empty<string>(); - SupportedSubtitleCodecs = Array.Empty<string>(); - } + public long? StartTimeTicks => BaseRequest.StartTimeTicks; + + public bool CopyTimestamps => BaseRequest.CopyTimestamps; public bool IsSegmentedLiveStream => TranscodingType != TranscodingJobType.Progressive && !RunTimeTicks.HasValue; - public bool EnableBreakOnNonKeyFrames(string videoCodec) - { - if (TranscodingType != TranscodingJobType.Progressive) - { - if (IsSegmentedLiveStream) - { - return false; - } - - return BaseRequest.BreakOnNonKeyFrames && EncodingHelper.IsCopyCodec(videoCodec); - } - - return false; - } - public int? TotalOutputBitrate => (OutputAudioBitrate ?? 0) + (OutputVideoBitrate ?? 0); public int? OutputWidth @@ -597,7 +422,7 @@ namespace MediaBrowser.Controller.MediaEncoding if (EncodingHelper.IsCopyCodec(OutputVideoCodec)) { - return VideoStream?.Codec; + return VideoStream.Codec; } return OutputVideoCodec; @@ -615,7 +440,7 @@ namespace MediaBrowser.Controller.MediaEncoding if (EncodingHelper.IsCopyCodec(OutputAudioCodec)) { - return AudioStream?.Codec; + return AudioStream.Codec; } return OutputAudioCodec; @@ -682,6 +507,21 @@ namespace MediaBrowser.Controller.MediaEncoding public int HlsListSize => 0; + public bool EnableBreakOnNonKeyFrames(string videoCodec) + { + if (TranscodingType != TranscodingJobType.Progressive) + { + if (IsSegmentedLiveStream) + { + return false; + } + + return BaseRequest.BreakOnNonKeyFrames && EncodingHelper.IsCopyCodec(videoCodec); + } + + return false; + } + private int? GetMediaStreamCount(MediaStreamType type, int limit) { var count = MediaSource.GetStreamCount(type); @@ -694,7 +534,172 @@ namespace MediaBrowser.Controller.MediaEncoding return count; } - public IProgress<double> Progress { get; set; } + public string GetMimeType(string outputPath, bool enableStreamDefault = true) + { + if (!string.IsNullOrEmpty(MimeType)) + { + return MimeType; + } + + if (enableStreamDefault) + { + return MimeTypes.GetMimeType(outputPath); + } + + return MimeTypes.GetMimeType(outputPath, null); + } + + public bool DeInterlace(string videoCodec, bool forceDeinterlaceIfSourceIsInterlaced) + { + var videoStream = VideoStream; + var isInputInterlaced = videoStream != null && videoStream.IsInterlaced; + + if (!isInputInterlaced) + { + return false; + } + + // Support general param + if (BaseRequest.DeInterlace) + { + return true; + } + + if (!string.IsNullOrEmpty(videoCodec)) + { + if (string.Equals(BaseRequest.GetOption(videoCodec, "deinterlace"), "true", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + + return forceDeinterlaceIfSourceIsInterlaced; + } + + public string[] GetRequestedProfiles(string codec) + { + if (!string.IsNullOrEmpty(BaseRequest.Profile)) + { + return BaseRequest.Profile.Split(new[] { '|', ',' }, StringSplitOptions.RemoveEmptyEntries); + } + + if (!string.IsNullOrEmpty(codec)) + { + var profile = BaseRequest.GetOption(codec, "profile"); + + if (!string.IsNullOrEmpty(profile)) + { + return profile.Split(new[] { '|', ',' }, StringSplitOptions.RemoveEmptyEntries); + } + } + + return Array.Empty<string>(); + } + + public string GetRequestedLevel(string codec) + { + if (!string.IsNullOrEmpty(BaseRequest.Level)) + { + return BaseRequest.Level; + } + + if (!string.IsNullOrEmpty(codec)) + { + return BaseRequest.GetOption(codec, "level"); + } + + return null; + } + + public int? GetRequestedMaxRefFrames(string codec) + { + if (BaseRequest.MaxRefFrames.HasValue) + { + return BaseRequest.MaxRefFrames; + } + + if (!string.IsNullOrEmpty(codec)) + { + var value = BaseRequest.GetOption(codec, "maxrefframes"); + if (!string.IsNullOrEmpty(value) + && int.TryParse(value, NumberStyles.Any, CultureInfo.InvariantCulture, out var result)) + { + return result; + } + } + + return null; + } + + public int? GetRequestedVideoBitDepth(string codec) + { + if (BaseRequest.MaxVideoBitDepth.HasValue) + { + return BaseRequest.MaxVideoBitDepth; + } + + if (!string.IsNullOrEmpty(codec)) + { + var value = BaseRequest.GetOption(codec, "videobitdepth"); + if (!string.IsNullOrEmpty(value) + && int.TryParse(value, NumberStyles.Any, CultureInfo.InvariantCulture, out var result)) + { + return result; + } + } + + return null; + } + + public int? GetRequestedAudioBitDepth(string codec) + { + if (BaseRequest.MaxAudioBitDepth.HasValue) + { + return BaseRequest.MaxAudioBitDepth; + } + + if (!string.IsNullOrEmpty(codec)) + { + var value = BaseRequest.GetOption(codec, "audiobitdepth"); + if (!string.IsNullOrEmpty(value) + && int.TryParse(value, NumberStyles.Any, CultureInfo.InvariantCulture, out var result)) + { + return result; + } + } + + return null; + } + + public int? GetRequestedAudioChannels(string codec) + { + if (!string.IsNullOrEmpty(codec)) + { + var value = BaseRequest.GetOption(codec, "audiochannels"); + if (!string.IsNullOrEmpty(value) + && int.TryParse(value, NumberStyles.Any, CultureInfo.InvariantCulture, out var result)) + { + return result; + } + } + + if (BaseRequest.MaxAudioChannels.HasValue) + { + return BaseRequest.MaxAudioChannels; + } + + if (BaseRequest.AudioChannels.HasValue) + { + return BaseRequest.AudioChannels; + } + + if (BaseRequest.TranscodingMaxAudioChannels.HasValue) + { + return BaseRequest.TranscodingMaxAudioChannels; + } + + return null; + } public virtual void ReportTranscodingProgress(TimeSpan? transcodingPosition, float? framerate, double? percentComplete, long? bytesTranscoded, int? bitRate) { diff --git a/MediaBrowser.Controller/MediaEncoding/FilterOptionType.cs b/MediaBrowser.Controller/MediaEncoding/FilterOptionType.cs new file mode 100644 index 0000000000..7ce707b19e --- /dev/null +++ b/MediaBrowser.Controller/MediaEncoding/FilterOptionType.cs @@ -0,0 +1,23 @@ +namespace MediaBrowser.Controller.MediaEncoding +{ + /// <summary> + /// Enum FilterOptionType. + /// </summary> + public enum FilterOptionType + { + /// <summary> + /// The scale_cuda_format. + /// </summary> + ScaleCudaFormat = 0, + + /// <summary> + /// The tonemap_cuda_name. + /// </summary> + TonemapCudaName = 1, + + /// <summary> + /// The tonemap_opencl_bt2390. + /// </summary> + TonemapOpenclBt2390 = 2 + } +} diff --git a/MediaBrowser.Controller/MediaEncoding/IEncodingManager.cs b/MediaBrowser.Controller/MediaEncoding/IEncodingManager.cs index 773547872c..8ce40a58d1 100644 --- a/MediaBrowser.Controller/MediaEncoding/IEncodingManager.cs +++ b/MediaBrowser.Controller/MediaEncoding/IEncodingManager.cs @@ -16,6 +16,13 @@ namespace MediaBrowser.Controller.MediaEncoding /// <summary> /// Refreshes the chapter images. /// </summary> + /// <param name="video">Video to use.</param> + /// <param name="directoryService">Directory service to use.</param> + /// <param name="chapters">Set of chapters to refresh.</param> + /// <param name="extractImages">Option to extract images.</param> + /// <param name="saveChapters">Option to save chapters.</param> + /// <param name="cancellationToken">CancellationToken to use for operation.</param> + /// <returns><c>true</c> if successful, <c>false</c> if not.</returns> Task<bool> RefreshChapterImages(Video video, IDirectoryService directoryService, IReadOnlyList<ChapterInfo> chapters, bool extractImages, bool saveChapters, CancellationToken cancellationToken); } } diff --git a/MediaBrowser.Controller/MediaEncoding/IMediaEncoder.cs b/MediaBrowser.Controller/MediaEncoding/IMediaEncoder.cs index 76a9fd7c74..e6511ca8d7 100644 --- a/MediaBrowser.Controller/MediaEncoding/IMediaEncoder.cs +++ b/MediaBrowser.Controller/MediaEncoding/IMediaEncoder.cs @@ -10,7 +10,6 @@ using MediaBrowser.Model.Dlna; using MediaBrowser.Model.Dto; using MediaBrowser.Model.Entities; using MediaBrowser.Model.MediaInfo; -using MediaBrowser.Model.System; namespace MediaBrowser.Controller.MediaEncoding { @@ -19,11 +18,6 @@ namespace MediaBrowser.Controller.MediaEncoding /// </summary> public interface IMediaEncoder : ITranscoderSupport { - /// <summary> - /// Gets location of the discovered FFmpeg tool. - /// </summary> - FFmpegLocation EncoderLocation { get; } - /// <summary> /// Gets the encoder path. /// </summary> @@ -55,9 +49,21 @@ namespace MediaBrowser.Controller.MediaEncoding /// Whether given filter is supported. /// </summary> /// <param name="filter">The filter.</param> + /// <returns><c>true</c> if the filter is supported, <c>false</c> otherwise.</returns> + bool SupportsFilter(string filter); + + /// <summary> + /// Whether filter is supported with the given option. + /// </summary> /// <param name="option">The option.</param> /// <returns><c>true</c> if the filter is supported, <c>false</c> otherwise.</returns> - bool SupportsFilter(string filter, string option); + 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. @@ -71,13 +77,43 @@ namespace MediaBrowser.Controller.MediaEncoding /// <summary> /// Extracts the video image. /// </summary> + /// <param name="inputFile">Input file.</param> + /// <param name="container">Video container type.</param> + /// <param name="mediaSource">Media source information.</param> + /// <param name="videoStream">Media stream information.</param> + /// <param name="threedFormat">Video 3D format.</param> + /// <param name="offset">Time offset.</param> + /// <param name="cancellationToken">CancellationToken to use for operation.</param> + /// <returns>Location of video image.</returns> Task<string> ExtractVideoImage(string inputFile, string container, MediaSourceInfo mediaSource, MediaStream videoStream, Video3DFormat? threedFormat, TimeSpan? offset, CancellationToken cancellationToken); - Task<string> ExtractVideoImage(string inputFile, string container, MediaSourceInfo mediaSource, MediaStream imageStream, int? imageStreamIndex, CancellationToken cancellationToken); + /// <summary> + /// Extracts the video image. + /// </summary> + /// <param name="inputFile">Input file.</param> + /// <param name="container">Video container type.</param> + /// <param name="mediaSource">Media source information.</param> + /// <param name="imageStream">Media stream information.</param> + /// <param name="imageStreamIndex">Index of the stream to extract from.</param> + /// <param name="outputExtension">The extension of the file to write, including the '.'.</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, string outputExtension, 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, @@ -122,10 +158,24 @@ namespace MediaBrowser.Controller.MediaEncoding /// <returns>System.String.</returns> string EscapeSubtitleFilterPath(string path); + /// <summary> + /// Sets the path to find FFmpeg. + /// </summary> void SetFFmpegPath(); + /// <summary> + /// Updates the encoder path. + /// </summary> + /// <param name="path">The path.</param> + /// <param name="pathType">The type of path.</param> void UpdateEncoderPath(string path, string pathType); + /// <summary> + /// Gets the primary playlist of .vob files. + /// </summary> + /// <param name="path">The to the .vob files.</param> + /// <param name="titleNumber">The title number to start with.</param> + /// <returns>A playlist.</returns> IEnumerable<string> GetPrimaryPlaylistVobFiles(string path, uint? titleNumber); } } diff --git a/MediaBrowser.Controller/MediaEncoding/ISubtitleEncoder.cs b/MediaBrowser.Controller/MediaEncoding/ISubtitleEncoder.cs index 3fb2c47e13..4483cf708a 100644 --- a/MediaBrowser.Controller/MediaEncoding/ISubtitleEncoder.cs +++ b/MediaBrowser.Controller/MediaEncoding/ISubtitleEncoder.cs @@ -15,6 +15,14 @@ namespace MediaBrowser.Controller.MediaEncoding /// <summary> /// Gets the subtitles. /// </summary> + /// <param name="item">Item to use.</param> + /// <param name="mediaSourceId">Media source.</param> + /// <param name="subtitleStreamIndex">Subtitle stream to use.</param> + /// <param name="outputFormat">Output format to use.</param> + /// <param name="startTimeTicks">Start time.</param> + /// <param name="endTimeTicks">End time.</param> + /// <param name="preserveOriginalTimestamps">Option to preserve original timestamps.</param> + /// <param name="cancellationToken">The cancellation token for the operation.</param> /// <returns>Task{Stream}.</returns> Task<Stream> GetSubtitles( BaseItem item, diff --git a/MediaBrowser.Controller/MediaEncoding/JobLogger.cs b/MediaBrowser.Controller/MediaEncoding/JobLogger.cs index b23c951127..933f440ac6 100644 --- a/MediaBrowser.Controller/MediaEncoding/JobLogger.cs +++ b/MediaBrowser.Controller/MediaEncoding/JobLogger.cs @@ -6,7 +6,6 @@ using System; using System.Globalization; using System.IO; using System.Text; -using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Logging; @@ -14,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) @@ -88,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; } @@ -97,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; } @@ -107,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; @@ -121,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); @@ -129,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; } @@ -140,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); @@ -148,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/Net/BasePeriodicWebSocketListener.cs b/MediaBrowser.Controller/Net/BasePeriodicWebSocketListener.cs index d8995ce74a..0813a8e7d5 100644 --- a/MediaBrowser.Controller/Net/BasePeriodicWebSocketListener.cs +++ b/MediaBrowser.Controller/Net/BasePeriodicWebSocketListener.cs @@ -1,6 +1,6 @@ #nullable disable -#pragma warning disable CS1591 +#pragma warning disable CS1591, SA1306, SA1401 using System; using System.Collections.Generic; @@ -30,6 +30,21 @@ namespace MediaBrowser.Controller.Net private readonly List<Tuple<IWebSocketConnection, CancellationTokenSource, TStateType>> _activeConnections = new List<Tuple<IWebSocketConnection, CancellationTokenSource, TStateType>>(); + /// <summary> + /// The logger. + /// </summary> + protected ILogger<BasePeriodicWebSocketListener<TReturnDataType, TStateType>> Logger; + + protected BasePeriodicWebSocketListener(ILogger<BasePeriodicWebSocketListener<TReturnDataType, TStateType>> logger) + { + if (logger == null) + { + throw new ArgumentNullException(nameof(logger)); + } + + Logger = logger; + } + /// <summary> /// Gets the type used for the messages sent to the client. /// </summary> @@ -54,21 +69,6 @@ namespace MediaBrowser.Controller.Net /// <returns>Task{`1}.</returns> protected abstract Task<TReturnDataType> GetDataToSend(); - /// <summary> - /// The logger. - /// </summary> - protected ILogger<BasePeriodicWebSocketListener<TReturnDataType, TStateType>> Logger; - - protected BasePeriodicWebSocketListener(ILogger<BasePeriodicWebSocketListener<TReturnDataType, TStateType>> logger) - { - if (logger == null) - { - throw new ArgumentNullException(nameof(logger)); - } - - Logger = logger; - } - /// <summary> /// Processes the message. /// </summary> diff --git a/MediaBrowser.Controller/Net/IAuthService.cs b/MediaBrowser.Controller/Net/IAuthService.cs index d15c6d3183..a7da740e05 100644 --- a/MediaBrowser.Controller/Net/IAuthService.cs +++ b/MediaBrowser.Controller/Net/IAuthService.cs @@ -1,3 +1,4 @@ +using System.Threading.Tasks; using Microsoft.AspNetCore.Http; namespace MediaBrowser.Controller.Net @@ -12,6 +13,6 @@ namespace MediaBrowser.Controller.Net /// </summary> /// <param name="request">The request.</param> /// <returns>Authorization information. Null if unauthenticated.</returns> - AuthorizationInfo Authenticate(HttpRequest request); + Task<AuthorizationInfo> Authenticate(HttpRequest request); } } diff --git a/MediaBrowser.Controller/Net/IAuthorizationContext.cs b/MediaBrowser.Controller/Net/IAuthorizationContext.cs index 0d310548dc..5c6ca43d1e 100644 --- a/MediaBrowser.Controller/Net/IAuthorizationContext.cs +++ b/MediaBrowser.Controller/Net/IAuthorizationContext.cs @@ -1,3 +1,4 @@ +using System.Threading.Tasks; using Microsoft.AspNetCore.Http; namespace MediaBrowser.Controller.Net @@ -11,14 +12,14 @@ namespace MediaBrowser.Controller.Net /// Gets the authorization information. /// </summary> /// <param name="requestContext">The request context.</param> - /// <returns>AuthorizationInfo.</returns> - AuthorizationInfo GetAuthorizationInfo(HttpContext requestContext); + /// <returns>A task containing the authorization info.</returns> + Task<AuthorizationInfo> GetAuthorizationInfo(HttpContext requestContext); /// <summary> /// Gets the authorization information. /// </summary> /// <param name="requestContext">The request context.</param> - /// <returns>AuthorizationInfo.</returns> - AuthorizationInfo GetAuthorizationInfo(HttpRequest requestContext); + /// <returns>A <see cref="Task"/> containing the authorization info.</returns> + Task<AuthorizationInfo> GetAuthorizationInfo(HttpRequest requestContext); } } diff --git a/MediaBrowser.Controller/Net/ISessionContext.cs b/MediaBrowser.Controller/Net/ISessionContext.cs index 6b896b41f4..b48181b3fb 100644 --- a/MediaBrowser.Controller/Net/ISessionContext.cs +++ b/MediaBrowser.Controller/Net/ISessionContext.cs @@ -1,5 +1,6 @@ #pragma warning disable CS1591 +using System.Threading.Tasks; using Jellyfin.Data.Entities; using MediaBrowser.Controller.Session; using Microsoft.AspNetCore.Http; @@ -8,12 +9,12 @@ namespace MediaBrowser.Controller.Net { public interface ISessionContext { - SessionInfo GetSession(object requestContext); + Task<SessionInfo> GetSession(object requestContext); - User? GetUser(object requestContext); + Task<User?> GetUser(object requestContext); - SessionInfo GetSession(HttpContext requestContext); + Task<SessionInfo> GetSession(HttpContext requestContext); - User? GetUser(HttpContext requestContext); + Task<User?> GetUser(HttpContext requestContext); } } diff --git a/MediaBrowser.Controller/Persistence/IItemRepository.cs b/MediaBrowser.Controller/Persistence/IItemRepository.cs index 0a9073e7f5..a084f91969 100644 --- a/MediaBrowser.Controller/Persistence/IItemRepository.cs +++ b/MediaBrowser.Controller/Persistence/IItemRepository.cs @@ -49,17 +49,17 @@ namespace MediaBrowser.Controller.Persistence /// <summary> /// Gets chapters for an item. /// </summary> - /// <param name="id">The item.</param> + /// <param name="item">The item.</param> /// <returns>The list of chapter info.</returns> - List<ChapterInfo> GetChapters(BaseItem id); + List<ChapterInfo> GetChapters(BaseItem item); /// <summary> /// Gets a single chapter for an item. /// </summary> - /// <param name="id">The item.</param> + /// <param name="item">The item.</param> /// <param name="index">The chapter index.</param> /// <returns>The chapter info at the specified index.</returns> - ChapterInfo GetChapter(BaseItem id, int index); + ChapterInfo GetChapter(BaseItem item, int index); /// <summary> /// Saves the chapters. diff --git a/MediaBrowser.Controller/Persistence/IUserDataRepository.cs b/MediaBrowser.Controller/Persistence/IUserDataRepository.cs index 5fa5834c85..c43acfb6de 100644 --- a/MediaBrowser.Controller/Persistence/IUserDataRepository.cs +++ b/MediaBrowser.Controller/Persistence/IUserDataRepository.cs @@ -18,7 +18,6 @@ namespace MediaBrowser.Controller.Persistence /// <param name="key">The key.</param> /// <param name="userData">The user data.</param> /// <param name="cancellationToken">The cancellation token.</param> - /// <returns>Task.</returns> void SaveUserData(long userId, string key, UserItemData userData, CancellationToken cancellationToken); /// <summary> diff --git a/MediaBrowser.Controller/Playlists/Playlist.cs b/MediaBrowser.Controller/Playlists/Playlist.cs index 3eaf235152..5e671a725d 100644 --- a/MediaBrowser.Controller/Playlists/Playlist.cs +++ b/MediaBrowser.Controller/Playlists/Playlist.cs @@ -31,24 +31,18 @@ namespace MediaBrowser.Controller.Playlists ".zpl" }; - public Guid OwnerUserId { get; set; } - - public Share[] Shares { get; set; } - public Playlist() { Shares = Array.Empty<Share>(); } + public Guid OwnerUserId { get; set; } + + public Share[] Shares { get; set; } + [JsonIgnore] public bool IsFile => IsPlaylistFile(Path); - public static bool IsPlaylistFile(string path) - { - // The path will sometimes be a directory and "Path.HasExtension" returns true if the name contains a '.' (dot). - return System.IO.Path.HasExtension(path) && !Directory.Exists(path); - } - [JsonIgnore] public override string ContainingFolderPath { @@ -80,6 +74,41 @@ namespace MediaBrowser.Controller.Playlists [JsonIgnore] public override bool SupportsCumulativeRunTimeTicks => true; + [JsonIgnore] + public override bool IsPreSorted => true; + + public string PlaylistMediaType { get; set; } + + [JsonIgnore] + public override string MediaType => PlaylistMediaType; + + [JsonIgnore] + private bool IsSharedItem + { + get + { + var path = Path; + + if (string.IsNullOrEmpty(path)) + { + return false; + } + + return FileSystem.ContainsSubPath(ConfigurationManager.ApplicationPaths.DataPath, path); + } + } + + public static bool IsPlaylistFile(string path) + { + // The path will sometimes be a directory and "Path.HasExtension" returns true if the name contains a '.' (dot). + return System.IO.Path.HasExtension(path) && !Directory.Exists(path); + } + + public void SetMediaType(string value) + { + PlaylistMediaType = value; + } + public override double GetDefaultPrimaryImageAspectRatio() { return 1; @@ -197,35 +226,6 @@ namespace MediaBrowser.Controller.Playlists return new[] { item }; } - [JsonIgnore] - public override bool IsPreSorted => true; - - public string PlaylistMediaType { get; set; } - - [JsonIgnore] - public override string MediaType => PlaylistMediaType; - - public void SetMediaType(string value) - { - PlaylistMediaType = value; - } - - [JsonIgnore] - private bool IsSharedItem - { - get - { - var path = Path; - - if (string.IsNullOrEmpty(path)) - { - return false; - } - - return FileSystem.ContainsSubPath(ConfigurationManager.ApplicationPaths.DataPath, path); - } - } - public override bool IsVisible(User user) { if (!IsSharedItem) diff --git a/MediaBrowser.Controller/Providers/AlbumInfo.cs b/MediaBrowser.Controller/Providers/AlbumInfo.cs index c7fad5974a..aefa520e72 100644 --- a/MediaBrowser.Controller/Providers/AlbumInfo.cs +++ b/MediaBrowser.Controller/Providers/AlbumInfo.cs @@ -1,4 +1,4 @@ -#pragma warning disable CS1591 +#pragma warning disable CA1002, CA2227, CS1591 using System; using System.Collections.Generic; diff --git a/MediaBrowser.Controller/Providers/ArtistInfo.cs b/MediaBrowser.Controller/Providers/ArtistInfo.cs index e9181f4765..4854d1a5fa 100644 --- a/MediaBrowser.Controller/Providers/ArtistInfo.cs +++ b/MediaBrowser.Controller/Providers/ArtistInfo.cs @@ -1,4 +1,4 @@ -#pragma warning disable CS1591 +#pragma warning disable CA1002, CA2227, CS1591 using System.Collections.Generic; diff --git a/MediaBrowser.Controller/Providers/EpisodeInfo.cs b/MediaBrowser.Controller/Providers/EpisodeInfo.cs index 0c932fa877..b59a037384 100644 --- a/MediaBrowser.Controller/Providers/EpisodeInfo.cs +++ b/MediaBrowser.Controller/Providers/EpisodeInfo.cs @@ -1,6 +1,6 @@ #nullable disable -#pragma warning disable CS1591 +#pragma warning disable CA2227, CS1591 using System; using System.Collections.Generic; diff --git a/MediaBrowser.Controller/Providers/IDirectoryService.cs b/MediaBrowser.Controller/Providers/IDirectoryService.cs index b1a36e1024..48d6276918 100644 --- a/MediaBrowser.Controller/Providers/IDirectoryService.cs +++ b/MediaBrowser.Controller/Providers/IDirectoryService.cs @@ -1,4 +1,4 @@ -#pragma warning disable CS1591 +#pragma warning disable CA1002, CA1819, CS1591 using System.Collections.Generic; using MediaBrowser.Model.IO; diff --git a/MediaBrowser.Controller/Providers/IExternalId.cs b/MediaBrowser.Controller/Providers/IExternalId.cs index e2dbef2bc1..0d847520d1 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/IProviderManager.cs b/MediaBrowser.Controller/Providers/IProviderManager.cs index 684bd9e681..9f7a76be64 100644 --- a/MediaBrowser.Controller/Providers/IProviderManager.cs +++ b/MediaBrowser.Controller/Providers/IProviderManager.cs @@ -31,6 +31,9 @@ namespace MediaBrowser.Controller.Providers /// <summary> /// Queues the refresh. /// </summary> + /// <param name="itemId">Item ID.</param> + /// <param name="options">MetadataRefreshOptions for operation.</param> + /// <param name="priority">RefreshPriority for operation.</param> void QueueRefresh(Guid itemId, MetadataRefreshOptions options, RefreshPriority priority); /// <summary> @@ -85,6 +88,13 @@ namespace MediaBrowser.Controller.Providers /// <summary> /// Saves the image. /// </summary> + /// <param name="item">Image to save.</param> + /// <param name="source">Source of image.</param> + /// <param name="mimeType">Mime type image.</param> + /// <param name="type">Type of image.</param> + /// <param name="imageIndex">Index of image.</param> + /// <param name="saveLocallyWithMedia">Option to save locally.</param> + /// <param name="cancellationToken">CancellationToken to use with operation.</param> /// <returns>Task.</returns> Task SaveImage(BaseItem item, string source, string mimeType, ImageType type, int? imageIndex, bool? saveLocallyWithMedia, CancellationToken cancellationToken); @@ -93,6 +103,11 @@ namespace MediaBrowser.Controller.Providers /// <summary> /// Adds the metadata providers. /// </summary> + /// <param name="imageProviders">Image providers to use.</param> + /// <param name="metadataServices">Metadata services to use.</param> + /// <param name="metadataProviders">Metadata providers to use.</param> + /// <param name="metadataSavers">Metadata savers to use.</param> + /// <param name="externalIds">External IDs to use.</param> void AddParts( IEnumerable<IImageProvider> imageProviders, IEnumerable<IMetadataService> metadataServices, diff --git a/MediaBrowser.Controller/Providers/ImageRefreshOptions.cs b/MediaBrowser.Controller/Providers/ImageRefreshOptions.cs index 81a22affb0..2ac4c728ba 100644 --- a/MediaBrowser.Controller/Providers/ImageRefreshOptions.cs +++ b/MediaBrowser.Controller/Providers/ImageRefreshOptions.cs @@ -1,6 +1,6 @@ #nullable disable -#pragma warning disable CS1591 +#pragma warning disable CA1819, CS1591 using System; using System.Linq; @@ -10,6 +10,15 @@ namespace MediaBrowser.Controller.Providers { public class ImageRefreshOptions { + public ImageRefreshOptions(IDirectoryService directoryService) + { + ImageRefreshMode = MetadataRefreshMode.Default; + DirectoryService = directoryService; + + ReplaceImages = Array.Empty<ImageType>(); + IsAutomated = true; + } + public MetadataRefreshMode ImageRefreshMode { get; set; } public IDirectoryService DirectoryService { get; private set; } @@ -20,15 +29,6 @@ namespace MediaBrowser.Controller.Providers public bool IsAutomated { get; set; } - public ImageRefreshOptions(IDirectoryService directoryService) - { - ImageRefreshMode = MetadataRefreshMode.Default; - DirectoryService = directoryService; - - ReplaceImages = Array.Empty<ImageType>(); - IsAutomated = true; - } - public bool IsReplacingImage(ImageType type) { return ImageRefreshMode == MetadataRefreshMode.FullRefresh && diff --git a/MediaBrowser.Controller/Providers/ItemLookupInfo.cs b/MediaBrowser.Controller/Providers/ItemLookupInfo.cs index 2fd89e3bb4..460f4e500f 100644 --- a/MediaBrowser.Controller/Providers/ItemLookupInfo.cs +++ b/MediaBrowser.Controller/Providers/ItemLookupInfo.cs @@ -1,6 +1,6 @@ #nullable disable -#pragma warning disable CS1591 +#pragma warning disable CA2227, CS1591 using System; using System.Collections.Generic; @@ -23,7 +23,7 @@ namespace MediaBrowser.Controller.Providers public string Name { get; set; } /// <summary> - /// Gets or sets the original title + /// Gets or sets the original title. /// </summary> /// <value>The original title of the item.</value> public string OriginalTitle { get; set; } diff --git a/MediaBrowser.Controller/Providers/MetadataRefreshOptions.cs b/MediaBrowser.Controller/Providers/MetadataRefreshOptions.cs index 2cf5367793..a42c7f8b5e 100644 --- a/MediaBrowser.Controller/Providers/MetadataRefreshOptions.cs +++ b/MediaBrowser.Controller/Providers/MetadataRefreshOptions.cs @@ -1,6 +1,6 @@ #nullable disable -#pragma warning disable CS1591 +#pragma warning disable CA1819, CS1591 using System; using System.Linq; diff --git a/MediaBrowser.Controller/Providers/MetadataResult.cs b/MediaBrowser.Controller/Providers/MetadataResult.cs index 7ec1eefcd6..2085ae4adf 100644 --- a/MediaBrowser.Controller/Providers/MetadataResult.cs +++ b/MediaBrowser.Controller/Providers/MetadataResult.cs @@ -1,6 +1,6 @@ #nullable disable -#pragma warning disable CS1591 +#pragma warning disable CA1002, CA2227, CS1591 using System; using System.Collections.Generic; diff --git a/MediaBrowser.Controller/Providers/SeasonInfo.cs b/MediaBrowser.Controller/Providers/SeasonInfo.cs index 7e39bc37a6..1edceb0e4a 100644 --- a/MediaBrowser.Controller/Providers/SeasonInfo.cs +++ b/MediaBrowser.Controller/Providers/SeasonInfo.cs @@ -1,4 +1,4 @@ -#pragma warning disable CS1591 +#pragma warning disable CA2227, CS1591 using System; using System.Collections.Generic; diff --git a/MediaBrowser.Controller/Providers/SongInfo.cs b/MediaBrowser.Controller/Providers/SongInfo.cs index c90717a2e1..4b64a8a982 100644 --- a/MediaBrowser.Controller/Providers/SongInfo.cs +++ b/MediaBrowser.Controller/Providers/SongInfo.cs @@ -9,16 +9,16 @@ namespace MediaBrowser.Controller.Providers { public class SongInfo : ItemLookupInfo { - public IReadOnlyList<string> AlbumArtists { get; set; } - - public string Album { get; set; } - - public IReadOnlyList<string> Artists { get; set; } - public SongInfo() { Artists = Array.Empty<string>(); AlbumArtists = Array.Empty<string>(); } + + public IReadOnlyList<string> AlbumArtists { get; set; } + + public string Album { get; set; } + + public IReadOnlyList<string> Artists { get; set; } } } diff --git a/MediaBrowser.Controller/QuickConnect/IQuickConnect.cs b/MediaBrowser.Controller/QuickConnect/IQuickConnect.cs index ad34c86042..ec3706773a 100644 --- a/MediaBrowser.Controller/QuickConnect/IQuickConnect.cs +++ b/MediaBrowser.Controller/QuickConnect/IQuickConnect.cs @@ -1,4 +1,7 @@ using System; +using System.Threading.Tasks; +using MediaBrowser.Controller.Authentication; +using MediaBrowser.Controller.Net; using MediaBrowser.Model.QuickConnect; namespace MediaBrowser.Controller.QuickConnect @@ -16,8 +19,9 @@ namespace MediaBrowser.Controller.QuickConnect /// <summary> /// Initiates a new quick connect request. /// </summary> + /// <param name="authorizationInfo">The initiator authorization info.</param> /// <returns>A quick connect result with tokens to proceed or throws an exception if not active.</returns> - QuickConnectResult TryConnect(); + QuickConnectResult TryConnect(AuthorizationInfo authorizationInfo); /// <summary> /// Checks the status of an individual request. @@ -32,6 +36,13 @@ namespace MediaBrowser.Controller.QuickConnect /// <param name="userId">User id.</param> /// <param name="code">Identifying code for the request.</param> /// <returns>A boolean indicating if the authorization completed successfully.</returns> - bool AuthorizeRequest(Guid userId, string code); + Task<bool> AuthorizeRequest(Guid userId, string code); + + /// <summary> + /// Gets the authorized request for the secret. + /// </summary> + /// <param name="secret">The secret.</param> + /// <returns>The authentication result.</returns> + AuthenticationResult GetAuthorizedRequest(string secret); } } diff --git a/MediaBrowser.Controller/Resolvers/IItemResolver.cs b/MediaBrowser.Controller/Resolvers/IItemResolver.cs index 75286eadc0..b95d00aa3c 100644 --- a/MediaBrowser.Controller/Resolvers/IItemResolver.cs +++ b/MediaBrowser.Controller/Resolvers/IItemResolver.cs @@ -13,18 +13,18 @@ namespace MediaBrowser.Controller.Resolvers /// </summary> public interface IItemResolver { + /// <summary> + /// Gets the priority. + /// </summary> + /// <value>The priority.</value> + ResolverPriority Priority { get; } + /// <summary> /// Resolves the path. /// </summary> /// <param name="args">The args.</param> /// <returns>BaseItem.</returns> BaseItem ResolvePath(ItemResolveArgs args); - - /// <summary> - /// Gets the priority. - /// </summary> - /// <value>The priority.</value> - ResolverPriority Priority { get; } } public interface IMultiItemResolver @@ -38,14 +38,14 @@ namespace MediaBrowser.Controller.Resolvers public class MultiItemResolverResult { - public List<BaseItem> Items { get; set; } - - public List<FileSystemMetadata> ExtraFiles { get; set; } - public MultiItemResolverResult() { Items = new List<BaseItem>(); ExtraFiles = new List<FileSystemMetadata>(); } + + public List<BaseItem> Items { get; set; } + + public List<FileSystemMetadata> ExtraFiles { get; set; } } } diff --git a/MediaBrowser.Controller/Security/AuthenticationInfoQuery.cs b/MediaBrowser.Controller/Security/AuthenticationInfoQuery.cs deleted file mode 100644 index 3af6a525c7..0000000000 --- a/MediaBrowser.Controller/Security/AuthenticationInfoQuery.cs +++ /dev/null @@ -1,53 +0,0 @@ -#nullable disable - -#pragma warning disable CS1591 - -using System; - -namespace MediaBrowser.Controller.Security -{ - public class AuthenticationInfoQuery - { - /// <summary> - /// Gets or sets the device identifier. - /// </summary> - /// <value>The device identifier.</value> - public string DeviceId { get; set; } - - /// <summary> - /// Gets or sets the user identifier. - /// </summary> - /// <value>The user identifier.</value> - public Guid UserId { get; set; } - - /// <summary> - /// Gets or sets the access token. - /// </summary> - /// <value>The access token.</value> - public string AccessToken { get; set; } - - /// <summary> - /// Gets or sets a value indicating whether this instance is active. - /// </summary> - /// <value><c>null</c> if [is active] contains no value, <c>true</c> if [is active]; otherwise, <c>false</c>.</value> - public bool? IsActive { get; set; } - - /// <summary> - /// Gets or sets a value indicating whether this instance has user. - /// </summary> - /// <value><c>null</c> if [has user] contains no value, <c>true</c> if [has user]; otherwise, <c>false</c>.</value> - public bool? HasUser { get; set; } - - /// <summary> - /// Gets or sets the start index. - /// </summary> - /// <value>The start index.</value> - public int? StartIndex { get; set; } - - /// <summary> - /// Gets or sets the limit. - /// </summary> - /// <value>The limit.</value> - public int? Limit { get; set; } - } -} diff --git a/MediaBrowser.Controller/Security/IAuthenticationManager.cs b/MediaBrowser.Controller/Security/IAuthenticationManager.cs new file mode 100644 index 0000000000..e3d18c8c09 --- /dev/null +++ b/MediaBrowser.Controller/Security/IAuthenticationManager.cs @@ -0,0 +1,33 @@ +#nullable enable + +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace MediaBrowser.Controller.Security +{ + /// <summary> + /// Handles the retrieval and storage of API keys. + /// </summary> + public interface IAuthenticationManager + { + /// <summary> + /// Creates an API key. + /// </summary> + /// <param name="name">The name of the key.</param> + /// <returns>A task representing the creation of the key.</returns> + Task CreateApiKey(string name); + + /// <summary> + /// Gets the API keys. + /// </summary> + /// <returns>A task representing the retrieval of the API keys.</returns> + Task<IReadOnlyList<AuthenticationInfo>> GetApiKeys(); + + /// <summary> + /// Deletes an API key with the provided access token. + /// </summary> + /// <param name="accessToken">The access token.</param> + /// <returns>A task representing the deletion of the API key.</returns> + Task DeleteApiKey(string accessToken); + } +} diff --git a/MediaBrowser.Controller/Security/IAuthenticationRepository.cs b/MediaBrowser.Controller/Security/IAuthenticationRepository.cs deleted file mode 100644 index 1dd69ccd85..0000000000 --- a/MediaBrowser.Controller/Security/IAuthenticationRepository.cs +++ /dev/null @@ -1,39 +0,0 @@ -#nullable disable - -#pragma warning disable CS1591 - -using MediaBrowser.Model.Devices; -using MediaBrowser.Model.Querying; - -namespace MediaBrowser.Controller.Security -{ - public interface IAuthenticationRepository - { - /// <summary> - /// Creates the specified information. - /// </summary> - /// <param name="info">The information.</param> - /// <returns>Task.</returns> - void Create(AuthenticationInfo info); - - /// <summary> - /// Updates the specified information. - /// </summary> - /// <param name="info">The information.</param> - /// <returns>Task.</returns> - void Update(AuthenticationInfo info); - - /// <summary> - /// Gets the specified query. - /// </summary> - /// <param name="query">The query.</param> - /// <returns>QueryResult{AuthenticationInfo}.</returns> - QueryResult<AuthenticationInfo> Get(AuthenticationInfoQuery query); - - void Delete(AuthenticationInfo info); - - DeviceOptions GetDeviceOptions(string deviceId); - - void UpdateDeviceOptions(string deviceId, DeviceOptions options); - } -} diff --git a/MediaBrowser.Controller/Session/ISessionController.cs b/MediaBrowser.Controller/Session/ISessionController.cs index 6bc39d6f4e..b38ee11462 100644 --- a/MediaBrowser.Controller/Session/ISessionController.cs +++ b/MediaBrowser.Controller/Session/ISessionController.cs @@ -26,6 +26,12 @@ namespace MediaBrowser.Controller.Session /// <summary> /// Sends the message. /// </summary> + /// <typeparam name="T">The type of data.</typeparam> + /// <param name="name">Name of message type.</param> + /// <param name="messageId">Message ID.</param> + /// <param name="data">Data to send.</param> + /// <param name="cancellationToken">CancellationToken for operation.</param> + /// <returns>A task.</returns> Task SendMessage<T>(SessionMessageType name, Guid messageId, T data, CancellationToken cancellationToken); } } diff --git a/MediaBrowser.Controller/Session/ISessionManager.cs b/MediaBrowser.Controller/Session/ISessionManager.cs index 4c3cf5ffe1..c865560958 100644 --- a/MediaBrowser.Controller/Session/ISessionManager.cs +++ b/MediaBrowser.Controller/Session/ISessionManager.cs @@ -6,11 +6,10 @@ using System; using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; +using Jellyfin.Data.Entities.Security; using Jellyfin.Data.Events; using MediaBrowser.Controller.Authentication; using MediaBrowser.Controller.Library; -using MediaBrowser.Controller.Security; -using MediaBrowser.Model.Dto; using MediaBrowser.Model.Session; using MediaBrowser.Model.SyncPlay; @@ -83,7 +82,8 @@ namespace MediaBrowser.Controller.Session /// <param name="deviceName">Name of the device.</param> /// <param name="remoteEndPoint">The remote end point.</param> /// <param name="user">The user.</param> - SessionInfo LogSessionActivity(string appName, string appVersion, string deviceId, string deviceName, string remoteEndPoint, Jellyfin.Data.Entities.User user); + /// <returns>A task containing the session information.</returns> + Task<SessionInfo> LogSessionActivity(string appName, string appVersion, string deviceId, string deviceName, string remoteEndPoint, Jellyfin.Data.Entities.User user); /// <summary> /// Used to report that a session controller has connected. @@ -105,7 +105,7 @@ namespace MediaBrowser.Controller.Session /// </summary> /// <param name="info">The info.</param> /// <returns>Task.</returns> - /// <exception cref="ArgumentNullException"></exception> + /// <exception cref="ArgumentNullException">Throws if an argument is null.</exception> Task OnPlaybackProgress(PlaybackProgressInfo info); Task OnPlaybackProgress(PlaybackProgressInfo info, bool isAutomated); @@ -115,14 +115,13 @@ namespace MediaBrowser.Controller.Session /// </summary> /// <param name="info">The info.</param> /// <returns>Task.</returns> - /// <exception cref="ArgumentNullException"></exception> + /// <exception cref="ArgumentNullException">Throws if an argument is null.</exception> Task OnPlaybackStopped(PlaybackStopInfo info); /// <summary> /// Reports the session ended. /// </summary> /// <param name="sessionId">The session identifier.</param> - /// <returns>Task.</returns> void ReportSessionEnded(string sessionId); /// <summary> @@ -158,20 +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. @@ -196,8 +196,8 @@ namespace MediaBrowser.Controller.Session /// <summary> /// Sends the message to admin sessions. /// </summary> - /// <typeparam name="T"></typeparam> - /// <param name="name">The name.</param> + /// <typeparam name="T">Type of data.</typeparam> + /// <param name="name">Message type name.</param> /// <param name="data">The data.</param> /// <param name="cancellationToken">The cancellation token.</param> /// <returns>Task.</returns> @@ -206,18 +206,31 @@ namespace MediaBrowser.Controller.Session /// <summary> /// Sends the message to user sessions. /// </summary> - /// <typeparam name="T"></typeparam> + /// <typeparam name="T">Type of data.</typeparam> + /// <param name="userIds">Users to send messages to.</param> + /// <param name="name">Message type name.</param> + /// <param name="data">The data.</param> + /// <param name="cancellationToken">The cancellation token.</param> /// <returns>Task.</returns> Task SendMessageToUserSessions<T>(List<Guid> userIds, SessionMessageType name, T data, CancellationToken cancellationToken); + /// <summary> + /// Sends the message to user sessions. + /// </summary> + /// <typeparam name="T">Type of data.</typeparam> + /// <param name="userIds">Users to send messages to.</param> + /// <param name="name">Message type name.</param> + /// <param name="dataFn">Data function.</param> + /// <param name="cancellationToken">The cancellation token.</param> + /// <returns>Task.</returns> Task SendMessageToUserSessions<T>(List<Guid> userIds, SessionMessageType name, Func<T> dataFn, CancellationToken cancellationToken); /// <summary> /// Sends the message to user device sessions. /// </summary> - /// <typeparam name="T"></typeparam> + /// <typeparam name="T">Type of data.</typeparam> /// <param name="deviceId">The device identifier.</param> - /// <param name="name">The name.</param> + /// <param name="name">Message type name.</param> /// <param name="data">The data.</param> /// <param name="cancellationToken">The cancellation token.</param> /// <returns>Task.</returns> @@ -265,13 +278,6 @@ namespace MediaBrowser.Controller.Session /// <param name="itemId">The item identifier.</param> void ReportNowViewingItem(string sessionId, string itemId); - /// <summary> - /// Reports the now viewing item. - /// </summary> - /// <param name="sessionId">The session identifier.</param> - /// <param name="item">The item.</param> - void ReportNowViewingItem(string sessionId, BaseItemDto item); - /// <summary> /// Authenticates the new session. /// </summary> @@ -279,20 +285,7 @@ namespace MediaBrowser.Controller.Session /// <returns>Task{SessionInfo}.</returns> Task<AuthenticationResult> AuthenticateNewSession(AuthenticationRequest request); - /// <summary> - /// Authenticates a new session with quick connect. - /// </summary> - /// <param name="request">The request.</param> - /// <param name="token">Quick connect access token.</param> - /// <returns>Task{SessionInfo}.</returns> - Task<AuthenticationResult> AuthenticateQuickConnect(AuthenticationRequest request, string token); - - /// <summary> - /// Creates the new session. - /// </summary> - /// <param name="request">The request.</param> - /// <returns>Task<AuthenticationResult>.</returns> - Task<AuthenticationResult> CreateNewSession(AuthenticationRequest request); + Task<AuthenticationResult> AuthenticateDirect(AuthenticationRequest request); /// <summary> /// Reports the capabilities. @@ -330,7 +323,7 @@ namespace MediaBrowser.Controller.Session /// <param name="deviceId">The device identifier.</param> /// <param name="remoteEndpoint">The remote endpoint.</param> /// <returns>SessionInfo.</returns> - SessionInfo GetSessionByAuthenticationToken(string token, string deviceId, string remoteEndpoint); + Task<SessionInfo> GetSessionByAuthenticationToken(string token, string deviceId, string remoteEndpoint); /// <summary> /// Gets the session by authentication token. @@ -340,26 +333,24 @@ namespace MediaBrowser.Controller.Session /// <param name="remoteEndpoint">The remote endpoint.</param> /// <param name="appVersion">The application version.</param> /// <returns>Task<SessionInfo>.</returns> - SessionInfo GetSessionByAuthenticationToken(AuthenticationInfo info, string deviceId, string remoteEndpoint, string appVersion); + Task<SessionInfo> GetSessionByAuthenticationToken(Device info, string deviceId, string remoteEndpoint, string appVersion); /// <summary> /// Logouts the specified access token. /// </summary> /// <param name="accessToken">The access token.</param> - void Logout(string accessToken); + /// <returns>A <see cref="Task"/> representing the log out process.</returns> + Task Logout(string accessToken); - void Logout(AuthenticationInfo accessToken); + Task Logout(Device device); /// <summary> /// Revokes the user tokens. /// </summary> - void RevokeUserTokens(Guid userId, string currentAccessToken); - - /// <summary> - /// Revokes the token. - /// </summary> - /// <param name="id">The identifier.</param> - void RevokeToken(string id); + /// <param name="userId">The user's id.</param> + /// <param name="currentAccessToken">The current access token.</param> + /// <returns>Task.</returns> + Task RevokeUserTokens(Guid userId, string currentAccessToken); void CloseIfNeeded(SessionInfo session); } diff --git a/MediaBrowser.Controller/Subtitles/ISubtitleManager.cs b/MediaBrowser.Controller/Subtitles/ISubtitleManager.cs index 9e661cbe42..52aa44024d 100644 --- a/MediaBrowser.Controller/Subtitles/ISubtitleManager.cs +++ b/MediaBrowser.Controller/Subtitles/ISubtitleManager.cs @@ -28,10 +28,17 @@ namespace MediaBrowser.Controller.Subtitles /// <summary> /// Searches the subtitles. /// </summary> + /// <param name="video">The video.</param> + /// <param name="language">Subtitle language.</param> + /// <param name="isPerfectMatch">Require perfect match.</param> + /// <param name="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> @@ -47,11 +54,20 @@ namespace MediaBrowser.Controller.Subtitles /// <summary> /// Downloads the subtitles. /// </summary> + /// <param name="video">The video.</param> + /// <param name="subtitleId">Subtitle ID.</param> + /// <param name="cancellationToken">CancellationToken to use for the operation.</param> + /// <returns>A task.</returns> Task DownloadSubtitles(Video video, string subtitleId, CancellationToken cancellationToken); /// <summary> /// Downloads the subtitles. /// </summary> + /// <param name="video">The video.</param> + /// <param name="libraryOptions">Library options to use.</param> + /// <param name="subtitleId">Subtitle ID.</param> + /// <param name="cancellationToken">CancellationToken to use for the operation.</param> + /// <returns>A task.</returns> Task DownloadSubtitles(Video video, LibraryOptions libraryOptions, string subtitleId, CancellationToken cancellationToken); /// <summary> @@ -73,11 +89,16 @@ namespace MediaBrowser.Controller.Subtitles /// <summary> /// Deletes the subtitles. /// </summary> + /// <param name="item">Media item.</param> + /// <param name="index">Subtitle index.</param> + /// <returns>A task.</returns> Task DeleteSubtitles(BaseItem item, int index); /// <summary> /// Gets the providers. /// </summary> + /// <param name="item">The media item.</param> + /// <returns>Subtitles providers.</returns> SubtitleProviderInfo[] GetSupportedProviders(BaseItem item); } } diff --git a/MediaBrowser.Controller/Subtitles/SubtitleSearchRequest.cs b/MediaBrowser.Controller/Subtitles/SubtitleSearchRequest.cs index 0f7c47e767..ef052237af 100644 --- a/MediaBrowser.Controller/Subtitles/SubtitleSearchRequest.cs +++ b/MediaBrowser.Controller/Subtitles/SubtitleSearchRequest.cs @@ -11,6 +11,15 @@ namespace MediaBrowser.Controller.Subtitles { public class SubtitleSearchRequest : IHasProviderIds { + public SubtitleSearchRequest() + { + SearchAllProviders = true; + ProviderIds = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase); + + DisabledSubtitleFetchers = Array.Empty<string>(); + SubtitleFetcherOrder = Array.Empty<string>(); + } + public string Language { get; set; } public string TwoLetterISOLanguageName { get; set; } @@ -43,13 +52,6 @@ namespace MediaBrowser.Controller.Subtitles public string[] SubtitleFetcherOrder { get; set; } - public SubtitleSearchRequest() - { - SearchAllProviders = true; - ProviderIds = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase); - - DisabledSubtitleFetchers = Array.Empty<string>(); - SubtitleFetcherOrder = Array.Empty<string>(); - } + public bool IsAutomated { get; set; } } } diff --git a/MediaBrowser.Controller/SyncPlay/GroupMember.cs b/MediaBrowser.Controller/SyncPlay/GroupMember.cs index 7e7e759a51..b973672c40 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/AbstractGroupState.cs b/MediaBrowser.Controller/SyncPlay/GroupStates/AbstractGroupState.cs index 91a13fb28e..51c95a1bb2 100644 --- a/MediaBrowser.Controller/SyncPlay/GroupStates/AbstractGroupState.cs +++ b/MediaBrowser.Controller/SyncPlay/GroupStates/AbstractGroupState.cs @@ -68,7 +68,16 @@ namespace MediaBrowser.Controller.SyncPlay.GroupStates /// <inheritdoc /> public virtual void HandleRequest(RemoveFromPlaylistGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken) { - var playingItemRemoved = context.RemoveFromPlayQueue(request.PlaylistItemIds); + bool playingItemRemoved; + if (request.ClearPlaylist) + { + context.ClearPlayQueue(request.ClearPlayingItem); + playingItemRemoved = request.ClearPlayingItem; + } + else + { + playingItemRemoved = context.RemoveFromPlayQueue(request.PlaylistItemIds); + } var playQueueUpdate = context.GetPlayQueueUpdate(PlayQueueUpdateReason.RemoveItems); var update = context.NewSyncPlayGroupUpdate(GroupUpdateType.PlayQueue, playQueueUpdate); diff --git a/MediaBrowser.Controller/SyncPlay/IGroupStateContext.cs b/MediaBrowser.Controller/SyncPlay/IGroupStateContext.cs index de26c7d9ef..d2de224503 100644 --- a/MediaBrowser.Controller/SyncPlay/IGroupStateContext.cs +++ b/MediaBrowser.Controller/SyncPlay/IGroupStateContext.cs @@ -162,6 +162,12 @@ namespace MediaBrowser.Controller.SyncPlay /// <returns><c>true</c> if the play queue has been changed; <c>false</c> if something went wrong.</returns> bool SetPlayingItem(Guid playlistItemId); + /// <summary> + /// Clears the play queue. + /// </summary> + /// <param name="clearPlayingItem">Whether to remove the playing item as well.</param> + void ClearPlayQueue(bool clearPlayingItem); + /// <summary> /// Removes items from the play queue. /// </summary> diff --git a/MediaBrowser.Controller/SyncPlay/PlaybackRequests/RemoveFromPlaylistGroupRequest.cs b/MediaBrowser.Controller/SyncPlay/PlaybackRequests/RemoveFromPlaylistGroupRequest.cs index 6891452934..2f38d6adc3 100644 --- a/MediaBrowser.Controller/SyncPlay/PlaybackRequests/RemoveFromPlaylistGroupRequest.cs +++ b/MediaBrowser.Controller/SyncPlay/PlaybackRequests/RemoveFromPlaylistGroupRequest.cs @@ -17,9 +17,13 @@ namespace MediaBrowser.Controller.SyncPlay.PlaybackRequests /// Initializes a new instance of the <see cref="RemoveFromPlaylistGroupRequest"/> class. /// </summary> /// <param name="items">The playlist ids of the items to remove.</param> - public RemoveFromPlaylistGroupRequest(IReadOnlyList<Guid> items) + /// <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>(); + ClearPlaylist = clearPlaylist; + ClearPlayingItem = clearPlayingItem; } /// <summary> @@ -28,6 +32,18 @@ namespace MediaBrowser.Controller.SyncPlay.PlaybackRequests /// <value>The playlist identifiers ot the items.</value> public IReadOnlyList<Guid> PlaylistItemIds { get; } + /// <summary> + /// Gets a value indicating whether the entire playlist should be cleared. + /// </summary> + /// <value>Whether the entire playlist should be cleared.</value> + public bool ClearPlaylist { get; } + + /// <summary> + /// Gets a value indicating whether the playing item should be removed as well. + /// </summary> + /// <value>Whether the playing item should be removed as well.</value> + public bool ClearPlayingItem { get; } + /// <inheritdoc /> public override PlaybackRequestType Action { get; } = PlaybackRequestType.RemoveFromPlaylist; diff --git a/MediaBrowser.Controller/SyncPlay/Queue/PlayQueueManager.cs b/MediaBrowser.Controller/SyncPlay/Queue/PlayQueueManager.cs index b8ae9f3ff6..f49876cca4 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/LocalImageProvider.cs b/MediaBrowser.LocalMetadata/Images/LocalImageProvider.cs index 31475e22f4..988581df92 100644 --- a/MediaBrowser.LocalMetadata/Images/LocalImageProvider.cs +++ b/MediaBrowser.LocalMetadata/Images/LocalImageProvider.cs @@ -60,8 +60,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> @@ -283,7 +281,7 @@ namespace MediaBrowser.LocalMetadata.Images { imageFileNames = _seriesImageFileNames; } - else if (item is Video && !(item is Episode)) + else if (item is Video && item is not Episode) { imageFileNames = _videoImageFileNames; } @@ -434,7 +432,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 eb2077a5ff..a3db717b9f 100644 --- a/MediaBrowser.LocalMetadata/MediaBrowser.LocalMetadata.csproj +++ b/MediaBrowser.LocalMetadata/MediaBrowser.LocalMetadata.csproj @@ -11,13 +11,9 @@ </ItemGroup> <PropertyGroup> - <TargetFramework>net5.0</TargetFramework> + <TargetFramework>net6.0</TargetFramework> <GenerateAssemblyInfo>false</GenerateAssemblyInfo> <GenerateDocumentationFile>true</GenerateDocumentationFile> - <TreatWarningsAsErrors>true</TreatWarningsAsErrors> - <Nullable>enable</Nullable> - <AnalysisMode>AllEnabledByDefault</AnalysisMode> - <CodeAnalysisRuleSet>../jellyfin.ruleset</CodeAnalysisRuleSet> </PropertyGroup> <ItemGroup> diff --git a/MediaBrowser.LocalMetadata/Parsers/BaseItemXmlParser.cs b/MediaBrowser.LocalMetadata/Parsers/BaseItemXmlParser.cs index 32e5ac7615..5a36c16630 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> @@ -81,7 +80,7 @@ namespace MediaBrowser.LocalMetadata.Parsers var id = info.Key + "Id"; if (!_validProviderIds.ContainsKey(id)) { - _validProviderIds.Add(id, info.Key!); + _validProviderIds.Add(id, info.Key); } } @@ -144,9 +143,9 @@ 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 { @@ -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; } @@ -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; } } @@ -750,46 +749,6 @@ namespace MediaBrowser.LocalMetadata.Parsers item.Shares = list.ToArray(); } - private Share GetShareFromNode(XmlReader reader) - { - var share = new Share(); - - reader.MoveToContent(); - reader.Read(); - - // Loop through each element - while (!reader.EOF && reader.ReadState == ReadState.Interactive) - { - if (reader.NodeType == XmlNodeType.Element) - { - switch (reader.Name) - { - case "UserId": - { - share.UserId = reader.ReadElementContentAsString(); - break; - } - - case "CanEdit": - { - share.CanEdit = string.Equals(reader.ReadElementContentAsString(), true.ToString(CultureInfo.InvariantCulture), StringComparison.OrdinalIgnoreCase); - break; - } - - default: - reader.Skip(); - break; - } - } - else - { - reader.Read(); - } - } - - return share; - } - private void FetchFromCountriesNode(XmlReader reader) { reader.MoveToContent(); @@ -1101,7 +1060,7 @@ namespace MediaBrowser.LocalMetadata.Parsers switch (reader.Name) { case "Name": - name = reader.ReadElementContentAsString() ?? string.Empty; + name = reader.ReadElementContentAsString(); break; case "Type": @@ -1134,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; } @@ -1270,8 +1229,6 @@ namespace MediaBrowser.LocalMetadata.Parsers /// <returns>IEnumerable{System.String}.</returns> private IEnumerable<string> SplitNames(string value) { - value ??= string.Empty; - // Only split by comma if there is no pipe in the string // We have to be careful to not split names like Matthew, Jr. var separator = !value.Contains('|', StringComparison.Ordinal) diff --git a/MediaBrowser.LocalMetadata/Savers/BaseXmlSaver.cs b/MediaBrowser.LocalMetadata/Savers/BaseXmlSaver.cs index 98ed3dcf7d..5e7af23fc3 100644 --- a/MediaBrowser.LocalMetadata/Savers/BaseXmlSaver.cs +++ b/MediaBrowser.LocalMetadata/Savers/BaseXmlSaver.cs @@ -25,24 +25,18 @@ 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> /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param> /// <param name="configurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param> /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> - /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> - /// <param name="userDataManager">Instance of the <see cref="IUserDataManager"/> interface.</param> /// <param name="logger">Instance of the <see cref="ILogger{BaseXmlSaver}"/> interface.</param> - protected BaseXmlSaver(IFileSystem fileSystem, IServerConfigurationManager configurationManager, ILibraryManager libraryManager, IUserManager userManager, IUserDataManager userDataManager, ILogger<BaseXmlSaver> logger) + protected BaseXmlSaver(IFileSystem fileSystem, IServerConfigurationManager configurationManager, ILibraryManager libraryManager, ILogger<BaseXmlSaver> logger) { FileSystem = fileSystem; ConfigurationManager = configurationManager; LibraryManager = libraryManager; - UserManager = userManager; - UserDataManager = userDataManager; Logger = logger; } @@ -61,16 +55,6 @@ namespace MediaBrowser.LocalMetadata.Savers /// </summary> protected ILibraryManager LibraryManager { get; private set; } - /// <summary> - /// Gets the user manager. - /// </summary> - protected IUserManager UserManager { get; private set; } - - /// <summary> - /// Gets the user data manager. - /// </summary> - protected IUserDataManager UserDataManager { get; private set; } - /// <summary> /// Gets the logger. /// </summary> @@ -129,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); } @@ -219,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)) @@ -237,7 +229,7 @@ namespace MediaBrowser.LocalMetadata.Savers writer.WriteElementString("CustomRating", item.CustomRating); } - if (!string.IsNullOrEmpty(item.Name) && !(item is Episode)) + if (!string.IsNullOrEmpty(item.Name) && item is not Episode) { writer.WriteElementString("LocalTitle", item.Name); } @@ -254,7 +246,7 @@ namespace MediaBrowser.LocalMetadata.Savers { writer.WriteElementString("BirthDate", item.PremiereDate.Value.ToLocalTime().ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)); } - else if (!(item is Episode)) + else if (item is not Episode) { writer.WriteElementString("PremiereDate", item.PremiereDate.Value.ToLocalTime().ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)); } @@ -266,7 +258,7 @@ namespace MediaBrowser.LocalMetadata.Savers { writer.WriteElementString("DeathDate", item.EndDate.Value.ToLocalTime().ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)); } - else if (!(item is Episode)) + else if (item is not Episode) { writer.WriteElementString("EndDate", item.EndDate.Value.ToLocalTime().ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)); } @@ -303,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 Person)) + 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) @@ -334,9 +326,9 @@ namespace MediaBrowser.LocalMetadata.Savers if (runTimeTicks.HasValue) { - var timespan = TimeSpan.FromTicks(runTimeTicks!.Value); + var timespan = TimeSpan.FromTicks(runTimeTicks.Value); - writer.WriteElementString("RunningTime", Math.Floor(timespan.TotalMinutes).ToString(_usCulture)); + writer.WriteElementString("RunningTime", Math.Floor(timespan.TotalMinutes).ToString(CultureInfo.InvariantCulture)); } if (item.ProviderIds != null) @@ -409,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.LocalMetadata/Savers/BoxSetXmlSaver.cs b/MediaBrowser.LocalMetadata/Savers/BoxSetXmlSaver.cs index b08387b0c6..8a5da95bf3 100644 --- a/MediaBrowser.LocalMetadata/Savers/BoxSetXmlSaver.cs +++ b/MediaBrowser.LocalMetadata/Savers/BoxSetXmlSaver.cs @@ -20,11 +20,9 @@ namespace MediaBrowser.LocalMetadata.Savers /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param> /// <param name="configurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param> /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> - /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> - /// <param name="userDataManager">Instance of the <see cref="IUserDataManager"/> interface.</param> /// <param name="logger">Instance of the <see cref="ILogger{BoxSetXmlSaver}"/> interface.</param> - public BoxSetXmlSaver(IFileSystem fileSystem, IServerConfigurationManager configurationManager, ILibraryManager libraryManager, IUserManager userManager, IUserDataManager userDataManager, ILogger<BoxSetXmlSaver> logger) - : base(fileSystem, configurationManager, libraryManager, userManager, userDataManager, logger) + public BoxSetXmlSaver(IFileSystem fileSystem, IServerConfigurationManager configurationManager, ILibraryManager libraryManager, ILogger<BoxSetXmlSaver> logger) + : base(fileSystem, configurationManager, libraryManager, logger) { } diff --git a/MediaBrowser.LocalMetadata/Savers/PlaylistXmlSaver.cs b/MediaBrowser.LocalMetadata/Savers/PlaylistXmlSaver.cs index c2f1064238..76252bc090 100644 --- a/MediaBrowser.LocalMetadata/Savers/PlaylistXmlSaver.cs +++ b/MediaBrowser.LocalMetadata/Savers/PlaylistXmlSaver.cs @@ -25,11 +25,9 @@ namespace MediaBrowser.LocalMetadata.Savers /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param> /// <param name="configurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param> /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> - /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> - /// <param name="userDataManager">Instance of the <see cref="IUserDataManager"/> interface.</param> /// <param name="logger">Instance of the <see cref="ILogger{PlaylistXmlSaver}"/> interface.</param> - public PlaylistXmlSaver(IFileSystem fileSystem, IServerConfigurationManager configurationManager, ILibraryManager libraryManager, IUserManager userManager, IUserDataManager userDataManager, ILogger<PlaylistXmlSaver> logger) - : base(fileSystem, configurationManager, libraryManager, userManager, userDataManager, logger) + public PlaylistXmlSaver(IFileSystem fileSystem, IServerConfigurationManager configurationManager, ILibraryManager libraryManager, ILogger<PlaylistXmlSaver> logger) + : base(fileSystem, configurationManager, libraryManager, logger) { } diff --git a/MediaBrowser.MediaEncoding/Attachments/AttachmentExtractor.cs b/MediaBrowser.MediaEncoding/Attachments/AttachmentExtractor.cs index a0ec3bd90d..a524aeaa98 100644 --- a/MediaBrowser.MediaEncoding/Attachments/AttachmentExtractor.cs +++ b/MediaBrowser.MediaEncoding/Attachments/AttachmentExtractor.cs @@ -89,7 +89,7 @@ namespace MediaBrowser.MediaEncoding.Attachments CancellationToken cancellationToken) { var attachmentPath = await GetReadableFile(mediaSource.Path, mediaSource.Path, mediaSource, mediaAttachment, cancellationToken).ConfigureAwait(false); - return File.OpenRead(attachmentPath); + return AsyncFile.OpenRead(attachmentPath); } private async Task<string> GetReadableFile( diff --git a/MediaBrowser.MediaEncoding/BdInfo/BdInfoDirectoryInfo.cs b/MediaBrowser.MediaEncoding/BdInfo/BdInfoDirectoryInfo.cs index e86e518be6..409379c355 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/BdInfo/BdInfoFileInfo.cs b/MediaBrowser.MediaEncoding/BdInfo/BdInfoFileInfo.cs index 41143c2593..d55688e3df 100644 --- a/MediaBrowser.MediaEncoding/BdInfo/BdInfoFileInfo.cs +++ b/MediaBrowser.MediaEncoding/BdInfo/BdInfoFileInfo.cs @@ -24,7 +24,7 @@ namespace MediaBrowser.MediaEncoding.BdInfo public bool IsDir => _impl.IsDirectory; - public System.IO.Stream OpenRead() + public Stream OpenRead() { return new FileStream( FullName, @@ -33,9 +33,9 @@ namespace MediaBrowser.MediaEncoding.BdInfo FileShare.Read); } - public System.IO.StreamReader OpenText() + public StreamReader OpenText() { - return new System.IO.StreamReader(OpenRead()); + return new StreamReader(OpenRead()); } } } diff --git a/MediaBrowser.MediaEncoding/Encoder/EncoderValidator.cs b/MediaBrowser.MediaEncoding/Encoder/EncoderValidator.cs index f782e65bd1..60a2d39e5a 100644 --- a/MediaBrowser.MediaEncoding/Encoder/EncoderValidator.cs +++ b/MediaBrowser.MediaEncoding/Encoder/EncoderValidator.cs @@ -12,8 +12,6 @@ namespace MediaBrowser.MediaEncoding.Encoder { public class EncoderValidator { - private const string DefaultEncoderPath = "ffmpeg"; - private static readonly string[] _requiredDecoders = new[] { "h264", @@ -89,6 +87,24 @@ namespace MediaBrowser.MediaEncoding.Encoder "hevc_videotoolbox" }; + private static readonly string[] _requiredFilters = new[] + { + "scale_cuda", + "yadif_cuda", + "hwupload_cuda", + "overlay_cuda", + "tonemap_cuda", + "tonemap_opencl", + "tonemap_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" } } + }; + // These are the library versions that corresponds to our minimum ffmpeg version 4.x according to the version table below private static readonly IReadOnlyDictionary<string, Version> _ffmpegMinimumLibraryVersions = new Dictionary<string, Version> { @@ -106,7 +122,7 @@ namespace MediaBrowser.MediaEncoding.Encoder private readonly string _encoderPath; - public EncoderValidator(ILogger logger, string encoderPath = DefaultEncoderPath) + public EncoderValidator(ILogger logger, string encoderPath) { _logger = logger; _encoderPath = encoderPath; @@ -156,7 +172,7 @@ namespace MediaBrowser.MediaEncoding.Encoder } // Work out what the version under test is - var version = GetFFmpegVersion(versionOutput); + var version = GetFFmpegVersionInternal(versionOutput); _logger.LogInformation("Found ffmpeg version {Version}", version != null ? version.ToString() : "unknown"); @@ -200,6 +216,34 @@ namespace MediaBrowser.MediaEncoding.Encoder public IEnumerable<string> GetHwaccels() => GetHwaccelTypes(); + public IEnumerable<string> GetFilters() => GetFFmpegFilters(); + + public IDictionary<int, bool> GetFiltersWithOption() => GetFFmpegFiltersWithOption(); + + public Version? GetFFmpegVersion() + { + string output; + try + { + output = GetProcessOutput(_encoderPath, "-version"); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error validating encoder"); + return null; + } + + if (string.IsNullOrWhiteSpace(output)) + { + _logger.LogError("FFmpeg validation: The process returned no result"); + return null; + } + + _logger.LogDebug("ffmpeg output: {Output}", output); + + return GetFFmpegVersionInternal(output); + } + /// <summary> /// Using the output from "ffmpeg -version" work out the FFmpeg version. /// For pre-built binaries the first line should contain a string like "ffmpeg version x.y", which is easy @@ -208,7 +252,7 @@ namespace MediaBrowser.MediaEncoding.Encoder /// </summary> /// <param name="output">The output from "ffmpeg -version".</param> /// <returns>The FFmpeg version.</returns> - internal Version? GetFFmpegVersion(string output) + internal Version? GetFFmpegVersionInternal(string output) { // For pre-built binaries the FFmpeg version should be mentioned at the very start of the output var match = Regex.Match(output, @"^ffmpeg version n?((?:[0-9]+\.?)+)"); @@ -297,9 +341,9 @@ namespace MediaBrowser.MediaEncoding.Encoder return found; } - public bool CheckFilter(string filter, string option) + public bool CheckFilterWithOption(string filter, string option) { - if (string.IsNullOrEmpty(filter)) + if (string.IsNullOrEmpty(filter) || string.IsNullOrEmpty(option)) { return false; } @@ -317,11 +361,6 @@ namespace MediaBrowser.MediaEncoding.Encoder if (output.Contains("Filter " + filter, StringComparison.Ordinal)) { - if (string.IsNullOrEmpty(option)) - { - return true; - } - return output.Contains(option, StringComparison.Ordinal); } @@ -362,6 +401,49 @@ namespace MediaBrowser.MediaEncoding.Encoder return found; } + private IEnumerable<string> GetFFmpegFilters() + { + string output; + try + { + output = GetProcessOutput(_encoderPath, "-filters"); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error detecting available filters"); + return Enumerable.Empty<string>(); + } + + if (string.IsNullOrWhiteSpace(output)) + { + return Enumerable.Empty<string>(); + } + + var found = Regex + .Matches(output, @"^\s\S{3}\s(?<filter>[\w|-]+)\s+.+$", RegexOptions.Multiline) + .Cast<Match>() + .Select(x => x.Groups["filter"].Value) + .Where(x => _requiredFilters.Contains(x)); + + _logger.LogInformation("Available filters: {Filters}", found); + + return found; + } + + private IDictionary<int, bool> GetFFmpegFiltersWithOption() + { + IDictionary<int, bool> dict = new Dictionary<int, bool>(); + for (int i = 0; i < _filterOptionsDict.Count; i++) + { + if (_filterOptionsDict.TryGetValue(i, out var val) && val.Length == 2) + { + dict.Add(i, CheckFilterWithOption(val[0], val[1])); + } + } + + return dict; + } + private string GetProcessOutput(string path, string arguments) { using (var process = new Process() diff --git a/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs b/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs index 412a953216..fbc7ba72f0 100644 --- a/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs +++ b/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs @@ -12,6 +12,7 @@ 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; @@ -23,7 +24,6 @@ using MediaBrowser.Model.Entities; using MediaBrowser.Model.Globalization; using MediaBrowser.Model.IO; using MediaBrowser.Model.MediaInfo; -using MediaBrowser.Model.System; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; @@ -44,11 +44,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; @@ -66,10 +61,13 @@ namespace MediaBrowser.MediaEncoding.Encoder private List<string> _encoders = new List<string>(); private List<string> _decoders = new List<string>(); private List<string> _hwaccels = new List<string>(); + private List<string> _filters = new List<string>(); + private IDictionary<int, bool> _filtersWithOption = new Dictionary<int, bool>(); + private Version _ffmpegVersion = null; private string _ffmpegPath = string.Empty; private string _ffprobePath; - private int threads; + private int _threads; public MediaEncoder( ILogger<MediaEncoder> logger, @@ -89,9 +87,6 @@ namespace MediaBrowser.MediaEncoding.Encoder /// <inheritdoc /> public string EncoderPath => _ffmpegPath; - /// <inheritdoc /> - public FFmpegLocation EncoderLocation { get; private set; } - /// <summary> /// Run at startup or if the user removes a Custom path from transcode page. /// Sets global variables FFmpegPath. @@ -100,20 +95,23 @@ namespace MediaBrowser.MediaEncoding.Encoder public void SetFFmpegPath() { // 1) Custom path stored in config/encoding xml file under tag <EncoderAppPath> takes precedence - if (!ValidatePath(_configurationManager.GetEncodingOptions().EncoderAppPath, FFmpegLocation.Custom)) + var ffmpegPath = _configurationManager.GetEncodingOptions().EncoderAppPath; + if (string.IsNullOrEmpty(ffmpegPath)) { // 2) Check if the --ffmpeg CLI switch has been given - if (!ValidatePath(_startupOptionFFmpegPath, FFmpegLocation.SetByArgument)) + ffmpegPath = _startupOptionFFmpegPath; + if (string.IsNullOrEmpty(ffmpegPath)) { - // 3) Search system $PATH environment variable for valid FFmpeg - if (!ValidatePath(ExistsOnSystemPath("ffmpeg"), FFmpegLocation.System)) - { - EncoderLocation = FFmpegLocation.NotFound; - _ffmpegPath = null; - } + // 3) Check "ffmpeg" + ffmpegPath = "ffmpeg"; } } + if (!ValidatePath(ffmpegPath)) + { + _ffmpegPath = null; + } + // 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; @@ -130,11 +128,15 @@ namespace MediaBrowser.MediaEncoding.Encoder SetAvailableDecoders(validator.GetDecoders()); SetAvailableEncoders(validator.GetEncoders()); + SetAvailableFilters(validator.GetFilters()); + SetAvailableFiltersWithOption(validator.GetFiltersWithOption()); SetAvailableHwaccels(validator.GetHwaccels()); - threads = EncodingHelper.GetNumberOfThreads(null, _configurationManager.GetEncodingOptions(), null); + SetMediaEncoderVersion(validator); + + _threads = EncodingHelper.GetNumberOfThreads(null, _configurationManager.GetEncodingOptions(), null); } - _logger.LogInformation("FFmpeg: {EncoderLocation}: {FfmpegPath}", EncoderLocation, _ffmpegPath ?? string.Empty); + _logger.LogInformation("FFmpeg: {FfmpegPath}", _ffmpegPath ?? string.Empty); } /// <summary> @@ -145,6 +147,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); @@ -153,28 +165,32 @@ namespace MediaBrowser.MediaEncoding.Encoder { throw new ArgumentException("Unexpected pathType value"); } - else if (string.IsNullOrWhiteSpace(path)) + + if (string.IsNullOrWhiteSpace(path)) { // User had cleared the custom path in UI newPath = string.Empty; } - else if (File.Exists(path)) - { - newPath = path; - } - else if (Directory.Exists(path)) - { - // Given path is directory, so resolve down to filename - newPath = GetEncoderPathFromDirectory(path, "ffmpeg"); - } else { - throw new ResourceNotFoundException(); + 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); @@ -184,37 +200,26 @@ namespace MediaBrowser.MediaEncoding.Encoder /// <summary> /// Validates the supplied FQPN to ensure it is a ffmpeg utility. - /// If checks pass, global variable FFmpegPath and EncoderLocation are updated. + /// If checks pass, global variable FFmpegPath is updated. /// </summary> /// <param name="path">FQPN to test.</param> - /// <param name="location">Location (External, Custom, System) of tool.</param> /// <returns><c>true</c> if the version validation succeeded; otherwise, <c>false</c>.</returns> - private bool ValidatePath(string path, FFmpegLocation location) + private bool ValidatePath(string path) { - bool rc = false; - - if (!string.IsNullOrEmpty(path)) + if (string.IsNullOrEmpty(path)) { - if (File.Exists(path)) - { - rc = new EncoderValidator(_logger, path).ValidateVersion(); - - if (!rc) - { - _logger.LogWarning("FFmpeg: {Location}: Failed version check: {Path}", location, path); - } + return false; + } - _ffmpegPath = path; - EncoderLocation = location; - return true; - } - else - { - _logger.LogWarning("FFmpeg: {Location}: File not found: {Path}", location, path); - } + bool rc = new EncoderValidator(_logger, path).ValidateVersion(); + if (!rc) + { + _logger.LogWarning("FFmpeg: Failed version check: {Path}", path); + return false; } - return rc; + _ffmpegPath = path; + return true; } private string GetEncoderPathFromDirectory(string path, string filename, bool recursive = false) @@ -235,34 +240,6 @@ namespace MediaBrowser.MediaEncoding.Encoder } } - /// <summary> - /// Search the system $PATH environment variable looking for given filename. - /// </summary> - /// <param name="fileName">The filename.</param> - /// <returns>The full path to the file.</returns> - private string ExistsOnSystemPath(string fileName) - { - var inJellyfinPath = GetEncoderPathFromDirectory(AppContext.BaseDirectory, fileName, recursive: true); - if (!string.IsNullOrEmpty(inJellyfinPath)) - { - return inJellyfinPath; - } - - var values = Environment.GetEnvironmentVariable("PATH"); - - foreach (var path in values.Split(Path.PathSeparator)) - { - var candidatePath = GetEncoderPathFromDirectory(path, fileName); - - if (!string.IsNullOrEmpty(candidatePath)) - { - return candidatePath; - } - } - - return null; - } - public void SetAvailableEncoders(IEnumerable<string> list) { _encoders = list.ToList(); @@ -278,6 +255,21 @@ namespace MediaBrowser.MediaEncoding.Encoder _hwaccels = list.ToList(); } + public void SetAvailableFilters(IEnumerable<string> list) + { + _filters = list.ToList(); + } + + public void SetAvailableFiltersWithOption(IDictionary<int, bool> dict) + { + _filtersWithOption = dict; + } + + public void SetMediaEncoderVersion(EncoderValidator validator) + { + _ffmpegVersion = validator.GetFFmpegVersion(); + } + public bool SupportsEncoder(string encoder) { return _encoders.Contains(encoder, StringComparer.OrdinalIgnoreCase); @@ -293,17 +285,26 @@ namespace MediaBrowser.MediaEncoding.Encoder return _hwaccels.Contains(hwaccel, StringComparer.OrdinalIgnoreCase); } - public bool SupportsFilter(string filter, string option) + public bool SupportsFilter(string filter) { - if (_ffmpegPath != null) + return _filters.Contains(filter, StringComparer.OrdinalIgnoreCase); + } + + public bool SupportsFilterWithOption(FilterOptionType option) + { + if (_filtersWithOption.TryGetValue((int)option, out var val)) { - var validator = new EncoderValidator(_logger, _ffmpegPath); - return validator.CheckFilter(filter, option); + return val; } return false; } + public Version GetMediaEncoderVersion() + { + return _ffmpegVersion; + } + public bool CanEncodeToAudioCodec(string codec) { if (string.Equals(codec, "opus", StringComparison.OrdinalIgnoreCase)) @@ -394,7 +395,7 @@ namespace MediaBrowser.MediaEncoding.Encoder var args = extractChapters ? "{0} -i {1} -threads {2} -v warning -print_format json -show_streams -show_chapters -show_format" : "{0} -i {1} -threads {2} -v warning -print_format json -show_streams -show_format"; - args = string.Format(CultureInfo.InvariantCulture, args, probeSizeArgument, inputPath, threads).Trim(); + args = string.Format(CultureInfo.InvariantCulture, args, probeSizeArgument, inputPath, _threads).Trim(); var process = new Process { @@ -477,17 +478,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, ".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, ".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, string outputExtension, CancellationToken cancellationToken) { - return ExtractImage(inputFile, container, imageStream, imageStreamIndex, mediaSource, false, null, null, cancellationToken); + return ExtractImage(inputFile, container, imageStream, imageStreamIndex, mediaSource, false, null, null, outputExtension, cancellationToken); } private async Task<string> ExtractImage( @@ -499,24 +500,17 @@ namespace MediaBrowser.MediaEncoding.Encoder bool isAudio, Video3DFormat? threedFormat, TimeSpan? offset, + string outputExtension, CancellationToken cancellationToken) { var inputArgument = GetInputArgument(inputFile, mediaSource); - if (isAudio) - { - if (imageStreamIndex.HasValue && imageStreamIndex.Value > 0) - { - // It seems for audio files we need to subtract 1 (for the audio stream??) - imageStreamIndex = imageStreamIndex.Value - 1; - } - } - else + 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); + return await ExtractImageInternal(inputArgument, container, videoStream, imageStreamIndex, threedFormat, offset, true, true, outputExtension, cancellationToken).ConfigureAwait(false); } catch (ArgumentException) { @@ -529,7 +523,7 @@ namespace MediaBrowser.MediaEncoding.Encoder try { - return await ExtractImageInternal(inputArgument, container, videoStream, imageStreamIndex, threedFormat, offset, false, true, cancellationToken).ConfigureAwait(false); + return await ExtractImageInternal(inputArgument, container, videoStream, imageStreamIndex, threedFormat, offset, false, true, outputExtension, cancellationToken).ConfigureAwait(false); } catch (ArgumentException) { @@ -542,7 +536,7 @@ namespace MediaBrowser.MediaEncoding.Encoder 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, false, outputExtension, cancellationToken).ConfigureAwait(false); } catch (ArgumentException) { @@ -554,17 +548,26 @@ 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, false, outputExtension, 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, bool allowTonemap, string outputExtension, CancellationToken cancellationToken) { if (string.IsNullOrEmpty(inputPath)) { throw new ArgumentNullException(nameof(inputPath)); } - var tempExtractPath = Path.Combine(_configurationManager.ApplicationPaths.TempDirectory, Guid.NewGuid() + ".jpg"); + if (string.IsNullOrEmpty(outputExtension)) + { + outputExtension = ".jpg"; + } + else if (outputExtension[0] != '.') + { + outputExtension = "." + outputExtension; + } + + var tempExtractPath = Path.Combine(_configurationManager.ApplicationPaths.TempDirectory, Guid.NewGuid() + outputExtension); Directory.CreateDirectory(Path.GetDirectoryName(tempExtractPath)); // apply some filters to thumbnail extracted below (below) crop any black lines that we made and get the correct ar. @@ -582,7 +585,7 @@ namespace MediaBrowser.MediaEncoding.Encoder _ => string.Empty }; - var mapArg = imageStreamIndex.HasValue ? (" -map 0:v:" + imageStreamIndex.Value.ToString(CultureInfo.InvariantCulture)) : string.Empty; + 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) @@ -615,7 +618,7 @@ namespace MediaBrowser.MediaEncoding.Encoder } } - var args = string.Format(CultureInfo.InvariantCulture, "-i {0}{3} -threads {4} -v quiet -vframes 1 {2} -f image2 \"{1}\"", inputPath, tempExtractPath, vf, mapArg, threads); + var args = string.Format(CultureInfo.InvariantCulture, "-i {0}{3} -threads {4} -v quiet -vframes 1 {2} -f image2 \"{1}\"", inputPath, tempExtractPath, vf, mapArg, _threads); if (offset.HasValue) { @@ -699,7 +702,7 @@ namespace MediaBrowser.MediaEncoding.Encoder public string GetTimeParameter(TimeSpan time) { - return time.ToString(@"hh\:mm\:ss\.fff", _usCulture); + return time.ToString(@"hh\:mm\:ss\.fff", CultureInfo.InvariantCulture); } public async Task ExtractVideoImagesOnInterval( @@ -716,11 +719,11 @@ namespace MediaBrowser.MediaEncoding.Encoder { var inputArgument = GetInputArgument(inputFile, mediaSource); - var vf = "fps=fps=1/" + interval.TotalSeconds.ToString(_usCulture); + var vf = "fps=fps=1/" + interval.TotalSeconds.ToString(CultureInfo.InvariantCulture); if (maxWidth.HasValue) { - var maxWidthParam = maxWidth.Value.ToString(_usCulture); + var maxWidthParam = maxWidth.Value.ToString(CultureInfo.InvariantCulture); vf += string.Format(CultureInfo.InvariantCulture, ",scale=min(iw\\,{0}):trunc(ow/dar/2)*2", maxWidthParam); } @@ -728,7 +731,7 @@ namespace MediaBrowser.MediaEncoding.Encoder Directory.CreateDirectory(targetDirectory); var outputPath = Path.Combine(targetDirectory, filenamePrefix + "%05d.jpg"); - var args = string.Format(CultureInfo.InvariantCulture, "-i {0} -threads {3} -v quiet {2} -f image2 \"{1}\"", inputArgument, outputPath, vf, threads); + var args = string.Format(CultureInfo.InvariantCulture, "-i {0} -threads {3} -v quiet {2} -f image2 \"{1}\"", inputArgument, outputPath, vf, _threads); if (!string.IsNullOrWhiteSpace(container)) { diff --git a/MediaBrowser.MediaEncoding/MediaBrowser.MediaEncoding.csproj b/MediaBrowser.MediaEncoding/MediaBrowser.MediaEncoding.csproj index 7733e715f2..a6caca8db7 100644 --- a/MediaBrowser.MediaEncoding/MediaBrowser.MediaEncoding.csproj +++ b/MediaBrowser.MediaEncoding/MediaBrowser.MediaEncoding.csproj @@ -6,13 +6,9 @@ </PropertyGroup> <PropertyGroup> - <TargetFramework>net5.0</TargetFramework> + <TargetFramework>net6.0</TargetFramework> <GenerateAssemblyInfo>false</GenerateAssemblyInfo> <GenerateDocumentationFile>true</GenerateDocumentationFile> - <TreatWarningsAsErrors>true</TreatWarningsAsErrors> - <Nullable>enable</Nullable> - <AnalysisMode>AllEnabledByDefault</AnalysisMode> - <CodeAnalysisRuleSet>../jellyfin.ruleset</CodeAnalysisRuleSet> </PropertyGroup> <ItemGroup> @@ -27,10 +23,10 @@ <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.3.0" /> + <PackageReference Include="libse" Version="3.6.2" /> + <PackageReference Include="Microsoft.Extensions.Http" Version="6.0.0-rc.2*" /> + <PackageReference Include="System.Text.Encoding.CodePages" Version="6.0.0-rc.2*" /> + <PackageReference Include="UTF.Unknown" Version="2.4.0" /> </ItemGroup> <!-- Code Analyzers--> diff --git a/MediaBrowser.MediaEncoding/Probing/FFProbeHelpers.cs b/MediaBrowser.MediaEncoding/Probing/FFProbeHelpers.cs index 1fa90bb213..a9e753726d 100644 --- a/MediaBrowser.MediaEncoding/Probing/FFProbeHelpers.cs +++ b/MediaBrowser.MediaEncoding/Probing/FFProbeHelpers.cs @@ -1,5 +1,3 @@ -#nullable disable - using System; using System.Collections.Generic; using System.Globalization; @@ -22,7 +20,7 @@ namespace MediaBrowser.MediaEncoding.Probing throw new ArgumentNullException(nameof(result)); } - if (result.Format != null && result.Format.Tags != null) + if (result.Format?.Tags != null) { result.Format.Tags = ConvertDictionaryToCaseInsensitive(result.Format.Tags); } @@ -40,39 +38,17 @@ namespace MediaBrowser.MediaEncoding.Probing } } - /// <summary> - /// Gets a string from an FFProbeResult tags dictionary. - /// </summary> - /// <param name="tags">The tags.</param> - /// <param name="key">The key.</param> - /// <returns>System.String.</returns> - public static string GetDictionaryValue(IReadOnlyDictionary<string, string> tags, string key) - { - if (tags == null) - { - return null; - } - - tags.TryGetValue(key, out var val); - return val; - } - /// <summary> /// Gets an int from an FFProbeResult tags dictionary. /// </summary> /// <param name="tags">The tags.</param> /// <param name="key">The key.</param> /// <returns>System.Nullable{System.Int32}.</returns> - public static int? GetDictionaryNumericValue(Dictionary<string, string> tags, string key) + public static int? GetDictionaryNumericValue(IReadOnlyDictionary<string, string> tags, string key) { - var val = GetDictionaryValue(tags, key); - - if (!string.IsNullOrEmpty(val)) + if (tags.TryGetValue(key, out var val) && int.TryParse(val, out var i)) { - if (int.TryParse(val, out var i)) - { - return i; - } + return i; } return null; @@ -84,18 +60,13 @@ namespace MediaBrowser.MediaEncoding.Probing /// <param name="tags">The tags.</param> /// <param name="key">The key.</param> /// <returns>System.Nullable{DateTime}.</returns> - public static DateTime? GetDictionaryDateTime(Dictionary<string, string> tags, string key) + public static DateTime? GetDictionaryDateTime(IReadOnlyDictionary<string, string> tags, string key) { - var val = GetDictionaryValue(tags, key); - - if (string.IsNullOrEmpty(val)) - { - return null; - } - - if (DateTime.TryParse(val, DateTimeFormatInfo.CurrentInfo, DateTimeStyles.AssumeUniversal, out var i)) + if (tags.TryGetValue(key, out var val) + && (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 i.ToUniversalTime(); + return dateTime; } return null; diff --git a/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs b/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs index dd3faf5766..c9885ae76a 100644 --- a/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs +++ b/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs @@ -7,7 +7,9 @@ using System.Globalization; using System.IO; using System.Linq; using System.Text; +using System.Text.RegularExpressions; using System.Xml; +using Jellyfin.Extensions; using MediaBrowser.Controller.Library; using MediaBrowser.Model.Dto; using MediaBrowser.Model.Entities; @@ -26,7 +28,8 @@ namespace MediaBrowser.MediaEncoding.Probing private readonly char[] _nameDelimiters = { '/', '|', ';', '\\' }; - private readonly CultureInfo _usCulture = new CultureInfo("en-US"); + private static readonly Regex _performerPattern = new (@"(?<name>.*) \((?<instrument>.*)\)"); + private readonly ILogger _logger; private readonly ILocalizationManager _localization; @@ -38,7 +41,17 @@ namespace MediaBrowser.MediaEncoding.Probing _localization = localization; } - private IReadOnlyList<string> SplitWhitelist => _splitWhiteList ??= new string[] { "AC/DC" }; + private IReadOnlyList<string> SplitWhitelist => _splitWhiteList ??= new string[] + { + "AC/DC", + "Au/Ra", + "이달의 소녀 1/3", + "LOONA 1/3", + "LOONA / yyxy", + "LOONA / ODD EYE CIRCLE", + "K/DA", + "22/7" + }; public MediaInfo GetMediaInfo(InternalMediaInfoResult data, VideoType? videoType, bool isAudio, string path, MediaProtocol protocol) { @@ -70,7 +83,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; } @@ -80,72 +93,38 @@ namespace MediaBrowser.MediaEncoding.Probing var tags = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase); var tagStreamType = isAudio ? "audio" : "video"; - if (data.Streams != null) - { - var tagStream = data.Streams.FirstOrDefault(i => string.Equals(i.CodecType, tagStreamType, StringComparison.OrdinalIgnoreCase)); + var tagStream = data.Streams?.FirstOrDefault(i => string.Equals(i.CodecType, tagStreamType, StringComparison.OrdinalIgnoreCase)); - if (tagStream != null && tagStream.Tags != null) + if (tagStream?.Tags != null) + { + foreach (var (key, value) in tagStream.Tags) { - foreach (var pair in tagStream.Tags) - { - tags[pair.Key] = pair.Value; - } + tags[key] = value; } } - if (data.Format != null && data.Format.Tags != null) + if (data.Format?.Tags != null) { - foreach (var pair in data.Format.Tags) + foreach (var (key, value) in data.Format.Tags) { - tags[pair.Key] = pair.Value; + tags[key] = value; } } FetchGenres(info, tags); - var overview = FFProbeHelpers.GetDictionaryValue(tags, "synopsis"); - - if (string.IsNullOrWhiteSpace(overview)) - { - overview = FFProbeHelpers.GetDictionaryValue(tags, "description"); - } - - if (string.IsNullOrWhiteSpace(overview)) - { - overview = FFProbeHelpers.GetDictionaryValue(tags, "desc"); - } - - if (!string.IsNullOrWhiteSpace(overview)) - { - info.Overview = overview; - } - var title = FFProbeHelpers.GetDictionaryValue(tags, "title"); - if (!string.IsNullOrWhiteSpace(title)) - { - info.Name = title; - } - else - { - title = FFProbeHelpers.GetDictionaryValue(tags, "title-eng"); - if (!string.IsNullOrWhiteSpace(title)) - { - info.Name = title; - } - } - - var titleSort = FFProbeHelpers.GetDictionaryValue(tags, "titlesort"); - if (!string.IsNullOrWhiteSpace(titleSort)) - { - info.ForcedSortName = titleSort; - } + info.Name = tags.GetFirstNotNullNorWhiteSpaceValue("title", "title-eng"); + info.ForcedSortName = tags.GetFirstNotNullNorWhiteSpaceValue("sort_name", "title-sort", "titlesort"); + info.Overview = tags.GetFirstNotNullNorWhiteSpaceValue("synopsis", "description", "desc"); info.IndexNumber = FFProbeHelpers.GetDictionaryNumericValue(tags, "episode_sort"); info.ParentIndexNumber = FFProbeHelpers.GetDictionaryNumericValue(tags, "season_number"); - info.ShowName = FFProbeHelpers.GetDictionaryValue(tags, "show_name"); + info.ShowName = tags.GetValueOrDefault("show_name"); info.ProductionYear = FFProbeHelpers.GetDictionaryNumericValue(tags, "date"); // Several different forms of retail/premiere date info.PremiereDate = + FFProbeHelpers.GetDictionaryDateTime(tags, "originaldate") ?? FFProbeHelpers.GetDictionaryDateTime(tags, "retaildate") ?? FFProbeHelpers.GetDictionaryDateTime(tags, "retail date") ?? FFProbeHelpers.GetDictionaryDateTime(tags, "retail_date") ?? @@ -153,32 +132,21 @@ namespace MediaBrowser.MediaEncoding.Probing FFProbeHelpers.GetDictionaryDateTime(tags, "date"); // Set common metadata for music (audio) and music videos (video) - info.Album = FFProbeHelpers.GetDictionaryValue(tags, "album"); - - var artists = FFProbeHelpers.GetDictionaryValue(tags, "artists"); + info.Album = tags.GetValueOrDefault("album"); - if (!string.IsNullOrWhiteSpace(artists)) + if (tags.TryGetValue("artists", out var artists) && !string.IsNullOrWhiteSpace(artists)) { - info.Artists = SplitArtists(artists, new[] { '/', ';' }, false) - .DistinctNames() - .ToArray(); + info.Artists = SplitDistinctArtists(artists, new[] { '/', ';' }, false).ToArray(); } else { - var artist = FFProbeHelpers.GetDictionaryValue(tags, "artist"); - if (string.IsNullOrWhiteSpace(artist)) - { - info.Artists = Array.Empty<string>(); - } - else - { - info.Artists = SplitArtists(artist, _nameDelimiters, true) - .DistinctNames() - .ToArray(); - } + var artist = tags.GetFirstNotNullNorWhiteSpaceValue("artist"); + info.Artists = artist == null + ? Array.Empty<string>() + : SplitDistinctArtists(artist, _nameDelimiters, true).ToArray(); } - // If we don't have a ProductionYear try and get it from PremiereDate + // Guess ProductionYear from PremiereDate if missing if (!info.ProductionYear.HasValue && info.PremiereDate.HasValue) { info.ProductionYear = info.PremiereDate.Value.Year; @@ -198,10 +166,10 @@ namespace MediaBrowser.MediaEncoding.Probing { FetchStudios(info, tags, "copyright"); - var iTunEXTC = FFProbeHelpers.GetDictionaryValue(tags, "iTunEXTC"); - if (!string.IsNullOrWhiteSpace(iTunEXTC)) + var iTunExtc = tags.GetFirstNotNullNorWhiteSpaceValue("iTunEXTC"); + if (iTunExtc != null) { - var parts = iTunEXTC.Split('|', StringSplitOptions.RemoveEmptyEntries); + var parts = iTunExtc.Split('|', StringSplitOptions.RemoveEmptyEntries); // Example // mpaa|G|100|For crude humor if (parts.Length > 1) @@ -215,15 +183,15 @@ namespace MediaBrowser.MediaEncoding.Probing } } - var itunesXml = FFProbeHelpers.GetDictionaryValue(tags, "iTunMOVI"); - if (!string.IsNullOrWhiteSpace(itunesXml)) + var iTunXml = tags.GetFirstNotNullNorWhiteSpaceValue("iTunMOVI"); + if (iTunXml != null) { - FetchFromItunesInfo(itunesXml, info); + FetchFromItunesInfo(iTunXml, info); } if (data.Format != null && !string.IsNullOrEmpty(data.Format.Duration)) { - 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); @@ -235,8 +203,7 @@ namespace MediaBrowser.MediaEncoding.Probing ExtractTimestamp(info); - var stereoMode = GetDictionaryValue(tags, "stereo_mode"); - if (string.Equals(stereoMode, "left_right", StringComparison.OrdinalIgnoreCase)) + if (tags.TryGetValue("stereo_mode", out var stereoMode) && string.Equals(stereoMode, "left_right", StringComparison.OrdinalIgnoreCase)) { info.Video3DFormat = Video3DFormat.FullSideBySide; } @@ -289,42 +256,36 @@ namespace MediaBrowser.MediaEncoding.Probing if (string.Equals(codec, "aac", StringComparison.OrdinalIgnoreCase) || string.Equals(codec, "mp3", StringComparison.OrdinalIgnoreCase)) { - if (channelsValue <= 2) + switch (channelsValue) { - return 192000; - } - - if (channelsValue >= 5) - { - return 320000; + case <= 2: + return 192000; + case >= 5: + return 320000; } } if (string.Equals(codec, "ac3", StringComparison.OrdinalIgnoreCase) || string.Equals(codec, "eac3", StringComparison.OrdinalIgnoreCase)) { - if (channelsValue <= 2) - { - return 192000; - } - - if (channelsValue >= 5) + switch (channelsValue) { - return 640000; + case <= 2: + return 192000; + case >= 5: + return 640000; } } if (string.Equals(codec, "flac", StringComparison.OrdinalIgnoreCase) || string.Equals(codec, "alac", StringComparison.OrdinalIgnoreCase)) { - if (channelsValue <= 2) - { - return 960000; - } - - if (channelsValue >= 5) + switch (channelsValue) { - return 2880000; + case <= 2: + return 960000; + case >= 5: + return 2880000; } } @@ -621,7 +582,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; } @@ -707,7 +669,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; } @@ -723,6 +685,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)) { @@ -731,6 +703,16 @@ 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)) { @@ -790,6 +772,23 @@ namespace MediaBrowser.MediaEncoding.Probing stream.BitDepth = streamInfo.BitsPerRawSample; } + if (!stream.BitDepth.HasValue) + { + if (!string.IsNullOrEmpty(streamInfo.PixelFormat) + && streamInfo.PixelFormat.Contains("p10", StringComparison.OrdinalIgnoreCase)) + { + 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; + } + } + // stream.IsAnamorphic = string.Equals(streamInfo.sample_aspect_ratio, "0:1", StringComparison.OrdinalIgnoreCase) || // string.Equals(stream.AspectRatio, "2.35:1", StringComparison.OrdinalIgnoreCase) || // string.Equals(stream.AspectRatio, "2.40:1", StringComparison.OrdinalIgnoreCase); @@ -832,7 +831,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; } @@ -845,7 +844,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; } @@ -862,7 +861,7 @@ namespace MediaBrowser.MediaEncoding.Probing || string.Equals(streamInfo.CodecType, "video", StringComparison.OrdinalIgnoreCase))) { var bps = GetBPSFromTags(streamInfo); - if (bps != null && bps > 0) + if (bps > 0) { stream.BitRate = bps; } @@ -931,6 +930,7 @@ namespace MediaBrowser.MediaEncoding.Probing } tags.TryGetValue(key, out var val); + return val; } @@ -938,7 +938,7 @@ namespace MediaBrowser.MediaEncoding.Probing { if (string.IsNullOrEmpty(input)) { - return input; + return null; } return input.Split('(').FirstOrDefault(); @@ -950,8 +950,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)) { @@ -1026,64 +1026,64 @@ namespace MediaBrowser.MediaEncoding.Probing /// <returns>System.Nullable{System.Single}.</returns> private float? GetFrameRate(string value) { - if (!string.IsNullOrEmpty(value)) + if (string.IsNullOrEmpty(value)) { - var parts = value.Split('/'); + return null; + } - float result; + var parts = value.Split('/'); - if (parts.Length == 2) - { - result = float.Parse(parts[0], _usCulture) / float.Parse(parts[1], _usCulture); - } - else - { - result = float.Parse(parts[0], _usCulture); - } + float result; - return float.IsNaN(result) ? (float?)null : result; + if (parts.Length == 2) + { + result = float.Parse(parts[0], CultureInfo.InvariantCulture) / float.Parse(parts[1], CultureInfo.InvariantCulture); + } + else + { + result = float.Parse(parts[0], CultureInfo.InvariantCulture); } - return null; + return float.IsNaN(result) ? null : result; } private void SetAudioRuntimeTicks(InternalMediaInfoResult result, MediaInfo data) { - if (result.Streams != null) + // Get the first info stream + var stream = result.Streams?.FirstOrDefault(s => string.Equals(s.CodecType, "audio", StringComparison.OrdinalIgnoreCase)); + if (stream == null) { - // Get the first info stream - var stream = result.Streams.FirstOrDefault(s => string.Equals(s.CodecType, "audio", StringComparison.OrdinalIgnoreCase)); + return; + } - if (stream != null) - { - // Get duration from stream properties - var duration = stream.Duration; + // Get duration from stream properties + var duration = stream.Duration; - // If it's not there go into format properties - if (string.IsNullOrEmpty(duration)) - { - duration = result.Format.Duration; - } + // If it's not there go into format properties + if (string.IsNullOrEmpty(duration)) + { + duration = result.Format.Duration; + } - // If we got something, parse it - if (!string.IsNullOrEmpty(duration)) - { - data.RunTimeTicks = TimeSpan.FromSeconds(double.Parse(duration, _usCulture)).Ticks; - } - } + // If we got something, parse it + if (!string.IsNullOrEmpty(duration)) + { + data.RunTimeTicks = TimeSpan.FromSeconds(double.Parse(duration, CultureInfo.InvariantCulture)).Ticks; } } private int? GetBPSFromTags(MediaStreamInfo streamInfo) { - if (streamInfo != null && streamInfo.Tags != null) + if (streamInfo?.Tags == null) { - var bps = GetDictionaryValue(streamInfo.Tags, "BPS-eng") ?? GetDictionaryValue(streamInfo.Tags, "BPS"); - if (!string.IsNullOrEmpty(bps) - && int.TryParse(bps, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsedBps)) - { - return parsedBps; - } + return null; + } + + var bps = GetDictionaryValue(streamInfo.Tags, "BPS-eng") ?? GetDictionaryValue(streamInfo.Tags, "BPS"); + if (!string.IsNullOrEmpty(bps) + && int.TryParse(bps, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsedBps)) + { + return parsedBps; } return null; @@ -1091,13 +1091,15 @@ namespace MediaBrowser.MediaEncoding.Probing private double? GetRuntimeSecondsFromTags(MediaStreamInfo streamInfo) { - if (streamInfo != null && streamInfo.Tags != null) + if (streamInfo?.Tags == null) { - var duration = GetDictionaryValue(streamInfo.Tags, "DURATION-eng") ?? GetDictionaryValue(streamInfo.Tags, "DURATION"); - if (!string.IsNullOrEmpty(duration) && TimeSpan.TryParse(duration, out var parsedDuration)) - { - return parsedDuration.TotalSeconds; - } + return null; + } + + var duration = GetDictionaryValue(streamInfo.Tags, "DURATION-eng") ?? GetDictionaryValue(streamInfo.Tags, "DURATION"); + if (!string.IsNullOrEmpty(duration) && TimeSpan.TryParse(duration, out var parsedDuration)) + { + return parsedDuration.TotalSeconds; } return null; @@ -1105,14 +1107,17 @@ namespace MediaBrowser.MediaEncoding.Probing private long? GetNumberOfBytesFromTags(MediaStreamInfo streamInfo) { - if (streamInfo != null && streamInfo.Tags != null) + if (streamInfo?.Tags == null) { - var numberOfBytes = GetDictionaryValue(streamInfo.Tags, "NUMBER_OF_BYTES-eng") ?? GetDictionaryValue(streamInfo.Tags, "NUMBER_OF_BYTES"); - if (!string.IsNullOrEmpty(numberOfBytes) - && long.TryParse(numberOfBytes, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsedBytes)) - { - return parsedBytes; - } + return null; + } + + var numberOfBytes = GetDictionaryValue(streamInfo.Tags, "NUMBER_OF_BYTES-eng") + ?? GetDictionaryValue(streamInfo.Tags, "NUMBER_OF_BYTES"); + if (!string.IsNullOrEmpty(numberOfBytes) + && long.TryParse(numberOfBytes, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsedBytes)) + { + return parsedBytes; } return null; @@ -1120,24 +1125,18 @@ namespace MediaBrowser.MediaEncoding.Probing private void SetSize(InternalMediaInfoResult data, MediaInfo info) { - if (data.Format != null) + if (data.Format == null) { - if (!string.IsNullOrEmpty(data.Format.Size)) - { - info.Size = long.Parse(data.Format.Size, _usCulture); - } - else - { - info.Size = null; - } + return; } + + info.Size = string.IsNullOrEmpty(data.Format.Size) ? null : long.Parse(data.Format.Size, CultureInfo.InvariantCulture); } - private void SetAudioInfoFromTags(MediaInfo audio, Dictionary<string, string> tags) + private void SetAudioInfoFromTags(MediaInfo audio, IReadOnlyDictionary<string, string> tags) { var people = new List<BaseItemPerson>(); - var composer = FFProbeHelpers.GetDictionaryValue(tags, "composer"); - if (!string.IsNullOrWhiteSpace(composer)) + if (tags.TryGetValue("composer", out var composer) && !string.IsNullOrWhiteSpace(composer)) { foreach (var person in Split(composer, false)) { @@ -1145,8 +1144,7 @@ namespace MediaBrowser.MediaEncoding.Probing } } - var conductor = FFProbeHelpers.GetDictionaryValue(tags, "conductor"); - if (!string.IsNullOrWhiteSpace(conductor)) + if (tags.TryGetValue("conductor", out var conductor) && !string.IsNullOrWhiteSpace(conductor)) { foreach (var person in Split(conductor, false)) { @@ -1154,8 +1152,7 @@ namespace MediaBrowser.MediaEncoding.Probing } } - var lyricist = FFProbeHelpers.GetDictionaryValue(tags, "lyricist"); - if (!string.IsNullOrWhiteSpace(lyricist)) + if (tags.TryGetValue("lyricist", out var lyricist) && !string.IsNullOrWhiteSpace(lyricist)) { foreach (var person in Split(lyricist, false)) { @@ -1163,9 +1160,27 @@ namespace MediaBrowser.MediaEncoding.Probing } } - // Check for writer some music is tagged that way as alternative to composer/lyricist - var writer = FFProbeHelpers.GetDictionaryValue(tags, "writer"); - if (!string.IsNullOrWhiteSpace(writer)) + if (tags.TryGetValue("performer", out var performer) && !string.IsNullOrWhiteSpace(performer)) + { + foreach (var person in Split(performer, false)) + { + Match match = _performerPattern.Match(person); + + // If the performer doesn't have any instrument/role associated, it won't match. In that case, chances are it's simply a band name, so we skip it. + if (match.Success) + { + people.Add(new BaseItemPerson + { + Name = match.Groups["name"].Value, + Type = PersonType.Actor, + Role = CultureInfo.InvariantCulture.TextInfo.ToTitleCase(match.Groups["instrument"].Value) + }); + } + } + } + + // In cases where there isn't sufficient information as to which role a writer performed on a recording, tagging software uses the "writer" tag. + if (tags.TryGetValue("writer", out var writer) && !string.IsNullOrWhiteSpace(writer)) { foreach (var person in Split(writer, false)) { @@ -1173,40 +1188,57 @@ namespace MediaBrowser.MediaEncoding.Probing } } - audio.People = people.ToArray(); - - var albumArtist = FFProbeHelpers.GetDictionaryValue(tags, "albumartist"); - if (string.IsNullOrWhiteSpace(albumArtist)) + if (tags.TryGetValue("arranger", out var arranger) && !string.IsNullOrWhiteSpace(arranger)) { - albumArtist = FFProbeHelpers.GetDictionaryValue(tags, "album artist"); + foreach (var person in Split(arranger, false)) + { + people.Add(new BaseItemPerson { Name = person, Type = PersonType.Arranger }); + } } - if (string.IsNullOrWhiteSpace(albumArtist)) + if (tags.TryGetValue("engineer", out var engineer) && !string.IsNullOrWhiteSpace(engineer)) { - albumArtist = FFProbeHelpers.GetDictionaryValue(tags, "album_artist"); + foreach (var person in Split(engineer, false)) + { + people.Add(new BaseItemPerson { Name = person, Type = PersonType.Engineer }); + } } - if (string.IsNullOrWhiteSpace(albumArtist)) + if (tags.TryGetValue("mixer", out var mixer) && !string.IsNullOrWhiteSpace(mixer)) { - audio.AlbumArtists = Array.Empty<string>(); + foreach (var person in Split(mixer, false)) + { + people.Add(new BaseItemPerson { Name = person, Type = PersonType.Mixer }); + } } - else + + if (tags.TryGetValue("remixer", out var remixer) && !string.IsNullOrWhiteSpace(remixer)) { - audio.AlbumArtists = SplitArtists(albumArtist, _nameDelimiters, true) - .DistinctNames() - .ToArray(); + foreach (var person in Split(remixer, false)) + { + people.Add(new BaseItemPerson { Name = person, Type = PersonType.Remixer }); + } } + audio.People = people.ToArray(); + + // Set album artist + var albumArtist = tags.GetFirstNotNullNorWhiteSpaceValue("albumartist", "album artist", "album_artist"); + audio.AlbumArtists = albumArtist != null + ? SplitDistinctArtists(albumArtist, _nameDelimiters, true).ToArray() + : Array.Empty<string>(); + + // Set album artist to artist if empty if (audio.AlbumArtists.Length == 0) { audio.AlbumArtists = audio.Artists; } // Track number - audio.IndexNumber = GetDictionaryDiscValue(tags, "track"); + audio.IndexNumber = GetDictionaryTrackOrDiscNumber(tags, "track"); // Disc number - audio.ParentIndexNumber = GetDictionaryDiscValue(tags, "disc"); + audio.ParentIndexNumber = GetDictionaryTrackOrDiscNumber(tags, "disc"); // There's several values in tags may or may not be present FetchStudios(audio, tags, "organization"); @@ -1214,30 +1246,25 @@ namespace MediaBrowser.MediaEncoding.Probing FetchStudios(audio, tags, "publisher"); FetchStudios(audio, tags, "label"); - // These support mulitple values, but for now we only store the first. - var mb = GetMultipleMusicBrainzId(FFProbeHelpers.GetDictionaryValue(tags, "MusicBrainz Album Artist Id")) - ?? GetMultipleMusicBrainzId(FFProbeHelpers.GetDictionaryValue(tags, "MUSICBRAINZ_ALBUMARTISTID")); - + // These support multiple values, but for now we only store the first. + var mb = GetMultipleMusicBrainzId(tags.GetValueOrDefault("MusicBrainz Album Artist Id")) + ?? GetMultipleMusicBrainzId(tags.GetValueOrDefault("MUSICBRAINZ_ALBUMARTISTID")); audio.SetProviderId(MetadataProvider.MusicBrainzAlbumArtist, mb); - mb = GetMultipleMusicBrainzId(FFProbeHelpers.GetDictionaryValue(tags, "MusicBrainz Artist Id")) - ?? GetMultipleMusicBrainzId(FFProbeHelpers.GetDictionaryValue(tags, "MUSICBRAINZ_ARTISTID")); - + mb = GetMultipleMusicBrainzId(tags.GetValueOrDefault("MusicBrainz Artist Id")) + ?? GetMultipleMusicBrainzId(tags.GetValueOrDefault("MUSICBRAINZ_ARTISTID")); audio.SetProviderId(MetadataProvider.MusicBrainzArtist, mb); - mb = GetMultipleMusicBrainzId(FFProbeHelpers.GetDictionaryValue(tags, "MusicBrainz Album Id")) - ?? GetMultipleMusicBrainzId(FFProbeHelpers.GetDictionaryValue(tags, "MUSICBRAINZ_ALBUMID")); - + mb = GetMultipleMusicBrainzId(tags.GetValueOrDefault("MusicBrainz Album Id")) + ?? GetMultipleMusicBrainzId(tags.GetValueOrDefault("MUSICBRAINZ_ALBUMID")); audio.SetProviderId(MetadataProvider.MusicBrainzAlbum, mb); - mb = GetMultipleMusicBrainzId(FFProbeHelpers.GetDictionaryValue(tags, "MusicBrainz Release Group Id")) - ?? GetMultipleMusicBrainzId(FFProbeHelpers.GetDictionaryValue(tags, "MUSICBRAINZ_RELEASEGROUPID")); - + mb = GetMultipleMusicBrainzId(tags.GetValueOrDefault("MusicBrainz Release Group Id")) + ?? GetMultipleMusicBrainzId(tags.GetValueOrDefault("MUSICBRAINZ_RELEASEGROUPID")); audio.SetProviderId(MetadataProvider.MusicBrainzReleaseGroup, mb); - mb = GetMultipleMusicBrainzId(FFProbeHelpers.GetDictionaryValue(tags, "MusicBrainz Release Track Id")) - ?? GetMultipleMusicBrainzId(FFProbeHelpers.GetDictionaryValue(tags, "MUSICBRAINZ_RELEASETRACKID")); - + mb = GetMultipleMusicBrainzId(tags.GetValueOrDefault("MusicBrainz Release Track Id")) + ?? GetMultipleMusicBrainzId(tags.GetValueOrDefault("MUSICBRAINZ_RELEASETRACKID")); audio.SetProviderId(MetadataProvider.MusicBrainzTrack, mb); } @@ -1261,18 +1288,18 @@ namespace MediaBrowser.MediaEncoding.Probing /// <returns>System.String[][].</returns> private IEnumerable<string> Split(string val, bool allowCommaDelimiter) { - // Only use the comma as a delimeter if there are no slashes or pipes. + // Only use the comma as a delimiter if there are no slashes or pipes. // We want to be careful not to split names that have commas in them - var delimeter = !allowCommaDelimiter || _nameDelimiters.Any(i => val.IndexOf(i, StringComparison.Ordinal) != -1) ? + var delimiter = !allowCommaDelimiter || _nameDelimiters.Any(i => val.IndexOf(i, StringComparison.Ordinal) != -1) ? _nameDelimiters : new[] { ',' }; - return val.Split(delimeter, StringSplitOptions.RemoveEmptyEntries) + return val.Split(delimiter, StringSplitOptions.RemoveEmptyEntries) .Where(i => !string.IsNullOrWhiteSpace(i)) .Select(i => i.Trim()); } - private IEnumerable<string> SplitArtists(string val, char[] delimiters, bool splitFeaturing) + private IEnumerable<string> SplitDistinctArtists(string val, char[] delimiters, bool splitFeaturing) { if (splitFeaturing) { @@ -1298,7 +1325,7 @@ namespace MediaBrowser.MediaEncoding.Probing .Select(i => i.Trim()); artistsFound.AddRange(artists); - return artistsFound; + return artistsFound.DistinctNames(); } /// <summary> @@ -1307,36 +1334,38 @@ namespace MediaBrowser.MediaEncoding.Probing /// <param name="info">The info.</param> /// <param name="tags">The tags.</param> /// <param name="tagName">Name of the tag.</param> - private void FetchStudios(MediaInfo info, Dictionary<string, string> tags, string tagName) + private void FetchStudios(MediaInfo info, IReadOnlyDictionary<string, string> tags, string tagName) { - var val = FFProbeHelpers.GetDictionaryValue(tags, tagName); + var val = tags.GetValueOrDefault(tagName); - if (!string.IsNullOrEmpty(val)) + if (string.IsNullOrEmpty(val)) { - var studios = Split(val, true); - var studioList = new List<string>(); + return; + } - foreach (var studio in studios) - { - // Sometimes the artist name is listed here, account for that - if (info.Artists.Contains(studio, StringComparer.OrdinalIgnoreCase)) - { - continue; - } + var studios = Split(val, true); + var studioList = new List<string>(); - if (info.AlbumArtists.Contains(studio, StringComparer.OrdinalIgnoreCase)) - { - continue; - } + foreach (var studio in studios) + { + if (string.IsNullOrWhiteSpace(studio)) + { + continue; + } - studioList.Add(studio); + // Don't add artist/album artist name to studios, even if it's listed there + if (info.Artists.Contains(studio, StringComparer.OrdinalIgnoreCase) + || info.AlbumArtists.Contains(studio, StringComparer.OrdinalIgnoreCase)) + { + continue; } - info.Studios = studioList - .Where(i => !string.IsNullOrWhiteSpace(i)) - .Distinct(StringComparer.OrdinalIgnoreCase) - .ToArray(); + studioList.Add(studio); } + + info.Studios = studioList + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); } /// <summary> @@ -1344,58 +1373,55 @@ namespace MediaBrowser.MediaEncoding.Probing /// </summary> /// <param name="info">The information.</param> /// <param name="tags">The tags.</param> - private void FetchGenres(MediaInfo info, Dictionary<string, string> tags) + private void FetchGenres(MediaInfo info, IReadOnlyDictionary<string, string> tags) { - var val = FFProbeHelpers.GetDictionaryValue(tags, "genre"); + var genreVal = tags.GetValueOrDefault("genre"); + if (string.IsNullOrEmpty(genreVal)) + { + return; + } - if (!string.IsNullOrEmpty(val)) + var genres = new List<string>(info.Genres); + foreach (var genre in Split(genreVal, true)) { - var genres = new List<string>(info.Genres); - foreach (var genre in Split(val, true)) + if (string.IsNullOrWhiteSpace(genre)) { - genres.Add(genre); + continue; } - info.Genres = genres - .Where(i => !string.IsNullOrWhiteSpace(i)) - .Distinct(StringComparer.OrdinalIgnoreCase) - .ToArray(); + genres.Add(genre); } + + info.Genres = genres + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); } /// <summary> - /// Gets the disc number, which is sometimes can be in the form of '1', or '1/3'. + /// Gets the track or disc number, which can be in the form of '1', or '1/3'. /// </summary> /// <param name="tags">The tags.</param> /// <param name="tagName">Name of the tag.</param> - /// <returns>System.Nullable{System.Int32}.</returns> - private int? GetDictionaryDiscValue(Dictionary<string, string> tags, string tagName) + /// <returns>The track or disc number, or null, if missing or not parseable.</returns> + private static int? GetDictionaryTrackOrDiscNumber(IReadOnlyDictionary<string, string> tags, string tagName) { - var disc = FFProbeHelpers.GetDictionaryValue(tags, tagName); + var disc = tags.GetValueOrDefault(tagName); - if (!string.IsNullOrEmpty(disc)) + if (!string.IsNullOrEmpty(disc) && int.TryParse(disc.AsSpan().LeftPart('/'), out var discNum)) { - disc = disc.Split('/')[0]; - - if (int.TryParse(disc, out var num)) - { - return num; - } + return discNum; } return null; } - private ChapterInfo GetChapterInfo(MediaChapter chapter) + private static ChapterInfo GetChapterInfo(MediaChapter chapter) { var info = new ChapterInfo(); - if (chapter.Tags != null) + if (chapter.Tags != null && chapter.Tags.TryGetValue("title", out string name)) { - if (chapter.Tags.TryGetValue("title", out string name)) - { - info.Name = name; - } + info.Name = name; } // Limit accuracy to milliseconds to match xml saving @@ -1412,14 +1438,14 @@ namespace MediaBrowser.MediaEncoding.Probing private void FetchWtvInfo(MediaInfo video, InternalMediaInfoResult data) { - if (data.Format == null || data.Format.Tags == null) + var tags = data.Format?.Tags; + + if (tags == null) { return; } - var genres = FFProbeHelpers.GetDictionaryValue(data.Format.Tags, "WM/Genre"); - - if (!string.IsNullOrWhiteSpace(genres)) + if (tags.TryGetValue("WM/Genre", out var genres) && !string.IsNullOrWhiteSpace(genres)) { var genreList = genres.Split(new[] { ';', '/', ',' }, StringSplitOptions.RemoveEmptyEntries) .Where(i => !string.IsNullOrWhiteSpace(i)) @@ -1433,16 +1459,12 @@ namespace MediaBrowser.MediaEncoding.Probing } } - var officialRating = FFProbeHelpers.GetDictionaryValue(data.Format.Tags, "WM/ParentalRating"); - - if (!string.IsNullOrWhiteSpace(officialRating)) + if (tags.TryGetValue("WM/ParentalRating", out var officialRating) && !string.IsNullOrWhiteSpace(officialRating)) { video.OfficialRating = officialRating; } - var people = FFProbeHelpers.GetDictionaryValue(data.Format.Tags, "WM/MediaCredits"); - - if (!string.IsNullOrEmpty(people)) + if (tags.TryGetValue("WM/MediaCredits", out var people) && !string.IsNullOrEmpty(people)) { video.People = people.Split(new[] { ';', '/' }, StringSplitOptions.RemoveEmptyEntries) .Where(i => !string.IsNullOrWhiteSpace(i)) @@ -1450,29 +1472,21 @@ namespace MediaBrowser.MediaEncoding.Probing .ToArray(); } - var year = FFProbeHelpers.GetDictionaryValue(data.Format.Tags, "WM/OriginalReleaseTime"); - if (!string.IsNullOrWhiteSpace(year)) + if (tags.TryGetValue("WM/OriginalReleaseTime", out var year) && int.TryParse(year, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsedYear)) { - if (int.TryParse(year, NumberStyles.Integer, _usCulture, out var val)) - { - video.ProductionYear = val; - } + video.ProductionYear = parsedYear; } - var premiereDateString = FFProbeHelpers.GetDictionaryValue(data.Format.Tags, "WM/MediaOriginalBroadcastDateTime"); - if (!string.IsNullOrWhiteSpace(premiereDateString)) + // Credit to MCEBuddy: https://mcebuddy2x.codeplex.com/ + // DateTime is reported along with timezone info (typically Z i.e. UTC hence assume None) + if (tags.TryGetValue("WM/MediaOriginalBroadcastDateTime", out var premiereDateString) && DateTime.TryParse(year, null, DateTimeStyles.AdjustToUniversal, out var parsedDate)) { - // Credit to MCEBuddy: https://mcebuddy2x.codeplex.com/ - // DateTime is reported along with timezone info (typically Z i.e. UTC hence assume None) - if (DateTime.TryParse(year, null, DateTimeStyles.None, out var val)) - { - video.PremiereDate = val.ToUniversalTime(); - } + video.PremiereDate = parsedDate; } - var description = FFProbeHelpers.GetDictionaryValue(data.Format.Tags, "WM/SubTitleDescription"); + var description = tags.GetValueOrDefault("WM/SubTitleDescription"); - var subTitle = FFProbeHelpers.GetDictionaryValue(data.Format.Tags, "WM/SubTitle"); + var subTitle = tags.GetValueOrDefault("WM/SubTitle"); // For below code, credit to MCEBuddy: https://mcebuddy2x.codeplex.com/ @@ -1483,49 +1497,48 @@ namespace MediaBrowser.MediaEncoding.Probing // e.g. -> CBeebies Bedtime Hour. The Mystery: Animated adventures of two friends who live on an island in the middle of the big city. Some of Abney and Teal's favourite objects are missing. [S] if (string.IsNullOrWhiteSpace(subTitle) && !string.IsNullOrWhiteSpace(description) - && description.AsSpan().Slice(0, Math.Min(description.Length, MaxSubtitleDescriptionExtractionLength)).IndexOf(':') != -1) // Check within the Subtitle size limit, otherwise from description it can get too long creating an invalid filename + && description.AsSpan()[..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[] parts = description.Split(':'); - if (parts.Length > 0) + string[] descriptionParts = description.Split(':'); + if (descriptionParts.Length > 0) { - string subtitle = parts[0]; + string subtitle = descriptionParts[0]; try { - if (subtitle.Contains('/', StringComparison.Ordinal)) // It contains a episode number and season number + // Check if it contains a episode number and season number + if (subtitle.Contains('/', StringComparison.Ordinal)) { - string[] numbers = subtitle.Split(' '); - video.IndexNumber = int.Parse(numbers[0].Replace(".", string.Empty, StringComparison.Ordinal).Split('/')[0], CultureInfo.InvariantCulture); - int totalEpisodesInSeason = int.Parse(numbers[0].Replace(".", string.Empty, StringComparison.Ordinal).Split('/')[1], CultureInfo.InvariantCulture); + string[] subtitleParts = subtitle.Split(' '); + string[] numbers = subtitleParts[0].Replace(".", string.Empty, StringComparison.Ordinal).Split('/'); + video.IndexNumber = int.Parse(numbers[0], CultureInfo.InvariantCulture); + // int totalEpisodesInSeason = int.Parse(numbers[1], CultureInfo.InvariantCulture); - description = string.Join(' ', numbers, 1, numbers.Length - 1).Trim(); // Skip the first, concatenate the rest, clean up spaces and save it + // Skip the numbers, concatenate the rest, trim and set as new description + description = string.Join(' ', subtitleParts, 1, subtitleParts.Length - 1).Trim(); + } + else if (subtitle.Contains('.', StringComparison.Ordinal)) + { + var subtitleParts = subtitle.Split('.'); + description = string.Join('.', subtitleParts, 1, subtitleParts.Length - 1).Trim(); } else { - // Switch to default parsing - if (subtitle.Contains('.', StringComparison.Ordinal)) - { - // skip the comment, keep the subtitle - description = string.Join('.', subtitle.Split('.'), 1, subtitle.Split('.').Length - 1).Trim(); // skip the first - } - else - { - description = subtitle.Trim(); // Clean up whitespaces and save it - } + description = subtitle.Trim(); } } catch (Exception ex) { _logger.LogError(ex, "Error while parsing subtitle field"); - // Default parsing + // Fallback to default parsing if (subtitle.Contains('.', StringComparison.Ordinal)) { - // skip the comment, keep the subtitle - description = string.Join('.', subtitle.Split('.'), 1, subtitle.Split('.').Length - 1).Trim(); // skip the first + var subtitleParts = subtitle.Split('.'); + description = string.Join('.', subtitleParts, 1, subtitleParts.Length - 1).Trim(); } else { - description = subtitle.Trim(); // Clean up whitespaces and save it + description = subtitle.Trim(); } } } @@ -1539,24 +1552,27 @@ namespace MediaBrowser.MediaEncoding.Probing private void ExtractTimestamp(MediaInfo video) { - if (video.VideoType == VideoType.VideoFile) + if (video.VideoType != VideoType.VideoFile) { - if (string.Equals(video.Container, "mpeg2ts", StringComparison.OrdinalIgnoreCase) || - string.Equals(video.Container, "m2ts", StringComparison.OrdinalIgnoreCase) || - string.Equals(video.Container, "ts", StringComparison.OrdinalIgnoreCase)) - { - try - { - video.Timestamp = GetMpegTimestamp(video.Path); + return; + } - _logger.LogDebug("Video has {Timestamp} timestamp", video.Timestamp); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error extracting timestamp info from {Path}", video.Path); - video.Timestamp = null; - } - } + if (!string.Equals(video.Container, "mpeg2ts", StringComparison.OrdinalIgnoreCase) + && !string.Equals(video.Container, "m2ts", StringComparison.OrdinalIgnoreCase) + && !string.Equals(video.Container, "ts", StringComparison.OrdinalIgnoreCase)) + { + return; + } + + try + { + video.Timestamp = GetMpegTimestamp(video.Path); + _logger.LogDebug("Video has {Timestamp} timestamp", video.Timestamp); + } + catch (Exception ex) + { + video.Timestamp = null; + _logger.LogError(ex, "Error extracting timestamp info from {Path}", video.Path); } } @@ -1565,7 +1581,7 @@ namespace MediaBrowser.MediaEncoding.Probing { var packetBuffer = new byte[197]; - using (var fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read)) + using (var fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read, 1)) { fs.Read(packetBuffer); } @@ -1575,17 +1591,17 @@ namespace MediaBrowser.MediaEncoding.Probing return TransportStreamTimestamp.None; } - if ((packetBuffer[4] == 71) && (packetBuffer[196] == 71)) + if ((packetBuffer[4] != 71) || (packetBuffer[196] != 71)) { - if ((packetBuffer[0] == 0) && (packetBuffer[1] == 0) && (packetBuffer[2] == 0) && (packetBuffer[3] == 0)) - { - return TransportStreamTimestamp.Zero; - } + return TransportStreamTimestamp.None; + } - return TransportStreamTimestamp.Valid; + if ((packetBuffer[0] == 0) && (packetBuffer[1] == 0) && (packetBuffer[2] == 0) && (packetBuffer[3] == 0)) + { + return TransportStreamTimestamp.Zero; } - return TransportStreamTimestamp.None; + return TransportStreamTimestamp.Valid; } } } diff --git a/MediaBrowser.MediaEncoding/Subtitles/SubtitleEditParser.cs b/MediaBrowser.MediaEncoding/Subtitles/SubtitleEditParser.cs index 24ceb1b571..52c1b64677 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 608ebf4436..2b2de2ff6f 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; @@ -192,10 +193,10 @@ namespace MediaBrowser.MediaEncoding.Subtitles } } - return File.OpenRead(fileInfo.Path); + 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) @@ -671,7 +668,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles string text; Encoding encoding; - using (var fileStream = File.OpenRead(file)) + using (var fileStream = AsyncFile.OpenRead(file)) using (var reader = new StreamReader(fileStream, true)) { encoding = reader.CurrentEncoding; @@ -683,8 +680,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)) + 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); @@ -750,13 +746,13 @@ namespace MediaBrowser.MediaEncoding.Subtitles } case MediaProtocol.File: - return File.OpenRead(path); + return AsyncFile.OpenRead(path); default: throw new ArgumentOutOfRangeException(nameof(protocol)); } } - private struct SubtitleInfo + internal readonly struct SubtitleInfo { public SubtitleInfo(string path, MediaProtocol protocol, string format, bool isExternal) { @@ -766,13 +762,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 ad32cb7943..6d56dda91f 100644 --- a/MediaBrowser.MediaEncoding/Subtitles/VttWriter.cs +++ b/MediaBrowser.MediaEncoding/Subtitles/VttWriter.cs @@ -18,14 +18,14 @@ namespace MediaBrowser.MediaEncoding.Subtitles using (var writer = new StreamWriter(stream, Encoding.UTF8, 1024, true)) { writer.WriteLine("WEBVTT"); - writer.WriteLine(string.Empty); + writer.WriteLine(); 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(); foreach (var trackEvent in info.TrackEvents) { cancellationToken.ThrowIfCancellationRequested(); diff --git a/MediaBrowser.Model/Activity/ActivityLogEntry.cs b/MediaBrowser.Model/Activity/ActivityLogEntry.cs index 1d47ef9f69..f83dde56d4 100644 --- a/MediaBrowser.Model/Activity/ActivityLogEntry.cs +++ b/MediaBrowser.Model/Activity/ActivityLogEntry.cs @@ -1,13 +1,26 @@ -#nullable disable -#pragma warning disable CS1591 - using System; using Microsoft.Extensions.Logging; namespace MediaBrowser.Model.Activity { + /// <summary> + /// An activity log entry. + /// </summary> public class ActivityLogEntry { + /// <summary> + /// Initializes a new instance of the <see cref="ActivityLogEntry"/> class. + /// </summary> + /// <param name="name">The name.</param> + /// <param name="type">The type.</param> + /// <param name="userId">The user id.</param> + public ActivityLogEntry(string name, string type, Guid userId) + { + Name = name; + Type = type; + UserId = userId; + } + /// <summary> /// Gets or sets the identifier. /// </summary> @@ -24,13 +37,13 @@ namespace MediaBrowser.Model.Activity /// Gets or sets the overview. /// </summary> /// <value>The overview.</value> - public string Overview { get; set; } + public string? Overview { get; set; } /// <summary> /// Gets or sets the short overview. /// </summary> /// <value>The short overview.</value> - public string ShortOverview { get; set; } + public string? ShortOverview { get; set; } /// <summary> /// Gets or sets the type. @@ -42,7 +55,7 @@ namespace MediaBrowser.Model.Activity /// Gets or sets the item identifier. /// </summary> /// <value>The item identifier.</value> - public string ItemId { get; set; } + public string? ItemId { get; set; } /// <summary> /// Gets or sets the date. @@ -61,7 +74,7 @@ namespace MediaBrowser.Model.Activity /// </summary> /// <value>The user primary image tag.</value> [Obsolete("UserPrimaryImageTag is not used.")] - public string UserPrimaryImageTag { get; set; } + public string? UserPrimaryImageTag { get; set; } /// <summary> /// Gets or sets the log severity. diff --git a/MediaBrowser.Model/Branding/BrandingOptions.cs b/MediaBrowser.Model/Branding/BrandingOptions.cs index 5ddf1e7e6e..7f19a5b852 100644 --- a/MediaBrowser.Model/Branding/BrandingOptions.cs +++ b/MediaBrowser.Model/Branding/BrandingOptions.cs @@ -1,4 +1,3 @@ -#nullable disable #pragma warning disable CS1591 namespace MediaBrowser.Model.Branding @@ -9,12 +8,12 @@ namespace MediaBrowser.Model.Branding /// Gets or sets the login disclaimer. /// </summary> /// <value>The login disclaimer.</value> - public string LoginDisclaimer { get; set; } + public string? LoginDisclaimer { get; set; } /// <summary> /// Gets or sets the custom CSS. /// </summary> /// <value>The custom CSS.</value> - public string CustomCss { get; set; } + public string? CustomCss { get; set; } } } diff --git a/MediaBrowser.Model/Channels/ChannelFeatures.cs b/MediaBrowser.Model/Channels/ChannelFeatures.cs index d925b78b6d..1ca8e80a6f 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/ChannelInfo.cs b/MediaBrowser.Model/Channels/ChannelInfo.cs deleted file mode 100644 index f2432aaeb2..0000000000 --- a/MediaBrowser.Model/Channels/ChannelInfo.cs +++ /dev/null @@ -1,32 +0,0 @@ -#nullable disable -#pragma warning disable CS1591 - -namespace MediaBrowser.Model.Channels -{ - public class ChannelInfo - { - /// <summary> - /// Gets or sets the name. - /// </summary> - /// <value>The name.</value> - public string Name { get; set; } - - /// <summary> - /// Gets or sets the identifier. - /// </summary> - /// <value>The identifier.</value> - public string Id { get; set; } - - /// <summary> - /// Gets or sets the home page URL. - /// </summary> - /// <value>The home page URL.</value> - public string HomePageUrl { get; set; } - - /// <summary> - /// Gets or sets the features. - /// </summary> - /// <value>The features.</value> - public ChannelFeatures Features { get; set; } - } -} diff --git a/MediaBrowser.Model/Channels/ChannelQuery.cs b/MediaBrowser.Model/Channels/ChannelQuery.cs index 59966127fb..f9380ce3a3 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/Configuration/BaseApplicationConfiguration.cs b/MediaBrowser.Model/Configuration/BaseApplicationConfiguration.cs index b00d2fffb7..57759a7d39 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/LibraryOptions.cs b/MediaBrowser.Model/Configuration/LibraryOptions.cs index 24698360ec..aae5359b1d 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; @@ -52,21 +51,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,7 +75,7 @@ namespace MediaBrowser.Model.Configuration public bool SkipSubtitlesIfAudioTrackMatches { get; set; } - public string[] SubtitleDownloadLanguages { get; set; } + public string[]? SubtitleDownloadLanguages { get; set; } public bool RequirePerfectSubtitleMatch { get; set; } @@ -84,7 +83,7 @@ namespace MediaBrowser.Model.Configuration 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/MediaPathInfo.cs b/MediaBrowser.Model/Configuration/MediaPathInfo.cs index 4f311c58f0..a7bc435901 100644 --- a/MediaBrowser.Model/Configuration/MediaPathInfo.cs +++ b/MediaBrowser.Model/Configuration/MediaPathInfo.cs @@ -1,12 +1,22 @@ -#nullable disable #pragma warning disable CS1591 namespace MediaBrowser.Model.Configuration { public class MediaPathInfo { + public MediaPathInfo(string path) + { + Path = path; + } + + // Needed for xml serialization + public MediaPathInfo() + { + Path = string.Empty; + } + public string Path { get; set; } - public string NetworkPath { get; set; } + public string? NetworkPath { get; set; } } } diff --git a/MediaBrowser.Model/Configuration/MetadataOptions.cs b/MediaBrowser.Model/Configuration/MetadataOptions.cs index 76b72bd08e..384a7997fd 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/UserConfiguration.cs b/MediaBrowser.Model/Configuration/UserConfiguration.cs index 935e6cbe10..81359462c3 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 8ad070dcbf..07129d7150 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.Model/Devices/DeviceInfo.cs b/MediaBrowser.Model/Devices/DeviceInfo.cs index 0cccf931c4..7a1c7a7382 100644 --- a/MediaBrowser.Model/Devices/DeviceInfo.cs +++ b/MediaBrowser.Model/Devices/DeviceInfo.cs @@ -15,6 +15,11 @@ namespace MediaBrowser.Model.Devices public string Name { get; set; } + /// <summary> + /// Gets or sets the access token. + /// </summary> + public string AccessToken { get; set; } + /// <summary> /// Gets or sets the identifier. /// </summary> diff --git a/MediaBrowser.Model/Devices/DeviceOptions.cs b/MediaBrowser.Model/Devices/DeviceOptions.cs deleted file mode 100644 index 037ffeb5e8..0000000000 --- a/MediaBrowser.Model/Devices/DeviceOptions.cs +++ /dev/null @@ -1,9 +0,0 @@ -#pragma warning disable CS1591 - -namespace MediaBrowser.Model.Devices -{ - public class DeviceOptions - { - public string? CustomName { get; set; } - } -} diff --git a/MediaBrowser.Model/Devices/DeviceQuery.cs b/MediaBrowser.Model/Devices/DeviceQuery.cs deleted file mode 100644 index 64e366a560..0000000000 --- a/MediaBrowser.Model/Devices/DeviceQuery.cs +++ /dev/null @@ -1,21 +0,0 @@ -#pragma warning disable CS1591 - -using System; - -namespace MediaBrowser.Model.Devices -{ - public class DeviceQuery - { - /// <summary> - /// Gets or sets a value indicating whether [supports synchronize]. - /// </summary> - /// <value><c>null</c> if [supports synchronize] contains no value, <c>true</c> if [supports synchronize]; otherwise, <c>false</c>.</value> - public bool? SupportsSync { get; set; } - - /// <summary> - /// Gets or sets the user identifier. - /// </summary> - /// <value>The user identifier.</value> - public Guid UserId { get; set; } - } -} diff --git a/MediaBrowser.Model/Dlna/ContentFeatureBuilder.cs b/MediaBrowser.Model/Dlna/ContentFeatureBuilder.cs index 600a441570..e8fd18ae49 100644 --- a/MediaBrowser.Model/Dlna/ContentFeatureBuilder.cs +++ b/MediaBrowser.Model/Dlna/ContentFeatureBuilder.cs @@ -208,7 +208,6 @@ namespace MediaBrowser.Model.Dlna if (string.IsNullOrEmpty(orgPn)) { contentFeatureList.Add(orgOp.TrimStart(';') + orgCi + dlnaflags); - continue; } else { diff --git a/MediaBrowser.Model/Dlna/DeviceIdentification.cs b/MediaBrowser.Model/Dlna/DeviceIdentification.cs index c511801f4f..6625b79814 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/DirectPlayProfile.cs b/MediaBrowser.Model/Dlna/DirectPlayProfile.cs index fa3ad098f0..03c3a72657 100644 --- a/MediaBrowser.Model/Dlna/DirectPlayProfile.cs +++ b/MediaBrowser.Model/Dlna/DirectPlayProfile.cs @@ -1,6 +1,5 @@ #pragma warning disable CS1591 -using System.ComponentModel.DataAnnotations; using System.Xml.Serialization; namespace MediaBrowser.Model.Dlna diff --git a/MediaBrowser.Model/Dlna/ITranscoderSupport.cs b/MediaBrowser.Model/Dlna/ITranscoderSupport.cs index d9bd094d9f..a70ce44cc6 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/MediaFormatProfile.cs b/MediaBrowser.Model/Dlna/MediaFormatProfile.cs index 20e05b8a94..06f6660f4b 100644 --- a/MediaBrowser.Model/Dlna/MediaFormatProfile.cs +++ b/MediaBrowser.Model/Dlna/MediaFormatProfile.cs @@ -1,4 +1,4 @@ -#pragma warning disable CS1591 +#pragma warning disable CS1591, CA1707 namespace MediaBrowser.Model.Dlna { diff --git a/MediaBrowser.Model/Dlna/MediaFormatProfileResolver.cs b/MediaBrowser.Model/Dlna/MediaFormatProfileResolver.cs index 7ce248509c..93a9ae6159 100644 --- a/MediaBrowser.Model/Dlna/MediaFormatProfileResolver.cs +++ b/MediaBrowser.Model/Dlna/MediaFormatProfileResolver.cs @@ -4,7 +4,6 @@ using System; using System.Collections.Generic; using System.Globalization; -using System.Linq; using MediaBrowser.Model.MediaInfo; namespace MediaBrowser.Model.Dlna diff --git a/MediaBrowser.Model/Dlna/ResolutionNormalizer.cs b/MediaBrowser.Model/Dlna/ResolutionNormalizer.cs index 65fccbdd41..94071b4196 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[] @@ -21,11 +21,7 @@ namespace MediaBrowser.Model.Dlna public static ResolutionOptions Normalize( int? inputBitrate, - int? unused1, - int? unused2, int outputBitrate, - string inputCodec, - string outputCodec, int? maxWidth, int? maxHeight) { diff --git a/MediaBrowser.Model/Dlna/StreamBuilder.cs b/MediaBrowser.Model/Dlna/StreamBuilder.cs index f4c69fe8f5..84d99d550f 100644 --- a/MediaBrowser.Model/Dlna/StreamBuilder.cs +++ b/MediaBrowser.Model/Dlna/StreamBuilder.cs @@ -694,7 +694,7 @@ namespace MediaBrowser.Model.Dlna if (isEligibleForDirectPlay || isEligibleForDirectStream) { // See if it can be direct played - var directPlayInfo = GetVideoDirectPlayProfile(options, item, videoStream, audioStream, isEligibleForDirectPlay, isEligibleForDirectStream); + var directPlayInfo = GetVideoDirectPlayProfile(options, item, videoStream, audioStream, isEligibleForDirectStream); var directPlay = directPlayInfo.Item1; if (directPlay != null) @@ -810,7 +810,7 @@ namespace MediaBrowser.Model.Dlna // Honor requested max channels playlistItem.GlobalMaxAudioChannels = options.MaxAudioChannels; - int audioBitrate = GetAudioBitrate(playlistItem.SubProtocol, options.GetMaxBitrate(false) ?? 0, playlistItem.TargetAudioCodec, audioStream, playlistItem); + int audioBitrate = GetAudioBitrate(options.GetMaxBitrate(false) ?? 0, playlistItem.TargetAudioCodec, audioStream, playlistItem); playlistItem.AudioBitrate = Math.Min(playlistItem.AudioBitrate ?? audioBitrate, audioBitrate); isFirstAppliedCodecProfile = true; @@ -907,7 +907,7 @@ namespace MediaBrowser.Model.Dlna return 192000; } - private static int GetAudioBitrate(string subProtocol, long maxTotalBitrate, string[] targetAudioCodecs, MediaStream audioStream, StreamInfo item) + private static int GetAudioBitrate(long maxTotalBitrate, string[] targetAudioCodecs, MediaStream audioStream, StreamInfo item) { string targetAudioCodec = targetAudioCodecs.Length == 0 ? null : targetAudioCodecs[0]; @@ -1005,7 +1005,6 @@ namespace MediaBrowser.Model.Dlna MediaSourceInfo mediaSource, MediaStream videoStream, MediaStream audioStream, - bool isEligibleForDirectPlay, bool isEligibleForDirectStream) { if (options.ForceDirectPlay) @@ -1146,7 +1145,7 @@ namespace MediaBrowser.Model.Dlna { string audioCodec = audioStream.Codec; conditions = new List<ProfileCondition>(); - bool? isSecondaryAudio = audioStream == null ? null : mediaSource.IsSecondaryAudio(audioStream); + bool? isSecondaryAudio = mediaSource.IsSecondaryAudio(audioStream); foreach (var i in profile.CodecProfiles) { @@ -1230,7 +1229,7 @@ namespace MediaBrowser.Model.Dlna bool result = IsAudioEligibleForDirectPlay(item, maxBitrate, playMethod); - return (result, result ? (TranscodeReason?)null : TranscodeReason.ContainerBitrateExceedsLimit); + return (result, result ? null : TranscodeReason.ContainerBitrateExceedsLimit); } public static SubtitleProfile GetSubtitleProfile( @@ -1262,7 +1261,7 @@ namespace MediaBrowser.Model.Dlna continue; } - if (playMethod == PlayMethod.Transcode && !IsSubtitleEmbedSupported(subtitleStream, profile, transcodingSubProtocol, outputContainer)) + if (playMethod == PlayMethod.Transcode && !IsSubtitleEmbedSupported(outputContainer)) { continue; } @@ -1291,7 +1290,7 @@ namespace MediaBrowser.Model.Dlna continue; } - if (playMethod == PlayMethod.Transcode && !IsSubtitleEmbedSupported(subtitleStream, profile, transcodingSubProtocol, outputContainer)) + if (playMethod == PlayMethod.Transcode && !IsSubtitleEmbedSupported(outputContainer)) { continue; } @@ -1313,7 +1312,7 @@ namespace MediaBrowser.Model.Dlna }; } - private static bool IsSubtitleEmbedSupported(MediaStream subtitleStream, SubtitleProfile subtitleProfile, string transcodingSubProtocol, string transcodingContainer) + private static bool IsSubtitleEmbedSupported(string transcodingContainer) { if (!string.IsNullOrEmpty(transcodingContainer)) { @@ -1728,18 +1727,14 @@ namespace MediaBrowser.Model.Dlna continue; } - if (!string.IsNullOrEmpty(value)) - { - // change from split by | to comma - - // strip spaces to avoid having to encode - var values = value - .Split('|', StringSplitOptions.RemoveEmptyEntries); + // change from split by | to comma + // strip spaces to avoid having to encode + var values = value + .Split('|', StringSplitOptions.RemoveEmptyEntries); - if (condition.Condition == ProfileConditionType.Equals || condition.Condition == ProfileConditionType.EqualsAny) - { - item.SetOption(qualifier, "profile", string.Join(',', values)); - } + if (condition.Condition == ProfileConditionType.Equals || condition.Condition == ProfileConditionType.EqualsAny) + { + item.SetOption(qualifier, "profile", string.Join(',', values)); } break; diff --git a/MediaBrowser.Model/Dlna/StreamInfo.cs b/MediaBrowser.Model/Dlna/StreamInfo.cs index 252872847a..4414415a27 100644 --- a/MediaBrowser.Model/Dlna/StreamInfo.cs +++ b/MediaBrowser.Model/Dlna/StreamInfo.cs @@ -133,7 +133,7 @@ namespace MediaBrowser.Model.Dlna var stream = TargetAudioStream; return AudioSampleRate.HasValue && !IsDirectStream ? AudioSampleRate - : stream == null ? null : stream.SampleRate; + : stream?.SampleRate; } } @@ -146,7 +146,7 @@ namespace MediaBrowser.Model.Dlna { if (IsDirectStream) { - return TargetAudioStream == null ? (int?)null : TargetAudioStream.BitDepth; + return TargetAudioStream?.BitDepth; } var targetAudioCodecs = TargetAudioCodec; @@ -156,7 +156,7 @@ namespace MediaBrowser.Model.Dlna return GetTargetAudioBitDepth(audioCodec); } - return TargetAudioStream == null ? (int?)null : TargetAudioStream.BitDepth; + return TargetAudioStream?.BitDepth; } } @@ -169,7 +169,7 @@ namespace MediaBrowser.Model.Dlna { if (IsDirectStream) { - return TargetVideoStream == null ? (int?)null : TargetVideoStream.BitDepth; + return TargetVideoStream?.BitDepth; } var targetVideoCodecs = TargetVideoCodec; @@ -179,7 +179,7 @@ namespace MediaBrowser.Model.Dlna return GetTargetVideoBitDepth(videoCodec); } - return TargetVideoStream == null ? (int?)null : TargetVideoStream.BitDepth; + return TargetVideoStream?.BitDepth; } } @@ -193,7 +193,7 @@ namespace MediaBrowser.Model.Dlna { if (IsDirectStream) { - return TargetVideoStream == null ? (int?)null : TargetVideoStream.RefFrames; + return TargetVideoStream?.RefFrames; } var targetVideoCodecs = TargetVideoCodec; @@ -203,7 +203,7 @@ namespace MediaBrowser.Model.Dlna return GetTargetRefFrames(videoCodec); } - return TargetVideoStream == null ? (int?)null : TargetVideoStream.RefFrames; + return TargetVideoStream?.RefFrames; } } @@ -230,7 +230,7 @@ namespace MediaBrowser.Model.Dlna { if (IsDirectStream) { - return TargetVideoStream == null ? (double?)null : TargetVideoStream.Level; + return TargetVideoStream?.Level; } var targetVideoCodecs = TargetVideoCodec; @@ -240,7 +240,7 @@ namespace MediaBrowser.Model.Dlna return GetTargetVideoLevel(videoCodec); } - return TargetVideoStream == null ? (double?)null : TargetVideoStream.Level; + return TargetVideoStream?.Level; } } @@ -254,7 +254,7 @@ namespace MediaBrowser.Model.Dlna var stream = TargetVideoStream; return !IsDirectStream ? null - : stream == null ? null : stream.PacketLength; + : stream?.PacketLength; } } @@ -267,7 +267,7 @@ namespace MediaBrowser.Model.Dlna { if (IsDirectStream) { - return TargetVideoStream == null ? null : TargetVideoStream.Profile; + return TargetVideoStream?.Profile; } var targetVideoCodecs = TargetVideoCodec; @@ -277,7 +277,7 @@ namespace MediaBrowser.Model.Dlna return GetOption(videoCodec, "profile"); } - return TargetVideoStream == null ? null : TargetVideoStream.Profile; + return TargetVideoStream?.Profile; } } @@ -292,7 +292,7 @@ namespace MediaBrowser.Model.Dlna var stream = TargetVideoStream; return !IsDirectStream ? null - : stream == null ? null : stream.CodecTag; + : stream?.CodecTag; } } @@ -306,7 +306,7 @@ namespace MediaBrowser.Model.Dlna var stream = TargetAudioStream; return AudioBitrate.HasValue && !IsDirectStream ? AudioBitrate - : stream == null ? null : stream.BitRate; + : stream?.BitRate; } } @@ -319,7 +319,7 @@ namespace MediaBrowser.Model.Dlna { if (IsDirectStream) { - return TargetAudioStream == null ? (int?)null : TargetAudioStream.Channels; + return TargetAudioStream?.Channels; } var targetAudioCodecs = TargetAudioCodec; @@ -329,7 +329,7 @@ namespace MediaBrowser.Model.Dlna return GetTargetRefFrames(codec); } - return TargetAudioStream == null ? (int?)null : TargetAudioStream.Channels; + return TargetAudioStream?.Channels; } } @@ -425,7 +425,7 @@ namespace MediaBrowser.Model.Dlna return VideoBitrate.HasValue && !IsDirectStream ? VideoBitrate - : stream == null ? null : stream.BitRate; + : stream?.BitRate; } } @@ -451,7 +451,7 @@ namespace MediaBrowser.Model.Dlna { if (IsDirectStream) { - return TargetVideoStream == null ? null : TargetVideoStream.IsAnamorphic; + return TargetVideoStream?.IsAnamorphic; } return false; @@ -464,7 +464,7 @@ namespace MediaBrowser.Model.Dlna { if (IsDirectStream) { - return TargetVideoStream == null ? (bool?)null : TargetVideoStream.IsInterlaced; + return TargetVideoStream?.IsInterlaced; } var targetVideoCodecs = TargetVideoCodec; @@ -477,7 +477,7 @@ namespace MediaBrowser.Model.Dlna } } - return TargetVideoStream == null ? (bool?)null : TargetVideoStream.IsInterlaced; + return TargetVideoStream?.IsInterlaced; } } @@ -487,7 +487,7 @@ namespace MediaBrowser.Model.Dlna { if (IsDirectStream) { - return TargetVideoStream == null ? null : TargetVideoStream.IsAVC; + return TargetVideoStream?.IsAVC; } return true; @@ -618,30 +618,30 @@ namespace MediaBrowser.Model.Dlna } // Try to keep the url clean by omitting defaults - if (string.Equals(pair.Name, "StartTimeTicks", StringComparison.OrdinalIgnoreCase) && - string.Equals(pair.Value, "0", StringComparison.OrdinalIgnoreCase)) + if (string.Equals(pair.Name, "StartTimeTicks", StringComparison.OrdinalIgnoreCase) + && string.Equals(pair.Value, "0", StringComparison.OrdinalIgnoreCase)) { continue; } - if (string.Equals(pair.Name, "SubtitleStreamIndex", StringComparison.OrdinalIgnoreCase) && - string.Equals(pair.Value, "-1", StringComparison.OrdinalIgnoreCase)) + if (string.Equals(pair.Name, "SubtitleStreamIndex", StringComparison.OrdinalIgnoreCase) + && string.Equals(pair.Value, "-1", StringComparison.OrdinalIgnoreCase)) { continue; } - if (string.Equals(pair.Name, "Static", StringComparison.OrdinalIgnoreCase) && - string.Equals(pair.Value, "false", StringComparison.OrdinalIgnoreCase)) + if (string.Equals(pair.Name, "Static", StringComparison.OrdinalIgnoreCase) + && string.Equals(pair.Value, "false", StringComparison.OrdinalIgnoreCase)) { continue; } - var encodedValue = pair.Value.Replace(" ", "%20"); + var encodedValue = pair.Value.Replace(" ", "%20", StringComparison.Ordinal); list.Add(string.Format(CultureInfo.InvariantCulture, "{0}={1}", pair.Name, encodedValue)); } - string queryString = string.Join("&", list.ToArray()); + string queryString = string.Join('&', list); return GetUrl(baseUrl, queryString); } @@ -681,11 +681,11 @@ namespace MediaBrowser.Model.Dlna string audioCodecs = item.AudioCodecs.Length == 0 ? string.Empty : - string.Join(",", item.AudioCodecs); + string.Join(',', item.AudioCodecs); string videoCodecs = item.VideoCodecs.Length == 0 ? string.Empty : - string.Join(",", item.VideoCodecs); + string.Join(',', item.VideoCodecs); list.Add(new NameValuePair("DeviceProfileId", item.DeviceProfileId ?? string.Empty)); list.Add(new NameValuePair("DeviceId", item.DeviceId ?? string.Empty)); @@ -1024,30 +1024,5 @@ namespace MediaBrowser.Model.Dlna return count; } - - public List<MediaStream> GetSelectableAudioStreams() - { - return GetSelectableStreams(MediaStreamType.Audio); - } - - public List<MediaStream> GetSelectableSubtitleStreams() - { - return GetSelectableStreams(MediaStreamType.Subtitle); - } - - public List<MediaStream> GetSelectableStreams(MediaStreamType type) - { - var list = new List<MediaStream>(); - - foreach (var stream in MediaSource.MediaStreams) - { - if (type == stream.Type) - { - list.Add(stream); - } - } - - return list; - } } } diff --git a/MediaBrowser.Model/Dlna/TranscodingProfile.cs b/MediaBrowser.Model/Dlna/TranscodingProfile.cs index 214578a85e..709bdad312 100644 --- a/MediaBrowser.Model/Dlna/TranscodingProfile.cs +++ b/MediaBrowser.Model/Dlna/TranscodingProfile.cs @@ -1,7 +1,6 @@ #pragma warning disable CS1591 using System.ComponentModel; -using System.ComponentModel.DataAnnotations; using System.Xml.Serialization; namespace MediaBrowser.Model.Dlna diff --git a/MediaBrowser.Model/Entities/MediaStream.cs b/MediaBrowser.Model/Entities/MediaStream.cs index c67f30d045..38ac44794d 100644 --- a/MediaBrowser.Model/Entities/MediaStream.cs +++ b/MediaBrowser.Model/Entities/MediaStream.cs @@ -255,13 +255,18 @@ namespace MediaBrowser.Model.Entities attributes.Add(string.IsNullOrEmpty(LocalizedForced) ? "Forced" : LocalizedForced); } + if (!string.IsNullOrEmpty(Codec)) + { + attributes.Add(Codec.ToUpperInvariant()); + } + if (!string.IsNullOrEmpty(Title)) { var result = new StringBuilder(Title); foreach (var tag in attributes) { // Keep Tags that are not already in Title. - if (Title.IndexOf(tag, StringComparison.OrdinalIgnoreCase) == -1) + if (!Title.Contains(tag, StringComparison.OrdinalIgnoreCase)) { result.Append(" - ").Append(tag); } @@ -469,64 +474,30 @@ namespace MediaBrowser.Model.Entities /// <value><c>true</c> if this instance is anamorphic; otherwise, <c>false</c>.</value> public bool? IsAnamorphic { get; set; } - private string GetResolutionText() + internal string GetResolutionText() { - var i = this; - - if (i.Width.HasValue && i.Height.HasValue) + if (!Width.HasValue || !Height.HasValue) { - var width = i.Width.Value; - var height = i.Height.Value; - - if (width >= 3800 || height >= 2000) - { - return "4K"; - } - - if (width >= 2500) - { - if (i.IsInterlaced) - { - return "1440i"; - } - - return "1440p"; - } - - if (width >= 1900 || height >= 1000) - { - if (i.IsInterlaced) - { - return "1080i"; - } - - return "1080p"; - } - - if (width >= 1260 || height >= 700) - { - if (i.IsInterlaced) - { - return "720i"; - } - - return "720p"; - } - - if (width >= 700 || height >= 440) - { - if (i.IsInterlaced) - { - return "480i"; - } - - return "480p"; - } - - return "SD"; + return null; } - return null; + return Width switch + { + <= 720 when Height <= 480 => IsInterlaced ? "480i" : "480p", + // 720x576 (PAL) (768 when rescaled for square pixels) + <= 768 when Height <= 576 => IsInterlaced ? "576i" : "576p", + // 960x540 (sometimes 544 which is multiple of 16) + <= 960 when Height <= 544 => IsInterlaced ? "540i" : "540p", + // 1280x720 + <= 1280 when Height <= 962 => IsInterlaced ? "720i" : "720p", + // 1920x1080 + <= 1920 when Height <= 1440 => IsInterlaced ? "1080i" : "1080p", + // 4K + <= 4096 when Height <= 3072 => "4K", + // 8K + <= 8192 when Height <= 6144 => "8K", + _ => null + }; } public static bool IsTextFormat(string format) diff --git a/MediaBrowser.Model/Entities/PersonType.cs b/MediaBrowser.Model/Entities/PersonType.cs index 81db9c6131..b985507f0c 100644 --- a/MediaBrowser.Model/Entities/PersonType.cs +++ b/MediaBrowser.Model/Entities/PersonType.cs @@ -1,48 +1,68 @@ namespace MediaBrowser.Model.Entities { /// <summary> - /// Struct PersonType. + /// Types of persons. /// </summary> - public class PersonType + public static class PersonType { /// <summary> - /// The actor. + /// A person whose profession is acting on the stage, in films, or on television. /// </summary> public const string Actor = "Actor"; /// <summary> - /// The director. + /// A person who supervises the actors and other staff in a film, play, or similar production. /// </summary> public const string Director = "Director"; /// <summary> - /// The composer. + /// A person who writes music, especially as a professional occupation. /// </summary> public const string Composer = "Composer"; /// <summary> - /// The writer. + /// A writer of a book, article, or document. Can also be used as a generic term for music writer if there is a lack of specificity. /// </summary> public const string Writer = "Writer"; /// <summary> - /// The guest star. + /// A well-known actor or other performer who appears in a work in which they do not have a regular role. /// </summary> public const string GuestStar = "GuestStar"; /// <summary> - /// The producer. + /// A person responsible for the financial and managerial aspects of the making of a film or broadcast or for staging a play, opera, etc. /// </summary> public const string Producer = "Producer"; /// <summary> - /// The conductor. + /// A person who directs the performance of an orchestra or choir. /// </summary> public const string Conductor = "Conductor"; /// <summary> - /// The lyricist. + /// A person who writes the words to a song or musical. /// </summary> public const string Lyricist = "Lyricist"; + + /// <summary> + /// A person who adapts a musical composition for performance. + /// </summary> + public const string Arranger = "Arranger"; + + /// <summary> + /// An audio engineer who performed a general engineering role. + /// </summary> + public const string Engineer = "Engineer"; + + /// <summary> + /// An engineer responsible for using a mixing console to mix a recorded track into a single piece of music suitable for release. + /// </summary> + public const string Mixer = "Mixer"; + + /// <summary> + /// A person who remixed a recording by taking one or more other tracks, substantially altering them and mixing them together with other material. + /// </summary> + public const string Remixer = "Remixer"; } } diff --git a/MediaBrowser.Model/Extensions/EnumerableExtensions.cs b/MediaBrowser.Model/Extensions/EnumerableExtensions.cs index 712fa381ee..a5a6b18aa8 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,14 +33,16 @@ namespace MediaBrowser.Model.Extensions return 3; } - if (!isRequestedLanguageEn && string.Equals("en", i.Language, StringComparison.OrdinalIgnoreCase)) + if (string.IsNullOrEmpty(i.Language)) { - return 2; + // Assume empty image language is likely to be English. + return isRequestedLanguageEn ? 3 : 2; } - if (string.IsNullOrEmpty(i.Language)) + if (!isRequestedLanguageEn && string.Equals(i.Language, "en", StringComparison.OrdinalIgnoreCase)) { - return isRequestedLanguageEn ? 3 : 2; + // Prioritize English over non-requested languages. + return 2; } return 0; diff --git a/MediaBrowser.Model/Globalization/ILocalizationManager.cs b/MediaBrowser.Model/Globalization/ILocalizationManager.cs index baefeb39cf..e00157dce9 100644 --- a/MediaBrowser.Model/Globalization/ILocalizationManager.cs +++ b/MediaBrowser.Model/Globalization/ILocalizationManager.cs @@ -1,6 +1,4 @@ -#nullable disable using System.Collections.Generic; -using System.Globalization; using MediaBrowser.Model.Entities; namespace MediaBrowser.Model.Globalization @@ -57,18 +55,10 @@ namespace MediaBrowser.Model.Globalization IEnumerable<LocalizationOption> GetLocalizationOptions(); /// <summary> - /// Checks if the string contains a character with the specified unicode category. - /// </summary> - /// <param name="value">The string.</param> - /// <param name="category">The unicode category.</param> - /// <returns>Wether or not the string contains a character with the specified unicode category.</returns> - bool HasUnicodeCategory(string value, UnicodeCategory category); - - /// <summary> - /// Returns the correct <see cref="CultureInfo" /> for the given language. + /// 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> - CultureDto FindLanguageInfo(string language); + /// <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 new file mode 100644 index 0000000000..3c8007d1c4 --- /dev/null +++ b/MediaBrowser.Model/IO/AsyncFile.cs @@ -0,0 +1,45 @@ +using System.IO; + +namespace MediaBrowser.Model.IO +{ + /// <summary> + /// Helper class to create async <see cref="FileStream" />s. + /// </summary> + public static class AsyncFile + { + /// <summary> + /// Gets the default <see cref="FileStreamOptions"/> for reading files async. + /// </summary> + 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. + /// </summary> + /// <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, FileOptions.Asynchronous); + + /// <summary> + /// Opens an existing file for writing. + /// </summary> + /// <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, FileOptions.Asynchronous); + } +} diff --git a/MediaBrowser.Model/IO/IFileSystem.cs b/MediaBrowser.Model/IO/IFileSystem.cs index be4f1e16b5..0f77d6b5bf 100644 --- a/MediaBrowser.Model/IO/IFileSystem.cs +++ b/MediaBrowser.Model/IO/IFileSystem.cs @@ -51,7 +51,7 @@ namespace MediaBrowser.Model.IO /// <returns>A <see cref="FileSystemMetadata" /> object.</returns> /// <remarks><para>If the specified path points to a directory, the returned <see cref="FileSystemMetadata" /> object's /// <see cref="FileSystemMetadata.IsDirectory" /> property and the <see cref="FileSystemMetadata.Exists" /> property will both be set to false.</para> - /// <para>For automatic handling of files <b>and</b> directories, use <see cref="M:IFileSystem.GetFileSystemInfo(System.String)" />.</para></remarks> + /// <para>For automatic handling of files <b>and</b> directories, use <see cref="GetFileSystemInfo(string)" />.</para></remarks> FileSystemMetadata GetFileInfo(string path); /// <summary> @@ -61,7 +61,7 @@ namespace MediaBrowser.Model.IO /// <returns>A <see cref="FileSystemMetadata" /> object.</returns> /// <remarks><para>If the specified path points to a file, the returned <see cref="FileSystemMetadata" /> object's /// <see cref="FileSystemMetadata.IsDirectory" /> property will be set to true and the <see cref="FileSystemMetadata.Exists" /> property will be set to false.</para> - /// <para>For automatic handling of files <b>and</b> directories, use <see cref="M:IFileSystem.GetFileSystemInfo(System.String)" />.</para></remarks> + /// <para>For automatic handling of files <b>and</b> directories, use <see cref="GetFileSystemInfo(string)" />.</para></remarks> FileSystemMetadata GetDirectoryInfo(string path); /// <summary> diff --git a/MediaBrowser.Model/MediaBrowser.Model.csproj b/MediaBrowser.Model/MediaBrowser.Model.csproj index c475d905a9..16bc4adf8a 100644 --- a/MediaBrowser.Model/MediaBrowser.Model.csproj +++ b/MediaBrowser.Model/MediaBrowser.Model.csproj @@ -14,13 +14,9 @@ </PropertyGroup> <PropertyGroup> - <TargetFramework>net5.0</TargetFramework> + <TargetFramework>net6.0</TargetFramework> <GenerateAssemblyInfo>false</GenerateAssemblyInfo> <GenerateDocumentationFile>true</GenerateDocumentationFile> - <TreatWarningsAsErrors>true</TreatWarningsAsErrors> - <Nullable>enable</Nullable> - <!-- <AnalysisMode>AllEnabledByDefault</AnalysisMode> --> - <CodeAnalysisRuleSet>../jellyfin.ruleset</CodeAnalysisRuleSet> <PublishRepositoryUrl>true</PublishRepositoryUrl> <EmbedUntrackedSources>true</EmbedUntrackedSources> <IncludeSymbols>true</IncludeSymbols> @@ -34,9 +30,9 @@ <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.Extensions.Logging.Abstractions" Version="6.0.0-rc.2*" /> <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.0-rc.2*" /> </ItemGroup> <ItemGroup> diff --git a/MediaBrowser.Model/Net/MimeTypes.cs b/MediaBrowser.Model/Net/MimeTypes.cs index 96f5ab51ae..748170a0e1 100644 --- a/MediaBrowser.Model/Net/MimeTypes.cs +++ b/MediaBrowser.Model/Net/MimeTypes.cs @@ -2,8 +2,10 @@ using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.IO; using System.Linq; +using Jellyfin.Extensions; namespace MediaBrowser.Model.Net { @@ -163,15 +165,16 @@ namespace MediaBrowser.Model.Net return dict; } - public static string? GetMimeType(string path) => GetMimeType(path, true); + 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: NotNullIfNotNullAttribute("defaultValue")] + public static string? GetMimeType(string filename, string? defaultValue = null) { if (filename.Length == 0) { @@ -210,7 +213,7 @@ namespace MediaBrowser.Model.Net return "application/octet-stream"; } - return enableStreamDefault ? "application/octet-stream" : null; + return defaultValue; } public static string? ToExtension(string mimeType) @@ -221,7 +224,7 @@ 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)) { diff --git a/MediaBrowser.Model/Properties/AssemblyInfo.cs b/MediaBrowser.Model/Properties/AssemblyInfo.cs index f99e9ece96..e50baf604e 100644 --- a/MediaBrowser.Model/Properties/AssemblyInfo.cs +++ b/MediaBrowser.Model/Properties/AssemblyInfo.cs @@ -1,5 +1,6 @@ using System.Reflection; using System.Resources; +using System.Runtime.CompilerServices; using System.Runtime.InteropServices; // General Information about an assembly is controlled through the following @@ -14,6 +15,7 @@ using System.Runtime.InteropServices; [assembly: AssemblyTrademark("")] [assembly: AssemblyCulture("")] [assembly: NeutralResourcesLanguage("en")] +[assembly: InternalsVisibleTo("Jellyfin.Model.Tests")] // Setting ComVisible to false makes the types in this assembly not visible // to COM components. If you need to access a type in this assembly from diff --git a/MediaBrowser.Model/Providers/ExternalIdInfo.cs b/MediaBrowser.Model/Providers/ExternalIdInfo.cs index 0ea3e96cae..d026d574fa 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/QuickConnect/QuickConnectResult.cs b/MediaBrowser.Model/QuickConnect/QuickConnectResult.cs index d180d29860..35a82f47cd 100644 --- a/MediaBrowser.Model/QuickConnect/QuickConnectResult.cs +++ b/MediaBrowser.Model/QuickConnect/QuickConnectResult.cs @@ -13,17 +13,32 @@ namespace MediaBrowser.Model.QuickConnect /// <param name="secret">The secret used to query the request state.</param> /// <param name="code">The code used to allow the request.</param> /// <param name="dateAdded">The time when the request was created.</param> - public QuickConnectResult(string secret, string code, DateTime dateAdded) + /// <param name="deviceId">The requesting device id.</param> + /// <param name="deviceName">The requesting device name.</param> + /// <param name="appName">The requesting app name.</param> + /// <param name="appVersion">The requesting app version.</param> + public QuickConnectResult( + string secret, + string code, + DateTime dateAdded, + string deviceId, + string deviceName, + string appName, + string appVersion) { Secret = secret; Code = code; DateAdded = dateAdded; + DeviceId = deviceId; + DeviceName = deviceName; + AppName = appName; + AppVersion = appVersion; } /// <summary> - /// Gets a value indicating whether this request is authorized. + /// Gets or sets a value indicating whether this request is authorized. /// </summary> - public bool Authenticated => Authentication != null; + public bool Authenticated { get; set; } /// <summary> /// Gets the secret value used to uniquely identify this request. Can be used to retrieve authentication information. @@ -36,9 +51,24 @@ namespace MediaBrowser.Model.QuickConnect public string Code { get; } /// <summary> - /// Gets or sets the private access token. + /// Gets the requesting device id. /// </summary> - public Guid? Authentication { get; set; } + public string DeviceId { get; } + + /// <summary> + /// Gets the requesting device name. + /// </summary> + public string DeviceName { get; } + + /// <summary> + /// Gets the requesting app name. + /// </summary> + public string AppName { get; } + + /// <summary> + /// Gets the requesting app version. + /// </summary> + public string AppVersion { get; } /// <summary> /// Gets or sets the DateTime that this request was created. diff --git a/MediaBrowser.Model/Session/HardwareEncodingType.cs b/MediaBrowser.Model/Session/HardwareEncodingType.cs new file mode 100644 index 0000000000..0e172f35f3 --- /dev/null +++ b/MediaBrowser.Model/Session/HardwareEncodingType.cs @@ -0,0 +1,48 @@ +namespace MediaBrowser.Model.Session +{ + /// <summary> + /// Enum HardwareEncodingType. + /// </summary> + public enum HardwareEncodingType + { + /// <summary> + /// AMD AMF + /// </summary> + AMF = 0, + + /// <summary> + /// Intel Quick Sync Video + /// </summary> + QSV = 1, + + /// <summary> + /// NVIDIA NVENC + /// </summary> + NVENC = 2, + + /// <summary> + /// OpenMax OMX + /// </summary> + OMX = 3, + + /// <summary> + /// Exynos V4L2 MFC + /// </summary> + V4L2M2M = 4, + + /// <summary> + /// MediaCodec Android + /// </summary> + MediaCodec = 5, + + /// <summary> + /// Video Acceleration API (VAAPI) + /// </summary> + VAAPI = 6, + + /// <summary> + /// Video ToolBox + /// </summary> + VideoToolBox = 7 + } +} diff --git a/MediaBrowser.Model/Session/TranscodingInfo.cs b/MediaBrowser.Model/Session/TranscodingInfo.cs index 064a087d5d..68ab691f88 100644 --- a/MediaBrowser.Model/Session/TranscodingInfo.cs +++ b/MediaBrowser.Model/Session/TranscodingInfo.cs @@ -34,6 +34,8 @@ namespace MediaBrowser.Model.Session public int? AudioChannels { get; set; } + public HardwareEncodingType? HardwareAccelerationType { get; set; } + public TranscodeReason[] TranscodeReasons { get; set; } } } diff --git a/MediaBrowser.Model/SyncPlay/PlayQueueUpdate.cs b/MediaBrowser.Model/SyncPlay/PlayQueueUpdate.cs index a851229f74..cce99c77d5 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/System/SystemInfo.cs b/MediaBrowser.Model/System/SystemInfo.cs index d75ae91c02..a82c1c8c0c 100644 --- a/MediaBrowser.Model/System/SystemInfo.cs +++ b/MediaBrowser.Model/System/SystemInfo.cs @@ -130,8 +130,10 @@ namespace MediaBrowser.Model.System /// Gets or sets a value indicating whether this instance has update available. /// </summary> /// <value><c>true</c> if this instance has update available; otherwise, <c>false</c>.</value> + [Obsolete("This should be handled by the package manager")] public bool HasUpdateAvailable { get; set; } + [Obsolete("This isn't set correctly anymore")] public FFmpegLocation EncoderLocation { get; set; } public Architecture SystemArchitecture { get; set; } diff --git a/MediaBrowser.Model/Users/PinRedeemResult.cs b/MediaBrowser.Model/Users/PinRedeemResult.cs index 7e4553bac8..23fa631e86 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/UserActionType.cs b/MediaBrowser.Model/Users/UserActionType.cs deleted file mode 100644 index dbb1513f22..0000000000 --- a/MediaBrowser.Model/Users/UserActionType.cs +++ /dev/null @@ -1,9 +0,0 @@ -#pragma warning disable CS1591 - -namespace MediaBrowser.Model.Users -{ - public enum UserActionType - { - PlayedItem = 0 - } -} diff --git a/MediaBrowser.Model/Users/UserPolicy.cs b/MediaBrowser.Model/Users/UserPolicy.cs index 111070d813..3634d07058 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/BoxSets/BoxSetMetadataService.cs b/MediaBrowser.Providers/BoxSets/BoxSetMetadataService.cs index e5326da71c..88ce8d087d 100644 --- a/MediaBrowser.Providers/BoxSets/BoxSetMetadataService.cs +++ b/MediaBrowser.Providers/BoxSets/BoxSetMetadataService.cs @@ -59,9 +59,9 @@ namespace MediaBrowser.Providers.BoxSets } /// <inheritdoc /> - protected override ItemUpdateType BeforeSaveInternal(BoxSet item, bool isFullRefresh, ItemUpdateType currentUpdateType) + protected override ItemUpdateType BeforeSaveInternal(BoxSet item, bool isFullRefresh, ItemUpdateType updateType) { - var updateType = base.BeforeSaveInternal(item, isFullRefresh, currentUpdateType); + var updatedType = base.BeforeSaveInternal(item, isFullRefresh, updateType); var libraryFolderIds = item.GetLibraryFolderIds(); @@ -69,10 +69,10 @@ namespace MediaBrowser.Providers.BoxSets if (itemLibraryFolderIds == null || !libraryFolderIds.SequenceEqual(itemLibraryFolderIds)) { item.LibraryFolderIds = libraryFolderIds; - updateType |= ItemUpdateType.MetadataImport; + updatedType |= ItemUpdateType.MetadataImport; } - return updateType; + return updatedType; } } } diff --git a/MediaBrowser.Providers/Manager/ImageSaver.cs b/MediaBrowser.Providers/Manager/ImageSaver.cs index fb1d4f4906..8ded2d1446 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; @@ -29,8 +31,6 @@ namespace MediaBrowser.Providers.Manager /// </summary> public class ImageSaver { - private static readonly CultureInfo UsCulture = new CultureInfo("en-US"); - /// <summary> /// The _config. /// </summary> @@ -91,7 +91,7 @@ namespace MediaBrowser.Providers.Manager throw new ArgumentNullException(nameof(mimeType)); } - var saveLocally = item.SupportsLocalMetadata && item.IsSaveLocalMetadataEnabled() && !item.ExtraType.HasValue && !(item is Audio); + var saveLocally = item.SupportsLocalMetadata && item.IsSaveLocalMetadataEnabled() && !item.ExtraType.HasValue && item is not Audio; if (type != ImageType.Primary && item is Episode) { @@ -263,8 +263,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, FileOptions.Asynchronous)) + 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 +379,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 +402,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; @@ -495,12 +497,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), StringComparer.OrdinalIgnoreCase)) { current++; } - return numberedIndexPrefix + current.ToString(UsCulture); + return numberedIndexPrefix + current.ToString(CultureInfo.InvariantCulture); } /// <summary> @@ -539,7 +541,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 +558,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 +570,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 +584,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 966a3d8220..39372acb94 100644 --- a/MediaBrowser.Providers/Manager/ItemImageProvider.cs +++ b/MediaBrowser.Providers/Manager/ItemImageProvider.cs @@ -1,4 +1,6 @@ -#pragma warning disable CS1591 +#nullable disable + +#pragma warning disable CA1002, CS1591 using System; using System.Collections.Generic; @@ -56,7 +58,7 @@ namespace MediaBrowser.Providers.Manager { var hasChanges = false; - if (!(item is Photo)) + if (item is not Photo) { var images = providers.OfType<ILocalImageProvider>() .SelectMany(i => i.GetImages(item, directoryService)) @@ -102,7 +104,7 @@ namespace MediaBrowser.Providers.Manager { if (provider is IRemoteImageProvider remoteProvider) { - await RefreshFromProvider(item, libraryOptions, remoteProvider, refreshOptions, typeOptions, backdropLimit, screenshotLimit, downloadedImages, result, cancellationToken).ConfigureAwait(false); + await RefreshFromProvider(item, remoteProvider, refreshOptions, typeOptions, backdropLimit, screenshotLimit, downloadedImages, result, cancellationToken).ConfigureAwait(false); continue; } @@ -163,7 +165,7 @@ namespace MediaBrowser.Providers.Manager { var mimeType = MimeTypes.GetMimeType(response.Path); - var stream = new FileStream(response.Path, FileMode.Open, FileAccess.Read, FileShare.Read, IODefaults.FileStreamBufferSize, true); + var stream = AsyncFile.OpenRead(response.Path); await _providerManager.SaveImage(item, stream, mimeType, imageType, null, cancellationToken).ConfigureAwait(false); } @@ -235,7 +237,6 @@ namespace MediaBrowser.Providers.Manager /// Refreshes from provider. /// </summary> /// <param name="item">The item.</param> - /// <param name="libraryOptions">The library options.</param> /// <param name="provider">The provider.</param> /// <param name="refreshOptions">The refresh options.</param> /// <param name="savedOptions">The saved options.</param> @@ -247,7 +248,6 @@ namespace MediaBrowser.Providers.Manager /// <returns>Task.</returns> private async Task RefreshFromProvider( BaseItem item, - LibraryOptions libraryOptions, IRemoteImageProvider provider, ImageRefreshOptions refreshOptions, TypeOptions savedOptions, @@ -277,7 +277,7 @@ namespace MediaBrowser.Providers.Manager item, new RemoteImageQuery(provider.Name) { - IncludeAllLanguages = false, + IncludeAllLanguages = true, IncludeDisabledProviders = false, }, cancellationToken).ConfigureAwait(false); @@ -295,7 +295,7 @@ namespace MediaBrowser.Providers.Manager if (!HasImage(item, imageType) || (refreshOptions.IsReplacingImage(imageType) && !downloadedImages.Contains(imageType))) { minWidth = savedOptions.GetMinWidth(imageType); - var downloaded = await DownloadImage(item, libraryOptions, provider, result, list, minWidth, imageType, cancellationToken).ConfigureAwait(false); + var downloaded = await DownloadImage(item, provider, result, list, minWidth, imageType, cancellationToken).ConfigureAwait(false); if (downloaded) { @@ -305,12 +305,12 @@ namespace MediaBrowser.Providers.Manager } minWidth = savedOptions.GetMinWidth(ImageType.Backdrop); - await DownloadBackdrops(item, libraryOptions, ImageType.Backdrop, backdropLimit, provider, result, list, minWidth, cancellationToken).ConfigureAwait(false); + await DownloadBackdrops(item, ImageType.Backdrop, backdropLimit, provider, result, list, minWidth, cancellationToken).ConfigureAwait(false); if (item is IHasScreenshots hasScreenshots) { minWidth = savedOptions.GetMinWidth(ImageType.Screenshot); - await DownloadBackdrops(item, libraryOptions, ImageType.Screenshot, screenshotLimit, provider, result, list, minWidth, cancellationToken).ConfigureAwait(false); + await DownloadBackdrops(item, ImageType.Screenshot, screenshotLimit, provider, result, list, minWidth, cancellationToken).ConfigureAwait(false); } } catch (OperationCanceledException) @@ -360,7 +360,7 @@ namespace MediaBrowser.Providers.Manager } } - public bool MergeImages(BaseItem item, List<LocalImageInfo> images) + public bool MergeImages(BaseItem item, IReadOnlyList<LocalImageInfo> images) { var changed = false; @@ -444,12 +444,12 @@ namespace MediaBrowser.Providers.Manager return null; } - private bool UpdateMultiImages(BaseItem item, List<LocalImageInfo> images, ImageType type) + private bool UpdateMultiImages(BaseItem item, IReadOnlyList<LocalImageInfo> images, ImageType type) { var changed = false; var newImageFileInfos = images - .FindAll(i => i.Type == type) + .Where(i => i.Type == type) .Select(i => i.FileInfo) .ToList(); @@ -463,7 +463,6 @@ namespace MediaBrowser.Providers.Manager private async Task<bool> DownloadImage( BaseItem item, - LibraryOptions libraryOptions, IRemoteImageProvider provider, RefreshResult result, IEnumerable<RemoteImageInfo> images, @@ -472,10 +471,10 @@ 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 >= minWidth) .ToList(); - if (EnableImageStub(item, libraryOptions) && eligibleImages.Count > 0) + if (EnableImageStub(item) && eligibleImages.Count > 0) { SaveImageStub(item, type, eligibleImages.Select(i => i.Url)); result.UpdateType |= ItemUpdateType.ImageUpdate; @@ -489,7 +488,20 @@ 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( @@ -503,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; } } @@ -519,7 +524,7 @@ namespace MediaBrowser.Providers.Manager return false; } - private bool EnableImageStub(BaseItem item, LibraryOptions libraryOptions) + private bool EnableImageStub(BaseItem item) { if (item is LiveTvProgram) { @@ -531,7 +536,7 @@ namespace MediaBrowser.Providers.Manager return true; } - if (item is IItemByName && !(item is MusicArtist)) + if (item is IItemByName && item is not MusicArtist) { var hasDualAccess = item as IHasDualAccess; if (hasDualAccess == null || hasDualAccess.IsAccessedByName) @@ -539,6 +544,7 @@ namespace MediaBrowser.Providers.Manager return true; } } + // We always want to use prefetched images return false; } @@ -563,7 +569,7 @@ namespace MediaBrowser.Providers.Manager newIndex); } - private async Task DownloadBackdrops(BaseItem item, LibraryOptions libraryOptions, ImageType imageType, int limit, IRemoteImageProvider provider, RefreshResult result, IEnumerable<RemoteImageInfo> images, int minWidth, CancellationToken cancellationToken) + private async Task DownloadBackdrops(BaseItem item, ImageType imageType, int limit, IRemoteImageProvider provider, RefreshResult result, IEnumerable<RemoteImageInfo> images, int minWidth, CancellationToken cancellationToken) { foreach (var image in images.Where(i => i.Type == imageType)) { @@ -579,7 +585,7 @@ namespace MediaBrowser.Providers.Manager var url = image.Url; - if (EnableImageStub(item, libraryOptions)) + if (EnableImageStub(item)) { SaveImageStub(item, imageType, new[] { url }); result.UpdateType |= ItemUpdateType.ImageUpdate; @@ -590,6 +596,19 @@ namespace MediaBrowser.Providers.Manager { using var response = await provider.GetImageResponse(url, cancellationToken).ConfigureAwait(false); + // 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 size, skip it if (response.Content.Headers.ContentLength.HasValue) { @@ -617,15 +636,8 @@ namespace MediaBrowser.Providers.Manager cancellationToken).ConfigureAwait(false); result.UpdateType = 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 827cb69b9a..ffb3baeb1f 100644 --- a/MediaBrowser.Providers/Manager/MetadataService.cs +++ b/MediaBrowser.Providers/Manager/MetadataService.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System; @@ -505,6 +507,11 @@ namespace MediaBrowser.Providers.Manager /// <summary> /// Gets the providers. /// </summary> + /// <param name="item">A media item.</param> + /// <param name="libraryOptions">The LibraryOptions to use.</param> + /// <param name="options">The MetadataRefreshOptions to use.</param> + /// <param name="isFirstRefresh">Specifies first refresh mode.</param> + /// <param name="requiresRefresh">Specifies refresh mode.</param> /// <returns>IEnumerable{`0}.</returns> protected IEnumerable<IMetadataProvider> GetProviders(BaseItem item, LibraryOptions libraryOptions, MetadataRefreshOptions options, bool isFirstRefresh, bool requiresRefresh) { @@ -579,7 +586,7 @@ namespace MediaBrowser.Providers.Manager protected virtual IEnumerable<IImageProvider> GetNonLocalImageProviders(BaseItem item, IEnumerable<IImageProvider> allImageProviders, ImageRefreshOptions options) { // Get providers to refresh - var providers = allImageProviders.Where(i => !(i is ILocalImageProvider)); + var providers = allImageProviders.Where(i => i is not ILocalImageProvider); var dateLastImageRefresh = item.DateLastRefreshed; @@ -617,7 +624,7 @@ namespace MediaBrowser.Providers.Manager MetadataResult<TItemType> metadata, TIdType id, MetadataRefreshOptions options, - List<IMetadataProvider> providers, + ICollection<IMetadataProvider> providers, ItemImageProvider imageService, CancellationToken cancellationToken) { @@ -724,7 +731,7 @@ namespace MediaBrowser.Providers.Manager refreshResult.Failures += remoteResult.Failures; } - if (providers.Any(i => !(i is ICustomMetadataProvider))) + if (providers.Any(i => i is not ICustomMetadataProvider)) { if (refreshResult.UpdateType > ItemUpdateType.None) { @@ -743,7 +750,7 @@ namespace MediaBrowser.Providers.Manager // var isUnidentified = failedProviderCount > 0 && successfulProviderCount == 0; - foreach (var provider in customProviders.Where(i => !(i is IPreRefreshProvider))) + foreach (var provider in customProviders.Where(i => i is not IPreRefreshProvider)) { await RunCustomProvider(provider, item, logName, options, refreshResult, cancellationToken).ConfigureAwait(false); } diff --git a/MediaBrowser.Providers/Manager/ProviderManager.cs b/MediaBrowser.Providers/Manager/ProviderManager.cs index 2dfaa372c5..ca557f6d61 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; @@ -24,6 +26,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 +212,7 @@ namespace MediaBrowser.Providers.Manager throw new ArgumentNullException(nameof(source)); } - var fileStream = new FileStream(source, FileMode.Open, FileAccess.Read, FileShare.ReadWrite, IODefaults.FileStreamBufferSize, true); - + var fileStream = AsyncFile.OpenRead(source); return new ImageSaver(_configurationManager, _libraryMonitor, _fileSystem, _logger).SaveImage(item, fileStream, mimeType, type, imageIndex, saveLocallyWithMedia, cancellationToken); } @@ -235,14 +237,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 +249,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 +273,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) { @@ -323,7 +325,7 @@ namespace MediaBrowser.Providers.Manager .OrderBy(i => { // See if there's a user-defined order - if (!(i is ILocalImageProvider)) + if (i is not ILocalImageProvider) { var fetcherOrder = typeFetcherOrder ?? currentOptions.ImageFetcherOrder; var index = Array.IndexOf(fetcherOrder, i.Name); @@ -390,7 +392,7 @@ namespace MediaBrowser.Providers.Manager if (!includeDisabled) { // If locked only allow local providers - if (item.IsLocked && !(provider is ILocalMetadataProvider) && !(provider is IForcedProvider)) + if (item.IsLocked && provider is not ILocalMetadataProvider && provider is not IForcedProvider) { return false; } @@ -431,7 +433,7 @@ namespace MediaBrowser.Providers.Manager if (!includeDisabled) { // If locked only allow local providers - if (item.IsLocked && !(provider is ILocalImageProvider)) + if (item.IsLocked && provider is not ILocalImageProvider) { if (refreshOptions.ImageRefreshMode != MetadataRefreshMode.FullRefresh) { @@ -466,7 +468,7 @@ namespace MediaBrowser.Providers.Manager /// <returns>System.Int32.</returns> private int GetOrder(IImageProvider provider) { - if (!(provider is IHasOrder hasOrder)) + if (provider is not IHasOrder hasOrder) { return 0; } @@ -745,7 +747,7 @@ namespace MediaBrowser.Providers.Manager { // Manual edit occurred // Even if save local is off, save locally anyway if the metadata file already exists - if (!(saver is IMetadataFileSaver fileSaver) || !File.Exists(fileSaver.GetSavePath(item))) + if (saver is not IMetadataFileSaver fileSaver || !File.Exists(fileSaver.GetSavePath(item))) { return false; } diff --git a/MediaBrowser.Providers/Manager/ProviderUtils.cs b/MediaBrowser.Providers/Manager/ProviderUtils.cs index aceba22158..b90136d509 100644 --- a/MediaBrowser.Providers/Manager/ProviderUtils.cs +++ b/MediaBrowser.Providers/Manager/ProviderUtils.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System; @@ -135,7 +137,7 @@ namespace MediaBrowser.Providers.Manager { if (replaceData || !target.RunTimeTicks.HasValue) { - if (!(target is Audio) && !(target is Video)) + if (target is not Audio && target is not Video) { target.RunTimeTicks = source.RunTimeTicks; } diff --git a/MediaBrowser.Providers/Manager/RefreshResult.cs b/MediaBrowser.Providers/Manager/RefreshResult.cs index 72fc61e424..663ffc5245 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 cdb07a15da..9d0a6944bb 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-rc.2*" /> + <PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="6.0.0-rc.2*" /> + <PackageReference Include="Microsoft.Extensions.Http" Version="6.0.0-rc.2*" /> <PackageReference Include="Newtonsoft.Json" Version="13.0.1" /> <PackageReference Include="OptimizedPriorityQueue" Version="5.0.0" /> <PackageReference Include="PlaylistsNET" Version="1.1.3" /> @@ -26,11 +26,9 @@ </ItemGroup> <PropertyGroup> - <TargetFramework>net5.0</TargetFramework> + <TargetFramework>net6.0</TargetFramework> <GenerateAssemblyInfo>false</GenerateAssemblyInfo> <GenerateDocumentationFile>true</GenerateDocumentationFile> - <TreatWarningsAsErrors Condition=" '$(Configuration)' == 'Release'">true</TreatWarningsAsErrors> - <AnalysisMode Condition=" '$(Configuration)' == 'Debug'">AllEnabledByDefault</AnalysisMode> <CodeAnalysisRuleSet>../jellyfin.ruleset</CodeAnalysisRuleSet> </PropertyGroup> @@ -48,5 +46,7 @@ <EmbeddedResource Include="Plugins\Omdb\Configuration\config.html" /> <None Remove="Plugins\MusicBrainz\Configuration\config.html" /> <EmbeddedResource Include="Plugins\MusicBrainz\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 03e45fb869..8c81b08db7 100644 --- a/MediaBrowser.Providers/MediaInfo/AudioImageProvider.cs +++ b/MediaBrowser.Providers/MediaInfo/AudioImageProvider.cs @@ -1,4 +1,6 @@ -#pragma warning disable CS1591 +#nullable disable + +#pragma warning disable CA1002, CS1591 using System; using System.Collections.Generic; diff --git a/MediaBrowser.Providers/MediaInfo/EmbeddedImageProvider.cs b/MediaBrowser.Providers/MediaInfo/EmbeddedImageProvider.cs new file mode 100644 index 0000000000..c41d36d763 --- /dev/null +++ b/MediaBrowser.Providers/MediaInfo/EmbeddedImageProvider.cs @@ -0,0 +1,222 @@ +#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.MediaEncoding; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.Drawing; +using MediaBrowser.Model.Dto; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.MediaInfo; +using MediaBrowser.Model.Net; + +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 IMediaEncoder _mediaEncoder; + + /// <summary> + /// Initializes a new instance of the <see cref="EmbeddedImageProvider"/> class. + /// </summary> + /// <param name="mediaEncoder">The media encoder for extracting attached/embedded images.</param> + public EmbeddedImageProvider(IMediaEncoder mediaEncoder) + { + _mediaEncoder = mediaEncoder; + } + + /// <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, + _ => _primaryImageFileNames + }; + + // Try attachments first + var attachmentStream = item.GetMediaSources(false) + .SelectMany(source => source.MediaAttachments) + .FirstOrDefault(attachment => !string.IsNullOrEmpty(attachment.FileName) + && imageFileNames.Any(name => attachment.FileName.Contains(name, StringComparison.OrdinalIgnoreCase))); + + if (attachmentStream != null) + { + return await ExtractAttachment(item, cancellationToken, attachmentStream, mediaSource); + } + + // Fall back to EmbeddedImage streams + var imageStreams = item.GetMediaStreams().FindAll(i => i.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 }; + } + } + + string extractedImagePath = + await _mediaEncoder.ExtractVideoImage(item.Path, item.Container, mediaSource, imageStream, imageStream.Index, ".jpg", cancellationToken) + .ConfigureAwait(false); + + return new DynamicImageResponse + { + Format = ImageFormat.Jpg, + HasImage = true, + Path = extractedImagePath, + Protocol = MediaProtocol.File + }; + } + + private async Task<DynamicImageResponse> ExtractAttachment(Video item, CancellationToken cancellationToken, MediaAttachment attachmentStream, MediaSourceInfo mediaSource) + { + var extension = string.IsNullOrEmpty(attachmentStream.MimeType) + ? Path.GetExtension(attachmentStream.FileName) + : MimeTypes.ToExtension(attachmentStream.MimeType); + + if (string.IsNullOrEmpty(extension)) + { + extension = ".jpg"; + } + + string extractedAttachmentPath = + await _mediaEncoder.ExtractVideoImage(item.Path, item.Container, mediaSource, null, attachmentStream.Index, extension, cancellationToken) + .ConfigureAwait(false); + + ImageFormat format = extension switch + { + ".bmp" => ImageFormat.Bmp, + ".gif" => ImageFormat.Gif, + ".jpg" => ImageFormat.Jpg, + ".png" => ImageFormat.Png, + ".webp" => ImageFormat.Webp, + _ => ImageFormat.Jpg + }; + + 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 cf271e7db1..9eb79c39d8 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 4fff57273c..d4b5d8655c 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; diff --git a/MediaBrowser.Providers/MediaInfo/FFProbeVideoInfo.cs b/MediaBrowser.Providers/MediaInfo/FFProbeVideoInfo.cs index 12e1fbea55..4ab15f60e2 100644 --- a/MediaBrowser.Providers/MediaInfo/FFProbeVideoInfo.cs +++ b/MediaBrowser.Providers/MediaInfo/FFProbeVideoInfo.cs @@ -1,4 +1,6 @@ -#pragma warning disable CS1591 +#nullable disable + +#pragma warning disable CA1068, CS1591 using System; using System.Collections.Generic; @@ -557,6 +559,7 @@ namespace MediaBrowser.Providers.MediaInfo subtitleDownloadLanguages, libraryOptions.DisabledSubtitleFetchers, libraryOptions.SubtitleFetcherOrder, + true, cancellationToken).ConfigureAwait(false); // Rescan diff --git a/MediaBrowser.Providers/MediaInfo/SubtitleDownloader.cs b/MediaBrowser.Providers/MediaInfo/SubtitleDownloader.cs index 44ab5aa5b9..b2b93940a8 100644 --- a/MediaBrowser.Providers/MediaInfo/SubtitleDownloader.cs +++ b/MediaBrowser.Providers/MediaInfo/SubtitleDownloader.cs @@ -1,4 +1,6 @@ -#pragma warning disable CS1591 +#nullable disable + +#pragma warning disable CA1002, CS1591 using System; using System.Collections.Generic; @@ -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 3cd7ec7728..dd4a5f061c 100644 --- a/MediaBrowser.Providers/MediaInfo/SubtitleResolver.cs +++ b/MediaBrowser.Providers/MediaInfo/SubtitleResolver.cs @@ -1,4 +1,6 @@ -#pragma warning disable CS1591 +#nullable disable + +#pragma warning disable CA1002, CS1591 using System; using System.Collections.Generic; diff --git a/MediaBrowser.Providers/MediaInfo/SubtitleScheduledTask.cs b/MediaBrowser.Providers/MediaInfo/SubtitleScheduledTask.cs index 9804ec3bb4..1eacbf1e1b 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; @@ -197,6 +199,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 30af6710ab..d226182c09 100644 --- a/MediaBrowser.Providers/MediaInfo/VideoImageProvider.cs +++ b/MediaBrowser.Providers/MediaInfo/VideoImageProvider.cs @@ -1,5 +1,3 @@ -#pragma warning disable CS1591 - using System; using System.Collections.Generic; using System.Linq; @@ -11,47 +9,50 @@ using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Drawing; using MediaBrowser.Model.Dto; using MediaBrowser.Model.Entities; -using MediaBrowser.Model.IO; using MediaBrowser.Model.MediaInfo; 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 IMediaEncoder _mediaEncoder; private readonly ILogger<VideoImageProvider> _logger; - private readonly IFileSystem _fileSystem; - public VideoImageProvider(IMediaEncoder mediaEncoder, ILogger<VideoImageProvider> logger, IFileSystem fileSystem) + /// <summary> + /// Initializes a new instance of the <see cref="VideoImageProvider"/> class. + /// </summary> + /// <param name="mediaEncoder">The media encoder for capturing images.</param> + /// <param name="logger">The logger.</param> + public VideoImageProvider(IMediaEncoder mediaEncoder, ILogger<VideoImageProvider> logger) { _mediaEncoder = mediaEncoder; _logger = logger; - _fileSystem = fileSystem; } + /// <inheritdoc /> public string Name => "Screen Grabber"; + /// <inheritdoc /> // Make sure this comes after internet image providers public int Order => 100; + /// <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 video = (Video)item; - // No support for this - if (video.IsPlaceHolder) - { - return Task.FromResult(new DynamicImageResponse { HasImage = false }); - } - - // No support for this - if (video.VideoType == VideoType.Dvd) + // No support for these + if (video.IsPlaceHolder || video.VideoType == VideoType.Dvd) { return Task.FromResult(new DynamicImageResponse { HasImage = false }); } @@ -59,80 +60,39 @@ namespace MediaBrowser.Providers.MediaInfo // Can't extract if we didn't find a video stream in the file if (!video.DefaultVideoStreamIndex.HasValue) { - _logger.LogInformation("Skipping image extraction due to missing DefaultVideoStreamIndex for {0}.", video.Path ?? string.Empty); + _logger.LogInformation("Skipping image extraction due to missing DefaultVideoStreamIndex for {Path}.", video.Path ?? string.Empty); return Task.FromResult(new DynamicImageResponse { HasImage = false }); } return GetVideoImage(video, cancellationToken); } - public async Task<DynamicImageResponse> GetVideoImage(Video item, CancellationToken cancellationToken) + private async Task<DynamicImageResponse> GetVideoImage(Video item, CancellationToken cancellationToken) { - var protocol = item.PathProtocol ?? MediaProtocol.File; - - var inputPath = item.Path; - - var mediaStreams = - item.GetMediaStreams(); - - var imageStreams = - mediaStreams - .Where(i => i.Type == MediaStreamType.EmbeddedImage) - .ToList(); + MediaSourceInfo mediaSource = new MediaSourceInfo + { + VideoType = item.VideoType, + IsoType = item.IsoType, + Protocol = item.PathProtocol ?? MediaProtocol.File, + }; - var imageStream = imageStreams.FirstOrDefault(i => (i.Comment ?? string.Empty).IndexOf("front", StringComparison.OrdinalIgnoreCase) != -1) ?? - imageStreams.FirstOrDefault(i => (i.Comment ?? string.Empty).IndexOf("cover", StringComparison.OrdinalIgnoreCase) != -1) ?? - imageStreams.FirstOrDefault(); + // 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); - string extractedImagePath; + var videoStream = item.GetDefaultVideoStream() ?? item.GetMediaStreams().FirstOrDefault(i => i.Type == MediaStreamType.Video); - if (imageStream != null) - { - // Instead of using the raw stream index, we need to use nth video/embedded image stream - var videoIndex = -1; - foreach (var mediaStream in mediaStreams) - { - if (mediaStream.Type == MediaStreamType.Video || - mediaStream.Type == MediaStreamType.EmbeddedImage) - { - videoIndex++; - } - - if (mediaStream == imageStream) - { - break; - } - } - - MediaSourceInfo mediaSource = new MediaSourceInfo - { - VideoType = item.VideoType, - IsoType = item.IsoType, - Protocol = item.PathProtocol.Value, - }; - - extractedImagePath = await _mediaEncoder.ExtractVideoImage(inputPath, item.Container, mediaSource, imageStream, videoIndex, cancellationToken).ConfigureAwait(false); - } - else + 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(Convert.ToInt64(item.RunTimeTicks.Value * .1)) - : TimeSpan.FromSeconds(10); - - var videoStream = mediaStreams.FirstOrDefault(i => i.Type == MediaStreamType.Video); - var mediaSource = new MediaSourceInfo - { - VideoType = item.VideoType, - IsoType = item.IsoType, - Protocol = item.PathProtocol.Value, - }; - - extractedImagePath = await _mediaEncoder.ExtractVideoImage(inputPath, item.Container, mediaSource, videoStream, item.Video3DFormat, imageOffset, 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, @@ -142,6 +102,7 @@ namespace MediaBrowser.Providers.MediaInfo }; } + /// <inheritdoc /> public bool Supports(BaseItem item) { if (item.IsShortcut) @@ -154,12 +115,7 @@ namespace MediaBrowser.Providers.MediaInfo return false; } - if (item is Video video && !video.IsPlaceHolder && video.IsCompleteMedia) - { - return true; - } - - return false; + return item is Video video && !video.IsPlaceHolder && video.IsCompleteMedia; } } } diff --git a/MediaBrowser.Providers/Movies/ImdbExternalId.cs b/MediaBrowser.Providers/Movies/ImdbExternalId.cs index a8d74aa0b5..d00f37db58 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 8151ab4715..1bb5e1ea83 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/Music/AlbumInfoExtensions.cs b/MediaBrowser.Providers/Music/AlbumInfoExtensions.cs index dddfd02e4f..d3fce37c71 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 8c9a1f59b1..7c5b80e1e4 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; diff --git a/MediaBrowser.Providers/Music/ImvdbId.cs b/MediaBrowser.Providers/Music/ImvdbId.cs index a1726b9965..ed69f369c0 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/Playlists/PlaylistItemsProvider.cs b/MediaBrowser.Providers/Playlists/PlaylistItemsProvider.cs index 067d585cba..8baca30c65 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; diff --git a/MediaBrowser.Providers/Plugins/AudioDb/AudioDbAlbumExternalId.cs b/MediaBrowser.Providers/Plugins/AudioDb/AudioDbAlbumExternalId.cs index 138cfef19a..3a400575bc 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/AlbumImageProvider.cs b/MediaBrowser.Providers/Plugins/AudioDb/AudioDbAlbumImageProvider.cs similarity index 96% rename from MediaBrowser.Providers/Plugins/AudioDb/AlbumImageProvider.cs rename to MediaBrowser.Providers/Plugins/AudioDb/AudioDbAlbumImageProvider.cs index 36d8eeb401..ad0247fb2e 100644 --- a/MediaBrowser.Providers/Plugins/AudioDb/AlbumImageProvider.cs +++ b/MediaBrowser.Providers/Plugins/AudioDb/AudioDbAlbumImageProvider.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System.Collections.Generic; @@ -13,6 +15,7 @@ using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.Audio; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Entities; +using MediaBrowser.Model.IO; using MediaBrowser.Model.Providers; namespace MediaBrowser.Providers.Plugins.AudioDb @@ -57,7 +60,7 @@ namespace MediaBrowser.Providers.Plugins.AudioDb var path = AudioDbAlbumProvider.GetAlbumInfoPath(_config.ApplicationPaths, id); - await using FileStream jsonStream = File.OpenRead(path); + await using FileStream jsonStream = AsyncFile.OpenRead(path); var obj = await JsonSerializer.DeserializeAsync<AudioDbAlbumProvider.RootObject>(jsonStream, _jsonOptions, cancellationToken).ConfigureAwait(false); if (obj != null && obj.album != null && obj.album.Count > 0) diff --git a/MediaBrowser.Providers/Plugins/AudioDb/AlbumProvider.cs b/MediaBrowser.Providers/Plugins/AudioDb/AudioDbAlbumProvider.cs similarity index 95% rename from MediaBrowser.Providers/Plugins/AudioDb/AlbumProvider.cs rename to MediaBrowser.Providers/Plugins/AudioDb/AudioDbAlbumProvider.cs index 9539c396d9..43f30824b8 100644 --- a/MediaBrowser.Providers/Plugins/AudioDb/AlbumProvider.cs +++ b/MediaBrowser.Providers/Plugins/AudioDb/AudioDbAlbumProvider.cs @@ -1,4 +1,6 @@ -#pragma warning disable CS1591 +#nullable disable + +#pragma warning disable CA1002, CS1591, SA1300 using System; using System.Collections.Generic; @@ -9,9 +11,9 @@ using System.Net.Http; using System.Text.Json; using System.Threading; using System.Threading.Tasks; +using Jellyfin.Extensions.Json; using MediaBrowser.Common.Configuration; using MediaBrowser.Common.Extensions; -using Jellyfin.Extensions.Json; using MediaBrowser.Common.Net; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Entities.Audio; @@ -30,7 +32,9 @@ namespace MediaBrowser.Providers.Plugins.AudioDb private readonly IHttpClientFactory _httpClientFactory; private readonly JsonSerializerOptions _jsonOptions = JsonDefaults.Options; +#pragma warning disable SA1401, CA2211 public static AudioDbAlbumProvider Current; +#pragma warning restore SA1401, CA2211 public AudioDbAlbumProvider(IServerConfigurationManager config, IFileSystem fileSystem, IHttpClientFactory httpClientFactory) { @@ -64,7 +68,7 @@ namespace MediaBrowser.Providers.Plugins.AudioDb var path = GetAlbumInfoPath(_config.ApplicationPaths, id); - await using FileStream jsonStream = File.OpenRead(path); + await using FileStream jsonStream = AsyncFile.OpenRead(path); var obj = await JsonSerializer.DeserializeAsync<RootObject>(jsonStream, _jsonOptions, cancellationToken).ConfigureAwait(false); if (obj != null && obj.album != null && obj.album.Count > 0) @@ -170,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, true); + + 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); } @@ -196,6 +203,13 @@ namespace MediaBrowser.Providers.Plugins.AudioDb return Path.Combine(dataPath, "album.json"); } + /// <inheritdoc /> + public Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken cancellationToken) + { + throw new NotImplementedException(); + } + +#pragma warning disable CA1034, CA2227 public class Album { public string idAlbum { get; set; } @@ -279,11 +293,5 @@ namespace MediaBrowser.Providers.Plugins.AudioDb { public List<Album> album { get; set; } } - - /// <inheritdoc /> - public Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken cancellationToken) - { - throw new NotImplementedException(); - } } } diff --git a/MediaBrowser.Providers/Plugins/AudioDb/AudioDbArtistExternalId.cs b/MediaBrowser.Providers/Plugins/AudioDb/AudioDbArtistExternalId.cs index 8aceb48c0c..b9e57eb265 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/ArtistImageProvider.cs b/MediaBrowser.Providers/Plugins/AudioDb/AudioDbArtistImageProvider.cs similarity index 97% rename from MediaBrowser.Providers/Plugins/AudioDb/ArtistImageProvider.cs rename to MediaBrowser.Providers/Plugins/AudioDb/AudioDbArtistImageProvider.cs index aa61a56f66..9c24476608 100644 --- a/MediaBrowser.Providers/Plugins/AudioDb/ArtistImageProvider.cs +++ b/MediaBrowser.Providers/Plugins/AudioDb/AudioDbArtistImageProvider.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System.Collections.Generic; @@ -13,6 +15,7 @@ using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.Audio; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Entities; +using MediaBrowser.Model.IO; using MediaBrowser.Model.Providers; namespace MediaBrowser.Providers.Plugins.AudioDb @@ -59,7 +62,7 @@ namespace MediaBrowser.Providers.Plugins.AudioDb var path = AudioDbArtistProvider.GetArtistInfoPath(_config.ApplicationPaths, id); - await using FileStream jsonStream = File.OpenRead(path); + await using FileStream jsonStream = AsyncFile.OpenRead(path); var obj = await JsonSerializer.DeserializeAsync<AudioDbArtistProvider.RootObject>(jsonStream, _jsonOptions, cancellationToken).ConfigureAwait(false); if (obj != null && obj.artists != null && obj.artists.Count > 0) diff --git a/MediaBrowser.Providers/Plugins/AudioDb/ArtistProvider.cs b/MediaBrowser.Providers/Plugins/AudioDb/AudioDbArtistProvider.cs similarity index 96% rename from MediaBrowser.Providers/Plugins/AudioDb/ArtistProvider.cs rename to MediaBrowser.Providers/Plugins/AudioDb/AudioDbArtistProvider.cs index b2f05d76de..538dc67c42 100644 --- a/MediaBrowser.Providers/Plugins/AudioDb/ArtistProvider.cs +++ b/MediaBrowser.Providers/Plugins/AudioDb/AudioDbArtistProvider.cs @@ -1,4 +1,6 @@ -#pragma warning disable CS1591 +#nullable disable + +#pragma warning disable CA1034, CS1591, CA1002, SA1028, SA1300 using System; using System.Collections.Generic; @@ -8,9 +10,9 @@ using System.Net.Http; using System.Text.Json; using System.Threading; using System.Threading.Tasks; +using Jellyfin.Extensions.Json; using MediaBrowser.Common.Configuration; using MediaBrowser.Common.Extensions; -using Jellyfin.Extensions.Json; using MediaBrowser.Common.Net; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Entities.Audio; @@ -65,7 +67,7 @@ namespace MediaBrowser.Providers.Plugins.AudioDb var path = GetArtistInfoPath(_config.ApplicationPaths, id); - await using FileStream jsonStream = File.OpenRead(path); + await using FileStream jsonStream = AsyncFile.OpenRead(path); var obj = await JsonSerializer.DeserializeAsync<RootObject>(jsonStream, _jsonOptions, cancellationToken).ConfigureAwait(false); if (obj != null && obj.artists != null && obj.artists.Count > 0) @@ -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, true); + 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); } @@ -183,6 +187,12 @@ namespace MediaBrowser.Providers.Plugins.AudioDb return Path.Combine(dataPath, "artist.json"); } + /// <inheritdoc /> + public Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken cancellationToken) + { + throw new NotImplementedException(); + } + public class Artist { public string idArtist { get; set; } @@ -268,15 +278,10 @@ namespace MediaBrowser.Providers.Plugins.AudioDb public string strLocked { get; set; } } +#pragma warning disable CA2227 public class RootObject { public List<Artist> artists { get; set; } } - - /// <inheritdoc /> - public Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken cancellationToken) - { - throw new NotImplementedException(); - } } } diff --git a/MediaBrowser.Providers/Plugins/AudioDb/AudioDbOtherAlbumExternalId.cs b/MediaBrowser.Providers/Plugins/AudioDb/AudioDbOtherAlbumExternalId.cs index 014481da24..f8f6253ffd 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 7875391043..fd598c9186 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 664474dcdd..d61ec6cb17 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 ba0d7b5697..6c2ad0573e 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 0cec9e359c..9c27bd7d3f 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/ExternalIds.cs b/MediaBrowser.Providers/Plugins/MusicBrainz/ExternalIds.cs deleted file mode 100644 index 5600c389c0..0000000000 --- a/MediaBrowser.Providers/Plugins/MusicBrainz/ExternalIds.cs +++ /dev/null @@ -1,119 +0,0 @@ -#pragma warning disable CS1591 - -using MediaBrowser.Controller.Entities.Audio; -using MediaBrowser.Controller.Providers; -using MediaBrowser.Model.Entities; -using MediaBrowser.Model.Providers; -using MediaBrowser.Providers.Plugins.MusicBrainz; - -namespace MediaBrowser.Providers.Music -{ - public class MusicBrainzReleaseGroupExternalId : IExternalId - { - /// <inheritdoc /> - public string ProviderName => "MusicBrainz"; - - /// <inheritdoc /> - public string Key => MetadataProvider.MusicBrainzReleaseGroup.ToString(); - - /// <inheritdoc /> - public ExternalIdMediaType? Type => ExternalIdMediaType.ReleaseGroup; - - /// <inheritdoc /> - public string UrlFormatString => Plugin.Instance.Configuration.Server + "/release-group/{0}"; - - /// <inheritdoc /> - public bool Supports(IHasProviderIds item) => item is Audio || item is MusicAlbum; - } - - public class MusicBrainzAlbumArtistExternalId : IExternalId - { - /// <inheritdoc /> - public string ProviderName => "MusicBrainz"; - - /// <inheritdoc /> - public string Key => MetadataProvider.MusicBrainzAlbumArtist.ToString(); - - /// <inheritdoc /> - public ExternalIdMediaType? Type => ExternalIdMediaType.AlbumArtist; - - /// <inheritdoc /> - public string UrlFormatString => Plugin.Instance.Configuration.Server + "/artist/{0}"; - - /// <inheritdoc /> - public bool Supports(IHasProviderIds item) => item is Audio; - } - - public class MusicBrainzAlbumExternalId : IExternalId - { - /// <inheritdoc /> - public string ProviderName => "MusicBrainz"; - - /// <inheritdoc /> - public string Key => MetadataProvider.MusicBrainzAlbum.ToString(); - - /// <inheritdoc /> - public ExternalIdMediaType? Type => ExternalIdMediaType.Album; - - /// <inheritdoc /> - public string UrlFormatString => Plugin.Instance.Configuration.Server + "/release/{0}"; - - /// <inheritdoc /> - public bool Supports(IHasProviderIds item) => item is Audio || item is MusicAlbum; - } - - public class MusicBrainzArtistExternalId : IExternalId - { - /// <inheritdoc /> - public string ProviderName => "MusicBrainz"; - - /// <inheritdoc /> - public string Key => MetadataProvider.MusicBrainzArtist.ToString(); - - /// <inheritdoc /> - public ExternalIdMediaType? Type => ExternalIdMediaType.Artist; - - /// <inheritdoc /> - public string UrlFormatString => Plugin.Instance.Configuration.Server + "/artist/{0}"; - - /// <inheritdoc /> - public bool Supports(IHasProviderIds item) => item is MusicArtist; - } - - public class MusicBrainzOtherArtistExternalId : IExternalId - { - /// <inheritdoc /> - public string ProviderName => "MusicBrainz"; - - /// <inheritdoc /> - - public string Key => MetadataProvider.MusicBrainzArtist.ToString(); - - /// <inheritdoc /> - public ExternalIdMediaType? Type => ExternalIdMediaType.OtherArtist; - - /// <inheritdoc /> - public string UrlFormatString => Plugin.Instance.Configuration.Server + "/artist/{0}"; - - /// <inheritdoc /> - public bool Supports(IHasProviderIds item) => item is Audio || item is MusicAlbum; - } - - public class MusicBrainzTrackId : IExternalId - { - /// <inheritdoc /> - public string ProviderName => "MusicBrainz"; - - /// <inheritdoc /> - public string Key => MetadataProvider.MusicBrainzTrack.ToString(); - - /// <inheritdoc /> - public ExternalIdMediaType? Type => ExternalIdMediaType.Track; - - /// <inheritdoc /> - public string UrlFormatString => Plugin.Instance.Configuration.Server + "/track/{0}"; - - /// <inheritdoc /> - public bool Supports(IHasProviderIds item) => item is Audio; - } -} diff --git a/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzAlbumArtistExternalId.cs b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzAlbumArtistExternalId.cs new file mode 100644 index 0000000000..c54cdda3d3 --- /dev/null +++ b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzAlbumArtistExternalId.cs @@ -0,0 +1,28 @@ +#pragma warning disable CS1591 + +using MediaBrowser.Controller.Entities.Audio; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.Providers; +using MediaBrowser.Providers.Plugins.MusicBrainz; + +namespace MediaBrowser.Providers.Music +{ + public class MusicBrainzAlbumArtistExternalId : IExternalId + { + /// <inheritdoc /> + public string ProviderName => "MusicBrainz"; + + /// <inheritdoc /> + public string Key => MetadataProvider.MusicBrainzAlbumArtist.ToString(); + + /// <inheritdoc /> + public ExternalIdMediaType? Type => ExternalIdMediaType.AlbumArtist; + + /// <inheritdoc /> + public string? UrlFormatString => Plugin.Instance.Configuration.Server + "/artist/{0}"; + + /// <inheritdoc /> + public bool Supports(IHasProviderIds item) => item is Audio; + } +} diff --git a/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzAlbumExternalId.cs b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzAlbumExternalId.cs new file mode 100644 index 0000000000..8f7fadd060 --- /dev/null +++ b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzAlbumExternalId.cs @@ -0,0 +1,28 @@ +#pragma warning disable CS1591 + +using MediaBrowser.Controller.Entities.Audio; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.Providers; +using MediaBrowser.Providers.Plugins.MusicBrainz; + +namespace MediaBrowser.Providers.Music +{ + public class MusicBrainzAlbumExternalId : IExternalId + { + /// <inheritdoc /> + public string ProviderName => "MusicBrainz"; + + /// <inheritdoc /> + public string Key => MetadataProvider.MusicBrainzAlbum.ToString(); + + /// <inheritdoc /> + public ExternalIdMediaType? Type => ExternalIdMediaType.Album; + + /// <inheritdoc /> + public string? UrlFormatString => Plugin.Instance.Configuration.Server + "/release/{0}"; + + /// <inheritdoc /> + public bool Supports(IHasProviderIds item) => item is Audio || item is MusicAlbum; + } +} diff --git a/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzAlbumProvider.cs b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzAlbumProvider.cs index 0023d59594..5559b9db6a 100644 --- a/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzAlbumProvider.cs +++ b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzAlbumProvider.cs @@ -1,4 +1,6 @@ -#pragma warning disable CS1591 +#nullable disable + +#pragma warning disable CS1591, SA1401 using System; using System.Collections.Generic; @@ -12,7 +14,6 @@ using System.Text; using System.Threading; using System.Threading.Tasks; using System.Xml; -using MediaBrowser.Common; using MediaBrowser.Common.Net; using MediaBrowser.Controller.Entities.Audio; using MediaBrowser.Controller.Providers; @@ -23,7 +24,7 @@ using Microsoft.Extensions.Logging; namespace MediaBrowser.Providers.Music { - public class MusicBrainzAlbumProvider : IRemoteMetadataProvider<MusicAlbum, AlbumInfo>, IHasOrder + public class MusicBrainzAlbumProvider : IRemoteMetadataProvider<MusicAlbum, AlbumInfo>, IHasOrder, IDisposable { /// <summary> /// For each single MB lookup/search, this is the maximum number of @@ -36,12 +37,11 @@ namespace MediaBrowser.Providers.Music /// The Jellyfin user-agent is unrestricted but source IP must not exceed /// one request per second, therefore we rate limit to avoid throttling. /// Be prudent, use a value slightly above the minimun required. - /// https://musicbrainz.org/doc/XML_Web_Service/Rate_Limiting + /// https://musicbrainz.org/doc/XML_Web_Service/Rate_Limiting. /// </summary> private readonly long _musicBrainzQueryIntervalMs; private readonly IHttpClientFactory _httpClientFactory; - private readonly IApplicationHost _appHost; private readonly ILogger<MusicBrainzAlbumProvider> _logger; private readonly string _musicBrainzBaseUrl; @@ -51,11 +51,9 @@ namespace MediaBrowser.Providers.Music public MusicBrainzAlbumProvider( IHttpClientFactory httpClientFactory, - IApplicationHost appHost, ILogger<MusicBrainzAlbumProvider> logger) { _httpClientFactory = httpClientFactory; - _appHost = appHost; _logger = logger; _musicBrainzBaseUrl = Plugin.Instance.Configuration.Server; @@ -174,10 +172,10 @@ namespace MediaBrowser.Providers.Music } /// <inheritdoc /> - public async Task<MetadataResult<MusicAlbum>> GetMetadata(AlbumInfo id, CancellationToken cancellationToken) + public async Task<MetadataResult<MusicAlbum>> GetMetadata(AlbumInfo info, CancellationToken cancellationToken) { - var releaseId = id.GetReleaseId(); - var releaseGroupId = id.GetReleaseGroupId(); + var releaseId = info.GetReleaseId(); + var releaseGroupId = info.GetReleaseGroupId(); var result = new MetadataResult<MusicAlbum> { @@ -193,9 +191,9 @@ namespace MediaBrowser.Providers.Music if (string.IsNullOrWhiteSpace(releaseId)) { - var artistMusicBrainzId = id.GetMusicBrainzArtistId(); + var artistMusicBrainzId = info.GetMusicBrainzArtistId(); - var releaseResult = await GetReleaseResult(artistMusicBrainzId, id.GetAlbumArtist(), id.Name, cancellationToken).ConfigureAwait(false); + var releaseResult = await GetReleaseResult(artistMusicBrainzId, info.GetAlbumArtist(), info.Name, cancellationToken).ConfigureAwait(false); if (releaseResult != null) { @@ -305,181 +303,6 @@ namespace MediaBrowser.Providers.Music return ReleaseResult.Parse(reader).FirstOrDefault(); } - private class ReleaseResult - { - public string ReleaseId; - public string ReleaseGroupId; - public string Title; - public string Overview; - public int? Year; - - public List<ValueTuple<string, string>> Artists = new List<ValueTuple<string, string>>(); - - public static IEnumerable<ReleaseResult> Parse(XmlReader reader) - { - reader.MoveToContent(); - reader.Read(); - - // Loop through each element - while (!reader.EOF && reader.ReadState == ReadState.Interactive) - { - if (reader.NodeType == XmlNodeType.Element) - { - switch (reader.Name) - { - case "release-list": - { - if (reader.IsEmptyElement) - { - reader.Read(); - continue; - } - - using var subReader = reader.ReadSubtree(); - return ParseReleaseList(subReader).ToList(); - } - - default: - { - reader.Skip(); - break; - } - } - } - else - { - reader.Read(); - } - } - - return Enumerable.Empty<ReleaseResult>(); - } - - private static IEnumerable<ReleaseResult> ParseReleaseList(XmlReader reader) - { - reader.MoveToContent(); - reader.Read(); - - // Loop through each element - while (!reader.EOF && reader.ReadState == ReadState.Interactive) - { - if (reader.NodeType == XmlNodeType.Element) - { - switch (reader.Name) - { - case "release": - { - if (reader.IsEmptyElement) - { - reader.Read(); - continue; - } - - var releaseId = reader.GetAttribute("id"); - - using var subReader = reader.ReadSubtree(); - var release = ParseRelease(subReader, releaseId); - if (release != null) - { - yield return release; - } - - break; - } - - default: - { - reader.Skip(); - break; - } - } - } - else - { - reader.Read(); - } - } - } - - private static ReleaseResult ParseRelease(XmlReader reader, string releaseId) - { - var result = new ReleaseResult - { - ReleaseId = releaseId - }; - - reader.MoveToContent(); - reader.Read(); - - // http://stackoverflow.com/questions/2299632/why-does-xmlreader-skip-every-other-element-if-there-is-no-whitespace-separator - - // Loop through each element - while (!reader.EOF && reader.ReadState == ReadState.Interactive) - { - if (reader.NodeType == XmlNodeType.Element) - { - switch (reader.Name) - { - case "title": - { - result.Title = reader.ReadElementContentAsString(); - break; - } - - case "date": - { - var val = reader.ReadElementContentAsString(); - if (DateTime.TryParse(val, out var date)) - { - result.Year = date.Year; - } - - break; - } - - case "annotation": - { - result.Overview = reader.ReadElementContentAsString(); - break; - } - - case "release-group": - { - result.ReleaseGroupId = reader.GetAttribute("id"); - reader.Skip(); - break; - } - - case "artist-credit": - { - using var subReader = reader.ReadSubtree(); - var artist = ParseArtistCredit(subReader); - - if (!string.IsNullOrEmpty(artist.Item1)) - { - result.Artists.Add(artist); - } - - break; - } - - default: - { - reader.Skip(); - break; - } - } - } - else - { - reader.Read(); - } - } - - return result; - } - } - private static (string, string) ParseArtistCredit(XmlReader reader) { reader.MoveToContent(); @@ -501,10 +324,10 @@ namespace MediaBrowser.Providers.Music } default: - { - reader.Skip(); - break; - } + { + reader.Skip(); + break; + } } } else @@ -711,6 +534,9 @@ namespace MediaBrowser.Providers.Music /// A number of retries shall be made in order to try and satisfy the request before /// giving up and returning null. /// </summary> + /// <param name="url">Address of MusicBrainz server.</param> + /// <param name="cancellationToken">CancellationToken to use for method.</param> + /// <returns>Returns response from MusicBrainz service.</returns> internal async Task<HttpResponseMessage> GetMusicBrainzResponse(string url, CancellationToken cancellationToken) { await _apiRequestLock.WaitAsync(cancellationToken).ConfigureAwait(false); @@ -766,5 +592,195 @@ namespace MediaBrowser.Providers.Music { throw new NotImplementedException(); } + + protected virtual void Dispose(bool disposing) + { + if (disposing) + { + _apiRequestLock?.Dispose(); + } + } + + /// <inheritdoc /> + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + private class ReleaseResult + { + public string ReleaseId; + public string ReleaseGroupId; + public string Title; + public string Overview; + public int? Year; + + public List<ValueTuple<string, string>> Artists = new List<ValueTuple<string, string>>(); + + public static IEnumerable<ReleaseResult> Parse(XmlReader reader) + { + reader.MoveToContent(); + reader.Read(); + + // Loop through each element + while (!reader.EOF && reader.ReadState == ReadState.Interactive) + { + if (reader.NodeType == XmlNodeType.Element) + { + switch (reader.Name) + { + case "release-list": + { + if (reader.IsEmptyElement) + { + reader.Read(); + continue; + } + + using var subReader = reader.ReadSubtree(); + return ParseReleaseList(subReader).ToList(); + } + + default: + { + reader.Skip(); + break; + } + } + } + else + { + reader.Read(); + } + } + + return Enumerable.Empty<ReleaseResult>(); + } + + private static IEnumerable<ReleaseResult> ParseReleaseList(XmlReader reader) + { + reader.MoveToContent(); + reader.Read(); + + // Loop through each element + while (!reader.EOF && reader.ReadState == ReadState.Interactive) + { + if (reader.NodeType == XmlNodeType.Element) + { + switch (reader.Name) + { + case "release": + { + if (reader.IsEmptyElement) + { + reader.Read(); + continue; + } + + var releaseId = reader.GetAttribute("id"); + + using var subReader = reader.ReadSubtree(); + var release = ParseRelease(subReader, releaseId); + if (release != null) + { + yield return release; + } + + break; + } + + default: + { + reader.Skip(); + break; + } + } + } + else + { + reader.Read(); + } + } + } + + private static ReleaseResult ParseRelease(XmlReader reader, string releaseId) + { + var result = new ReleaseResult + { + ReleaseId = releaseId + }; + + reader.MoveToContent(); + reader.Read(); + + // http://stackoverflow.com/questions/2299632/why-does-xmlreader-skip-every-other-element-if-there-is-no-whitespace-separator + + // Loop through each element + while (!reader.EOF && reader.ReadState == ReadState.Interactive) + { + if (reader.NodeType == XmlNodeType.Element) + { + switch (reader.Name) + { + case "title": + { + result.Title = reader.ReadElementContentAsString(); + break; + } + + case "date": + { + var val = reader.ReadElementContentAsString(); + if (DateTime.TryParse(val, out var date)) + { + result.Year = date.Year; + } + + break; + } + + case "annotation": + { + result.Overview = reader.ReadElementContentAsString(); + break; + } + + case "release-group": + { + result.ReleaseGroupId = reader.GetAttribute("id"); + reader.Skip(); + break; + } + + case "artist-credit": + { + using var subReader = reader.ReadSubtree(); + var artist = ParseArtistCredit(subReader); + + if (!string.IsNullOrEmpty(artist.Item1)) + { + result.Artists.Add(artist); + } + + break; + } + + default: + { + reader.Skip(); + break; + } + } + } + else + { + reader.Read(); + } + } + + return result; + } + } } } diff --git a/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzArtistExternalId.cs b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzArtistExternalId.cs new file mode 100644 index 0000000000..941ffea721 --- /dev/null +++ b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzArtistExternalId.cs @@ -0,0 +1,28 @@ +#pragma warning disable CS1591 + +using MediaBrowser.Controller.Entities.Audio; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.Providers; +using MediaBrowser.Providers.Plugins.MusicBrainz; + +namespace MediaBrowser.Providers.Music +{ + public class MusicBrainzArtistExternalId : IExternalId + { + /// <inheritdoc /> + public string ProviderName => "MusicBrainz"; + + /// <inheritdoc /> + public string Key => MetadataProvider.MusicBrainzArtist.ToString(); + + /// <inheritdoc /> + public ExternalIdMediaType? Type => ExternalIdMediaType.Artist; + + /// <inheritdoc /> + public string? UrlFormatString => Plugin.Instance.Configuration.Server + "/artist/{0}"; + + /// <inheritdoc /> + public bool Supports(IHasProviderIds item) => item is MusicArtist; + } +} diff --git a/MediaBrowser.Providers/Plugins/MusicBrainz/ArtistProvider.cs b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzArtistProvider.cs similarity index 97% rename from MediaBrowser.Providers/Plugins/MusicBrainz/ArtistProvider.cs rename to MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzArtistProvider.cs index 0cae7768a7..1feb7f4eab 100644 --- a/MediaBrowser.Providers/Plugins/MusicBrainz/ArtistProvider.cs +++ b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzArtistProvider.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System; @@ -22,6 +24,8 @@ namespace MediaBrowser.Providers.Music { public class MusicBrainzArtistProvider : IRemoteMetadataProvider<MusicArtist, ArtistInfo> { + public string Name => "MusicBrainz"; + /// <inheritdoc /> public async Task<IEnumerable<RemoteSearchResult>> GetSearchResults(ArtistInfo searchInfo, CancellationToken cancellationToken) { @@ -215,18 +219,19 @@ namespace MediaBrowser.Providers.Music return result; } - public async Task<MetadataResult<MusicArtist>> GetMetadata(ArtistInfo id, CancellationToken cancellationToken) + /// <inheritdoc /> + public async Task<MetadataResult<MusicArtist>> GetMetadata(ArtistInfo info, CancellationToken cancellationToken) { var result = new MetadataResult<MusicArtist> { Item = new MusicArtist() }; - var musicBrainzId = id.GetMusicBrainzArtistId(); + var musicBrainzId = info.GetMusicBrainzArtistId(); if (string.IsNullOrWhiteSpace(musicBrainzId)) { - var searchResults = await GetSearchResults(id, cancellationToken).ConfigureAwait(false); + var searchResults = await GetSearchResults(info, cancellationToken).ConfigureAwait(false); var singleResult = searchResults.FirstOrDefault(); @@ -261,8 +266,6 @@ namespace MediaBrowser.Providers.Music return WebUtility.UrlEncode(name); } - public string Name => "MusicBrainz"; - public Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken cancellationToken) { throw new NotImplementedException(); diff --git a/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzOtherArtistExternalId.cs b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzOtherArtistExternalId.cs new file mode 100644 index 0000000000..05db2d98f7 --- /dev/null +++ b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzOtherArtistExternalId.cs @@ -0,0 +1,28 @@ +#pragma warning disable CS1591 + +using MediaBrowser.Controller.Entities.Audio; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.Providers; +using MediaBrowser.Providers.Plugins.MusicBrainz; + +namespace MediaBrowser.Providers.Music +{ + public class MusicBrainzOtherArtistExternalId : IExternalId + { + /// <inheritdoc /> + public string ProviderName => "MusicBrainz"; + + /// <inheritdoc /> + public string Key => MetadataProvider.MusicBrainzArtist.ToString(); + + /// <inheritdoc /> + public ExternalIdMediaType? Type => ExternalIdMediaType.OtherArtist; + + /// <inheritdoc /> + public string? UrlFormatString => Plugin.Instance.Configuration.Server + "/artist/{0}"; + + /// <inheritdoc /> + public bool Supports(IHasProviderIds item) => item is Audio || item is MusicAlbum; + } +} diff --git a/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzReleaseGroupExternalId.cs b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzReleaseGroupExternalId.cs new file mode 100644 index 0000000000..acb652fe01 --- /dev/null +++ b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzReleaseGroupExternalId.cs @@ -0,0 +1,28 @@ +#pragma warning disable CS1591 + +using MediaBrowser.Controller.Entities.Audio; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.Providers; +using MediaBrowser.Providers.Plugins.MusicBrainz; + +namespace MediaBrowser.Providers.Music +{ + public class MusicBrainzReleaseGroupExternalId : IExternalId + { + /// <inheritdoc /> + public string ProviderName => "MusicBrainz"; + + /// <inheritdoc /> + public string Key => MetadataProvider.MusicBrainzReleaseGroup.ToString(); + + /// <inheritdoc /> + public ExternalIdMediaType? Type => ExternalIdMediaType.ReleaseGroup; + + /// <inheritdoc /> + public string? UrlFormatString => Plugin.Instance.Configuration.Server + "/release-group/{0}"; + + /// <inheritdoc /> + public bool Supports(IHasProviderIds item) => item is Audio || item is MusicAlbum; + } +} diff --git a/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzTrackId.cs b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzTrackId.cs new file mode 100644 index 0000000000..14805b9b79 --- /dev/null +++ b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzTrackId.cs @@ -0,0 +1,28 @@ +#pragma warning disable CS1591 + +using MediaBrowser.Controller.Entities.Audio; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.Providers; +using MediaBrowser.Providers.Plugins.MusicBrainz; + +namespace MediaBrowser.Providers.Music +{ + public class MusicBrainzTrackId : IExternalId + { + /// <inheritdoc /> + public string ProviderName => "MusicBrainz"; + + /// <inheritdoc /> + public string Key => MetadataProvider.MusicBrainzTrack.ToString(); + + /// <inheritdoc /> + public ExternalIdMediaType? Type => ExternalIdMediaType.Track; + + /// <inheritdoc /> + public string? UrlFormatString => Plugin.Instance.Configuration.Server + "/track/{0}"; + + /// <inheritdoc /> + public bool Supports(IHasProviderIds item) => item is Audio; + } +} diff --git a/MediaBrowser.Providers/Plugins/MusicBrainz/Plugin.cs b/MediaBrowser.Providers/Plugins/MusicBrainz/Plugin.cs index 43bd3a472f..cfa10dd648 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; @@ -11,6 +12,16 @@ namespace MediaBrowser.Providers.Plugins.MusicBrainz { public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages { + public const string DefaultServer = "https://musicbrainz.org"; + + public const long DefaultRateLimit = 2000u; + + public Plugin(IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer) + : base(applicationPaths, xmlSerializer) + { + Instance = this; + } + public static Plugin Instance { get; private set; } public override Guid Id => new Guid("8c95c4d2-e50c-4fb0-a4f3-6c06ff0f9a1a"); @@ -19,19 +30,9 @@ namespace MediaBrowser.Providers.Plugins.MusicBrainz public override string Description => "Get artist and album metadata from any MusicBrainz server."; - public const string DefaultServer = "https://musicbrainz.org"; - - public const long DefaultRateLimit = 2000u; - // TODO remove when plugin removed from server. public override string ConfigurationFileName => "Jellyfin.Plugin.MusicBrainz.xml"; - public Plugin(IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer) - : base(applicationPaths, xmlSerializer) - { - Instance = this; - } - public IEnumerable<PluginPageInfo> GetPages() { yield return new PluginPageInfo diff --git a/MediaBrowser.Providers/Plugins/Omdb/Configuration/PluginConfiguration.cs b/MediaBrowser.Providers/Plugins/Omdb/Configuration/PluginConfiguration.cs index 196f14e7c8..0995470055 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 268538815e..8bfdc461e6 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 c19589d45b..f35880a046 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 24ef80a35d..f67ac6ede4 100644 --- a/MediaBrowser.Providers/Plugins/Omdb/OmdbEpisodeProvider.cs +++ b/MediaBrowser.Providers/Plugins/Omdb/OmdbEpisodeProvider.cs @@ -61,7 +61,7 @@ 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)) { if (info.IndexNumber.HasValue && info.ParentIndexNumber.HasValue) { diff --git a/MediaBrowser.Providers/Plugins/Omdb/OmdbImageProvider.cs b/MediaBrowser.Providers/Plugins/Omdb/OmdbImageProvider.cs index df67aff31c..fa82089c86 100644 --- a/MediaBrowser.Providers/Plugins/Omdb/OmdbImageProvider.cs +++ b/MediaBrowser.Providers/Plugins/Omdb/OmdbImageProvider.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System.Collections.Generic; diff --git a/MediaBrowser.Providers/Plugins/Omdb/OmdbItemProvider.cs b/MediaBrowser.Providers/Plugins/Omdb/OmdbItemProvider.cs index 78eea02e03..2409993a29 100644 --- a/MediaBrowser.Providers/Plugins/Omdb/OmdbItemProvider.cs +++ b/MediaBrowser.Providers/Plugins/Omdb/OmdbItemProvider.cs @@ -1,4 +1,6 @@ -#pragma warning disable CS1591 +#nullable disable + +#pragma warning disable CS1591, SA1300 using System; using System.Collections.Generic; @@ -9,9 +11,8 @@ using System.Net.Http; using System.Text.Json; using System.Threading; using System.Threading.Tasks; -using MediaBrowser.Common; using Jellyfin.Extensions.Json; -using Jellyfin.Extensions.Json.Converters; +using MediaBrowser.Common; using MediaBrowser.Common.Net; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Entities; diff --git a/MediaBrowser.Providers/Plugins/Omdb/OmdbProvider.cs b/MediaBrowser.Providers/Plugins/Omdb/OmdbProvider.cs index 5d9fd36d35..816a882b46 100644 --- a/MediaBrowser.Providers/Plugins/Omdb/OmdbProvider.cs +++ b/MediaBrowser.Providers/Plugins/Omdb/OmdbProvider.cs @@ -1,4 +1,6 @@ -#pragma warning disable CS1591 +#nullable disable + +#pragma warning disable CS159, SA1300 using System; using System.Collections.Generic; @@ -9,9 +11,8 @@ using System.Net.Http; using System.Text.Json; using System.Threading; using System.Threading.Tasks; -using MediaBrowser.Common; using Jellyfin.Extensions.Json; -using Jellyfin.Extensions.Json.Converters; +using MediaBrowser.Common; using MediaBrowser.Common.Net; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Entities; @@ -21,15 +22,20 @@ using MediaBrowser.Model.IO; namespace MediaBrowser.Providers.Plugins.Omdb { + /// <summary>Provider for OMDB service.</summary> public class OmdbProvider { 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) { _httpClientFactory = httpClientFactory; @@ -42,6 +48,14 @@ namespace MediaBrowser.Providers.Plugins.Omdb _jsonOptions.Converters.Add(new JsonOmdbNotAvailableInt32Converter()); } + /// <summary>Fetches data from OMDB service.</summary> + /// <param name="itemResult">Metadata about media item.</param> + /// <param name="imdbId">IMDB ID for media.</param> + /// <param name="language">Media language.</param> + /// <param name="country">Country of origin.</param> + /// <param name="cancellationToken">CancellationToken to use for operation.</param> + /// <typeparam name="T">The first generic type parameter.</typeparam> + /// <returns>Returns a Task object that can be awaited.</returns> public async Task Fetch<T>(MetadataResult<T> itemResult, string imdbId, string language, string country, CancellationToken cancellationToken) where T : BaseItem { @@ -66,7 +80,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) + && int.TryParse(result.Year.AsSpan().Slice(0, 4), NumberStyles.Number, CultureInfo.InvariantCulture, out var year) && year >= 0) { item.ProductionYear = year; @@ -80,14 +94,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; @@ -106,6 +120,17 @@ namespace MediaBrowser.Providers.Plugins.Omdb ParseAdditionalMetadata(itemResult, result); } + /// <summary>Gets data about an episode.</summary> + /// <param name="itemResult">Metadata about episode.</param> + /// <param name="episodeNumber">Episode number.</param> + /// <param name="seasonNumber">Season number.</param> + /// <param name="episodeImdbId">Episode ID.</param> + /// <param name="seriesImdbId">Season ID.</param> + /// <param name="language">Episode language.</param> + /// <param name="country">Country of origin.</param> + /// <param name="cancellationToken">CancellationToken to use for operation.</param> + /// <typeparam name="T">The first generic type parameter.</typeparam> + /// <returns>Whether operation was successful.</returns> public async Task<bool> FetchEpisodeData<T>(MetadataResult<T> itemResult, int episodeNumber, int seasonNumber, string episodeImdbId, string seriesImdbId, string language, string country, CancellationToken cancellationToken) where T : BaseItem { @@ -167,7 +192,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) + && int.TryParse(result.Year.AsSpan().Slice(0, 4), NumberStyles.Number, CultureInfo.InvariantCulture, out var year) && year >= 0) { item.ProductionYear = year; @@ -181,14 +206,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; @@ -212,31 +237,20 @@ namespace MediaBrowser.Providers.Plugins.Omdb internal async Task<RootObject> GetRootObject(string imdbId, CancellationToken cancellationToken) { var path = await EnsureItemInfo(imdbId, cancellationToken).ConfigureAwait(false); - await using var stream = File.OpenRead(path); - return await JsonSerializer.DeserializeAsync<RootObject>(stream, _jsonOptions, cancellationToken); + await using var stream = AsyncFile.OpenRead(path); + return await JsonSerializer.DeserializeAsync<RootObject>(stream, _jsonOptions, cancellationToken).ConfigureAwait(false); } internal async Task<SeasonRootObject> GetSeasonRootObject(string imdbId, int seasonId, CancellationToken cancellationToken) { var path = await EnsureSeasonInfo(imdbId, seasonId, cancellationToken).ConfigureAwait(false); - await using var stream = File.OpenRead(path); - return await JsonSerializer.DeserializeAsync<SeasonRootObject>(stream, _jsonOptions, cancellationToken); - } - - internal static bool IsValidSeries(Dictionary<string, string> seriesProviderIds) - { - if (seriesProviderIds.TryGetValue(MetadataProvider.Imdb.ToString(), out string id) && !string.IsNullOrEmpty(id)) - { - // This check should ideally never be necessary but we're seeing some cases of this and haven't tracked them down yet. - if (!string.IsNullOrWhiteSpace(id)) - { - return true; - } - } - - return false; + await using var stream = AsyncFile.OpenRead(path); + return await JsonSerializer.DeserializeAsync<SeasonRootObject>(stream, _jsonOptions, cancellationToken).ConfigureAwait(false); } + /// <summary>Gets OMDB URL.</summary> + /// <param name="query">Appends query string to URL.</param> + /// <returns>OMDB URL with optional query string.</returns> public static string GetOmdbUrl(string query) { const string Url = "https://www.omdbapi.com?apikey=2c9d9507"; @@ -282,7 +296,7 @@ namespace MediaBrowser.Providers.Plugins.Omdb 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); + 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; @@ -322,12 +336,18 @@ namespace MediaBrowser.Providers.Plugins.Omdb 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); + 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); @@ -336,6 +356,11 @@ namespace MediaBrowser.Providers.Plugins.Omdb 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); @@ -539,10 +564,14 @@ namespace MediaBrowser.Providers.Plugins.Omdb } } +#pragma warning disable CA1034 + /// <summary>Describes OMDB rating.</summary> public class OmdbRating { + /// <summary>Gets or sets rating source.</summary> public string Source { get; set; } + /// <summary>Gets or sets rating value.</summary> public string Value { get; set; } } } diff --git a/MediaBrowser.Providers/Plugins/Omdb/Plugin.cs b/MediaBrowser.Providers/Plugins/Omdb/Plugin.cs index d7f6781e50..a0fba48f05 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; @@ -11,6 +12,12 @@ namespace MediaBrowser.Providers.Plugins.Omdb { public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages { + public Plugin(IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer) + : base(applicationPaths, xmlSerializer) + { + Instance = this; + } + public static Plugin Instance { get; private set; } public override Guid Id => new Guid("a628c0da-fac5-4c7e-9d1a-7134223f14c8"); @@ -22,12 +29,6 @@ namespace MediaBrowser.Providers.Plugins.Omdb // TODO remove when plugin removed from server. public override string ConfigurationFileName => "Jellyfin.Plugin.Omdb.xml"; - public Plugin(IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer) - : base(applicationPaths, xmlSerializer) - { - Instance = this; - } - public IEnumerable<PluginPageInfo> GetPages() { yield return new PluginPageInfo diff --git a/MediaBrowser.Providers/Plugins/Tmdb/BoxSets/TmdbBoxSetExternalId.cs b/MediaBrowser.Providers/Plugins/Tmdb/BoxSets/TmdbBoxSetExternalId.cs index 1f7ec64338..3217ac2f13 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; /// <inheritdoc /> - public string UrlFormatString => TmdbUtils.BaseTmdbUrl + "collection/{0}"; + public string? UrlFormatString => TmdbUtils.BaseTmdbUrl + "collection/{0}"; /// <inheritdoc /> public bool Supports(IHasProviderIds item) diff --git a/MediaBrowser.Providers/Plugins/Tmdb/BoxSets/TmdbBoxSetImageProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/BoxSets/TmdbBoxSetImageProvider.cs index 5ad61c567f..35dc36811e 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; @@ -13,7 +15,6 @@ 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 @@ -101,7 +102,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.BoxSets }); } - return remoteImages.OrderByLanguageDescending(language); + return remoteImages; } public Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken cancellationToken) diff --git a/MediaBrowser.Providers/Plugins/Tmdb/BoxSets/TmdbBoxSetProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/BoxSets/TmdbBoxSetProvider.cs index ca1af6c499..62bc9c65f9 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; @@ -79,16 +81,16 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.BoxSets return collections; } - public async Task<MetadataResult<BoxSet>> GetMetadata(BoxSetInfo id, CancellationToken cancellationToken) + public async Task<MetadataResult<BoxSet>> GetMetadata(BoxSetInfo info, CancellationToken cancellationToken) { - var tmdbId = Convert.ToInt32(id.GetProviderId(MetadataProvider.Tmdb), CultureInfo.InvariantCulture); - var language = id.MetadataLanguage; + var tmdbId = Convert.ToInt32(info.GetProviderId(MetadataProvider.Tmdb), CultureInfo.InvariantCulture); + var language = info.MetadataLanguage; // We don't already have an Id, need to fetch it if (tmdbId <= 0) { // ParseName is required here. // Caller provides the filename with extension stripped and NOT the parsed filename - var parsedName = _libraryManager.ParseName(id.Name); + var parsedName = _libraryManager.ParseName(info.Name); var cleanedName = TmdbUtils.CleanName(parsedName.Name); var searchResults = await _tmdbClientManager.SearchCollectionAsync(cleanedName, language, cancellationToken).ConfigureAwait(false); diff --git a/MediaBrowser.Providers/Plugins/Tmdb/Configuration/PluginConfiguration.cs b/MediaBrowser.Providers/Plugins/Tmdb/Configuration/PluginConfiguration.cs new file mode 100644 index 0000000000..907f0160d6 --- /dev/null +++ b/MediaBrowser.Providers/Plugins/Tmdb/Configuration/PluginConfiguration.cs @@ -0,0 +1,15 @@ +using MediaBrowser.Model.Plugins; + +namespace MediaBrowser.Providers.Plugins.Tmdb +{ + /// <summary> + /// Plugin configuration class for TMDb library. + /// </summary> + public class PluginConfiguration : BasePluginConfiguration + { + /// <summary> + /// Gets or sets a value indicating whether include adult content when searching with TMDb. + /// </summary> + public bool IncludeAdult { 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 0000000000..6f42549d7d --- /dev/null +++ b/MediaBrowser.Providers/Plugins/Tmdb/Configuration/config.html @@ -0,0 +1,52 @@ +<!DOCTYPE html> +<html> +<head> + <title>TMDb + + +
+
+
+
+ +
+
+ +
+
+
+
+ +
+ + diff --git a/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieExternalId.cs b/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieExternalId.cs index f1a1b65d82..31310a8d41 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 f34d689c1a..015eddc1ac 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; @@ -13,7 +15,6 @@ 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; @@ -118,7 +119,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.Movies }); } - 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 4a0884c079..9dd0678561 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; @@ -154,7 +156,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.Movies var movieResultFromImdbId = await _tmdbClientManager.FindByExternalIdAsync(imdbId, FindExternalSource.Imdb, info.MetadataLanguage, cancellationToken).ConfigureAwait(false); if (movieResultFromImdbId?.MovieResults.Count > 0) { - tmdbId = movieResultFromImdbId.MovieResults[0].Id.ToString(); + tmdbId = movieResultFromImdbId.MovieResults[0].Id.ToString(CultureInfo.InvariantCulture); } } diff --git a/MediaBrowser.Providers/Plugins/Tmdb/People/TmdbPersonExternalId.cs b/MediaBrowser.Providers/Plugins/Tmdb/People/TmdbPersonExternalId.cs index de74a7a4c7..9804d60bdd 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 e4c908a62c..1fc5ccba59 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 @@ -77,7 +76,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.People }; } - 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 6db550b1d0..8790e37592 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; @@ -77,14 +79,14 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.People return remoteSearchResults; } - public async Task> GetMetadata(PersonLookupInfo id, CancellationToken cancellationToken) + public async Task> GetMetadata(PersonLookupInfo info, CancellationToken cancellationToken) { - var personTmdbId = Convert.ToInt32(id.GetProviderId(MetadataProvider.Tmdb), CultureInfo.InvariantCulture); + var personTmdbId = Convert.ToInt32(info.GetProviderId(MetadataProvider.Tmdb), CultureInfo.InvariantCulture); // We don't already have an Id, need to fetch it if (personTmdbId <= 0) { - var personSearchResults = await _tmdbClientManager.SearchPersonAsync(id.Name, cancellationToken).ConfigureAwait(false); + var personSearchResults = await _tmdbClientManager.SearchPersonAsync(info.Name, cancellationToken).ConfigureAwait(false); if (personSearchResults.Count > 0) { personTmdbId = personSearchResults[0].Id; @@ -95,7 +97,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.People if (personTmdbId > 0) { - var person = await _tmdbClientManager.GetPersonAsync(personTmdbId, id.MetadataLanguage, cancellationToken).ConfigureAwait(false); + var person = await _tmdbClientManager.GetPersonAsync(personTmdbId, info.MetadataLanguage, cancellationToken).ConfigureAwait(false); result.HasMetadata = true; @@ -103,7 +105,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.People { // Take name from incoming info, don't rename the person // TODO: This should go in PersonMetadataService, not each person provider - Name = id.Name, + Name = info.Name, HomePageUrl = person.Homepage, Overview = person.Biography, PremiereDate = person.Birthday?.ToUniversalTime(), diff --git a/MediaBrowser.Providers/Plugins/Tmdb/Plugin.cs b/MediaBrowser.Providers/Plugins/Tmdb/Plugin.cs new file mode 100644 index 0000000000..4adde8366a --- /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 ba18c542fe..eb75e94050 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; @@ -12,7 +14,6 @@ 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 @@ -92,7 +93,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV }; } - return remoteImages.OrderByLanguageDescending(language); + 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 8ec8f64641..3f826843a6 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; diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeasonImageProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeasonImageProvider.cs index 0d23c7872f..ca44c9bbc3 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeasonImageProvider.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeasonImageProvider.cs @@ -13,7 +13,6 @@ 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 @@ -81,7 +80,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV }; } - return remoteImages.OrderByLanguageDescending(language); + 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 4c1f697632..4ac8896801 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeasonProvider.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeasonProvider.cs @@ -33,7 +33,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; @@ -55,7 +55,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV result.Item = new Season { IndexNumber = seasonNumber, - Overview = seasonResult?.Overview + Overview = seasonResult.Overview }; if (!string.IsNullOrEmpty(seasonResult.ExternalIds?.TvdbId)) diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesExternalId.cs b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesExternalId.cs index 6ecc055d71..8a2be80cde 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 326c116b3b..f3f3403789 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesImageProvider.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesImageProvider.cs @@ -13,7 +13,6 @@ 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 @@ -107,7 +106,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV }; } - 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 da76345b5b..feda15cf75 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; diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TmdbClientManager.cs b/MediaBrowser.Providers/Plugins/Tmdb/TmdbClientManager.cs index 79ec6139d1..74be4c7933 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/TmdbClientManager.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/TmdbClientManager.cs @@ -1,4 +1,6 @@ -using System; +#nullable disable + +using System; using System.Collections.Generic; using System.Globalization; using System.Threading; @@ -18,7 +20,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb /// /// Manager class for abstracting the TMDb API client library. /// - public class TmdbClientManager + public class TmdbClientManager : IDisposable { private const int CacheDurationInHours = 1; @@ -242,7 +244,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb await EnsureClientConfigAsync().ConfigureAwait(false); - var group = await GetSeriesGroupAsync(tvShowId, displayOrder, language, imageLanguages, cancellationToken); + var group = await GetSeriesGroupAsync(tvShowId, displayOrder, language, imageLanguages, cancellationToken).ConfigureAwait(false); if (group != null) { var season = group.Groups.Find(s => s.Order == seasonNumber); @@ -358,7 +360,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) @@ -386,7 +388,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) @@ -428,7 +430,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) @@ -532,5 +534,25 @@ namespace MediaBrowser.Providers.Plugins.Tmdb { return !_tmDbClient.HasConfig ? _tmDbClient.GetConfigAsync() : Task.CompletedTask; } + + /// + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + +/// + /// Releases unmanaged and - optionally - managed resources. + /// + /// true to release both managed and unmanaged resources; false to release only unmanaged resources. + protected virtual void Dispose(bool disposing) + { + if (disposing) + { + _memoryCache?.Dispose(); + _tmDbClient?.Dispose(); + } + } } } diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TmdbUtils.cs b/MediaBrowser.Providers/Plugins/Tmdb/TmdbUtils.cs index b713736a0f..58ab9f5473 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; diff --git a/MediaBrowser.Providers/Studios/StudioMetadataService.cs b/MediaBrowser.Providers/Studios/StudioMetadataService.cs index 78042b40de..091b33ce0e 100644 --- a/MediaBrowser.Providers/Studios/StudioMetadataService.cs +++ b/MediaBrowser.Providers/Studios/StudioMetadataService.cs @@ -17,7 +17,8 @@ namespace MediaBrowser.Providers.Studios IServerConfigurationManager serverConfigurationManager, ILogger logger, IProviderManager providerManager, - IFileSystem fileSystem, ILibraryManager libraryManager) + IFileSystem fileSystem, + ILibraryManager libraryManager) : base(serverConfigurationManager, logger, providerManager, fileSystem, libraryManager) { } diff --git a/MediaBrowser.Providers/Studios/StudiosImageProvider.cs b/MediaBrowser.Providers/Studios/StudiosImageProvider.cs index 63e78d15e8..7f1665532a 100644 --- a/MediaBrowser.Providers/Studios/StudiosImageProvider.cs +++ b/MediaBrowser.Providers/Studios/StudiosImageProvider.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System; @@ -146,7 +148,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); + 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 +174,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/Subtitles/SubtitleManager.cs b/MediaBrowser.Providers/Subtitles/SubtitleManager.cs index 6aacaa15de..c2b420c331 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; @@ -21,7 +23,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 { @@ -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,15 +245,20 @@ 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, true); + 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; } catch (Exception ex) { +// Bug in analyzer -- https://github.com/dotnet/roslyn-analyzers/issues/5160 +#pragma warning disable CA1508 (exs ??= new List()).Add(ex); +#pragma warning restore CA1508 } finally { @@ -269,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) { @@ -303,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) @@ -370,15 +377,15 @@ namespace MediaBrowser.Providers.Subtitles } /// - public SubtitleProviderInfo[] GetSupportedProviders(BaseItem video) + public SubtitleProviderInfo[] GetSupportedProviders(BaseItem item) { VideoContentType mediaType; - if (video is Episode) + if (item is Episode) { mediaType = VideoContentType.Episode; } - else if (video is Movie) + else if (item is Movie) { mediaType = VideoContentType.Movie; } diff --git a/MediaBrowser.Providers/TV/EpisodeMetadataService.cs b/MediaBrowser.Providers/TV/EpisodeMetadataService.cs index 170f1bdd8c..08cb6ced90 100644 --- a/MediaBrowser.Providers/TV/EpisodeMetadataService.cs +++ b/MediaBrowser.Providers/TV/EpisodeMetadataService.cs @@ -25,46 +25,46 @@ namespace MediaBrowser.Providers.TV } /// - protected override ItemUpdateType BeforeSaveInternal(Episode item, bool isFullRefresh, ItemUpdateType currentUpdateType) + protected override ItemUpdateType BeforeSaveInternal(Episode item, bool isFullRefresh, ItemUpdateType updateType) { - var updateType = base.BeforeSaveInternal(item, isFullRefresh, currentUpdateType); + var updatedType = base.BeforeSaveInternal(item, isFullRefresh, updateType); var seriesName = item.FindSeriesName(); if (!string.Equals(item.SeriesName, seriesName, StringComparison.Ordinal)) { item.SeriesName = seriesName; - updateType |= ItemUpdateType.MetadataImport; + updatedType |= ItemUpdateType.MetadataImport; } var seasonName = item.FindSeasonName(); if (!string.Equals(item.SeasonName, seasonName, StringComparison.Ordinal)) { item.SeasonName = seasonName; - updateType |= ItemUpdateType.MetadataImport; + updatedType |= ItemUpdateType.MetadataImport; } var seriesId = item.FindSeriesId(); if (!item.SeriesId.Equals(seriesId)) { item.SeriesId = seriesId; - updateType |= ItemUpdateType.MetadataImport; + updatedType |= ItemUpdateType.MetadataImport; } var seasonId = item.FindSeasonId(); if (!item.SeasonId.Equals(seasonId)) { item.SeasonId = seasonId; - updateType |= ItemUpdateType.MetadataImport; + updatedType |= ItemUpdateType.MetadataImport; } var seriesPresentationUniqueKey = item.FindSeriesPresentationUniqueKey(); if (!string.Equals(item.SeriesPresentationUniqueKey, seriesPresentationUniqueKey, StringComparison.Ordinal)) { item.SeriesPresentationUniqueKey = seriesPresentationUniqueKey; - updateType |= ItemUpdateType.MetadataImport; + updatedType |= ItemUpdateType.MetadataImport; } - return updateType; + return updatedType; } /// diff --git a/MediaBrowser.Providers/TV/SeasonMetadataService.cs b/MediaBrowser.Providers/TV/SeasonMetadataService.cs index 4e59f78bc4..b173fc7a3c 100644 --- a/MediaBrowser.Providers/TV/SeasonMetadataService.cs +++ b/MediaBrowser.Providers/TV/SeasonMetadataService.cs @@ -31,9 +31,9 @@ namespace MediaBrowser.Providers.TV protected override bool EnableUpdatingPremiereDateFromChildren => true; /// - protected override ItemUpdateType BeforeSaveInternal(Season item, bool isFullRefresh, ItemUpdateType currentUpdateType) + protected override ItemUpdateType BeforeSaveInternal(Season item, bool isFullRefresh, ItemUpdateType updateType) { - var updateType = base.BeforeSaveInternal(item, isFullRefresh, currentUpdateType); + var updatedType = base.BeforeSaveInternal(item, isFullRefresh, updateType); if (item.IndexNumber.HasValue && item.IndexNumber.Value == 0) { @@ -42,7 +42,7 @@ namespace MediaBrowser.Providers.TV if (!string.Equals(item.Name, seasonZeroDisplayName, StringComparison.OrdinalIgnoreCase)) { item.Name = seasonZeroDisplayName; - updateType = updateType | ItemUpdateType.MetadataEdit; + updatedType |= ItemUpdateType.MetadataEdit; } } @@ -50,24 +50,24 @@ namespace MediaBrowser.Providers.TV if (!string.Equals(item.SeriesName, seriesName, StringComparison.Ordinal)) { item.SeriesName = seriesName; - updateType |= ItemUpdateType.MetadataImport; + updatedType |= ItemUpdateType.MetadataImport; } var seriesPresentationUniqueKey = item.FindSeriesPresentationUniqueKey(); if (!string.Equals(item.SeriesPresentationUniqueKey, seriesPresentationUniqueKey, StringComparison.Ordinal)) { item.SeriesPresentationUniqueKey = seriesPresentationUniqueKey; - updateType |= ItemUpdateType.MetadataImport; + updatedType |= ItemUpdateType.MetadataImport; } var seriesId = item.FindSeriesId(); if (!item.SeriesId.Equals(seriesId)) { item.SeriesId = seriesId; - updateType |= ItemUpdateType.MetadataImport; + updatedType |= ItemUpdateType.MetadataImport; } - return updateType; + return updatedType; } /// diff --git a/MediaBrowser.Providers/TV/SeriesMetadataService.cs b/MediaBrowser.Providers/TV/SeriesMetadataService.cs index 9679081975..9d223b4b66 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; @@ -187,7 +189,7 @@ namespace MediaBrowser.Providers.TV SeriesName = series.Name }; - series.AddChild(season, cancellationToken); + series.AddChild(season); await season.RefreshMetadata(new MetadataRefreshOptions(new DirectoryService(FileSystem)), cancellationToken).ConfigureAwait(false); diff --git a/MediaBrowser.Providers/TV/Zap2ItExternalId.cs b/MediaBrowser.Providers/TV/Zap2ItExternalId.cs index 3cb18e4248..087e4036a5 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.XbmcMetadata/Configuration/NfoConfigurationFactory.cs b/MediaBrowser.XbmcMetadata/Configuration/NfoConfigurationFactory.cs index 8325bfdbd5..c73989e761 100644 --- a/MediaBrowser.XbmcMetadata/Configuration/NfoConfigurationFactory.cs +++ b/MediaBrowser.XbmcMetadata/Configuration/NfoConfigurationFactory.cs @@ -15,8 +15,8 @@ namespace MediaBrowser.XbmcMetadata.Configuration { new ConfigurationStore { - ConfigurationType = typeof(XbmcMetadataOptions), - Key = "xbmcmetadata" + ConfigurationType = typeof(XbmcMetadataOptions), + Key = "xbmcmetadata" } }; } diff --git a/MediaBrowser.XbmcMetadata/MediaBrowser.XbmcMetadata.csproj b/MediaBrowser.XbmcMetadata/MediaBrowser.XbmcMetadata.csproj index 2904b40ecf..926be5a927 100644 --- a/MediaBrowser.XbmcMetadata/MediaBrowser.XbmcMetadata.csproj +++ b/MediaBrowser.XbmcMetadata/MediaBrowser.XbmcMetadata.csproj @@ -15,13 +15,9 @@ - net5.0 + net6.0 false true - true - enable - AllEnabledByDefault - ../jellyfin.ruleset diff --git a/MediaBrowser.XbmcMetadata/Parsers/BaseNfoParser.cs b/MediaBrowser.XbmcMetadata/Parsers/BaseNfoParser.cs index 302c93f0bc..9d558b6ce7 100644 --- a/MediaBrowser.XbmcMetadata/Parsers/BaseNfoParser.cs +++ b/MediaBrowser.XbmcMetadata/Parsers/BaseNfoParser.cs @@ -8,6 +8,7 @@ using System.Linq; using System.Text; using System.Threading; using System.Xml; +using Jellyfin.Extensions; using MediaBrowser.Common.Configuration; using MediaBrowser.Common.Providers; using MediaBrowser.Controller.Entities; @@ -57,8 +58,6 @@ namespace MediaBrowser.XbmcMetadata.Parsers _directoryService = directoryService; } - protected CultureInfo UsCulture { get; } = new CultureInfo("en-US"); - /// /// Gets the logger. /// @@ -148,80 +147,76 @@ namespace MediaBrowser.XbmcMetadata.Parsers return; } - using (var fileStream = File.OpenRead(metadataFile)) - using (var streamReader = new StreamReader(fileStream, Encoding.UTF8)) - { - item.ResetPeople(); + item.ResetPeople(); - // Need to handle a url after the xml data - // http://kodi.wiki/view/NFO_files/movies + // Need to handle a url after the xml data + // http://kodi.wiki/view/NFO_files/movies - var xml = streamReader.ReadToEnd(); + var xml = File.ReadAllText(metadataFile); - // Find last closing Tag - // Need to do this in two steps to account for random > characters after the closing xml - var index = xml.LastIndexOf(@" characters after the closing xml + var index = xml.LastIndexOf(@"', index); - } - - if (index != -1) - { - var endingXml = xml.Substring(index); + // If closing tag exists, move to end of Tag + if (index != -1) + { + index = xml.IndexOf('>', index); + } - ParseProviderLinks(item.Item, endingXml); + if (index != -1) + { + var endingXml = xml.AsSpan().Slice(index); - // If the file is just an imdb url, don't go any further - if (index == 0) - { - return; - } + ParseProviderLinks(item.Item, endingXml); - xml = xml.Substring(0, index + 1); - } - else + // If the file is just an imdb url, don't go any further + if (index == 0) { - // If the file is just provider urls, handle that - ParseProviderLinks(item.Item, xml); - return; } - // These are not going to be valid xml so no sense in causing the provider to fail and spamming the log with exceptions - try + xml = xml.Substring(0, index + 1); + } + else + { + // If the file is just provider urls, handle that + ParseProviderLinks(item.Item, xml); + + return; + } + + // These are not going to be valid xml so no sense in causing the provider to fail and spamming the log with exceptions + try + { + using (var stringReader = new StringReader(xml)) + using (var reader = XmlReader.Create(stringReader, settings)) { - using (var stringReader = new StringReader(xml)) - using (var reader = XmlReader.Create(stringReader, settings)) + reader.MoveToContent(); + reader.Read(); + + // Loop through each element + while (!reader.EOF && reader.ReadState == ReadState.Interactive) { - reader.MoveToContent(); - reader.Read(); + cancellationToken.ThrowIfCancellationRequested(); - // Loop through each element - while (!reader.EOF && reader.ReadState == ReadState.Interactive) + if (reader.NodeType == XmlNodeType.Element) { - cancellationToken.ThrowIfCancellationRequested(); - - if (reader.NodeType == XmlNodeType.Element) - { - FetchDataFromXmlNode(reader, item); - } - else - { - reader.Read(); - } + FetchDataFromXmlNode(reader, item); + } + else + { + reader.Read(); } } } - catch (XmlException) - { - } + } + catch (XmlException) + { } } - protected void ParseProviderLinks(T item, string xml) + protected void ParseProviderLinks(T item, ReadOnlySpan xml) { if (ProviderIdParsers.TryFindImdbId(xml, out var imdbId)) { @@ -271,9 +266,9 @@ namespace MediaBrowser.XbmcMetadata.Parsers if (!string.IsNullOrWhiteSpace(val)) { - if (DateTime.TryParse(val, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var added)) + if (DateTime.TryParse(val, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var added)) { - item.DateCreated = added.ToUniversalTime(); + item.DateCreated = added; } else { @@ -312,7 +307,7 @@ namespace MediaBrowser.XbmcMetadata.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; } @@ -373,7 +368,7 @@ namespace MediaBrowser.XbmcMetadata.Parsers var val = reader.ReadElementContentAsString(); if (!string.IsNullOrWhiteSpace(val) && userData != null) { - if (int.TryParse(val, NumberStyles.Integer, UsCulture, out var count)) + if (int.TryParse(val, NumberStyles.Integer, CultureInfo.InvariantCulture, out var count)) { userData.PlayCount = count; } @@ -387,9 +382,9 @@ namespace MediaBrowser.XbmcMetadata.Parsers var val = reader.ReadElementContentAsString(); if (!string.IsNullOrWhiteSpace(val) && userData != null) { - if (DateTime.TryParse(val, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var added)) + if (DateTime.TryParse(val, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var added)) { - userData.LastPlayedDate = added.ToUniversalTime(); + userData.LastPlayedDate = added; } else { @@ -478,7 +473,7 @@ namespace MediaBrowser.XbmcMetadata.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; } @@ -688,9 +683,9 @@ namespace MediaBrowser.XbmcMetadata.Parsers if (!string.IsNullOrWhiteSpace(val)) { - if (DateTime.TryParseExact(val, formatString, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var date) && date.Year > 1850) + if (DateTime.TryParseExact(val, formatString, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var date) && date.Year > 1850) { - item.PremiereDate = date.ToUniversalTime(); + item.PremiereDate = date; item.ProductionYear = date.Year; } } @@ -706,9 +701,9 @@ namespace MediaBrowser.XbmcMetadata.Parsers if (!string.IsNullOrWhiteSpace(val)) { - if (DateTime.TryParseExact(val, formatString, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var date) && date.Year > 1850) + if (DateTime.TryParseExact(val, formatString, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var date) && date.Year > 1850) { - item.EndDate = date.ToUniversalTime(); + item.EndDate = date; } } @@ -783,59 +778,15 @@ namespace MediaBrowser.XbmcMetadata.Parsers case "thumb": { - var artType = reader.GetAttribute("aspect"); - var val = reader.ReadElementContentAsString(); - - // skip: - // - empty aspect tag - // - empty uri - // - tag containing '.' because we can't set images for seasons, episodes or movie sets within series or movies - if (string.IsNullOrEmpty(artType) || string.IsNullOrEmpty(val) || artType.Contains('.', StringComparison.Ordinal)) - { - break; - } - - ImageType imageType = GetImageType(artType); - - if (!Uri.TryCreate(val, UriKind.Absolute, out var uri)) - { - Logger.LogError("Image location {Path} specified in nfo file for {ItemName} is not a valid URL or file path.", val, item.Name); - break; - } - - if (uri.IsFile) - { - // only allow one item of each type - if (itemResult.Images.Any(x => x.Type == imageType)) - { - break; - } - - var fileSystemMetadata = _directoryService.GetFile(val); - // non existing file returns null - if (fileSystemMetadata == null || !fileSystemMetadata.Exists) - { - Logger.LogWarning("Artwork file {Path} specified in nfo file for {ItemName} does not exist.", uri, item.Name); - break; - } - - itemResult.Images.Add(new LocalImageInfo() - { - FileInfo = fileSystemMetadata, - Type = imageType - }); - } - else - { - // only allow one item of each type - if (itemResult.RemoteImages.Any(x => x.type == imageType)) - { - break; - } - - itemResult.RemoteImages.Add((uri.ToString(), imageType)); - } + FetchThumbNode(reader, itemResult); + break; + } + case "fanart": + { + var subtree = reader.ReadSubtree(); + subtree.ReadToDescendant("thumb"); + FetchThumbNode(subtree, itemResult); break; } @@ -858,6 +809,68 @@ namespace MediaBrowser.XbmcMetadata.Parsers } } + private void FetchThumbNode(XmlReader reader, MetadataResult itemResult) + { + var artType = reader.GetAttribute("aspect"); + var val = reader.ReadElementContentAsString(); + + // artType is null if the thumb node is a child of the fanart tag + // -> set image type to fanart + if (string.IsNullOrWhiteSpace(artType)) + { + artType = "fanart"; + } + + // skip: + // - empty uri + // - tag containing '.' because we can't set images for seasons, episodes or movie sets within series or movies + if (string.IsNullOrEmpty(val) || artType.Contains('.', StringComparison.Ordinal)) + { + return; + } + + ImageType imageType = GetImageType(artType); + + if (!Uri.TryCreate(val, UriKind.Absolute, out var uri)) + { + Logger.LogError("Image location {Path} specified in nfo file for {ItemName} is not a valid URL or file path.", val, itemResult.Item.Name); + return; + } + + if (uri.IsFile) + { + // only allow one item of each type + if (itemResult.Images.Any(x => x.Type == imageType)) + { + return; + } + + var fileSystemMetadata = _directoryService.GetFile(val); + // non existing file returns null + if (fileSystemMetadata == null || !fileSystemMetadata.Exists) + { + Logger.LogWarning("Artwork file {Path} specified in nfo file for {ItemName} does not exist.", uri, itemResult.Item.Name); + return; + } + + itemResult.Images.Add(new LocalImageInfo() + { + FileInfo = fileSystemMetadata, + Type = imageType + }); + } + else + { + // only allow one item of each type + if (itemResult.RemoteImages.Any(x => x.type == imageType)) + { + return; + } + + itemResult.RemoteImages.Add((uri.ToString(), imageType)); + } + } + private void FetchFromFileInfoNode(XmlReader reader, T item) { reader.MoveToContent(); @@ -1205,7 +1218,7 @@ namespace MediaBrowser.XbmcMetadata.Parsers switch (reader.Name) { case "name": - name = reader.ReadElementContentAsString() ?? string.Empty; + name = reader.ReadElementContentAsString(); break; case "role": @@ -1250,7 +1263,7 @@ namespace MediaBrowser.XbmcMetadata.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.XbmcMetadata/Parsers/EpisodeNfoParser.cs b/MediaBrowser.XbmcMetadata/Parsers/EpisodeNfoParser.cs index 6b16075305..d2f349ad7a 100644 --- a/MediaBrowser.XbmcMetadata/Parsers/EpisodeNfoParser.cs +++ b/MediaBrowser.XbmcMetadata/Parsers/EpisodeNfoParser.cs @@ -1,7 +1,6 @@ using System; using System.Globalization; using System.IO; -using System.Text; using System.Threading; using System.Xml; using MediaBrowser.Common.Configuration; @@ -40,72 +39,68 @@ namespace MediaBrowser.XbmcMetadata.Parsers /// protected override void Fetch(MetadataResult item, string metadataFile, XmlReaderSettings settings, CancellationToken cancellationToken) { - using (var fileStream = File.OpenRead(metadataFile)) - using (var streamReader = new StreamReader(fileStream, Encoding.UTF8)) - { - item.ResetPeople(); + item.ResetPeople(); - var xmlFile = streamReader.ReadToEnd(); + var xmlFile = File.ReadAllText(metadataFile); - var srch = ""; - var index = xmlFile.IndexOf(srch, StringComparison.OrdinalIgnoreCase); + var srch = ""; + var index = xmlFile.IndexOf(srch, StringComparison.OrdinalIgnoreCase); - var xml = xmlFile; + var xml = xmlFile; - if (index != -1) - { - xml = xmlFile.Substring(0, index + srch.Length); - xmlFile = xmlFile.Substring(index + srch.Length); - } + if (index != -1) + { + xml = xmlFile.Substring(0, index + srch.Length); + xmlFile = xmlFile.Substring(index + srch.Length); + } - // These are not going to be valid xml so no sense in causing the provider to fail and spamming the log with exceptions - try + // These are not going to be valid xml so no sense in causing the provider to fail and spamming the log with exceptions + try + { + // Extract episode details from the first episodedetails block + using (var stringReader = new StringReader(xml)) + using (var reader = XmlReader.Create(stringReader, settings)) { - // Extract episode details from the first episodedetails block - using (var stringReader = new StringReader(xml)) - using (var reader = XmlReader.Create(stringReader, settings)) + reader.MoveToContent(); + reader.Read(); + + // Loop through each element + while (!reader.EOF && reader.ReadState == ReadState.Interactive) { - reader.MoveToContent(); - reader.Read(); + cancellationToken.ThrowIfCancellationRequested(); - // Loop through each element - while (!reader.EOF && reader.ReadState == ReadState.Interactive) + if (reader.NodeType == XmlNodeType.Element) { - cancellationToken.ThrowIfCancellationRequested(); - - if (reader.NodeType == XmlNodeType.Element) - { - FetchDataFromXmlNode(reader, item); - } - else - { - reader.Read(); - } + FetchDataFromXmlNode(reader, item); + } + else + { + reader.Read(); } } + } - // Extract the last episode number from nfo - // This is needed because XBMC metadata uses multiple episodedetails blocks instead of episodenumberend tag - while ((index = xmlFile.IndexOf(srch, StringComparison.OrdinalIgnoreCase)) != -1) + // Extract the last episode number from nfo + // This is needed because XBMC metadata uses multiple episodedetails blocks instead of episodenumberend tag + while ((index = xmlFile.IndexOf(srch, StringComparison.OrdinalIgnoreCase)) != -1) + { + xml = xmlFile.Substring(0, index + srch.Length); + xmlFile = xmlFile.Substring(index + srch.Length); + + using (var stringReader = new StringReader(xml)) + using (var reader = XmlReader.Create(stringReader, settings)) { - xml = xmlFile.Substring(0, index + srch.Length); - xmlFile = xmlFile.Substring(index + srch.Length); + reader.MoveToContent(); - using (var stringReader = new StringReader(xml)) - using (var reader = XmlReader.Create(stringReader, settings)) + if (reader.ReadToDescendant("episode") && int.TryParse(reader.ReadElementContentAsString(), out var num)) { - reader.MoveToContent(); - - if (reader.ReadToDescendant("episode") && int.TryParse(reader.ReadElementContentAsString(), out var num)) - { - item.Item.IndexNumberEnd = Math.Max(num, item.Item.IndexNumberEnd ?? num); - } + item.Item.IndexNumberEnd = Math.Max(num, item.Item.IndexNumberEnd ?? num); } } } - catch (XmlException) - { - } + } + catch (XmlException) + { } } @@ -168,7 +163,7 @@ namespace MediaBrowser.XbmcMetadata.Parsers if (!string.IsNullOrWhiteSpace(val)) { // int.TryParse is local aware, so it can be problematic, force us culture - if (int.TryParse(val, NumberStyles.Integer, UsCulture, out var rval)) + if (int.TryParse(val, NumberStyles.Integer, CultureInfo.InvariantCulture, out var rval)) { item.AirsBeforeEpisodeNumber = rval; } @@ -184,7 +179,7 @@ namespace MediaBrowser.XbmcMetadata.Parsers if (!string.IsNullOrWhiteSpace(val)) { // int.TryParse is local aware, so it can be problematic, force us culture - if (int.TryParse(val, NumberStyles.Integer, UsCulture, out var rval)) + if (int.TryParse(val, NumberStyles.Integer, CultureInfo.InvariantCulture, out var rval)) { item.AirsAfterSeasonNumber = rval; } @@ -200,7 +195,7 @@ namespace MediaBrowser.XbmcMetadata.Parsers if (!string.IsNullOrWhiteSpace(val)) { // int.TryParse is local aware, so it can be problematic, force us culture - if (int.TryParse(val, NumberStyles.Integer, UsCulture, out var rval)) + if (int.TryParse(val, NumberStyles.Integer, CultureInfo.InvariantCulture, out var rval)) { item.AirsBeforeSeasonNumber = rval; } @@ -216,7 +211,7 @@ namespace MediaBrowser.XbmcMetadata.Parsers if (!string.IsNullOrWhiteSpace(val)) { // int.TryParse is local aware, so it can be problematic, force us culture - if (int.TryParse(val, NumberStyles.Integer, UsCulture, out var rval)) + if (int.TryParse(val, NumberStyles.Integer, CultureInfo.InvariantCulture, out var rval)) { item.AirsBeforeSeasonNumber = rval; } @@ -232,7 +227,7 @@ namespace MediaBrowser.XbmcMetadata.Parsers if (!string.IsNullOrWhiteSpace(val)) { // int.TryParse is local aware, so it can be problematic, force us culture - if (int.TryParse(val, NumberStyles.Integer, UsCulture, out var rval)) + if (int.TryParse(val, NumberStyles.Integer, CultureInfo.InvariantCulture, out var rval)) { item.AirsBeforeEpisodeNumber = rval; } diff --git a/MediaBrowser.XbmcMetadata/Savers/BaseNfoSaver.cs b/MediaBrowser.XbmcMetadata/Savers/BaseNfoSaver.cs index 3be35e2d9b..594402258a 100644 --- a/MediaBrowser.XbmcMetadata/Savers/BaseNfoSaver.cs +++ b/MediaBrowser.XbmcMetadata/Savers/BaseNfoSaver.cs @@ -203,8 +203,15 @@ namespace MediaBrowser.XbmcMetadata.Savers // On Windows, saving 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); } @@ -555,7 +562,7 @@ namespace MediaBrowser.XbmcMetadata.Savers } // Series xml saver already saves this - if (!(item is Series)) + if (item is not Series) { var tvdb = item.GetProviderId(MetadataProvider.Tvdb); if (!string.IsNullOrEmpty(tvdb)) @@ -582,7 +589,7 @@ namespace MediaBrowser.XbmcMetadata.Savers writer.WriteElementString("countrycode", item.PreferredMetadataCountryCode); } - if (item.PremiereDate.HasValue && !(item is Episode)) + if (item.PremiereDate.HasValue && item is not Episode) { var formatString = options.ReleaseDateFormat; @@ -605,7 +612,7 @@ namespace MediaBrowser.XbmcMetadata.Savers if (item.EndDate.HasValue) { - if (!(item is Episode)) + if (item is not Episode) { var formatString = options.ReleaseDateFormat; diff --git a/MediaBrowser.XbmcMetadata/Savers/EpisodeNfoSaver.cs b/MediaBrowser.XbmcMetadata/Savers/EpisodeNfoSaver.cs index 62f80e81bd..2cd3fdf028 100644 --- a/MediaBrowser.XbmcMetadata/Savers/EpisodeNfoSaver.cs +++ b/MediaBrowser.XbmcMetadata/Savers/EpisodeNfoSaver.cs @@ -17,8 +17,6 @@ namespace MediaBrowser.XbmcMetadata.Savers /// public class EpisodeNfoSaver : BaseNfoSaver { - private readonly CultureInfo _usCulture = new CultureInfo("en-US"); - /// /// Initializes a new instance of the class. /// @@ -60,17 +58,17 @@ namespace MediaBrowser.XbmcMetadata.Savers if (episode.IndexNumber.HasValue) { - writer.WriteElementString("episode", episode.IndexNumber.Value.ToString(_usCulture)); + writer.WriteElementString("episode", episode.IndexNumber.Value.ToString(CultureInfo.InvariantCulture)); } if (episode.IndexNumberEnd.HasValue) { - writer.WriteElementString("episodenumberend", episode.IndexNumberEnd.Value.ToString(_usCulture)); + writer.WriteElementString("episodenumberend", episode.IndexNumberEnd.Value.ToString(CultureInfo.InvariantCulture)); } if (episode.ParentIndexNumber.HasValue) { - writer.WriteElementString("season", episode.ParentIndexNumber.Value.ToString(_usCulture)); + writer.WriteElementString("season", episode.ParentIndexNumber.Value.ToString(CultureInfo.InvariantCulture)); } if (episode.PremiereDate.HasValue) @@ -84,28 +82,28 @@ namespace MediaBrowser.XbmcMetadata.Savers { if (episode.AirsAfterSeasonNumber.HasValue && episode.AirsAfterSeasonNumber.Value != -1) { - writer.WriteElementString("airsafter_season", episode.AirsAfterSeasonNumber.Value.ToString(_usCulture)); + writer.WriteElementString("airsafter_season", episode.AirsAfterSeasonNumber.Value.ToString(CultureInfo.InvariantCulture)); } if (episode.AirsBeforeEpisodeNumber.HasValue && episode.AirsBeforeEpisodeNumber.Value != -1) { - writer.WriteElementString("airsbefore_episode", episode.AirsBeforeEpisodeNumber.Value.ToString(_usCulture)); + writer.WriteElementString("airsbefore_episode", episode.AirsBeforeEpisodeNumber.Value.ToString(CultureInfo.InvariantCulture)); } if (episode.AirsBeforeSeasonNumber.HasValue && episode.AirsBeforeSeasonNumber.Value != -1) { - writer.WriteElementString("airsbefore_season", episode.AirsBeforeSeasonNumber.Value.ToString(_usCulture)); + writer.WriteElementString("airsbefore_season", episode.AirsBeforeSeasonNumber.Value.ToString(CultureInfo.InvariantCulture)); } if (episode.AirsBeforeEpisodeNumber.HasValue && episode.AirsBeforeEpisodeNumber.Value != -1) { - writer.WriteElementString("displayepisode", episode.AirsBeforeEpisodeNumber.Value.ToString(_usCulture)); + writer.WriteElementString("displayepisode", episode.AirsBeforeEpisodeNumber.Value.ToString(CultureInfo.InvariantCulture)); } var specialSeason = episode.AiredSeasonNumber; if (specialSeason.HasValue && specialSeason.Value != -1) { - writer.WriteElementString("displayseason", specialSeason.Value.ToString(_usCulture)); + writer.WriteElementString("displayseason", specialSeason.Value.ToString(CultureInfo.InvariantCulture)); } } } diff --git a/MediaBrowser.XbmcMetadata/Savers/MovieNfoSaver.cs b/MediaBrowser.XbmcMetadata/Savers/MovieNfoSaver.cs index 412e8031b2..21e7e2335b 100644 --- a/MediaBrowser.XbmcMetadata/Savers/MovieNfoSaver.cs +++ b/MediaBrowser.XbmcMetadata/Savers/MovieNfoSaver.cs @@ -82,7 +82,7 @@ namespace MediaBrowser.XbmcMetadata.Savers } // Check parent for null to avoid running this against things like video backdrops - if (item is Video video && !(item is Episode) && !video.ExtraType.HasValue) + if (item is Video video && item is not Episode && !video.ExtraType.HasValue) { return updateType >= MinimumUpdateType; } diff --git a/MediaBrowser.XbmcMetadata/Savers/SeasonNfoSaver.cs b/MediaBrowser.XbmcMetadata/Savers/SeasonNfoSaver.cs index b9d73ba822..e975506308 100644 --- a/MediaBrowser.XbmcMetadata/Savers/SeasonNfoSaver.cs +++ b/MediaBrowser.XbmcMetadata/Savers/SeasonNfoSaver.cs @@ -52,7 +52,7 @@ namespace MediaBrowser.XbmcMetadata.Savers return false; } - if (!(item is Season)) + if (item is not Season) { return false; } diff --git a/README.md b/README.md index 6859a8a76f..6653bff116 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,9 @@ Master Commits RSS Feed + +Total LGTM alerts +

--- @@ -68,6 +71,8 @@ Check out our Weblate instance to h Detailed Translation Status +--- + ## Jellyfin Server This repository contains the code for Jellyfin's backend server. Note that this is only one of many projects under the Jellyfin GitHub [organization](https://github.com/jellyfin/) on GitHub. If you want to contribute, you can start by checking out our [documentation](https://jellyfin.org/docs/general/contributing/index.html) to see what to work on. @@ -78,7 +83,7 @@ These instructions will help you get set up with a local development environment ### Prerequisites -Before the project can be built, you must first install the [.NET 5.0 SDK](https://dotnet.microsoft.com/download) on your system. +Before the project can be built, you must first install the [.NET 6.0 SDK](https://dotnet.microsoft.com/download/dotnet) on your system. Instructions to run this project from the command line are included here, but you will also need to install an IDE if you want to debug the server while it is running. Any IDE that supports .NET Core development will work, but two options are recent versions of [Visual Studio](https://visualstudio.microsoft.com/downloads/) (at least 2017) and [Visual Studio Code](https://code.visualstudio.com/Download). @@ -162,3 +167,13 @@ switch `--nowebclient` or the environment variable `JELLYFIN_NOWEBCONTENT=true`. Since this is a common scenario, there is also a separate launch profile defined for Visual Studio called `Jellyfin.Server (nowebcontent)` that can be selected from the 'Start Debugging' dropdown in the main toolbar. **NOTE:** The setup wizard can not be run if the web client is hosted separately. + +--- +

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

diff --git a/RSSDP/DisposableManagedObjectBase.cs b/RSSDP/DisposableManagedObjectBase.cs index 7d6a471f95..5d7da4124e 100644 --- a/RSSDP/DisposableManagedObjectBase.cs +++ b/RSSDP/DisposableManagedObjectBase.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Globalization; using System.Text; namespace Rssdp.Infrastructure @@ -45,11 +46,11 @@ namespace Rssdp.Infrastructure const string ArgFormat = "{0}: {1}\r\n"; - builder.AppendFormat("{0}\r\n", header); + builder.AppendFormat(CultureInfo.InvariantCulture, "{0}\r\n", header); foreach (var pair in values) { - builder.AppendFormat(ArgFormat, pair.Key, pair.Value); + builder.AppendFormat(CultureInfo.InvariantCulture, ArgFormat, pair.Key, pair.Value); } builder.Append("\r\n"); diff --git a/RSSDP/RSSDP.csproj b/RSSDP/RSSDP.csproj index c64ee9389d..77130983b5 100644 --- a/RSSDP/RSSDP.csproj +++ b/RSSDP/RSSDP.csproj @@ -11,9 +11,11 @@ - net5.0 + net6.0 false - true + AllDisabledByDefault + disable + CA2016
diff --git a/RSSDP/SsdpCommunicationsServer.cs b/RSSDP/SsdpCommunicationsServer.cs index e49c0e77be..58dabc628b 100644 --- a/RSSDP/SsdpCommunicationsServer.cs +++ b/RSSDP/SsdpCommunicationsServer.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Generic; -using System.Diagnostics; using System.Linq; using System.Net; using System.Net.Http; @@ -396,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 4005d836d9..c826830f1d 100644 --- a/RSSDP/SsdpDevice.cs +++ b/RSSDP/SsdpDevice.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Collections.ObjectModel; +using System.Globalization; using Rssdp.Infrastructure; namespace Rssdp @@ -134,11 +135,13 @@ namespace Rssdp { get { - return String.Format("urn:{0}:{3}:{1}:{2}", - this.DeviceTypeNamespace ?? String.Empty, - this.DeviceType ?? String.Empty, - this.DeviceVersion, - this.DeviceClass ?? "device"); + return String.Format( + CultureInfo.InvariantCulture, + "urn:{0}:{3}:{1}:{2}", + this.DeviceTypeNamespace ?? String.Empty, + this.DeviceType ?? String.Empty, + this.DeviceVersion, + this.DeviceClass ?? "device"); } } diff --git a/RSSDP/SsdpDeviceLocator.cs b/RSSDP/SsdpDeviceLocator.cs index 188e298e2a..3a52b2a3e6 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 c9e795d565..64d19803df 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; @@ -233,7 +234,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 +341,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 +364,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 +498,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 +523,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 +543,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 +551,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 8012ec07f1..f615606e2d 100755 --- a/bump_version +++ b/bump_version @@ -21,7 +21,14 @@ fi shared_version_file="./SharedVersion.cs" build_file="./build.yaml" # csproj files for nuget packages -jellyfin_subprojects=( MediaBrowser.Common/MediaBrowser.Common.csproj Jellyfin.Data/Jellyfin.Data.csproj MediaBrowser.Controller/MediaBrowser.Controller.csproj MediaBrowser.Model/MediaBrowser.Model.csproj Emby.Naming/Emby.Naming.csproj ) +jellyfin_subprojects=( + MediaBrowser.Common/MediaBrowser.Common.csproj + Jellyfin.Data/Jellyfin.Data.csproj + MediaBrowser.Controller/MediaBrowser.Controller.csproj + MediaBrowser.Model/MediaBrowser.Model.csproj + Emby.Naming/Emby.Naming.csproj + src/Jellyfin.Extensions/Jellyfin.Extensions.csproj +) new_version="$1" diff --git a/debian/bin/restart.sh b/debian/bin/restart.sh index 34fce06708..4847b918be 100755 --- a/debian/bin/restart.sh +++ b/debian/bin/restart.sh @@ -11,23 +11,43 @@ # # This script is used by the Debian/Ubuntu/Fedora/CentOS packages. -get_service_command() { - for command in systemctl service; do - if which $command &>/dev/null; then - echo $command && return +# This is the Right Way(tm) to check if we are booted with +# systemd, according to sd_booted(3) +if [ -d /run/systemd/system ]; then + cmd=systemctl +else + # Everything else is really hard to figure out, so we just use + # service(8) if it's available - that works with most init + # systems/distributions I know of, including FreeBSD + if type service >/dev/null 2>&1; then + cmd=service + else + # If even service(8) isn't available, we just try /etc/init.d + # and hope for the best + if [ -d /etc/init.d ]; then + cmd=sysv + else + echo "Unable to detect a way to restart Jellyfin; bailing out" 1>&2 + echo "Please report this bug to https://github.com/jellyfin/jellyfin/issues" 1>&2 + exit 1 fi - done - echo "sysv" -} + fi +fi + +if type sudo >/dev/null 2>&1; then + sudo_command=sudo +else + sudo_command= +fi -cmd="$( get_service_command )" echo "Detected service control platform '$cmd'; using it to restart Jellyfin..." case $cmd in 'systemctl') - echo "sleep 0.5; /usr/bin/sudo $( which systemctl ) start jellyfin" | at now + # Without systemd-run here, `jellyfin.service`'s shutdown terminates this process too + $sudo_command systemd-run systemctl restart jellyfin ;; 'service') - echo "sleep 0.5; /usr/bin/sudo $( which service ) jellyfin start" | at now + echo "sleep 0.5; $sudo_command service jellyfin start" | at now ;; 'sysv') echo "sleep 0.5; /usr/bin/sudo /etc/init.d/jellyfin start" | at now diff --git a/debian/conf/jellyfin b/debian/conf/jellyfin index 9ebaf2bd88..ab8d5d1d4d 100644 --- a/debian/conf/jellyfin +++ b/debian/conf/jellyfin @@ -33,6 +33,9 @@ JELLYFIN_FFMPEG_OPT="--ffmpeg=/usr/lib/jellyfin-ffmpeg/ffmpeg" # [OPTIONAL] run Jellyfin without the web app #JELLYFIN_NOWEBAPP_OPT="--nowebclient" +# Space to add additional command line options to jellyfin (for help see ~$ jellyfin --help) +JELLYFIN_ADDITIONAL_OPTS="" + # [OPTIONAL] run Jellyfin with ASP.NET Server Garbage Collection (uses more RAM and less CPU than Workstation GC) # 0 = Workstation # 1 = Server @@ -45,4 +48,4 @@ JELLYFIN_FFMPEG_OPT="--ffmpeg=/usr/lib/jellyfin-ffmpeg/ffmpeg" # Application username JELLYFIN_USER="jellyfin" # Full application command -JELLYFIN_ARGS="$JELLYFIN_WEB_OPT $JELLYFIN_RESTART_OPT $JELLYFIN_FFMPEG_OPT $JELLYFIN_SERVICE_OPT $JELLYFIN_NOWEBAPP_OPT" +JELLYFIN_ARGS="$JELLYFIN_WEB_OPT $JELLYFIN_RESTART_OPT $JELLYFIN_FFMPEG_OPT $JELLYFIN_SERVICE_OPT $JELLYFIN_NOWEBAPP_OPT $JELLFIN_ADDITIONAL_OPTS" diff --git a/debian/conf/jellyfin-sudoers b/debian/conf/jellyfin-sudoers index b481ba4ad4..f84e7454ff 100644 --- a/debian/conf/jellyfin-sudoers +++ b/debian/conf/jellyfin-sudoers @@ -2,9 +2,9 @@ Cmnd_Alias RESTARTSERVER_SYSV = /sbin/service jellyfin restart, /usr/sbin/service jellyfin restart Cmnd_Alias STARTSERVER_SYSV = /sbin/service jellyfin start, /usr/sbin/service jellyfin start Cmnd_Alias STOPSERVER_SYSV = /sbin/service jellyfin stop, /usr/sbin/service jellyfin stop -Cmnd_Alias RESTARTSERVER_SYSTEMD = /usr/bin/systemctl restart jellyfin, /bin/systemctl restart jellyfin -Cmnd_Alias STARTSERVER_SYSTEMD = /usr/bin/systemctl start jellyfin, /bin/systemctl start jellyfin -Cmnd_Alias STOPSERVER_SYSTEMD = /usr/bin/systemctl stop jellyfin, /bin/systemctl stop jellyfin +Cmnd_Alias RESTARTSERVER_SYSTEMD = /usr/bin/systemd-run systemctl restart jellyfin +Cmnd_Alias STARTSERVER_SYSTEMD = /usr/bin/systemd-run systemctl start jellyfin +Cmnd_Alias STOPSERVER_SYSTEMD = /usr/bin/systemd-run systemctl stop jellyfin Cmnd_Alias RESTARTSERVER_INITD = /etc/init.d/jellyfin restart Cmnd_Alias STARTSERVER_INITD = /etc/init.d/jellyfin start Cmnd_Alias STOPSERVER_INITD = /etc/init.d/jellyfin stop diff --git a/debian/control b/debian/control index 9675d36ca6..da9aa94d4d 100644 --- a/debian/control +++ b/debian/control @@ -3,7 +3,7 @@ Section: misc Priority: optional Maintainer: Jellyfin Team Build-Depends: debhelper (>= 9), - dotnet-sdk-5.0, + dotnet-sdk-6.0, libc6-dev, libcurl4-openssl-dev, libfontconfig1-dev, @@ -23,6 +23,6 @@ Depends: at, libfontconfig1, libfreetype6, libssl1.1 -Recommends: jellyfin-web +Recommends: jellyfin-web, sudo Description: Jellyfin is the Free Software Media System. This package provides the Jellyfin server backend and API. diff --git a/debian/jellyfin.service b/debian/jellyfin.service index f1a8f4652c..b79cd47c72 100644 --- a/debian/jellyfin.service +++ b/debian/jellyfin.service @@ -1,12 +1,12 @@ [Unit] Description = Jellyfin Media Server -After = network.target +After = network-online.target [Service] Type = simple EnvironmentFile = /etc/default/jellyfin User = jellyfin -ExecStart = /usr/bin/jellyfin ${JELLYFIN_WEB_OPT} ${JELLYFIN_RESTART_OPT} ${JELLYFIN_FFMPEG_OPT} ${JELLYFIN_SERVICE_OPT} ${JELLYFIN_NOWEBAPP_OPT} +ExecStart = /usr/bin/jellyfin ${JELLYFIN_WEB_OPT} ${JELLYFIN_RESTART_OPT} ${JELLYFIN_FFMPEG_OPT} ${JELLYFIN_SERVICE_OPT} ${JELLYFIN_NOWEBAPP_OPT} ${JELLYFIN_ADDITIONAL_OPTS} Restart = on-failure TimeoutSec = 15 diff --git a/deployment/Dockerfile.centos.amd64 b/deployment/Dockerfile.centos.amd64 index 01fc1aaac2..78f051e4fb 100644 --- a/deployment/Dockerfile.centos.amd64 +++ b/deployment/Dockerfile.centos.amd64 @@ -2,28 +2,28 @@ FROM centos:7 # Docker build arguments ARG SOURCE_DIR=/jellyfin ARG ARTIFACT_DIR=/dist -ARG SDK_VERSION=5.0 # Docker run environment ENV SOURCE_DIR=/jellyfin ENV ARTIFACT_DIR=/dist ENV IS_DOCKER=YES # Prepare CentOS environment -RUN yum update -y \ - && yum install -y epel-release \ - && yum install -y @buildsys-build rpmdevtools yum-plugins-core libcurl-devel fontconfig-devel freetype-devel openssl-devel glibc-devel libicu-devel git +RUN yum update -yq \ + && yum install -yq epel-release \ + && yum install -yq @buildsys-build rpmdevtools yum-plugins-core libcurl-devel fontconfig-devel freetype-devel openssl-devel glibc-devel libicu-devel git wget # Install DotNET SDK -RUN rpm -Uvh https://packages.microsoft.com/config/rhel/7/packages-microsoft-prod.rpm \ - && rpmdev-setuptree \ - && yum install -y dotnet-sdk-${SDK_VERSION} +RUN wget -q https://download.visualstudio.microsoft.com/download/pr/20283373-1d83-4879-8278-0afb7fd4035e/56f204f174743b29a656499ad0fc93c3/dotnet-sdk-6.0.100-rc.2.21505.57-linux-x64.tar.gz -O dotnet-sdk.tar.gz \ + && mkdir -p dotnet-sdk \ + && tar -xzf dotnet-sdk.tar.gz -C dotnet-sdk \ + && ln -s $( pwd )/dotnet-sdk/dotnet /usr/bin/dotnet # Create symlinks and directories RUN ln -sf ${SOURCE_DIR}/deployment/build.centos.amd64 /build.sh \ - && mkdir -p ${SOURCE_DIR}/SPECS \ - && ln -s ${SOURCE_DIR}/fedora/jellyfin.spec ${SOURCE_DIR}/SPECS/jellyfin.spec \ - && mkdir -p ${SOURCE_DIR}/SOURCES \ - && ln -s ${SOURCE_DIR}/fedora ${SOURCE_DIR}/SOURCES + && mkdir -p ${SOURCE_DIR}/SPECS \ + && ln -s ${SOURCE_DIR}/fedora/jellyfin.spec ${SOURCE_DIR}/SPECS/jellyfin.spec \ + && mkdir -p ${SOURCE_DIR}/SOURCES \ + && ln -s ${SOURCE_DIR}/fedora ${SOURCE_DIR}/SOURCES VOLUME ${SOURCE_DIR}/ diff --git a/deployment/Dockerfile.debian.amd64 b/deployment/Dockerfile.debian.amd64 index 4c426b6d5c..daba0eb7d1 100644 --- a/deployment/Dockerfile.debian.amd64 +++ b/deployment/Dockerfile.debian.amd64 @@ -1,8 +1,7 @@ -FROM debian:10 +FROM mcr.microsoft.com/dotnet/sdk:6.0-bullseye-slim # Docker build arguments ARG SOURCE_DIR=/jellyfin ARG ARTIFACT_DIR=/dist -ARG SDK_VERSION=5.0 # Docker run environment ENV SOURCE_DIR=/jellyfin ENV ARTIFACT_DIR=/dist @@ -11,15 +10,11 @@ ENV ARCH=amd64 ENV IS_DOCKER=YES # Prepare Debian build environment -RUN apt-get update \ - && apt-get install -y apt-transport-https debhelper gnupg wget devscripts mmv libc6-dev libcurl4-openssl-dev libfontconfig1-dev libfreetype6-dev libssl-dev libssl1.1 liblttng-ust0 - -# Install dotnet repository -# https://dotnet.microsoft.com/download/linux-package-manager/debian9/sdk-current -RUN wget https://download.visualstudio.microsoft.com/download/pr/e1c236ec-c392-4eaa-a846-c600c82bb7f6/b13bd8b69f875f87cf83fc6f5457bcdf/dotnet-sdk-5.0.301-linux-x64.tar.gz -O dotnet-sdk.tar.gz \ - && mkdir -p dotnet-sdk \ - && tar -xzf dotnet-sdk.tar.gz -C dotnet-sdk \ - && ln -s $( pwd )/dotnet-sdk/dotnet /usr/bin/dotnet +RUN apt-get update -yqq \ + && apt-get install -yqq --no-install-recommends \ + apt-transport-https debhelper gnupg devscripts build-essential mmv \ + libcurl4-openssl-dev libfontconfig1-dev libfreetype6-dev libssl-dev \ + libssl1.1 liblttng-ust0 # Link to build script RUN ln -sf ${SOURCE_DIR}/deployment/build.debian.amd64 /build.sh diff --git a/deployment/Dockerfile.debian.arm64 b/deployment/Dockerfile.debian.arm64 index 7ed6d52bc8..db4e7f8178 100644 --- a/deployment/Dockerfile.debian.arm64 +++ b/deployment/Dockerfile.debian.arm64 @@ -1,8 +1,7 @@ -FROM debian:10 +FROM mcr.microsoft.com/dotnet/sdk:6.0-bullseye-slim # Docker build arguments ARG SOURCE_DIR=/jellyfin ARG ARTIFACT_DIR=/dist -ARG SDK_VERSION=5.0 # Docker run environment ENV SOURCE_DIR=/jellyfin ENV ARTIFACT_DIR=/dist @@ -11,23 +10,24 @@ ENV ARCH=amd64 ENV IS_DOCKER=YES # Prepare Debian build environment -RUN apt-get update \ - && apt-get install -y apt-transport-https debhelper gnupg wget devscripts mmv - -# Install dotnet repository -# https://dotnet.microsoft.com/download/linux-package-manager/debian9/sdk-current -RUN wget https://download.visualstudio.microsoft.com/download/pr/e1c236ec-c392-4eaa-a846-c600c82bb7f6/b13bd8b69f875f87cf83fc6f5457bcdf/dotnet-sdk-5.0.301-linux-x64.tar.gz -O dotnet-sdk.tar.gz \ - && mkdir -p dotnet-sdk \ - && tar -xzf dotnet-sdk.tar.gz -C dotnet-sdk \ - && ln -s $( pwd )/dotnet-sdk/dotnet /usr/bin/dotnet +RUN apt-get update -yqq \ + && apt-get install -yqq --no-install-recommends \ + apt-transport-https debhelper gnupg devscripts build-essential mmv # Prepare the cross-toolchain RUN dpkg --add-architecture arm64 \ - && apt-get update \ - && apt-get install -y cross-gcc-dev \ - && TARGET_LIST="arm64" cross-gcc-gensource 8 \ - && cd cross-gcc-packages-amd64/cross-gcc-8-arm64 \ - && apt-get install -y gcc-8-source libstdc++-8-dev-arm64-cross binutils-aarch64-linux-gnu bison flex libtool gdb sharutils netbase libmpc-dev libmpfr-dev libgmp-dev systemtap-sdt-dev autogen expect chrpath zlib1g-dev zip libc6-dev:arm64 linux-libc-dev:arm64 libgcc1:arm64 libcurl4-openssl-dev:arm64 libfontconfig1-dev:arm64 libfreetype6-dev:arm64 libssl-dev:arm64 liblttng-ust0:arm64 libstdc++-8-dev:arm64 + && apt-get update -yqq \ + && apt-get install -yqq --no-install-recommends cross-gcc-dev \ + && TARGET_LIST="arm64" cross-gcc-gensource 9 \ + && cd cross-gcc-packages-amd64/cross-gcc-9-arm64 \ + && apt-get install -yqq --no-install-recommends \ + gcc-9-source libstdc++-9-dev-arm64-cross \ + binutils-aarch64-linux-gnu bison flex libtool \ + gdb sharutils netbase libmpc-dev libmpfr-dev libgmp-dev \ + systemtap-sdt-dev autogen expect chrpath zlib1g-dev zip \ + libc6-dev:arm64 linux-libc-dev:arm64 libgcc1:arm64 \ + libcurl4-openssl-dev:arm64 libfontconfig1-dev:arm64 \ + libfreetype6-dev:arm64 libssl-dev:arm64 liblttng-ust0:arm64 libstdc++-9-dev:arm64 # Link to build script RUN ln -sf ${SOURCE_DIR}/deployment/build.debian.arm64 /build.sh diff --git a/deployment/Dockerfile.debian.armhf b/deployment/Dockerfile.debian.armhf index b46cceaa46..9b008e7fb0 100644 --- a/deployment/Dockerfile.debian.armhf +++ b/deployment/Dockerfile.debian.armhf @@ -1,8 +1,7 @@ -FROM debian:10 +FROM mcr.microsoft.com/dotnet/sdk:6.0-bullseye-slim # Docker build arguments ARG SOURCE_DIR=/jellyfin ARG ARTIFACT_DIR=/dist -ARG SDK_VERSION=5.0 # Docker run environment ENV SOURCE_DIR=/jellyfin ENV ARTIFACT_DIR=/dist @@ -11,23 +10,25 @@ ENV ARCH=amd64 ENV IS_DOCKER=YES # Prepare Debian build environment -RUN apt-get update \ - && apt-get install -y apt-transport-https debhelper gnupg wget devscripts mmv - -# Install dotnet repository -# https://dotnet.microsoft.com/download/linux-package-manager/debian9/sdk-current -RUN wget https://download.visualstudio.microsoft.com/download/pr/e1c236ec-c392-4eaa-a846-c600c82bb7f6/b13bd8b69f875f87cf83fc6f5457bcdf/dotnet-sdk-5.0.301-linux-x64.tar.gz -O dotnet-sdk.tar.gz \ - && mkdir -p dotnet-sdk \ - && tar -xzf dotnet-sdk.tar.gz -C dotnet-sdk \ - && ln -s $( pwd )/dotnet-sdk/dotnet /usr/bin/dotnet +RUN apt-get update -yqq \ + && apt-get install -yqq --no-install-recommends \ + apt-transport-https debhelper gnupg devscripts build-essential mmv # Prepare the cross-toolchain RUN dpkg --add-architecture armhf \ - && apt-get update \ - && apt-get install -y cross-gcc-dev \ - && TARGET_LIST="armhf" cross-gcc-gensource 8 \ - && cd cross-gcc-packages-amd64/cross-gcc-8-armhf \ - && apt-get install -y gcc-8-source libstdc++-8-dev-armhf-cross binutils-aarch64-linux-gnu bison flex libtool gdb sharutils netbase libmpc-dev libmpfr-dev libgmp-dev systemtap-sdt-dev autogen expect chrpath zlib1g-dev zip binutils-arm-linux-gnueabihf libc6-dev:armhf linux-libc-dev:armhf libgcc1:armhf libcurl4-openssl-dev:armhf libfontconfig1-dev:armhf libfreetype6-dev:armhf libssl-dev:armhf liblttng-ust0:armhf libstdc++-8-dev:armhf + && apt-get update -yqq \ + && apt-get install -yqq --no-install-recommends cross-gcc-dev \ + && TARGET_LIST="armhf" cross-gcc-gensource 9 \ + && cd cross-gcc-packages-amd64/cross-gcc-9-armhf \ + && apt-get install -yqq --no-install-recommends\ + gcc-9-source libstdc++-9-dev-armhf-cross \ + binutils-aarch64-linux-gnu bison flex libtool gdb \ + sharutils netbase libmpc-dev libmpfr-dev libgmp-dev \ + systemtap-sdt-dev autogen expect chrpath zlib1g-dev \ + zip binutils-arm-linux-gnueabihf libc6-dev:armhf \ + linux-libc-dev:armhf libgcc1:armhf libcurl4-openssl-dev:armhf \ + libfontconfig1-dev:armhf libfreetype6-dev:armhf libssl-dev:armhf \ + liblttng-ust0:armhf libstdc++-9-dev:armhf # Link to build script RUN ln -sf ${SOURCE_DIR}/deployment/build.debian.armhf /build.sh diff --git a/deployment/Dockerfile.docker.amd64 b/deployment/Dockerfile.docker.amd64 index 0b1a57014f..b2bd40713d 100644 --- a/deployment/Dockerfile.docker.amd64 +++ b/deployment/Dockerfile.docker.amd64 @@ -1,6 +1,4 @@ -ARG DOTNET_VERSION=5.0 - -FROM mcr.microsoft.com/dotnet/sdk:${DOTNET_VERSION}-buster-slim +FROM mcr.microsoft.com/dotnet/sdk:6.0-bullseye-slim ARG SOURCE_DIR=/src ARG ARTIFACT_DIR=/jellyfin diff --git a/deployment/Dockerfile.docker.arm64 b/deployment/Dockerfile.docker.arm64 index 583f53ca09..fc60f16246 100644 --- a/deployment/Dockerfile.docker.arm64 +++ b/deployment/Dockerfile.docker.arm64 @@ -1,6 +1,4 @@ -ARG DOTNET_VERSION=5.0 - -FROM mcr.microsoft.com/dotnet/sdk:${DOTNET_VERSION}-buster-slim +FROM mcr.microsoft.com/dotnet/sdk:6.0-bullseye-slim ARG SOURCE_DIR=/src ARG ARTIFACT_DIR=/jellyfin diff --git a/deployment/Dockerfile.docker.armhf b/deployment/Dockerfile.docker.armhf index 177c117134..f5cc47d83e 100644 --- a/deployment/Dockerfile.docker.armhf +++ b/deployment/Dockerfile.docker.armhf @@ -1,6 +1,4 @@ -ARG DOTNET_VERSION=5.0 - -FROM mcr.microsoft.com/dotnet/sdk:${DOTNET_VERSION}-buster-slim +FROM mcr.microsoft.com/dotnet/sdk:6.0-bullseye-slim ARG SOURCE_DIR=/src ARG ARTIFACT_DIR=/jellyfin diff --git a/deployment/Dockerfile.fedora.amd64 b/deployment/Dockerfile.fedora.amd64 index 137e56ecf2..14eeb6eed8 100644 --- a/deployment/Dockerfile.fedora.amd64 +++ b/deployment/Dockerfile.fedora.amd64 @@ -2,25 +2,28 @@ FROM fedora:33 # Docker build arguments ARG SOURCE_DIR=/jellyfin ARG ARTIFACT_DIR=/dist -ARG SDK_VERSION=5.0 # Docker run environment ENV SOURCE_DIR=/jellyfin ENV ARTIFACT_DIR=/dist ENV IS_DOCKER=YES # Prepare Fedora environment -RUN dnf update -y \ - && dnf install -y @buildsys-build rpmdevtools git dnf-plugins-core libcurl-devel fontconfig-devel freetype-devel openssl-devel glibc-devel libicu-devel systemd +RUN dnf update -yq \ + && dnf install -yq @buildsys-build rpmdevtools git dnf-plugins-core libcurl-devel fontconfig-devel freetype-devel openssl-devel glibc-devel libicu-devel systemd wget # Install DotNET SDK -RUN dnf install -y dotnet-sdk-${SDK_VERSION} dotnet-runtime-${SDK_VERSION} +RUN wget -q https://download.visualstudio.microsoft.com/download/pr/20283373-1d83-4879-8278-0afb7fd4035e/56f204f174743b29a656499ad0fc93c3/dotnet-sdk-6.0.100-rc.2.21505.57-linux-x64.tar.gz -O dotnet-sdk.tar.gz \ + && mkdir -p dotnet-sdk \ + && tar -xzf dotnet-sdk.tar.gz -C dotnet-sdk \ + && ln -s $( pwd )/dotnet-sdk/dotnet /usr/bin/dotnet + # Create symlinks and directories RUN ln -sf ${SOURCE_DIR}/deployment/build.fedora.amd64 /build.sh \ - && mkdir -p ${SOURCE_DIR}/SPECS \ - && ln -s ${SOURCE_DIR}/fedora/jellyfin.spec ${SOURCE_DIR}/SPECS/jellyfin.spec \ - && mkdir -p ${SOURCE_DIR}/SOURCES \ - && ln -s ${SOURCE_DIR}/fedora ${SOURCE_DIR}/SOURCES + && mkdir -p ${SOURCE_DIR}/SPECS \ + && ln -s ${SOURCE_DIR}/fedora/jellyfin.spec ${SOURCE_DIR}/SPECS/jellyfin.spec \ + && mkdir -p ${SOURCE_DIR}/SOURCES \ + && ln -s ${SOURCE_DIR}/fedora ${SOURCE_DIR}/SOURCES VOLUME ${SOURCE_DIR}/ diff --git a/deployment/Dockerfile.linux.amd64 b/deployment/Dockerfile.linux.amd64 index a0e23557aa..2c7e41cace 100644 --- a/deployment/Dockerfile.linux.amd64 +++ b/deployment/Dockerfile.linux.amd64 @@ -1,8 +1,7 @@ -FROM debian:10 +FROM mcr.microsoft.com/dotnet/sdk:6.0-bullseye-slim # Docker build arguments ARG SOURCE_DIR=/jellyfin ARG ARTIFACT_DIR=/dist -ARG SDK_VERSION=5.0 # Docker run environment ENV SOURCE_DIR=/jellyfin ENV ARTIFACT_DIR=/dist @@ -11,15 +10,11 @@ ENV ARCH=amd64 ENV IS_DOCKER=YES # Prepare Debian build environment -RUN apt-get update \ - && apt-get install -y apt-transport-https debhelper gnupg wget devscripts mmv libc6-dev libcurl4-openssl-dev libfontconfig1-dev libfreetype6-dev libssl-dev libssl1.1 liblttng-ust0 - -# Install dotnet repository -# https://dotnet.microsoft.com/download/linux-package-manager/debian9/sdk-current -RUN wget https://download.visualstudio.microsoft.com/download/pr/e1c236ec-c392-4eaa-a846-c600c82bb7f6/b13bd8b69f875f87cf83fc6f5457bcdf/dotnet-sdk-5.0.301-linux-x64.tar.gz -O dotnet-sdk.tar.gz \ - && mkdir -p dotnet-sdk \ - && tar -xzf dotnet-sdk.tar.gz -C dotnet-sdk \ - && ln -s $( pwd )/dotnet-sdk/dotnet /usr/bin/dotnet +RUN apt-get update -yqq \ + && apt-get install -yqq --no-install-recommends \ + apt-transport-https debhelper gnupg devscripts unzip \ + mmv libcurl4-openssl-dev libfontconfig1-dev \ + libfreetype6-dev libssl-dev libssl1.1 liblttng-ust0 # Link to docker-build script RUN ln -sf ${SOURCE_DIR}/deployment/build.linux.amd64 /build.sh diff --git a/deployment/Dockerfile.linux.amd64-musl b/deployment/Dockerfile.linux.amd64-musl index af0f55f8e3..e903cf1d32 100644 --- a/deployment/Dockerfile.linux.amd64-musl +++ b/deployment/Dockerfile.linux.amd64-musl @@ -1,8 +1,7 @@ -FROM debian:10 +FROM mcr.microsoft.com/dotnet/sdk:6.0-bullseye-slim # Docker build arguments ARG SOURCE_DIR=/jellyfin ARG ARTIFACT_DIR=/dist -ARG SDK_VERSION=5.0 # Docker run environment ENV SOURCE_DIR=/jellyfin ENV ARTIFACT_DIR=/dist @@ -11,15 +10,11 @@ ENV ARCH=amd64 ENV IS_DOCKER=YES # Prepare Debian build environment -RUN apt-get update \ - && apt-get install -y apt-transport-https debhelper gnupg wget devscripts mmv libc6-dev libcurl4-openssl-dev libfontconfig1-dev libfreetype6-dev libssl-dev libssl1.1 liblttng-ust0 - -# Install dotnet repository -# https://dotnet.microsoft.com/download/linux-package-manager/debian9/sdk-current -RUN wget https://download.visualstudio.microsoft.com/download/pr/e1c236ec-c392-4eaa-a846-c600c82bb7f6/b13bd8b69f875f87cf83fc6f5457bcdf/dotnet-sdk-5.0.301-linux-x64.tar.gz -O dotnet-sdk.tar.gz \ - && mkdir -p dotnet-sdk \ - && tar -xzf dotnet-sdk.tar.gz -C dotnet-sdk \ - && ln -s $( pwd )/dotnet-sdk/dotnet /usr/bin/dotnet +RUN apt-get update -yqq \ + && apt-get install -yqq --no-install-recommends \ + apt-transport-https debhelper gnupg devscripts unzip \ + mmv libcurl4-openssl-dev libfontconfig1-dev \ + libfreetype6-dev libssl-dev libssl1.1 liblttng-ust0 # Link to docker-build script RUN ln -sf ${SOURCE_DIR}/deployment/build.linux.amd64-musl /build.sh diff --git a/deployment/Dockerfile.linux.arm64 b/deployment/Dockerfile.linux.arm64 index ba004bb6a8..0dd3c5e4e8 100644 --- a/deployment/Dockerfile.linux.arm64 +++ b/deployment/Dockerfile.linux.arm64 @@ -1,8 +1,7 @@ -FROM debian:10 +FROM mcr.microsoft.com/dotnet/sdk:6.0-bullseye-slim # Docker build arguments ARG SOURCE_DIR=/jellyfin ARG ARTIFACT_DIR=/dist -ARG SDK_VERSION=5.0 # Docker run environment ENV SOURCE_DIR=/jellyfin ENV ARTIFACT_DIR=/dist @@ -11,15 +10,11 @@ ENV ARCH=arm64 ENV IS_DOCKER=YES # Prepare Debian build environment -RUN apt-get update \ - && apt-get install -y apt-transport-https debhelper gnupg wget devscripts mmv libc6-dev libcurl4-openssl-dev libfontconfig1-dev libfreetype6-dev libssl-dev libssl1.1 liblttng-ust0 - -# Install dotnet repository -# https://dotnet.microsoft.com/download/linux-package-manager/debian9/sdk-current -RUN wget https://download.visualstudio.microsoft.com/download/pr/e1c236ec-c392-4eaa-a846-c600c82bb7f6/b13bd8b69f875f87cf83fc6f5457bcdf/dotnet-sdk-5.0.301-linux-x64.tar.gz -O dotnet-sdk.tar.gz \ - && mkdir -p dotnet-sdk \ - && tar -xzf dotnet-sdk.tar.gz -C dotnet-sdk \ - && ln -s $( pwd )/dotnet-sdk/dotnet /usr/bin/dotnet +RUN apt-get update -yqq \ + && apt-get install -yqq --no-install-recommends \ + apt-transport-https debhelper gnupg devscripts unzip \ + mmv libcurl4-openssl-dev libfontconfig1-dev \ + libfreetype6-dev libssl-dev libssl1.1 liblttng-ust0 # Link to docker-build script RUN ln -sf ${SOURCE_DIR}/deployment/build.linux.arm64 /build.sh diff --git a/deployment/Dockerfile.linux.armhf b/deployment/Dockerfile.linux.armhf index 0d1114c011..16a8218e18 100644 --- a/deployment/Dockerfile.linux.armhf +++ b/deployment/Dockerfile.linux.armhf @@ -1,8 +1,7 @@ -FROM debian:10 +FROM mcr.microsoft.com/dotnet/sdk:6.0-bullseye-slim # Docker build arguments ARG SOURCE_DIR=/jellyfin ARG ARTIFACT_DIR=/dist -ARG SDK_VERSION=5.0 # Docker run environment ENV SOURCE_DIR=/jellyfin ENV ARTIFACT_DIR=/dist @@ -11,15 +10,11 @@ ENV ARCH=armhf ENV IS_DOCKER=YES # Prepare Debian build environment -RUN apt-get update \ - && apt-get install -y apt-transport-https debhelper gnupg wget devscripts mmv libc6-dev libcurl4-openssl-dev libfontconfig1-dev libfreetype6-dev libssl-dev libssl1.1 liblttng-ust0 - -# Install dotnet repository -# https://dotnet.microsoft.com/download/linux-package-manager/debian9/sdk-current -RUN wget https://download.visualstudio.microsoft.com/download/pr/e1c236ec-c392-4eaa-a846-c600c82bb7f6/b13bd8b69f875f87cf83fc6f5457bcdf/dotnet-sdk-5.0.301-linux-x64.tar.gz -O dotnet-sdk.tar.gz \ - && mkdir -p dotnet-sdk \ - && tar -xzf dotnet-sdk.tar.gz -C dotnet-sdk \ - && ln -s $( pwd )/dotnet-sdk/dotnet /usr/bin/dotnet +RUN apt-get update -yqq \ + && apt-get install -yqq --no-install-recommends \ + apt-transport-https debhelper gnupg devscripts unzip \ + mmv libcurl4-openssl-dev libfontconfig1-dev \ + libfreetype6-dev libssl-dev libssl1.1 liblttng-ust0 # Link to docker-build script RUN ln -sf ${SOURCE_DIR}/deployment/build.linux.armhf /build.sh diff --git a/deployment/Dockerfile.macos b/deployment/Dockerfile.macos index b57dc53f50..699ab2d408 100644 --- a/deployment/Dockerfile.macos +++ b/deployment/Dockerfile.macos @@ -1,8 +1,7 @@ -FROM debian:10 +FROM mcr.microsoft.com/dotnet/sdk:6.0-bullseye-slim # Docker build arguments ARG SOURCE_DIR=/jellyfin ARG ARTIFACT_DIR=/dist -ARG SDK_VERSION=5.0 # Docker run environment ENV SOURCE_DIR=/jellyfin ENV ARTIFACT_DIR=/dist @@ -11,15 +10,11 @@ ENV ARCH=amd64 ENV IS_DOCKER=YES # Prepare Debian build environment -RUN apt-get update \ - && apt-get install -y apt-transport-https debhelper gnupg wget devscripts mmv libc6-dev libcurl4-openssl-dev libfontconfig1-dev libfreetype6-dev libssl-dev libssl1.1 liblttng-ust0 - -# Install dotnet repository -# https://dotnet.microsoft.com/download/linux-package-manager/debian9/sdk-current -RUN wget https://download.visualstudio.microsoft.com/download/pr/e1c236ec-c392-4eaa-a846-c600c82bb7f6/b13bd8b69f875f87cf83fc6f5457bcdf/dotnet-sdk-5.0.301-linux-x64.tar.gz -O dotnet-sdk.tar.gz \ - && mkdir -p dotnet-sdk \ - && tar -xzf dotnet-sdk.tar.gz -C dotnet-sdk \ - && ln -s $( pwd )/dotnet-sdk/dotnet /usr/bin/dotnet +RUN apt-get update -yqq \ + && apt-get install -yqq --no-install-recommends \ + apt-transport-https debhelper gnupg devscripts \ + mmv libcurl4-openssl-dev libfontconfig1-dev \ + libfreetype6-dev libssl-dev libssl1.1 liblttng-ust0 # Link to docker-build script RUN ln -sf ${SOURCE_DIR}/deployment/build.macos /build.sh diff --git a/deployment/Dockerfile.portable b/deployment/Dockerfile.portable index 3783dfacf0..b567d7bcea 100644 --- a/deployment/Dockerfile.portable +++ b/deployment/Dockerfile.portable @@ -1,8 +1,7 @@ -FROM debian:10 +FROM mcr.microsoft.com/dotnet/sdk:6.0-bullseye-slim # Docker build arguments ARG SOURCE_DIR=/jellyfin ARG ARTIFACT_DIR=/dist -ARG SDK_VERSION=5.0 # Docker run environment ENV SOURCE_DIR=/jellyfin ENV ARTIFACT_DIR=/dist @@ -10,15 +9,11 @@ ENV DEB_BUILD_OPTIONS=noddebs ENV IS_DOCKER=YES # Prepare Debian build environment -RUN apt-get update \ - && apt-get install -y apt-transport-https debhelper gnupg wget devscripts mmv libc6-dev libcurl4-openssl-dev libfontconfig1-dev libfreetype6-dev libssl-dev libssl1.1 liblttng-ust0 - -# Install dotnet repository -# https://dotnet.microsoft.com/download/linux-package-manager/debian9/sdk-current -RUN wget https://download.visualstudio.microsoft.com/download/pr/e1c236ec-c392-4eaa-a846-c600c82bb7f6/b13bd8b69f875f87cf83fc6f5457bcdf/dotnet-sdk-5.0.301-linux-x64.tar.gz -O dotnet-sdk.tar.gz \ - && mkdir -p dotnet-sdk \ - && tar -xzf dotnet-sdk.tar.gz -C dotnet-sdk \ - && ln -s $( pwd )/dotnet-sdk/dotnet /usr/bin/dotnet +RUN apt-get update -yqq \ + && apt-get install -yqq --no-install-recommends \ + apt-transport-https debhelper gnupg devscripts \ + mmv libcurl4-openssl-dev libfontconfig1-dev \ + libfreetype6-dev libssl-dev libssl1.1 liblttng-ust0 # Link to docker-build script RUN ln -sf ${SOURCE_DIR}/deployment/build.portable /build.sh diff --git a/deployment/Dockerfile.ubuntu.amd64 b/deployment/Dockerfile.ubuntu.amd64 index 663a7af9e9..8733be89cf 100644 --- a/deployment/Dockerfile.ubuntu.amd64 +++ b/deployment/Dockerfile.ubuntu.amd64 @@ -2,7 +2,6 @@ FROM ubuntu:bionic # Docker build arguments ARG SOURCE_DIR=/jellyfin ARG ARTIFACT_DIR=/dist -ARG SDK_VERSION=5.0 # Docker run environment ENV SOURCE_DIR=/jellyfin ENV ARTIFACT_DIR=/dist @@ -11,15 +10,17 @@ ENV ARCH=amd64 ENV IS_DOCKER=YES # Prepare Debian build environment -RUN apt-get update \ - && apt-get install -y apt-transport-https debhelper gnupg wget devscripts mmv libc6-dev libcurl4-openssl-dev libfontconfig1-dev libfreetype6-dev libssl-dev libssl1.1 liblttng-ust0 +RUN apt-get update -yqq \ + && apt-get install -yqq --no-install-recommends \ + apt-transport-https debhelper gnupg wget ca-certificates devscripts \ + mmv build-essential libcurl4-openssl-dev libfontconfig1-dev \ + libfreetype6-dev libssl-dev libssl1.1 liblttng-ust0 # Install dotnet repository -# https://dotnet.microsoft.com/download/linux-package-manager/debian9/sdk-current -RUN wget https://download.visualstudio.microsoft.com/download/pr/e1c236ec-c392-4eaa-a846-c600c82bb7f6/b13bd8b69f875f87cf83fc6f5457bcdf/dotnet-sdk-5.0.301-linux-x64.tar.gz -O dotnet-sdk.tar.gz \ - && mkdir -p dotnet-sdk \ - && tar -xzf dotnet-sdk.tar.gz -C dotnet-sdk \ - && ln -s $( pwd )/dotnet-sdk/dotnet /usr/bin/dotnet +RUN wget -q https://download.visualstudio.microsoft.com/download/pr/20283373-1d83-4879-8278-0afb7fd4035e/56f204f174743b29a656499ad0fc93c3/dotnet-sdk-6.0.100-rc.2.21505.57-linux-x64.tar.gz -O dotnet-sdk.tar.gz \ + && mkdir -p dotnet-sdk \ + && tar -xzf dotnet-sdk.tar.gz -C dotnet-sdk \ + && ln -s $( pwd )/dotnet-sdk/dotnet /usr/bin/dotnet # Link to build script RUN ln -sf ${SOURCE_DIR}/deployment/build.ubuntu.amd64 /build.sh diff --git a/deployment/Dockerfile.ubuntu.arm64 b/deployment/Dockerfile.ubuntu.arm64 index 83eb24e422..6ae0d53ccb 100644 --- a/deployment/Dockerfile.ubuntu.arm64 +++ b/deployment/Dockerfile.ubuntu.arm64 @@ -2,7 +2,6 @@ FROM ubuntu:bionic # Docker build arguments ARG SOURCE_DIR=/jellyfin ARG ARTIFACT_DIR=/dist -ARG SDK_VERSION=5.0 # Docker run environment ENV SOURCE_DIR=/jellyfin ENV ARTIFACT_DIR=/dist @@ -11,34 +10,40 @@ ENV ARCH=amd64 ENV IS_DOCKER=YES # Prepare Debian build environment -RUN apt-get update \ - && apt-get install -y apt-transport-https debhelper gnupg wget devscripts mmv +RUN apt-get update -yqq \ + && apt-get install -yqq --no-install-recommends \ + apt-transport-https debhelper gnupg wget ca-certificates devscripts \ + mmv build-essential lsb-release # Install dotnet repository -# https://dotnet.microsoft.com/download/linux-package-manager/debian9/sdk-current -RUN wget https://download.visualstudio.microsoft.com/download/pr/e1c236ec-c392-4eaa-a846-c600c82bb7f6/b13bd8b69f875f87cf83fc6f5457bcdf/dotnet-sdk-5.0.301-linux-x64.tar.gz -O dotnet-sdk.tar.gz \ - && mkdir -p dotnet-sdk \ - && tar -xzf dotnet-sdk.tar.gz -C dotnet-sdk \ - && ln -s $( pwd )/dotnet-sdk/dotnet /usr/bin/dotnet +RUN wget -q https://download.visualstudio.microsoft.com/download/pr/20283373-1d83-4879-8278-0afb7fd4035e/56f204f174743b29a656499ad0fc93c3/dotnet-sdk-6.0.100-rc.2.21505.57-linux-x64.tar.gz -O dotnet-sdk.tar.gz \ + && mkdir -p dotnet-sdk \ + && tar -xzf dotnet-sdk.tar.gz -C dotnet-sdk \ + && ln -s $( pwd )/dotnet-sdk/dotnet /usr/bin/dotnet # Prepare the cross-toolchain RUN rm /etc/apt/sources.list \ - && export CODENAME="$( lsb_release -c -s )" \ - && echo "deb [arch=amd64] http://archive.ubuntu.com/ubuntu/ ${CODENAME} main restricted universe multiverse" >>/etc/apt/sources.list.d/amd64.list \ - && echo "deb [arch=amd64] http://archive.ubuntu.com/ubuntu/ ${CODENAME}-updates main restricted universe multiverse" >>/etc/apt/sources.list.d/amd64.list \ - && echo "deb [arch=amd64] http://archive.ubuntu.com/ubuntu/ ${CODENAME}-backports main restricted universe multiverse" >>/etc/apt/sources.list.d/amd64.list \ - && echo "deb [arch=amd64] http://archive.ubuntu.com/ubuntu/ ${CODENAME}-security main restricted universe multiverse" >>/etc/apt/sources.list.d/amd64.list \ - && echo "deb [arch=arm64] http://ports.ubuntu.com/ ${CODENAME} main restricted universe multiverse" >>/etc/apt/sources.list.d/arm64.list \ - && echo "deb [arch=arm64] http://ports.ubuntu.com/ ${CODENAME}-updates main restricted universe multiverse" >>/etc/apt/sources.list.d/arm64.list \ - && echo "deb [arch=arm64] http://ports.ubuntu.com/ ${CODENAME}-backports main restricted universe multiverse" >>/etc/apt/sources.list.d/arm64.list \ - && echo "deb [arch=arm64] http://ports.ubuntu.com/ ${CODENAME}-security main restricted universe multiverse" >>/etc/apt/sources.list.d/arm64.list \ - && dpkg --add-architecture arm64 \ - && apt-get update \ - && apt-get install -y cross-gcc-dev \ - && TARGET_LIST="arm64" cross-gcc-gensource 6 \ - && cd cross-gcc-packages-amd64/cross-gcc-6-arm64 \ - && ln -fs /usr/share/zoneinfo/America/Toronto /etc/localtime \ - && apt-get install -y gcc-6-source libstdc++6-arm64-cross binutils-aarch64-linux-gnu bison flex libtool gdb sharutils netbase libcloog-isl-dev libmpc-dev libmpfr-dev libgmp-dev systemtap-sdt-dev autogen expect chrpath zlib1g-dev zip libc6-dev:arm64 linux-libc-dev:arm64 libgcc1:arm64 libcurl4-openssl-dev:arm64 libfontconfig1-dev:arm64 libfreetype6-dev:arm64 liblttng-ust0:arm64 libstdc++6:arm64 libssl-dev:arm64 + && export CODENAME="$( lsb_release -c -s )" \ + && echo "deb [arch=amd64] http://archive.ubuntu.com/ubuntu/ ${CODENAME} main restricted universe multiverse" >>/etc/apt/sources.list.d/amd64.list \ + && echo "deb [arch=amd64] http://archive.ubuntu.com/ubuntu/ ${CODENAME}-updates main restricted universe multiverse" >>/etc/apt/sources.list.d/amd64.list \ + && echo "deb [arch=amd64] http://archive.ubuntu.com/ubuntu/ ${CODENAME}-backports main restricted universe multiverse" >>/etc/apt/sources.list.d/amd64.list \ + && echo "deb [arch=amd64] http://archive.ubuntu.com/ubuntu/ ${CODENAME}-security main restricted universe multiverse" >>/etc/apt/sources.list.d/amd64.list \ + && echo "deb [arch=arm64] http://ports.ubuntu.com/ ${CODENAME} main restricted universe multiverse" >>/etc/apt/sources.list.d/arm64.list \ + && echo "deb [arch=arm64] http://ports.ubuntu.com/ ${CODENAME}-updates main restricted universe multiverse" >>/etc/apt/sources.list.d/arm64.list \ + && echo "deb [arch=arm64] http://ports.ubuntu.com/ ${CODENAME}-backports main restricted universe multiverse" >>/etc/apt/sources.list.d/arm64.list \ + && echo "deb [arch=arm64] http://ports.ubuntu.com/ ${CODENAME}-security main restricted universe multiverse" >>/etc/apt/sources.list.d/arm64.list \ + && dpkg --add-architecture arm64 \ + && apt-get update -yqq \ + && apt-get install -yqq --no-install-recommends cross-gcc-dev \ + && TARGET_LIST="arm64" cross-gcc-gensource 6 \ + && cd cross-gcc-packages-amd64/cross-gcc-6-arm64 \ + && ln -fs /usr/share/zoneinfo/America/Toronto /etc/localtime \ + && apt-get install -yqq --no-install-recommends \ + gcc-6-source libstdc++6-arm64-cross binutils-aarch64-linux-gnu \ + bison flex libtool gdb sharutils netbase libcloog-isl-dev libmpc-dev \ + libmpfr-dev libgmp-dev systemtap-sdt-dev autogen expect chrpath zlib1g-dev \ + zip libc6-dev:arm64 linux-libc-dev:arm64 libgcc1:arm64 libcurl4-openssl-dev:arm64 \ + libfontconfig1-dev:arm64 libfreetype6-dev:arm64 liblttng-ust0:arm64 libstdc++6:arm64 libssl-dev:arm64 # Link to build script RUN ln -sf ${SOURCE_DIR}/deployment/build.ubuntu.arm64 /build.sh diff --git a/deployment/Dockerfile.ubuntu.armhf b/deployment/Dockerfile.ubuntu.armhf index 1187f37b95..154388148c 100644 --- a/deployment/Dockerfile.ubuntu.armhf +++ b/deployment/Dockerfile.ubuntu.armhf @@ -2,7 +2,6 @@ FROM ubuntu:bionic # Docker build arguments ARG SOURCE_DIR=/jellyfin ARG ARTIFACT_DIR=/dist -ARG SDK_VERSION=5.0 # Docker run environment ENV SOURCE_DIR=/jellyfin ENV ARTIFACT_DIR=/dist @@ -11,34 +10,40 @@ ENV ARCH=amd64 ENV IS_DOCKER=YES # Prepare Debian build environment -RUN apt-get update \ - && apt-get install -y apt-transport-https debhelper gnupg wget devscripts mmv +RUN apt-get update -yqq \ + && apt-get install -yqq --no-install-recommends \ + apt-transport-https debhelper gnupg wget ca-certificates devscripts \ + mmv build-essential lsb-release # Install dotnet repository -# https://dotnet.microsoft.com/download/linux-package-manager/debian9/sdk-current -RUN wget https://download.visualstudio.microsoft.com/download/pr/e1c236ec-c392-4eaa-a846-c600c82bb7f6/b13bd8b69f875f87cf83fc6f5457bcdf/dotnet-sdk-5.0.301-linux-x64.tar.gz -O dotnet-sdk.tar.gz \ - && mkdir -p dotnet-sdk \ - && tar -xzf dotnet-sdk.tar.gz -C dotnet-sdk \ - && ln -s $( pwd )/dotnet-sdk/dotnet /usr/bin/dotnet +RUN wget -q https://download.visualstudio.microsoft.com/download/pr/20283373-1d83-4879-8278-0afb7fd4035e/56f204f174743b29a656499ad0fc93c3/dotnet-sdk-6.0.100-rc.2.21505.57-linux-x64.tar.gz -O dotnet-sdk.tar.gz \ + && mkdir -p dotnet-sdk \ + && tar -xzf dotnet-sdk.tar.gz -C dotnet-sdk \ + && ln -s $( pwd )/dotnet-sdk/dotnet /usr/bin/dotnet # Prepare the cross-toolchain RUN rm /etc/apt/sources.list \ - && export CODENAME="$( lsb_release -c -s )" \ - && echo "deb [arch=amd64] http://archive.ubuntu.com/ubuntu/ ${CODENAME} main restricted universe multiverse" >>/etc/apt/sources.list.d/amd64.list \ - && echo "deb [arch=amd64] http://archive.ubuntu.com/ubuntu/ ${CODENAME}-updates main restricted universe multiverse" >>/etc/apt/sources.list.d/amd64.list \ - && echo "deb [arch=amd64] http://archive.ubuntu.com/ubuntu/ ${CODENAME}-backports main restricted universe multiverse" >>/etc/apt/sources.list.d/amd64.list \ - && echo "deb [arch=amd64] http://archive.ubuntu.com/ubuntu/ ${CODENAME}-security main restricted universe multiverse" >>/etc/apt/sources.list.d/amd64.list \ - && echo "deb [arch=armhf] http://ports.ubuntu.com/ ${CODENAME} main restricted universe multiverse" >>/etc/apt/sources.list.d/armhf.list \ - && echo "deb [arch=armhf] http://ports.ubuntu.com/ ${CODENAME}-updates main restricted universe multiverse" >>/etc/apt/sources.list.d/armhf.list \ - && echo "deb [arch=armhf] http://ports.ubuntu.com/ ${CODENAME}-backports main restricted universe multiverse" >>/etc/apt/sources.list.d/armhf.list \ - && echo "deb [arch=armhf] http://ports.ubuntu.com/ ${CODENAME}-security main restricted universe multiverse" >>/etc/apt/sources.list.d/armhf.list \ - && dpkg --add-architecture armhf \ - && apt-get update \ - && apt-get install -y cross-gcc-dev \ - && TARGET_LIST="armhf" cross-gcc-gensource 6 \ - && cd cross-gcc-packages-amd64/cross-gcc-6-armhf \ - && ln -fs /usr/share/zoneinfo/America/Toronto /etc/localtime \ - && apt-get install -y gcc-6-source libstdc++6-armhf-cross binutils-arm-linux-gnueabihf bison flex libtool gdb sharutils netbase libcloog-isl-dev libmpc-dev libmpfr-dev libgmp-dev systemtap-sdt-dev autogen expect chrpath zlib1g-dev zip libc6-dev:armhf linux-libc-dev:armhf libgcc1:armhf libcurl4-openssl-dev:armhf libfontconfig1-dev:armhf libfreetype6-dev:armhf liblttng-ust0:armhf libstdc++6:armhf libssl-dev:armhf + && export CODENAME="$( lsb_release -c -s )" \ + && echo "deb [arch=amd64] http://archive.ubuntu.com/ubuntu/ ${CODENAME} main restricted universe multiverse" >>/etc/apt/sources.list.d/amd64.list \ + && echo "deb [arch=amd64] http://archive.ubuntu.com/ubuntu/ ${CODENAME}-updates main restricted universe multiverse" >>/etc/apt/sources.list.d/amd64.list \ + && echo "deb [arch=amd64] http://archive.ubuntu.com/ubuntu/ ${CODENAME}-backports main restricted universe multiverse" >>/etc/apt/sources.list.d/amd64.list \ + && echo "deb [arch=amd64] http://archive.ubuntu.com/ubuntu/ ${CODENAME}-security main restricted universe multiverse" >>/etc/apt/sources.list.d/amd64.list \ + && echo "deb [arch=armhf] http://ports.ubuntu.com/ ${CODENAME} main restricted universe multiverse" >>/etc/apt/sources.list.d/armhf.list \ + && echo "deb [arch=armhf] http://ports.ubuntu.com/ ${CODENAME}-updates main restricted universe multiverse" >>/etc/apt/sources.list.d/armhf.list \ + && echo "deb [arch=armhf] http://ports.ubuntu.com/ ${CODENAME}-backports main restricted universe multiverse" >>/etc/apt/sources.list.d/armhf.list \ + && echo "deb [arch=armhf] http://ports.ubuntu.com/ ${CODENAME}-security main restricted universe multiverse" >>/etc/apt/sources.list.d/armhf.list \ + && dpkg --add-architecture armhf \ + && apt-get update -yqq \ + && apt-get install -yqq cross-gcc-dev \ + && TARGET_LIST="armhf" cross-gcc-gensource 6 \ + && cd cross-gcc-packages-amd64/cross-gcc-6-armhf \ + && ln -fs /usr/share/zoneinfo/America/Toronto /etc/localtime \ + && apt-get install -yqq --no-install-recommends \ + gcc-6-source libstdc++6-armhf-cross binutils-arm-linux-gnueabihf \ + bison flex libtool gdb sharutils netbase libcloog-isl-dev libmpc-dev \ + libmpfr-dev libgmp-dev systemtap-sdt-dev autogen expect chrpath zlib1g-dev \ + zip libc6-dev:armhf linux-libc-dev:armhf libgcc1:armhf libcurl4-openssl-dev:armhf \ + libfontconfig1-dev:armhf libfreetype6-dev:armhf liblttng-ust0:armhf libstdc++6:armhf libssl-dev:armhf # Link to build script RUN ln -sf ${SOURCE_DIR}/deployment/build.debian.armhf /build.sh diff --git a/deployment/Dockerfile.windows.amd64 b/deployment/Dockerfile.windows.amd64 index 8b2361f0bb..b9543a7c91 100644 --- a/deployment/Dockerfile.windows.amd64 +++ b/deployment/Dockerfile.windows.amd64 @@ -1,8 +1,7 @@ -FROM debian:10 +FROM mcr.microsoft.com/dotnet/sdk:6.0-bullseye-slim # Docker build arguments ARG SOURCE_DIR=/jellyfin ARG ARTIFACT_DIR=/dist -ARG SDK_VERSION=5.0 # Docker run environment ENV SOURCE_DIR=/jellyfin ENV ARTIFACT_DIR=/dist @@ -10,15 +9,11 @@ ENV DEB_BUILD_OPTIONS=noddebs ENV IS_DOCKER=YES # Prepare Debian build environment -RUN apt-get update \ - && apt-get install -y apt-transport-https debhelper gnupg wget devscripts mmv libc6-dev libcurl4-openssl-dev libfontconfig1-dev libfreetype6-dev libssl-dev libssl1.1 liblttng-ust0 zip - -# Install dotnet repository -# https://dotnet.microsoft.com/download/linux-package-manager/debian9/sdk-current -RUN wget https://download.visualstudio.microsoft.com/download/pr/e1c236ec-c392-4eaa-a846-c600c82bb7f6/b13bd8b69f875f87cf83fc6f5457bcdf/dotnet-sdk-5.0.301-linux-x64.tar.gz -O dotnet-sdk.tar.gz \ - && mkdir -p dotnet-sdk \ - && tar -xzf dotnet-sdk.tar.gz -C dotnet-sdk \ - && ln -s $( pwd )/dotnet-sdk/dotnet /usr/bin/dotnet +RUN apt-get update -yqq \ + && apt-get install -yqq --no-install-recommends \ + apt-transport-https debhelper gnupg devscripts unzip \ + mmv libcurl4-openssl-dev libfontconfig1-dev \ + libfreetype6-dev libssl-dev libssl1.1 liblttng-ust0 zip # Link to docker-build script RUN ln -sf ${SOURCE_DIR}/deployment/build.windows.amd64 /build.sh diff --git a/deployment/build.centos.amd64 b/deployment/build.centos.amd64 index 69f0cadcfe..bfdc6e591e 100755 --- a/deployment/build.centos.amd64 +++ b/deployment/build.centos.amd64 @@ -8,6 +8,16 @@ set -o xtrace # Move to source directory pushd ${SOURCE_DIR} +if [[ ${IS_DOCKER} == YES ]]; then + # Remove BuildRequires for dotnet-sdk-6.0, since it's installed manually + pushd fedora + + cp -a jellyfin.spec /tmp/spec.orig + sed -i 's/BuildRequires: dotnet/# BuildRequires: dotnet/' jellyfin.spec + + popd +fi + # Modify changelog to unstable configuration if IS_UNSTABLE if [[ ${IS_UNSTABLE} == 'yes' ]]; then pushd fedora @@ -37,4 +47,13 @@ fi rm -f fedora/jellyfin*.tar.gz +if [[ ${IS_DOCKER} == YES ]]; then + pushd fedora + + cp -a /tmp/spec.orig jellyfin.spec + chown -Rc $(stat -c %u:%g ${ARTIFACT_DIR}) ${ARTIFACT_DIR} + + popd +fi + popd diff --git a/deployment/build.debian.amd64 b/deployment/build.debian.amd64 index 145e28d871..b2bbf9c29e 100755 --- a/deployment/build.debian.amd64 +++ b/deployment/build.debian.amd64 @@ -9,9 +9,9 @@ set -o xtrace pushd ${SOURCE_DIR} if [[ ${IS_DOCKER} == YES ]]; then - # Remove build-dep for dotnet-sdk-5.0, since it's installed manually + # Remove build-dep for dotnet-sdk-6.0, since it's installed manually cp -a debian/control /tmp/control.orig - sed -i '/dotnet-sdk-5.0,/d' debian/control + sed -i '/dotnet-sdk-6.0,/d' debian/control fi # Modify changelog to unstable configuration if IS_UNSTABLE diff --git a/deployment/build.debian.arm64 b/deployment/build.debian.arm64 index 5699133a01..02f84471e6 100755 --- a/deployment/build.debian.arm64 +++ b/deployment/build.debian.arm64 @@ -9,9 +9,9 @@ set -o xtrace pushd ${SOURCE_DIR} if [[ ${IS_DOCKER} == YES ]]; then - # Remove build-dep for dotnet-sdk-5.0, since it's installed manually + # Remove build-dep for dotnet-sdk-6.0, since it's installed manually cp -a debian/control /tmp/control.orig - sed -i '/dotnet-sdk-5.0,/d' debian/control + sed -i '/dotnet-sdk-6.0,/d' debian/control fi # Modify changelog to unstable configuration if IS_UNSTABLE diff --git a/deployment/build.debian.armhf b/deployment/build.debian.armhf index 20af2ddfbe..92779cb594 100755 --- a/deployment/build.debian.armhf +++ b/deployment/build.debian.armhf @@ -9,9 +9,9 @@ set -o xtrace pushd ${SOURCE_DIR} if [[ ${IS_DOCKER} == YES ]]; then - # Remove build-dep for dotnet-sdk-5.0, since it's installed manually + # Remove build-dep for dotnet-sdk-6.0, since it's installed manually cp -a debian/control /tmp/control.orig - sed -i '/dotnet-sdk-5.0,/d' debian/control + sed -i '/dotnet-sdk-6.0,/d' debian/control fi # Modify changelog to unstable configuration if IS_UNSTABLE diff --git a/deployment/build.fedora.amd64 b/deployment/build.fedora.amd64 index 2c7bff5068..23c5ed86a2 100755 --- a/deployment/build.fedora.amd64 +++ b/deployment/build.fedora.amd64 @@ -8,6 +8,16 @@ set -o xtrace # Move to source directory pushd ${SOURCE_DIR} +if [[ ${IS_DOCKER} == YES ]]; then + # Remove BuildRequires for dotnet-sdk-6.0, since it's installed manually + pushd fedora + + cp -a jellyfin.spec /tmp/spec.orig + sed -i 's/BuildRequires: dotnet/# BuildRequires: dotnet/' jellyfin.spec + + popd +fi + # Modify changelog to unstable configuration if IS_UNSTABLE if [[ ${IS_UNSTABLE} == 'yes' ]]; then pushd fedora @@ -37,4 +47,13 @@ fi rm -f fedora/jellyfin*.tar.gz +if [[ ${IS_DOCKER} == YES ]]; then + pushd fedora + + cp -a /tmp/spec.orig jellyfin.spec + chown -Rc $(stat -c %u:%g ${ARTIFACT_DIR}) ${ARTIFACT_DIR} + + popd +fi + popd diff --git a/deployment/build.portable b/deployment/build.portable index ea40ade5d9..a6c7418810 100755 --- a/deployment/build.portable +++ b/deployment/build.portable @@ -16,7 +16,7 @@ else fi # Build archives -dotnet publish Jellyfin.Server --configuration Release --output dist/jellyfin-server_${version}/ "-p:DebugSymbols=false;DebugType=none;UseAppHost=true" +dotnet publish Jellyfin.Server --configuration Release --output dist/jellyfin-server_${version}/ "-p:DebugSymbols=false;DebugType=none;UseAppHost=false" tar -czf jellyfin-server_${version}_portable.tar.gz -C dist jellyfin-server_${version} rm -rf dist/jellyfin-server_${version} diff --git a/deployment/build.ubuntu.amd64 b/deployment/build.ubuntu.amd64 index 0c29286c02..c36978c9e3 100755 --- a/deployment/build.ubuntu.amd64 +++ b/deployment/build.ubuntu.amd64 @@ -9,9 +9,9 @@ set -o xtrace pushd ${SOURCE_DIR} if [[ ${IS_DOCKER} == YES ]]; then - # Remove build-dep for dotnet-sdk-5.0, since it's installed manually + # Remove build-dep for dotnet-sdk-6.0, since it's installed manually cp -a debian/control /tmp/control.orig - sed -i '/dotnet-sdk-5.0,/d' debian/control + sed -i '/dotnet-sdk-6.0,/d' debian/control fi # Modify changelog to unstable configuration if IS_UNSTABLE diff --git a/deployment/build.ubuntu.arm64 b/deployment/build.ubuntu.arm64 index 65d67f80f7..76d51e321f 100755 --- a/deployment/build.ubuntu.arm64 +++ b/deployment/build.ubuntu.arm64 @@ -9,9 +9,9 @@ set -o xtrace pushd ${SOURCE_DIR} if [[ ${IS_DOCKER} == YES ]]; then - # Remove build-dep for dotnet-sdk-5.0, since it's installed manually + # Remove build-dep for dotnet-sdk-6.0, since it's installed manually cp -a debian/control /tmp/control.orig - sed -i '/dotnet-sdk-5.0,/d' debian/control + sed -i '/dotnet-sdk-6.0,/d' debian/control fi # Modify changelog to unstable configuration if IS_UNSTABLE diff --git a/deployment/build.ubuntu.armhf b/deployment/build.ubuntu.armhf index 370370abc1..0ff5ab0662 100755 --- a/deployment/build.ubuntu.armhf +++ b/deployment/build.ubuntu.armhf @@ -9,9 +9,9 @@ set -o xtrace pushd ${SOURCE_DIR} if [[ ${IS_DOCKER} == YES ]]; then - # Remove build-dep for dotnet-sdk-5.0, since it's installed manually + # Remove build-dep for dotnet-sdk-6.0, since it's installed manually cp -a debian/control /tmp/control.orig - sed -i '/dotnet-sdk-5.0,/d' debian/control + sed -i '/dotnet-sdk-6.0,/d' debian/control fi # Modify changelog to unstable configuration if IS_UNSTABLE diff --git a/fedora/jellyfin.service b/fedora/jellyfin.service index b092ebf2f0..f706b0ad3f 100644 --- a/fedora/jellyfin.service +++ b/fedora/jellyfin.service @@ -1,5 +1,5 @@ [Unit] -After=network.target +After=network-online.target Description=Jellyfin is a free software media system that puts you in control of managing and streaming your media. [Service] diff --git a/fedora/jellyfin.spec b/fedora/jellyfin.spec index 928fe590f0..47dee7c13f 100644 --- a/fedora/jellyfin.spec +++ b/fedora/jellyfin.spec @@ -27,7 +27,7 @@ BuildRequires: libcurl-devel, fontconfig-devel, freetype-devel, openssl-devel, # Requirements not packaged in main repos # COPR @dotnet-sig/dotnet or # https://packages.microsoft.com/rhel/7/prod/ -BuildRequires: dotnet-runtime-5.0, dotnet-sdk-5.0 +BuildRequires: dotnet-runtime-6.0, dotnet-sdk-6.0 Requires: %{name}-server = %{version}-%{release}, %{name}-web = %{version}-%{release} # Disable Automatic Dependency Processing AutoReqProv: no @@ -40,7 +40,7 @@ Jellyfin is a free software media system that puts you in control of managing an Summary: The Free Software Media System Server backend Requires(pre): shadow-utils Requires: ffmpeg -Requires: libcurl, fontconfig, freetype, openssl, glibc, libicu, at +Requires: libcurl, fontconfig, freetype, openssl, glibc, libicu, at, sudo %description server The Jellyfin media server backend. diff --git a/fedora/jellyfin.sudoers b/fedora/jellyfin.sudoers index dd245af4b8..57a9e7b671 100644 --- a/fedora/jellyfin.sudoers +++ b/fedora/jellyfin.sudoers @@ -1,8 +1,7 @@ # Allow jellyfin group to start, stop and restart itself -Cmnd_Alias RESTARTSERVER_SYSTEMD = /usr/bin/systemctl restart jellyfin, /bin/systemctl restart jellyfin -Cmnd_Alias STARTSERVER_SYSTEMD = /usr/bin/systemctl start jellyfin, /bin/systemctl start jellyfin -Cmnd_Alias STOPSERVER_SYSTEMD = /usr/bin/systemctl stop jellyfin, /bin/systemctl stop jellyfin - +Cmnd_Alias RESTARTSERVER_SYSTEMD = /usr/bin/systemd-run systemctl restart jellyfin +Cmnd_Alias STARTSERVER_SYSTEMD = /usr/bin/systemd-run systemctl start jellyfin +Cmnd_Alias STOPSERVER_SYSTEMD = /usr/bin/systemd-run systemctl stop jellyfin jellyfin ALL=(ALL) NOPASSWD: RESTARTSERVER_SYSTEMD jellyfin ALL=(ALL) NOPASSWD: STARTSERVER_SYSTEMD diff --git a/fedora/restart.sh b/fedora/restart.sh index 34fce06708..4847b918be 100755 --- a/fedora/restart.sh +++ b/fedora/restart.sh @@ -11,23 +11,43 @@ # # This script is used by the Debian/Ubuntu/Fedora/CentOS packages. -get_service_command() { - for command in systemctl service; do - if which $command &>/dev/null; then - echo $command && return +# This is the Right Way(tm) to check if we are booted with +# systemd, according to sd_booted(3) +if [ -d /run/systemd/system ]; then + cmd=systemctl +else + # Everything else is really hard to figure out, so we just use + # service(8) if it's available - that works with most init + # systems/distributions I know of, including FreeBSD + if type service >/dev/null 2>&1; then + cmd=service + else + # If even service(8) isn't available, we just try /etc/init.d + # and hope for the best + if [ -d /etc/init.d ]; then + cmd=sysv + else + echo "Unable to detect a way to restart Jellyfin; bailing out" 1>&2 + echo "Please report this bug to https://github.com/jellyfin/jellyfin/issues" 1>&2 + exit 1 fi - done - echo "sysv" -} + fi +fi + +if type sudo >/dev/null 2>&1; then + sudo_command=sudo +else + sudo_command= +fi -cmd="$( get_service_command )" echo "Detected service control platform '$cmd'; using it to restart Jellyfin..." case $cmd in 'systemctl') - echo "sleep 0.5; /usr/bin/sudo $( which systemctl ) start jellyfin" | at now + # Without systemd-run here, `jellyfin.service`'s shutdown terminates this process too + $sudo_command systemd-run systemctl restart jellyfin ;; 'service') - echo "sleep 0.5; /usr/bin/sudo $( which service ) jellyfin start" | at now + echo "sleep 0.5; $sudo_command service jellyfin start" | at now ;; 'sysv') echo "sleep 0.5; /usr/bin/sudo /etc/init.d/jellyfin start" | at now diff --git a/fuzz/Emby.Server.Implementations.Fuzz/Emby.Server.Implementations.Fuzz.csproj b/fuzz/Emby.Server.Implementations.Fuzz/Emby.Server.Implementations.Fuzz.csproj index 791cb140db..6abdb77342 100644 --- a/fuzz/Emby.Server.Implementations.Fuzz/Emby.Server.Implementations.Fuzz.csproj +++ b/fuzz/Emby.Server.Implementations.Fuzz/Emby.Server.Implementations.Fuzz.csproj @@ -12,6 +12,13 @@ + + + + + + + diff --git a/fuzz/Emby.Server.Implementations.Fuzz/Program.cs b/fuzz/Emby.Server.Implementations.Fuzz/Program.cs index a4a6f5f54d..03b2964948 100644 --- a/fuzz/Emby.Server.Implementations.Fuzz/Program.cs +++ b/fuzz/Emby.Server.Implementations.Fuzz/Program.cs @@ -1,5 +1,12 @@ using System; +using AutoFixture; +using AutoFixture.AutoMoq; +using Emby.Server.Implementations.Data; using Emby.Server.Implementations.Library; +using MediaBrowser.Controller; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Model.Entities; +using Moq; using SharpFuzz; namespace Emby.Server.Implementations.Fuzz @@ -11,6 +18,7 @@ namespace Emby.Server.Implementations.Fuzz switch (args[0]) { case "PathExtensions.TryReplaceSubPath": Run(PathExtensions_TryReplaceSubPath); return; + case "SqliteItemRepository.ItemImageInfoFromValueString": Run(SqliteItemRepository_ItemImageInfoFromValueString); return; default: throw new ArgumentException($"Unknown fuzzing function: {args[0]}"); } } @@ -28,5 +36,27 @@ namespace Emby.Server.Implementations.Fuzz _ = PathExtensions.TryReplaceSubPath(parts[0], parts[1], parts[2], out _); } + + private static void SqliteItemRepository_ItemImageInfoFromValueString(string data) + { + var sqliteItemRepository = MockSqliteItemRepository(); + sqliteItemRepository.ItemImageInfoFromValueString(data); + } + + private static SqliteItemRepository MockSqliteItemRepository() + { + const string VirtualMetaDataPath = "%MetadataPath%"; + const string MetaDataPath = "/meta/data/path"; + + var appHost = new Mock(); + appHost.Setup(x => x.ExpandVirtualPath(It.IsAny())) + .Returns((string x) => x.Replace(VirtualMetaDataPath, MetaDataPath, StringComparison.Ordinal)); + appHost.Setup(x => x.ReverseVirtualPath(It.IsAny())) + .Returns((string x) => x.Replace(MetaDataPath, VirtualMetaDataPath, StringComparison.Ordinal)); + + IFixture fixture = new Fixture().Customize(new AutoMoqCustomization { ConfigureMembers = true }); + fixture.Inject(appHost); + return fixture.Create(); + } } } diff --git a/fuzz/Emby.Server.Implementations.Fuzz/Testcases/SqliteItemRepository.ItemImageInfoFromValueString/test1.txt b/fuzz/Emby.Server.Implementations.Fuzz/Testcases/SqliteItemRepository.ItemImageInfoFromValueString/test1.txt new file mode 100644 index 0000000000..1b0115882f --- /dev/null +++ b/fuzz/Emby.Server.Implementations.Fuzz/Testcases/SqliteItemRepository.ItemImageInfoFromValueString/test1.txt @@ -0,0 +1 @@ +/mnt/series/Family Guy/Season 1/Family Guy - S01E01-thumb.jpg*637452096478512963*Primary*1920*1080*WjQbtJtSO8nhNZ%L_Io#R/oaS6o}-;adXAoIn7j[%hW9s:WGw[nN diff --git a/jellyfin.ruleset b/jellyfin.ruleset index 44bc343690..dfb9911704 100644 --- a/jellyfin.ruleset +++ b/jellyfin.ruleset @@ -1,9 +1,6 @@ - - - @@ -12,6 +9,8 @@ + + @@ -39,22 +38,35 @@ + + + + + + + + + + + + + - - @@ -82,5 +92,7 @@ + + diff --git a/src/Jellyfin.Extensions/DictionaryExtensions.cs b/src/Jellyfin.Extensions/DictionaryExtensions.cs new file mode 100644 index 0000000000..5bb828d016 --- /dev/null +++ b/src/Jellyfin.Extensions/DictionaryExtensions.cs @@ -0,0 +1,64 @@ +using System.Collections.Generic; + +namespace Jellyfin.Extensions +{ + /// + /// Static extensions for the interface. + /// + public static class DictionaryExtensions + { + /// + /// Gets a string from a string dictionary, checking all keys sequentially, + /// stopping at the first key that returns a result that's neither null nor blank. + /// + /// The dictionary. + /// The first checked key. + /// System.String. + public static string? GetFirstNotNullNorWhiteSpaceValue(this IReadOnlyDictionary dictionary, string key1) + { + return dictionary.GetFirstNotNullNorWhiteSpaceValue(key1, string.Empty, string.Empty); + } + + /// + /// Gets a string from a string dictionary, checking all keys sequentially, + /// stopping at the first key that returns a result that's neither null nor blank. + /// + /// The dictionary. + /// The first checked key. + /// The second checked key. + /// System.String. + public static string? GetFirstNotNullNorWhiteSpaceValue(this IReadOnlyDictionary dictionary, string key1, string key2) + { + return dictionary.GetFirstNotNullNorWhiteSpaceValue(key1, key2, string.Empty); + } + + /// + /// Gets a string from a string dictionary, checking all keys sequentially, + /// stopping at the first key that returns a result that's neither null nor blank. + /// + /// The dictionary. + /// The first checked key. + /// The second checked key. + /// The third checked key. + /// System.String. + public static string? GetFirstNotNullNorWhiteSpaceValue(this IReadOnlyDictionary dictionary, string key1, string key2, string key3) + { + if (dictionary.TryGetValue(key1, out var val) && !string.IsNullOrWhiteSpace(val)) + { + return val; + } + + if (!string.IsNullOrEmpty(key2) && dictionary.TryGetValue(key2, out val) && !string.IsNullOrWhiteSpace(val)) + { + return val; + } + + if (!string.IsNullOrEmpty(key3) && dictionary.TryGetValue(key3, out val) && !string.IsNullOrWhiteSpace(val)) + { + return val; + } + + return null; + } + } +} diff --git a/src/Jellyfin.Extensions/Jellyfin.Extensions.csproj b/src/Jellyfin.Extensions/Jellyfin.Extensions.csproj index f343be1e31..3d9538d1bb 100644 --- a/src/Jellyfin.Extensions/Jellyfin.Extensions.csproj +++ b/src/Jellyfin.Extensions/Jellyfin.Extensions.csproj @@ -1,21 +1,28 @@ - net5.0 + net6.0 false true - true - enable - AllEnabledByDefault - ../../jellyfin.ruleset + true + true + true + snupkg Jellyfin Contributors + Jellyfin.Extensions + 10.8.0 https://github.com/jellyfin/jellyfin GPL-3.0-only + + + $(AllowedOutputExtensionsInPackageBuildOutputFolder);.pdb + + diff --git a/src/Jellyfin.Extensions/Json/Converters/JsonCommaDelimitedArrayConverter.cs b/src/Jellyfin.Extensions/Json/Converters/JsonCommaDelimitedArrayConverter.cs index 44980ec02f..0d0cc2d063 100644 --- a/src/Jellyfin.Extensions/Json/Converters/JsonCommaDelimitedArrayConverter.cs +++ b/src/Jellyfin.Extensions/Json/Converters/JsonCommaDelimitedArrayConverter.cs @@ -1,9 +1,4 @@ -using System; -using System.ComponentModel; -using System.Text.Json; -using System.Text.Json.Serialization; - -namespace Jellyfin.Extensions.Json.Converters +namespace Jellyfin.Extensions.Json.Converters { /// /// Convert comma delimited string to array of type. diff --git a/src/Jellyfin.Extensions/Json/Converters/JsonDelimitedArrayConverter.cs b/src/Jellyfin.Extensions/Json/Converters/JsonDelimitedArrayConverter.cs index c39805aa35..51b955145f 100644 --- a/src/Jellyfin.Extensions/Json/Converters/JsonDelimitedArrayConverter.cs +++ b/src/Jellyfin.Extensions/Json/Converters/JsonDelimitedArrayConverter.cs @@ -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/JsonPipeDelimitedArrayConverter.cs b/src/Jellyfin.Extensions/Json/Converters/JsonPipeDelimitedArrayConverter.cs index e3e492e24d..6e59fe4641 100644 --- a/src/Jellyfin.Extensions/Json/Converters/JsonPipeDelimitedArrayConverter.cs +++ b/src/Jellyfin.Extensions/Json/Converters/JsonPipeDelimitedArrayConverter.cs @@ -1,9 +1,4 @@ -using System; -using System.ComponentModel; -using System.Text.Json; -using System.Text.Json.Serialization; - -namespace Jellyfin.Extensions.Json.Converters +namespace Jellyfin.Extensions.Json.Converters { /// /// Convert Pipe delimited string to array of type. diff --git a/src/Jellyfin.Extensions/ReadOnlyListExtension.cs b/src/Jellyfin.Extensions/ReadOnlyListExtension.cs new file mode 100644 index 0000000000..7785cfb495 --- /dev/null +++ b/src/Jellyfin.Extensions/ReadOnlyListExtension.cs @@ -0,0 +1,61 @@ +using System; +using System.Collections.Generic; + +namespace Jellyfin.Extensions +{ + /// + /// Static extensions for the interface. + /// + public static class ReadOnlyListExtension + { + /// + /// Finds the index of the desired item. + /// + /// The source list. + /// The value to fine. + /// The type of item to find. + /// Index if found, else -1. + public static int IndexOf(this IReadOnlyList source, T value) + { + if (source is IList list) + { + return list.IndexOf(value); + } + + for (int i = 0; i < source.Count; i++) + { + if (Equals(value, source[i])) + { + return i; + } + } + + return -1; + } + + /// + /// Finds the index of the predicate. + /// + /// The source list. + /// The value to find. + /// The type of item to find. + /// Index if found, else -1. + public static int FindIndex(this IReadOnlyList source, Predicate match) + { + if (source is List list) + { + return list.FindIndex(match); + } + + for (int i = 0; i < source.Count; i++) + { + if (match(source[i])) + { + return i; + } + } + + return -1; + } + } +} diff --git a/src/Jellyfin.Extensions/ShuffleExtensions.cs b/src/Jellyfin.Extensions/ShuffleExtensions.cs index 4e481983f3..33c492053a 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/StringExtensions.cs b/src/Jellyfin.Extensions/StringExtensions.cs index acc695ed2f..3a77072539 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 de03aa5f5b..cd03958b66 100644 --- a/tests/Jellyfin.Api.Tests/Auth/CustomAuthenticationHandlerTests.cs +++ b/tests/Jellyfin.Api.Tests/Auth/CustomAuthenticationHandlerTests.cs @@ -136,7 +136,7 @@ namespace Jellyfin.Api.Tests.Auth _jellyfinAuthServiceMock.Setup( a => a.Authenticate( It.IsAny())) - .Returns(authorizationInfo); + .Returns(Task.FromResult(authorizationInfo)); return authorizationInfo; } diff --git a/tests/Jellyfin.Api.Tests/Auth/DefaultAuthorizationPolicy/DefaultAuthorizationHandlerTests.cs b/tests/Jellyfin.Api.Tests/Auth/DefaultAuthorizationPolicy/DefaultAuthorizationHandlerTests.cs index a62fd8d5ae..23c51999fa 100644 --- a/tests/Jellyfin.Api.Tests/Auth/DefaultAuthorizationPolicy/DefaultAuthorizationHandlerTests.cs +++ b/tests/Jellyfin.Api.Tests/Auth/DefaultAuthorizationPolicy/DefaultAuthorizationHandlerTests.cs @@ -4,6 +4,7 @@ using AutoFixture; using AutoFixture.AutoMoq; using Jellyfin.Api.Auth.DefaultAuthorizationPolicy; using Jellyfin.Api.Constants; +using Jellyfin.Server.Implementations.Security; using MediaBrowser.Common.Configuration; using MediaBrowser.Controller.Library; using Microsoft.AspNetCore.Authorization; @@ -49,5 +50,61 @@ namespace Jellyfin.Api.Tests.Auth.DefaultAuthorizationPolicy await _sut.HandleAsync(context); Assert.True(context.HasSucceeded); } + + [Theory] + [MemberData(nameof(GetParts_ValidAuthHeader_Success_Data))] + public void GetParts_ValidAuthHeader_Success(string input, Dictionary parts) + { + var dict = AuthorizationContext.GetParts(input); + foreach (var (key, value) in parts) + { + Assert.Equal(dict[key], value); + } + } + + private static TheoryData> GetParts_ValidAuthHeader_Success_Data() + { + var data = new TheoryData>(); + + data.Add( + "x=\"123,123\",y=\"123\"", + new Dictionary + { + { "x", "123,123" }, + { "y", "123" } + }); + + data.Add( + "x=\"123,123\", y=\"123\",z=\"'hi'\"", + new Dictionary + { + { "x", "123,123" }, + { "y", "123" }, + { "z", "'hi'" } + }); + + data.Add( + "x=\"ab\"", + new Dictionary + { + { "x", "ab" } + }); + + data.Add( + "param=Hörbücher", + new Dictionary + { + { "param", "Hörbücher" } + }); + + data.Add( + "param=%22%Hörbücher", + new Dictionary + { + { "param", "\"%Hörbücher" } + }); + + return data; + } } } diff --git a/tests/Jellyfin.Api.Tests/Controllers/DynamicHlsControllerTests.cs b/tests/Jellyfin.Api.Tests/Controllers/DynamicHlsControllerTests.cs index 1170838156..1f06e8fde6 100644 --- a/tests/Jellyfin.Api.Tests/Controllers/DynamicHlsControllerTests.cs +++ b/tests/Jellyfin.Api.Tests/Controllers/DynamicHlsControllerTests.cs @@ -1,13 +1,5 @@ using System; -using System.Collections.Generic; -using AutoFixture; -using AutoFixture.AutoMoq; using Jellyfin.Api.Controllers; -using Jellyfin.Api.Helpers; -using Jellyfin.Api.Models.StreamingDtos; -using MediaBrowser.Controller.Library; -using MediaBrowser.Controller.MediaEncoding; -using Moq; using Xunit; namespace Jellyfin.Api.Tests.Controllers @@ -26,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 97e441b1dc..4ba7e1d2ff 100644 --- a/tests/Jellyfin.Api.Tests/Helpers/RequestHelpersTests.cs +++ b/tests/Jellyfin.Api.Tests/Helpers/RequestHelpersTests.cs @@ -15,16 +15,16 @@ namespace Jellyfin.Api.Tests.Helpers Assert.Equal(expected, RequestHelpers.GetOrderBy(sortBy, requestedSortOrder)); } - public static IEnumerable GetOrderBy_Success_TestData() + public static TheoryData, IReadOnlyList, (string, SortOrder)[]> GetOrderBy_Success_TestData() { - yield return new object[] - { + var data = new TheoryData, IReadOnlyList, (string, SortOrder)[]>(); + + data.Add( Array.Empty(), Array.Empty(), - Array.Empty<(string, SortOrder)>() - }; - yield return new object[] - { + Array.Empty<(string, SortOrder)>()); + + data.Add( new string[] { "IsFavoriteOrLiked", @@ -35,10 +35,9 @@ namespace Jellyfin.Api.Tests.Helpers { ("IsFavoriteOrLiked", SortOrder.Ascending), ("Random", SortOrder.Ascending), - } - }; - yield return new object[] - { + }); + + data.Add( new string[] { "SortName", @@ -52,8 +51,9 @@ namespace Jellyfin.Api.Tests.Helpers { ("SortName", SortOrder.Descending), ("ProductionYear", SortOrder.Descending), - } - }; + }); + + return data; } [Fact] diff --git a/tests/Jellyfin.Api.Tests/Jellyfin.Api.Tests.csproj b/tests/Jellyfin.Api.Tests/Jellyfin.Api.Tests.csproj index d4ea91872c..922b3d94f7 100644 --- a/tests/Jellyfin.Api.Tests/Jellyfin.Api.Tests.csproj +++ b/tests/Jellyfin.Api.Tests/Jellyfin.Api.Tests.csproj @@ -6,11 +6,8 @@ - net5.0 + net6.0 false - true - enable - AllEnabledByDefault ../jellyfin-tests.ruleset @@ -18,12 +15,12 @@ - - - + + + - + diff --git a/tests/Jellyfin.Common.Tests/Cryptography/PasswordHashTests.cs b/tests/Jellyfin.Common.Tests/Cryptography/PasswordHashTests.cs index e6c325bac0..bfece97b6b 100644 --- a/tests/Jellyfin.Common.Tests/Cryptography/PasswordHashTests.cs +++ b/tests/Jellyfin.Common.Tests/Cryptography/PasswordHashTests.cs @@ -19,18 +19,16 @@ namespace Jellyfin.Common.Tests.Cryptography Assert.Throws(() => new PasswordHash(string.Empty, Array.Empty())); } - public static IEnumerable Parse_Valid_TestData() + public static TheoryData Parse_Valid_TestData() { + var data = new TheoryData(); // Id - yield return new object[] - { + data.Add( "$PBKDF2", - new PasswordHash("PBKDF2", Array.Empty()) - }; + new PasswordHash("PBKDF2", Array.Empty())); // Id + parameter - yield return new object[] - { + data.Add( "$PBKDF2$iterations=1000", new PasswordHash( "PBKDF2", @@ -39,12 +37,10 @@ namespace Jellyfin.Common.Tests.Cryptography new Dictionary() { { "iterations", "1000" }, - }) - }; + })); // Id + parameters - yield return new object[] - { + data.Add( "$PBKDF2$iterations=1000,m=120", new PasswordHash( "PBKDF2", @@ -54,34 +50,28 @@ namespace Jellyfin.Common.Tests.Cryptography { { "iterations", "1000" }, { "m", "120" } - }) - }; + })); // Id + hash - yield return new object[] - { + data.Add( "$PBKDF2$62FBA410AFCA5B4475F35137AB2E8596B127E4D927BA23F6CC05C067E897042D", new PasswordHash( "PBKDF2", Convert.FromHexString("62FBA410AFCA5B4475F35137AB2E8596B127E4D927BA23F6CC05C067E897042D"), Array.Empty(), - new Dictionary()) - }; + new Dictionary())); // Id + salt + hash - yield return new object[] - { + data.Add( "$PBKDF2$69F420$62FBA410AFCA5B4475F35137AB2E8596B127E4D927BA23F6CC05C067E897042D", new PasswordHash( "PBKDF2", Convert.FromHexString("62FBA410AFCA5B4475F35137AB2E8596B127E4D927BA23F6CC05C067E897042D"), Convert.FromHexString("69F420"), - new Dictionary()) - }; + new Dictionary())); // Id + parameter + hash - yield return new object[] - { + data.Add( "$PBKDF2$iterations=1000$62FBA410AFCA5B4475F35137AB2E8596B127E4D927BA23F6CC05C067E897042D", new PasswordHash( "PBKDF2", @@ -90,12 +80,9 @@ namespace Jellyfin.Common.Tests.Cryptography new Dictionary() { { "iterations", "1000" } - }) - }; - + })); // Id + parameters + hash - yield return new object[] - { + data.Add( "$PBKDF2$iterations=1000,m=120$62FBA410AFCA5B4475F35137AB2E8596B127E4D927BA23F6CC05C067E897042D", new PasswordHash( "PBKDF2", @@ -105,12 +92,9 @@ namespace Jellyfin.Common.Tests.Cryptography { { "iterations", "1000" }, { "m", "120" } - }) - }; - + })); // Id + parameters + salt + hash - yield return new object[] - { + data.Add( "$PBKDF2$iterations=1000,m=120$69F420$62FBA410AFCA5B4475F35137AB2E8596B127E4D927BA23F6CC05C067E897042D", new PasswordHash( "PBKDF2", @@ -120,8 +104,8 @@ namespace Jellyfin.Common.Tests.Cryptography { { "iterations", "1000" }, { "m", "120" } - }) - }; + })); + return data; } [Theory] @@ -171,11 +155,11 @@ namespace Jellyfin.Common.Tests.Cryptography [InlineData("$PBKDF2$=$62FBA410AFCA5B4475F35137AB2E8596B127E4D927BA23F6CC05C067E897042D")] // Invalid parmeter [InlineData("$PBKDF2$=1000$62FBA410AFCA5B4475F35137AB2E8596B127E4D927BA23F6CC05C067E897042D")] // Invalid parmeter [InlineData("$PBKDF2$iterations=$62FBA410AFCA5B4475F35137AB2E8596B127E4D927BA23F6CC05C067E897042D")] // Invalid parmeter - [InlineData("$PBKDF2$iterations=$62FBA410AFCA5B4475F35137AB2E8596B127E4D927BA23F6CC05C067E897042D$")] // Ends on $ - [InlineData("$PBKDF2$iterations=$69F420$62FBA410AFCA5B4475F35137AB2E8596B127E4D927BA23F6CC05C067E897042D$")] // Extra segment - [InlineData("$PBKDF2$iterations=$69F420$62FBA410AFCA5B4475F35137AB2E8596B127E4D927BA23F6CC05C067E897042D$anotherone")] // Extra segment - [InlineData("$PBKDF2$iterations=$invalidstalt$62FBA410AFCA5B4475F35137AB2E8596B127E4D927BA23F6CC05C067E897042D")] // Invalid salt - [InlineData("$PBKDF2$iterations=$69F420$invalid hash")] // Invalid hash + [InlineData("$PBKDF2$iterations=1000$62FBA410AFCA5B4475F35137AB2E8596B127E4D927BA23F6CC05C067E897042D$")] // Ends on $ + [InlineData("$PBKDF2$iterations=1000$69F420$62FBA410AFCA5B4475F35137AB2E8596B127E4D927BA23F6CC05C067E897042D$")] // Extra segment + [InlineData("$PBKDF2$iterations=1000$69F420$62FBA410AFCA5B4475F35137AB2E8596B127E4D927BA23F6CC05C067E897042D$anotherone")] // Extra segment + [InlineData("$PBKDF2$iterations=1000$invalidstalt$62FBA410AFCA5B4475F35137AB2E8596B127E4D927BA23F6CC05C067E897042D")] // Invalid salt + [InlineData("$PBKDF2$iterations=1000$69F420$invalid hash")] // Invalid hash [InlineData("$PBKDF2$69F420$")] // Empty hash public static void Parse_InvalidFormat_ThrowsFormatException(string passwordHash) { diff --git a/tests/Jellyfin.Common.Tests/Jellyfin.Common.Tests.csproj b/tests/Jellyfin.Common.Tests/Jellyfin.Common.Tests.csproj index 546b2487e8..1fe4e25656 100644 --- a/tests/Jellyfin.Common.Tests/Jellyfin.Common.Tests.csproj +++ b/tests/Jellyfin.Common.Tests/Jellyfin.Common.Tests.csproj @@ -6,20 +6,17 @@ - net5.0 + net6.0 false - true - enable - AllEnabledByDefault ../jellyfin-tests.ruleset - + - - + + diff --git a/tests/Jellyfin.Controller.Tests/Jellyfin.Controller.Tests.csproj b/tests/Jellyfin.Controller.Tests/Jellyfin.Controller.Tests.csproj index 9a8ddafa0b..e9a9515710 100644 --- a/tests/Jellyfin.Controller.Tests/Jellyfin.Controller.Tests.csproj +++ b/tests/Jellyfin.Controller.Tests/Jellyfin.Controller.Tests.csproj @@ -6,20 +6,17 @@ - net5.0 + net6.0 false - true - enable - AllEnabledByDefault ../jellyfin-tests.ruleset - + - + diff --git a/tests/Jellyfin.Dlna.Tests/Jellyfin.Dlna.Tests.csproj b/tests/Jellyfin.Dlna.Tests/Jellyfin.Dlna.Tests.csproj index 1f6cd541cb..1fb95aab4d 100644 --- a/tests/Jellyfin.Dlna.Tests/Jellyfin.Dlna.Tests.csproj +++ b/tests/Jellyfin.Dlna.Tests/Jellyfin.Dlna.Tests.csproj @@ -1,20 +1,17 @@ - net5.0 + net6.0 false - true - enable - AllEnabledByDefault ../jellyfin-tests.ruleset - + - + diff --git a/tests/Jellyfin.Extensions.Tests/CopyToExtensionsTests.cs b/tests/Jellyfin.Extensions.Tests/CopyToExtensionsTests.cs index 6fdca46944..d46beedd99 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 4b6dca3777..2dc4ac19a5 100644 --- a/tests/Jellyfin.Extensions.Tests/Jellyfin.Extensions.Tests.csproj +++ b/tests/Jellyfin.Extensions.Tests/Jellyfin.Extensions.Tests.csproj @@ -1,26 +1,23 @@ - net5.0 + net6.0 false - true - enable - AllEnabledByDefault ../jellyfin-tests.ruleset - + runtime; build; native; contentfiles; analyzers; buildtransitive all - + runtime; build; native; contentfiles; analyzers; buildtransitive all - + diff --git a/tests/Jellyfin.Extensions.Tests/Json/Converters/JsonBoolNumberTests.cs b/tests/Jellyfin.Extensions.Tests/Json/Converters/JsonBoolNumberTests.cs index d0e3e94568..125229ff92 100644 --- a/tests/Jellyfin.Extensions.Tests/Json/Converters/JsonBoolNumberTests.cs +++ b/tests/Jellyfin.Extensions.Tests/Json/Converters/JsonBoolNumberTests.cs @@ -1,4 +1,3 @@ -using System.Globalization; using System.Text.Json; using FsCheck; using FsCheck.Xunit; diff --git a/tests/Jellyfin.Extensions.Tests/ShuffleExtensionsTests.cs b/tests/Jellyfin.Extensions.Tests/ShuffleExtensionsTests.cs index c72216d94b..a73cfb0781 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 d1aa2e4764..7186cc0236 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 39fd8afda1..c0c363d3da 100644 --- a/tests/Jellyfin.MediaEncoding.Tests/EncoderValidatorTests.cs +++ b/tests/Jellyfin.MediaEncoding.Tests/EncoderValidatorTests.cs @@ -1,6 +1,4 @@ using System; -using System.Collections; -using System.Collections.Generic; using MediaBrowser.MediaEncoding.Encoder; using Microsoft.Extensions.Logging.Abstractions; using Xunit; @@ -9,15 +7,18 @@ namespace Jellyfin.MediaEncoding.Tests { public class EncoderValidatorTests { + private readonly EncoderValidator _encoderValidator = new EncoderValidator(new NullLogger(), "ffmpeg"); + [Theory] [ClassData(typeof(GetFFmpegVersionTestData))] public void GetFFmpegVersionTest(string versionOutput, Version? version) { - var val = new EncoderValidator(new NullLogger()); - Assert.Equal(version, val.GetFFmpegVersion(versionOutput)); + Assert.Equal(version, _encoderValidator.GetFFmpegVersionInternal(versionOutput)); } [Theory] + [InlineData(EncoderValidatorTestsData.FFmpegV44Output, true)] + [InlineData(EncoderValidatorTestsData.FFmpegV432Output, true)] [InlineData(EncoderValidatorTestsData.FFmpegV431Output, true)] [InlineData(EncoderValidatorTestsData.FFmpegV43Output, true)] [InlineData(EncoderValidatorTestsData.FFmpegV421Output, true)] @@ -28,25 +29,24 @@ namespace Jellyfin.MediaEncoding.Tests [InlineData(EncoderValidatorTestsData.FFmpegGitUnknownOutput, false)] public void ValidateVersionInternalTest(string versionOutput, bool valid) { - var val = new EncoderValidator(new NullLogger()); - Assert.Equal(valid, val.ValidateVersionInternal(versionOutput)); + Assert.Equal(valid, _encoderValidator.ValidateVersionInternal(versionOutput)); } - private class GetFFmpegVersionTestData : IEnumerable + private class GetFFmpegVersionTestData : TheoryData { - public IEnumerator GetEnumerator() + public GetFFmpegVersionTestData() { - yield return new object?[] { EncoderValidatorTestsData.FFmpegV431Output, new Version(4, 3, 1) }; - yield return new object?[] { EncoderValidatorTestsData.FFmpegV43Output, new Version(4, 3) }; - yield return new object?[] { EncoderValidatorTestsData.FFmpegV421Output, new Version(4, 2, 1) }; - yield return new object?[] { EncoderValidatorTestsData.FFmpegV42Output, new Version(4, 2) }; - yield return new object?[] { EncoderValidatorTestsData.FFmpegV414Output, new Version(4, 1, 4) }; - yield return new object?[] { EncoderValidatorTestsData.FFmpegV404Output, new Version(4, 0, 4) }; - yield return new object?[] { EncoderValidatorTestsData.FFmpegGitUnknownOutput2, new Version(4, 0) }; - yield return new object?[] { EncoderValidatorTestsData.FFmpegGitUnknownOutput, null }; + Add(EncoderValidatorTestsData.FFmpegV44Output, new Version(4, 4)); + Add(EncoderValidatorTestsData.FFmpegV432Output, new Version(4, 3, 2)); + Add(EncoderValidatorTestsData.FFmpegV431Output, new Version(4, 3, 1)); + Add(EncoderValidatorTestsData.FFmpegV43Output, new Version(4, 3)); + Add(EncoderValidatorTestsData.FFmpegV421Output, new Version(4, 2, 1)); + Add(EncoderValidatorTestsData.FFmpegV42Output, new Version(4, 2)); + Add(EncoderValidatorTestsData.FFmpegV414Output, new Version(4, 1, 4)); + Add(EncoderValidatorTestsData.FFmpegV404Output, new Version(4, 0, 4)); + Add(EncoderValidatorTestsData.FFmpegGitUnknownOutput2, new Version(4, 0)); + Add(EncoderValidatorTestsData.FFmpegGitUnknownOutput, null); } - - IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); } } } diff --git a/tests/Jellyfin.MediaEncoding.Tests/EncoderValidatorTestsData.cs b/tests/Jellyfin.MediaEncoding.Tests/EncoderValidatorTestsData.cs index 9f5bef9a88..02bf046ed1 100644 --- a/tests/Jellyfin.MediaEncoding.Tests/EncoderValidatorTestsData.cs +++ b/tests/Jellyfin.MediaEncoding.Tests/EncoderValidatorTestsData.cs @@ -2,6 +2,30 @@ namespace Jellyfin.MediaEncoding.Tests { internal static class EncoderValidatorTestsData { + public const string FFmpegV44Output = @"ffmpeg version 4.4-Jellyfin Copyright (c) 2000-2021 the FFmpeg developers +built with gcc 10.3.0 (Rev5, Built by MSYS2 project) +configuration: --disable-static --enable-shared --extra-version=Jellyfin --disable-ffplay --disable-debug --enable-gpl --enable-version3 --enable-bzlib --enable-iconv --enable-lzma --enable-zlib --enable-sdl2 --enable-fontconfig --enable-gmp --enable-libass --enable-libzimg --enable-libbluray --enable-libfreetype --enable-libmp3lame --enable-libopus --enable-libtheora --enable-libvorbis --enable-libwebp --enable-libvpx --enable-libx264 --enable-libx265 --enable-libdav1d --enable-opencl --enable-dxva2 --enable-d3d11va --enable-amf --enable-libmfx --enable-cuda --enable-cuda-llvm --enable-cuvid --enable-nvenc --enable-nvdec --enable-ffnvcodec --enable-gnutls +libavutil 56. 70.100 / 56. 70.100 +libavcodec 58.134.100 / 58.134.100 +libavformat 58. 76.100 / 58. 76.100 +libavdevice 58. 13.100 / 58. 13.100 +libavfilter 7.110.100 / 7.110.100 +libswscale 5. 9.100 / 5. 9.100 +libswresample 3. 9.100 / 3. 9.100 +libpostproc 55. 9.100 / 55. 9.100"; + + public const string FFmpegV432Output = @"ffmpeg version n4.3.2-Jellyfin Copyright (c) 2000-2021 the FFmpeg developers +built with gcc 10.2.0 (Rev9, Built by MSYS2 project) +configuration: --disable-static --enable-shared --cc='ccache gcc' --cxx='ccache g++' --extra-version=Jellyfin --disable-ffplay --disable-debug --enable-lto --enable-gpl --enable-version3 --enable-bzlib --enable-iconv --enable-lzma --enable-zlib --enable-sdl2 --enable-fontconfig --enable-gmp --enable-libass --enable-libzimg --enable-libbluray --enable-libfreetype --enable-libmp3lame --enable-libopus --enable-libtheora --enable-libvorbis --enable-libwebp --enable-libvpx --enable-libx264 --enable-libx265 --enable-libdav1d --enable-opencl --enable-dxva2 --enable-d3d11va --enable-amf --enable-libmfx --enable-cuda --enable-cuda-llvm --enable-cuvid --enable-nvenc --enable-nvdec --enable-ffnvcodec --enable-gnutls +libavutil 56. 51.100 / 56. 51.100 +libavcodec 58. 91.100 / 58. 91.100 +libavformat 58. 45.100 / 58. 45.100 +libavdevice 58. 10.100 / 58. 10.100 +libavfilter 7. 85.100 / 7. 85.100 +libswscale 5. 7.100 / 5. 7.100 +libswresample 3. 7.100 / 3. 7.100 +libpostproc 55. 7.100 / 55. 7.100"; + public const string FFmpegV431Output = @"ffmpeg version n4.3.1 Copyright (c) 2000-2020 the FFmpeg developers built with gcc 10.1.0 (GCC) configuration: --prefix=/usr --disable-debug --disable-static --disable-stripping --enable-avisynth --enable-fontconfig --enable-gmp --enable-gnutls --enable-gpl --enable-ladspa --enable-libaom --enable-libass --enable-libbluray --enable-libdav1d --enable-libdrm --enable-libfreetype --enable-libfribidi --enable-libgsm --enable-libiec61883 --enable-libjack --enable-libmfx --enable-libmodplug --enable-libmp3lame --enable-libopencore_amrnb --enable-libopencore_amrwb --enable-libopenjpeg --enable-libopus --enable-libpulse --enable-librav1e --enable-libsoxr --enable-libspeex --enable-libsrt --enable-libssh --enable-libtheora --enable-libv4l2 --enable-libvidstab --enable-libvmaf --enable-libvorbis --enable-libvpx --enable-libwebp --enable-libx264 --enable-libx265 --enable-libxcb --enable-libxml2 --enable-libxvid --enable-nvdec --enable-nvenc --enable-omx --enable-shared --enable-version3 diff --git a/tests/Jellyfin.MediaEncoding.Tests/FFprobeParserTests.cs b/tests/Jellyfin.MediaEncoding.Tests/FFprobeParserTests.cs index 45808375f1..97dbb3be03 100644 --- a/tests/Jellyfin.MediaEncoding.Tests/FFprobeParserTests.cs +++ b/tests/Jellyfin.MediaEncoding.Tests/FFprobeParserTests.cs @@ -3,6 +3,7 @@ using System.Text.Json; using System.Threading.Tasks; using Jellyfin.Extensions.Json; using MediaBrowser.MediaEncoding.Probing; +using MediaBrowser.Model.IO; using Xunit; namespace Jellyfin.MediaEncoding.Tests @@ -14,9 +15,10 @@ namespace Jellyfin.MediaEncoding.Tests public async Task Test(string fileName) { var path = Path.Join("Test Data", fileName); - using (var stream = File.OpenRead(path)) + await using (var stream = AsyncFile.OpenRead(path)) { - await JsonSerializer.DeserializeAsync(stream, JsonDefaults.Options).ConfigureAwait(false); + var res = await JsonSerializer.DeserializeAsync(stream, JsonDefaults.Options).ConfigureAwait(false); + Assert.NotNull(res); } } } diff --git a/tests/Jellyfin.MediaEncoding.Tests/Jellyfin.MediaEncoding.Tests.csproj b/tests/Jellyfin.MediaEncoding.Tests/Jellyfin.MediaEncoding.Tests.csproj index 6b828e1135..201f63a2d6 100644 --- a/tests/Jellyfin.MediaEncoding.Tests/Jellyfin.MediaEncoding.Tests.csproj +++ b/tests/Jellyfin.MediaEncoding.Tests/Jellyfin.MediaEncoding.Tests.csproj @@ -6,11 +6,8 @@ - net5.0 + net6.0 false - true - enable - AllEnabledByDefault ../jellyfin-tests.ruleset @@ -21,10 +18,14 @@ - + + + + + + - diff --git a/tests/Jellyfin.MediaEncoding.Tests/Probing/ProbeResultNormalizerTests.cs b/tests/Jellyfin.MediaEncoding.Tests/Probing/ProbeResultNormalizerTests.cs index d8089eea2d..4504924cbf 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 @@ -55,6 +57,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 +137,60 @@ 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] + public void GetMediaInfo_GivenOriginalDateContainsOnlyYear_Success() + { + var bytes = File.ReadAllBytes("Test Data/Probing/music_year_only_metadata.json"); + var internalMediaInfoResult = JsonSerializer.Deserialize(bytes, _jsonOptions); + MediaInfo res = _probeResultNormalizer.GetMediaInfo(internalMediaInfoResult, null, true, "Test Data/Probing/music.flac", MediaProtocol.File); + + Assert.Equal("Baker Street", res.Name); + Assert.Single(res.Artists); + Assert.Equal("Gerry Rafferty", res.Artists[0]); + Assert.Equal("City to City", res.Album); + Assert.Equal(1978, res.ProductionYear); + Assert.True(res.PremiereDate.HasValue); + Assert.Equal(DateTime.Parse("1978-01-01T00:00Z", DateTimeFormatInfo.CurrentInfo, DateTimeStyles.AdjustToUniversal), res.PremiereDate); + Assert.Contains("Electronic", res.Genres); + Assert.Contains("Ambient", res.Genres); + Assert.Contains("Pop", res.Genres); + Assert.Contains("Jazz", res.Genres); + } + + [Fact] + public void GetMediaInfo_Music_Success() + { + var bytes = File.ReadAllBytes("Test Data/Probing/music_metadata.json"); + var internalMediaInfoResult = JsonSerializer.Deserialize(bytes, _jsonOptions); + MediaInfo res = _probeResultNormalizer.GetMediaInfo(internalMediaInfoResult, null, true, "Test Data/Probing/music.flac", MediaProtocol.File); + + Assert.Equal("UP NO MORE", res.Name); + Assert.Single(res.Artists); + Assert.Equal("TWICE", res.Artists[0]); + Assert.Equal("Eyes wide open", res.Album); + Assert.Equal(2020, res.ProductionYear); + Assert.True(res.PremiereDate.HasValue); + Assert.Equal(DateTime.Parse("2020-10-26T00:00Z", DateTimeFormatInfo.CurrentInfo, DateTimeStyles.AdjustToUniversal), res.PremiereDate); + Assert.Equal(22, res.People.Length); + Assert.Equal("Krysta Youngs", res.People[0].Name); + Assert.Equal(PersonType.Composer, res.People[0].Type); + Assert.Equal("Julia Ross", res.People[1].Name); + Assert.Equal(PersonType.Composer, res.People[1].Type); + Assert.Equal("Yiwoomin", res.People[2].Name); + Assert.Equal(PersonType.Composer, res.People[2].Type); + Assert.Equal("Ji-hyo Park", res.People[3].Name); + Assert.Equal(PersonType.Lyricist, res.People[3].Type); + Assert.Equal("Yiwoomin", res.People[4].Name); + Assert.Equal(PersonType.Actor, res.People[4].Type); + Assert.Equal("Electric Piano", res.People[4].Role); + Assert.Equal(4, res.Genres.Length); + Assert.Contains("Electronic", res.Genres); + Assert.Contains("Trance", res.Genres); + Assert.Contains("Dance", res.Genres); + Assert.Contains("Jazz", res.Genres); } } } diff --git a/tests/Jellyfin.MediaEncoding.Tests/Subtitles/SrtParserTests.cs b/tests/Jellyfin.MediaEncoding.Tests/Subtitles/SrtParserTests.cs index 537a944b03..c07c9ea7db 100644 --- a/tests/Jellyfin.MediaEncoding.Tests/Subtitles/SrtParserTests.cs +++ b/tests/Jellyfin.MediaEncoding.Tests/Subtitles/SrtParserTests.cs @@ -31,5 +31,27 @@ namespace Jellyfin.MediaEncoding.Subtitles.Tests Assert.Equal("Very good, Lieutenant.", trackEvent2.Text); } } + + [Fact] + public void Parse_EmptyNewlineBetweenText_Success() + { + using (var stream = File.OpenRead("Test Data/example2.srt")) + { + var parsed = new SrtParser(new NullLogger()).Parse(stream, CancellationToken.None); + Assert.Equal(2, parsed.TrackEvents.Count); + + var trackEvent1 = parsed.TrackEvents[0]; + Assert.Equal("311", trackEvent1.Id); + Assert.Equal(TimeSpan.Parse("00:16:46.465", CultureInfo.InvariantCulture).Ticks, trackEvent1.StartPositionTicks); + Assert.Equal(TimeSpan.Parse("00:16:49.009", CultureInfo.InvariantCulture).Ticks, trackEvent1.EndPositionTicks); + Assert.Equal("Una vez que la gente se entere" + Environment.NewLine + Environment.NewLine + "de que ustedes están aquí,", trackEvent1.Text); + + var trackEvent2 = parsed.TrackEvents[1]; + Assert.Equal("312", trackEvent2.Id); + Assert.Equal(TimeSpan.Parse("00:16:49.092", CultureInfo.InvariantCulture).Ticks, trackEvent2.StartPositionTicks); + Assert.Equal(TimeSpan.Parse("00:16:51.470", CultureInfo.InvariantCulture).Ticks, trackEvent2.EndPositionTicks); + Assert.Equal("este lugar se convertirá" + Environment.NewLine + Environment.NewLine + "en un maldito zoológico.", trackEvent2.Text); + } + } } } diff --git a/tests/Jellyfin.MediaEncoding.Tests/Subtitles/SsaParserTests.cs b/tests/Jellyfin.MediaEncoding.Tests/Subtitles/SsaParserTests.cs index 5db80c3001..56649db8f8 100644 --- a/tests/Jellyfin.MediaEncoding.Tests/Subtitles/SsaParserTests.cs +++ b/tests/Jellyfin.MediaEncoding.Tests/Subtitles/SsaParserTests.cs @@ -38,10 +38,11 @@ namespace Jellyfin.MediaEncoding.Subtitles.Tests } } - public static IEnumerable Parse_MultipleDialogues_TestData() + public static TheoryData> Parse_MultipleDialogues_TestData() { - yield return new object[] - { + var data = new TheoryData>(); + + data.Add( @"[Events] Format: Layer, Start, End, Text Dialogue: ,0:00:01.18,0:00:01.85,dialogue1 @@ -65,8 +66,9 @@ namespace Jellyfin.MediaEncoding.Subtitles.Tests StartPositionTicks = 31800000, EndPositionTicks = 38500000 } - } - }; + }); + + return data; } [Fact] diff --git a/tests/Jellyfin.MediaEncoding.Tests/Subtitles/SubtitleEncoderTests.cs b/tests/Jellyfin.MediaEncoding.Tests/Subtitles/SubtitleEncoderTests.cs new file mode 100644 index 0000000000..639c364df2 --- /dev/null +++ b/tests/Jellyfin.MediaEncoding.Tests/Subtitles/SubtitleEncoderTests.cs @@ -0,0 +1,83 @@ +using System.Threading; +using System.Threading.Tasks; +using AutoFixture; +using AutoFixture.AutoMoq; +using MediaBrowser.MediaEncoding.Subtitles; +using MediaBrowser.Model.Dto; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.MediaInfo; +using Xunit; + +namespace Jellyfin.MediaEncoding.Subtitles.Tests +{ + public class SubtitleEncoderTests + { + internal static TheoryData GetReadableFile_Valid_TestData() + { + var data = new TheoryData(); + + data.Add( + new MediaSourceInfo() + { + Protocol = MediaProtocol.File + }, + new MediaStream() + { + Path = "/media/sub.ass", + IsExternal = true + }, + new SubtitleEncoder.SubtitleInfo("/media/sub.ass", MediaProtocol.File, "ass", true)); + + data.Add( + new MediaSourceInfo() + { + Protocol = MediaProtocol.File + }, + new MediaStream() + { + Path = "/media/sub.ssa", + IsExternal = true + }, + new SubtitleEncoder.SubtitleInfo("/media/sub.ssa", MediaProtocol.File, "ssa", true)); + + data.Add( + new MediaSourceInfo() + { + Protocol = MediaProtocol.File + }, + new MediaStream() + { + Path = "/media/sub.srt", + IsExternal = true + }, + new SubtitleEncoder.SubtitleInfo("/media/sub.srt", MediaProtocol.File, "srt", true)); + + data.Add( + new MediaSourceInfo() + { + Protocol = MediaProtocol.Http + }, + new MediaStream() + { + Path = "/media/sub.ass", + IsExternal = true + }, + new SubtitleEncoder.SubtitleInfo("/media/sub.ass", MediaProtocol.File, "ass", true)); + + return data; + } + + [Theory] + [MemberData(nameof(GetReadableFile_Valid_TestData))] + internal async Task GetReadableFile_Valid_Success(MediaSourceInfo mediaSource, MediaStream subtitleStream, SubtitleEncoder.SubtitleInfo subtitleInfo) + { + var fixture = new Fixture().Customize(new AutoMoqCustomization { ConfigureMembers = true }); + var subtitleEncoder = fixture.Create(); + var result = await subtitleEncoder.GetReadableFile(mediaSource, subtitleStream, CancellationToken.None).ConfigureAwait(false); + Assert.Equal(subtitleInfo.Path, result.Path); + Assert.Equal(subtitleInfo.Protocol, result.Protocol); + Assert.Equal(subtitleInfo.Format, result.Format); + Assert.Equal(subtitleInfo.IsExternal, result.IsExternal); + } + } +} diff --git a/tests/Jellyfin.MediaEncoding.Tests/Test Data/Probing/music_metadata.json b/tests/Jellyfin.MediaEncoding.Tests/Test Data/Probing/music_metadata.json new file mode 100644 index 0000000000..6530629fe8 --- /dev/null +++ b/tests/Jellyfin.MediaEncoding.Tests/Test Data/Probing/music_metadata.json @@ -0,0 +1,144 @@ +{ + "streams": [ + { + "index": 0, + "codec_name": "flac", + "codec_long_name": "FLAC (Free Lossless Audio Codec)", + "codec_type": "audio", + "codec_tag_string": "[0][0][0][0]", + "codec_tag": "0x0000", + "sample_fmt": "s16", + "sample_rate": "44100", + "channels": 2, + "channel_layout": "stereo", + "bits_per_sample": 0, + "r_frame_rate": "0/0", + "avg_frame_rate": "0/0", + "time_base": "1/44100", + "start_pts": 0, + "start_time": "0.000000", + "duration_ts": 9447984, + "duration": "214.240000", + "bits_per_raw_sample": "16", + "disposition": { + "default": 0, + "dub": 0, + "original": 0, + "comment": 0, + "lyrics": 0, + "karaoke": 0, + "forced": 0, + "hearing_impaired": 0, + "visual_impaired": 0, + "clean_effects": 0, + "attached_pic": 0, + "timed_thumbnails": 0 + } + }, + { + "index": 1, + "codec_name": "mjpeg", + "codec_long_name": "Motion JPEG", + "profile": "Baseline", + "codec_type": "video", + "codec_tag_string": "[0][0][0][0]", + "codec_tag": "0x0000", + "width": 500, + "height": 500, + "coded_width": 500, + "coded_height": 500, + "closed_captions": 0, + "has_b_frames": 0, + "sample_aspect_ratio": "1:1", + "display_aspect_ratio": "1:1", + "pix_fmt": "yuvj420p", + "level": -99, + "color_range": "pc", + "color_space": "bt470bg", + "chroma_location": "center", + "refs": 1, + "r_frame_rate": "90000/1", + "avg_frame_rate": "0/0", + "time_base": "1/90000", + "start_pts": 0, + "start_time": "0.000000", + "duration_ts": 19281600, + "duration": "214.240000", + "bits_per_raw_sample": "8", + "disposition": { + "default": 0, + "dub": 0, + "original": 0, + "comment": 0, + "lyrics": 0, + "karaoke": 0, + "forced": 0, + "hearing_impaired": 0, + "visual_impaired": 0, + "clean_effects": 0, + "attached_pic": 1, + "timed_thumbnails": 0 + }, + "tags": { + "comment": "Cover (front)" + } + } + ], + "format": { + "filename": "03 UP NO MORE.flac", + "nb_streams": 2, + "nb_programs": 0, + "format_name": "flac", + "format_long_name": "raw FLAC", + "start_time": "0.000000", + "duration": "214.240000", + "size": "28714641", + "bit_rate": "1072242", + "probe_score": 100, + "tags": { + "MUSICBRAINZ_RELEASEGROUPID": "aa05ff10-8589-4c9c-a0d4-6b024f4e4556", + "ORIGINALDATE": "2020-10-26", + "ORIGINALYEAR": "2020", + "RELEASETYPE": "album", + "MUSICBRAINZ_ALBUMID": "222e6610-75c9-400e-8dc3-bb61f9fc5ca7", + "SCRIPT": "Latn", + "ALBUM": "Eyes wide open", + "RELEASECOUNTRY": "JP", + "BARCODE": "190295105280", + "LABEL": "JYP Entertainment", + "RELEASESTATUS": "official", + "DATE": "2020-10-26", + "MUSICBRAINZ_ALBUMARTISTID": "8da127cc-c432-418f-b356-ef36210d82ac", + "album_artist": "TWICE", + "ALBUMARTISTSORT": "TWICE", + "TOTALDISCS": "1", + "TOTALTRACKS": "13", + "MEDIA": "Digital Media", + "disc": "1", + "MUSICBRAINZ_TRACKID": "7d1a1044-b564-480d-9df3-22f9656fdb97", + "TITLE": "UP NO MORE", + "ISRC": "US5TA2000136", + "PERFORMER": "Yiwoomin (electric piano);Yiwoomin (synthesizer);Yiwoomin (bass);Yiwoomin (guitar);TWICE;Tzu-yu Chou (vocals);Momo Hirai (vocals);Na-yeon Im (vocals);Da-hyun Kim (vocals);Sana Minatozaki (vocals);Mina Myoui (vocals);Ji-hyo Park (vocals);Chae-young Son (vocals);Jeong-yeon Yoo (vocals);Perrie (background vocals)", + "MIXER": "Bong Won Shin", + "ARRANGER": "Krysta Youngs;Julia Ross;Yiwoomin", + "MUSICBRAINZ_WORKID": "02b37083-0337-4721-9f17-bf31971043e8", + "LANGUAGE": "kor;eng", + "WORK": "Up No More", + "COMPOSER": "Krysta Youngs;Julia Ross;Yiwoomin", + "COMPOSERSORT": "Krysta Youngs;Ross, Julia;Yiwoomin", + "LYRICIST": "Ji-hyo Park", + "MUSICBRAINZ_ARTISTID": "8da127cc-c432-418f-b356-ef36210d82ac", + "ARTIST": "TWICE", + "ARTISTSORT": "TWICE", + "ARTISTS": "TWICE", + "MUSICBRAINZ_RELEASETRACKID": "ad49b840-da9e-4e7c-924b-29fdee187052", + "track": "3", + "GENRE": "Electronic;Trance;Dance;Jazz", + "WEBSITE": "http://twice.jype.com/;http://www.twicejapan.com/", + "ACOUSTID_ID": "aae2e972-108c-4d0c-8e31-9d078283e3dc", + "MOOD": "Not acoustic;Not aggressive;Electronic;Happy;Party;Not relaxed;Not sad", + "TRACKTOTAL": "13", + "DISCTOTAL": "1" + } + } +} diff --git a/tests/Jellyfin.MediaEncoding.Tests/Test Data/Probing/music_year_only_metadata.json b/tests/Jellyfin.MediaEncoding.Tests/Test Data/Probing/music_year_only_metadata.json new file mode 100644 index 0000000000..ddf890c453 --- /dev/null +++ b/tests/Jellyfin.MediaEncoding.Tests/Test Data/Probing/music_year_only_metadata.json @@ -0,0 +1,147 @@ +{ + "streams": [ + { + "index": 0, + "codec_name": "flac", + "codec_long_name": "FLAC (Free Lossless Audio Codec)", + "codec_type": "audio", + "codec_tag_string": "[0][0][0][0]", + "codec_tag": "0x0000", + "sample_fmt": "s16", + "sample_rate": "44100", + "channels": 2, + "channel_layout": "stereo", + "bits_per_sample": 0, + "r_frame_rate": "0/0", + "avg_frame_rate": "0/0", + "time_base": "1/44100", + "start_pts": 0, + "start_time": "0.000000", + "duration_ts": 16394616, + "duration": "371.760000", + "bits_per_raw_sample": "16", + "disposition": { + "default": 0, + "dub": 0, + "original": 0, + "comment": 0, + "lyrics": 0, + "karaoke": 0, + "forced": 0, + "hearing_impaired": 0, + "visual_impaired": 0, + "clean_effects": 0, + "attached_pic": 0, + "timed_thumbnails": 0 + } + }, + { + "index": 1, + "codec_name": "mjpeg", + "codec_long_name": "Motion JPEG", + "profile": "Baseline", + "codec_type": "video", + "codec_tag_string": "[0][0][0][0]", + "codec_tag": "0x0000", + "width": 500, + "height": 498, + "coded_width": 500, + "coded_height": 498, + "closed_captions": 0, + "has_b_frames": 0, + "sample_aspect_ratio": "1:1", + "display_aspect_ratio": "250:249", + "pix_fmt": "yuvj420p", + "level": -99, + "color_range": "pc", + "color_space": "bt470bg", + "chroma_location": "center", + "refs": 1, + "r_frame_rate": "90000/1", + "avg_frame_rate": "0/0", + "time_base": "1/90000", + "start_pts": 0, + "start_time": "0.000000", + "duration_ts": 33458400, + "duration": "371.760000", + "bits_per_raw_sample": "8", + "disposition": { + "default": 0, + "dub": 0, + "original": 0, + "comment": 0, + "lyrics": 0, + "karaoke": 0, + "forced": 0, + "hearing_impaired": 0, + "visual_impaired": 0, + "clean_effects": 0, + "attached_pic": 1, + "timed_thumbnails": 0 + }, + "tags": { + "comment": "Cover (front)" + } + } + ], + "format": { + "filename": "02 Baker Street.flac", + "nb_streams": 2, + "nb_programs": 0, + "format_name": "flac", + "format_long_name": "raw FLAC", + "start_time": "0.000000", + "duration": "371.760000", + "size": "37072649", + "bit_rate": "797775", + "probe_score": 100, + "tags": { + "MUSICBRAINZ_RELEASEGROUPID": "238c3fb4-5792-342b-b217-02f66298b424", + "ORIGINALDATE": "1978", + "ORIGINALYEAR": "1978", + "RELEASETYPE": "album", + "MUSICBRAINZ_ALBUMID": "30156786-e511-3106-ac95-66f0e880b24b", + "ASIN": "B000007O5H", + "MUSICBRAINZ_ALBUMARTISTID": "563201cb-721c-4cfb-acca-c1ba69e3d1fb", + "album_artist": "Gerry Rafferty", + "ALBUMARTISTSORT": "Rafferty, Gerry", + "LABEL": "Liberty EMI Records UK", + "CATALOGNUMBER": "CDP 7 46049 2", + "DATE": "1989-07-26", + "RELEASECOUNTRY": "GB", + "BARCODE": "077774604925", + "ALBUM": "City to City", + "SCRIPT": "Latn", + "RELEASESTATUS": "official", + "TOTALDISCS": "1", + "disc": "1", + "MEDIA": "CD", + "TOTALTRACKS": "10", + "MUSICBRAINZ_TRACKID": "9235e22e-afbd-48f7-b329-21dae6da2810", + "ISRC": "GBAYE1100924;GBAYE7800619", + "PERFORMER": "Hugh Burns (electric guitar);Nigel Jenkins (electric guitar);Tommy Eyre (keyboard and Moog);Glen LeFleur (percussion);Raphael Ravenscroft (saxophone);Henry Spinetti (drums (drum set));Gary Taylor (bass);Gerry Rafferty (lead vocals)", + "ARRANGER": "Graham Preskett", + "MIXER": "Declan O’Doherty", + "PRODUCER": "Hugh Murphy;Gerry Rafferty", + "MUSICBRAINZ_WORKID": "a9eb3c45-784c-3c32-860c-4b406f03961b", + "LANGUAGE": "eng", + "WORK": "Baker Street", + "COMPOSER": "Gerry Rafferty", + "COMPOSERSORT": "Rafferty, Gerry", + "LYRICIST": "Gerry Rafferty", + "TITLE": "Baker Street", + "MUSICBRAINZ_ARTISTID": "563201cb-721c-4cfb-acca-c1ba69e3d1fb", + "ARTIST": "Gerry Rafferty", + "ARTISTSORT": "Rafferty, Gerry", + "ARTISTS": "Gerry Rafferty", + "MUSICBRAINZ_RELEASETRACKID": "407cf7f7-440d-3e76-8b89-8686198868ea", + "track": "2", + "GENRE": "Electronic;Ambient;Pop;Jazz", + "WEBSITE": "http://www.gerryrafferty.com/", + "ACOUSTID_ID": "68f8d979-a659-4aa0-a216-eb3721a951eb", + "MOOD": "Acoustic;Not aggressive;Not electronic;Not happy;Party;Relaxed;Not sad", + "TRACKTOTAL": "10", + "DISCTOTAL": "1" + } + } +} diff --git a/tests/Jellyfin.MediaEncoding.Tests/Test Data/Probing/video_mp4_metadata.json b/tests/Jellyfin.MediaEncoding.Tests/Test Data/Probing/video_mp4_metadata.json new file mode 100644 index 0000000000..77e3def76d --- /dev/null +++ b/tests/Jellyfin.MediaEncoding.Tests/Test Data/Probing/video_mp4_metadata.json @@ -0,0 +1,260 @@ +{ + "streams": [ + { + "index": 0, + "codec_name": "h264", + "codec_long_name": "H.264 / AVC / MPEG-4 AVC / MPEG-4 part 10", + "profile": "High", + "codec_type": "video", + "codec_tag_string": "avc1", + "codec_tag": "0x31637661", + "width": 720, + "height": 358, + "coded_width": 720, + "coded_height": 358, + "closed_captions": 0, + "has_b_frames": 2, + "sample_aspect_ratio": "32:27", + "display_aspect_ratio": "1280:537", + "pix_fmt": "yuv420p", + "level": 31, + "color_range": "tv", + "color_space": "smpte170m", + "color_transfer": "bt709", + "color_primaries": "smpte170m", + "chroma_location": "left", + "refs": 1, + "is_avc": "true", + "nal_length_size": "4", + "r_frame_rate": "120/1", + "avg_frame_rate": "1704753000/71073479", + "time_base": "1/90000", + "start_pts": 0, + "start_time": "0.000000", + "duration_ts": 1421469580, + "duration": "15794.106444", + "bit_rate": "1147365", + "bits_per_raw_sample": "8", + "nb_frames": "378834", + "disposition": { + "default": 1, + "dub": 0, + "original": 0, + "comment": 0, + "lyrics": 0, + "karaoke": 0, + "forced": 0, + "hearing_impaired": 0, + "visual_impaired": 0, + "clean_effects": 0, + "attached_pic": 0, + "timed_thumbnails": 0 + }, + "tags": { + "creation_time": "2021-09-13T22:42:42.000000Z", + "language": "und", + "handler_name": "VideoHandler", + "vendor_id": "[0][0][0][0]" + } + }, + { + "index": 1, + "codec_name": "aac", + "codec_long_name": "AAC (Advanced Audio Coding)", + "profile": "LC", + "codec_type": "audio", + "codec_tag_string": "mp4a", + "codec_tag": "0x6134706d", + "sample_fmt": "fltp", + "sample_rate": "48000", + "channels": 7, + "bits_per_sample": 0, + "r_frame_rate": "0/0", + "avg_frame_rate": "0/0", + "time_base": "1/48000", + "start_pts": 0, + "start_time": "0.000000", + "duration_ts": 758115312, + "duration": "15794.069000", + "bit_rate": "224197", + "nb_frames": "740348", + "disposition": { + "default": 1, + "dub": 0, + "original": 0, + "comment": 0, + "lyrics": 0, + "karaoke": 0, + "forced": 0, + "hearing_impaired": 0, + "visual_impaired": 0, + "clean_effects": 0, + "attached_pic": 0, + "timed_thumbnails": 0 + }, + "tags": { + "creation_time": "2021-09-13T22:42:42.000000Z", + "language": "eng", + "handler_name": "Surround 6.1", + "vendor_id": "[0][0][0][0]" + } + }, + { + "index": 2, + "codec_name": "aac", + "codec_long_name": "AAC (Advanced Audio Coding)", + "profile": "LC", + "codec_type": "audio", + "codec_tag_string": "mp4a", + "codec_tag": "0x6134706d", + "sample_fmt": "fltp", + "sample_rate": "48000", + "channels": 2, + "channel_layout": "stereo", + "bits_per_sample": 0, + "r_frame_rate": "0/0", + "avg_frame_rate": "0/0", + "time_base": "1/48000", + "start_pts": 0, + "start_time": "0.000000", + "duration_ts": 758114304, + "duration": "15794.048000", + "bit_rate": "160519", + "nb_frames": "740347", + "disposition": { + "default": 0, + "dub": 0, + "original": 0, + "comment": 0, + "lyrics": 0, + "karaoke": 0, + "forced": 0, + "hearing_impaired": 0, + "visual_impaired": 0, + "clean_effects": 0, + "attached_pic": 0, + "timed_thumbnails": 0 + }, + "tags": { + "creation_time": "2021-09-13T22:42:42.000000Z", + "language": "eng", + "handler_name": "Commentary", + "vendor_id": "[0][0][0][0]" + } + }, + { + "index": 3, + "codec_name": "dvd_subtitle", + "codec_long_name": "DVD subtitles", + "codec_type": "subtitle", + "codec_tag_string": "mp4s", + "codec_tag": "0x7334706d", + "width": 720, + "height": 480, + "r_frame_rate": "0/0", + "avg_frame_rate": "0/0", + "time_base": "1/90000", + "start_pts": 0, + "start_time": "0.000000", + "duration_ts": 1300301588, + "duration": "14447.795422", + "bit_rate": "2653", + "nb_frames": "3545", + "disposition": { + "default": 0, + "dub": 0, + "original": 0, + "comment": 0, + "lyrics": 0, + "karaoke": 0, + "forced": 0, + "hearing_impaired": 0, + "visual_impaired": 0, + "clean_effects": 0, + "attached_pic": 0, + "timed_thumbnails": 0 + }, + "tags": { + "creation_time": "2021-09-13T22:42:42.000000Z", + "language": "spa", + "handler_name": "SubtitleHandler" + } + }, + { + "index": 4, + "codec_name": "mov_text", + "codec_long_name": "MOV text", + "codec_type": "subtitle", + "codec_tag_string": "tx3g", + "codec_tag": "0x67337874", + "width": 853, + "height": 51, + "r_frame_rate": "0/0", + "avg_frame_rate": "0/0", + "time_base": "1/90000", + "start_pts": 0, + "start_time": "0.000000", + "duration_ts": 1401339330, + "duration": "15570.437000", + "bit_rate": "88", + "nb_frames": "5079", + "disposition": { + "default": 1, + "dub": 0, + "original": 0, + "comment": 0, + "lyrics": 0, + "karaoke": 0, + "forced": 0, + "hearing_impaired": 0, + "visual_impaired": 0, + "clean_effects": 0, + "attached_pic": 0, + "timed_thumbnails": 0 + }, + "tags": { + "creation_time": "2021-09-13T22:42:42.000000Z", + "language": "eng", + "handler_name": "SubtitleHandler" + } + }, + { + "index": 5, + "codec_name": "mov_text", + "codec_long_name": "MOV text", + "codec_type": "subtitle", + "codec_tag_string": "tx3g", + "codec_tag": "0x67337874", + "width": 853, + "height": 51, + "r_frame_rate": "0/0", + "avg_frame_rate": "0/0", + "time_base": "1/90000", + "start_pts": 0, + "start_time": "0.000000", + "duration_ts": 1370580300, + "duration": "15228.670000", + "bit_rate": "18", + "nb_frames": "1563", + "disposition": { + "default": 0, + "dub": 0, + "original": 0, + "comment": 0, + "lyrics": 0, + "karaoke": 0, + "forced": 0, + "hearing_impaired": 0, + "visual_impaired": 0, + "clean_effects": 0, + "attached_pic": 0, + "timed_thumbnails": 0 + }, + "tags": { + "creation_time": "2021-09-13T22:42:42.000000Z", + "language": "eng", + "handler_name": "Commentary" + } + } + ] +} diff --git a/tests/Jellyfin.MediaEncoding.Tests/Test Data/example2.srt b/tests/Jellyfin.MediaEncoding.Tests/Test Data/example2.srt new file mode 100644 index 0000000000..b14aa8ea34 --- /dev/null +++ b/tests/Jellyfin.MediaEncoding.Tests/Test Data/example2.srt @@ -0,0 +1,11 @@ +311 +00:16:46,465 --> 00:16:49,009 +Una vez que la gente se entere + +de que ustedes están aquí, + +312 +00:16:49,092 --> 00:16:51,470 +este lugar se convertirá + +en un maldito zoológico. diff --git a/tests/Jellyfin.Model.Tests/Entities/MediaStreamTests.cs b/tests/Jellyfin.Model.Tests/Entities/MediaStreamTests.cs new file mode 100644 index 0000000000..0c97a90b43 --- /dev/null +++ b/tests/Jellyfin.Model.Tests/Entities/MediaStreamTests.cs @@ -0,0 +1,150 @@ +using MediaBrowser.Model.Entities; +using Xunit; + +namespace Jellyfin.Model.Tests.Entities +{ + public class MediaStreamTests + { + public static TheoryData Get_DisplayTitle_TestData() + { + var data = new TheoryData(); + + data.Add( + new MediaStream + { + Type = MediaStreamType.Subtitle, + Title = "English", + Language = string.Empty, + IsForced = false, + IsDefault = false, + Codec = "ASS" + }, + "English - Und - ASS"); + + data.Add( + new MediaStream + { + Type = MediaStreamType.Subtitle, + Title = "English", + Language = string.Empty, + IsForced = false, + IsDefault = false, + Codec = string.Empty + }, + "English - Und"); + + data.Add( + new MediaStream + { + Type = MediaStreamType.Subtitle, + Title = "English", + Language = "EN", + IsForced = false, + IsDefault = false, + Codec = string.Empty + }, + "English"); + + data.Add( + new MediaStream + { + Type = MediaStreamType.Subtitle, + Title = "English", + Language = "EN", + IsForced = true, + IsDefault = true, + Codec = "SRT" + }, + "English - Default - Forced - SRT"); + + data.Add( + new MediaStream + { + Type = MediaStreamType.Subtitle, + Title = null, + Language = null, + IsForced = false, + IsDefault = false, + Codec = null + }, + "Und"); + + return data; + } + + [Theory] + [MemberData(nameof(Get_DisplayTitle_TestData))] + public void Get_DisplayTitle_should_return_valid_title(MediaStream mediaStream, string expected) + { + Assert.Equal(expected, mediaStream.DisplayTitle); + } + + [Theory] + [InlineData(null, null, false, null)] + [InlineData(null, 0, false, null)] + [InlineData(0, null, false, null)] + [InlineData(640, 480, false, "480p")] + [InlineData(640, 480, true, "480i")] + [InlineData(720, 576, false, "576p")] + [InlineData(720, 576, true, "576i")] + [InlineData(960, 540, false, "540p")] + [InlineData(960, 540, true, "540i")] + [InlineData(1280, 720, false, "720p")] + [InlineData(1280, 720, true, "720i")] + [InlineData(1920, 1080, false, "1080p")] + [InlineData(1920, 1080, true, "1080i")] + [InlineData(4096, 3072, false, "4K")] + [InlineData(8192, 6144, false, "8K")] + [InlineData(512, 384, false, "480p")] + [InlineData(576, 336, false, "480p")] + [InlineData(624, 352, false, "480p")] + [InlineData(640, 352, false, "480p")] + [InlineData(704, 396, false, "480p")] + [InlineData(720, 404, false, "480p")] + [InlineData(720, 480, false, "480p")] + [InlineData(768, 576, false, "576p")] + [InlineData(960, 720, false, "720p")] + [InlineData(1280, 528, false, "720p")] + [InlineData(1280, 532, false, "720p")] + [InlineData(1280, 534, false, "720p")] + [InlineData(1280, 536, false, "720p")] + [InlineData(1280, 544, false, "720p")] + [InlineData(1280, 690, false, "720p")] + [InlineData(1280, 694, false, "720p")] + [InlineData(1280, 696, false, "720p")] + [InlineData(1280, 716, false, "720p")] + [InlineData(1280, 718, false, "720p")] + [InlineData(1912, 792, false, "1080p")] + [InlineData(1916, 1076, false, "1080p")] + [InlineData(1918, 1080, false, "1080p")] + [InlineData(1920, 796, false, "1080p")] + [InlineData(1920, 800, false, "1080p")] + [InlineData(1920, 802, false, "1080p")] + [InlineData(1920, 804, false, "1080p")] + [InlineData(1920, 808, false, "1080p")] + [InlineData(1920, 816, false, "1080p")] + [InlineData(1920, 856, false, "1080p")] + [InlineData(1920, 960, false, "1080p")] + [InlineData(1920, 1024, false, "1080p")] + [InlineData(1920, 1040, false, "1080p")] + [InlineData(1920, 1072, false, "1080p")] + [InlineData(1440, 1072, false, "1080p")] + [InlineData(1440, 1080, false, "1080p")] + [InlineData(3840, 1600, false, "4K")] + [InlineData(3840, 1606, false, "4K")] + [InlineData(3840, 1608, false, "4K")] + [InlineData(3840, 2160, false, "4K")] + [InlineData(7680, 4320, false, "8K")] + public void GetResolutionText_Valid(int? width, int? height, bool interlaced, string expected) + { + var mediaStream = new MediaStream() + { + Width = width, + Height = height, + IsInterlaced = interlaced + }; + + Assert.Equal(expected, mediaStream.GetResolutionText()); + } + } +} diff --git a/tests/Jellyfin.Model.Tests/Jellyfin.Model.Tests.csproj b/tests/Jellyfin.Model.Tests/Jellyfin.Model.Tests.csproj index 40c51e5248..a37e5ac920 100644 --- a/tests/Jellyfin.Model.Tests/Jellyfin.Model.Tests.csproj +++ b/tests/Jellyfin.Model.Tests/Jellyfin.Model.Tests.csproj @@ -1,20 +1,17 @@ - net5.0 + net6.0 false - true - enable - AllEnabledByDefault ../jellyfin-tests.ruleset - + - - + + diff --git a/tests/Jellyfin.Naming.Tests/AudioBook/AudioBookResolverTests.cs b/tests/Jellyfin.Naming.Tests/AudioBook/AudioBookResolverTests.cs index 53b35c2d6c..c72a3315e7 100644 --- a/tests/Jellyfin.Naming.Tests/AudioBook/AudioBookResolverTests.cs +++ b/tests/Jellyfin.Naming.Tests/AudioBook/AudioBookResolverTests.cs @@ -1,4 +1,3 @@ -using System.Collections.Generic; using Emby.Naming.AudioBook; using Emby.Naming.Common; using Xunit; @@ -9,29 +8,29 @@ namespace Jellyfin.Naming.Tests.AudioBook { private readonly NamingOptions _namingOptions = new NamingOptions(); - public static IEnumerable Resolve_ValidFileNameTestData() + public static TheoryData Resolve_ValidFileNameTestData() { - yield return new object[] - { + var data = new TheoryData(); + + data.Add( new AudioBookFileInfo( @"/server/AudioBooks/Larry Potter/Larry Potter.mp3", - "mp3") - }; - yield return new object[] - { + "mp3")); + + data.Add( new AudioBookFileInfo( @"/server/AudioBooks/Berry Potter/Chapter 1 .ogg", "ogg", - chapterNumber: 1) - }; - yield return new object[] - { + chapterNumber: 1)); + + data.Add( new AudioBookFileInfo( @"/server/AudioBooks/Nerry Potter/Part 3 - Chapter 2.mp3", "mp3", chapterNumber: 2, - partNumber: 3) - }; + partNumber: 3)); + + return data; } [Theory] diff --git a/tests/Jellyfin.Naming.Tests/Jellyfin.Naming.Tests.csproj b/tests/Jellyfin.Naming.Tests/Jellyfin.Naming.Tests.csproj index e386cb8c16..75d466198a 100644 --- a/tests/Jellyfin.Naming.Tests/Jellyfin.Naming.Tests.csproj +++ b/tests/Jellyfin.Naming.Tests/Jellyfin.Naming.Tests.csproj @@ -6,19 +6,16 @@ - net5.0 + net6.0 false - true - enable - AllEnabledByDefault ../jellyfin-tests.ruleset - + - + diff --git a/tests/Jellyfin.Naming.Tests/TV/EpisodeNumberTests.cs b/tests/Jellyfin.Naming.Tests/TV/EpisodeNumberTests.cs index 921c2b1f57..2873f61613 100644 --- a/tests/Jellyfin.Naming.Tests/TV/EpisodeNumberTests.cs +++ b/tests/Jellyfin.Naming.Tests/TV/EpisodeNumberTests.cs @@ -70,7 +70,8 @@ namespace Jellyfin.Naming.Tests.TV [InlineData("Log Horizon 2/[HorribleSubs] Log Horizon 2 - 03 [720p].mkv", 3)] // digit in series name [InlineData("Season 1/seriesname 05.mkv", 5)] // no hyphen between series name and episode number [InlineData("[BBT-RMX] Ranma ½ - 154 [50AC421A].mkv", 154)] // hyphens in the pre-name info, triple digit episode number - // TODO: [InlineData("Case Closed (1996-2007)/Case Closed - 317.mkv", 317)] // triple digit episode number + [InlineData("Season 2/Episode 21 - 94 Meetings.mp4", 21)] // Title starts with a number + // [InlineData("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)] diff --git a/tests/Jellyfin.Naming.Tests/TV/SimpleEpisodeTests.cs b/tests/Jellyfin.Naming.Tests/TV/SimpleEpisodeTests.cs index 89579c0376..6d49ac832d 100644 --- a/tests/Jellyfin.Naming.Tests/TV/SimpleEpisodeTests.cs +++ b/tests/Jellyfin.Naming.Tests/TV/SimpleEpisodeTests.cs @@ -21,7 +21,8 @@ namespace Jellyfin.Naming.Tests.TV [InlineData("[Baz-Bar]Foo - [1080p][Multiple Subtitle]/[Baz-Bar] Foo - 05 [1080p][Multiple Subtitle].mkv", "Foo", null, 5)] [InlineData(@"/Foo/The.Series.Name.S01E04.WEBRip.x264-Baz[Bar]/the.series.name.s01e04.webrip.x264-Baz[Bar].mkv", "The.Series.Name", 1, 4)] [InlineData(@"Love.Death.and.Robots.S01.1080p.NF.WEB-DL.DDP5.1.x264-NTG/Love.Death.and.Robots.S01E01.Sonnies.Edge.1080p.NF.WEB-DL.DDP5.1.x264-NTG.mkv", "Love.Death.and.Robots", 1, 1)] - // TODO: [InlineData("[Baz-Bar]Foo - 01 - 12[1080p][Multiple Subtitle]/[Baz-Bar] Foo - 05 [1080p][Multiple Subtitle].mkv", "Foo", null, 5)] + [InlineData("[YuiSubs] Tensura Nikki - Tensei Shitara Slime Datta Ken/[YuiSubs] Tensura Nikki - Tensei Shitara Slime Datta Ken - 12 (NVENC H.265 1080p).mkv", "Tensura Nikki - Tensei Shitara Slime Datta Ken", null, 12)] + [InlineData("[Baz-Bar]Foo - 01 - 12[1080p][Multiple Subtitle]/[Baz-Bar] Foo - 05 [1080p][Multiple Subtitle].mkv", "Foo", null, 5)] // TODO: [InlineData("E:\\Anime\\Yahari Ore no Seishun Love Comedy wa Machigatteiru\\Yahari Ore no Seishun Love Comedy wa Machigatteiru. Zoku\\Oregairu Zoku 11 - Hayama Hayato Always Renconds to Everyone's Expectations..mkv", "Yahari Ore no Seishun Love Comedy wa Machigatteiru", null, 11)] // TODO: [InlineData(@"/Library/Series/The Grand Tour (2016)/Season 1/S01E01 The Holy Trinity.mkv", "The Grand Tour", 1, 1)] public void TestSimple(string path, string seriesName, int? seasonNumber, int? episodeNumber) diff --git a/tests/Jellyfin.Naming.Tests/Video/CleanStringTests.cs b/tests/Jellyfin.Naming.Tests/Video/CleanStringTests.cs index fb050cf5a7..1574bce581 100644 --- a/tests/Jellyfin.Naming.Tests/Video/CleanStringTests.cs +++ b/tests/Jellyfin.Naming.Tests/Video/CleanStringTests.cs @@ -1,4 +1,3 @@ -using System; using Emby.Naming.Common; using Emby.Naming.Video; using Xunit; @@ -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 f872f94f8e..d13e89cee8 100644 --- a/tests/Jellyfin.Naming.Tests/Video/ExtraTests.cs +++ b/tests/Jellyfin.Naming.Tests/Video/ExtraTests.cs @@ -18,30 +18,31 @@ namespace Jellyfin.Naming.Tests.Video [Fact] public void TestKodiExtras() { - Test("trailer.mp4", ExtraType.Trailer, _videoOptions); - Test("300-trailer.mp4", ExtraType.Trailer, _videoOptions); + Test("trailer.mp4", ExtraType.Trailer); + Test("300-trailer.mp4", ExtraType.Trailer); - Test("theme.mp3", ExtraType.ThemeSong, _videoOptions); + Test("theme.mp3", ExtraType.ThemeSong); } [Fact] public void TestExpandedExtras() { - Test("trailer.mp4", ExtraType.Trailer, _videoOptions); - Test("trailer.mp3", null, _videoOptions); - Test("300-trailer.mp4", ExtraType.Trailer, _videoOptions); - - Test("theme.mp3", ExtraType.ThemeSong, _videoOptions); - Test("theme.mkv", null, _videoOptions); - - Test("300-scene.mp4", ExtraType.Scene, _videoOptions); - Test("300-scene2.mp4", ExtraType.Scene, _videoOptions); - Test("300-clip.mp4", ExtraType.Clip, _videoOptions); - - Test("300-deleted.mp4", ExtraType.DeletedScene, _videoOptions); - Test("300-deletedscene.mp4", ExtraType.DeletedScene, _videoOptions); - Test("300-interview.mp4", ExtraType.Interview, _videoOptions); - Test("300-behindthescenes.mp4", ExtraType.BehindTheScenes, _videoOptions); + Test("trailer.mp4", ExtraType.Trailer); + Test("trailer.mp3", null); + Test("300-trailer.mp4", ExtraType.Trailer); + Test("stuff trailerthings.mkv", null); + + Test("theme.mp3", ExtraType.ThemeSong); + Test("theme.mkv", null); + + Test("300-scene.mp4", ExtraType.Scene); + Test("300-scene2.mp4", ExtraType.Scene); + Test("300-clip.mp4", ExtraType.Clip); + + Test("300-deleted.mp4", ExtraType.DeletedScene); + Test("300-deletedscene.mp4", ExtraType.DeletedScene); + Test("300-interview.mp4", ExtraType.Interview); + Test("300-behindthescenes.mp4", ExtraType.BehindTheScenes); } [Theory] @@ -55,9 +56,9 @@ namespace Jellyfin.Naming.Tests.Video [InlineData(ExtraType.Unknown, "extras")] public void TestDirectories(ExtraType type, string dirName) { - Test(dirName + "/300.mp4", type, _videoOptions); - Test("300/" + dirName + "/something.mkv", type, _videoOptions); - Test("/data/something/Movies/300/" + dirName + "/whoknows.mp4", type, _videoOptions); + Test(dirName + "/300.mp4", type); + Test("300/" + dirName + "/something.mkv", type); + Test("/data/something/Movies/300/" + dirName + "/whoknows.mp4", type); } [Theory] @@ -66,32 +67,25 @@ namespace Jellyfin.Naming.Tests.Video [InlineData("The Big Short")] public void TestNonExtraDirectories(string dirName) { - Test(dirName + "/300.mp4", null, _videoOptions); - Test("300/" + dirName + "/something.mkv", null, _videoOptions); - Test("/data/something/Movies/300/" + dirName + "/whoknows.mp4", null, _videoOptions); - Test("/data/something/Movies/" + dirName + "/" + dirName + ".mp4", null, _videoOptions); + Test(dirName + "/300.mp4", null); + Test("300/" + dirName + "/something.mkv", null); + Test("/data/something/Movies/300/" + dirName + "/whoknows.mp4", null); + Test("/data/something/Movies/" + dirName + "/" + dirName + ".mp4", null); } [Fact] public void TestSample() { - Test("300-sample.mp4", ExtraType.Sample, _videoOptions); + Test("300-sample.mp4", ExtraType.Sample); } - private void Test(string input, ExtraType? expectedType, NamingOptions videoOptions) + private void Test(string input, ExtraType? expectedType) { - var parser = GetExtraTypeParser(videoOptions); + var parser = GetExtraTypeParser(_videoOptions); var extraType = parser.GetExtraInfo(input).ExtraType; - if (expectedType == null) - { - Assert.Null(extraType); - } - else - { - Assert.Equal(expectedType, extraType); - } + Assert.Equal(expectedType, extraType); } [Fact] diff --git a/tests/Jellyfin.Naming.Tests/Video/VideoResolverTests.cs b/tests/Jellyfin.Naming.Tests/Video/VideoResolverTests.cs index ac5a7a21e6..33a99e107f 100644 --- a/tests/Jellyfin.Naming.Tests/Video/VideoResolverTests.cs +++ b/tests/Jellyfin.Naming.Tests/Video/VideoResolverTests.cs @@ -1,4 +1,3 @@ -using System.Collections.Generic; using System.Linq; using Emby.Naming.Common; using Emby.Naming.Video; @@ -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 97bf673ae6..75d9b9ea90 100644 --- a/tests/Jellyfin.Networking.Tests/Jellyfin.Networking.Tests.csproj +++ b/tests/Jellyfin.Networking.Tests/Jellyfin.Networking.Tests.csproj @@ -6,20 +6,17 @@ - net5.0 + net6.0 false - true - enable - AllEnabledByDefault ../jellyfin-tests.ruleset - + - - + + diff --git a/tests/Jellyfin.Networking.Tests/NetworkParseTests.cs b/tests/Jellyfin.Networking.Tests/NetworkParseTests.cs index 97c14d463b..a24eee6934 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; } /// diff --git a/tests/Jellyfin.Providers.Tests/Jellyfin.Providers.Tests.csproj b/tests/Jellyfin.Providers.Tests/Jellyfin.Providers.Tests.csproj index 14bd53db5f..0b2db64b0b 100644 --- a/tests/Jellyfin.Providers.Tests/Jellyfin.Providers.Tests.csproj +++ b/tests/Jellyfin.Providers.Tests/Jellyfin.Providers.Tests.csproj @@ -1,23 +1,20 @@ - net5.0 + net6.0 false - true - enable - AllEnabledByDefault ../jellyfin-tests.ruleset - + runtime; build; native; contentfiles; analyzers; buildtransitive all - + runtime; build; native; contentfiles; analyzers; buildtransitive all diff --git a/tests/Jellyfin.Providers.Tests/MediaInfo/EmbeddedImageProviderTests.cs b/tests/Jellyfin.Providers.Tests/MediaInfo/EmbeddedImageProviderTests.cs new file mode 100644 index 0000000000..b194e38855 --- /dev/null +++ b/tests/Jellyfin.Providers.Tests/MediaInfo/EmbeddedImageProviderTests.cs @@ -0,0 +1,216 @@ +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.MediaEncoding; +using MediaBrowser.Model.Drawing; +using MediaBrowser.Model.Dto; +using MediaBrowser.Model.Entities; +using MediaBrowser.Providers.MediaInfo; +using Moq; +using Xunit; + +namespace Jellyfin.Providers.Tests.MediaInfo +{ + public class EmbeddedImageProviderTests + { + private static TheoryData GetSupportedImages_UnsupportedBaseItems_ReturnsEmpty_TestData() + { + return new () + { + new AudioBook(), + new BoxSet(), + new Series(), + new Season(), + }; + } + + [Theory] + [MemberData(nameof(GetSupportedImages_UnsupportedBaseItems_ReturnsEmpty_TestData))] + public void GetSupportedImages_UnsupportedBaseItems_ReturnsEmpty(BaseItem item) + { + var embeddedImageProvider = GetEmbeddedImageProvider(null); + Assert.Empty(embeddedImageProvider.GetSupportedImages(item)); + } + + private static TheoryData> GetSupportedImages_SupportedBaseItems_ReturnsPopulated_TestData() + { + return new TheoryData> + { + { new Episode(), new List { ImageType.Primary } }, + { new Movie(), new List { ImageType.Logo, ImageType.Backdrop, ImageType.Primary } }, + }; + } + + [Theory] + [MemberData(nameof(GetSupportedImages_SupportedBaseItems_ReturnsPopulated_TestData))] + public void GetSupportedImages_SupportedBaseItems_ReturnsPopulated(BaseItem item, IEnumerable expected) + { + var embeddedImageProvider = GetEmbeddedImageProvider(null); + var actual = embeddedImageProvider.GetSupportedImages(item); + Assert.Equal(expected.OrderBy(i => i.ToString()), actual.OrderBy(i => i.ToString())); + } + + [Fact] + public async void GetImage_InputWithNoStreams_ReturnsNoImage() + { + var embeddedImageProvider = GetEmbeddedImageProvider(null); + + var input = GetMovie(new List(), new List()); + + var actual = await embeddedImageProvider.GetImage(input, ImageType.Primary, CancellationToken.None); + Assert.NotNull(actual); + Assert.False(actual.HasImage); + } + + [Fact] + public async void GetImage_InputWithUnlabeledAttachments_ReturnsNoImage() + { + var embeddedImageProvider = GetEmbeddedImageProvider(null); + + // add an attachment without a filename - has a list to look through but finds nothing + var input = GetMovie( + new List { new () }, + new List()); + + var actual = await embeddedImageProvider.GetImage(input, ImageType.Primary, CancellationToken.None); + Assert.NotNull(actual); + Assert.False(actual.HasImage); + } + + [Fact] + public async void GetImage_InputWithLabeledAttachments_ReturnsCorrectSelection() + { + // first tests file extension detection, second uses mimetype, third defaults to jpg + MediaAttachment sampleAttachment1 = new () { FileName = "clearlogo.png", Index = 1 }; + MediaAttachment sampleAttachment2 = new () { FileName = "backdrop", MimeType = "image/bmp", Index = 2 }; + MediaAttachment sampleAttachment3 = new () { FileName = "poster", Index = 3 }; + string targetPath1 = "path1.png"; + string targetPath2 = "path2.bmp"; + string targetPath3 = "path2.jpg"; + + var mediaEncoder = new Mock(MockBehavior.Strict); + mediaEncoder.Setup(encoder => encoder.ExtractVideoImage(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), 1, ".png", CancellationToken.None)) + .Returns(Task.FromResult(targetPath1)); + mediaEncoder.Setup(encoder => encoder.ExtractVideoImage(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), 2, ".bmp", CancellationToken.None)) + .Returns(Task.FromResult(targetPath2)); + mediaEncoder.Setup(encoder => encoder.ExtractVideoImage(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), 3, ".jpg", CancellationToken.None)) + .Returns(Task.FromResult(targetPath3)); + var embeddedImageProvider = GetEmbeddedImageProvider(mediaEncoder.Object); + + var input = GetMovie( + new List { sampleAttachment1, sampleAttachment2, sampleAttachment3 }, + new List()); + + var actualLogo = await embeddedImageProvider.GetImage(input, ImageType.Logo, CancellationToken.None); + Assert.NotNull(actualLogo); + Assert.True(actualLogo.HasImage); + Assert.Equal(targetPath1, actualLogo.Path); + Assert.Equal(ImageFormat.Png, actualLogo.Format); + + var actualBackdrop = await embeddedImageProvider.GetImage(input, ImageType.Backdrop, CancellationToken.None); + Assert.NotNull(actualBackdrop); + Assert.True(actualBackdrop.HasImage); + Assert.Equal(targetPath2, actualBackdrop.Path); + Assert.Equal(ImageFormat.Bmp, actualBackdrop.Format); + + var actualPrimary = await embeddedImageProvider.GetImage(input, ImageType.Primary, CancellationToken.None); + Assert.NotNull(actualPrimary); + Assert.True(actualPrimary.HasImage); + Assert.Equal(targetPath3, actualPrimary.Path); + Assert.Equal(ImageFormat.Jpg, actualPrimary.Format); + } + + [Fact] + public async void GetImage_InputWithUnlabeledEmbeddedImages_BackdropReturnsNoImage() + { + var embeddedImageProvider = GetEmbeddedImageProvider(null); + + var input = GetMovie( + new List(), + new List { new () { Type = MediaStreamType.EmbeddedImage } }); + + var actual = await embeddedImageProvider.GetImage(input, ImageType.Backdrop, CancellationToken.None); + Assert.NotNull(actual); + Assert.False(actual.HasImage); + } + + [Fact] + public async void GetImage_InputWithUnlabeledEmbeddedImages_PrimaryReturnsImage() + { + MediaStream sampleStream = new () { Type = MediaStreamType.EmbeddedImage, Index = 1 }; + string targetPath = "path"; + + var mediaEncoder = new Mock(MockBehavior.Strict); + mediaEncoder.Setup(encoder => encoder.ExtractVideoImage(It.IsAny(), It.IsAny(), It.IsAny(), sampleStream, 1, ".jpg", CancellationToken.None)) + .Returns(Task.FromResult(targetPath)); + var embeddedImageProvider = GetEmbeddedImageProvider(mediaEncoder.Object); + + var input = GetMovie( + new List(), + new List { sampleStream }); + + var actual = await embeddedImageProvider.GetImage(input, ImageType.Primary, CancellationToken.None); + Assert.NotNull(actual); + Assert.True(actual.HasImage); + Assert.Equal(targetPath, actual.Path); + Assert.Equal(ImageFormat.Jpg, actual.Format); + } + + [Fact] + public async void GetImage_InputWithLabeledEmbeddedImages_ReturnsCorrectSelection() + { + // primary is second stream to ensure it's not defaulting, backdrop is first + MediaStream sampleStream1 = new () { Type = MediaStreamType.EmbeddedImage, Index = 1, Comment = "backdrop" }; + MediaStream sampleStream2 = new () { Type = MediaStreamType.EmbeddedImage, Index = 2, Comment = "cover" }; + string targetPath1 = "path1.jpg"; + string targetPath2 = "path2.jpg"; + + var mediaEncoder = new Mock(MockBehavior.Strict); + mediaEncoder.Setup(encoder => encoder.ExtractVideoImage(It.IsAny(), It.IsAny(), It.IsAny(), sampleStream1, 1, ".jpg", CancellationToken.None)) + .Returns(Task.FromResult(targetPath1)); + mediaEncoder.Setup(encoder => encoder.ExtractVideoImage(It.IsAny(), It.IsAny(), It.IsAny(), sampleStream2, 2, ".jpg", CancellationToken.None)) + .Returns(Task.FromResult(targetPath2)); + var embeddedImageProvider = GetEmbeddedImageProvider(mediaEncoder.Object); + + var input = GetMovie( + new List(), + new List { sampleStream1, sampleStream2 }); + + var actualPrimary = await embeddedImageProvider.GetImage(input, ImageType.Primary, CancellationToken.None); + Assert.NotNull(actualPrimary); + Assert.True(actualPrimary.HasImage); + Assert.Equal(targetPath2, actualPrimary.Path); + Assert.Equal(ImageFormat.Jpg, actualPrimary.Format); + + var actualBackdrop = await embeddedImageProvider.GetImage(input, ImageType.Backdrop, CancellationToken.None); + Assert.NotNull(actualBackdrop); + Assert.True(actualBackdrop.HasImage); + Assert.Equal(targetPath1, actualBackdrop.Path); + Assert.Equal(ImageFormat.Jpg, actualBackdrop.Format); + } + + private static EmbeddedImageProvider GetEmbeddedImageProvider(IMediaEncoder? mediaEncoder) + { + return new EmbeddedImageProvider(mediaEncoder); + } + + private static Movie GetMovie(List mediaAttachments, List mediaStreams) + { + // Mocking IMediaSourceManager GetMediaAttachments and GetMediaStreams instead of mocking Movie works, but + // has concurrency problems between this and VideoImageProviderTests due to BaseItem.MediaSourceManager + // being static + var movie = new Mock(); + + movie.Setup(item => item.GetMediaSources(It.IsAny())) + .Returns(new List { new () { MediaAttachments = mediaAttachments } } ); + movie.Setup(item => item.GetMediaStreams()) + .Returns(mediaStreams); + + return movie.Object; + } + } +} diff --git a/tests/Jellyfin.Providers.Tests/MediaInfo/SubtitleResolverTests.cs b/tests/Jellyfin.Providers.Tests/MediaInfo/SubtitleResolverTests.cs index b160e676e4..c289a71129 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] diff --git a/tests/Jellyfin.Providers.Tests/MediaInfo/VideoImageProviderTests.cs b/tests/Jellyfin.Providers.Tests/MediaInfo/VideoImageProviderTests.cs new file mode 100644 index 0000000000..0f51a2b8f8 --- /dev/null +++ b/tests/Jellyfin.Providers.Tests/MediaInfo/VideoImageProviderTests.cs @@ -0,0 +1,176 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Controller.Entities.Movies; +using MediaBrowser.Controller.MediaEncoding; +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 + { + [Fact] + public async void GetImage_InputIsPlaceholder_ReturnsNoImage() + { + var videoImageProvider = GetVideoImageProvider(null); + + var input = new Movie + { + IsPlaceHolder = true + }; + + var actual = await videoImageProvider.GetImage(input, ImageType.Primary, CancellationToken.None); + Assert.NotNull(actual); + Assert.False(actual.HasImage); + } + + [Fact] + public async void GetImage_NoDefaultVideoStream_ReturnsNoImage() + { + var videoImageProvider = GetVideoImageProvider(null); + + var input = new Movie + { + DefaultVideoStreamIndex = null + }; + + var actual = await videoImageProvider.GetImage(input, ImageType.Primary, CancellationToken.None); + Assert.NotNull(actual); + Assert.False(actual.HasImage); + } + + [Fact] + public async void GetImage_DefaultSetButNoVideoStream_ReturnsNoImage() + { + var videoImageProvider = GetVideoImageProvider(null); + + // set a default index but don't put anything there (invalid input, but provider shouldn't break) + var input = GetMovie(0, null, new List()); + + var actual = await videoImageProvider.GetImage(input, ImageType.Primary, CancellationToken.None); + Assert.NotNull(actual); + Assert.False(actual.HasImage); + } + + [Fact] + public async void GetImage_DefaultSetMultipleVideoStreams_ReturnsDefaultStreamImage() + { + MediaStream firstStream = new () { Type = MediaStreamType.Video, Index = 0 }; + MediaStream targetStream = new () { Type = MediaStreamType.Video, Index = 1 }; + string targetPath = "path.jpg"; + + var mediaEncoder = new Mock(MockBehavior.Strict); + mediaEncoder.Setup(encoder => encoder.ExtractVideoImage(It.IsAny(), It.IsAny(), It.IsAny(), firstStream, It.IsAny(), It.IsAny(), CancellationToken.None)) + .Returns(Task.FromResult("wrong stream called!")); + mediaEncoder.Setup(encoder => encoder.ExtractVideoImage(It.IsAny(), It.IsAny(), It.IsAny(), targetStream, It.IsAny(), It.IsAny(), CancellationToken.None)) + .Returns(Task.FromResult(targetPath)); + var videoImageProvider = GetVideoImageProvider(mediaEncoder.Object); + + var input = GetMovie(1, targetStream, new List { firstStream, targetStream } ); + + var actual = await videoImageProvider.GetImage(input, ImageType.Primary, CancellationToken.None); + Assert.NotNull(actual); + Assert.True(actual.HasImage); + Assert.Equal(targetPath, actual.Path); + Assert.Equal(ImageFormat.Jpg, actual.Format); + } + + [Fact] + public async void GetImage_InvalidDefaultSingleVideoStream_ReturnsFirstVideoStreamImage() + { + MediaStream targetStream = new () { Type = MediaStreamType.Video, Index = 0 }; + string targetPath = "path.jpg"; + + var mediaEncoder = new Mock(MockBehavior.Strict); + mediaEncoder.Setup(encoder => encoder.ExtractVideoImage(It.IsAny(), It.IsAny(), It.IsAny(), targetStream, It.IsAny(), It.IsAny(), CancellationToken.None)) + .Returns(Task.FromResult(targetPath)); + var videoImageProvider = GetVideoImageProvider(mediaEncoder.Object); + + // provide query results for default (empty) and all streams (populated) + var input = GetMovie(5, null, new List { targetStream }); + + var actual = await videoImageProvider.GetImage(input, ImageType.Primary, CancellationToken.None); + Assert.NotNull(actual); + Assert.True(actual.HasImage); + Assert.Equal(targetPath, actual.Path); + Assert.Equal(ImageFormat.Jpg, actual.Format); + } + + [Fact] + public async void GetImage_NoTimeSpanSet_CallsEncoderWithDefaultTime() + { + MediaStream targetStream = new () { Type = MediaStreamType.Video, Index = 0 }; + + // use a callback to catch the actual value + // provides more information on failure than verifying a specific input was called on the mock + TimeSpan? actualTimeSpan = null; + var mediaEncoder = new Mock(MockBehavior.Strict); + mediaEncoder.Setup(encoder => encoder.ExtractVideoImage(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), CancellationToken.None)) + .Callback((_, _, _, _, _, timeSpan, _) => actualTimeSpan = timeSpan) + .Returns(Task.FromResult("path")); + var videoImageProvider = GetVideoImageProvider(mediaEncoder.Object); + + var input = GetMovie(0, targetStream, new List { targetStream }); + + // not testing return, just verifying what gets requested for time span + await videoImageProvider.GetImage(input, ImageType.Primary, CancellationToken.None); + + Assert.Equal(TimeSpan.FromSeconds(10), actualTimeSpan); + } + + [Fact] + public async void GetImage_TimeSpanSet_CallsEncoderWithCalculatedTime() + { + MediaStream targetStream = new () { Type = MediaStreamType.Video, Index = 0 }; + + TimeSpan? actualTimeSpan = null; + var mediaEncoder = new Mock(MockBehavior.Strict); + mediaEncoder.Setup(encoder => encoder.ExtractVideoImage(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), CancellationToken.None)) + .Callback((_, _, _, _, _, timeSpan, _) => actualTimeSpan = timeSpan) + .Returns(Task.FromResult("path")); + var videoImageProvider = GetVideoImageProvider(mediaEncoder.Object); + + var input = GetMovie(0, targetStream, new List { targetStream }); + input.RunTimeTicks = 5000; + + // not testing return, just verifying what gets requested for time span + await videoImageProvider.GetImage(input, ImageType.Primary, CancellationToken.None); + + Assert.Equal(TimeSpan.FromTicks(500), actualTimeSpan); + } + + private static VideoImageProvider GetVideoImageProvider(IMediaEncoder? mediaEncoder) + { + // strict to ensure this isn't accidentally used where a prepared mock is intended + mediaEncoder ??= new Mock(MockBehavior.Strict).Object; + return new VideoImageProvider(mediaEncoder, new NullLogger()); + } + + private static Movie GetMovie(int defaultVideoStreamIndex, MediaStream? defaultStream, List mediaStreams) + { + // Mocking IMediaSourceManager GetMediaStreams instead of mocking Movie works, but has concurrency problems + // between this and EmbeddedImageProviderTests due to BaseItem.MediaSourceManager being static + var movie = new Mock + { + Object = + { + DefaultVideoStreamIndex = defaultVideoStreamIndex + } + }; + + movie.Setup(item => item.GetDefaultVideoStream()) + .Returns(defaultStream!); + movie.Setup(item => item.GetMediaStreams()) + .Returns(mediaStreams); + + return movie.Object; + } + } +} diff --git a/tests/Jellyfin.Server.Implementations.Tests/Data/SqliteItemRepositoryTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Data/SqliteItemRepositoryTests.cs index f312933fbf..6337dea412 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/Data/SqliteItemRepositoryTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/Data/SqliteItemRepositoryTests.cs @@ -32,10 +32,11 @@ namespace Jellyfin.Server.Implementations.Tests.Data _sqliteItemRepository = _fixture.Create(); } - public static IEnumerable ItemImageInfoFromValueString_Valid_TestData() + public static TheoryData ItemImageInfoFromValueString_Valid_TestData() { - yield return new object[] - { + var data = new TheoryData(); + + data.Add( "/mnt/series/Family Guy/Season 1/Family Guy - S01E01-thumb.jpg*637452096478512963*Primary*1920*1080*WjQbtJtSO8nhNZ%L_Io#R/oaS6o}-;adXAoIn7j[%hW9s:WGw[nN", new ItemImageInfo { @@ -45,41 +46,33 @@ namespace Jellyfin.Server.Implementations.Tests.Data Width = 1920, Height = 1080, BlurHash = "WjQbtJtSO8nhNZ%L_Io#R*oaS6o}-;adXAoIn7j[%hW9s:WGw[nN" - } - }; + }); - yield return new object[] - { + data.Add( "https://image.tmdb.org/t/p/original/zhB5CHEgqqh4wnEqDNJLfWXJlcL.jpg*0*Primary*0*0", new ItemImageInfo { Path = "https://image.tmdb.org/t/p/original/zhB5CHEgqqh4wnEqDNJLfWXJlcL.jpg", Type = ImageType.Primary, - } - }; + }); - yield return new object[] - { + data.Add( "https://image.tmdb.org/t/p/original/zhB5CHEgqqh4wnEqDNJLfWXJlcL.jpg*0*Primary", new ItemImageInfo { Path = "https://image.tmdb.org/t/p/original/zhB5CHEgqqh4wnEqDNJLfWXJlcL.jpg", Type = ImageType.Primary, - } - }; + }); - yield return new object[] - { + data.Add( "https://image.tmdb.org/t/p/original/zhB5CHEgqqh4wnEqDNJLfWXJlcL.jpg*0*Primary*600", new ItemImageInfo { Path = "https://image.tmdb.org/t/p/original/zhB5CHEgqqh4wnEqDNJLfWXJlcL.jpg", Type = ImageType.Primary, - } - }; + }); - yield return new object[] - { + data.Add( "%MetadataPath%/library/68/68578562b96c80a7ebd530848801f645/poster.jpg*637264380567586027*Primary*600*336", new ItemImageInfo { @@ -88,8 +81,9 @@ namespace Jellyfin.Server.Implementations.Tests.Data DateModified = new DateTime(637264380567586027, DateTimeKind.Utc), Width = 600, Height = 336 - } - }; + }); + + return data; } [Theory] @@ -109,15 +103,18 @@ namespace Jellyfin.Server.Implementations.Tests.Data [InlineData("")] [InlineData("*")] [InlineData("https://image.tmdb.org/t/p/original/zhB5CHEgqqh4wnEqDNJLfWXJlcL.jpg*0")] + [InlineData("/mnt/series/Family Guy/Season 1/Family Guy - S01E01-thumb.jpg*6374520964785129080*WjQbtJtSO8nhNZ%L_Io#R/oaS DeserializeImages_Valid_TestData() + public static TheoryData DeserializeImages_Valid_TestData() { - yield return new object[] - { + var data = new TheoryData(); + data.Add( "/mnt/series/Family Guy/Season 1/Family Guy - S01E01-thumb.jpg*637452096478512963*Primary*1920*1080*WjQbtJtSO8nhNZ%L_Io#R/oaS6o}-;adXAoIn7j[%hW9s:WGw[nN", new ItemImageInfo[] { @@ -130,11 +127,9 @@ namespace Jellyfin.Server.Implementations.Tests.Data Height = 1080, BlurHash = "WjQbtJtSO8nhNZ%L_Io#R*oaS6o}-;adXAoIn7j[%hW9s:WGw[nN" } - } - }; + }); - yield return new object[] - { + data.Add( "%MetadataPath%/library/2a/2a27372f1e9bc757b1db99721bbeae1e/poster.jpg*637261226720645297*Primary*0*0|%MetadataPath%/library/2a/2a27372f1e9bc757b1db99721bbeae1e/logo.png*637261226720805297*Logo*0*0|%MetadataPath%/library/2a/2a27372f1e9bc757b1db99721bbeae1e/landscape.jpg*637261226721285297*Thumb*0*0|%MetadataPath%/library/2a/2a27372f1e9bc757b1db99721bbeae1e/backdrop.jpg*637261226721685297*Backdrop*0*0", new ItemImageInfo[] { @@ -162,20 +157,19 @@ namespace Jellyfin.Server.Implementations.Tests.Data Type = ImageType.Backdrop, DateModified = new DateTime(637261226721685297, DateTimeKind.Utc), } - } - }; + }); + + return data; } - public static IEnumerable DeserializeImages_ValidAndInvalid_TestData() + public static TheoryData DeserializeImages_ValidAndInvalid_TestData() { - yield return new object[] - { + var data = new TheoryData(); + data.Add( string.Empty, - Array.Empty() - }; + Array.Empty()); - yield return new object[] - { + data.Add( "/mnt/series/Family Guy/Season 1/Family Guy - S01E01-thumb.jpg*637452096478512963*Primary*1920*1080*WjQbtJtSO8nhNZ%L_Io#R/oaS6o}-;adXAoIn7j[%hW9s:WGw[nN|test|1234||ss", new ItemImageInfo[] { @@ -188,14 +182,13 @@ namespace Jellyfin.Server.Implementations.Tests.Data Height = 1080, BlurHash = "WjQbtJtSO8nhNZ%L_Io#R*oaS6o}-;adXAoIn7j[%hW9s:WGw[nN" } - } - }; + }); - yield return new object[] - { + data.Add( "|", - Array.Empty() - }; + Array.Empty()); + + return data; } [Theory] @@ -239,30 +232,27 @@ namespace Jellyfin.Server.Implementations.Tests.Data Assert.Equal(expected, _sqliteItemRepository.SerializeImages(value)); } - public static IEnumerable DeserializeProviderIds_Valid_TestData() + public static TheoryData> DeserializeProviderIds_Valid_TestData() { - yield return new object[] - { + var data = new TheoryData>(); + + data.Add( "Imdb=tt0119567", new Dictionary() { { "Imdb", "tt0119567" }, - } - }; + }); - yield return new object[] - { + data.Add( "Imdb=tt0119567|Tmdb=330|TmdbCollection=328", new Dictionary() { { "Imdb", "tt0119567" }, { "Tmdb", "330" }, { "TmdbCollection", "328" }, - } - }; + }); - yield return new object[] - { + data.Add( "MusicBrainzAlbum=9d363e43-f24f-4b39-bc5a-7ef305c677c7|MusicBrainzReleaseGroup=63eba062-847c-3b73-8b0f-6baf27bba6fa|AudioDbArtist=111352|AudioDbAlbum=2116560|MusicBrainzAlbumArtist=20244d07-534f-4eff-b4d4-930878889970", new Dictionary() { @@ -271,8 +261,9 @@ namespace Jellyfin.Server.Implementations.Tests.Data { "AudioDbArtist", "111352" }, { "AudioDbAlbum", "2116560" }, { "MusicBrainzAlbumArtist", "20244d07-534f-4eff-b4d4-930878889970" }, - } - }; + }); + + return data; } [Theory] diff --git a/tests/Jellyfin.Server.Implementations.Tests/IO/ManagedFileSystemTests.cs b/tests/Jellyfin.Server.Implementations.Tests/IO/ManagedFileSystemTests.cs index 30e6542f94..d991f55748 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/IO/ManagedFileSystemTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/IO/ManagedFileSystemTests.cs @@ -1,10 +1,10 @@ +using System; using System.Diagnostics.CodeAnalysis; using System.IO; using System.Runtime.InteropServices; using AutoFixture; using AutoFixture.AutoMoq; using Emby.Server.Implementations.IO; -using MediaBrowser.Model.System; using Xunit; namespace Jellyfin.Server.Implementations.Tests.IO @@ -31,7 +31,7 @@ namespace Jellyfin.Server.Implementations.Tests.IO { var generatedPath = _sut.MakeAbsolutePath(folderPath, filePath); - if (MediaBrowser.Common.System.OperatingSystem.Id == OperatingSystemId.Windows) + if (OperatingSystem.IsWindows()) { var expectedWindowsPath = expectedAbsolutePath.Replace('/', '\\'); Assert.Equal(expectedWindowsPath, generatedPath.Split(':')[1]); @@ -55,7 +55,7 @@ namespace Jellyfin.Server.Implementations.Tests.IO [SkippableFact] public void GetFileInfo_DanglingSymlink_ExistsFalse() { - Skip.If(RuntimeInformation.IsOSPlatform(OSPlatform.Windows)); + Skip.If(OperatingSystem.IsWindows()); string testFileDir = Path.Combine(Path.GetTempPath(), "jellyfin-test-data"); string testFileName = Path.Combine(testFileDir, Path.GetRandomFileName() + "-danglingsym.link"); diff --git a/tests/Jellyfin.Server.Implementations.Tests/Jellyfin.Server.Implementations.Tests.csproj b/tests/Jellyfin.Server.Implementations.Tests/Jellyfin.Server.Implementations.Tests.csproj index adbca83446..5ecd846047 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/Jellyfin.Server.Implementations.Tests.csproj +++ b/tests/Jellyfin.Server.Implementations.Tests/Jellyfin.Server.Implementations.Tests.csproj @@ -6,11 +6,8 @@ - net5.0 + net6.0 false - true - enable - AllEnabledByDefault ../jellyfin-tests.ruleset Jellyfin.Server.Implementations.Tests @@ -24,12 +21,12 @@ - + - + diff --git a/tests/Jellyfin.Server.Implementations.Tests/LiveTv/RecordingHelperTests.cs b/tests/Jellyfin.Server.Implementations.Tests/LiveTv/RecordingHelperTests.cs index e8b93b437b..976afe1958 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/LiveTv/RecordingHelperTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/LiveTv/RecordingHelperTests.cs @@ -1,5 +1,4 @@ using System; -using System.Collections.Generic; using Emby.Server.Implementations.LiveTv.EmbyTV; using MediaBrowser.Controller.LiveTv; using Xunit; @@ -8,43 +7,36 @@ namespace Jellyfin.Server.Implementations.Tests.LiveTv { public static class RecordingHelperTests { - public static IEnumerable GetRecordingName_Success_TestData() + public static TheoryData GetRecordingName_Success_TestData() { - yield return new object[] - { + var data = new TheoryData(); + + data.Add( "The Incredibles 2020_04_20_21_06_00", new TimerInfo { Name = "The Incredibles", StartDate = new DateTime(2020, 4, 20, 21, 6, 0, DateTimeKind.Local), IsMovie = true - } - }; + }); - yield return new object[] - { + data.Add( "The Incredibles (2004)", new TimerInfo { Name = "The Incredibles", IsMovie = true, ProductionYear = 2004 - } - }; - - yield return new object[] - { + }); + data.Add( "The Big Bang Theory 2020_04_20_21_06_00", new TimerInfo { Name = "The Big Bang Theory", StartDate = new DateTime(2020, 4, 20, 21, 6, 0, DateTimeKind.Local), IsProgramSeries = true, - } - }; - - yield return new object[] - { + }); + data.Add( "The Big Bang Theory S12E10", new TimerInfo { @@ -52,11 +44,8 @@ namespace Jellyfin.Server.Implementations.Tests.LiveTv IsProgramSeries = true, SeasonNumber = 12, EpisodeNumber = 10 - } - }; - - yield return new object[] - { + }); + data.Add( "The Big Bang Theory S12E10 The VCR Illumination", new TimerInfo { @@ -65,22 +54,17 @@ namespace Jellyfin.Server.Implementations.Tests.LiveTv SeasonNumber = 12, EpisodeNumber = 10, EpisodeTitle = "The VCR Illumination" - } - }; - - yield return new object[] - { + }); + data.Add( "The Big Bang Theory 2018-12-06", new TimerInfo { Name = "The Big Bang Theory", IsProgramSeries = true, OriginalAirDate = new DateTime(2018, 12, 6) - } - }; + }); - yield return new object[] - { + data.Add( "The Big Bang Theory 2018-12-06 - The VCR Illumination", new TimerInfo { @@ -88,11 +72,9 @@ namespace Jellyfin.Server.Implementations.Tests.LiveTv IsProgramSeries = true, OriginalAirDate = new DateTime(2018, 12, 6), EpisodeTitle = "The VCR Illumination" - } - }; + }); - yield return new object[] - { + data.Add( "The Big Bang Theory 2018_12_06_21_06_00 - The VCR Illumination", new TimerInfo { @@ -101,8 +83,9 @@ namespace Jellyfin.Server.Implementations.Tests.LiveTv IsProgramSeries = true, OriginalAirDate = new DateTime(2018, 12, 6), EpisodeTitle = "The VCR Illumination" - } - }; + }); + + return data; } [Theory] diff --git a/tests/Jellyfin.Server.Implementations.Tests/LiveTv/SchedulesDirect/SchedulesDirectDeserializeTests.cs b/tests/Jellyfin.Server.Implementations.Tests/LiveTv/SchedulesDirect/SchedulesDirectDeserializeTests.cs new file mode 100644 index 0000000000..3b3e38bd1f --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/LiveTv/SchedulesDirect/SchedulesDirectDeserializeTests.cs @@ -0,0 +1,240 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text.Json; +using Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos; +using Jellyfin.Extensions.Json; +using Xunit; + +namespace Jellyfin.Server.Implementations.Tests.LiveTv.SchedulesDirect +{ + public class SchedulesDirectDeserializeTests + { + private readonly JsonSerializerOptions _jsonOptions; + + public SchedulesDirectDeserializeTests() + { + _jsonOptions = JsonDefaults.Options; + } + + /// + /// /token reponse. + /// + [Fact] + public void Deserialize_Token_Response_Live_Success() + { + var bytes = File.ReadAllBytes("Test Data/SchedulesDirect/token_live_response.json"); + var tokenDto = JsonSerializer.Deserialize(bytes, _jsonOptions); + + Assert.NotNull(tokenDto); + Assert.Equal(0, tokenDto!.Code); + Assert.Equal("OK", tokenDto.Message); + Assert.Equal("AWS-SD-web.1", tokenDto.ServerId); + Assert.Equal(new DateTime(2016, 08, 23, 13, 55, 25, DateTimeKind.Utc), tokenDto.TokenTimestamp); + Assert.Equal("f3fca79989cafe7dead71beefedc812b", tokenDto.Token); + } + + /// + /// /token response. + /// + [Fact] + public void Deserialize_Token_Response_Offline_Success() + { + var bytes = File.ReadAllBytes("Test Data/SchedulesDirect/token_offline_response.json"); + var tokenDto = JsonSerializer.Deserialize(bytes, _jsonOptions); + + Assert.NotNull(tokenDto); + Assert.Equal(3_000, tokenDto!.Code); + Assert.Equal("Server offline for maintenance.", tokenDto.Message); + Assert.Equal("20141201.web.1", tokenDto.ServerId); + Assert.Equal(new DateTime(2015, 04, 23, 00, 03, 32, DateTimeKind.Utc), tokenDto.TokenTimestamp); + Assert.Equal("CAFEDEADBEEFCAFEDEADBEEFCAFEDEADBEEFCAFE", tokenDto.Token); + Assert.Equal("SERVICE_OFFLINE", tokenDto.Response); + } + + /// + /// /schedules request. + /// + [Fact] + public void Serialize_Schedule_Request_Success() + { + var expectedString = File.ReadAllText("Test Data/SchedulesDirect/schedules_request.json").Trim(); + + var requestObject = new RequestScheduleForChannelDto[] + { + new RequestScheduleForChannelDto + { + StationId = "20454", + Date = new[] + { + "2015-03-13", + "2015-03-17" + } + }, + new RequestScheduleForChannelDto + { + StationId = "10021", + Date = new[] + { + "2015-03-12", + "2015-03-13" + } + } + }; + + var requestString = JsonSerializer.Serialize(requestObject, _jsonOptions); + Assert.Equal(expectedString, requestString); + } + + /// + /// /schedules response. + /// + [Fact] + public void Deserialize_Schedule_Response_Success() + { + var bytes = File.ReadAllBytes("Test Data/SchedulesDirect/schedules_response.json"); + var days = JsonSerializer.Deserialize>(bytes, _jsonOptions); + + Assert.NotNull(days); + Assert.Equal(1, days!.Count); + + var dayDto = days[0]; + Assert.Equal("20454", dayDto.StationId); + Assert.Equal(2, dayDto.Programs.Count); + + Assert.Equal("SH005371070000", dayDto.Programs[0].ProgramId); + Assert.Equal(new DateTime(2015, 03, 03, 00, 00, 00, DateTimeKind.Utc), dayDto.Programs[0].AirDateTime); + Assert.Equal(1_800, dayDto.Programs[0].Duration); + Assert.Equal("Sy8HEMBPcuiAx3FBukUhKQ", dayDto.Programs[0].Md5); + Assert.True(dayDto.Programs[0].New); + Assert.Equal(2, dayDto.Programs[0].AudioProperties.Count); + Assert.Equal("stereo", dayDto.Programs[0].AudioProperties[0]); + Assert.Equal("cc", dayDto.Programs[0].AudioProperties[1]); + Assert.Equal(1, dayDto.Programs[0].VideoProperties.Count); + Assert.Equal("hdtv", dayDto.Programs[0].VideoProperties[0]); + } + + /// + /// /programs response. + /// + [Fact] + public void Deserialize_Program_Response_Success() + { + var bytes = File.ReadAllBytes("Test Data/SchedulesDirect/programs_response.json"); + var programDtos = JsonSerializer.Deserialize>(bytes, _jsonOptions); + + Assert.NotNull(programDtos); + Assert.Equal(2, programDtos!.Count); + Assert.Equal("EP000000060003", programDtos[0].ProgramId); + Assert.Equal(1, programDtos[0].Titles.Count); + Assert.Equal("'Allo 'Allo!", programDtos[0].Titles[0].Title120); + Assert.Equal("Series", programDtos[0].EventDetails?.SubType); + Assert.Equal("en", programDtos[0].Descriptions?.Description1000[0].DescriptionLanguage); + Assert.Equal("A disguised British Intelligence officer is sent to help the airmen.", programDtos[0].Descriptions?.Description1000[0].Description); + Assert.Equal(new DateTime(1985, 11, 04), programDtos[0].OriginalAirDate); + Assert.Equal(1, programDtos[0].Genres.Count); + Assert.Equal("Sitcom", programDtos[0].Genres[0]); + Assert.Equal("The Poloceman Cometh", programDtos[0].EpisodeTitle150); + Assert.Equal(2, programDtos[0].Metadata[0].Gracenote?.Season); + Assert.Equal(3, programDtos[0].Metadata[0].Gracenote?.Episode); + Assert.Equal(13, programDtos[0].Cast.Count); + Assert.Equal("383774", programDtos[0].Cast[0].PersonId); + Assert.Equal("392649", programDtos[0].Cast[0].NameId); + Assert.Equal("Gorden Kaye", programDtos[0].Cast[0].Name); + Assert.Equal("Actor", programDtos[0].Cast[0].Role); + Assert.Equal("01", programDtos[0].Cast[0].BillingOrder); + Assert.Equal(3, programDtos[0].Crew.Count); + Assert.Equal("354407", programDtos[0].Crew[0].PersonId); + Assert.Equal("363281", programDtos[0].Crew[0].NameId); + Assert.Equal("David Croft", programDtos[0].Crew[0].Name); + Assert.Equal("Director", programDtos[0].Crew[0].Role); + Assert.Equal("01", programDtos[0].Crew[0].BillingOrder); + } + + /// + /// /metadata/programs response. + /// + [Fact] + public void Deserialize_Metadata_Programs_Response_Success() + { + var bytes = File.ReadAllBytes("Test Data/SchedulesDirect/metadata_programs_response.json"); + var showImagesDtos = JsonSerializer.Deserialize>(bytes, _jsonOptions); + + Assert.NotNull(showImagesDtos); + Assert.Equal(1, showImagesDtos!.Count); + Assert.Equal("SH00712240", showImagesDtos[0].ProgramId); + Assert.Equal(4, showImagesDtos[0].Data.Count); + Assert.Equal("135", showImagesDtos[0].Data[0].Width); + Assert.Equal("180", showImagesDtos[0].Data[0].Height); + Assert.Equal("assets/p282288_b_v2_aa.jpg", showImagesDtos[0].Data[0].Uri); + Assert.Equal("Sm", showImagesDtos[0].Data[0].Size); + Assert.Equal("3x4", showImagesDtos[0].Data[0].Aspect); + Assert.Equal("Banner-L3", showImagesDtos[0].Data[0].Category); + Assert.Equal("yes", showImagesDtos[0].Data[0].Text); + Assert.Equal("true", showImagesDtos[0].Data[0].Primary); + Assert.Equal("Series", showImagesDtos[0].Data[0].Tier); + } + + /// + /// /headends response. + /// + [Fact] + public void Deserialize_Headends_Response_Success() + { + var bytes = File.ReadAllBytes("Test Data/SchedulesDirect/headends_response.json"); + var headendsDtos = JsonSerializer.Deserialize>(bytes, _jsonOptions); + + Assert.NotNull(headendsDtos); + Assert.Equal(8, headendsDtos!.Count); + Assert.Equal("CA00053", headendsDtos[0].Headend); + Assert.Equal("Cable", headendsDtos[0].Transport); + Assert.Equal("Beverly Hills", headendsDtos[0].Location); + Assert.Equal(2, headendsDtos[0].Lineups.Count); + Assert.Equal("Time Warner Cable - Cable", headendsDtos[0].Lineups[0].Name); + Assert.Equal("USA-CA00053-DEFAULT", headendsDtos[0].Lineups[0].Lineup); + Assert.Equal("/20141201/lineups/USA-CA00053-DEFAULT", headendsDtos[0].Lineups[0].Uri); + } + + /// + /// /lineups response. + /// + [Fact] + public void Deserialize_Lineups_Response_Success() + { + var bytes = File.ReadAllBytes("Test Data/SchedulesDirect/lineups_response.json"); + var lineupsDto = JsonSerializer.Deserialize(bytes, _jsonOptions); + + Assert.NotNull(lineupsDto); + Assert.Equal(0, lineupsDto!.Code); + Assert.Equal("20141201.web.1", lineupsDto.ServerId); + Assert.Equal(new DateTime(2015, 04, 17, 14, 22, 17, DateTimeKind.Utc), lineupsDto.LineupTimestamp); + Assert.Equal(5, lineupsDto.Lineups.Count); + Assert.Equal("GBR-0001317-DEFAULT", lineupsDto.Lineups[0].Lineup); + Assert.Equal("Freeview - Carlton - LWT (Southeast)", lineupsDto.Lineups[0].Name); + Assert.Equal("DVB-T", lineupsDto.Lineups[0].Transport); + Assert.Equal("London", lineupsDto.Lineups[0].Location); + Assert.Equal("/20141201/lineups/GBR-0001317-DEFAULT", lineupsDto.Lineups[0].Uri); + + Assert.Equal("DELETED LINEUP", lineupsDto.Lineups[4].Name); + Assert.True(lineupsDto.Lineups[4].IsDeleted); + } + + /// + /// /lineup/:id response. + /// + [Fact] + public void Deserialize_Lineup_Response_Success() + { + var bytes = File.ReadAllBytes("Test Data/SchedulesDirect/lineup_response.json"); + var channelDto = JsonSerializer.Deserialize(bytes, _jsonOptions); + + Assert.NotNull(channelDto); + Assert.Equal(2, channelDto!.Map.Count); + Assert.Equal("24326", channelDto.Map[0].StationId); + Assert.Equal("001", channelDto.Map[0].Channel); + Assert.Equal("BBC ONE South", channelDto.Map[0].ProvderCallsign); + Assert.Equal("1", channelDto.Map[0].LogicalChannelNumber); + Assert.Equal("providerCallsign", channelDto.Map[0].MatchType); + } + } +} diff --git a/tests/Jellyfin.Server.Implementations.Tests/Localization/LocalizationManagerTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Localization/LocalizationManagerTests.cs new file mode 100644 index 0000000000..143020d436 --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/Localization/LocalizationManagerTests.cs @@ -0,0 +1,179 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using Emby.Server.Implementations.Localization; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Model.Configuration; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Xunit; + +namespace Jellyfin.Server.Implementations.Tests.Localization +{ + public class LocalizationManagerTests + { + [Fact] + public void GetCountries_All_Success() + { + var localizationManager = Setup(new ServerConfiguration + { + UICulture = "de-DE" + }); + var countries = localizationManager.GetCountries().ToList(); + + Assert.Equal(139, countries.Count); + + var germany = countries.FirstOrDefault(x => x.Name.Equals("DE", StringComparison.Ordinal)); + Assert.NotNull(germany); + Assert.Equal("Germany", germany!.DisplayName); + Assert.Equal("DEU", germany.ThreeLetterISORegionName); + Assert.Equal("DE", germany.TwoLetterISORegionName); + } + + [Fact] + public async Task GetCultures_All_Success() + { + var localizationManager = Setup(new ServerConfiguration + { + UICulture = "de-DE" + }); + await localizationManager.LoadAll(); + var cultures = localizationManager.GetCultures().ToList(); + + Assert.Equal(189, cultures.Count); + + var germany = cultures.FirstOrDefault(x => x.TwoLetterISOLanguageName.Equals("de", StringComparison.Ordinal)); + Assert.NotNull(germany); + Assert.Equal("ger", germany!.ThreeLetterISOLanguageName); + Assert.Equal("German", germany.DisplayName); + Assert.Equal("German", germany.Name); + Assert.Contains("deu", germany.ThreeLetterISOLanguageNames); + Assert.Contains("ger", germany.ThreeLetterISOLanguageNames); + } + + [Theory] + [InlineData("de")] + [InlineData("ger")] + [InlineData("german")] + public async Task FindLanguageInfo_Valid_Success(string identifier) + { + var localizationManager = Setup(new ServerConfiguration + { + UICulture = "de-DE" + }); + await localizationManager.LoadAll(); + + var germany = localizationManager.FindLanguageInfo(identifier); + Assert.NotNull(germany); + + Assert.Equal("ger", germany!.ThreeLetterISOLanguageName); + Assert.Equal("German", germany.DisplayName); + Assert.Equal("German", germany.Name); + Assert.Contains("deu", germany.ThreeLetterISOLanguageNames); + Assert.Contains("ger", germany.ThreeLetterISOLanguageNames); + } + + [Fact] + public async Task GetParentalRatings_Default_Success() + { + var localizationManager = Setup(new ServerConfiguration + { + UICulture = "de-DE" + }); + await localizationManager.LoadAll(); + var ratings = localizationManager.GetParentalRatings().ToList(); + + Assert.Equal(23, ratings.Count); + + var tvma = ratings.FirstOrDefault(x => x.Name.Equals("TV-MA", StringComparison.Ordinal)); + Assert.NotNull(tvma); + Assert.Equal(9, tvma!.Value); + } + + [Fact] + public async Task GetParentalRatings_ConfiguredCountryCode_Success() + { + var localizationManager = Setup(new ServerConfiguration() + { + MetadataCountryCode = "DE" + }); + await localizationManager.LoadAll(); + var ratings = localizationManager.GetParentalRatings().ToList(); + + Assert.Equal(10, ratings.Count); + + var fsk = ratings.FirstOrDefault(x => x.Name.Equals("FSK-12", StringComparison.Ordinal)); + Assert.NotNull(fsk); + Assert.Equal(7, fsk!.Value); + } + + [Theory] + [InlineData("CA-R", "CA", 10)] + [InlineData("FSK-16", "DE", 8)] + [InlineData("FSK-18", "DE", 9)] + [InlineData("FSK-18", "US", 9)] + [InlineData("TV-MA", "US", 9)] + [InlineData("XXX", "asdf", 100)] + [InlineData("Germany: FSK-18", "DE", 9)] + public async Task GetRatingLevel_GivenValidString_Success(string value, string countryCode, int expectedLevel) + { + var localizationManager = Setup(new ServerConfiguration() + { + MetadataCountryCode = countryCode + }); + await localizationManager.LoadAll(); + var level = localizationManager.GetRatingLevel(value); + Assert.NotNull(level); + Assert.Equal(expectedLevel, level!); + } + + [Fact] + public async Task GetRatingLevel_GivenUnratedString_Success() + { + var localizationManager = Setup(new ServerConfiguration() + { + UICulture = "de-DE" + }); + await localizationManager.LoadAll(); + Assert.Null(localizationManager.GetRatingLevel("n/a")); + } + + [Theory] + [InlineData("Default", "Default")] + [InlineData("HeaderLiveTV", "Live TV")] + public void GetLocalizedString_Valid_Success(string key, string expected) + { + var localizationManager = Setup(new ServerConfiguration() + { + UICulture = "en-US" + }); + + var translated = localizationManager.GetLocalizedString(key); + Assert.NotNull(translated); + Assert.Equal(expected, translated); + } + + [Fact] + public void GetLocalizedString_Invalid_Success() + { + var localizationManager = Setup(new ServerConfiguration() + { + UICulture = "en-US" + }); + + var key = "SuperInvalidTranslationKeyThatWillNeverBeAdded"; + + var translated = localizationManager.GetLocalizedString(key); + Assert.NotNull(translated); + Assert.Equal(key, translated); + } + + private LocalizationManager Setup(ServerConfiguration config) + { + var mockConfiguration = new Mock(); + mockConfiguration.SetupGet(x => x.Configuration).Returns(config); + + return new LocalizationManager(mockConfiguration.Object, new NullLogger()); + } + } +} diff --git a/tests/Jellyfin.Server.Implementations.Tests/QuickConnect/QuickConnectManagerTests.cs b/tests/Jellyfin.Server.Implementations.Tests/QuickConnect/QuickConnectManagerTests.cs new file mode 100644 index 0000000000..28d832ef87 --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/QuickConnect/QuickConnectManagerTests.cs @@ -0,0 +1,124 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using AutoFixture; +using AutoFixture.AutoMoq; +using Emby.Server.Implementations.QuickConnect; +using MediaBrowser.Common.Extensions; +using MediaBrowser.Controller.Authentication; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.Net; +using MediaBrowser.Model.Configuration; +using Moq; +using Xunit; + +namespace Jellyfin.Server.Implementations.Tests.QuickConnect +{ + public class QuickConnectManagerTests + { + private static readonly AuthorizationInfo _quickConnectAuthInfo = new AuthorizationInfo + { + Device = "Device", + DeviceId = "DeviceId", + Client = "Client", + Version = "1.0.0" + }; + + private readonly Fixture _fixture; + private readonly ServerConfiguration _config; + private readonly QuickConnectManager _quickConnectManager; + + public QuickConnectManagerTests() + { + _config = new ServerConfiguration(); + var configManager = new Mock(); + configManager.Setup(x => x.Configuration).Returns(_config); + + _fixture = new Fixture(); + _fixture.Customize(new AutoMoqCustomization + { + ConfigureMembers = true + }).Inject(configManager.Object); + + // User object contains circular references. + _fixture.Behaviors.OfType().ToList() + .ForEach(b => _fixture.Behaviors.Remove(b)); + _fixture.Behaviors.Add(new OmitOnRecursionBehavior()); + + _quickConnectManager = _fixture.Create(); + } + + [Fact] + public void IsEnabled_QuickConnectUnavailable_False() + => Assert.False(_quickConnectManager.IsEnabled); + + [Theory] + [InlineData("", "DeviceId", "Client", "1.0.0")] + [InlineData("Device", "", "Client", "1.0.0")] + [InlineData("Device", "DeviceId", "", "1.0.0")] + [InlineData("Device", "DeviceId", "Client", "")] + public void TryConnect_InvalidAuthorizationInfo_ThrowsArgumentException(string device, string deviceId, string client, string version) + => Assert.Throws(() => _quickConnectManager.TryConnect( + new AuthorizationInfo + { + Device = device, + DeviceId = deviceId, + Client = client, + Version = version + })); + + [Fact] + public void TryConnect_QuickConnectUnavailable_ThrowsAuthenticationException() + => Assert.Throws(() => _quickConnectManager.TryConnect(_quickConnectAuthInfo)); + + [Fact] + public void CheckRequestStatus_QuickConnectUnavailable_ThrowsAuthenticationException() + => Assert.Throws(() => _quickConnectManager.CheckRequestStatus(string.Empty)); + + [Fact] + public void AuthorizeRequest_QuickConnectUnavailable_ThrowsAuthenticationException() + => Assert.ThrowsAsync(() => _quickConnectManager.AuthorizeRequest(Guid.Empty, string.Empty)); + + [Fact] + public void GetAuthorizedRequest_QuickConnectUnavailable_ThrowsAuthenticationException() + => Assert.Throws(() => _quickConnectManager.GetAuthorizedRequest(string.Empty)); + + [Fact] + public void IsEnabled_QuickConnectAvailable_True() + { + _config.QuickConnectAvailable = true; + Assert.True(_quickConnectManager.IsEnabled); + } + + [Fact] + public void CheckRequestStatus_QuickConnectAvailable_Success() + { + _config.QuickConnectAvailable = true; + var res1 = _quickConnectManager.TryConnect(_quickConnectAuthInfo); + var res2 = _quickConnectManager.CheckRequestStatus(res1.Secret); + Assert.Equal(res1, res2); + } + + [Fact] + public void CheckRequestStatus_UnknownSecret_ThrowsResourceNotFoundException() + { + _config.QuickConnectAvailable = true; + Assert.Throws(() => _quickConnectManager.CheckRequestStatus("Unknown secret")); + } + + [Fact] + public void GetAuthorizedRequest_UnknownSecret_ThrowsResourceNotFoundException() + { + _config.QuickConnectAvailable = true; + Assert.Throws(() => _quickConnectManager.GetAuthorizedRequest("Unknown secret")); + } + + [Fact] + public async Task AuthorizeRequest_QuickConnectAvailable_Success() + { + _config.QuickConnectAvailable = true; + var res = _quickConnectManager.TryConnect(_quickConnectAuthInfo); + Assert.True(await _quickConnectManager.AuthorizeRequest(Guid.Empty, res.Code)); + } + } +} diff --git a/tests/Jellyfin.Server.Implementations.Tests/Sorting/AiredEpisodeOrderComparerTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Sorting/AiredEpisodeOrderComparerTests.cs new file mode 100644 index 0000000000..59d82678e4 --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/Sorting/AiredEpisodeOrderComparerTests.cs @@ -0,0 +1,164 @@ +using System; +using Emby.Server.Implementations.Sorting; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.Movies; +using MediaBrowser.Controller.Entities.TV; +using Xunit; + +namespace Jellyfin.Server.Implementations.Tests.Sorting +{ + public class AiredEpisodeOrderComparerTests + { + [Theory] + [ClassData(typeof(EpisodeBadData))] + public void Compare_GivenNull_ThrowsArgumentNullException(BaseItem? x, BaseItem? y) + { + var cmp = new AiredEpisodeOrderComparer(); + Assert.Throws(() => cmp.Compare(x, y)); + } + + [Theory] + [ClassData(typeof(EpisodeTestData))] + public void AiredEpisodeOrderCompareTest(BaseItem x, BaseItem y, int expected) + { + var cmp = new AiredEpisodeOrderComparer(); + + Assert.Equal(expected, cmp.Compare(x, y)); + Assert.Equal(-expected, cmp.Compare(y, x)); + } + + private class EpisodeBadData : TheoryData + { + public EpisodeBadData() + { + Add(null, new Episode()); + Add(new Episode(), null); + } + } + + private class EpisodeTestData : TheoryData + { + public EpisodeTestData() + { + Add( + new Movie(), + new Movie(), + 0); + + Add( + new Movie(), + new Episode(), + 1); + + // Good cases + Add( + new Episode(), + new Episode(), + 0); + + Add( + new Episode { ParentIndexNumber = 1, IndexNumber = 1 }, + new Episode { ParentIndexNumber = 1, IndexNumber = 1 }, + 0); + + Add( + new Episode { ParentIndexNumber = 1, IndexNumber = 2 }, + new Episode { ParentIndexNumber = 1, IndexNumber = 1 }, + 1); + + Add( + new Episode { ParentIndexNumber = 2, IndexNumber = 1 }, + new Episode { ParentIndexNumber = 1, IndexNumber = 1 }, + 1); + + // Good Specials + Add( + new Episode { ParentIndexNumber = 0, IndexNumber = 1 }, + new Episode { ParentIndexNumber = 0, IndexNumber = 1 }, + 0); + + Add( + new Episode { ParentIndexNumber = 0, IndexNumber = 2 }, + new Episode { ParentIndexNumber = 0, IndexNumber = 1 }, + 1); + + // Specials to Episodes + Add( + new Episode { ParentIndexNumber = 1, IndexNumber = 1 }, + new Episode { ParentIndexNumber = 0, IndexNumber = 1 }, + 1); + + Add( + new Episode { ParentIndexNumber = 1, IndexNumber = 1 }, + new Episode { ParentIndexNumber = 0, IndexNumber = 2 }, + 1); + + Add( + new Episode { ParentIndexNumber = 1, IndexNumber = 2 }, + new Episode { ParentIndexNumber = 0, IndexNumber = 1 }, + 1); + + Add( + new Episode { ParentIndexNumber = 1, IndexNumber = 2 }, + new Episode { ParentIndexNumber = 0, IndexNumber = 1 }, + 1); + + Add( + new Episode { ParentIndexNumber = 1, IndexNumber = 1 }, + new Episode { ParentIndexNumber = 0, IndexNumber = 2 }, + 1); + + Add( + new Episode { ParentIndexNumber = 0, IndexNumber = 1, AirsAfterSeasonNumber = 1 }, + new Episode { ParentIndexNumber = 1, IndexNumber = 1 }, + 1); + + Add( + new Episode { ParentIndexNumber = 3, IndexNumber = 1 }, + new Episode { ParentIndexNumber = 0, IndexNumber = 1, AirsAfterSeasonNumber = 1 }, + 1); + + Add( + new Episode { ParentIndexNumber = 3, IndexNumber = 1 }, + new Episode { ParentIndexNumber = 0, IndexNumber = 1, AirsAfterSeasonNumber = 1, AirsBeforeEpisodeNumber = 2 }, + 1); + + Add( + new Episode { ParentIndexNumber = 1, IndexNumber = 1 }, + new Episode { ParentIndexNumber = 0, IndexNumber = 1, AirsBeforeSeasonNumber = 1 }, + 1); + + Add( + new Episode { ParentIndexNumber = 1, IndexNumber = 2 }, + new Episode { ParentIndexNumber = 0, IndexNumber = 1, AirsBeforeSeasonNumber = 1, AirsBeforeEpisodeNumber = 2 }, + 1); + + Add( + new Episode { ParentIndexNumber = 1 }, + new Episode { ParentIndexNumber = 0, IndexNumber = 1, AirsBeforeSeasonNumber = 1, AirsBeforeEpisodeNumber = 2 }, + 0); + + Add( + new Episode { ParentIndexNumber = 1, IndexNumber = 3 }, + new Episode { ParentIndexNumber = 0, IndexNumber = 1, AirsBeforeSeasonNumber = 1, AirsBeforeEpisodeNumber = 2 }, + 1); + + // Premiere Date + Add( + new Episode { ParentIndexNumber = 1, IndexNumber = 1, PremiereDate = new DateTime(2021, 09, 12, 0, 0, 0) }, + new Episode { ParentIndexNumber = 1, IndexNumber = 1, PremiereDate = new DateTime(2021, 09, 12, 0, 0, 0) }, + 0); + + Add( + new Episode { ParentIndexNumber = 1, IndexNumber = 1, PremiereDate = new DateTime(2021, 09, 11, 0, 0, 0) }, + new Episode { ParentIndexNumber = 1, IndexNumber = 1, PremiereDate = new DateTime(2021, 09, 12, 0, 0, 0) }, + -1); + + Add( + new Episode { ParentIndexNumber = 1, IndexNumber = 1, PremiereDate = new DateTime(2021, 09, 12, 0, 0, 0) }, + new Episode { ParentIndexNumber = 1, IndexNumber = 1, PremiereDate = new DateTime(2021, 09, 11, 0, 0, 0) }, + 1); + } + } + } +} diff --git a/tests/Jellyfin.Server.Implementations.Tests/Test Data/SchedulesDirect/headends_response.json b/tests/Jellyfin.Server.Implementations.Tests/Test Data/SchedulesDirect/headends_response.json new file mode 100644 index 0000000000..015afeecc0 --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/Test Data/SchedulesDirect/headends_response.json @@ -0,0 +1 @@ +[{"headend":"CA00053","transport":"Cable","location":"Beverly Hills","lineups":[{"name":"Time Warner Cable - Cable","lineup":"USA-CA00053-DEFAULT","uri":"/20141201/lineups/USA-CA00053-DEFAULT"},{"name":"Time Warner Cable - Digital","lineup":"USA-CA00053-X","uri":"/20141201/lineups/USA-CA00053-X"}]},{"headend":"CA61222","transport":"Cable","location":"Beverly Hills","lineups":[{"name":"Mulholland Estates - Cable","lineup":"USA-CA61222-DEFAULT","uri":"/20141201/lineups/USA-CA61222-DEFAULT"}]},{"headend":"CA66511","transport":"Cable","location":"Los Angeles","lineups":[{"name":"AT&T U-verse TV - Digital","lineup":"USA-CA66511-X","uri":"/20141201/lineups/USA-CA66511-X"}]},{"headend":"CA67309","transport":"Cable","location":"Westchester","lineups":[{"name":"Time Warner Cable Sherman Oaks - Cable","lineup":"USA-CA67309-DEFAULT","uri":"/20141201/lineups/USA-CA67309-DEFAULT"},{"name":"Time Warner Cable Sherman Oaks - Digital","lineup":"USA-CA67309-X","uri":"/20141201/lineups/USA-CA67309-X"}]},{"headend":"CA67310","transport":"Cable","location":"Eagle Rock","lineups":[{"name":"Time Warner Cable City of Los Angeles - Cable","lineup":"USA-CA67310-DEFAULT","uri":"/20141201/lineups/USA-CA67310-DEFAULT"},{"name":"Time Warner Cable City of Los Angeles - Digital","lineup":"USA-CA67310-X","uri":"/20141201/lineups/USA-CA67310-X"}]},{"headend":"DISH803","transport":"Satellite","location":"Los Angeles","lineups":[{"name":"DISH Los Angeles - Satellite","lineup":"USA-DISH803-DEFAULT","uri":"/20141201/lineups/USA-DISH803-DEFAULT"}]},{"headend":"DITV803","transport":"Satellite","location":"Los Angeles","lineups":[{"name":"DIRECTV Los Angeles - Satellite","lineup":"USA-DITV803-DEFAULT","uri":"/20141201/lineups/USA-DITV803-DEFAULT"}]},{"headend":"90210","transport":"Antenna","location":"90210","lineups":[{"name":"Antenna","lineup":"USA-OTA-90210","uri":"/20141201/lineups/USA-OTA-90210"}]}] diff --git a/tests/Jellyfin.Server.Implementations.Tests/Test Data/SchedulesDirect/lineup_response.json b/tests/Jellyfin.Server.Implementations.Tests/Test Data/SchedulesDirect/lineup_response.json new file mode 100644 index 0000000000..0720894702 --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/Test Data/SchedulesDirect/lineup_response.json @@ -0,0 +1 @@ +{"map":[{"stationID":"24326","channel":"001","providerCallsign":"BBC ONE South","logicalChannelNumber":"1","matchType":"providerCallsign"},{"stationID":"17154","channel":"002","providerCallsign":"BBC TWO","logicalChannelNumber":"2","matchType":"providerCallsign"}]} diff --git a/tests/Jellyfin.Server.Implementations.Tests/Test Data/SchedulesDirect/lineups_response.json b/tests/Jellyfin.Server.Implementations.Tests/Test Data/SchedulesDirect/lineups_response.json new file mode 100644 index 0000000000..032a84e59e --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/Test Data/SchedulesDirect/lineups_response.json @@ -0,0 +1 @@ +{"code":0,"serverID":"20141201.web.1","datetime":"2015-04-17T14:22:17Z","lineups":[{"lineup":"GBR-0001317-DEFAULT","name":"Freeview - Carlton - LWT (Southeast)","transport":"DVB-T","location":"London","uri":"/20141201/lineups/GBR-0001317-DEFAULT"},{"lineup":"USA-IL57303-X","name":"Comcast Waukegan/Lake Forest Area - Digital","transport":"Cable","location":"Lake Forest","uri":"/20141201/lineups/USA-IL57303-X"},{"lineup":"USA-NY67791-X","name":"Verizon Fios Queens - Digital","transport":"Cable","location":"Fresh Meadows","uri":"/20141201/lineups/USA-NY67791-X"},{"lineup":"USA-OTA-60030","name":"Local Over the Air Broadcast","transport":"Antenna","location":"60030","uri":"/20141201/lineups/USA-OTA-60030"},{"lineup":"USA-WI61859-DEFAULT","name":"DELETED LINEUP","isDeleted":true}]} diff --git a/tests/Jellyfin.Server.Implementations.Tests/Test Data/SchedulesDirect/metadata_programs_response.json b/tests/Jellyfin.Server.Implementations.Tests/Test Data/SchedulesDirect/metadata_programs_response.json new file mode 100644 index 0000000000..78166f09a4 --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/Test Data/SchedulesDirect/metadata_programs_response.json @@ -0,0 +1 @@ +[{"programID":"SH00712240","data":[{"width":"135","height":"180","uri":"assets/p282288_b_v2_aa.jpg","size":"Sm","aspect":"3x4","category":"Banner-L3","text":"yes","primary":"true","tier":"Series"},{"width":"720","height":"540","uri":"assets/p282288_b_h6_aa.jpg","size":"Lg","aspect":"4x3","category":"Banner-L3","text":"yes","primary":"true","tier":"Series"},{"width":"960","height":"1440","uri":"assets/p282288_b_v8_aa.jpg","size":"Ms","aspect":"2x3","category":"Banner-L3","text":"yes","primary":"true","tier":"Series"},{"width":"180","height":"135","uri":"assets/p282288_b_h5_aa.jpg","size":"Sm","aspect":"4x3","category":"Banner-L3","text":"yes","primary":"true","tier":"Series"}]}] diff --git a/tests/Jellyfin.Server.Implementations.Tests/Test Data/SchedulesDirect/programs_response.json b/tests/Jellyfin.Server.Implementations.Tests/Test Data/SchedulesDirect/programs_response.json new file mode 100644 index 0000000000..fe2a944364 --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/Test Data/SchedulesDirect/programs_response.json @@ -0,0 +1 @@ +[{"programID":"EP000000060003","titles":[{"title120":"'Allo 'Allo!"}],"eventDetails":{"subType":"Series"},"descriptions":{"description1000":[{"descriptionLanguage":"en","description":"A disguised British Intelligence officer is sent to help the airmen."}]},"originalAirDate":"1985-11-04","genres":["Sitcom"],"episodeTitle150":"The Poloceman Cometh","metadata":[{"Gracenote":{"season":2,"episode":3}}],"cast":[{"personId":"383774","nameId":"392649","name":"Gorden Kaye","role":"Actor","billingOrder":"01"},{"personId":"246840","nameId":"250387","name":"Carmen Silvera","role":"Actor","billingOrder":"02"},{"personId":"376955","nameId":"385830","name":"Rose Hill","role":"Actor","billingOrder":"03"},{"personId":"259773","nameId":"263340","name":"Vicki Michelle","role":"Actor","billingOrder":"04"},{"personId":"353113","nameId":"361987","name":"Kirsten Cooke","role":"Actor","billingOrder":"05"},{"personId":"77787","nameId":"77787","name":"Richard Marner","role":"Actor","billingOrder":"06"},{"personId":"230921","nameId":"234193","name":"Guy Siner","role":"Actor","billingOrder":"07"},{"personId":"374934","nameId":"383809","name":"Kim Hartman","role":"Actor","billingOrder":"08"},{"personId":"369151","nameId":"378026","name":"Richard Gibson","role":"Actor","billingOrder":"09"},{"personId":"343690","nameId":"352564","name":"Arthur Bostrom","role":"Actor","billingOrder":"10"},{"personId":"352557","nameId":"361431","name":"John D. Collins","role":"Actor","billingOrder":"11"},{"personId":"605275","nameId":"627734","name":"Nicholas Frankau","role":"Actor","billingOrder":"12"},{"personId":"373394","nameId":"382269","name":"Jack Haig","role":"Actor","billingOrder":"13"}],"crew":[{"personId":"354407","nameId":"363281","name":"David Croft","role":"Director","billingOrder":"01"},{"personId":"354407","nameId":"363281","name":"David Croft","role":"Writer","billingOrder":"02"},{"personId":"105145","nameId":"105145","name":"Jeremy Lloyd","role":"Writer","billingOrder":"03"}],"showType":"Series","hasImageArtwork":true,"md5":"Jo5NKxoo44xRvBCAq8QT2A"},{"programID":"EP000000510142","titles":[{"title120":"A Different World"}],"eventDetails":{"subType":"Series"},"descriptions":{"description1000":[{"descriptionLanguage":"en","description":"Whitley and Dwayne tell new students about their honeymoon in Los Angeles."}]},"originalAirDate":"1992-09-24","genres":["Sitcom"],"episodeTitle150":"Honeymoon in L.A.","metadata":[{"Gracenote":{"season":6,"episode":1}}],"cast":[{"personId":"700","nameId":"700","name":"Jasmine Guy","role":"Actor","billingOrder":"01"},{"personId":"729","nameId":"729","name":"Kadeem Hardison","role":"Actor","billingOrder":"02"},{"personId":"120","nameId":"120","name":"Darryl M. Bell","role":"Actor","billingOrder":"03"},{"personId":"1729","nameId":"1729","name":"Cree Summer","role":"Actor","billingOrder":"04"},{"personId":"217","nameId":"217","name":"Charnele Brown","role":"Actor","billingOrder":"05"},{"personId":"1811","nameId":"1811","name":"Glynn Turman","role":"Actor","billingOrder":"06"},{"personId":"1232","nameId":"1232","name":"Lou Myers","role":"Actor","billingOrder":"07"},{"personId":"1363","nameId":"1363","name":"Jada Pinkett","role":"Guest Star","billingOrder":"08"},{"personId":"222967","nameId":"225536","name":"Ajai Sanders","role":"Guest Star","billingOrder":"09"},{"personId":"181744","nameId":"183292","name":"Karen Malina White","role":"Guest Star","billingOrder":"10"},{"personId":"305017","nameId":"318897","name":"Patrick Y. Malone","role":"Guest Star","billingOrder":"11"},{"personId":"9841","nameId":"9841","name":"Bumper Robinson","role":"Guest Star","billingOrder":"12"},{"personId":"426422","nameId":"435297","name":"Sister Souljah","role":"Guest Star","billingOrder":"13"},{"personId":"25","nameId":"25","name":"Debbie Allen","role":"Guest Star","billingOrder":"14"},{"personId":"668","nameId":"668","name":"Gilbert Gottfried","role":"Guest Star","billingOrder":"15"}],"showType":"Series","hasImageArtwork":true,"md5":"P5kz0QmCeYxIA+yL0H4DWw"}] diff --git a/tests/Jellyfin.Server.Implementations.Tests/Test Data/SchedulesDirect/schedules_request.json b/tests/Jellyfin.Server.Implementations.Tests/Test Data/SchedulesDirect/schedules_request.json new file mode 100644 index 0000000000..5ef1bfb1c4 --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/Test Data/SchedulesDirect/schedules_request.json @@ -0,0 +1 @@ +[{"stationID":"20454","date":["2015-03-13","2015-03-17"]},{"stationID":"10021","date":["2015-03-12","2015-03-13"]}] diff --git a/tests/Jellyfin.Server.Implementations.Tests/Test Data/SchedulesDirect/schedules_response.json b/tests/Jellyfin.Server.Implementations.Tests/Test Data/SchedulesDirect/schedules_response.json new file mode 100644 index 0000000000..4a97e55172 --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/Test Data/SchedulesDirect/schedules_response.json @@ -0,0 +1 @@ +[{"stationID":"20454","programs":[{"programID":"SH005371070000","airDateTime":"2015-03-03T00:00:00Z","duration":1800,"md5":"Sy8HEMBPcuiAx3FBukUhKQ","new":true,"audioProperties":["stereo","cc"],"videoProperties":["hdtv"]},{"programID":"EP000014577244","airDateTime":"2015-03-03T00:30:00Z","duration":1800,"md5":"25DNXVXO192JI7Y9vSW9lQ","new":true,"audioProperties":["stereo","cc"],"videoProperties":["hdtv"]}]}] diff --git a/tests/Jellyfin.Server.Implementations.Tests/Test Data/SchedulesDirect/token_live_response.json b/tests/Jellyfin.Server.Implementations.Tests/Test Data/SchedulesDirect/token_live_response.json new file mode 100644 index 0000000000..e5fb64a6fa --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/Test Data/SchedulesDirect/token_live_response.json @@ -0,0 +1 @@ +{"code":0,"message":"OK","serverID":"AWS-SD-web.1","datetime":"2016-08-23T13:55:25Z","token":"f3fca79989cafe7dead71beefedc812b"} diff --git a/tests/Jellyfin.Server.Implementations.Tests/Test Data/SchedulesDirect/token_offline_response.json b/tests/Jellyfin.Server.Implementations.Tests/Test Data/SchedulesDirect/token_offline_response.json new file mode 100644 index 0000000000..b66a4ed0c4 --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/Test Data/SchedulesDirect/token_offline_response.json @@ -0,0 +1 @@ +{"response":"SERVICE_OFFLINE","code":3000,"serverID":"20141201.web.1","message":"Server offline for maintenance.","datetime":"2015-04-23T00:03:32Z","token":"CAFEDEADBEEFCAFEDEADBEEFCAFEDEADBEEFCAFE"} diff --git a/tests/Jellyfin.Server.Implementations.Tests/Test Data/Updates/empty.zip b/tests/Jellyfin.Server.Implementations.Tests/Test Data/Updates/empty.zip new file mode 100644 index 0000000000..15628e26b3 Binary files /dev/null and b/tests/Jellyfin.Server.Implementations.Tests/Test Data/Updates/empty.zip differ diff --git a/tests/Jellyfin.Server.Implementations.Tests/TypedBaseItem/BaseItemKindTests.cs b/tests/Jellyfin.Server.Implementations.Tests/TypedBaseItem/BaseItemKindTests.cs new file mode 100644 index 0000000000..31f33c6825 --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/TypedBaseItem/BaseItemKindTests.cs @@ -0,0 +1,63 @@ +using System; +using System.Linq; +using Jellyfin.Data.Enums; +using Xunit; + +namespace Jellyfin.Server.Implementations.Tests.TypedBaseItem +{ + public class BaseItemKindTests + { + public static TheoryData BaseItemKind_TestData() + { + var data = new TheoryData(); + + var loadedAssemblies = AppDomain.CurrentDomain.GetAssemblies(); + foreach (var assembly in loadedAssemblies) + { + if (IsProjectAssemblyName(assembly.FullName)) + { + var baseItemTypes = assembly.GetTypes() + .Where(targetType => targetType.IsClass + && !targetType.IsAbstract + && targetType.IsSubclassOf(typeof(MediaBrowser.Controller.Entities.BaseItem))); + foreach (var baseItemType in baseItemTypes) + { + data.Add(baseItemType); + } + } + } + + return data; + } + + [Theory] + [MemberData(nameof(BaseItemKind_TestData))] + public void EnumParse_GivenValidBaseItemType_ReturnsEnumValue(Type baseItemDescendantType) + { + var enumValue = Enum.Parse(baseItemDescendantType.Name); + Assert.True(Enum.IsDefined(typeof(BaseItemKind), enumValue)); + } + + [Theory] + [MemberData(nameof(BaseItemKind_TestData))] + public void GetBaseItemKind_WhenCalledAfterDefaultCtor_DoesNotThrow(Type baseItemDescendantType) + { + var defaultConstructor = baseItemDescendantType.GetConstructor(Type.EmptyTypes); + var instance = (MediaBrowser.Controller.Entities.BaseItem)defaultConstructor!.Invoke(null); + var exception = Record.Exception(() => instance.GetBaseItemKind()); + Assert.Null(exception); + } + + private static bool IsProjectAssemblyName(string? name) + { + if (name == null) + { + return false; + } + + return name.StartsWith("Jellyfin", StringComparison.OrdinalIgnoreCase) + || name.StartsWith("Emby", StringComparison.OrdinalIgnoreCase) + || name.StartsWith("MediaBrowser", StringComparison.OrdinalIgnoreCase); + } + } +} diff --git a/tests/Jellyfin.Server.Implementations.Tests/Updates/InstallationManagerTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Updates/InstallationManagerTests.cs index 70acbfc40e..09c4bd1004 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/Updates/InstallationManagerTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/Updates/InstallationManagerTests.cs @@ -6,7 +6,9 @@ using System.Threading; using System.Threading.Tasks; using AutoFixture; using AutoFixture.AutoMoq; +using Emby.Server.Implementations.Archiving; using Emby.Server.Implementations.Updates; +using MediaBrowser.Model.IO; using MediaBrowser.Model.Updates; using Moq; using Moq.Protected; @@ -40,7 +42,9 @@ namespace Jellyfin.Server.Implementations.Tests.Updates _fixture.Customize(new AutoMoqCustomization { ConfigureMembers = true - }).Inject(http); + }); + _fixture.Inject(http); + _fixture.Inject(new ZipClient()); _installationManager = _fixture.Create(); } @@ -78,5 +82,32 @@ namespace Jellyfin.Server.Implementations.Tests.Updates packages = _installationManager.FilterPackages(packages, id: new Guid("a4df60c5-6ab4-412a-8f79-2cab93fb2bc5")).ToArray(); Assert.Single(packages); } + + [Fact] + public async Task InstallPackage_InvalidChecksum_ThrowsInvalidDataException() + { + var packageInfo = new InstallationInfo() + { + Name = "Test", + SourceUrl = "https://repo.jellyfin.org/releases/plugin/empty/empty.zip", + Checksum = "InvalidChecksum" + }; + + await Assert.ThrowsAsync(() => _installationManager.InstallPackage(packageInfo, CancellationToken.None)).ConfigureAwait(false); + } + + [Fact] + public async Task InstallPackage_Valid_Success() + { + var packageInfo = new InstallationInfo() + { + Name = "Test", + SourceUrl = "https://repo.jellyfin.org/releases/plugin/empty/empty.zip", + Checksum = "11b5b2f1a9ebc4f66d6ef19018543361" + }; + + var ex = await Record.ExceptionAsync(() => _installationManager.InstallPackage(packageInfo, CancellationToken.None)).ConfigureAwait(false); + Assert.Null(ex); + } } } diff --git a/tests/Jellyfin.Server.Integration.Tests/Controllers/DlnaControllerTests.cs b/tests/Jellyfin.Server.Integration.Tests/Controllers/DlnaControllerTests.cs new file mode 100644 index 0000000000..4421ced727 --- /dev/null +++ b/tests/Jellyfin.Server.Integration.Tests/Controllers/DlnaControllerTests.cs @@ -0,0 +1,141 @@ +using System; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Net.Mime; +using System.Text; +using System.Text.Json; +using System.Threading.Tasks; +using Jellyfin.Extensions.Json; +using MediaBrowser.Model.Dlna; +using Xunit; +using Xunit.Priority; + +namespace Jellyfin.Server.Integration.Tests.Controllers +{ + [TestCaseOrderer(PriorityOrderer.Name, PriorityOrderer.Assembly)] + public sealed class DlnaControllerTests : IClassFixture + { + private const string NonExistentProfile = "1322f35b8f2c434dad3cc07c9b97dbd1"; + private readonly JellyfinApplicationFactory _factory; + private readonly JsonSerializerOptions _jsonOptions = JsonDefaults.Options; + private static string? _accessToken; + private static string? _newDeviceProfileId; + + public DlnaControllerTests(JellyfinApplicationFactory factory) + { + _factory = factory; + } + + [Fact] + [Priority(0)] + public async Task GetProfile_DoesNotExist_NotFound() + { + var client = _factory.CreateClient(); + client.DefaultRequestHeaders.AddAuthHeader(_accessToken ??= await AuthHelper.CompleteStartupAsync(client).ConfigureAwait(false)); + + using var getResponse = await client.GetAsync("/Dlna/Profiles/" + NonExistentProfile).ConfigureAwait(false); + Assert.Equal(HttpStatusCode.NotFound, getResponse.StatusCode); + } + + [Fact] + [Priority(0)] + public async Task DeleteProfile_DoesNotExist_NotFound() + { + var client = _factory.CreateClient(); + client.DefaultRequestHeaders.AddAuthHeader(_accessToken ??= await AuthHelper.CompleteStartupAsync(client).ConfigureAwait(false)); + + using var getResponse = await client.DeleteAsync("/Dlna/Profiles/" + NonExistentProfile).ConfigureAwait(false); + Assert.Equal(HttpStatusCode.NotFound, getResponse.StatusCode); + } + + [Fact] + [Priority(0)] + public async Task UpdateProfile_DoesNotExist_NotFound() + { + var client = _factory.CreateClient(); + client.DefaultRequestHeaders.AddAuthHeader(_accessToken ??= await AuthHelper.CompleteStartupAsync(client).ConfigureAwait(false)); + + var deviceProfile = new DeviceProfile() + { + Name = "ThisProfileDoesNotExist" + }; + + using var content = new ByteArrayContent(JsonSerializer.SerializeToUtf8Bytes(deviceProfile, _jsonOptions)); + content.Headers.ContentType = MediaTypeHeaderValue.Parse(MediaTypeNames.Application.Json); + using var getResponse = await client.PostAsync("/Dlna/Profiles/" + NonExistentProfile, content).ConfigureAwait(false); + Assert.Equal(HttpStatusCode.NotFound, getResponse.StatusCode); + } + + [Fact] + [Priority(1)] + public async Task CreateProfile_Valid_NoContent() + { + var client = _factory.CreateClient(); + client.DefaultRequestHeaders.AddAuthHeader(_accessToken ??= await AuthHelper.CompleteStartupAsync(client).ConfigureAwait(false)); + + var deviceProfile = new DeviceProfile() + { + Name = "ThisProfileIsNew" + }; + + using var content = new ByteArrayContent(JsonSerializer.SerializeToUtf8Bytes(deviceProfile, _jsonOptions)); + content.Headers.ContentType = MediaTypeHeaderValue.Parse(MediaTypeNames.Application.Json); + using var getResponse = await client.PostAsync("/Dlna/Profiles", content).ConfigureAwait(false); + Assert.Equal(HttpStatusCode.NoContent, getResponse.StatusCode); + } + + [Fact] + [Priority(2)] + public async Task GetProfileInfos_Valid_ContainsThisProfileIsNew() + { + var client = _factory.CreateClient(); + client.DefaultRequestHeaders.AddAuthHeader(_accessToken ??= await AuthHelper.CompleteStartupAsync(client).ConfigureAwait(false)); + + using var response = await client.GetAsync("/Dlna/ProfileInfos").ConfigureAwait(false); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal(MediaTypeNames.Application.Json, response.Content.Headers.ContentType?.MediaType); + Assert.Equal(Encoding.UTF8.BodyName, response.Content.Headers.ContentType?.CharSet); + + var profiles = await JsonSerializer.DeserializeAsync( + await response.Content.ReadAsStreamAsync().ConfigureAwait(false), + _jsonOptions).ConfigureAwait(false); + + var newProfile = profiles?.FirstOrDefault(x => string.Equals(x.Name, "ThisProfileIsNew", StringComparison.Ordinal)); + Assert.NotNull(newProfile); + _newDeviceProfileId = newProfile!.Id; + } + + [Fact] + [Priority(3)] + public async Task UpdateProfile_Valid_NoContent() + { + var client = _factory.CreateClient(); + client.DefaultRequestHeaders.AddAuthHeader(_accessToken ??= await AuthHelper.CompleteStartupAsync(client).ConfigureAwait(false)); + + var updatedProfile = new DeviceProfile() + { + Name = "ThisProfileIsUpdated", + Id = _newDeviceProfileId + }; + + using var content = new ByteArrayContent(JsonSerializer.SerializeToUtf8Bytes(updatedProfile, _jsonOptions)); + content.Headers.ContentType = MediaTypeHeaderValue.Parse(MediaTypeNames.Application.Json); + using var getResponse = await client.PostAsync("/Dlna/Profiles", content).ConfigureAwait(false); + Assert.Equal(HttpStatusCode.NoContent, getResponse.StatusCode); + } + + [Fact] + [Priority(4)] + public async Task DeleteProfile_Valid_NoContent() + { + var client = _factory.CreateClient(); + client.DefaultRequestHeaders.AddAuthHeader(_accessToken ??= await AuthHelper.CompleteStartupAsync(client).ConfigureAwait(false)); + + using var getResponse = await client.DeleteAsync("/Dlna/Profiles/" + _newDeviceProfileId).ConfigureAwait(false); + Console.WriteLine(await getResponse.Content.ReadAsStringAsync().ConfigureAwait(false)); + Assert.Equal(HttpStatusCode.NoContent, getResponse.StatusCode); + } + } +} diff --git a/tests/Jellyfin.Server.Integration.Tests/Controllers/MediaInfoControllerTests.cs b/tests/Jellyfin.Server.Integration.Tests/Controllers/MediaInfoControllerTests.cs new file mode 100644 index 0000000000..34d26680ad --- /dev/null +++ b/tests/Jellyfin.Server.Integration.Tests/Controllers/MediaInfoControllerTests.cs @@ -0,0 +1,61 @@ +using System.Globalization; +using System.Net; +using System.Net.Mime; +using System.Threading.Tasks; +using Xunit; + +namespace Jellyfin.Server.Integration.Tests.Controllers +{ + public sealed class MediaInfoControllerTests : IClassFixture + { + private readonly JellyfinApplicationFactory _factory; + private static string? _accessToken; + + public MediaInfoControllerTests(JellyfinApplicationFactory factory) + { + _factory = factory; + } + + [Fact] + public async Task BitrateTest_Default_Ok() + { + var client = _factory.CreateClient(); + client.DefaultRequestHeaders.AddAuthHeader(_accessToken ??= await AuthHelper.CompleteStartupAsync(client).ConfigureAwait(false)); + + var response = await client.GetAsync("Playback/BitrateTest").ConfigureAwait(false); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal(MediaTypeNames.Application.Octet, response.Content.Headers.ContentType?.MediaType); + Assert.NotNull(response.Content.Headers.ContentLength); + } + + [Theory] + [InlineData(102400)] + public async Task BitrateTest_WithValidParam_Ok(int size) + { + var client = _factory.CreateClient(); + client.DefaultRequestHeaders.AddAuthHeader(_accessToken ??= await AuthHelper.CompleteStartupAsync(client).ConfigureAwait(false)); + + var response = await client.GetAsync("Playback/BitrateTest?size=" + size.ToString(CultureInfo.InvariantCulture)).ConfigureAwait(false); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal(MediaTypeNames.Application.Octet, response.Content.Headers.ContentType?.MediaType); + Assert.NotNull(response.Content.Headers.ContentLength); + Assert.InRange(response.Content.Headers.ContentLength!.Value, size, long.MaxValue); + } + + [Theory] + [InlineData(0)] // Zero + [InlineData(-102400)] // Negative value + [InlineData(1000000000)] // Too large + public async Task BitrateTest_InvalidValue_BadRequest(int size) + { + var client = _factory.CreateClient(); + client.DefaultRequestHeaders.AddAuthHeader(_accessToken ??= await AuthHelper.CompleteStartupAsync(client).ConfigureAwait(false)); + + var response = await client.GetAsync("Playback/BitrateTest?size=" + size.ToString(CultureInfo.InvariantCulture)).ConfigureAwait(false); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + } +} diff --git a/tests/Jellyfin.Server.Integration.Tests/Controllers/MediaStructureControllerTests.cs b/tests/Jellyfin.Server.Integration.Tests/Controllers/MediaStructureControllerTests.cs new file mode 100644 index 0000000000..19d8381ea5 --- /dev/null +++ b/tests/Jellyfin.Server.Integration.Tests/Controllers/MediaStructureControllerTests.cs @@ -0,0 +1,122 @@ +using System; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Net.Mime; +using System.Text.Json; +using System.Threading.Tasks; +using Jellyfin.Api.Models.LibraryStructureDto; +using Jellyfin.Extensions.Json; +using MediaBrowser.Model.Configuration; +using Xunit; + +namespace Jellyfin.Server.Integration.Tests.Controllers +{ + public sealed class MediaStructureControllerTests : IClassFixture + { + private readonly JellyfinApplicationFactory _factory; + private readonly JsonSerializerOptions _jsonOptions = JsonDefaults.Options; + private static string? _accessToken; + + public MediaStructureControllerTests(JellyfinApplicationFactory factory) + { + _factory = factory; + } + + [Fact] + public async Task RenameVirtualFolder_WhiteSpaceName_ReturnsBadRequest() + { + var client = _factory.CreateClient(); + client.DefaultRequestHeaders.AddAuthHeader(_accessToken ??= await AuthHelper.CompleteStartupAsync(client).ConfigureAwait(false)); + + using var postContent = new ByteArrayContent(Array.Empty()); + var response = await client.PostAsync("Library/VirtualFolders/Name?name=+&newName=test", postContent).ConfigureAwait(false); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task RenameVirtualFolder_WhiteSpaceNewName_ReturnsBadRequest() + { + var client = _factory.CreateClient(); + client.DefaultRequestHeaders.AddAuthHeader(_accessToken ??= await AuthHelper.CompleteStartupAsync(client).ConfigureAwait(false)); + + using var postContent = new ByteArrayContent(Array.Empty()); + var response = await client.PostAsync("Library/VirtualFolders/Name?name=test&newName=+", postContent).ConfigureAwait(false); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task RenameVirtualFolder_NameDoesntExist_ReturnsNotFound() + { + var client = _factory.CreateClient(); + client.DefaultRequestHeaders.AddAuthHeader(_accessToken ??= await AuthHelper.CompleteStartupAsync(client).ConfigureAwait(false)); + + using var postContent = new ByteArrayContent(Array.Empty()); + var response = await client.PostAsync("Library/VirtualFolders/Name?name=doesnt+exist&newName=test", postContent).ConfigureAwait(false); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task AddMediaPath_PathDoesntExist_ReturnsNotFound() + { + var client = _factory.CreateClient(); + client.DefaultRequestHeaders.AddAuthHeader(_accessToken ??= await AuthHelper.CompleteStartupAsync(client).ConfigureAwait(false)); + + var data = new MediaPathDto() + { + Name = "Test", + Path = "/this/path/doesnt/exist" + }; + + using var postContent = new ByteArrayContent(JsonSerializer.SerializeToUtf8Bytes(data, _jsonOptions)); + postContent.Headers.ContentType = MediaTypeHeaderValue.Parse(MediaTypeNames.Application.Json); + var response = await client.PostAsync("Library/VirtualFolders/Paths", postContent).ConfigureAwait(false); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task UpdateMediaPath_WhiteSpaceName_ReturnsBadRequest() + { + var client = _factory.CreateClient(); + client.DefaultRequestHeaders.AddAuthHeader(_accessToken ??= await AuthHelper.CompleteStartupAsync(client).ConfigureAwait(false)); + + var data = new UpdateMediaPathRequestDto() + { + Name = " ", + PathInfo = new MediaPathInfo("test") + }; + + using var postContent = new ByteArrayContent(JsonSerializer.SerializeToUtf8Bytes(data, _jsonOptions)); + postContent.Headers.ContentType = MediaTypeHeaderValue.Parse(MediaTypeNames.Application.Json); + var response = await client.PostAsync("Library/VirtualFolders/Paths/Update", postContent).ConfigureAwait(false); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task RemoveMediaPath_WhiteSpaceName_ReturnsBadRequest() + { + var client = _factory.CreateClient(); + client.DefaultRequestHeaders.AddAuthHeader(_accessToken ??= await AuthHelper.CompleteStartupAsync(client).ConfigureAwait(false)); + + var response = await client.DeleteAsync("Library/VirtualFolders/Paths?name=+").ConfigureAwait(false); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task RemoveMediaPath_PathDoesntExist_ReturnsNotFound() + { + var client = _factory.CreateClient(); + client.DefaultRequestHeaders.AddAuthHeader(_accessToken ??= await AuthHelper.CompleteStartupAsync(client).ConfigureAwait(false)); + + var response = await client.DeleteAsync("Library/VirtualFolders/Paths?name=none&path=%2Fthis%2Fpath%2Fdoesnt%2Fexist").ConfigureAwait(false); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + } +} diff --git a/tests/Jellyfin.Server.Integration.Tests/EncodedQueryStringTest.cs b/tests/Jellyfin.Server.Integration.Tests/EncodedQueryStringTest.cs index 732b4f050d..2361e4aa4e 100644 --- a/tests/Jellyfin.Server.Integration.Tests/EncodedQueryStringTest.cs +++ b/tests/Jellyfin.Server.Integration.Tests/EncodedQueryStringTest.cs @@ -21,6 +21,7 @@ namespace Jellyfin.Server.Integration.Tests [InlineData("a=1", "a=1")] // won't be processed as it has a value [InlineData("a%3D1%26b%3D2%26c%3D3", "a=1&b=2&c=3")] // will be processed. [InlineData("a=b&a=c", "a=b")] + [InlineData("a%3D1", "a=1")] [InlineData("a%3Db%26a%3Dc", "a=b")] public async Task Ensure_Decoding_Of_Urls_Is_Working(string sourceUrl, string unencodedUrl) { diff --git a/tests/Jellyfin.Server.Integration.Tests/Jellyfin.Server.Integration.Tests.csproj b/tests/Jellyfin.Server.Integration.Tests/Jellyfin.Server.Integration.Tests.csproj index 59f125cd01..9d7b447edd 100644 --- a/tests/Jellyfin.Server.Integration.Tests/Jellyfin.Server.Integration.Tests.csproj +++ b/tests/Jellyfin.Server.Integration.Tests/Jellyfin.Server.Integration.Tests.csproj @@ -1,10 +1,7 @@ - net5.0 + net6.0 false - true - enable - AllEnabledByDefault ../jellyfin-tests.ruleset @@ -12,14 +9,14 @@ - - - + + + - - + + diff --git a/tests/Jellyfin.Server.Integration.Tests/JellyfinApplicationFactory.cs b/tests/Jellyfin.Server.Integration.Tests/JellyfinApplicationFactory.cs index d9ec81a271..976e19d468 100644 --- a/tests/Jellyfin.Server.Integration.Tests/JellyfinApplicationFactory.cs +++ b/tests/Jellyfin.Server.Integration.Tests/JellyfinApplicationFactory.cs @@ -44,10 +44,7 @@ namespace Jellyfin.Server.Integration.Tests protected override void ConfigureWebHost(IWebHostBuilder builder) { // Specify the startup command line options - var commandLineOpts = new StartupOptions - { - NoWebClient = true - }; + var commandLineOpts = new StartupOptions(); // Use a temporary directory for the application paths var webHostPathRoot = Path.Combine(_testPathRoot, "test-host-" + Path.GetFileNameWithoutExtension(Path.GetRandomFileName())); diff --git a/tests/Jellyfin.Server.Integration.Tests/Middleware/RobotsRedirectionMiddlewareTests.cs b/tests/Jellyfin.Server.Integration.Tests/Middleware/RobotsRedirectionMiddlewareTests.cs new file mode 100644 index 0000000000..8c49a2e2b5 --- /dev/null +++ b/tests/Jellyfin.Server.Integration.Tests/Middleware/RobotsRedirectionMiddlewareTests.cs @@ -0,0 +1,32 @@ +using System.Net; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc.Testing; +using Xunit; + +namespace Jellyfin.Server.Integration.Tests.Middleware +{ + public sealed class RobotsRedirectionMiddlewareTests : IClassFixture + { + private readonly JellyfinApplicationFactory _factory; + + public RobotsRedirectionMiddlewareTests(JellyfinApplicationFactory factory) + { + _factory = factory; + } + + [Fact] + public async Task RobotsDotTxtRedirects() + { + var client = _factory.CreateClient( + new WebApplicationFactoryClientOptions() + { + AllowAutoRedirect = false + }); + + var response = await client.GetAsync("robots.txt").ConfigureAwait(false); + + Assert.Equal(HttpStatusCode.Redirect, response.StatusCode); + Assert.Equal("web/robots.txt", response.Headers.Location?.ToString()); + } + } +} diff --git a/tests/Jellyfin.Server.Tests/Jellyfin.Server.Tests.csproj b/tests/Jellyfin.Server.Tests/Jellyfin.Server.Tests.csproj index c8e72c10dd..67ae0e0802 100644 --- a/tests/Jellyfin.Server.Tests/Jellyfin.Server.Tests.csproj +++ b/tests/Jellyfin.Server.Tests/Jellyfin.Server.Tests.csproj @@ -1,11 +1,8 @@ - net5.0 + net6.0 false - true - enable - AllEnabledByDefault ../jellyfin-tests.ruleset @@ -13,13 +10,13 @@ - - - + + + - - + + diff --git a/tests/Jellyfin.Server.Tests/ParseNetworkTests.cs b/tests/Jellyfin.Server.Tests/ParseNetworkTests.cs index 146b16cf94..a1bdfa31b8 100644 --- a/tests/Jellyfin.Server.Tests/ParseNetworkTests.cs +++ b/tests/Jellyfin.Server.Tests/ParseNetworkTests.cs @@ -1,10 +1,12 @@ -using System.Globalization; -using System.Text; +using System; +using System.Linq; +using System.Net; using Jellyfin.Networking.Configuration; using Jellyfin.Networking.Manager; using Jellyfin.Server.Extensions; using MediaBrowser.Common.Configuration; using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.HttpOverrides; using Microsoft.Extensions.Logging.Abstractions; using Moq; using Xunit; @@ -13,20 +15,63 @@ namespace Jellyfin.Server.Tests { public class ParseNetworkTests { - /// - /// Order of the result has always got to be hosts, then networks. - /// - /// IP4 enabled. - /// IP6 enabled. - /// List to parse. - /// What it should match. + public static TheoryData TestNetworks_TestData() + { + var data = new TheoryData(); + data.Add( + true, + true, + new string[] { "192.168.t", "127.0.0.1", "1234.1232.12.1234" }, + new IPAddress[] { IPAddress.Loopback.MapToIPv6() }, + Array.Empty()); + + data.Add( + true, + false, + new string[] { "192.168.x", "127.0.0.1", "1234.1232.12.1234" }, + new IPAddress[] { IPAddress.Loopback }, + Array.Empty()); + + data.Add( + true, + true, + new string[] { "::1" }, + Array.Empty(), + new IPNetwork[] { new IPNetwork(IPAddress.IPv6Loopback, 128) }); + + data.Add( + false, + false, + new string[] { "localhost" }, + Array.Empty(), + Array.Empty()); + + data.Add( + true, + false, + new string[] { "localhost" }, + new IPAddress[] { IPAddress.Loopback }, + Array.Empty()); + + data.Add( + false, + true, + new string[] { "localhost" }, + Array.Empty(), + new IPNetwork[] { new IPNetwork(IPAddress.IPv6Loopback, 128) }); + + data.Add( + true, + true, + new string[] { "localhost" }, + new IPAddress[] { IPAddress.Loopback.MapToIPv6() }, + new IPNetwork[] { new IPNetwork(IPAddress.IPv6Loopback, 128) }); + return data; + } + [Theory] - // [InlineData(true, true, "192.168.0.0/16,www.yahoo.co.uk", "::ffff:212.82.100.150,::ffff:192.168.0.0/16")] <- fails on Max. www.yahoo.co.uk resolves to a different ip address. - // [InlineData(true, false, "192.168.0.0/16,www.yahoo.co.uk", "212.82.100.150,192.168.0.0/16")] - [InlineData(true, true, "192.168.t,127.0.0.1,1234.1232.12.1234", "::ffff:127.0.0.1")] - [InlineData(true, false, "192.168.x,127.0.0.1,1234.1232.12.1234", "127.0.0.1")] - [InlineData(true, true, "::1", "::1/128")] - public void TestNetworks(bool ip4, bool ip6, string hostList, string match) + [MemberData(nameof(TestNetworks_TestData))] + public void TestNetworks(bool ip4, bool ip6, string[] hostList, IPAddress[] knownProxies, IPNetwork[] knownNetworks) { using var nm = CreateNetworkManager(); @@ -36,31 +81,25 @@ namespace Jellyfin.Server.Tests EnableIPV6 = ip6 }; - var result = match + ","; ForwardedHeadersOptions options = new ForwardedHeadersOptions(); // Need this here as ::1 and 127.0.0.1 are in them by default. options.KnownProxies.Clear(); options.KnownNetworks.Clear(); - ApiServiceCollectionExtensions.AddProxyAddresses(settings, hostList.Split(','), options); + ApiServiceCollectionExtensions.AddProxyAddresses(settings, hostList, options); - var sb = new StringBuilder(); - foreach (var item in options.KnownProxies) + Assert.Equal(knownProxies.Length, options.KnownProxies.Count); + foreach (var item in knownProxies) { - sb.Append(item) - .Append(','); + Assert.True(options.KnownProxies.Contains(item)); } - foreach (var item in options.KnownNetworks) + Assert.Equal(knownNetworks.Length, options.KnownNetworks.Count); + foreach (var item in knownNetworks) { - sb.Append(item.Prefix) - .Append('/') - .Append(item.PrefixLength.ToString(CultureInfo.InvariantCulture)) - .Append(','); + Assert.NotNull(options.KnownNetworks.FirstOrDefault(x => x.Prefix.Equals(item.Prefix) && x.PrefixLength == item.PrefixLength)); } - - Assert.Equal(sb.ToString(), result); } private static IConfigurationManager GetMockConfig(NetworkConfiguration conf) diff --git a/tests/Jellyfin.Server.Tests/UrlDecodeQueryFeatureTests.cs b/tests/Jellyfin.Server.Tests/UrlDecodeQueryFeatureTests.cs index 419afb2dc4..d15c9d6f54 100644 --- a/tests/Jellyfin.Server.Tests/UrlDecodeQueryFeatureTests.cs +++ b/tests/Jellyfin.Server.Tests/UrlDecodeQueryFeatureTests.cs @@ -12,9 +12,6 @@ namespace Jellyfin.Server.Tests { [Theory] [InlineData("e0a72cb2a2c7", "e0a72cb2a2c7")] // isn't encoded - [InlineData("random+test", "random test")] // encoded - [InlineData("random%20test", "random test")] // encoded - [InlineData("++", " ")] // encoded public static void EmptyValueTest(string query, string key) { var dict = new Dictionary diff --git a/tests/Jellyfin.XbmcMetadata.Tests/Jellyfin.XbmcMetadata.Tests.csproj b/tests/Jellyfin.XbmcMetadata.Tests/Jellyfin.XbmcMetadata.Tests.csproj index 0a04a5c543..94294c8bf3 100644 --- a/tests/Jellyfin.XbmcMetadata.Tests/Jellyfin.XbmcMetadata.Tests.csproj +++ b/tests/Jellyfin.XbmcMetadata.Tests/Jellyfin.XbmcMetadata.Tests.csproj @@ -1,11 +1,8 @@ - net5.0 + net6.0 false - true - enable - AllEnabledByDefault ../jellyfin-tests.ruleset @@ -16,11 +13,11 @@ - + - + diff --git a/tests/Jellyfin.XbmcMetadata.Tests/Location/MovieNfoLocationTests.cs b/tests/Jellyfin.XbmcMetadata.Tests/Location/MovieNfoLocationTests.cs index 357d61c0bc..8019e0ab39 100644 --- a/tests/Jellyfin.XbmcMetadata.Tests/Location/MovieNfoLocationTests.cs +++ b/tests/Jellyfin.XbmcMetadata.Tests/Location/MovieNfoLocationTests.cs @@ -1,8 +1,8 @@ -using System.Linq; +using System; +using System.Linq; using MediaBrowser.Controller.Entities.Movies; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Entities; -using MediaBrowser.Model.System; using MediaBrowser.XbmcMetadata.Savers; using Xunit; @@ -28,7 +28,7 @@ namespace Jellyfin.XbmcMetadata.Tests.Location var path2 = "/media/movies/Avengers Endgame/movie.nfo"; // uses ContainingFolderPath which uses Operating system specific paths - if (MediaBrowser.Common.System.OperatingSystem.Id == OperatingSystemId.Windows) + if (OperatingSystem.IsWindows()) { movie.Path = movie.Path.Replace('/', '\\'); path1 = path1.Replace('/', '\\'); @@ -49,7 +49,7 @@ namespace Jellyfin.XbmcMetadata.Tests.Location var path2 = "/media/movies/Avengers Endgame/VIDEO_TS/VIDEO_TS.nfo"; // uses ContainingFolderPath which uses Operating system specific paths - if (MediaBrowser.Common.System.OperatingSystem.Id == OperatingSystemId.Windows) + if (OperatingSystem.IsWindows()) { movie.Path = movie.Path.Replace('/', '\\'); path1 = path1.Replace('/', '\\'); diff --git a/tests/Jellyfin.XbmcMetadata.Tests/Parsers/MovieNfoParserTests.cs b/tests/Jellyfin.XbmcMetadata.Tests/Parsers/MovieNfoParserTests.cs index 30a48857a4..7ea45d14d0 100644 --- a/tests/Jellyfin.XbmcMetadata.Tests/Parsers/MovieNfoParserTests.cs +++ b/tests/Jellyfin.XbmcMetadata.Tests/Parsers/MovieNfoParserTests.cs @@ -11,7 +11,6 @@ using MediaBrowser.Model.Configuration; using MediaBrowser.Model.Entities; using MediaBrowser.Model.IO; using MediaBrowser.Model.Providers; -using MediaBrowser.Model.System; using MediaBrowser.Providers.Plugins.Tmdb.Movies; using MediaBrowser.XbmcMetadata.Parsers; using Microsoft.Extensions.Logging.Abstractions; @@ -59,7 +58,7 @@ namespace Jellyfin.XbmcMetadata.Tests.Parsers _localImageFileMetadata = new FileSystemMetadata() { Exists = true, - FullName = MediaBrowser.Common.System.OperatingSystem.Id == OperatingSystemId.Windows ? + FullName = OperatingSystem.IsWindows() ? "C:\\media\\movies\\Justice League (2017).jpg" : "/media/movies/Justice League (2017).jpg" }; @@ -207,6 +206,20 @@ namespace Jellyfin.XbmcMetadata.Tests.Parsers Assert.Equal(id, item.ProviderIds[provider]); } + [Fact] + public void Parse_GivenFileWithFanartTag_Success() + { + var result = new MetadataResult